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
Start the Bridge
Needs Bun. It listens on port
9090unless you pass--port, its only flag.bunx @unbound-app/debugger bunx @unbound-app/debugger --port 9191To keep
ub-debuggeron yourPATH, install it globally:bun add -g @unbound-app/debugger ub-debuggerNote the Address
The startup banner prints two addresses:
Address For Devices:<LAN IP>:<port>The phone. MCP:ws://127.0.0.1:<port>?mcpControllers on the same computer. Connect the Phone
Enable the debugger and set its address, as in Device Settings. The terminal prints
Device connectedand 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:
- In Unbound's General settings, turn on Developer Mode.
- Open the Developer screen.
- In Debugger, turn on Enable Debugger.
- Enter the address in Debugger Address.
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.
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| Result | Prints as |
|---|---|
| String | As-is, without quotes. |
| Function | [Function name]. Hermes can't return function source. |
| Error | Name: message, then the stack trace. |
undefined | undefined. |
| Promise | Its resolved value, or the rejection as an error. |
| Anything else | JSON.stringify with 2-space indent. Unserializable values (such as circular objects) fall back to String(value), usually [object Object]. |
| Feature | Behavior |
|---|---|
| Console | Every console.* call prints prefixed with «: grey for logs, yellow for warnings, red for errors. Output before the device connects is not replayed. |
| History | Up and down arrows recall inputs. Saved to .history.json in the node_modules/@unbound-app folder holding the package, so it persists between sessions. |
| Quit | Ctrl+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:
- 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. - 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.
type | Direction | Contents |
|---|---|---|
eval | Controller to device | id, code |
eval-result | Device to the controller that asked | id, ok, value or error |
plugin-push | Controller to device | id, addonId, bundle, manifest |
plugin-push-result | Device to the controller that asked | id, ok, error |
log | Device to all controllers | level (1 log, 2 warn, 3 error), message |
device-status | Bridge to controllers | connected |
views-request | Controller to device | id, parent (omit for the root views), offset, limit (at most VIEWS_PAGE_SIZE, 100) |
views-result | Device to the controller that asked | id, ok, nodes and total (the level's size), or error |
view-details-request | Controller to device | id, node, a listed view id |
view-details-result | Device to the controller that asked | id, ok, details (classChain, sections of { key, value, kind?, unit? } properties) or error |
view-highlight | Controller to device | node, a view id or null to clear |
explorer-object-request | Controller to device | id, target: { node } (a listed view id), { object } (a ref from an earlier result) or { className } |
explorer-object-result | Device to the controller that asked | id, ok, object (ref, className, description, platform, chain of classes with their image, instanceSize and member counts) or error and code |
explorer-members-request | Controller to device | id, className, kind, offset, limit (at most EXPLORER_MEMBERS_PAGE_SIZE, 100), filter |
explorer-members-result | Device to the controller that asked | id, ok, members and total (matches for the filter), or error and code |
explorer-values-request | Controller to device | id, object (a ref), className, kind (properties or ivars on iOS, fields on Android), names (at most EXPLORER_VALUES_LIMIT, 50), invokeGetters |
explorer-values-result | Device to the controller that asked | id, ok, values of { name, kind, text, object?, className? }, one per name, or error and code |
explorer-classes-request | Controller to device | id, image (omit to list images), filter, offset, limit (at most EXPLORER_CLASSES_PAGE_SIZE, 200) |
explorer-classes-result | Device to the controller that asked | id, ok, total and images ({ path, name, classCount }) or classes (names), or error and code |
react-devtools | Controller to device, device to the controller that last sent one | React 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:
code | Meaning |
|---|---|
stale | The ref or view is gone: the object was released, the tree was refreshed, or Discord reloaded. Open the class by name instead. |
disabled | iOS: neither developer mode nor the debugger is on, so the loader won't load UnboundInspector.dylib. |
unavailable | iOS: the inspector failed to load. error holds the reason. |
unsupported | The platform can't answer. Android has no class list, so open classes by name. |
unknown-class | No class has that name. |
failed | Anything 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.