Repository navigation
feat(bindings): link a browser to a phone or a board over Web Bluetooth - #637
Merged
Merged
Conversation
A page can be a GATT central in Chromium and nothing else: it cannot advertise and serves no GATT. BluetoothCarrier makes that useful: its person picks a device running the SDK in the browser's chooser, inside a click, or reconnects one the origin was granted, and the page's node links to it over the same BleTransport and fragment codec every binding runs. The page holds the GATT connection and moves bytes. It reads the Device id and Identity values and hands both to the engine; bleVerify runs the one verifier and compares the derived address with the Device id exactly, and bleLinkUp announces only an address that verification produced. The order is the contract's: choose the instance (by App tag when there are several), read and verify, subscribe to Message, write Hello with response, then link up, so nothing reaches Message before the peripheral can attribute it. The write payload is maxWriteWithoutResponseSize where the browser reports it and the 185-byte floor otherwise; the MTU is set before the peer is announced. Writes go one at a time per link, a failed one closes it, and a peer also linked over a peer stream is lost only with its last link. WebEngine.withNostr becomes withOptions, which takes the Bluetooth switch beside the Nostr ones. bleAppTag is the browser's copy of the App tag, pinned to the iOS and Android vectors. bluetooth.html drives the carrier through a stand-in for navigator.bluetooth whose device is a second node playing the peripheral, so the engine runs on both sides: verification, the hello first, a session, multi-fragment messages both ways, a lying peripheral refused, instance choice by App tag, and loss on disconnect. Moving the hello after the link-up fails the page. Documented as W14 and R31.
bahdotsh
force-pushed
the
feat/web-bluetooth-carrier
branch
from
October 25, 2026 17:17
8d25e8e to
a7a3f92
Compare
The Web Bluetooth carrier copied the GATT chapter's leniencies without noticing what they rest on. The chapter lets a central skip Hello, or keep a link whose hello was refused, because the peripheral can dial that central back and read who it is. Nothing can dial a browser back. So a device with no Hello, or one that refused the write, was linked and announced anyway: a neighbour that neither receives nor sends. The refused case is not exotic either. Below an ATT MTU of 99 the 96-byte hello goes out as a prepared write, which a peripheral MUST refuse. Make the hello mandatory for this central and refuse the device otherwise. It turns out the disconnect listener was only attached after the hello, and close() relied on that event. A device dropping during the handshake got announced on a dead GATT, and closing a link whose server was already gone fires no event, so the peer was never reported lost. Watch for the disconnect from the moment the GATT connects, refuse a link that dropped before it was up, and have close() report the loss itself instead of waiting for an event that is not coming. And the node's outbound sink was a single slot that a second carrier silently overwrote, after which every fragment for the first carrier's links got dropped on the floor. Confusion ensues. A second carrier now throws until the first is released, and the sink is closed when the node stops, which also closes the GATT connections it was leaving open. While at it, on the engine side: a replaced link to the same address no longer announces the peer twice, stop() reports Bluetooth links lost like it already did for peer streams, and the neighbour hook (outbox flush, custody, sync kicks) runs once per reassembled message instead of once per fragment. The native path never ran it per fragment, and neither should this one.
…call Calling connect() on a device the carrier already held ran the whole handshake again on the same connection. That sounds harmless until you remember that Chrome hands a page the *same* BluetoothDevice for one physical device, and the same cached characteristics while it is connected. So the second handshake wrote the hello again and subscribed a second listener to Message. Nothing ever removed the first: every path that ended a link took off the disconnect listener and forgot the notification one. From then on each fragment reached the engine once per connect() call. A reconnect loop over known() makes that grow without bound. Confusion ensues. A device already linked on a live connection now just hands back its peer. Every path that ends a link removes both listeners, through one helper, so nobody gets to forget one of them again. It turns out two connects to one device at the same time are broken too, and worse. The engine holds one verification per address, so the second link-up failed, and its cleanup took down the link the first one had just made. A connect for a device that already has a handshake under way now joins that handshake instead of racing it. A link that a different device proving the same address replaces while the engine is taking it no longer announces itself either: before, it said "connected" and never said "closed". While at it, read maxWriteWithoutResponseSize on the Message characteristic, where the Web Bluetooth proposal puts it, and not on the server, where nothing will ever put it. No browser ships it yet, so the 185-byte floor is what runs today, and W14 now says so. The fake peripheral now answers one hello before the next, as a real GATT stack does; without that the concurrent test was checking the fixture's race instead of the carrier's.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #635, which is stacked on #634. This branch contains both of their commits. Review only the last commit,
8d25e8e0. Merge #634, then #635, then merge main into this branch. Reaching an iPhone also needs #636, which makes the iOS peripheral serve Hello.What this does
In Chromium a page can be a GATT central and nothing else: it cannot advertise and it serves no GATT.
BluetoothCarrierlinks the page's node to a phone or a board running the SDK as a peripheral. Its person picks the device in the browser's chooser, inside a click, or the page reconnects one the origin was granted before. The link runs on the sameBleTransportand fragment codec as every binding.bleVerifyruns the one verifier and compares the derived address with the Device id exactly.bleLinkUpannounces only an address that verification produced.maxWriteWithoutResponseSizewhere the browser reports it and the 185-byte floor otherwise. The MTU is set before the peer is announced.WebEngine.withNostrbecomeswithOptions, which takes the Bluetooth switch beside the Nostr ones; the worker is its only caller.bleAppTagis the browser's copy of the App tag, pinned to the iOS and Android vectors. Documented as W14 and R31.Testing
cargo test -p offline-protocol-web --lib: 65 pass. Five new host tests link two engines over their Bluetooth slots, with the codec on both sides:the_app_tag_matches_the_native_vectorsandthe_bluetooth_carrier_names_the_contracts_uuidspin the tag and the UUID literals.bash bindings/web/scripts/test-browser.sh: 24 of 24 pass, eight pages in Chromium, Firefox and WebKit.bluetooth.htmldrives the carrier through a stand-in fornavigator.bluetoothwhose device is a second node playing the peripheral, so the engine runs on both sides.-D warnings.