Skip to content

feat(bindings): link a browser to a phone or a board over Web Bluetooth - #637

Merged
bahdotsh merged 3 commits into
mainfrom
feat/web-bluetooth-carrier
Oct 25, 2026
Merged

bahdotsh merged 3 commits into
mainfrom
feat/web-bluetooth-carrier

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

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. BluetoothCarrier links 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 same BleTransport and fragment codec as every binding.

  • The engine decides who is on the link. The page reads Device id and Identity and hands both over. bleVerify runs the one verifier and compares the derived address with the Device id exactly. bleLinkUp announces only an address that verification produced.
  • The handshake follows the contract's order. Choose the instance by App tag when there are several, read and verify, subscribe to Message, write Hello with response, then link up. Nothing reaches Message before the peripheral can attribute it.
  • The write payload falls back to the floor. The carrier uses 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, and a failed write closes the link. A peer also linked over a peer stream is lost only when its last link goes.

WebEngine.withNostr becomes withOptions, which takes the Bluetooth switch beside the Nostr ones; the worker is its only caller. bleAppTag is 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:
    • A session forms and a multi-fragment message crosses.
    • A Device id that the identity does not prove is refused, as are a forged identity and this device's own identity.
    • Only a verified address can be linked, and only a linked one delivers.
    • A disconnect reports the peer lost once.
  • the_app_tag_matches_the_native_vectors and the_bluetooth_carrier_names_the_contracts_uuids pin 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.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.
  • Negative control. With the hello moved after the link-up, the page fails: the peripheral drops the writes it cannot attribute, and no session forms.
  • Clippy is clean on the host and for wasm32, and docs build under -D warnings.

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
bahdotsh force-pushed the feat/web-bluetooth-carrier branch from 8d25e8e to a7a3f92 Compare October 25, 2026 17:17
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.
@bahdotsh
bahdotsh merged commit e911a49 into main Oct 25, 2026
33 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant