Two root causes kept the wasm web client from syncing with native peers,
both fixed here:
1. Gossip island — a node that registered with the rendezvous first joined
its gossip topic alone and never pulled later-joining peers into its
active view (its single subscribe_and_join was done, so a browser that
appeared afterwards had a live magicsock path but no gossip neighbor).
The re-register loop now seeds peer relay addrs into the MemoryLookup and
calls GossipSender::join_peers each round, so neighbors form both ways.
2. Engine::send panicked on wasm — directly_covered() calls Instant::now(),
which traps ("time not implemented") on wasm32-unknown-unknown, so send
threw before ever reaching the gossip broadcast (0 Broadcast commands).
web-time backs Instant with Performance.now() on wasm; native keeps
std::time and is unaffected (web-time isn't linked there).
Supporting connectivity: the rendezvous now stores + returns each peer's
iroh relay URL (PeerInfo{id,relay}) so a browser — relay-only, can't resolve
id→addr via discovery — can dial natives via a seeded MemoryLookup; CORS
added so the browser's fetch to the rendezvous is allowed.
Verified live, cross-relay: text typed in a browser tab lands on the Mac
clipboard and vice-versa, with the wasm engine running as a relay-only iroh
peer. Browser-console tracing capped at INFO; demo send wrapped in try/catch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
src/wasm.rs exposes the engine to JS (gated to wasm32 + iroh-transport): a
WasmEngine wrapping Arc<Engine> with new(server, room, from, account) / start(
on_message) / send / stop / irohId, plus roomForAccount so the browser derives
the same room as native clients. Inbound clipboards bridge to a JS callback via a
JsHandler.
MessageHandler's Send+Sync bound is relaxed on wasm (single JS thread; a
js_sys::Function isn't Send and the engine spawns via spawn_local where it isn't
needed). Adds wasm-bindgen + js-sys (wasm-only deps).
Native (UniFFI) + wasm both compile clean. Next: generate the JS bindings with
wasm-bindgen-cli and a browser smoke test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The web client is real: `tethercore` with the full iroh stack now builds for
wasm32-unknown-unknown, producing tethercore.wasm.
- Runtime abstraction: a `Spawner` replaces the bare tokio runtime Handle across
the Transport trait + Engine + all transports — tokio Handle on native, the
browser event loop (wasm-bindgen-futures::spawn_local) on wasm. The engine no
longer builds a tokio multi-thread runtime on wasm (there are no OS threads).
- IrohTransport's internal tasks (re-register loop, blob fetch) route through the
Spawner; its 30s timer uses n0-future (iroh's cross-platform async) instead of
tokio::time, which isn't available on wasm.
- iroh on wasm: default-features=false + tls-ring (ring builds for wasm with a
wasm-capable clang) + iroh-gossip net; getrandom uses its wasm_js backend.
- build-web.sh documents the toolchain (Homebrew LLVM clang for ring's C, the
getrandom rustflag) and produces the .wasm.
Native (default + iroh + all clients) is byte-unaffected and verified clean
throughout. Remaining for a running web app: wasm-bindgen JS bindings + a browser
shim (relay-only in-browser).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First real step toward the web/WASM target. The dependency graph is now
target-conditional: the legacy transports' OS-socket deps (tokio net, mdns-sd,
webrtc) + UniFFI + native reqwest live under cfg(not(wasm32)); wasm32 gets a
minimal tokio (sync/macros), the reqwest fetch backend, wasm-bindgen-futures, and
iroh with default-features off. The SSE/RTC/LAN transport modules are gated to
native (they need OS sockets). getrandom's wasm rng is wired (build-cfg for 0.4 +
the js-feature alias trick for the transitive 0.2).
Effect: `cargo build --target wasm32-unknown-unknown --features iroh-transport`
goes from "deps won't compile at all" to 15 well-understood errors, isolated to
four structural items: UniFFI (decouple the FFI layer for wasm), iroh's wasm
feature set (presets/gossip-net), the tokio runtime (→ spawn_local + ?Send), and
reqwest .timeout(). Native builds (default + iroh + examples) are byte-unaffected
— all verified clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IrohTransport.send_file now adds the (already-sealed) bytes to a MemStore and
broadcasts a blob announcement over gossip {hash, name, mime, provider}; on
receipt, peers fetch the blob P2P by hash via iroh-blobs and emit MeshEvent::File.
The engine's existing File handler decrypts and delivers — so the clipboard can
carry data/photos/etc, not just text. Replaces the hand-rolled M/C/E frame
protocol entirely.
Verified: `cargo run --features iroh-transport --example iroh_engine` syncs both
a text clipboard (gossip) AND a 200 KB photo (iroh-blobs), both E2E-decrypted
A→B, no SSE/RTC/LAN/relay. Default ship build untouched; feature build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
examples/iroh_gossip.rs proves account → {EndpointId} the P2P way: two endpoints
derive the same gossip TopicId = sha256(account) (its first 4 bytes are today's
room_for_account, so the models line up), join the topic, and learn each other's
public keys by broadcasting presence — zero hardcoded addresses in steady state.
Bootstrap is the one fact gossip can't self-supply: the joiner needs one peer's
EndpointId, and iroh's discovery (n0 DNS here, a tiny rendezvous supernode in
production) maps id→addr. Everything after first contact is peer gossip. A
GossipPresence MeshEvent source would emit Present/Discovered, retiring the SSE
presence bus. iroh-gossip compiles to WASM, so the web client gets the same
discovery unchanged.
iroh-gossip 0.101 added as a dev-dependency (tracks iroh ^1). Verified:
`cargo run --example iroh_gossip` → "✅ gossip discovery: both endpoints found
each other ... with zero hardcoded addresses", clean graceful shutdown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
De-risks the Iroh evaluation before any integration. examples/iroh_spike.rs
stands up two iroh 1.0 endpoints in-process, each identified by a public-key
EndpointId, and round-trips bytes over a QUIC bidirectional stream:
- accept side via Router + ProtocolHandler (≈ DirectUp + inbound MeshEvents)
- connect side via endpoint.connect(addr, ALPN) (≈ the LAN/RTC dial path)
- connection.remote_id() gives a durable per-device public key — identity is
the transport, so iroh subsumes the keypair/Noise layer from ARCHITECTURE §3
iroh is a dev-dependency only (not in the shipped lib). The API maps cleanly
onto the Transport/MeshEvent seam, so an eventual IrohTransport slots in without
touching the Sync/App tiers.
Verified: `cargo run --example iroh_spike` → "✅ iroh round-trip: 49 bytes
echoed P2P (QUIC, key-addressed)". Roadmap updated with milestones 2–4
(cross-network relay via homelab supernode, account→EndpointId discovery,
IrohTransport wrap + A/B vs RTC+LAN).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Encrypt clipboard text and file bytes at the engine with ChaCha20-Poly1305
(nonce‖ciphertext; clipboard base64'd into the text field) using a key derived
from the shared account secret. Sealing happens BEFORE any transport, so LAN,
the SSE relay, and RTC all carry only ciphertext — the server is reduced to pure
signaling (presence/SDP/ICE), never content. RTC stays doubly protected under
DTLS. Receive decrypts in the message + file sinks; undecryptable payloads (wrong
key) are dropped. Plaintext passthrough when signed out (account="").
Honest caveat (in crypto.rs): the key is sha256(account) and the account is an
email — low entropy, so this stops passive eavesdroppers but not someone who
knows the account. The real fix is a high-entropy secret (Sign in with Apple
`sub` / passphrase) → same from_secret() API.
Verified by examples/e2e_demo.rs: B decrypts A's clipboard over LAN; a relayed
send carries ciphertext only (plaintext never on the bus).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Engine.send_file(name, mime, data) and MessageHandler.on_file. Files stream
over each open RTC data channel as binary frames — M<json meta> · C<16-byte
id><16KB chunk>… · E<id> — and reassemble on the receiver in arrival order
(the channel is reliable+ordered). The data channel now distinguishes string
(clipboard) from binary (file) messages. Files are direct-only — never relayed,
matching prefer-direct.
Transport trait gains start(...files) + a default-noop send_file (only RTC
implements it). The agent saves received files to ~/Downloads; the apps stub
on_file (receive UI is a follow-up). All MessageHandler impls updated.
Proven by examples/file_demo.rs: A sends a 50KB blob (4 chunks), B reassembles
it to identical bytes over RTC.
Follow-ups: file transfer over LAN (same-network without RTC); attach/preview UI;
progress + size cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add room_for_account(account) = sha256(account)[..4] hex, exported via UniFFI so
every client derives the IDENTICAL room from a shared identity. Clients now
default their room to the account-derived id (falling back to a device id only
when signed out). With the prefilled account, all our devices land in one room
across LAN, the relay, and RTC — so "just us" works cross-network, not only on
the same Wi-Fi. The single canonical Rust impl guarantees the ids match across
Swift/Kotlin/agent (a hand-rolled per-platform hash would risk drift).
Verified: agent and the live Mac app both derive room 360309c8 from
"pecord@gmail.com"; the Mac app publishes there on the public relay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a third transport behind the Transport trait: each engine advertises
_tether._tcp.local. (TXT room/from/source) and browses for peers in the same
room, then connects directly over plain TCP (lower `from` dials to avoid
double-connects) exchanging newline-delimited JSON Messages. No server, no
internet — the first truly serverless transport, complementing SSE (relay) and
RTC (P2P-after-signaling).
Engine dedup already collapses a clipboard arriving over LAN + SSE/RTC.
Proven by examples/lan_p2p.rs: with a DEAD server URL (SSE/RTC can't deliver),
two engines discover each other via mDNS and sync over TCP.
Scope/caveats: room id is the only access control and the link is plaintext
(rustls keyed on a room secret is the follow-up); iOS needs the multicast
entitlement for mDNS, so the transport is inert there until that lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the webrtc dependency that the in-core RTC transport will use. Verified
it cross-compiles to aarch64-apple-ios, confirming RTC can live in the shared
core and reach every native platform from one implementation (the browser
keeps native RTCPeerConnection and interops over the wire).
Also fix a latent bug: the SSE transport forwarded every message type to the
sink, so a peer's presence/signal chirps would leak into the clipboard feed.
Now SSE forwards only clipboard; presence/signal belong to the RTC layer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce core/ — a Rust crate owning the wire protocol (Message codec,
SSE client, reconnect/backoff, send) behind a 4-method seam exported via
UniFFI: Engine::new / start(handler) / send / stop. This is the single
sync engine meant to back all clients; desktop links it directly, iOS
binds an .xcframework, Android will bind an .aar. RTC mesh + the signal
envelope are deliberately out of scope for v1 (SSE-only).
Verified end-to-end against the live server: examples/roundtrip.rs has a
subscriber engine receive a message published by a sender engine.
iOS now links it: TetherStore drops TetherKit's TetherClient/SSEClient/
Message and drives tethercore.Engine instead (RoomIdentity still from
TetherKit via selective import to avoid the Message name clash). The app
builds and links the Rust staticlib for device.
Artifacts (xcframework, Swift bindings) are generated by core/build-ios.sh
and git-ignored; commit source + script, not the ~80MB static libs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>