Commit Graph

86 Commits

Author SHA1 Message Date
6d76c7093d docs: web client verified syncing — browser↔Mac clipboard over iroh
Mark the wasm web-client milestone done (was [~] compiles → [x] verified):
the relay-only browser peer now syncs the clipboard both ways with native
peers. Record the two wasm fixes (Instant::now panic via web-time; gossip
island via join_peers) and check off the wasm-bindgen JS surface item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:09:49 -05:00
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
1135b7d27b web: JS bindings step + a minimal browser demo
build-web.sh now generates the browser package with wasm-bindgen
(web/pkg/tethercore.js + tethercore_bg.wasm, --target web) after building the
cdylib. web/index.html is a tiny demo: import the bindings, derive the room from
an account, new WasmEngine(...).start(onMessage)/send — relay-only iroh peer in
the browser. Generated web/pkg/ is git-ignored.

End to end now proven buildable: Rust core → wasm32 → wasm-bindgen JS (exports
WasmEngine + roomForAccount) → importable in a browser. A live in-browser run
needs serving web/ + a reachable rendezvous/relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:56:36 -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
39ec4bc216 docs: wasm — core now compiles to wasm32 (all 4 structural items done)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:29:20 -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
7859184a48 wasm: iroh tls-ring feature — resolves presets::N0 (matches n0 browser-chat)
default-features=false dropped too much; tls-ring brings back presets::N0 (ring
works on wasm). wasm Rust code now compiles except the runtime refactor; the
remaining build blocker is ring's build script needing a wasm-capable clang.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:13:49 -05:00
3b1244ad06 wasm: gate UniFFI + reqwest timeout + enable gossip net (15 → 3 errors)
- UniFFI is the native FFI surface; cfg_attr all of it (setup_scaffolding, Record
  derives, export/callback_interface/constructor) to cfg(not(wasm32)). The wasm
  binding layer will be wasm-bindgen instead.
- rendezvous_register: reqwest Builder::timeout isn't on the wasm fetch backend —
  cfg it (browser bounds the request itself).
- iroh-gossip on wasm needs default-features=false + features=["net"] for the net
  module (Gossip/GossipSender/api).

