Commit Graph

13 Commits

Author SHA1 Message Date
3d4af095b1 wasm: browser↔native clipboard sync works over iroh
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>
2026-06-23 20:08:45 -05:00
ad026e8871 wasm: wasm-bindgen surface — WasmEngine + roomForAccount for the browser
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>
2026-06-23 17:54:53 -05:00
2ec5d892aa wasm: tethercore compiles for wasm32 — runtime Spawner + n0-future timers
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>
2026-06-23 17:28:37 -05:00
55340fe8f6 core: wasm groundwork — target-split deps + gate native-only transports
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>
2026-06-23 16:28:15 -05:00
3cb916ee5e core: M4 — files/photos over iroh-blobs (multi-modal clipboard)
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>
2026-06-22 01:15:41 -05:00
106e1283cc core: iroh spike milestone 3 — discovery via iroh-gossip
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>
2026-06-22 00:19:22 -05:00
68738dd82c core: iroh spike — prove iroh 1.0 as an alternative Mesh (milestone 1)
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>
2026-06-21 23:49:22 -05:00
33d0fb7f7e core: E2E payload encryption — server (and LAN) only ever see ciphertext
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>
2026-06-21 15:05:08 -05:00
d3dd785921 core: file/image transfer over the RTC data channel (chunked, P2P)
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>
2026-06-21 11:54:29 -05:00
d1750a95d6 core: account-derived rooms — same account → same room everywhere
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>
2026-06-21 01:55:28 -05:00
09d63651ab core: LAN transport — serverless same-network sync over mDNS + TCP
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>
2026-06-21 00:15:06 -05:00
a700a4b54b core: add webrtc-rs (validated host + iOS); SSE forwards clipboard only
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>
2026-06-20 20:33:22 -05:00
2c375632a5 core: shared Rust sync engine (tethercore) + wire into iOS via UniFFI
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>
2026-06-20 18:44:58 -05:00