Debugger

A terminal REPL into Discord's JavaScript runtime.

The debugger runs JavaScript on your phone from a terminal and streams back its console output. It has two parts: the bridge (ub-debugger, from @unbound-app/debugger) and the debugger built-in in the client.

Quick Start

  1. Start the Bridge

    Needs Bun. It listens on port 9090 unless you pass --port, its only flag.

    bunx @unbound-app/debugger
    bunx @unbound-app/debugger --port 9191

    To keep ub-debugger on your PATH, install it globally:

    bun add -g @unbound-app/debugger
    ub-debugger
  2. Note the Address

    The startup banner prints two addresses:

    AddressFor
    Devices: <LAN IP>:<port>The phone.
    MCP: ws://127.0.0.1:<port>?mcpControllers on the same computer.
  3. Connect the Phone

    Enable the debugger and set its address, as in Device Settings. The terminal prints Device connected and shows the » prompt.

Device Settings

Both keys live in the unbound bucket of settings.json. See Settings Reference for the file location.

In the app:

  1. In Unbound's General settings, turn on Developer Mode.
  2. Open the Developer screen.
  3. In Debugger, turn on Enable Debugger.
  4. Enter the address in Debugger Address.
debugger.enabledbooleandefault: false

Starts the debugger built-in. Read once at startup, so turning it on applies on the next launch. Turning it off disconnects and stops forwarding logs right away.

debugger.addressstringdefault: ''

The bridge's host:port, for example 192.168.1.20:9090, without a scheme. The client connects to ws://<address>. Changing it while connected reconnects to the new address.

When empty, the client uses the address baked in at build time. Development builds (UNBOUND_DEV=1 or --dev) bake in the dev host on port 9090. Production builds bake in nothing, so set this key.

Reconnecting

  • If the socket fails or closes, the client retries every 2 seconds.
  • In the background it closes the socket and stops retrying. It reconnects when the app is active again.

Using the REPL

Type JavaScript at » and press Enter. It runs in the global scope of Discord's runtime, and the result prints after «. Errors print in red. The REPL has no commands of its own.

» 1 + 1
« 2
» globalThis.ReactNative.Platform.OS
« ios
ResultPrints as
StringAs-is, without quotes.
Function[Function name]. Hermes can't return function source.
ErrorName: message, then the stack trace.
undefinedundefined.
PromiseIts resolved value, or the rejection as an error.
Anything elseJSON.stringify with 2-space indent. Unserializable values (such as circular objects) fall back to String(value), usually [object Object].
FeatureBehavior
ConsoleEvery console.* call prints prefixed with «: grey for logs, yellow for warnings, red for errors. Output before the device connects is not replayed.
HistoryUp and down arrows recall inputs. Saved to .history.json in the node_modules/@unbound-app folder holding the package, so it persists between sessions.
QuitCtrl+C.

React DevTools and the View Tree

The Unbound DevTools app shows Discord's React component tree and its native views over the debugger. Nothing else listens or dials:

  1. In Developer, turn on the debugger and React DevTools (loader.devtools), then restart Discord. The loader runs the React DevTools backend before Discord's bundle.
  2. In the DevTools app, choose Connect Device and pick the bridge. The app attaches as a controller and starts a React DevTools session with the device. Its View Tree page lists the native views.

The client sends the backend's messages over the debugger socket it already has open, and the bridge passes them to the controller that last sent one. When that controller leaves, the bridge ends the device's session. Release bundles leave this code out.

Controllers

Other programs, such as ubd, attach to the bridge as controllers at ws://127.0.0.1:<port>?mcp. Any number can attach at once. The terminal prints MCP connected and MCP disconnected with the controller count.

A controller can:

  • Evaluate code. The terminal mirrors the request in magenta, tagged [mcp], then the result.
  • Receive console output and connection status.
  • Push a built plugin bundle and manifest. The device reloads it and shows a Hot reload toast, or Hot reload failed with the error.
  • List the device's native views one level at a time, 100 at most per request, read one view's class chain and properties, and outline one on screen.
  • Explore a native object or class: its superclass chain, its members a page at a time, and the values of its fields, properties and ivars.
  • Act as the React DevTools frontend.

Wire Protocol

@unbound-app/debugger-protocol defines the messages. Each frame is a JSON object with a type. Frames that don't parse or have an unknown type are dropped.

typeDirectionContents
evalController to deviceid, code
eval-resultDevice to the controller that askedid, ok, value or error
plugin-pushController to deviceid, addonId, bundle, manifest
plugin-push-resultDevice to the controller that askedid, ok, error
logDevice to all controllerslevel (1 log, 2 warn, 3 error), message
device-statusBridge to controllersconnected
views-requestController to deviceid, parent (omit for the root views), offset, limit (at most VIEWS_PAGE_SIZE, 100)
views-resultDevice to the controller that askedid, ok, nodes and total (the level's size), or error
view-details-requestController to deviceid, node, a listed view id
view-details-resultDevice to the controller that askedid, ok, details (classChain, sections of { key, value, kind?, unit? } properties) or error
view-highlightController to devicenode, a view id or null to clear
explorer-object-requestController to deviceid, target: { node } (a listed view id), { object } (a ref from an earlier result) or { className }
explorer-object-resultDevice to the controller that askedid, ok, object (ref, className, description, platform, chain of classes with their image, instanceSize and member counts) or error and code
explorer-members-requestController to deviceid, className, kind, offset, limit (at most EXPLORER_MEMBERS_PAGE_SIZE, 100), filter
explorer-members-resultDevice to the controller that askedid, ok, members and total (matches for the filter), or error and code
explorer-values-requestController to deviceid, object (a ref), className, kind (properties or ivars on iOS, fields on Android), names (at most EXPLORER_VALUES_LIMIT, 50), invokeGetters
explorer-values-resultDevice to the controller that askedid, ok, values of { name, kind, text, object?, className? }, one per name, or error and code
explorer-classes-requestController to deviceid, image (omit to list images), filter, offset, limit (at most EXPLORER_CLASSES_PAGE_SIZE, 200)
explorer-classes-resultDevice to the controller that askedid, ok, total and images ({ path, name, classCount }) or classes (names), or error and code
react-devtoolsController to device, device to the controller that last sent oneReact DevTools' event and payload. The device sends unbound:backend-status with { connected, error? } when its session starts or cannot start.

A result echoes its request's id, which routes it back to the right controller.

A failed explorer result carries a code:

codeMeaning
staleThe ref or view is gone: the object was released, the tree was refreshed, or Discord reloaded. Open the class by name instead.
disablediOS: neither developer mode nor the debugger is on, so the loader won't load UnboundInspector.dylib.
unavailableiOS: the inspector failed to load. error holds the reason.
unsupportedThe platform can't answer. Android has no class list, so open classes by name.
unknown-classNo class has that name.
failedAnything else. error says what.

Values come back for the rows asked for, and each one fails on its own as kind: "error". A value that holds an object includes an object ref, which you can open with another explorer-object-request. On iOS, reading a property only runs code when its getter is computed and the request sets invokeGetters. Until then the value comes back as skipped. Getters and ivars that are known to be unsafe always come back as skipped.

Troubleshooting