wasm32 build now down to the two genuinely structural blockers: the tokio
multi-thread runtime (new_multi_thread → spawn_local) and the iroh wasm endpoint
(presets::N0 is native-only — pulls ring/portmapper). Native unaffected (verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:08:54 -05:00
9febee6a60 docs: record wasm groundwork status + the 4 structural items remaining
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:29:00 -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
d855bbbfa7 android: TETHER_FEATURES switch in build-android.sh (build the iroh core)
Mirrors build-apple.sh: TETHER_FEATURES=iroh-transport ./build-android.sh builds
libtethercore.so with the iroh stack. Verified: .so builds for arm64-v8a + x86_64
(2378 iroh symbols in the arm64 .so) and Kotlin bindings generate. Android joins
the build-verified-on-iroh set; only the web/WASM target remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:00:39 -05:00
5dfb2aa133 agent: §8 provenance toast on Windows (PowerShell/WinRT)
Adds the Windows arm to notify() — a WinRT toast via PowerShell (no extra
modules on Win10/11), completing the "Copied from <device>" provenance across all
three desktop OSes (macOS osascript, Linux notify-send, Windows toast).

Verified: `cargo check --target x86_64-pc-windows-gnu --features iroh` passes —
the whole agent incl. the new arm type-checks for Windows. The final binary LINK
can't be done via the macOS→windows-gnu mingw cross-toolchain (iroh-relay pulls
Windows system libs mingw doesn't auto-link); a native MSVC build is the real
target and is expected to link. Code-complete + type-checked; runtime untested
without a Windows box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:41:17 -05:00
1538cc64b3 macos: link iroh's frameworks for MacTetherApp (matches the iOS app)
The macOS GUI app needs the same OTHER_LDFLAGS as the iOS app to link the iroh
staticlib: Network (netwatch nw_* symbols) + CoreFoundation/Foundation/objc
(objc2 bindings) + iconv. With this it builds against the iroh core like the
agent and the iOS app do. Verified: BUILD SUCCEEDED for macOS arm64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:50:38 -05:00
3f023402e3 core: iroh_e2e example — prove the clipboard is end-to-end encrypted
Three peers join the SAME room → SAME gossip topic, so all three receive the same
broadcast bytes off the wire: A sends, B (same account → same key) must decrypt,
C (wrong account → wrong key) must NOT. If C — sitting in the identical topic,
handed the same ciphertext — can't read what B can, the payload is sealed
end-to-end, not merely relayed. Documents/locks in the E2E property of the iroh
transport (ChaCha20-Poly1305 seal before gossip; rendezvous + topic membership
grant no content access).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:26:59 -05:00
3e4781c4af core: bound rendezvous_register with an 8s timeout (was hanging iOS)
reqwest::Client::new() has no default timeout, so a slow/unreachable rendezvous
wedged IrohTransport.start() right after coming online — the transport never
reached "bootstrapping"/"joined topic". This is what hung the iOS app (both the
device and the simulator) while the macOS agent happened to be fast enough not to
notice. Build a client with an 8s timeout and add a "transport ready; registering"
log to pinpoint it. With the fix, the iPhone joins the gossip topic in seconds and
clipboard syncs phone⇄Mac over iroh (verified on device + simulator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:22:29 -05:00
e3d70e17f1 ios: regenerate project.pbxproj with iroh link flags (matches project.yml)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:07:25 -05:00
1487ac4cb9 ios: link Network/CoreFoundation/Foundation/objc/iconv for the iroh staticlib
The iroh xcframework needs more system frameworks than the legacy build: iroh's
netwatch uses Network.framework (nw_* symbols), and the objc2 bindings pull in
CoreFoundation/Foundation/objc/iconv. Add them to OTHER_LDFLAGS so TetherApp
links against the iroh core (verified: arm64 simulator BUILD SUCCEEDED; harmless
extra frameworks for the legacy build too).

Also add examples/iroh_send.rs — a one-shot test sender to pre-flight the agent's
receive path (clipboard write + provenance toast) without a second device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:45:11 -05:00
33c577357c agent: §8 provenance toast on inbound + iroh feature
- On inbound clipboard (Option A: write straight to the OS clipboard), fire a
  non-blocking native toast — osascript on macOS, notify-send on Linux —
  "Copied from <device>", mapping the source label to a friendly name. The
  mandatory awareness from §8 so a remote clobber is never silent. Best-effort,
  never blocks the sync loop.
- Add an `iroh` feature (→ tethercore/iroh-transport) so the agent runs on the
  iroh Mesh; --server then points at the rendezvous. Both default and
  --features iroh builds verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:34:38 -05:00
eefd58cf5b rendezvous: optional bearer-token auth (TETHER_RENDEZVOUS_TOKEN)
When TETHER_RENDEZVOUS_TOKEN is set, register/peers require
`Authorization: Bearer <token>` (healthz stays open); empty token = open, so the
first cross-CGNAT test needs no token but production can gate. IrohTransport
sends the token from the same env var, so agent/CLI clients pick it up
automatically (iOS needs a token field — small Swift follow-up).

Verified: 401 without/with wrong token, 200 with the right one, healthz open; and
the full engine flow (text + 200KB photo) still syncs end-to-end through a
token-gated rendezvous. Default ship build untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:31:37 -05:00
2c1419d18c core+rendezvous: zero-config bootstrap + iroh on iOS
Two pieces that unblock the on-device cross-CGNAT test:

1. Rendezvous (new `rendezvous/` crate, tether-rendezvous, axum) — the only
   server tether still needs on iroh: maps room → {EndpointId}, in-memory, TTL,
   content-blind (room is a hash; never sees clipboard data). IrohTransport now
   registers under cfg.server and bootstraps gossip from the returned peers, and
   re-registers every 30s so late joiners can find it. The TETHER_IROH_BOOTSTRAP
   env var is demoted to a test override. Verified: two engines self-discover via
   a local rendezvous and sync text + a 200KB photo with zero env wiring.

2. iroh on Apple targets — the iroh-transport feature cross-compiles for
   ios-arm64 / ios-sim / macOS once IPHONEOS_DEPLOYMENT_TARGET=26.0 is set (ring/
   blake3 prebuilt objects target the newer SDK). build-apple.sh now exports the
   deployment targets and takes a TETHER_FEATURES switch;
   `TETHER_FEATURES=iroh-transport ./build-apple.sh` assembles + vendors the iroh
   xcframework (4891 iroh symbols in the iOS .a). Swift bindings are unchanged —
   the app builds on iroh with no Swift changes.

Remaining for the live test: deploy the rendezvous (+ optional self-hosted
iroh-relay) publicly on the homelab, then build TetherApp in Xcode and point its
server field at the rendezvous URL. Default ship build untouched throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:27:03 -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
b377a6708a core: M4 — IrohTransport, the engine on iroh (feature-gated)
Brings iroh into the lib behind the `iroh-transport` feature (off by default, so
the UniFFI/xcframework/agent builds are byte-for-byte unchanged). When on,
core/src/iroh.rs's IrohTransport replaces the whole hand-rolled SSE/RTC/LAN stack:

- implements the existing Transport trait, emits the existing MeshEvents, so the
  engine event loop and every client are untouched — the MeshEvent seam paying off
- iroh-gossip carries clipboard Messages + presence over a topic derived from the
  room (sha256("tether:"+room)); iroh handles key-addressing, holepunch, relay
- bootstrap via TETHER_IROH_BOOTSTRAP env (v1 stopgap until the rendezvous lands)

Verified: `cargo run --features iroh-transport --example iroh_engine` → the real
Engine (unchanged Engine::new/start/send + MessageHandler) syncs a clipboard A→B,
E2E-decrypted, over gossip, with NO SSE/RTC/LAN/relay. Default build + default
examples stay clean; feature build is warning-free.

Remaining (roadmap M4): rendezvous for zero-config bootstrap, iroh-blobs for
files, make webrtc/mdns/reqwest optional for a lean iroh/wasm build, A/B vs the
legacy stack, then flip the feature default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:04:14 -05:00
8797ac2670 docs: §8 — app #1 universal clipboard spec (push-ahead, LWW, Option A + toast)
Banks the decided spec for the first app now that the networking is settled:
- identity = sign in with Apple/Google; sub → gossip topic → device list + key
- scope = proximity (push only to directly-reachable peers, never the relay) —
  Apple-esque, reuses prefer-direct/DirectSet as the proximity gate
- flow = push-ahead + last-writer-wins (NOT pull): copy pushes to nearby peers;
  paste just works because the content already arrived. No CRDT (Automerge is
  for app #2).
- agent design: Option A (proactive clipboard write on receive) chosen over B
  (paste-time substitution / hotkey); A is trivial and rides native Ctrl-V.
- mandatory awareness: a non-blocking toast on every remote clobber
  ("Copied from Patrick's iPhone · Photo"), which is also the exfiltration
  tripwire. Optional restore of the prior clipboard.
- the remaining hard part is OS clipboard integration, esp. background capture
  on mobile (iOS/Android restrict it); receiving+pasting is easy everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:54:41 -05:00
6a96046a9c core: iroh spike milestone 2b — cross-NAT two-machine tester
examples/iroh_p2p.rs: `accept` prints this node's EndpointId and echoes any
stream; `connect <id>` resolves the peer by id via discovery (no addresses),
round-trips, and reports DIRECT (holepunch worked) vs RELAY (fallback) from
remote_info. This is the last unknown before M4 — M1/M2/M3 all ran same-host;
this proves real cross-NAT.

Same-host smoke test passes (DIRECT over loopback). Awaiting a genuine
two-network run (Mac on wifi ⇄ laptop on phone hotspot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:32:40 -05:00
2c54a67eac docs: §6.3 — browser-direct is a watch-item (WebTransport vs WebRTC), relay-only today
Records the current state of iroh browser P2P: relay-only is the floor; the
credible future unlock is WebTransport + serverCertificateHashes (n0's preferred
path — they avoid WebRTC's heavy ICE/STUN/TURN model on purpose), roadmap with
no timeline. WebRTC exists only as a third-party community alpha
(iroh-webrtc-transport, unstable-custom-transports). Both land behind the same
endpoint.connect(), so it's free upside — track n0's WebTransport work, don't
design for browser-direct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:29:55 -05:00
069641be4e docs: converge the stack on iroh + Automerge (+ iroh-blobs, iroh-gossip, BLE)
Banks the adoption decision after the spikes. The hand-rolled transport ladder
(LAN/RTC/SSE) collapses into iroh's one key-addressed substrate; the media we'd
have built ourselves are iroh's:
- iroh QUIC = the Mesh (key-dial, holepunch, relay, 0-RTT)
- iroh BLE (iroh-ble-transport, add_custom_transport) = the "BT later" item as a
  feature flag, not a Transport impl — same connection over BLE or IP. Caveats:
  experimental unstable-custom-transports, ~100kbps, AGPL.
- Sync = Automerge (automerge + automerge::sync over an iroh bi-stream); the
  clipboard becomes a CRDT document.
- iroh-blobs subsumes the hand-rolled M/C/E file-frame protocol (content-
  addressed, resumable) — deletes that plumbing.

The convergence is the validation: independently re-deriving QUIC + dial-keys-
not-IPs + key-exchange + holepunch, then finding iroh implements exactly that,
means it's the right substrate — adopted knowingly, not cargo-culted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:21:50 -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
6645f6f37f docs: resolve the server + web questions — iroh retires the Go server
Decisions banked after the iroh spike (M1/M2 proven) + reviewing iroh's WASM
browser support:
- The Go server is retired entirely post-iroh. iroh subsumes every networking
  role (STUN/TURN→holepunch+relay, signaling→connect(), SSE→iroh relay,
  presence→iroh-gossip). Replacement is off-the-shelf: self-hosted iroh-relay
  (supernode #1) + a tiny account→{EndpointId} rendezvous for gossip bootstrap.
- Web is not a reason to keep a server. The future web client is tethercore
  compiled to wasm32 as a relay-only iroh peer (no holepunch in-browser; reaches
  peers via the relay, E2E). iroh-gossip compiles to WASM, so discovery works
  in-browser unchanged. The MeshEvent seam makes it a build target, not a
  project: feature-gate transports → web build is "engine + IrohTransport only".
- Migration is graceful: IrohTransport runs alongside SSE/RTC/LAN as another
  MeshEvent source until it wins, then they retire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:11:59 -05:00
1788bf7a37 core: iroh spike milestone 2 — prove n0's hosted relay carries us
examples/iroh_relay.rs forces the relay path on one machine: the connect side
turns address lookup OFF and dials a relay-only EndpointAddr (id + home relay
URL, no direct IPs), so the connection can only bootstrap through n0's hosted
relay. Verified round-trip via use1-1.relay.n0.iroh.link; remote_info then shows
a DCUtR upgrade to a direct path (relay + direct both Active) — so this proves
both the relay bootstrap and the holepunch upgrade in one run.

Remaining for cross-network confidence (milestone 2b): two machines on different
networks, then repeat against a self-hosted homelab relay (supernode #1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:01:45 -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
da0d0365e4 core: drop dead Crypto::enabled() left by the base64 fix
The signed-out base64 fix (61f4fbe) made open_b64 unconditional, removing the
only caller of Crypto::enabled(). Delete the now-dead method to keep the build
warning-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:49:13 -05:00
61f4fbe678 core: fix signed-out clipboard delivered as base64 instead of plaintext
The send path always base64-encodes (seal_b64), but the receive path only
called open_b64 when crypto was enabled — so with an empty account (crypto
disabled) the base64 layer was never undone and clipboards arrived as
"aGVsbG8tb3Zlci1sYW4=" instead of "hello-over-lan".

Make open_b64 unconditional, symmetric with the always-on seal_b64: it
base64-decodes then open()s, which passes through when crypto is disabled.
The file path already uses raw seal/open with no base64, so only the text
path needed the change. Pre-existing since the E2E commit 33d0fb7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:01:26 -05:00
b85df3fadd core: extract Mesh/Sync seam via MeshEvent — transports emit, engine owns state
Inverts the dependency that made the Mesh non-swappable. Previously the RTC/LAN
transports were handed the engine's state maps (direct/present/discovered/
transfers) as Arc<Mutex> and mutated them directly — Mesh reaching up into Sync.

Now transports hold only an EventTx and emit MeshEvents (Message/Status/File/
Present/DirectUp/DirectDown/Discovered/Transfer). A single engine event loop is
the sole owner/writer of all derived state and reacts to events: presence,
reachability, discovery, transfer progress, plus the existing decrypt/dedup/
ack/deliver pipeline. Transports no longer touch engine state.

Also replaces the stringly-typed capability checks (t.name() == "rtc"||"lan",
== "sse") with typed Transport::direct() / is_relay().

This is the real Mesh/Sync boundary the architecture doc described — and the
prerequisite for swapping in an alternative Mesh (Iroh), which now slots in as
just another MeshEvent source behind the same Transport contract.

Public API (Engine::new/start/send/send_file/nearby/present/receipts/transfers,
MessageHandler) is unchanged — examples, agent, iOS and macOS need no changes.

Verified: clean build, no new clippy lints; serverless LAN delivery byte-
identical to prior behavior (git-stash diff); crypto-enabled round-trip through
the new loop decrypts + delivers (e2e_demo). Doc §5a/§1/roadmap updated to
reflect the seam now existing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:36:08 -05:00
e84f1b7d65 docs: ARCHITECTURE.md — north star + honest SoC grade
Captures tether as a P2P app backbone, not just a clipboard:
- three-tier model (Mesh / Sync / App) with the inter-tier contracts;
  Mesh+Sync both live in tethercore, App is the only thing outside
- transport-agnostic ladder (LAN/RTC/SSE → QUIC → BT/LoRa) behind one trait
- two-tier crypto (room key + durable device keypairs), rotation,
  private discovery (OPRF/PSI lookup, ZKP ownership) — marked target
- supernode model: no server, just nodes volunteering signaling/relay/
  DHT/discovery; homelab is supernode #1
- §5a: honest separation-of-concerns grade against the actual code —
  Transport + MessageHandler boundaries are clean; the Mesh/Sync seam is
  aspirational (Engine holds both), transports mutate sync state, caps are
  stringly-typed. The MeshEvent refactor is the prerequisite for the Iroh
  spike — same work.
- Iroh + Automerge recommendation; roadmap (ship / next chapter / horizon)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:16:51 -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
c9546ac222 core+apps: file transfer progress
Add a Transfer record {name, received, total, incoming, done} and an engine-side
transfers map updated by the RTC/LAN transports — fine-grained on receive (per
chunk in apply_file_frame), per-chunk on send. Engine.transfers() exposes
in-flight transfers (completed linger ~6s, then prune). Both apps poll it and
show a progress row with a bar + percent above the feed.

Verified with examples/progress_demo.rs: a 5MB file's transfer reports
received/total advancing to done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:57:43 -05:00
479dda22d6 core: file transfer over LAN; share file frames between RTC and LAN
Rework the LAN transport from newline-delimited JSON to length-prefixed framing
([u32 len][u8 tag][payload]; tags H/J/M/C/E) so it can carry binary file chunks
alongside clipboard messages. send_file now goes over BOTH direct transports
(RTC + LAN) — so same-network file transfer works even when RTC isn't the path
(e.g. dead/unreachable server). Per-peer write locks instead of a whole-map lock
so a large file send doesn't stall clipboard.

Extract the M/C/E frame build/parse + IncomingFile into shared crate helpers
(file_frames / apply_file_frame), used by both RTC and LAN.

Since a file can now arrive over both transports, de-dup in the engine's file
sink (sha256 of name+bytes, 15s window) so on_file fires once.

Verified: clipboard still flows over the new LAN framing; a file transfers over
LAN with a dead server (RTC can't connect); RTC file transfer still works; no
duplicate delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:39:58 -05:00
0195b1e872 ios: file/image UI — mirror the Mac (inline preview, attach to send)
Wire on_file into the iOS app: received files save to Documents; images render
inline in the feed, other files show a doc chip. A paperclip opens a file
importer → send_file. FeedItem gains optional image/fileURL, matching macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:16:03 -05:00
b2a1e06f56 macos: file/image UI — inline preview, attach to send, reveal in Finder
Wire on_file into the Mac app: received files save to ~/Downloads; images show
an inline preview in the feed, other files show a doc chip with a reveal-in-
Finder button. A paperclip in the composer opens a file picker → send_file
(mime inferred from extension). FeedItem gains optional image/fileURL.

Add `tether-agent send-file <path>` (one-shot share / test helper).

Verified live: an agent sent a 36KB PNG over RTC and it rendered inline in the
Mac app's feed.

Follow-up: iOS file UI (mirror), clipboard-image auto-send, transfer progress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:13:42 -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
fdbc99d40e agent: install/uninstall as a login service (launchd / systemd)
Add subcommands so the desktop agent runs in the background on login — the real
desktop product shape, no window. `tether-agent install` writes a launchd
LaunchAgent (macOS) or systemd user unit (Linux) with the resolved config baked
in (run mode + server/account/name/room), loads it, and it survives reboots;
`uninstall`/`status` manage it. Foreground `run` unchanged (the no-subcommand
default). Windows falls through with a hint.

Verified on macOS: install loads the agent (launchctl lists it, process runs
with the baked args), uninstall removes it cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:40:41 -05:00
88ae0e6958 presence: "who's here" — online devices in the room, with names
Presence chirps now carry the friendly device name, and the engine tracks the
present set as Peers (id/name/source/room). New Engine.present() -> [Peer]
exposes who's online in the room across any network. Both apps poll it and show
a green-dot row of online devices above the feed.

The present set already powered prefer-direct's coverage check; this enriches it
with names and surfaces it. Verified live on macOS: an agent in the room shows
as "● Patrick's NAS".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 04:15:14 -05:00
ee938600c3 core: harden receipts — hash not text, and gate the ack on direct-coverage
Receipts carried the clipboard text and weren't coverage-gated, so an ack could
echo content over the relay even when the clipboard stayed P2P. Now the receipt
carries fingerprint(text) (sha256[..8]) instead of the text, and the auto-ack
skips the SSE relay when a direct path covers the room — same rule as send().
Closes the last way content could reach the server once P2P is up.

Verified: prefer_direct still keeps the payload off the bus (only presence
relayed, no receipts), and receipt_demo still confirms delivery (matched by
device name now that the text is hashed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:25:38 -05:00
7ccb32bb64 core: prefer-direct — suppress the relay once P2P covers the room
The engine sent clipboard over every transport at once, so the server always
saw the payload even with RTC/LAN up. Now the RTC and LAN transports report
their directly-reachable peers (data-channel open / TCP connected) into a shared
set, and the RTC transport records who's present from presence chirps. send()
skips the SSE relay when every present peer is directly reachable — so once P2P
is established the server only ever carries signaling/presence, never content.
Falls back to the relay whenever a present peer isn't directly reachable, or
before the direct link is up.

Also drop empty-text clipboards in the sink (were surfacing as blank "unknown"
feed rows).

Proven by examples/prefer_direct.rs: two engines link directly on the live
server; a send reaches the peer but never appears on the server bus (only
presence does).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:22:09 -05:00
cd0ac012e0 apps: show delivery receipts — "✓ Seen by <device>"
Both apps now poll engine.receipts() alongside nearby() and render a "Seen by
<names>" bar above the composer when acks come in. Closes the loop the receipt
engine opened: you can see your clipboard was received, instead of asking.

Verified live on macOS: copying on the Mac with a peer in the room shows
"✓ Seen by Rust LAN Peer".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:59:35 -05:00
cb0df2af81 ios: Nearby-devices picker (AirDrop-style), matching the Mac
Port the nearby picker to iOS: the store polls engine.nearby() while connected
and RoomView shows discovered devices as tap-to-join chips with platform icons,
same as MacTetherApp. pair() joins the device's room.

Dormant on iOS until the multicast entitlement lands (Rust mdns-sd uses a raw
multicast socket iOS blocks without it) — the UI is ready; it'll populate once
the entitlement is granted. No effect when the list is empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:39:27 -05:00
290284f4e1 ios: auto-connect on launch + use https default placeholder
Add .task { store.connect() } so the app connects on open instead of requiring
a manual Connect tap (macOS already did this) — the defaults are production-ready
(public relay + account-derived room). This is also what surfaced the stale
"ttps://" typo bug: a corrupted saved server URL silently failed connect with no
visible error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:35:07 -05:00
1fbe989675 core: delivery receipts — "seen by <device>" instead of "did you see it?"
On every inbound clipboard the engine auto-sends a receipt back to the sender
(type:"receipt", to:sender, role:my-name, text:the-clipboard). Senders collect
them via Engine.receipts() -> [Receipt{from,name,source,text,ts}] so the UI can
show "delivered to iPhone" per sent item — visible read state, not manual asking.

Plumbing this required: SSE post now sends the full envelope (type/to/role), not
just text; the SSE stream forwards receipts as well as clipboards; RTC's raw-text
data channel skips non-clipboard messages (receipts ride SSE/LAN). Receipts
de-dupe across transports.

Proven by examples/receipt_demo.rs: A sends, B auto-acks, A.receipts() shows
"seen by Patrick's iPhone".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:34:45 -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
f7bc8eed4b clients: prefill a shared account so our devices auto-pair ("just us")
Default the account to a shared identity (overridable, persisted in
UserDefaults/prefs; agent via --account) instead of empty. With it prefilled,
every one of our devices on the same network auto-pairs over the LAN regardless
of room — no setup. Swapped in for the real Sign-in-with-Apple `sub` later.

Verified with the live Mac app: it and an agent in a totally different room
LAN-paired purely via the shared account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:49:35 -05:00