Two-way tunnel (wire protocol v2) - #1
Conversation
Published app releases (e.g. spotify-v0.11.1.zip) declare "macos" in their manifest platforms list, which is not a valid PlatformTypes value. On Mac, the compatibility check looks for "mac", so these apps are falsely flagged "App not compatible with mac platform" even though they run fine. Add a shared normalizePlatforms helper that maps legacy identifiers (macos/osx/darwin -> mac, win/win32 -> windows) and lowercases case variants, and apply it in constructManifest and both renderer compatibility checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
macOS removed Bluetooth PAN, so there is no IP-over-Bluetooth to lean on. Instead, a small TCP multiplexer runs over a single RFCOMM serial channel: btmux.py owns 127.0.0.1:8891 on the device (the endpoint the client already talks to) and frames its TCP streams across the radio to a helper on the computer, which replays them onto the local DeskThing server and serves a control API for the UI. After one-time provisioning the device only needs power and reconnects on its own on every boot — verified by cold-booting a flashed Car Thing: it re-establishes the Bluetooth link and resumes streaming Spotify with zero intervention. The device mux binds a real AF_BLUETOOTH RFCOMM socket (no rfcomm-binary tty bindings to leak) and a PING/PONG heartbeat on both sides tears down a half-open link so reconnects always recover cleanly. Pairing works the way the Car Thing originally did: the computer initiates, the device's screen shows a 6-digit code (btagent.py serves it locally for the client to draw), and the person confirms the same code. Helpers clear stale half-bonds before pairing fresh. Every supported OS gets a helper speaking the same frame protocol and control API: macOS in Swift over IOBluetooth (compiled at build time by bt_source/build-btbridge.js — no binaries in git), Linux in Python over BlueZ, Windows in C over Winsock RFCOMM + the Win32 Bluetooth authentication API. Python golden-vector tests lock the frame protocol across implementations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The main process owns the helper's lifecycle through a small manager facade: bridgeProcess starts and stops the packaged helper with the app, bridgeClient wraps its local control API, and the provisioner turns a USB-connected Car Thing into a Bluetooth-ready one — installing the mux and the pairing agent as supervisord services and reporting the device's radio address back so the pairing wizard knows who to talk to. Platforms without a helper get a stub manager that reports unsupported. The renderer reaches all of it through a typed BLUETOOTH IPC domain. Vitest suites cover the IPC dispatch, the manager's degradation when the helper is missing or unreachable, the control-API client, and the provisioner step machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bluetooth setup page becomes a pairing wizard: scan, pick the Car Thing, watch the same 6-digit code appear on the device's screen and in this dialog, confirm, done — plus one-time USB provisioning with a live step checklist, and unpair. Device details gain a Connection Type card with a Bluetooth/USB preference toggle, and the top bar shows an always-visible Bluetooth chip while the link is live. Everything hides itself on platforms without a bridge or when the helper isn't running. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Song-state updates dominate client traffic and are highly repetitive; perMessageDeflate shrinks them substantially, which matters on the ~155 KB/s Bluetooth link and costs nothing on faster transports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setup and everyday-use instructions: one-time USB provisioning, pairing with the code shown on the device screen, going wireless, the transport indicators, and troubleshooting. Complements the developer-oriented bt_source/README.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The heartbeat added during reboot-survival work only ever landed on the device mux and the macOS helper. btmux.py pings every 5s and drops the link after 15s without a PONG, and neither linux/btbridge nor win/btbridge.c handled frame type 4 at all — so on those platforms the link could never survive 15 seconds. It would come up, go silent, get torn down, reconnect, and repeat forever. Both now answer PING with PONG, track inbound PONGs, and run their own outbound heartbeat so a half-open link is torn down locally instead of lingering — matching the macOS helper and the device. Tests: PING/PONG golden vectors join the cross-implementation contract, plus a functional suite driving the Linux helper's real frame pump against a fake socket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v1 was one-directional: only the device opened streams, and every stream went to one hardcoded destination on the computer. That made the whole class of computer-to-device work impossible — no remote debugging, no log retrieval, no way to reach the device without a USB cable. v2 lets either side open a stream, and an OPEN says what it wants to reach: - Stream IDs are split by their high bit (device keeps the low half, the computer takes the high half) so both ends allocate without coordinating. An OPEN in the wrong half is refused — a collision would silently cross-wire two live TCP streams. - HELLO carries version, capabilities, and the computer's clock. The Car Thing has no RTC and nothing else sets its time; a wrong clock fails every TLS handshake it makes in ways that look like a tunnel bug. - OPEN carries a target descriptor. Named services only, default deny, enforced independently on both ends — the device is not a router, so 127.0.0.1:5037 is inexpressible rather than merely filtered. - OPEN_ACK distinguishes refused / unreachable / unknown-service / bad-namespace, which a bare CLOSE cannot. Backward compatible by construction: an empty OPEN payload keeps its v1 meaning, a peer that never sends HELLO is treated as v1, and an unknown frame type is skipped rather than resetting the receive buffer. Verified on hardware — a v2 helper against the still-v1 device reports inbound:false, offers no services, and carries music exactly as before. What this immediately enables: 'cdp' forwards the device's chromium debugger to a loopback port here, so chrome://inspect debugs the Car Thing over Bluetooth with no cable — also the only way to screenshot a device whose firmware has no screencap. 8891 is permanently absent from the registry: an inbound stream there would be handed back to handle_local and forwarded over the link again, an unbounded loop. Tests: v2 golden vectors and descriptor-rejection cases join the cross-implementation contract; a new suite drives the device mux's real inbound path against a fake radio. 36 vitest + 59 Python. The Windows helper is type-checked with clang against stub Win32 headers (-Wall -Wextra clean); it and the Linux helper remain untested on real hardware. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both surfaced the first time protocol v2 ran against the real device; neither was reachable from the unit tests as written. 1. The helper crash-looped with SIGTRAP the moment the device answered HELLO. applyHello runs on the state queue (drainFrames dispatches there) and was calling q.sync on that same queue — a same-queue dispatch_sync, which GCD traps. It now assigns directly, matching the convention the neighbouring PONG handler already follows. 2. Inbound streams silently dropped the peer's first payload. Opening a local service is async, but the peer sends its request straight after OPEN, so DATA arrived while open_connection was still in flight and was discarded — the far end then waited forever for a reply to a request the device never saw. DATA is now parked while a stream is connecting and flushed once the socket is up, and an early CLOSE abandons the pending open. A regression test drives that interleaving. Verified end to end over Bluetooth with no cable: capability negotiation, the cdp forward, chromium's debug endpoints answering through the tunnel, and a 150 KB page screenshot pulled off a device whose firmware has no screencap. Music kept streaming throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Now verified end-to-end on hardware — and it caught two real bugsDeployed v2 to the device and ran the full inbound path over Bluetooth with no cable. Both bugs below were invisible to the unit tests as written; both are fixed with regression coverage. 1. The helper crash-looped with SIGTRAP the instant the device answered HELLO. 2. Inbound streams silently dropped the peer's first payload. Opening a local service is async, but the peer sends its request immediately after OPEN — so DATA arrived while What now works over Bluetooth, cable-freeCapability negotiation reports Music kept streaming the whole time; the link stayed up and the normal client stream was unaffected. Also confirms an assumption from the internet-sharing design: the device really is Chrome 69, which is why browsing to modern YouTube in it won't work regardless of bandwidth. 36 vitest + 62 Python, typecheck clean. |
The Car Thing's firmware has no screencap, so there was no way to see what it was rendering. Protocol v2's cdp service forwards its chromium debugger, and this turns that into something usable: ./carthing-debug.py info what's running, what page is loaded ./carthing-debug.py shot screen.png screenshot the display ./carthing-debug.py eval <js> run JS in the live page ./carthing-debug.py console stream console output ./carthing-debug.py reload pick up a fresh client build Standard library only, so it runs from a plain checkout. Transport is automatic: the Bluetooth forward when the link is up, otherwise adb over USB, so it works whichever way the device is attached. docs/device-debugging.md covers the workflow and records device facts measured through it, several of which are easy to get wrong: - The browser is Chrome 69 (QtWebEngine 5.12), viewport 800x480. - Video must be VP8 in WebM. VP9 does NOT play, and neither does H.264/MP4 — there is no proprietary-codec build on this device. Audio: Opus, Vorbis and MP3 yes, AAC no. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eval command silently swallowed JS exceptions: a thrown error comes back as a successful CDP response carrying exceptionDetails, and the tool reported only the undefined result — printing "null" while hiding a real SyntaxError. It now reports the exception and exits non-zero. The device-facts section claimed video worked in VP8, based on canPlayType. canPlayType is wrong on this device: the QtWebEngine build ships the format tables without the decoders. A genuine VP8+Vorbis WebM fails with MediaError code 4 even served from 127.0.0.1 over adb reverse with no proxy, internet, CORS or TLS in the path, while curl on the device downloads that same file completely and an <img> over the same path renders fine. Decode failure, not transport — so video is not achievable in the stock browser by any codec or bandwidth choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Car Thing has no route to the internet. With sharing switched on the computer becomes its exit node: chromium points at a SOCKS5 port the mux serves on the device, each CONNECT becomes a host:port stream over the existing tunnel, and the computer dials out. The device resolves nothing — hostnames ride the tunnel verbatim and the computer resolves them, because the device has no resolver. Chromium always does proxy-side DNS for socks5://, so no DNS server is needed. The computer is the policy point: off by default and per-session, ports 80/443 only, and loopback/private/link-local/metadata ranges refused — re-checked on the RESOLVED address, so a hostname aimed at a private range is refused too. setup-browser-proxy.sh configures the browser during provisioning and can undo it. Its bypass list is not optional: Chromium 69 predates Chrome 72's implicit localhost bypass, so without it the client's own localhost:8891 traffic would be proxied and the /__bt probe would stop being answered locally. Proven over USB before wiring it to Bluetooth: the device fetched HTTP and HTTPS through the Mac and rendered a real page and a network image, with the /__bt probe still working. 38 vitest + 75 python. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captures frames back to back and reports when the screen changed, so
'the display feels slow' becomes a number instead of an impression.
Capture is not free — a frame crosses the same link as everything else —
so the cadence is measured and printed rather than assumed, and should
be read as the resolution of the measurement (~1s over Bluetooth at
default quality; lower --quality for a finer grid).
One caveat worth knowing: anything animating, such as the progress bar,
changes the frame every capture. For a specific milestone, drive it from
the page instead and time from navigation:
./carthing-debug.py eval "new Promise(r => { ... performance.now() ... })"
which is how the 11.9s load-to-track figure behind the client reconnect
fix was measured.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two-way tunnel (wire protocol v2)
Why
v1 is one-directional in a specific way: only the device can open a stream, and every stream goes to one hardcoded destination on the computer (
btbridge.swift'stargetHost/targetPort,SERVER_ADDRin the Linux helper,SERVER_PORTin the Windows one). That makes an entire class of work impossible — no remote debugging, no log retrieval, no way to reach the device at all without a USB cable.The device's firmware has no
screencap, so today there is literally no way to see what's on its screen without pointing a camera at it.What v2 adds
Either side can open a stream, and an OPEN says what it wants to reach.
0x00000001…0x7FFFFFFF, the computer takes0x80000001…0xFFFFFFFF. Both ends allocate without coordinating, and allocation wraps inside its own half. An OPEN arriving in the wrong half is refused: an ID collision would silently cross-wire two live TCP streams, which is a correctness and a confidentiality bug.host:port(defined but not yet accepted; that's the internet-sharing follow-up).Backward compatibility, by construction
Three properties make a v1 and a v2 peer interoperate:
rxBuf.removeAll()on an unknown type, which would discard the in-flight bytes of every other stream. That's fixed here.Verified on hardware: a v2 helper against the still-v1 device (I can't reflash it without USB) reports
inbound: false, offers no services, refuses a forward with a clear message, and carries Spotify exactly as before.What it immediately unlocks
The
cdpservice forwards the device's chromium debugger to a loopback port on the computer:Point
chrome://inspectat that port and you are debugging the Car Thing over Bluetooth with no cable — and it's the only route to a screenshot of a device with noscreencap.Security
Default deny, enforced independently on both ends. This isn't hardening, it replaces a protection being removed: the hardcoded
127.0.0.1:8891was the only thing standing between the device and everything on the computer's loopback.127.0.0.1:5037(adb, unauthenticated) is inexpressible, not merely filtered — there is no parser to trick with0177.0.0.1or[::ffff:127.0.0.1].test_protocol.py.handle_localand forwarded back over the link — an unbounded loop that eats the entire 155 KB/s.Tests
host:portrefusal, the 8891 exclusion, HELLO exchange, stream bookkeeping, and a genuine end-to-end relay from a live local service.Honest limitations
-Wall -Wextraclean), which catches signature and type errors but is not a substitute for running it.window.electron.bluetooth.openForward) and the control API. A Device Inspector panel is a natural follow-up, but the tunnel is the hard part and is worth reviewing on its own.Both directions are now hardware-verified: the device runs the v2
btmux.py, and every screenshot in this PR was captured over Bluetooth through the inboundcdpservice with no cable attached. The v2-computer ↔ v1-device compatibility direction was verified earlier, before the device was reflashed.🤖 Generated with Claude Code
Added since opening: internet sharing
The tunnel being two-way makes one more thing possible, and it is on this
branch now.
The Car Thing has no route to the internet. With sharing switched on, the
computer becomes its exit node:
The device resolves nothing — hostnames ride the tunnel verbatim and the
computer resolves them, because the device has no resolver at all. Chromium
always does proxy-side DNS for
socks5://, so no DNS server is needed anywhere.The computer is the policy point, since the device is now pointing a socket
at whatever it likes:
deliberately not a persisted setting
the resolved address so a hostname aimed at a private range is refused too
services on the computer stay unreachable
superbird/setup-browser-proxy.shconfigures the browser once duringprovisioning and can undo it (
enable/disable/status, idempotent, keepsa pristine backup). Its bypass list is not optional: this is Chromium 69,
which predates Chrome 72's implicit localhost bypass, so without it the client's
own
http://localhost:8891traffic would be proxied and the/__bttransportprobe would stop being answered locally — the badge would silently flip to USB.
The semicolons are quoted because
;starts a comment in supervisord's INIparser.
Verified on hardware, over Bluetooth, with the USB path removed
adb reversefor the proxy port was deleted first, so nothing could cross USB:/__btprobe still returns{"transport":"bluetooth"}13 new tests drive the device's real SOCKS handler: framing of the host:port
OPEN, the stream-id namespace, success/refused/unreachable replies, no payload
leaking on a refused stream, BIND rejected, and ack-waiter cleanup.
What this is not for
Video does not play on this device. The browser reports codec support it
does not have — a genuine VP8+Vorbis file fails with
MediaError.code 4evenserved from
127.0.0.1with no proxy in the path, whilecurlon the devicedownloads the same file completely. Internet sharing is for reaching the
network; it will not get you a video player. See
docs/device-debugging.md.