Compare commits

71 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
c3b9b7940f core: account identity — same-account devices auto-pair on the LAN
Add an `account` dimension (Apple/Google `sub`, or any shared id) advertised
over mDNS alongside the device name. The LAN transport now connects to a peer
when they share a room OR a non-empty account — so your own devices find each
other and sync on the network regardless of room, no pairing step. Peer gains
`account` so the UI can mark "your devices".

Engine::new gains `account` (6th arg, ""=signed out); all callers updated.
Apps pass "" until sign-in lands; agent takes --account.

Proven by examples/account_pair.rs: two devices in DIFFERENT rooms with the
same account sync over LAN with no server.

Next: derive the account from Sign in with Apple / Google so this is automatic
(the cross-network version = account-derived shared server room).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:41:00 -05:00
049456521f macos: Nearby-devices picker (AirDrop-style) over the LAN
Add a horizontal strip of discovered tether devices to the window: the store
polls engine.nearby() every 2s while connected and renders each as a chip with
its friendly name + platform icon. Tapping a device calls pair() — sets room to
that device's room and reconnects, so you join it without typing a room id.
Devices already in your room show "in your room" (disabled).

Verified live on macOS: two named LAN devices appear as "tap to join" chips
once the app has Local Network permission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:26:37 -05:00
6e24d19a58 core: nearby-device discovery (AirDrop-style) — friendly names + Engine.nearby()
Each device now advertises a friendly name over mDNS ("Patrick's MacBook"),
and the LAN transport records every tether device on the network — regardless
of room — into a discovered table. New Engine.nearby() -> [Peer] exposes that
list for an AirDrop-style picker; entries prune after 30s unseen. Peer carries
{id, name, source, room} so the UI can offer "pair" (join that device's room).

Engine::new gains a `name` param (5th); all callers updated (apps pass
UIDevice.name / Host.localizedName / Build.MODEL, agent passes hostname or
--name). Auto-connect stays room-scoped; only the *visibility* is broadened.

Proven by examples/nearby.rs: two engines in different rooms discover each
other by name over mDNS — and it picks up real devices on the LAN too.

iOS caveat unchanged: mDNS needs the multicast entitlement, so nearby() is
empty there until that lands. Desktop/Android work today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:09:56 -05:00
e52e20c2c5 clients: default server URL → https://tether.pecord.io
Replace the dead tether.lan / localhost / emulator-host placeholders with the
live public relay across all clients (iOS, macOS, Android, agent), so a fresh
install connects out of the box. All remain overridable (UI field / --server).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:01:58 -05:00
0e0b504808 core: RTC fetches server TURN creds from /api/turn-cred (cross-network P2P)
The RTC transport hardcoded Google STUN, so native clients never used the TURN
relay the homelab server already runs — meaning hole-punch failures (symmetric
NAT / CGNAT, i.e. cellular) had no E2E fallback. Now RtcTransport fetches
{server}/api/turn-cred at startup (same contract the web client uses) and feeds
the returned STUN+TURN iceServers into every RTCPeerConnection. Falls back to
STUN if the endpoint is absent (verified: local server 404s → STUN, delivery
still works via the other transports).

This is the client half of "cross-network P2P that just works" — the server
half (integrated pion TURN + /api/turn-cred) is already deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:54:38 -05:00
377aa67d4d core: quiet transport reconnect spam for LAN-only / dead-server mode
SSE and RTC logged an error (and SSE flipped status) on every reconnect
attempt, so running without a reachable server — the legit LAN-only case —
flooded the log and spammed on_status(false). Now each transport reports a
drop once per outage streak and resets on reconnect; SSE no longer logs
per-send failures (implied by the connection-state line).

Verified: serverless agent↔peer LAN sync still works both directions, with the
agent log down from ~25 lines to 7 (one disconnect notice per transport, then
real activity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:24:08 -05:00
f9a7f38bb7 core: lan_peer example — verify agent serverless sync without clipboard contention
A controllable LAN peer (dead server, doesn't touch the OS clipboard) used to
prove the desktop agent syncs over LAN alone. Confirmed both directions on macOS
with the server unreachable: pbcopy → agent → peer, and peer → agent → pasteboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:20:45 -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
38b9b55ac8 cleanup: retire dead TetherKit networking, fix iOS UI papercuts, modernize README
- Delete TetherKit's Message/SSEClient/TetherClient + MessageTests — all dead
  since both apps moved to tethercore. TetherKit is now just RoomIdentity; drop
  the empty test target and the now-unused `import TetherKit` from RoomView.
- iOS: Connect button mislabeled "Reconnect" while connected — now "Disconnect"
  and actually disconnects, matching the macOS app.
- iOS: feed timestamps for RTC-delivered messages (ts=0) fell back to 1970;
  use now, matching the macOS bridge.
- README: rewrite from the Go-only story to the shared-core architecture
  (tethercore engine + native clients on every platform) with a layout diagram.

All four targets rebuild clean (core, agent, iOS, macOS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:01:10 -05:00
3581c1e86c android: Compose client scaffold on tethercore (Kotlin/UniFFI)
Add an Android app that drives the same tethercore engine (SSE + RTC) as the
iOS/macOS apps via generated Kotlin/UniFFI bindings — proving the shared core
reaches a fourth language with no protocol reimplementation. Engine surface is
identical to Swift: Engine(server,room,from,source) / start / send / stop.

Includes the Gradle/Compose project, a TetherStore that bridges the engine to
ClipboardManager (auto-send via OnPrimaryClipChangedListener — Android allows
foreground clipboard observation, unlike iOS), per-install RoomIdentity, and
core/build-android.sh (cargo-ndk → jniLibs + Kotlin bindgen).

Not built here: this machine has no Android SDK/NDK, so the native .so and
Kotlin binding are generated (git-ignored), not committed. README documents the
NDK install + build steps. The binding itself was confirmed to generate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:42:36 -05:00
1caa44e253 agent: headless desktop clipboard agent on tethercore (macOS/Linux/Windows)
A single Rust binary: the shared tethercore engine (SSE + RTC) wired to the OS
clipboard via arboard. Inbound room clipboard → local clipboard; local changes
→ published to the room, with echo suppression. No UI — meant to run as a
login/service daemon. One codebase covers all three desktops.

  tether-agent --server http://host:8765 --room <id> [--source label]

Verified on macOS against the live server: RECEIVE (bus → clipboard via pbpaste)
and SEND (pbcopy → bus) both confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:38:24 -05:00
db64b3b9a6 macos: migrate app to the shared tethercore engine (SSE + RTC)
Drop TetherKit's TetherClient/SSEClient/Message from the macOS app; drive
tethercore.Engine instead (RoomIdentity still from TetherKit via selective
import). The Mac now gets direct P2P over the RTC data channel for free, and
the native Swift app is no longer a parallel wire-protocol implementation.

Background NSPasteboard polling (auto-send on copy) and write-on-receive are
preserved. Feed UI moves to a FeedItem view-model (RTC messages carry ts=0 →
fall back to local time).

Verified end-to-end against the live server: RECEIVE (bus → Mac pasteboard via
pbpaste) and SEND (pbcopy → bus) both confirmed.

Generalize core/build-ios.sh → build-apple.sh: builds iOS device + sim + macOS
arm64 into one TetherCore.xcframework and vendors it into both apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:31:54 -05:00
fdd65eea7c core: in-core RTC transport — direct P2P clipboard over WebRTC data channel
Implement RtcTransport (webrtc-rs) behind the Transport trait, so every native
platform that links tethercore gets direct P2P alongside SSE from one impl.

Signaling rides the existing server bus and matches the web client byte-for-
byte (presence chirp, lower-id-offers tie-break, {kind, sdp|candidate} signal
envelopes, "tether" data channel carrying raw clipboard text) — so Rust peers
interoperate with the browser's native RTCPeerConnection.

Dedup now keys on (from, text) with a 3s TTL so the SSE and data-channel copies
of the same clipboard collapse to one; the faster RTC copy wins.

Proven by examples/rtc_p2p.rs: two engines bring up a data channel and a
clipboard is delivered over it (received source == peer from-id, confirming the
RTC path beat the SSE relay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:38:12 -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
19f5eaccdf core: make the engine multi-transport (Transport trait + inbound dedup)
Restructure tethercore so the engine owns a list of pluggable Transports
instead of a hardcoded SSE path. SSE moves behind the trait unchanged;
RTC (webrtc-rs data channel) and BT (BLE GATT) now have a clean socket to
implement the same interface — start(sink, status) / publish(msg).

The engine multiplexes outbound across all transports and de-dups inbound
(keyed on from+ts+text) so a clipboard echoed over two channels surfaces
once. No-op with SSE alone; required the moment RTC lands.

FFI surface is unchanged (Engine::new / start / send / stop, Message,
MessageHandler) — no binding regen needed. Round-trip example still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:48:01 -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
b39fcc37e8 ios: fix on-device launch crash; harden room identity
Bump iOS deployment target 17→26: the OS_dispatch_mach_msg
"_setContext: unrecognized selector" launch crash on the iPhone 17 Pro
was a libdispatch/concurrency-runtime mismatch between the iOS 17 target
and the iOS 26 device. App now launches and stays resident on device.

RoomIdentity: drop the iCloud ubiquityIdentityToken + NSKeyedArchiver
path (a second suspected crash vector) in favor of IOKit hardware UUID
on macOS and a Keychain-stored 8-hex UUID on iOS. SiwA/iCloud identity
returns at v0.6 once dev-program entitlements land.

Also add ios/.gitignore and untrack .build/ artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:12:28 -05:00
c73108a126 Add macOS window UI (ContentView) alongside menu bar
WindowGroup with resizable window (560×520 default): connection toolbar,
feed list with source icons + text selection + copy button, composer with
Cmd+Return shortcut. Both window and menu bar share the same store.
Removed LSUIElement — app now shows in Dock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:27:55 -05:00
c4334143b0 Auto-derive room ID from iCloud account or device hardware
RoomIdentity.derive() in TetherKit: ubiquityIdentityToken (iCloud account,
portable across devices) → IOKit/identifierForVendor (hardware, device-tied)
→ Keychain UUID (last resort). No user config needed. Both apps auto-connect
on relaunch if server URL was previously saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:00:12 -05:00
6c2dcdd603 Add macOS menu bar app (MacTetherApp)
MenuBarExtra SwiftUI app sharing TetherKit wire layer with iOS.
Auto-sends clipboard changes via NSPasteboard polling (500ms),
auto-writes received messages to NSPasteboard. XcodeGen project.yml.
Both apps build clean; server tested end-to-end with curl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:27:21 -05:00
14b6be7548 Add iOS v1 client: TetherKit SPM + SwiftUI app
TetherKit: Codable Message envelope (1:1 with Go server), SSE reader over
URLSession.bytes, TetherClient with reconnect/backoff. 3 wire-format tests pass.
TetherApp: SwiftUI room UI (connect, live feed, send clipboard/text, tap-to-copy),
generated via XcodeGen (project.yml). SSE-only, foreground; no WebRTC/APNs yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:11:31 -05:00
74 changed files with 21346 additions and 12 deletions

510
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,510 @@
# tether — architecture & north star
> **What this is.** tether began as a private Universal Clipboard. The clipboard
> is the *proof*, not the *point*. The point is the thing underneath it: a
> **resilient, transport-agnostic P2P substrate** that any private cross-platform
> app can be built on. The clipboard is app #1.
This document is the durable record of where tether is and where it's going, so
the vision survives outside any one conversation. It is a map, not a spec —
where it describes things that don't exist yet, they are marked **(target)**.
---
## 1. The thesis
> As long as nodes can come up, find each other, prove who they are, and pass
> data — securely, over whatever medium is available — everything else is an
> app-layer concern.
That one sentence is the whole bet. It implies a clean separation into three
tiers. Each tier only ever talks to the one below it through a narrow contract,
so any tier can be swapped without disturbing the others.
```
┌─────────────────────────────────────────────────────────────┐
│ APP clipboard · (next app) · (your app) │ ← per-app
│ reads/writes named state; sends/receives msgs │ code
├═══════════════════════════════════════════════════════════════┤
│ SYNC CRDT state · message/data passing · presence │ ┐
│ turns "bytes between peers" into shared state │ │
├─────────────────────────────────────────────────────────────┤ │ tethercore
│ MESH identity · transports · discovery · │ │ (one crate,
│ (substrate) secure channel · reachability/relay │ │ UniFFI'd to
│ promises: "named, authenticated peers you can │ │ every client)
│ reach and send bytes to, over any medium" │ ┘
└─────────────────────────────────────────────────────────────┘
```
**Tiers vs. crates.** The three tiers are a *contract* boundary, not a packaging
one. **Mesh and Sync both live in `tethercore`** — sync is app-*agnostic* (every
app reuses dedup, ordering, CRDT merge, delivery, presence), so it belongs in the
shared core precisely *because* it's shared, not pushed up into each app. The
only thing outside the crate is the App tier. The double line above (`═`) is the
crate boundary; the single line (`─`) inside it is the internal Mesh/Sync seam.
**The seam is now real (as of the `MeshEvent` refactor).** Transports emit
`MeshEvent`s up to a single engine event loop that is the sole owner/writer of
all derived state; transports no longer touch engine state. The three clean
boundaries today: the `Transport` trait (Mesh's downward edge), `MeshEvent` (the
internal Mesh→Sync seam), and `MessageHandler` (the App edge). See §5a.
The contracts between tiers:
- **Mesh → Sync:** "Here is a set of authenticated, named peers. You can send
bytes to any of them and receive bytes from them. I don't care what the bytes
mean. I'll tell you who's present and reachable; I hide whether that's LAN,
WebRTC, QUIC, Bluetooth, or LoRa."
- **Sync → App:** "Here is replicated state and a message stream, scoped to an
identity. Read it, write it, subscribe to it. I handle conflict resolution,
ordering, dedup, and delivery; I hide which peer a change came from."
Everything tether-specific and clever lives in **Mesh**. Everything reusable
across future apps lives in **Sync**. The clipboard is a thin **App**.
### The one seam that's easy to get wrong
"Find each other *locally*" is the easy half — mDNS / link-local broadcast on a
shared L2 is basically solved. The irreducible hard part of the Mesh — the part
that must **not** leak up into the app — is everything that happens when two
nodes are *not* on the same network:
- **Non-local discovery** — peers on different networks finding each other →
rendezvous, then DHT.
- **NAT traversal** — hole-punching with a relay fallback → supernodes.
- **Identity & secure channel** — knowing *who* a peer is and that the link is
private → durable keypairs + an authenticated handshake.
These five — identity, transport, discovery, secure channel, reachability — are
the Mesh. Get them right once and the transport medium becomes a detail.
---
## 2. Transport-agnostic by construction
The Mesh sees every medium through one `Transport` trait. The upper tiers never
know which one carried a byte. Today's ladder, with room to grow downward:
**Decided: adopt iroh as the Mesh** (spike proven, §8 M1M3). The hand-rolled
ladder below collapses into iroh's one key-addressed substrate — and crucially,
the media we'd have hand-rolled (`QUIC`, `BLE`) are iroh's, not ours.
| Transport | Status | Reach | Notes |
|-----------|--------|-------|-------|
| **LAN** (mDNS + length-prefixed TCP) | shipped, *to retire* | same L2 | hand-rolled; iroh does same-LAN direct natively |
| **RTC** (WebRTC data channel) | shipped, *to retire* | cross-network | hand-rolled STUN/TURN; iroh's holepunch+relay replaces it |
| **SSE relay** | shipped, *to retire* | cross-network | signaling + fallback; iroh relay replaces it |
| **iroh QUIC** | **adopted** | LAN + cross-network | one substrate: key-dial, holepunch, relay, 0-RTT |
| **iroh BLE** | feature flag | proximity, no IP | `iroh-ble-transport` via `add_custom_transport()`*same connection over BLE*. Experimental (`unstable-custom-transports`), ~100kbps, **AGPL** |
| **LoRa / other** | someday | long-range, low-bw | iroh custom-transport API (same hook as BLE) if ever needed |
The payoff of adopting iroh: BT is no longer a `Transport` we write — it's a
flag, and the *same* `EndpointId` peer is reachable over BLE or IP with iroh
choosing the path. The convergence is the validation — re-deriving QUIC +
dial-keys-not-IPs + key-exchange + holepunch independently, then finding iroh
already implements exactly that, means it's the correct substrate, adopted
knowingly rather than cargo-culted.
---
## 3. Identity & crypto (the two-tier model)
Identity is part of the Mesh, not the app. The model is two layers:
1. **Room key — shared symmetric secret.** Everyone in a room/group can decrypt
group content. Today: `key = sha256(account)`, ChaCha20-Poly1305, payload
sealed before *any* transport touches it (see `core/src/crypto.rs`). The
server and any LAN sniffer see only ciphertext.
- **Known weakness (documented, honest):** the account is an email — low
entropy. This stops a passive eavesdropper, not someone who knows the
account. **(target)** Replace the secret with a high-entropy source
(Sign-in-with-Apple `sub`, or a passphrase) fed into the same
`from_secret`. No protocol change — just a better secret.
2. **Device keypairs — durable per-device identity. (target)** Each device has a
long-lived X25519 keypair. Peer-to-peer links run an authenticated handshake
(Noise via `snow`, or adopt libsignal/MLS rather than hand-roll) so each pair
has forward-secret session keys. The room key is then distributed by
*sealing it to each device's public key* (sealed-box) instead of being
derived from a guessable string.
**Rotation & unlinkability (target).** New shared key → new room. New device
identity → new public key, re-handshake. This buys forward secrecy and
unlinkability, but creates a discovery tension (if your identity keeps changing,
how does someone find you?). Resolved by a **stable handle + directory
indirection**: a durable, user-facing handle maps — *privately* — to whatever
the current key/room is.
**Private discovery (target).** Looking someone up by email/phone/handle without
revealing the lookup table:
- **OPRF / PSI** for the *lookup* ("do we both know this contact?" without
either side learning the other's full set).
- **ZKP** for *ownership proofs* ("I control the key behind this handle"), not
for the lookup itself.
These are v2+ research arcs. Don't hand-roll the primitives — stand on vetted
libraries.
---
## 4. The supernode model (there is no "server")
There is no server/client split. There are only **nodes**. Some nodes — by
virtue of public reachability + uptime + bandwidth — volunteer extra roles.
Those are **supernodes** (Skype/ultrapeer/libp2p-relay model). The current Go
"server" *is* a supernode; the architecture just generalizes it from *the one*
to *one of many, peer-selected.*
A supernode offers any subset of these — **none of which touch plaintext**:
- **Signaling / rendezvous** — peer-relayed SDP/ICE exchange (today: the SSE bus).
- **TURN relay** — forwards **E2E ciphertext** when hole-punching fails; blind to payload.
- **DHT bootstrap + capacity** — stable entry points; hold more of the keyspace.
- **Discovery hints** — same-ASN / same-egress-IP co-location hints (the node
that can see public IPs). Strong CGNAT false-positive caveat; mostly useful to
bias toward the LAN transport and to avoid TURN.
- **Store-and-forward mailbox (target)** — hold sealed messages for offline peers.
**Promotion** = reachable + up + has bandwidth. Auto-detected (a node that finds
it's publicly reachable volunteers) or designated (flag the homelab). Patrick's
homelab is **supernode #1**; the hosted relay is #2.
**Why "many, peer-selected" matters:** a single coordinator is a server with
extra steps. Several supernodes, cross-checked, are what resist the real failure
modes — **eclipse attacks** (one node controlling your view of the network) and
**traffic analysis** (metadata is visible even though content is E2E).
**Two honest constraints:**
- **Bootstrap is unavoidable.** You must *know* one supernode to start — a
hardcoded list or a DNS name. Even BitTorrent's DHT ships bootstrap nodes.
"Decentralized" never meant "zero fixed entry point."
- **Incentives at public scale.** For Patrick's own devices, he runs the
supernodes. For a *public* backbone, *who runs them* is a real economics
question (Skype conscripted user machines — controversial; BitTorrent/Veilid
lean on altruism). Decide deliberately, don't default into it.
---
## 5. Current state (what actually exists)
At commit `33d0fb7`. Verified on real hardware: bidirectional cross-network
clipboard sync (iPhone-on-cellular ⇄ Mac), file transfer (RTC + LAN),
prefer-direct (relay saw only presence), and E2E (relay carried only ciphertext).
- **`core/`** — `tethercore` Rust crate, the Mesh + a thin Sync. UniFFI-exported
to Swift/Kotlin. `Engine` multiplexes LAN + RTC + SSE, dedups across them,
prefer-direct (suppress relay once all present peers are directly reachable),
delivery receipts (hash, not content), presence, nearby (mDNS), file transfer,
E2E (`crypto.rs`). Account-derived room: `room = sha256(account)[..4]`.
- **`agent/`** — headless desktop node; installs as a launchd/systemd service;
clipboard loop; saves received files.
- **`ios/`** — SwiftUI iPhone + Mac apps over the same core (`TetherKit`).
- **`android/`** — scaffold; needs NDK build wired up.
- **`server/`** — Go supernode: pion signaling + integrated TURN +
`/api/turn-cred` (HMAC ephemeral creds) + web client. **⚠ The deployed binary
is ahead of this source** (integrated TURN, advanced web client, built from an
uncommitted tree ~2026-05-21). Do **not** redeploy `server/` as-is — it would
regress TURN. **Decided (post-iroh): the Go server is retired entirely.** iroh
subsumes every networking role it has — STUN/TURN → holepunch + relay,
RTC signaling → `endpoint.connect()` (gone), SSE relay → iroh relay, presence
`iroh-gossip`. Its replacement is *off-the-shelf*: a self-hosted **iroh-relay**
on the homelab (supernode #1) + a tiny `account → {EndpointId}` rendezvous for
gossip bootstrap. Web is not a reason to keep it (§6.3: web = WASM build of the
core). Migration is graceful, not big-bang — `IrohTransport` runs *alongside*
SSE/RTC/LAN as another `MeshEvent` source until it wins, then they retire.
What's **Mesh** today: identity (weak), LAN/RTC/SSE transports, mDNS + relay
discovery, E2E channel, presence/reachability. What's **Sync** today: message
passing, dedup, receipts, file frames — but *no replicated state* (no CRDT). What's
**App**: the clipboard UIs.
### 5a. Separation-of-concerns: honest grade
Graded against the code (`core/src/`).
**Clean and real (the three boundaries):**
- **`Transport` trait** — the downward edge. SSE/RTC/LAN each implement it;
adding QUIC/BT is "implement the trait." Capability is now *typed*
(`direct()` / `is_relay()`), so the engine never matches on `name()`.
- **`MeshEvent` enum** — the Mesh→Sync seam. Transports emit events
(`Message`/`Status`/`File`/`Present`/`DirectUp`/`DirectDown`/`Discovered`/
`Transfer`) over an `EventTx`; a single engine event loop is the sole
owner/writer of all derived state (presence, reachability, discovery,
transfers, dedup, receipts). Transports hold no engine state.
- **`MessageHandler` trait** — the App edge. The clipboard reaches core only
through `on_message`/`on_status`/`on_file`; apps never touch engine internals.
- **`crypto.rs`** — isolated; one seal point at the engine boundary before any
transport.
**The `MeshEvent` refactor (done) fixed the three gaps this section used to list:**
1. ~~No Mesh/Sync seam~~`MeshEvent` is the seam; a new transport (e.g. Iroh)
slots in as just another event source without touching the engine loop.
2. ~~Transports mutate Sync state~~ → the dependency arrow is inverted: transports
*emit*, the engine *reacts*. No more `Arc<Mutex>` state handed into transports.
3. ~~Stringly-typed capabilities~~`Transport::direct()` / `is_relay()` replace
the `name() == "rtc"` matches.
**Remaining watch-item:**
- **`Engine` is still a large struct** — it owns the state maps and the event
loop. The loop centralizes *writes* (good), but the struct still has many
fields. Fine for now; revisit if a second app lands on the core.
**Next, building on the seam:** spike **Iroh** as an alternative Mesh — it
becomes a `MeshEvent` source behind the same `Transport`/event contract, so the
Sync/App tiers above don't change.
---
## 6. The big gaps (clipboard → backbone)
The hard networking kernel exists. To become a general substrate:
1. **State sync, not just messages — adopt Automerge.** Apps need replicated
*state* (a doc, list, board), conflict-free across peers. **`automerge` (Rust)
+ its transport-agnostic `automerge::sync` protocol over an iroh bi-stream.**
The clipboard becomes an Automerge document (history as a CRDT list) synced
per-peer; app #2 is another document. This is the heart of the Sync tier and
the biggest single addition. **Bonus reuse — `iroh-blobs`** subsumes the
hand-rolled `M/C/E` file-frame protocol entirely: content-addressed,
resumable, deduped blob transfer for files/images, for free.
2. **An app API / SDK.** A clean surface: declare data, subscribe, send. The
thing a developer building app #2 actually touches.
3. **Web target = WASM (resolved by iroh).** The future web client is just
`tethercore` compiled to `wasm32` as a **relay-only iroh peer** — same core,
no bespoke server bridge ever. A browser iroh endpoint can't holepunch
(sandbox: no UDP), so it always reaches peers *through the relay* (E2E, relay
blind); `iroh-gossip` compiles to WASM too, so discovery (§ M3) works
in-browser unchanged. The `MeshEvent` seam makes this a *build target, not a
project*: native transports (LAN/mdns, webrtc-rs, reqwest) don't compile to
WASM and don't need to — feature-gate the transports and the web build is
"engine + `IrohTransport` only, `default-features=false`, `wasm32`." Cost:
browser peers lean on the relay (no P2P offload) — trivial for clipboard,
relay-carried for big files.
- **Browser-direct is a watch-item, not a dependency.** Relay-only is the
floor today. The *credible* path to browser P2P offload is **WebTransport
+ `serverCertificateHashes`** (n0's preferred route — they deliberately
avoid WebRTC, whose ICE/STUN/TURN bundle is the stateful/expensive model
iroh exists to escape); it's roadmap, no timeline. **WebRTC** exists only as
a community alpha (`iroh-webrtc-transport`, third-party, on the experimental
`unstable-custom-transports` API) — long-shot. Both would arrive behind the
same `endpoint.connect()`, so it's zero-effort upside we wait for. Track
whether n0 ships WebTransport browser-direct from core; don't design for it.
4. **Capabilities / permissions (target).** Multi-user needs object-capabilities
(UCAN), not ACLs.
5. **Persistence / offline-first (target).** Local store + sync-on-reconnect
(append-only log or CRDT doc store).
---
## 7. Don't reinvent the substrate
This space has serious, aligned prior art. The moat is **product/DX/UX** (the
"AirDrop-grade just works" + the app model) — **not** the transport/DHT
plumbing, which is being commoditized. Stand on vetted infra:
- **Iroh** (n0, Rust) — QUIC P2P + relay hole-punching + discovery
(DNS/pkarr-DHT) + `iroh-docs`/`iroh-blobs` for sync. Possibly ~70% of the Mesh
already, in Rust. Its **relay nodes = our supernodes**; its bootstrap/DHT = our
rendezvous. **Evaluate first.**
- **libp2p** — the components (relay-v2 + DCUtR + Kademlia) if you want them à la carte.
- **Veilid** (cDc, Rust) — privacy-first P2P app framework, DHT + E2E. Aligned
with the privacy/DHT angle specifically.
- **Automerge / Yjs** — the CRDT Sync layer. **UCAN** — capability auth.
**Willow / Earthstar** — capability-scoped P2P data.
**Strategic recommendation:** adopt **Iroh (Mesh) + Automerge (Sync)**, build
identity/E2E/discovery-UX + the app API on top, keep the clipboard as the
flagship app that proves the DX. Run the homelab as a self-hosted Iroh relay +
bootstrap node — that's supernode #1 — and the substrate hands you peer-relay +
DHT + multi-supernode selection for free, instead of a year of hand-rolling NAT
and crypto and getting it subtly wrong.
---
## 8. App #1 — the universal clipboard (the spec)
The first app on the substrate. Goal: **install, sign in once, never look at it
again** — and no walled gardens (works iPhone ⇄ Android ⇄ Windows ⇄ Mac ⇄ Linux,
unlike Apple's Apple-only Universal Clipboard).
**Identity = sign-in.** Sign in with Apple/Google → the `sub` is the account →
derives the gossip topic → you see your other devices (the Tailscale feeling).
That same high-entropy `sub` is also the crypto key (fixes §3's weak-key
problem). One move, three wins: identity, device list, key.
**Scope = proximity, not global.** Apple-esque: push only to **directly-reachable
peers** (same LAN / BLE), *not* relay-distant ones. This reuses the old
prefer-direct/`DirectSet` idea as a proximity gate, keeps content on the local
network (never touches the relay), and makes BLE the literal Handoff analog.
**The flow (push-ahead + last-writer-wins, NOT pull):**
```
iPhone clipboard COPY → push content to directly-reachable peers (scoped)
…moments later…
Windows agent: on RECEIVE → write to the local clipboard (Option A, below),
guarded by recency window + directly-reachable + remote-only
native Ctrl/Cmd-V → just works; the remote content is already there
```
Selection rule: most-recent-copy-anywhere wins (LWW by timestamp across
{local, received}); a recency window stops a stale phone clipboard ambushing a
paste. **No CRDT needed** — push events + LWW is enough; Automerge is for app #2's
shared state, not this.
**Agent design fork — chose A:**
- **A (proactive write, Apple-style, CHOSEN):** on receive, write straight to the
local clipboard → native paste works with zero interception. Trivial. Downside:
it clobbers the local clipboard.
- B (paste-time substitution): non-destructive but needs a dedicated hotkey /
keyboard hook / clipboard-manager role (no OS gives a background agent a "paste
event" to intercept). Deferred polish.
**Awareness is mandatory with A.** Because the write is silent, every remote
clobber fires a **non-blocking toast** (not a modal — a modal on every copy would
kill "never look at it again"): *"Copied from Patrick's iPhone · 📷 Photo"*.
Fires at receive (the clobber moment); only for remote copies; coalesce rapid
ones. Optional: stash the prior local clipboard for a one-click "restore". This
toast is also the **exfiltration tripwire** — nothing enters your clipboard
invisibly; with same-account scope + E2E, the trust model is sound.
**The hard part is no longer networking — it's OS clipboard integration.** iroh
makes the mesh trivial; the platform tax is: (1) **source-side capture on mobile**
— iOS/Android restrict *background* clipboard reads, so the iPhone capturing its
own copy while asleep is the constrained bit (desktop capture is fine); (2)
multi-modal payloads (text inline, audio/photo/data via `iroh-blobs`). Receiving
+ pasting is easy everywhere; capturing on mobile is where the effort goes.
---
## 9. Roadmap
**Near-term (ship the product):**
- [ ] Coordinated rebuild + reinstall of all clients so E2E actually activates
(running apps still carry the old plaintext core).
- [ ] Swap the room secret to a high-entropy source (SiwA `sub` / passphrase).
- [ ] Recover the uncommitted deployed `server/` source (don't redeploy until then).
- [ ] iOS multicast entitlement (Nearby/LAN are dormant on iOS).
- [ ] Android NDK build; clipboard-image auto-send; file-size cap.
**Next chapter (start the backbone, clean):**
- [x] **Extract the Mesh/Sync seam via `MeshEvent`** (§5a) — transports emit
events, engine owns state. Fixed the transport→sync coupling and the
stringly-typed capability check; verified behavior-preserving over LAN +
crypto round-trip. *Prerequisite for the Iroh spike — done.*
- [~] Spike **Iroh** as the Mesh — homelab as self-hosted relay/bootstrap
(supernode #1). Compare against the current hand-rolled RTC/LAN/SSE.
(Drops in as a `MeshEvent` source once the seam exists.)
- [x] **Milestone 1 — it builds & connects.** `examples/iroh_spike.rs`:
iroh 1.0, two key-addressed endpoints, QUIC bi-stream round-trip
in-process. `ProtocolHandler::accept``DirectUp` + inbound events;
`connect()` ≈ the dial path; `connection.remote_id()` is a durable
per-device public key — identity *is* the transport (no separate
keypair/Noise layer needed, cf. §3).
- [x] **Milestone 2 — n0's hosted relay carries us.**
`examples/iroh_relay.rs`: connect side with address lookup OFF + a
relay-only `EndpointAddr` → the connection *had* to bootstrap through
n0's relay (`use1-1.relay.n0.iroh.link`). `remote_info` then showed a
DCUtR upgrade to a direct path (relay + direct both Active) — relay
bootstrap *and* holepunch upgrade, on one machine.
- [~] Milestone 2b — true cross-NAT between two machines on different
networks. **Tester ready** (`examples/iroh_p2p.rs`): `accept` on one
machine prints its `EndpointId`; `connect <id>` from a second network
resolves via discovery, round-trips, and reports DIRECT (holepunch)
vs RELAY (fallback). Same-host smoke test passes; **awaiting a
two-network run** (Mac ⇄ phone-hotspot laptop). Then stand up the
homelab as a self-hosted iroh relay (supernode #1) and repeat.
- [x] **Milestone 3 — discovery via `iroh-gossip`.**
`examples/iroh_gossip.rs`: two endpoints derive the same topic from a
shared account (`TopicId = sha256(account)` — its first 4 bytes are
today's `room_for_account`, so the models line up), join, and learn
each other's `EndpointId` by broadcasting presence — zero hardcoded
addresses. Bootstrap = knowing one peer's `EndpointId`; iroh's
discovery (n0 DNS here, a rendezvous supernode in prod) maps id→addr.
A `GossipPresence` source would emit `MeshEvent::Present`/`Discovered`,
retiring the SSE presence bus. Confirmed: gossip compiles to WASM, so
the web client uses the same discovery unchanged (§6.3).
- [~] **Milestone 4 — `IrohTransport` landed** (`core/src/iroh.rs`, behind
the `iroh-transport` feature, off by default so the UniFFI/xcframework/
agent builds are untouched). Implements the `Transport` trait, emits
`MeshEvent`s; gossip carries clipboard `Message`s + presence over the
room-derived topic. Verified: `cargo run --features iroh-transport
--example iroh_engine` → the **real Engine** (unchanged public API)
syncs a clipboard A→B, E2E-decrypted, **no SSE/RTC/LAN/relay**. The
seam paid off — zero engine/client changes.
- [x] **Files via `iroh-blobs`**`send_file` adds the (sealed) bytes
to a `MemStore`, broadcasts a blob announcement over gossip;
peers fetch P2P by hash and emit `MeshEvent::File`. Replaces the
M/C/E frame protocol. Verified: a 200 KB photo syncs A→B
E2E-decrypted alongside text (`iroh_engine` example).
- [x] **Rendezvous for zero-config bootstrap**`rendezvous/`
(tether-rendezvous, axum): `room → {EndpointId}`, metadata-only,
content-blind. IrohTransport registers under `cfg.server` and
bootstraps gossip from the returned peers (env var now just a
test override). Verified: two engines self-discover via a local
rendezvous, sync text + photo, zero env wiring.
- [x] **iOS/Apple cross-compile + xcframework**`iroh-transport`
builds for ios-arm64 / ios-sim / macOS (needs
`IPHONEOS_DEPLOYMENT_TARGET=26.0`, now in `build-apple.sh` along
with a `TETHER_FEATURES` switch). `TETHER_FEATURES=iroh-transport
./build-apple.sh` assembles + vendors the iroh xcframework
(4,891 iroh symbols in the iOS staticlib); Swift bindings
unchanged, so the app builds with no Swift changes.
- [x] **Rendezvous auth** — optional bearer token
(`TETHER_RENDEZVOUS_TOKEN`) on register/peers; client sends it
via the same env var. Off by default (open) so the first test
needs no token; set one before public exposure. Verified 401/200.
- [ ] Remaining: deploy the rendezvous + a self-hosted iroh-relay
publicly (homelab) for the on-device cross-CGNAT test (set the
token; iOS needs a token field); per-IP rate-limiting; make
webrtc/mdns/reqwest optional for a lean iroh/wasm build (note:
also needs a wasm-compatible runtime — Engine uses tokio
multi-thread today); A/B vs RTC+LAN; flip the feature default.
- [ ] Spike **Automerge** as the Sync tier; re-express the clipboard as a tiny
app over replicated state to validate the App API.
- [ ] Durable device keypairs + authenticated handshake (Noise/`snow`, or
libsignal/MLS); seal the room key to device pubkeys.
**Horizon (the real vision — stateless P2P app backbone):**
- [ ] DHT + multi-supernode peer-relay (mostly *adopted* via Iroh/libp2p, not built).
- [ ] Rotatable rooms/keys/identities + stable-handle directory indirection.
- [ ] Private discovery (OPRF/PSI lookup; ZKP ownership proofs).
- [ ] Capability model (UCAN); offline mailbox.
- [x] **Web client = `tethercore` on `wasm32`** — relay-only iroh peer, same
core, no server bridge. **Compiles AND verified syncing**: text typed in a
browser tab lands on the Mac clipboard and vice-versa, cross-relay, with the
wasm engine running as a relay-only iroh peer (`build-web.sh`
`tethercore.wasm` + `web/index.html` demo). Native is byte-unaffected.
Two wasm-specific bugs had to be fixed to get delivery (both in the
browser-sync commit):
`Engine::send` panicked — `directly_covered()``Instant::now()` traps on
wasm32-unknown-unknown; `web-time` backs it with `Performance.now()`.
• Gossip island — a node that bootstrapped its topic alone never pulled
later-joining peers into its active view; the rendezvous re-register loop
now seeds peer relay addrs + `GossipSender::join_peers` each round.
- [x] Deps target-split (native-only transports/UniFFI/native-reqwest under
`cfg(not(wasm32))`; wasm gets fetch-reqwest + wasm-bindgen-futures).
- [x] **UniFFI decoupled** — all FFI macros `cfg_attr`'d to native; wasm
will bind via wasm-bindgen.
- [x] **iroh on wasm**`default-features=false` + `tls-ring` (ring builds
for wasm with a wasm clang) + `iroh-gossip/net`; getrandom `wasm_js`.
- [x] **Runtime abstraction** — a `Spawner` replaces the tokio `Handle`
across `Transport`/Engine: tokio runtime on native, `spawn_local` on
wasm. IrohTransport's timers use `n0-future` (no tokio::time on wasm).
- [x] reqwest `.timeout()` cfg'd to native.
- [x] **wasm-bindgen JS surface + browser shim**`WasmEngine` +
`roomForAccount` exposed to JS (`src/wasm.rs`); `web/index.html` is the
smoke test. Relay-only in-browser (no holepunch in the sandbox) —
confirmed: browser↔Mac clipboard syncs both ways over the n0 relay.
- [ ] Toolchain note: the wasm build needs a wasm-capable clang for ring
(Homebrew LLVM) + the getrandom rustflag — both wired in `build-web.sh`.
- [ ] **Retire the Go server** once IrohTransport wins — replace with a
self-hosted iroh-relay (supernode #1) + a tiny gossip-bootstrap rendezvous.
---
## 10. One-line summary
> **Peers with durable keypairs · E2E content · pluggable transports
> (LAN/RTC/QUIC/BT/LoRa) · supernodes volunteering signaling/relay/DHT/discovery
> · CRDT state sync · clipboard as app #1.** Built on Iroh + Automerge,
> differentiated on the "just works" DX. The network is the product; the
> clipboard is the proof.

View File

@@ -1,10 +1,28 @@
# tether # tether
Phone ↔ laptop clipboard relay. **v0.3 — WebRTC P2P working.** Universal clipboard. **Shared Rust engine + native clients on every platform.**
Today: an HTTP+SSE broadcast bus that also bootstraps WebRTC DataChannels An HTTP+SSE broadcast bus that also bootstraps WebRTC DataChannels between
between participants. Once paired, clipboard text flows direct participants. Once paired, clipboard text flows direct peer-to-peer with DTLS
peer-to-peer with DTLS encryption — the server never sees the payload. encryption — the server never sees the payload.
The wire protocol (codec + SSE + RTC + reconnect) lives once, in a Rust crate
**`core/` (`tethercore`)**, exposed via UniFFI. Every native client — iOS,
macOS, the desktop agent, Android — drives that same engine; only the UI and
binding language differ. The browser client and Go server interoperate over the
same wire format.
```
┌──────────────── tethercore (Rust) ────────────────┐
│ Message codec · Transport trait · dedup │
│ ├─ SSE (reqwest) reliable relay floor │
│ ├─ RTC (webrtc-rs) direct P2P data channel │
│ └─ LAN (mDNS + TCP) serverless same-network │
└───────────────────────────────────────────────────┘
iOS (Swift) ─ macOS (Swift) ─ agent (Rust, Linux/Win/Mac) ─ Android (Kotlin)
└──── all via UniFFI bindings ────┘
browser (JS, native WebRTC) ── interoperates over the wire ──┘
```
The roadmap is what makes it interesting: symmetric presence chirps for The roadmap is what makes it interesting: symmetric presence chirps for
self-healing mesh discovery, Sign in with Apple for cross-device self-healing mesh discovery, Sign in with Apple for cross-device
@@ -42,14 +60,22 @@ the buttons.
## Pieces ## Pieces
- `server/` — single Go binary. Embedded HTML page. Exposes: **Server & web (Go / JS)**
- `GET /` phone UI - `server/` — single Go binary. Embedded HTML page. Exposes `GET /` (phone UI),
- `POST /api/send` — accept a message `POST /api/send`, `GET /api/stream` (SSE), `GET /healthz`, `/metrics`.
- `GET /api/stream` — SSE feed of every published message - `server/web/index.html` — browser client (paste, send, live feed; native WebRTC).
- `GET /healthz` - `client/` — Go CLI client (`atotto/clipboard`), `-send` for one-shot.
- `client/` — CLI client. Subscribes to `/api/stream`, prints received
messages to stdout. `-send` for one-shot send. **Shared engine & native clients (Rust / Swift / Kotlin)**
- `server/web/index.html` — phone UI (paste, send, live feed of incoming). - `core/``tethercore`, the Rust sync engine (SSE + RTC behind a `Transport`
trait, dedup, reconnect). Exposed via UniFFI; `build-apple.sh` /
`build-android.sh` emit the xcframework / `.aar` artifacts. `examples/` prove
it end-to-end.
- `ios/TetherApp` — SwiftUI iOS client on tethercore.
- `ios/MacTetherApp` — SwiftUI macOS client on tethercore (window + menu bar).
- `ios/TetherKit` — thin SPM package, now just `RoomIdentity` (platform room-id).
- `agent/` — headless Rust daemon (tethercore + `arboard`) for macOS/Linux/Windows.
- `android/` — Jetpack Compose client on tethercore (Kotlin/UniFFI).
## Roadmap ## Roadmap

1
agent/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target/

6746
agent/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
agent/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "tether-agent"
version = "0.1.0"
edition = "2021"
description = "Headless tether clipboard agent: shared tethercore engine + native clipboard (arboard). Runs on macOS, Linux, Windows."
[[bin]]
name = "tether-agent"
path = "src/main.rs"
[dependencies]
tethercore = { path = "../core" }
arboard = "3"
[features]
default = []
# Build the agent on the iroh Mesh: `cargo build --features iroh`. Forwards to
# tethercore's iroh-transport, so the agent's --server becomes the rendezvous URL.
iroh = ["tethercore/iroh-transport"]

20
agent/README.md Normal file
View File

@@ -0,0 +1,20 @@
# tether-agent
Headless clipboard sync daemon on the shared `tethercore` engine (SSE + RTC +
LAN, prefer-direct E2E). One Rust binary for macOS / Linux / Windows.
```sh
tether-agent # run in the foreground (Ctrl-C to stop)
tether-agent install # run on login as a background service
tether-agent uninstall # remove the service
tether-agent status # service status
# config (applies to run + install; account derives the shared room):
tether-agent install --account you@example.com --name "Patrick's NAS"
```
- **macOS**: a launchd LaunchAgent at `~/Library/LaunchAgents/io.pecord.tether-agent.plist`
(logs → `~/Library/Logs/tether-agent.log`).
- **Linux**: a systemd user unit at `~/.config/systemd/user/tether-agent.service`
(`loginctl enable-linger` to start at boot without login).
- Defaults: `--server https://tether.pecord.io`, account-derived room, hostname as name.

397
agent/src/main.rs Normal file
View File

@@ -0,0 +1,397 @@
//! tether-agent — headless clipboard sync for desktops.
//!
//! The same shared `tethercore` engine that backs the native apps, wired to the
//! OS clipboard via `arboard`. One Rust binary covers macOS, Linux, and Windows.
//! No UI; meant to run as a login/service daemon.
//!
//! tether-agent --server http://host:8765 --room <id> [--source label]
//!
//! Default build uses the legacy transports; `--features iroh` builds it on the
//! iroh Mesh (then --server is the rendezvous URL).
//!
//! Inbound clipboard from the room is written to the local clipboard (§8 Option
//! A) and fires a non-blocking provenance toast ("Copied from <device>") so the
//! clobber is never silent; local clipboard changes are published. Echo is
//! suppressed by tracking the last value we set or sent.
use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::Duration;
use arboard::Clipboard;
use tethercore::{Engine, Message, MessageHandler};
/// An inbound clipboard with its provenance (who it came from), queued for the
/// main loop to apply to the OS clipboard.
struct Inbound {
text: String,
source: String, // sender's platform/device label, for the provenance toast
}
/// Inbound sink: the engine calls this on its runtime thread; we just queue the
/// item for the main loop, which owns the (non-Send) clipboard handle.
struct Inbox(Arc<Mutex<Vec<Inbound>>>);
impl MessageHandler for Inbox {
fn on_message(&self, msg: Message) {
eprintln!("← [{}] {}", msg.source, preview(&msg.text));
self.0.lock().unwrap().push(Inbound { text: msg.text, source: msg.source });
}
fn on_status(&self, connected: bool) {
eprintln!("{}", if connected { "connected" } else { "disconnected" });
}
fn on_file(&self, name: String, mime: String, data: Vec<u8>) {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
let safe: String = name
.chars()
.filter(|c| c.is_alphanumeric() || ".-_ ".contains(*c))
.collect();
let path = format!(
"{home}/Downloads/{}",
if safe.is_empty() { "tether-file".into() } else { safe }
);
match std::fs::write(&path, &data) {
Ok(_) => eprintln!("⬇ saved {path} ({} bytes, {mime})", data.len()),
Err(e) => eprintln!("{name} ({} bytes) — save failed: {e}", data.len()),
}
}
}
/// §8 provenance: a non-blocking native toast when a remote clipboard clobbers
/// the local one, so it's never silent ("Copied from <device>"). The mandatory
/// awareness for Option A. Best-effort — never blocks the sync loop.
fn notify(source: &str, text: &str) {
let who = friendly(source);
let body = preview(text);
#[cfg(target_os = "macos")]
{
// AppleScript notification — escape quotes/backslashes for the -e string.
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
let script = format!(
"display notification \"{}\" with title \"tether\" subtitle \"Copied from {}\"",
esc(&body), esc(&who)
);
let _ = std::process::Command::new("osascript").args(["-e", &script]).status();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("notify-send")
.args([&format!("tether — copied from {who}"), &body])
.status();
}
#[cfg(target_os = "windows")]
{
// Toast via PowerShell + WinRT — no extra modules on Win10/11.
let esc = |s: &str| s.replace('\'', "''"); // PowerShell single-quote escape
let script = format!(
"[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]>$null;\
[Windows.UI.Notifications.ToastNotification,Windows.UI.Notifications,ContentType=WindowsRuntime]>$null;\
$tpl=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);\
$t=$tpl.GetElementsByTagName('text');\
$t[0].InnerText='Copied from {who}';$t[1].InnerText='{body}';\
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('tether').Show([Windows.UI.Notifications.ToastNotification]::new($tpl))",
who = esc(&who),
body = esc(&body),
);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let _ = (who, body);
}
/// Map a source/platform label to something friendlier for the toast.
fn friendly(source: &str) -> String {
let s = source.to_lowercase();
if s.contains("iphone") || s.contains("ios") { "iPhone".into() }
else if s.contains("ipad") { "iPad".into() }
else if s.contains("mac") { "Mac".into() }
else if s.contains("windows") { "Windows".into() }
else if s.contains("android") { "Android".into() }
else if s.is_empty() { "another device".into() }
else { source.to_string() }
}
fn preview(s: &str) -> String {
let t = s.replace('\n', " ");
if t.chars().count() > 60 {
format!("{}", t.chars().take(60).collect::<String>())
} else {
t
}
}
fn arg(flag: &str, default: &str) -> String {
let a: Vec<String> = std::env::args().collect();
a.iter()
.position(|x| x == flag)
.and_then(|i| a.get(i + 1))
.cloned()
.unwrap_or_else(|| default.to_string())
}
struct AgentConfig {
server: String,
room: String,
source: String,
from: String,
name: String,
account: String,
}
fn config() -> AgentConfig {
let server = arg("--server", "https://tether.pecord.io");
let source = arg("--source", "agent");
let from = arg("--from", &format!("agent-{source}"));
let host = std::env::var("HOSTNAME").unwrap_or_else(|_| "tether-agent".into());
let name = arg("--name", &host);
// Shared identity: same-account devices pair on the LAN regardless of room,
// and share an account-derived room everywhere (so cross-network works too).
let account = arg("--account", "pecord@gmail.com");
let default_room = if account.is_empty() {
"default".to_string()
} else {
tethercore::room_for_account(account.clone())
};
let room = arg("--room", &default_room);
AgentConfig { server, room, source, from, name, account }
}
fn main() {
match std::env::args().nth(1).as_deref() {
Some("install") => install(),
Some("uninstall") => uninstall(),
Some("status") => status(),
Some("send-file") => send_file_once(),
// No subcommand (or --flags) → run in the foreground.
_ => run(),
}
}
/// `tether-agent send-file <path>` — connect, wait for a direct link, send the
/// file, exit. Handy for testing / one-shot sharing.
fn send_file_once() {
let path = std::env::args()
.nth(2)
.expect("usage: tether-agent send-file <path> [--server URL --account ID]");
let data = std::fs::read(&path).expect("read file");
let fname = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".into());
let mime = match path.rsplit('.').next().map(str::to_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("pdf") => "application/pdf",
_ => "application/octet-stream",
}
.to_string();
let c = config();
let engine = Engine::new(c.server, c.room, c.from, c.source, c.name, c.account);
engine.start(Box::new(Inbox(Arc::new(Mutex::new(Vec::new())))));
eprintln!("connecting + waiting for a direct link…");
sleep(Duration::from_secs(10));
eprintln!("sending {fname} ({} bytes, {mime})…", data.len());
engine.send_file(fname, mime, data);
sleep(Duration::from_secs(5)); // let chunks flush
eprintln!("done");
}
fn run() {
let c = config();
eprintln!("tether-agent → {} room={} source={}", c.server, c.room, c.source);
let inbox = Arc::new(Mutex::new(Vec::<Inbound>::new()));
let engine = Engine::new(c.server, c.room, c.from, c.source, c.name, c.account);
engine.start(Box::new(Inbox(inbox.clone())));
let mut clip = Clipboard::new().expect("open system clipboard");
let mut last = clip.get_text().unwrap_or_default();
loop {
// Apply inbound: received text → OS clipboard (Option A, §8). Each remote
// clobber fires a non-blocking provenance toast so it's never invisible.
let pending: Vec<Inbound> = std::mem::take(&mut *inbox.lock().unwrap());
for item in pending {
if item.text != last {
let _ = clip.set_text(item.text.clone());
notify(&item.source, &item.text);
last = item.text;
}
}
// Detect a local change → publish to the room.
if let Ok(cur) = clip.get_text() {
if !cur.is_empty() && cur != last {
last = cur.clone();
eprintln!("{}", preview(&cur));
engine.send(cur);
}
}
sleep(Duration::from_millis(500));
}
}
// ── Service install (runs the agent on login as a background daemon) ─────────
/// `run` + the resolved config, baked into the service so it's self-contained.
fn service_args() -> Vec<String> {
let c = config();
vec![
"run".into(),
"--server".into(), c.server,
"--account".into(), c.account,
"--name".into(), c.name,
"--room".into(), c.room,
"--source".into(), c.source,
"--from".into(), c.from,
]
}
fn install() {
let exe = std::env::current_exe()
.expect("current exe")
.to_string_lossy()
.into_owned();
let args = service_args();
#[cfg(target_os = "macos")]
install_launchd(&exe, &args);
#[cfg(target_os = "linux")]
install_systemd(&exe, &args);
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = (&exe, &args);
eprintln!("install isn't wired for this OS yet — run `tether-agent` directly.");
}
}
fn uninstall() {
#[cfg(target_os = "macos")]
{
let plist = launchd_plist_path();
let _ = std::process::Command::new("launchctl").args(["unload", &plist]).status();
let _ = std::fs::remove_file(&plist);
println!("✅ uninstalled launchd agent.");
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("systemctl")
.args(["--user", "disable", "--now", "tether-agent"])
.status();
let _ = std::fs::remove_file(systemd_unit_path());
println!("✅ uninstalled systemd user service.");
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
eprintln!("nothing to uninstall on this OS.");
}
fn status() {
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("launchctl")
.args(["list", "io.pecord.tether-agent"])
.status();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("systemctl")
.args(["--user", "status", "tether-agent"])
.status();
}
}
#[cfg(target_os = "macos")]
fn launchd_plist_path() -> String {
format!(
"{}/Library/LaunchAgents/io.pecord.tether-agent.plist",
std::env::var("HOME").unwrap()
)
}
#[cfg(target_os = "macos")]
fn install_launchd(exe: &str, args: &[String]) {
let home = std::env::var("HOME").unwrap();
std::fs::create_dir_all(format!("{home}/Library/LaunchAgents")).ok();
let path = launchd_plist_path();
let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
let mut program = format!(" <string>{}</string>\n", esc(exe));
for a in args {
program.push_str(&format!(" <string>{}</string>\n", esc(a)));
}
let log = format!("{home}/Library/Logs/tether-agent.log");
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>io.pecord.tether-agent</string>
<key>ProgramArguments</key>
<array>
{program} </array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>{log}</string>
<key>StandardErrorPath</key><string>{log}</string>
</dict>
</plist>
"#
);
std::fs::write(&path, plist).expect("write plist");
// Replace any prior load (quietly — errors if not loaded, which is fine).
let _ = std::process::Command::new("launchctl")
.args(["unload", &path])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let ok = std::process::Command::new("launchctl")
.args(["load", "-w", &path])
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
println!("✅ installed: {path}\n runs now and on every login · logs → {log}\n stop with: tether-agent uninstall");
} else {
eprintln!("wrote {path} but `launchctl load` failed");
}
}
#[cfg(target_os = "linux")]
fn systemd_unit_path() -> String {
format!(
"{}/.config/systemd/user/tether-agent.service",
std::env::var("HOME").unwrap()
)
}
#[cfg(target_os = "linux")]
fn install_systemd(exe: &str, args: &[String]) {
std::fs::create_dir_all(format!(
"{}/.config/systemd/user",
std::env::var("HOME").unwrap()
))
.ok();
let path = systemd_unit_path();
// Quote each token so names with spaces survive systemd's ExecStart parsing.
let exec = std::iter::once(exe.to_string())
.chain(args.iter().cloned())
.map(|a| format!("\"{}\"", a.replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(" ");
let unit = format!(
"[Unit]\nDescription=tether clipboard agent\nAfter=network-online.target\n\n[Service]\nExecStart={exec}\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n"
);
std::fs::write(&path, unit).expect("write unit");
let _ = std::process::Command::new("systemctl").args(["--user", "daemon-reload"]).status();
let ok = std::process::Command::new("systemctl")
.args(["--user", "enable", "--now", "tether-agent"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
println!("✅ installed: {path}\n runs now and on login (`loginctl enable-linger` for boot w/o login)\n stop with: tether-agent uninstall");
} else {
eprintln!("wrote {path} but `systemctl enable` failed");
}
}

11
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Gradle / Android Studio
.gradle/
build/
local.properties
.idea/
*.iml
captures/
# Generated by core/build-android.sh — regenerate, don't commit
app/src/main/jniLibs/
app/src/main/java/uniffi/

35
android/README.md Normal file
View File

@@ -0,0 +1,35 @@
# tether — Android client
Compose UI over the shared `tethercore` Rust engine (SSE + RTC) via UniFFI
Kotlin bindings. Same engine as the iOS/macOS apps and the desktop agent; only
the UI and the binding language differ.
## Build (requires the Android NDK)
This machine had no Android toolchain when the app was scaffolded, so the
native `.so` and Kotlin bindings are **not** committed — they're generated:
1. Install the toolchain:
- Android Studio (SDK + NDK), or `sdkmanager "ndk;26.x"`
- `cargo install cargo-ndk`
- `rustup target add aarch64-linux-android x86_64-linux-android`
- export `ANDROID_NDK_HOME=/path/to/ndk`
2. Generate native libs + bindings:
```sh
../core/build-android.sh
```
This places `libtethercore.so` under `app/src/main/jniLibs/<abi>/` and the
Kotlin binding under `app/src/main/java/uniffi/tethercore/`.
3. Build/run the app: open `android/` in Android Studio (it generates the
Gradle wrapper on first sync), then Run. From the CLI, generate the wrapper
once with `gradle wrapper`, then `./gradlew :app:assembleDebug`.
## Notes
- Default server is `http://10.0.2.2:8765` — the emulator's alias for the host
machine's `localhost`. On a physical device use the relay's LAN address.
- Android (unlike iOS) lets a foreground app observe clipboard changes, so the
app auto-sends via `OnPrimaryClipChangedListener`. A foreground Service would
extend that to the background (future work).
- The room id is a per-install UUID (`RoomIdentity`); account-derived shared
rooms are future work, matching the iOS plan.

View File

@@ -0,0 +1,48 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "io.pecord.tether"
compileSdk = 35
defaultConfig {
applicationId = "io.pecord.tether"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// Limit to the ABIs core/build-android.sh produces.
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { compose = true }
// Compose compiler comes from the org.jetbrains.kotlin.plugin.compose plugin
// (Kotlin 2.0+); no kotlinCompilerExtensionVersion needed.
// jniLibs/<abi>/libtethercore.so are placed by core/build-android.sh
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
// UniFFI Kotlin runtime requirements.
implementation("net.java.dev.jna:jna:5.14.0@aar")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="tether"
android:supportsRtl="true"
android:theme="@style/Theme.Material3.DynamicColors.DayNight">
<!-- Cleartext to the LAN relay (http://). Tighten for production. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:usesCleartextTraffic="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,86 @@
package io.pecord.tether
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val store = TetherStore(applicationContext)
setContent { MaterialTheme { RoomScreen(store) } }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomScreen(store: TetherStore) {
Scaffold(topBar = { TopAppBar(title = { Text("tether") }) }) { pad ->
Column(
Modifier.padding(pad).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = store.serverURL.value,
onValueChange = { store.serverURL.value = it },
label = { Text("Server URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = store.room.value,
onValueChange = { store.room.value = it },
label = { Text("Room") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(onClick = {
if (store.connected.value) store.disconnect() else store.connect()
}) {
Text(if (store.connected.value) "Disconnect" else "Connect")
}
}
Button(
onClick = { store.sendCurrentClipboard() },
enabled = store.connected.value,
modifier = Modifier.fillMaxWidth(),
) { Text("Send clipboard") }
HorizontalDivider()
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
items(store.feed, key = { it.id }) { item ->
Card(
onClick = { store.copyToClipboard(item) },
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(10.dp)) {
Text(item.text, maxLines = 4)
Text(item.source, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
package io.pecord.tether
import android.content.Context
import java.util.UUID
/// Stable 8-hex room id with no user configuration — the Android counterpart to
/// the iOS Keychain UUID. Generated once, persisted in SharedPreferences.
/// SiwA/account-derived identity can replace this later for cross-device rooms.
object RoomIdentity {
fun derive(context: Context): String {
val prefs = context.getSharedPreferences("tether", Context.MODE_PRIVATE)
prefs.getString("roomID", null)?.let { return it }
val id = UUID.randomUUID().toString().filter { it.isDigit() || it in 'a'..'f' }.take(8)
prefs.edit().putString("roomID", id).apply()
return id
}
}

View File

@@ -0,0 +1,106 @@
package io.pecord.tether
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import uniffi.tethercore.Engine
import uniffi.tethercore.Message
import uniffi.tethercore.MessageHandler
data class FeedItem(val text: String, val source: String, val ts: Long, val id: Long)
/// Drives the shared tethercore.Engine (SSE + RTC) and bridges it to the Android
/// clipboard. Unlike iOS, Android lets a foreground app observe clipboard
/// changes, so we auto-send via OnPrimaryClipChangedListener.
class TetherStore(private val context: Context) {
val serverURL = mutableStateOf(prefs().getString("serverURL", "https://tether.pecord.io")!!)
val account = mutableStateOf(prefs().getString("account", "pecord@gmail.com")!!)
// Room follows the account → all our devices share one room everywhere.
val room = mutableStateOf(
if (account.value.isEmpty()) RoomIdentity.derive(context)
else uniffi.tethercore.roomForAccount(account.value)
)
val feed = mutableStateListOf<FeedItem>()
val connected = mutableStateOf(false)
private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
private val main = Handler(Looper.getMainLooper())
private val deviceId = "android-" + RoomIdentity.derive(context)
private var engine: Engine? = null
private var lastClip: String = ""
private var counter = 0L
fun connect() {
prefs().edit().putString("serverURL", serverURL.value).putString("account", account.value).apply()
disconnect()
val e = Engine(serverURL.value.trim(), room.value, deviceId, "android", android.os.Build.MODEL, account.value)
e.start(Bridge())
engine = e
connected.value = true
clipboard.addPrimaryClipChangedListener(clipListener)
}
fun disconnect() {
clipboard.removePrimaryClipChangedListener(clipListener)
engine?.stop()
engine?.close()
engine = null
connected.value = false
}
fun sendCurrentClipboard() {
currentClipText()?.takeIf { it.isNotEmpty() }?.let { engine?.send(it) }
}
fun copyToClipboard(item: FeedItem) = writeClipboard(item.text)
private val clipListener = ClipboardManager.OnPrimaryClipChangedListener {
val t = currentClipText() ?: return@OnPrimaryClipChangedListener
if (t.isNotEmpty() && t != lastClip) {
lastClip = t
engine?.send(t)
}
}
private fun currentClipText(): String? =
clipboard.primaryClip?.takeIf { it.itemCount > 0 }
?.getItemAt(0)?.coerceToText(context)?.toString()
private fun writeClipboard(text: String) {
lastClip = text // suppress echo
clipboard.setPrimaryClip(ClipData.newPlainText("tether", text))
}
private fun prefs() = context.getSharedPreferences("tether", Context.MODE_PRIVATE)
/// Engine callbacks fire on the Rust runtime thread — marshal to main.
private inner class Bridge : MessageHandler {
override fun onMessage(msg: Message) {
main.post {
feed.add(
0,
FeedItem(
msg.text,
msg.source.ifEmpty { "unknown" },
if (msg.ts > 0) msg.ts else System.currentTimeMillis(),
counter++,
),
)
if (feed.size > 100) feed.removeAt(feed.size - 1)
writeClipboard(msg.text) // mirror received clipboard locally
}
}
override fun onStatus(connected: Boolean) {
main.post { this@TetherStore.connected.value = connected }
}
override fun onFile(name: String, mime: String, data: ByteArray) {
// File receive UI is a follow-up; engine handles transport + reassembly.
}
}
}

5
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
}

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "tether"
include(":app")

4
core/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target/
/bindings/
/build-xcframework/
/*.xcframework

6502
core/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

89
core/Cargo.toml Normal file
View File

@@ -0,0 +1,89 @@
[package]
name = "tethercore"
version = "0.1.0"
edition = "2021"
description = "Shared tether sync engine: codec + SSE client + reconnect, FFI-exported via UniFFI."
[lib]
# lib → examples/tests can link it natively
# staticlib → iOS .a / desktop agent
# cdylib → Android .so / bindgen library mode
crate-type = ["lib", "staticlib", "cdylib"]
name = "tethercore"
[[bin]]
name = "uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"
# Common, wasm-safe dependencies (no OS sockets / threads).
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures-util = "0.3"
sha2 = "0.11.0"
bytes = "1.12.0"
chacha20poly1305 = "0.10.1"
base64 = "0.22.1"
# n0-future: iroh's cross-platform async (works native + wasm) — used for the
# IrohTransport's timers so it doesn't depend on tokio's time on wasm.
n0-future = { version = "0.3", optional = true }
[features]
default = []
iroh-transport = ["dep:iroh", "dep:iroh-gossip", "dep:iroh-blobs", "dep:n0-future"]
# ── Native (desktop/mobile): legacy transports + UniFFI + full tokio ──
# The hand-rolled SSE/RTC/LAN stack and its OS-socket deps live here; none of it
# compiles to wasm. iroh (behind the feature) takes full default features.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
uniffi = { version = "0.28", features = ["cli"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
tokio = { version = "1", features = ["rt-multi-thread", "time", "sync", "macros", "net", "io-util"] }
webrtc = "0.17.1"
mdns-sd = "0.20.0"
getrandom = "0.4.3"
iroh = { version = "1", optional = true }
iroh-gossip = { version = "0.101", optional = true }
iroh-blobs = { version = "0.103", optional = true }
# ── wasm32 (browser): iroh-only, no OS sockets / UniFFI ──
# Browser iroh is relay-only; reqwest uses fetch; the runtime is the JS event
# loop (wasm-bindgen-futures) instead of a tokio runtime.
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", features = ["sync", "macros"] }
reqwest = { version = "0.12", default-features = false, features = ["json"] }
# wasm rng: getrandom 0.4 uses a build-cfg (--cfg getrandom_backend="wasm_js"),
# but a transitive getrandom 0.2 needs the `js` *feature* — enable it via the
# rename-alias trick (cargo unifies it with the transitive copy).
getrandom = "0.4.3"
getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] }
wasm-bindgen-futures = "0.4"
wasm-bindgen = "0.2"
js-sys = "0.3"
# Drop-in Instant for the browser: std::time::Instant::now() panics ("time not
# implemented") on wasm32-unknown-unknown. web_time backs it with Performance.now()
# (and re-exports std on native, so native is unaffected — it isn't even linked there).
web-time = "1"
# Browser diagnostics: surface Rust panics + iroh's tracing logs in the console.
console_error_panic_hook = "0.1"
tracing-wasm = "0.2"
tracing = "0.1"
# Browser iroh: default-features off (drops native portmapper/apple-datapath),
# but keep tls-ring — ring works on wasm and presets::N0 needs it. (Matches n0's
# own browser-chat example.)
iroh = { version = "1", optional = true, default-features = false, features = ["tls-ring"] }
iroh-gossip = { version = "0.101", optional = true, default-features = false, features = ["net"] }
iroh-blobs = { version = "0.103", optional = true, default-features = false }
[dev-dependencies]
# Iroh spikes (examples/iroh_*.rs) link iroh directly regardless of the feature.
iroh = "1"
iroh-gossip = "0.101"
anyhow = "1"
# The M4 integration example drives the real Engine over IrohTransport, so it
# needs the feature (and Engine::iroh_id, which only exists under it).
[[example]]
name = "iroh_engine"
required-features = ["iroh-transport"]

37
core/build-android.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Builds tethercore for Android and drops the artifacts the Gradle app needs:
# - libtethercore.so per ABI → app/src/main/jniLibs/<abi>/
# - Kotlin bindings → app/src/main/java/uniffi/tethercore/
#
# Requires the Android NDK + cargo-ndk (cargo install cargo-ndk) and
# ANDROID_NDK_HOME pointing at the NDK. Outputs are git-ignored.
set -euo pipefail
cd "$(dirname "$0")"
APP="../android/app/src/main"
ABIS=(arm64-v8a x86_64)
# Optional Cargo features, e.g. TETHER_FEATURES=iroh-transport for the iroh core.
# Plain string (not an array) for macOS bash 3.2 + `set -u`.
FEATURES="${TETHER_FEATURES:-}"
FEAT_ARGS=""
[ -n "$FEATURES" ] && FEAT_ARGS="--features $FEATURES" && echo "▸ features: $FEATURES"
if ! command -v cargo-ndk >/dev/null; then
echo "missing cargo-ndk → cargo install cargo-ndk; also install the Android NDK and set ANDROID_NDK_HOME" >&2
exit 1
fi
echo "▸ building .so for ${ABIS[*]}"
rustup target add aarch64-linux-android x86_64-linux-android >/dev/null 2>&1 || true
# shellcheck disable=SC2086 # intentional word-split of FEAT_ARGS
cargo ndk -t arm64-v8a -t x86_64 -o "$APP/jniLibs" build --release $FEAT_ARGS
echo "▸ generating Kotlin bindings…"
# shellcheck disable=SC2086
cargo build --release --lib $FEAT_ARGS
cargo run --release --bin uniffi-bindgen -- \
generate --library target/release/libtethercore.dylib \
--language kotlin --out-dir "$APP/java"
echo "✅ done — open ../android in Android Studio (or ./gradlew :app:assembleDebug)."

60
core/build-apple.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Builds the tethercore Rust engine for Apple targets and refreshes the
# artifacts both Xcode apps consume: the Swift bindings + a single
# TetherCore.xcframework (iOS device + iOS sim + macOS arm64).
# Outputs are git-ignored / regenerated — run after any change to core/src,
# then build the apps in Xcode.
set -euo pipefail
cd "$(dirname "$0")"
IOS_APP="../ios/TetherApp"
MAC_APP="../ios/MacTetherApp"
IOS=aarch64-apple-ios
SIM=aarch64-apple-ios-sim
MAC=aarch64-apple-darwin
# Deployment targets — must match the apps (iOS 26). Without this the linker
# defaults to iOS 10 and the cdylib fails to link against ring/blake3's prebuilt
# assembly objects (built for the newer SDK).
export IPHONEOS_DEPLOYMENT_TARGET=26.0
export MACOSX_DEPLOYMENT_TARGET=26.0
# Optional Cargo features, e.g. TETHER_FEATURES=iroh-transport to build the
# iroh-backed core. Empty → the default (legacy SSE/RTC/LAN) build. Plain string
# (not an array) so macOS bash 3.2 + `set -u` doesn't choke when it's empty.
FEATURES="${TETHER_FEATURES:-}"
FEAT_ARGS=""
[ -n "$FEATURES" ] && FEAT_ARGS="--features $FEATURES" && echo "▸ features: $FEATURES"
echo "▸ building static libs ($IOS, $SIM, $MAC)…"
rustup target add "$IOS" "$SIM" "$MAC" >/dev/null 2>&1 || true
# shellcheck disable=SC2086 # intentional word-split of FEAT_ARGS
cargo build --release --target "$IOS" --lib $FEAT_ARGS
cargo build --release --target "$SIM" --lib $FEAT_ARGS
cargo build --release --target "$MAC" --lib $FEAT_ARGS
echo "▸ generating Swift bindings…"
cargo run --release --bin uniffi-bindgen -- \
generate --library "target/$MAC/release/libtethercore.dylib" \
--language swift --out-dir bindings/swift
echo "▸ assembling TetherCore.xcframework (ios + ios-sim + macos)…"
rm -rf build-xcframework && mkdir -p build-xcframework/headers
cp bindings/swift/tethercoreFFI.h build-xcframework/headers/
cp bindings/swift/tethercoreFFI.modulemap build-xcframework/headers/module.modulemap
rm -rf TetherCore.xcframework
xcodebuild -create-xcframework \
-library "target/$IOS/release/libtethercore.a" -headers build-xcframework/headers \
-library "target/$SIM/release/libtethercore.a" -headers build-xcframework/headers \
-library "target/$MAC/release/libtethercore.a" -headers build-xcframework/headers \
-output TetherCore.xcframework
echo "▸ vendoring into both apps…"
for APP in "$IOS_APP" "$MAC_APP"; do
rm -rf "$APP/Vendor/TetherCore.xcframework"
mkdir -p "$APP/Vendor"
cp -R TetherCore.xcframework "$APP/Vendor/"
cp bindings/swift/tethercore.swift "$APP/Sources/tethercore.swift"
done
echo "✅ done — build TetherApp (iOS) and MacTetherApp (macOS) in Xcode."

34
core/build-web.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Builds tethercore for the browser (wasm32). The core compiles to wasm with the
# iroh Mesh (relay-only in-browser). The NEXT step — wasm-bindgen JS bindings +
# a browser shim for the web app — is still TODO; this just produces the .wasm.
#
# Requires:
# rustup target add wasm32-unknown-unknown
# Homebrew LLVM (`brew install llvm`) for a wasm-capable clang — ring's build
# script compiles C for wasm and Apple's clang has no wasm backend.
set -euo pipefail
cd "$(dirname "$0")"
# A clang/llvm-ar that can target wasm (override with LLVM_PREFIX if elsewhere).
LLVM="${LLVM_PREFIX:-/opt/homebrew/opt/llvm}"
export CC_wasm32_unknown_unknown="$LLVM/bin/clang"
export AR_wasm32_unknown_unknown="$LLVM/bin/llvm-ar"
# getrandom has no OS rng in the browser — use its wasm_js backend.
export RUSTFLAGS="${RUSTFLAGS:-} --cfg getrandom_backend=\"wasm_js\""
rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true
PROFILE="${PROFILE:-release}"
echo "▸ building tethercore for wasm32 ($PROFILE; iroh, relay-only in-browser)…"
cargo build --target wasm32-unknown-unknown --features iroh-transport --lib \
$([ "$PROFILE" = release ] && echo --release) "$@"
WASM="target/wasm32-unknown-unknown/$PROFILE/tethercore.wasm"
if command -v wasm-bindgen >/dev/null; then
echo "▸ generating JS bindings → web/pkg/ (wasm-bindgen)…"
wasm-bindgen "$WASM" --out-dir web/pkg --target web
echo "✅ web/pkg/tethercore.js + tethercore_bg.wasm — import into the browser app."
else
echo "⚠ wasm-bindgen CLI not found — built $WASM but skipped JS bindings."
echo " install: cargo install wasm-bindgen-cli --version <match Cargo.lock>"
fi

View File

@@ -0,0 +1,51 @@
//! Proves account-based LAN pairing: two devices in DIFFERENT rooms but with the
//! SAME account auto-connect over the LAN and sync — no shared room, no server.
//! cargo run --example account_pair
//!
//! This is the "same account, same network → just pair" behavior.
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Collector(mpsc::Sender<Message>);
impl MessageHandler for Collector {
fn on_message(&self, msg: Message) {
let _ = self.0.send(msg);
}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
let dead = "http://127.0.0.1:1".to_string(); // no server — LAN only
let account = "patrick@example.com"; // same identity on both devices
let (tx, rx) = mpsc::channel();
// Note the DIFFERENT rooms — they should still pair via the shared account.
let a = Engine::new(dead.clone(), "room-alpha".into(), "dev-a".into(), "macos".into(), "Mac".into(), account.into());
a.start(Box::new(Collector(tx)));
let b = Engine::new(dead, "room-bravo".into(), "dev-b".into(), "ios".into(), "Phone".into(), account.into());
b.start(Box::new(Collector(mpsc::channel().0)));
eprintln!("different rooms, same account — waiting for LAN pair…");
std::thread::sleep(Duration::from_secs(5));
let payload = "synced-by-account";
b.send(payload.to_string());
match rx.recv_timeout(Duration::from_secs(6)) {
Ok(m) if m.text == payload => {
println!("✅ same-account devices in different rooms synced over LAN: {:?}", m.text);
}
Ok(m) => println!("⚠️ unexpected: {m:?}"),
Err(_) => {
println!("❌ no sync — account pairing failed");
std::process::exit(1);
}
}
a.stop();
b.stop();
}

66
core/examples/e2e_demo.rs Normal file
View File

@@ -0,0 +1,66 @@
//! Proves payload E2E encryption.
//! cargo run --example e2e_demo -- <room> # round-trip (A→B decrypts)
//! SOLO=1 cargo run --example e2e_demo -- <room> # one sender, no peer →
//! relay carries ciphertext
//! In SOLO mode, subscribe to the room on the server and confirm the secret is
//! NOT visible (only base64 ciphertext in the text field).
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Noop;
impl MessageHandler for Noop {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _n: String, _m: String, _d: Vec<u8>) {}
}
struct Collector(mpsc::Sender<String>);
impl MessageHandler for Collector {
fn on_message(&self, m: Message) {
let _ = self.0.send(m.text);
}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _n: String, _m: String, _d: Vec<u8>) {}
}
const SECRET_PLAINTEXT: &str = "TOP-SECRET-PLAINTEXT";
fn main() {
let server = std::env::var("TETHER_SERVER").unwrap_or_else(|_| "https://tether.pecord.io".into());
let room = std::env::args().nth(1).unwrap_or_else(|| "e2e-demo".into());
let account = "shared-high-entropy-secret"; // same key on both ends
if std::env::var("SOLO").is_ok() {
// One sender, no peer → not "covered" → the relay carries the message.
let a = Engine::new(server, room, "solo-a".into(), "macos".into(), "A".into(), account.into());
a.start(Box::new(Noop));
std::thread::sleep(Duration::from_secs(3));
eprintln!("sending '{SECRET_PLAINTEXT}' (encrypted) over the relay…");
a.send(SECRET_PLAINTEXT.to_string());
std::thread::sleep(Duration::from_secs(3));
return;
}
let (tx, rx) = mpsc::channel();
let a = Engine::new(server.clone(), room.clone(), "dev-a".into(), "macos".into(), "A".into(), account.into());
let b = Engine::new(server, room, "dev-b".into(), "ios".into(), "B".into(), account.into());
a.start(Box::new(Noop));
b.start(Box::new(Collector(tx)));
eprintln!("waiting for link…");
std::thread::sleep(Duration::from_secs(8));
a.send(SECRET_PLAINTEXT.to_string());
match rx.recv_timeout(Duration::from_secs(6)) {
Ok(t) if t == SECRET_PLAINTEXT => println!("✅ B decrypted the payload: {t:?}"),
Ok(t) => println!("⚠️ B got {t:?} (expected plaintext)"),
Err(_) => {
println!("❌ B got nothing");
std::process::exit(1);
}
}
a.stop();
b.stop();
}

View File

@@ -0,0 +1,56 @@
//! Proves chunked file transfer over the RTC data channel. A sends a 50 KB blob
//! (multiple 16 KB chunks); B reassembles it and gets identical bytes — P2P,
//! never relayed.
//! cargo run --example file_demo -- <room>
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Noop;
impl MessageHandler for Noop {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
struct FileCollector(mpsc::Sender<(String, Vec<u8>)>);
impl MessageHandler for FileCollector {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, name: String, _mime: String, data: Vec<u8>) {
let _ = self.0.send((name, data));
}
}
fn main() {
let server = std::env::var("TETHER_SERVER").unwrap_or_else(|_| "https://tether.pecord.io".into());
let room = std::env::args().nth(1).unwrap_or_else(|| "file-demo".into());
let payload: Vec<u8> = (0..50_000u32).map(|i| (i % 251) as u8).collect();
let (tx, rx) = mpsc::channel();
let a = Engine::new(server.clone(), room.clone(), "dev-a".into(), "macos".into(), "A".into(), "".into());
let b = Engine::new(server, room, "dev-b".into(), "ios".into(), "B".into(), "".into());
a.start(Box::new(Noop));
b.start(Box::new(FileCollector(tx)));
eprintln!("waiting ~12s for the RTC data channel…");
std::thread::sleep(Duration::from_secs(12));
eprintln!("A sends a {}-byte file…", payload.len());
a.send_file("test.bin".into(), "application/octet-stream".into(), payload.clone());
match rx.recv_timeout(Duration::from_secs(10)) {
Ok((name, data)) if name == "test.bin" && data == payload => {
println!("✅ file received intact over RTC: {name} ({} bytes)", data.len())
}
Ok((name, data)) => println!("⚠️ mismatch: name={name} len={} (expected {})", data.len(), payload.len()),
Err(_) => {
println!("❌ no file received");
std::process::exit(1);
}
}
a.stop();
b.stop();
}

83
core/examples/iroh_e2e.rs Normal file
View File

@@ -0,0 +1,83 @@
//! Proves the clipboard is genuinely END-TO-END encrypted over iroh.
//! TETHER_RENDEZVOUS=http://localhost:8765 cargo run --features iroh-transport --example iroh_e2e
//!
//! Three peers join the SAME room → SAME gossip topic, so all three receive the
//! same broadcast bytes off the wire:
//! A (sender) — account "shared" → key K1
//! B (right key) — account "shared" → key K1 → MUST decrypt
//! C (WRONG key) — account "wrong" → key K2 → MUST NOT decrypt
//! The room is derived from nothing here (passed explicitly), so B and C are in
//! the identical topic and C truly sees the ciphertext — it just can't open it.
//! If C can't read what A sent but B can, the payload is E2E-sealed, not merely
//! relayed in the clear.
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Tap(&'static str, mpsc::Sender<(&'static str, String)>);
impl MessageHandler for Tap {
fn on_message(&self, m: Message) {
let _ = self.1.send((self.0, m.text));
}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _n: String, _m: String, _d: Vec<u8>) {}
}
fn main() {
let server = std::env::var("TETHER_RENDEZVOUS").unwrap_or_else(|_| "http://localhost:8765".into());
let room = "e2e-verify";
let secret = "TOP-SECRET-CLIPBOARD-🔒";
let (tx, rx) = mpsc::channel();
// A = sender (opens the topic).
let a = Engine::new(server.clone(), room.into(), "peer-a".into(), "macos".into(), "A".into(), "shared-key".into());
a.start(Box::new(Tap("A", tx.clone())));
std::thread::sleep(Duration::from_secs(4)); // let A register as the bootstrap
// B = same account → same key (should decrypt). C = wrong account → wrong key.
let b = Engine::new(server.clone(), room.into(), "peer-b".into(), "ios".into(), "B".into(), "shared-key".into());
let c = Engine::new(server, room.into(), "peer-c".into(), "android".into(), "C".into(), "wrong-key".into());
b.start(Box::new(Tap("B", tx.clone())));
c.start(Box::new(Tap("C", tx)));
eprintln!("waiting for the gossip mesh to form…");
std::thread::sleep(Duration::from_secs(8));
eprintln!("A broadcasting (sealed): {secret:?}");
a.send(secret.to_string());
// Collect for a few seconds.
let mut b_got: Option<String> = None;
let mut c_got: Option<String> = None;
let deadline = std::time::Instant::now() + Duration::from_secs(8);
while std::time::Instant::now() < deadline {
if let Ok((who, text)) = rx.recv_timeout(Duration::from_millis(500)) {
match who {
"B" => b_got = Some(text),
"C" => c_got = Some(text),
_ => {}
}
}
if b_got.is_some() && c_got.is_some() {
break;
}
}
println!("\n── results ──");
println!("B (right key): {:?}", b_got);
println!("C (WRONG key): {:?}", c_got);
let b_ok = b_got.as_deref() == Some(secret);
let c_blind = c_got.is_none();
if b_ok && c_blind {
println!("\n✅ E2E PROVEN: B (shared key) decrypted the clipboard; C — in the SAME gossip");
println!(" topic, receiving the same bytes — could NOT read it (wrong key). The payload");
println!(" is sealed end-to-end, not just relayed.");
} else {
println!("\n❌ E2E check failed: b_ok={b_ok} c_blind={c_blind}");
std::process::exit(1);
}
a.stop();
b.stop();
c.stop();
}

View File

@@ -0,0 +1,103 @@
//! M4 integration test — the real `Engine` syncing a clipboard over iroh.
//! cargo run --features iroh-transport --example iroh_engine
//!
//! Two engines, same account → same room → same gossip topic. No SSE/RTC/LAN:
//! with the iroh-transport feature the engine's only transport is IrohTransport.
//! Proves the public API (Engine::new/start/send + MessageHandler) is unchanged
//! while the entire Mesh underneath is iroh — the payoff of the MeshEvent seam.
//!
//! Bootstrap is wired in-process via the TETHER_IROH_BOOTSTRAP env var (the v1
//! stopgap until the rendezvous supernode): start A, read its EndpointId, hand
//! it to B.
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Noop;
impl MessageHandler for Noop {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _n: String, _m: String, _d: Vec<u8>) {}
}
struct Collector {
text: mpsc::Sender<String>,
file: mpsc::Sender<(String, usize)>,
}
impl MessageHandler for Collector {
fn on_message(&self, m: Message) {
let _ = self.text.send(m.text);
}
fn on_status(&self, _c: bool) {}
fn on_file(&self, name: String, _mime: String, data: Vec<u8>) {
let _ = self.file.send((name, data.len()));
}
}
fn main() {
let account = "pecord@gmail.com"; // same account → same room → same topic + key
let room = "iroh-engine-test";
// Zero-config bootstrap via the rendezvous (run tether-rendezvous first, or
// point at a deployed one). Both engines register under `room` and discover
// each other — no env var, no manual id wiring.
let server = std::env::var("TETHER_RENDEZVOUS")
.unwrap_or_else(|_| "http://localhost:8765".into());
eprintln!("rendezvous: {server}");
let a = Engine::new(
server.clone(), room.into(), "iroh-a".into(), "macos".into(), "A".into(), account.into(),
);
a.start(Box::new(Noop));
let (tx, rx) = mpsc::channel();
let (ftx, frx) = mpsc::channel();
let b = Engine::new(
server.clone(), room.into(), "iroh-b".into(), "ios".into(), "B".into(), account.into(),
);
b.start(Box::new(Collector { text: tx, file: ftx }));
eprintln!("waiting for gossip topic to form (via rendezvous)…");
std::thread::sleep(Duration::from_secs(8));
// 1) Text clipboard over gossip.
let payload = "clipboard synced over iroh 🎉";
eprintln!("A sending text: {payload:?}");
a.send(payload.to_string());
match rx.recv_timeout(Duration::from_secs(10)) {
Ok(t) if t == payload => {
println!("✅ text: B received {t:?} (E2E-decrypted, via gossip)");
}
Ok(t) => {
println!("⚠️ B got {t:?} (expected {payload:?})");
std::process::exit(1);
}
Err(_) => {
println!("❌ B received no text");
std::process::exit(1);
}
}
// 2) A "photo"/blob over iroh-blobs (announced via gossip, fetched P2P).
let photo: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
eprintln!("A sending blob: photo.bin ({} bytes)", photo.len());
a.send_file("photo.bin".into(), "image/png".into(), photo.clone());
match frx.recv_timeout(Duration::from_secs(15)) {
Ok((name, len)) if len == photo.len() => {
println!("✅ blob: B received {name:?} ({len} bytes, E2E-decrypted, via iroh-blobs)");
}
Ok((name, len)) => {
println!("⚠️ B got {name:?} with {len} bytes (expected {})", photo.len());
std::process::exit(1);
}
Err(_) => {
println!("❌ B received no blob");
std::process::exit(1);
}
}
println!("🎉 engine-over-iroh: text + photo synced, no SSE/RTC/LAN/relay");
a.stop();
b.stop();
}

View File

@@ -0,0 +1,133 @@
//! Iroh spike, milestone 3 — discovery via iroh-gossip (replaces the SSE presence bus).
//! cargo run --example iroh_gossip
//!
//! Proves `account → {EndpointId}` the P2P way: two endpoints derive the SAME
//! gossip topic from a shared account secret (exactly like room_for_account
//! today), join it, and learn each other's public keys by broadcasting presence
//! — no hardcoded peer addresses in the steady state.
//!
//! The ONE thing gossip can't do for itself is bootstrap: to join topic T you
//! must reach ≥1 peer already in T. Here we hand the joiner the opener's address
//! directly — in production that single fact comes from a tiny rendezvous
//! supernode (the homelab), which is all the server shrinks to. Everything after
//! the first contact is peer-to-peer gossip.
//!
//! Maps onto the engine: a `GossipPresence` source would emit `MeshEvent::Present`
//! / `MeshEvent::Discovered` from received presence, replacing rtc.rs's chirps.
use std::time::Duration;
use futures_util::TryStreamExt;
use iroh::{protocol::Router, Endpoint, endpoint::presets};
use iroh_gossip::{api::Event, net::Gossip, net::GOSSIP_ALPN, proto::TopicId};
use serde::{Deserialize, Serialize};
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
anyhow::anyhow!("{e}")
}
/// account → 32-byte gossip topic (same idea as room_for_account, full width).
fn topic_for_account(account: &str) -> TopicId {
use sha2::{Digest, Sha256};
let mut id = [0u8; 32];
id.copy_from_slice(&Sha256::digest(account.as_bytes()));
TopicId::from_bytes(id)
}
#[derive(Serialize, Deserialize)]
struct Presence {
id: String, // the broadcaster's EndpointId (author, carried in payload —
name: String, // gossip relays, so don't trust delivered_from for identity)
}
/// Build an endpoint + a gossip instance registered on a Router.
async fn node() -> anyhow::Result<(Endpoint, Gossip, Router)> {
let endpoint = Endpoint::bind(presets::N0).await.map_err(ae)?;
endpoint.online().await;
let gossip = Gossip::builder().spawn(endpoint.clone());
let router = Router::builder(endpoint.clone())
.accept(GOSSIP_ALPN, gossip.clone())
.spawn();
Ok((endpoint, gossip, router))
}
/// Join the topic, broadcast our presence, and wait until we hear the peer's.
async fn discover(
label: &'static str,
endpoint: Endpoint,
gossip: Gossip,
topic: TopicId,
bootstrap: Vec<iroh::EndpointId>,
) -> anyhow::Result<String> {
let me = Presence { id: endpoint.id().to_string(), name: label.to_string() };
let payload = serde_json::to_vec(&me)?;
let (sender, mut receiver) = gossip
.subscribe_and_join(topic, bootstrap)
.await
.map_err(ae)?
.split();
println!("[gossip] {label} joined topic — id={}", me.id);
// Re-broadcast on a ticker so a late-joining neighbor still hears us.
let ticker = {
let sender = sender.clone();
let payload = payload.clone();
tokio::spawn(async move {
loop {
let _ = sender.broadcast(payload.clone().into()).await;
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
};
let found = async {
while let Some(event) = receiver.try_next().await.map_err(ae)? {
if let Event::Received(msg) = event {
if let Ok(p) = serde_json::from_slice::<Presence>(&msg.content) {
if p.id != me.id {
return Ok::<String, anyhow::Error>(format!("{} ({})", p.name, p.id));
}
}
}
}
anyhow::bail!("{label}: gossip stream ended before discovering a peer")
};
let peer = tokio::time::timeout(Duration::from_secs(20), found)
.await
.map_err(|_| anyhow::anyhow!("{label}: timed out waiting to discover a peer"))??;
ticker.abort();
println!("[gossip] {label} discovered peer via gossip: {peer}");
Ok(peer)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let account = "pecord@gmail.com"; // both derive the same topic from this
let topic = topic_for_account(account);
println!("[gossip] account {account:?} → topic {topic}");
// Opener and joiner.
let (a_ep, a_gossip, a_router) = node().await?;
let (b_ep, b_gossip, b_router) = node().await?;
// Bootstrap: the joiner only needs the opener's EndpointId; iroh's own
// discovery (n0 DNS/pkarr here, a rendezvous supernode in production) maps
// that id → address. Everything after first contact is peer gossip.
let a_id = a_ep.id();
let a = discover("node-A", a_ep, a_gossip, topic, vec![]);
let b = discover("node-B", b_ep, b_gossip, topic, vec![a_id]);
let (a_found, b_found) = tokio::try_join!(a, b)?;
// Graceful teardown so background gossip/accept tasks stop cleanly.
a_router.shutdown().await.map_err(ae)?;
b_router.shutdown().await.map_err(ae)?;
println!(
"✅ gossip discovery: both endpoints found each other in the \
account-derived topic with zero hardcoded addresses\n A↔{a_found}\n B↔{b_found}"
);
Ok(())
}

120
core/examples/iroh_p2p.rs Normal file
View File

@@ -0,0 +1,120 @@
//! Iroh spike, milestone 2b — cross-NAT holepunch on TWO machines / networks.
//! Machine A (e.g. Mac on home wifi):
//! cargo run --example iroh_p2p -- accept
//! → prints this node's EndpointId, then waits, echoing any stream.
//! Machine B (e.g. laptop on phone hotspot / a different network):
//! cargo run --example iroh_p2p -- connect <ENDPOINT_ID_FROM_A>
//! → resolves A by id (n0 discovery), connects, round-trips, and prints
//! whether the path is DIRECT (holepunch worked) or RELAY (fell back).
//!
//! This is the last unknown before committing to iroh (M4): the M1/M2/M3 spikes
//! all ran same-host. Here the two endpoints are on different NATs, so a DIRECT
//! path in `remote_info` proves real holepunch; RELAY proves the fallback floor.
//! Either way the round-trip must succeed — that's the cross-network sync we
//! verified on the hand-rolled stack, now on iroh.
use std::time::Duration;
use iroh::{
endpoint::{presets, Connection},
protocol::{AcceptError, ProtocolHandler, Router},
Endpoint, EndpointAddr, EndpointId,
};
const ALPN: &[u8] = b"tether/iroh-p2p/0";
const PAYLOAD: &[u8] = b"tether cross-NAT round-trip over iroh";
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
anyhow::anyhow!("{e}")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mode = std::env::args().nth(1).unwrap_or_default();
match mode.as_str() {
"accept" => accept().await,
"connect" => {
let id = std::env::args()
.nth(2)
.ok_or_else(|| anyhow::anyhow!("usage: iroh_p2p connect <ENDPOINT_ID>"))?;
connect(&id).await
}
_ => {
eprintln!("usage:\n iroh_p2p accept\n iroh_p2p connect <ENDPOINT_ID>");
std::process::exit(2);
}
}
}
async fn accept() -> anyhow::Result<()> {
let endpoint = Endpoint::bind(presets::N0).await.map_err(ae)?;
let router = Router::builder(endpoint.clone())
.accept(ALPN, Echo)
.spawn();
endpoint.online().await;
println!("════════════════════════════════════════════════════════════");
println!(" accept side ready. On the OTHER machine/network, run:");
println!(" cargo run --example iroh_p2p -- connect {}", endpoint.id());
println!("════════════════════════════════════════════════════════════");
println!("(waiting for a connection — Ctrl-C to stop)");
std::future::pending::<()>().await; // run until killed
let _ = router;
Ok(())
}
async fn connect(id_str: &str) -> anyhow::Result<()> {
let target: EndpointId = id_str
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("invalid EndpointId: {id_str:?}"))?;
let endpoint = Endpoint::bind(presets::N0).await.map_err(ae)?;
endpoint.online().await;
println!("[p2p] dialing {target} (resolving address via discovery)…");
// Address-less target → iroh's discovery (n0 DNS/pkarr) resolves it, then
// holepunches; falls back to relay if the NATs won't cooperate.
let conn = endpoint
.connect(EndpointAddr::new(target), ALPN)
.await
.map_err(ae)?;
let (mut send, mut recv) = conn.open_bi().await.map_err(ae)?;
send.write_all(PAYLOAD).await.map_err(ae)?;
send.finish().map_err(ae)?;
let echoed = recv.read_to_end(64 * 1024).await.map_err(ae)?;
anyhow::ensure!(echoed == PAYLOAD, "echo mismatch");
// Give holepunch a moment to upgrade past the initial path, then report.
tokio::time::sleep(Duration::from_secs(1)).await;
let path = match endpoint.remote_info(target).await {
Some(info) => format!("{info:?}"),
None => "(no remote_info)".to_string(),
};
let direct = path.contains("Ip(") && path.contains("Active");
println!(
"✅ cross-NAT round-trip OK ({} bytes). Path: {}\n remote_info: {}",
echoed.len(),
if direct { "DIRECT (holepunch worked)" } else { "RELAY (fallback floor)" },
path
);
conn.close(0u32.into(), b"done");
endpoint.close().await;
Ok(())
}
/// Echo the first stream back (stands in for emitting MeshEvents).
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
println!("[p2p] inbound connection from {}", connection.remote_id());
let (mut send, mut recv) = connection.accept_bi().await?;
let n = tokio::io::copy(&mut recv, &mut send).await?;
println!("[p2p] echoed {n} byte(s)");
send.finish()?;
connection.closed().await;
Ok(())
}
}

101
core/examples/iroh_relay.rs Normal file
View File

@@ -0,0 +1,101 @@
//! Iroh spike, milestone 2 — prove n0's HOSTED RELAY carries our traffic.
//! cargo run --example iroh_relay
//!
//! Milestone 1 (iroh_spike.rs) connected two endpoints on loopback — that could
//! have gone direct. This forces the relay path so we actually exercise n0's
//! hosted infra (the reliability floor that makes cross-network work when
//! holepunch fails):
//! - the connecting endpoint has address lookup turned OFF, so it CANNOT
//! discover the server's direct socket addresses, and
//! - it is handed a RELAY-ONLY EndpointAddr (id + home relay URL, no IPs).
//! With no other path available, a successful round-trip can only have traversed
//! n0's relay. `remote_info` is printed to show the path actually in use.
//!
//! Honest scope: this proves the *relay carries data*, on one machine. It does
//! NOT prove cross-NAT holepunch+upgrade — that needs two machines on different
//! networks (milestone 2b, and the homelab-as-relay step).
use iroh::{
endpoint::{presets, Connection},
protocol::{AcceptError, ProtocolHandler, Router},
Endpoint, EndpointAddr,
};
const ALPN: &[u8] = b"tether/iroh-relay/0";
const PAYLOAD: &[u8] = b"tether clipboard, carried over n0's hosted relay";
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
anyhow::anyhow!("{e}")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// ── accept side: full N0 (relay + address publishing) ────────────────────
let server = Endpoint::bind(presets::N0).await.map_err(ae)?;
let server_id = server.id();
let router = Router::builder(server).accept(ALPN, Echo).spawn();
router.endpoint().online().await;
let relay = router
.endpoint()
.addr()
.relay_urls()
.next()
.cloned()
.ok_or_else(|| anyhow::anyhow!("accept side has no home relay assigned"))?;
println!("[relay] accept side online — id={server_id}\n home relay = {relay}");
// ── connect side: n0 relay kept, address lookup OFF, relay-only target ────
let client = Endpoint::builder(presets::N0)
.clear_address_lookup()
.bind()
.await
.map_err(ae)?;
client.online().await;
let relay_only = EndpointAddr::new(server_id).with_relay_url(relay);
println!(
"[relay] connect side id={} — dialing RELAY-ONLY (no direct addrs, lookup off)",
client.id()
);
let conn = client.connect(relay_only, ALPN).await.map_err(ae)?;
let (mut send, mut recv) = conn.open_bi().await.map_err(ae)?;
send.write_all(PAYLOAD).await.map_err(ae)?;
send.finish().map_err(ae)?;
let echoed = recv.read_to_end(64 * 1024).await.map_err(ae)?;
// Show the path actually negotiated (relay vs any holepunched direct upgrade).
if let Some(info) = client.remote_info(server_id).await {
println!("[relay] remote_info after round-trip: {info:?}");
}
conn.close(0u32.into(), b"done");
client.close().await;
router.shutdown().await.map_err(ae)?;
anyhow::ensure!(
echoed == PAYLOAD,
"echo mismatch: {:?}",
String::from_utf8_lossy(&echoed)
);
println!(
"✅ n0 hosted-relay round-trip: {} bytes (address lookup off + relay-only addr → \
the relay had to carry it)",
echoed.len()
);
Ok(())
}
/// Echo the first stream's bytes back (stands in for emitting MeshEvents).
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let (mut send, mut recv) = connection.accept_bi().await?;
let n = tokio::io::copy(&mut recv, &mut send).await?;
println!("[relay] echoed {n} byte(s) back to {}", connection.remote_id());
send.finish()?;
connection.closed().await;
Ok(())
}
}

View File

@@ -0,0 +1,36 @@
//! One-shot test sender — stands in for "another device" to pre-flight the
//! agent's receive path (write clipboard + provenance toast) before testing from
//! the phone.
//! TETHER_RENDEZVOUS=http://IP:8765 cargo run --features iroh-transport \
//! --example iroh_send -- "text to send" [account]
//! Joins the same account-derived room via the rendezvous and sends one clipboard.
use std::time::Duration;
use tethercore::{room_for_account, Engine, Message, MessageHandler};
struct Noop;
impl MessageHandler for Noop {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _n: String, _m: String, _d: Vec<u8>) {}
}
fn main() {
let text = std::env::args().nth(1).unwrap_or_else(|| "hello from a fake phone 📋".into());
let account = std::env::args().nth(2).unwrap_or_else(|| "pecord@gmail.com".into());
let server = std::env::var("TETHER_RENDEZVOUS").unwrap_or_else(|_| "http://localhost:8765".into());
let room = room_for_account(account.clone());
eprintln!("sender → rendezvous {server}, room {room}, account {account}");
let e = Engine::new(server, room, "preflight-sender".into(), "ios".into(), "Test iPhone".into(), account);
e.start(Box::new(Noop));
eprintln!("joining topic + discovering the agent…");
std::thread::sleep(Duration::from_secs(8));
eprintln!("sending: {text:?}");
e.send(text);
std::thread::sleep(Duration::from_secs(4)); // let gossip deliver
e.stop();
eprintln!("done — check the Mac clipboard + notification");
}

View File

@@ -0,0 +1,93 @@
//! Iroh spike — does iroh 1.0 give us the Mesh for free?
//! cargo run --example iroh_spike
//!
//! Proves, in-process, the three things a tether Mesh transport needs:
//! 1. an Endpoint identified by a public key (durable device identity — the
//! thing ARCHITECTURE.md §3 wants, here for free as `EndpointId`),
//! 2. an accept side (QUIC + relay holepunch handled by iroh),
//! 3. a connect side that dials by address and round-trips bytes.
//!
//! Mapping onto our contract (so the integration is obvious):
//! - `ProtocolHandler::accept(conn)` ≈ a peer became directly reachable
//! → emit `MeshEvent::DirectUp(remote_id)`, then read frames and emit
//! `MeshEvent::Message` / `MeshEvent::File` just like rtc.rs/lan.rs do.
//! - `endpoint.connect(addr, ALPN)` ≈ the LAN/RTC dial path (lower id dials).
//! - `connection.remote_id()` ≈ a stable per-device public key — no
//! separate keypair/Noise layer needed; identity is the transport.
//!
//! What this spike does NOT yet prove: cross-network holepunch via a relay
//! (needs two machines), and discovery (mapping an account/room → EndpointId).
//! Those are the next milestones if iroh earns the integration.
use iroh::{
endpoint::{presets, Connection},
protocol::{AcceptError, ProtocolHandler, Router},
Endpoint, EndpointAddr,
};
const ALPN: &[u8] = b"tether/iroh-spike/0";
const PAYLOAD: &[u8] = b"tether clipboard sync, carried over iroh 1.0 QUIC";
/// Turn any Display error into anyhow, so we don't pull in iroh's n0-error crate
/// just for a spike.
fn ae<E: std::fmt::Display>(e: E) -> anyhow::Error {
anyhow::anyhow!("{e}")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// ── accept side ──────────────────────────────────────────────────────────
let server = Endpoint::bind(presets::N0).await.map_err(ae)?;
let server_id = server.id();
let router = Router::builder(server).accept(ALPN, Echo).spawn();
// Wait until the endpoint has a reachable address to hand out.
router.endpoint().online().await;
let server_addr: EndpointAddr = router.endpoint().addr();
println!("[iroh] accept side online — id={server_id}");
// ── connect side ─────────────────────────────────────────────────────────
let client = Endpoint::bind(presets::N0).await.map_err(ae)?;
println!("[iroh] connect side — id={}", client.id());
let conn = client.connect(server_addr, ALPN).await.map_err(ae)?;
let (mut send, mut recv) = conn.open_bi().await.map_err(ae)?;
send.write_all(PAYLOAD).await.map_err(ae)?;
send.finish().map_err(ae)?;
// Echo handler sends the same bytes back.
let echoed = recv.read_to_end(64 * 1024).await.map_err(ae)?;
conn.close(0u32.into(), b"done");
client.close().await;
router.shutdown().await.map_err(ae)?;
if echoed == PAYLOAD {
println!(
"✅ iroh round-trip: {} bytes echoed P2P (QUIC, key-addressed)\n payload={:?}",
echoed.len(),
String::from_utf8_lossy(&echoed)
);
Ok(())
} else {
anyhow::bail!("echo mismatch: got {:?}", String::from_utf8_lossy(&echoed))
}
}
/// Minimal accepting protocol: echo the first stream's bytes back. In the real
/// transport this body would emit MeshEvents instead of echoing.
#[derive(Debug, Clone)]
struct Echo;
impl ProtocolHandler for Echo {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let remote = connection.remote_id();
println!("[iroh] accepted connection from {remote}");
let (mut send, mut recv) = connection.accept_bi().await?;
// ≈ read frames → emit MeshEvent::Message/File. Here: echo straight back.
let n = tokio::io::copy(&mut recv, &mut send).await?;
println!("[iroh] echoed {n} byte(s) back to {remote}");
send.finish()?;
connection.closed().await;
Ok(())
}
}

56
core/examples/lan_p2p.rs Normal file
View File

@@ -0,0 +1,56 @@
//! Proves serverless LAN sync: two engines with a DEAD server URL (so SSE and
//! RTC cannot deliver) discover each other over mDNS and exchange a clipboard
//! over plain TCP.
//! cargo run --example lan_p2p
//!
//! If the message arrives, it can only have come via the LAN transport.
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Collector(mpsc::Sender<Message>);
impl MessageHandler for Collector {
fn on_message(&self, msg: Message) {
let _ = self.0.send(msg);
}
fn on_status(&self, _connected: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
// Unroutable server → SSE/RTC are dead; only LAN can carry the message.
let dead = "http://127.0.0.1:1".to_string();
let room = "lan-spike".to_string();
let (tx, rx) = mpsc::channel();
let a = Engine::new(dead.clone(), room.clone(), "peer-a".into(), "lan-a".into(), "LAN A".into(), "".into());
a.start(Box::new(Collector(tx)));
let b_from = "peer-b";
let b = Engine::new(dead, room, b_from.into(), "lan-b".into(), "LAN B".into(), "".into());
b.start(Box::new(Collector(mpsc::channel().0)));
eprintln!("waiting for mDNS discovery + TCP connect…");
std::thread::sleep(Duration::from_secs(4));
let payload = "hello-over-lan";
b.send(payload.to_string());
match rx.recv_timeout(Duration::from_secs(6)) {
Ok(m) if m.text == payload => {
println!(
"✅ serverless LAN delivery: text={:?} from={} source={}",
m.text, m.from, m.source
);
}
Ok(m) => println!("⚠️ unexpected: {m:?}"),
Err(_) => {
println!("❌ nothing received — mDNS may be blocked, or peers didn't connect");
std::process::exit(1);
}
}
a.stop();
b.stop();
}

33
core/examples/lan_peer.rs Normal file
View File

@@ -0,0 +1,33 @@
//! A controllable LAN peer for verifying the agent's serverless sync, without a
//! second clipboard-touching process. Dead server → only the LAN transport can
//! move data.
//! cargo run --example lan_peer -- <room> <text-to-send>
//! Prints `RECV <text>` for anything received; sends <text-to-send> once after
//! discovery settles.
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Printer;
impl MessageHandler for Printer {
fn on_message(&self, msg: Message) {
println!("RECV {}", msg.text);
}
fn on_status(&self, _connected: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
let room = std::env::args().nth(1).unwrap_or_else(|| "lan-verify".into());
let send = std::env::args().nth(2);
// High `from` so it accepts the agent's dial (lower id dials).
let e = Engine::new("http://127.0.0.1:1".into(), room, "zzz-peer".into(), "peer".into(), "Rust LAN Peer".into(), "".into());
e.start(Box::new(Printer));
std::thread::sleep(Duration::from_secs(6)); // let mDNS discover + TCP connect
if let Some(s) = send {
e.send(s);
}
std::thread::sleep(Duration::from_secs(8));
}

41
core/examples/nearby.rs Normal file
View File

@@ -0,0 +1,41 @@
//! Proves AirDrop-style discovery: two engines in DIFFERENT rooms still see each
//! other in nearby() with friendly names (mDNS, no server).
//! cargo run --example nearby
use std::time::Duration;
use tethercore::{Engine, MessageHandler, Message};
struct Noop;
impl MessageHandler for Noop {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
let dead = "http://127.0.0.1:1".to_string();
let a = Engine::new(dead.clone(), "room-A".into(), "alpha".into(), "macos".into(), "Patrick's MacBook".into(), "acct-X".into());
let b = Engine::new(dead, "room-B".into(), "bravo".into(), "ios".into(), "Patrick's iPhone".into(), "acct-X".into());
a.start(Box::new(Noop));
b.start(Box::new(Noop));
eprintln!("browsing mDNS for ~5s…");
std::thread::sleep(Duration::from_secs(5));
println!("A.nearby():");
for p in a.nearby() {
println!("{} [{}] room={}", p.name, p.source, p.room);
}
println!("B.nearby():");
for p in b.nearby() {
println!("{} [{}] room={}", p.name, p.source, p.room);
}
let ok = a.nearby().iter().any(|p| p.name == "Patrick's iPhone" && p.room == "room-B")
&& b.nearby().iter().any(|p| p.name == "Patrick's MacBook" && p.room == "room-A");
println!("{}", if ok { "✅ cross-room discovery by name works" } else { "❌ did not discover" });
a.stop();
b.stop();
if !ok { std::process::exit(1); }
}

View File

@@ -0,0 +1,50 @@
//! Proves the relay carries content only until a direct path exists. A and B
//! join a room on the LIVE server and link directly (LAN/RTC). After that, A's
//! send must reach B but must NOT appear on the server bus.
//! cargo run --example prefer_direct -- <room>
//! (run an external SSE listener on the same room to confirm suppression)
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Collector(mpsc::Sender<String>);
impl MessageHandler for Collector {
fn on_message(&self, m: Message) {
eprintln!(" B.on_message: kind={:?} text={:?} from={:?}", m.kind, m.text, m.from);
let _ = self.0.send(m.text);
}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
let server = "https://tether.pecord.io".to_string();
let room = std::env::args().nth(1).unwrap_or_else(|| "prefer-direct-demo".into());
let (tx, rx) = mpsc::channel();
let a = Engine::new(server.clone(), room.clone(), "dev-a".into(), "macos".into(), "A".into(), "".into());
let b = Engine::new(server, room, "dev-b".into(), "ios".into(), "B".into(), "".into());
a.start(Box::new(Collector(mpsc::channel().0)));
b.start(Box::new(Collector(tx)));
// Let presence + the direct (LAN/RTC) link establish.
eprintln!("waiting ~12s for presence + direct link…");
std::thread::sleep(Duration::from_secs(12));
let secret = "DIRECT-ONLY-SECRET";
eprintln!("A sends — should go P2P, NOT via the relay");
a.send(secret.to_string());
let mut got = false;
while let Ok(t) = rx.recv_timeout(Duration::from_secs(4)) {
if t == secret { got = true; break; }
}
println!(
"{}",
if got { "✅ B received the secret over the direct path" } else { "❌ B never got the secret" }
);
a.stop();
b.stop();
}

View File

@@ -0,0 +1,44 @@
//! Sanity-check transfer progress: A sends a 5 MB file; poll B.transfers() and
//! A.transfers() to see received/total advance to done.
//! cargo run --example progress_demo
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Noop;
impl MessageHandler for Noop {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, name: String, _mime: String, data: Vec<u8>) {
eprintln!(" received {name} ({} bytes)", data.len());
}
}
fn main() {
let dead = "http://127.0.0.1:1".to_string(); // LAN only
let room = "progress-room".to_string();
let payload = vec![9u8; 5_000_000];
let a = Engine::new(dead.clone(), room.clone(), "dev-a".into(), "macos".into(), "A".into(), "".into());
let b = Engine::new(dead, room, "dev-b".into(), "ios".into(), "B".into(), "".into());
a.start(Box::new(Noop));
b.start(Box::new(Noop));
eprintln!("waiting for LAN link…");
std::thread::sleep(Duration::from_secs(5));
a.send_file("big.bin".into(), "application/octet-stream".into(), payload);
for _ in 0..30 {
std::thread::sleep(Duration::from_millis(150));
let rx = b.transfers();
let tx = a.transfers();
if let Some(t) = rx.first() {
println!("↓ recv {}/{} {}", t.received, t.total, if t.done { "✅ done" } else { "" });
if t.done { break; }
} else if let Some(t) = tx.first() {
println!("↑ send {}/{} {}", t.received, t.total, if t.done { "done" } else { "" });
}
}
a.stop();
b.stop();
}

View File

@@ -0,0 +1,40 @@
//! Proves delivery receipts: A sends a clipboard, B receives it and auto-acks,
//! and A.receipts() shows "seen by <B's name>" — no asking.
//! cargo run --example receipt_demo
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Noop;
impl MessageHandler for Noop {
fn on_message(&self, _m: Message) {}
fn on_status(&self, _c: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
let dead = "http://127.0.0.1:1".to_string(); // LAN-only
let room = "receipt-room".to_string();
let a = Engine::new(dead.clone(), room.clone(), "dev-a".into(), "macos".into(), "Patrick's Mac".into(), "".into());
let b = Engine::new(dead, room, "dev-b".into(), "ios".into(), "Patrick's iPhone".into(), "".into());
a.start(Box::new(Noop));
b.start(Box::new(Noop));
eprintln!("waiting for LAN link…");
std::thread::sleep(Duration::from_secs(5));
eprintln!("A sends a clipboard…");
a.send("important-link".to_string());
std::thread::sleep(Duration::from_secs(2)); // let B receive + ack, A record
let r = a.receipts();
if let Some(rec) = r.iter().find(|r| r.name == "Patrick's iPhone") {
println!("✅ A sees its clipboard was delivered → seen by {:?} [{}]", rec.name, rec.source);
} else {
println!("❌ no receipt recorded ({} total)", r.len());
std::process::exit(1);
}
a.stop();
b.stop();
}

View File

@@ -0,0 +1,54 @@
//! Proves the engine round-trips against a live tether server, no FFI involved.
//! cargo run --example roundtrip -- http://192.168.11.233:8765
//! Subscriber engine (from=rx) listens; sender engine (from=tx) publishes;
//! the message must come back through on_message.
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Collector(mpsc::Sender<Message>);
impl MessageHandler for Collector {
fn on_message(&self, msg: Message) {
let _ = self.0.send(msg);
}
fn on_status(&self, connected: bool) {
eprintln!("[rx] connected={connected}");
}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
let server = std::env::args()
.nth(1)
.unwrap_or_else(|| "http://192.168.11.233:8765".into());
let room = "rust-spike".to_string();
let (tx, rx) = mpsc::channel();
let sub = Engine::new(server.clone(), room.clone(), "rx".into(), "rust-rx".into(), "Rust RX".into(), "".into());
sub.start(Box::new(Collector(tx)));
// Let the SSE subscription establish before publishing.
std::thread::sleep(Duration::from_millis(800));
let payload = "hello-from-rust-core";
let pubr = Engine::new(server, room, "tx".into(), "rust-tx".into(), "Rust TX".into(), "".into());
pubr.send(payload.to_string());
match rx.recv_timeout(Duration::from_secs(5)) {
Ok(m) if m.text == payload => {
println!("✅ round-trip OK: kind={} from={} source={} ts={}\n text={:?}",
m.kind, m.from, m.source, m.ts, m.text);
}
Ok(m) => {
println!("⚠️ received unexpected message: {m:?}");
std::process::exit(1);
}
Err(_) => {
println!("❌ timed out — no message received (is the server up at the URL?)");
std::process::exit(1);
}
}
sub.stop();
}

62
core/examples/rtc_p2p.rs Normal file
View File

@@ -0,0 +1,62 @@
//! Proves two engines establish a WebRTC data channel and clipboard flows P2P.
//! cargo run --example rtc_p2p -- http://192.168.11.233:8765
//!
//! Path discriminator: an RTC-delivered message has source == the peer's
//! from-id; the SSE copy has source == the peer's source-label. Since RTC is
//! direct (no server round-trip) it should win the dedup race, so a received
//! source equal to the from-id proves delivery came over the data channel.
use std::sync::mpsc;
use std::time::Duration;
use tethercore::{Engine, Message, MessageHandler};
struct Collector(mpsc::Sender<Message>);
impl MessageHandler for Collector {
fn on_message(&self, msg: Message) {
let _ = self.0.send(msg);
}
fn on_status(&self, _connected: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}
fn main() {
let server = std::env::args()
.nth(1)
.unwrap_or_else(|| "http://192.168.11.233:8765".into());
let room = "rtc-p2p-spike".to_string();
let (tx, rx) = mpsc::channel();
let a = Engine::new(server.clone(), room.clone(), "peer-a".into(), "label-a".into(), "Peer A".into(), "".into());
a.start(Box::new(Collector(tx)));
// B's from-id and source-label are deliberately different so we can tell
// which transport delivered.
let b_from = "peer-b";
let b = Engine::new(server, room, b_from.into(), "label-b-sse".into(), "Peer B".into(), "".into());
b.start(Box::new(Collector(mpsc::channel().0))); // B needs to be live to negotiate
// Give presence + ICE + DTLS time to bring the data channel up.
eprintln!("waiting for RTC negotiation…");
std::thread::sleep(Duration::from_secs(6));
let payload = "hello-over-rtc";
b.send(payload.to_string());
match rx.recv_timeout(Duration::from_secs(5)) {
Ok(m) if m.text == payload => {
let via = if m.source == b_from { "RTC data channel ✅" } else { "SSE relay (RTC lost the race)" };
println!("received {:?}\n from={} source={} → delivered via {}", m.text, m.from, m.source, via);
if m.source != b_from {
println!(" note: message arrived, but over SSE — check the 'data channel open' log above to confirm RTC linked");
}
}
Ok(m) => println!("⚠️ unexpected: {m:?}"),
Err(_) => {
println!("❌ nothing received in time");
std::process::exit(1);
}
}
a.stop();
b.stop();
}

View File

@@ -0,0 +1,7 @@
// Library-mode binding generator. Build the crate, then:
// cargo run --bin uniffi-bindgen generate \
// --library target/<triple>/release/libtethercore.dylib \
// --language swift --out-dir bindings/swift
fn main() {
uniffi::uniffi_bindgen_main()
}

75
core/src/crypto.rs Normal file
View File

@@ -0,0 +1,75 @@
//! Payload encryption (ChaCha20-Poly1305) with a key derived from the shared
//! account secret. Clipboard/file content is sealed BEFORE it touches any
//! transport, so the server (SSE relay) and any LAN sniffer only ever see
//! ciphertext — the server is reduced to pure signaling.
//!
//! NOTE: the key is sha256(secret), and the current secret is the account (an
//! email — low entropy). This defends against passive eavesdroppers, not
//! someone who already knows the account. The real fix is a high-entropy secret
//! (Sign in with Apple `sub` / a passphrase) fed into the same `from_secret`.
use base64::Engine as _;
use chacha20poly1305::aead::Aead;
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce};
use sha2::{Digest, Sha256};
#[derive(Clone)]
pub(crate) struct Crypto {
cipher: Option<ChaCha20Poly1305>,
}
impl Crypto {
pub(crate) fn from_secret(secret: &str) -> Self {
if secret.is_empty() {
return Self { cipher: None }; // signed out → plaintext (no shared key)
}
let mut key = [0u8; 32];
key.copy_from_slice(&Sha256::digest(secret.as_bytes()));
Self {
cipher: Some(ChaCha20Poly1305::new(Key::from_slice(&key))),
}
}
/// nonce(12) ‖ ciphertext. Passthrough when disabled.
pub(crate) fn seal(&self, plaintext: &[u8]) -> Vec<u8> {
match &self.cipher {
Some(c) => {
let mut nonce = [0u8; 12];
getrandom::fill(&mut nonce).expect("rng");
match c.encrypt(Nonce::from_slice(&nonce), plaintext) {
Ok(ct) => {
let mut out = nonce.to_vec();
out.extend_from_slice(&ct);
out
}
Err(_) => plaintext.to_vec(),
}
}
None => plaintext.to_vec(),
}
}
/// Open nonce‖ciphertext. None if it can't be decrypted. Passthrough when
/// disabled.
pub(crate) fn open(&self, data: &[u8]) -> Option<Vec<u8>> {
match &self.cipher {
Some(c) => {
if data.len() < 12 {
return None;
}
let (nonce, ct) = data.split_at(12);
c.decrypt(Nonce::from_slice(nonce), ct).ok()
}
None => Some(data.to_vec()),
}
}
/// Clipboard text helpers — base64 so the ciphertext rides the JSON `text`.
pub(crate) fn seal_b64(&self, plaintext: &str) -> String {
base64::engine::general_purpose::STANDARD.encode(self.seal(plaintext.as_bytes()))
}
pub(crate) fn open_b64(&self, b64: &str) -> Option<String> {
let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
String::from_utf8(self.open(&bytes)?).ok()
}
}

388
core/src/iroh.rs Normal file
View File

@@ -0,0 +1,388 @@
//! IrohTransport — the Mesh, on iroh. The end state of M4: this one transport
//! subsumes the hand-rolled SSE/RTC/LAN stack. iroh handles key-addressing,
//! holepunch, and relay; `iroh-gossip` carries the clipboard `Message`s and
//! presence; `iroh-blobs` carries files/photos/data (content-addressed, fetched
//! P2P on demand) — replacing the hand-rolled M/C/E frame protocol.
//!
//! It implements the same `Transport` trait and emits the same `MeshEvent`s as
//! the legacy transports, so the engine event loop and every client are
//! unchanged — the whole point of the MeshEvent seam.
//!
//! Bootstrap note (v1): joining the gossip topic needs ≥1 peer id. Until the
//! rendezvous supernode lands (see ARCHITECTURE §M3), bootstrap ids come from
//! the `TETHER_IROH_BOOTSTRAP` env var (comma-separated).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use futures_util::TryStreamExt;
use iroh::{
address_lookup::MemoryLookup, endpoint::presets, protocol::Router, Endpoint, EndpointAddr,
EndpointId, RelayUrl,
};
use iroh_blobs::{store::mem::MemStore, BlobsProtocol, Hash};
use iroh_gossip::{
api::{Event, GossipSender},
net::{Gossip, GOSSIP_ALPN},
proto::TopicId,
};
use serde::{Deserialize, Serialize};
use crate::{Config, EventTx, MeshEvent, Message, Spawner, Transport};
/// Blob handles needed by `send_file` / blob downloads, set once online.
#[derive(Clone)]
struct Blobs {
store: MemStore,
endpoint: Endpoint,
my_id: String,
}
pub(crate) struct IrohTransport {
cfg: Config,
events: EventTx,
/// Set once the gossip topic is joined; used by `publish`.
sender: Arc<StdMutex<Option<GossipSender>>>,
/// Blob store + endpoint, set once online; used by `send_file` and downloads.
blobs: Arc<StdMutex<Option<Blobs>>>,
/// This node's iroh EndpointId, surfaced once online (for bootstrap wiring).
id: Arc<StdMutex<Option<String>>>,
}
impl IrohTransport {
pub(crate) fn new(cfg: Config, events: EventTx, id: Arc<StdMutex<Option<String>>>) -> Self {
Self {
cfg,
events,
sender: Arc::new(StdMutex::new(None)),
blobs: Arc::new(StdMutex::new(None)),
id,
}
}
}
/// A blob announcement, broadcast over gossip so peers can fetch the bytes P2P.
#[derive(Serialize, Deserialize)]
struct BlobAnnounce {
hash: String, // blake3 hash of the (sealed) bytes
name: String,
mime: String,
provider: String, // the sender's iroh EndpointId to download from
}
/// account/room → 32-byte gossip topic (consistent with room_for_account: the
/// room is already sha256(account)[..4], this is the full-width derivation).
fn topic_for(room: &str) -> TopicId {
use sha2::{Digest, Sha256};
let mut id = [0u8; 32];
id.copy_from_slice(&Sha256::digest(format!("tether:{room}").as_bytes()));
TopicId::from_bytes(id)
}
/// Env-var bootstrap (test/override path): comma-separated EndpointIds.
fn env_bootstrap_ids() -> Vec<EndpointId> {
std::env::var("TETHER_IROH_BOOTSTRAP")
.ok()
.into_iter()
.flat_map(|s| {
s.split(',')
.filter_map(|x| x.trim().parse::<EndpointId>().ok())
.collect::<Vec<_>>()
})
.collect()
}
/// {id, relay} JSON from the rendezvous → an EndpointAddr (id + optional relay).
/// The relay url is what lets a browser peer dial without discovery.
fn peer_to_addr(p: &serde_json::Value) -> Option<EndpointAddr> {
let id = p["id"].as_str()?.parse::<EndpointId>().ok()?;
let mut addr = EndpointAddr::new(id);
if let Some(relay) = p["relay"].as_str().filter(|s| !s.is_empty()) {
if let Ok(url) = relay.parse::<RelayUrl>() {
addr = addr.with_relay_url(url);
}
}
Some(addr)
}
/// Register this node (id + its relay url) with the rendezvous under our room and
/// get back the current peers as full addresses to bootstrap the gossip topic.
/// Zero-config: the room is a hash, content never touches the rendezvous.
async fn rendezvous_register(
server: &str,
room: &str,
my_id: &str,
my_relay: &str,
) -> Vec<EndpointAddr> {
if !(server.starts_with("http://") || server.starts_with("https://")) {
return Vec::new();
}
let url = format!("{}/rendezvous/register", server.trim_end_matches('/'));
let body = serde_json::json!({ "room": room, "id": my_id, "relay": my_relay });
// Bounded — a stuck rendezvous must never wedge the transport. On wasm the
// fetch backend has no Builder::timeout (the browser bounds it instead).
#[cfg(not(target_arch = "wasm32"))]
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()
.unwrap_or_default();
#[cfg(target_arch = "wasm32")]
let client = reqwest::Client::new();
let mut req = client.post(&url).json(&body);
// Optional bearer token, matching the rendezvous's TETHER_RENDEZVOUS_TOKEN.
if let Ok(token) = std::env::var("TETHER_RENDEZVOUS_TOKEN") {
if !token.is_empty() {
req = req.bearer_auth(token);
}
}
let resp = match req.send().await {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let Ok(v) = resp.json::<serde_json::Value>().await else {
return Vec::new();
};
v["peers"]
.as_array()
.map(|a| a.iter().filter_map(peer_to_addr).collect())
.unwrap_or_default()
}
impl IrohTransport {
/// Fetch a blob announced over gossip from its provider and surface it.
async fn fetch_blob(blobs: Blobs, events: EventTx, ann: BlobAnnounce) {
let (Ok(hash), Ok(provider)) =
(ann.hash.parse::<Hash>(), ann.provider.parse::<EndpointId>())
else {
return;
};
let downloader = blobs.store.downloader(&blobs.endpoint);
if let Err(e) = downloader.download(hash, Some(provider)).await {
eprintln!("tethercore[iroh]: blob download failed: {e}");
return;
}
match blobs.store.get_bytes(hash).await {
Ok(bytes) => {
eprintln!("tethercore[iroh]: fetched blob {:?} ({} bytes)", ann.name, bytes.len());
// Bytes are still sealed; the engine's File handler decrypts.
let _ = events.send(MeshEvent::File {
name: ann.name,
mime: ann.mime,
data: bytes.to_vec(),
});
}
Err(e) => eprintln!("tethercore[iroh]: blob read failed: {e}"),
}
}
}
impl Transport for IrohTransport {
fn name(&self) -> &'static str {
"iroh"
}
fn direct(&self) -> bool {
true // iroh chooses direct-vs-relay itself; treat as a direct peer channel
}
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
let this = self;
let spawner = rt.clone(); // for tasks spawned inside the transport loop
rt.spawn(async move {
// A static address book — we seed it with peers' relay addresses from
// the rendezvous so we can dial them without discovery (browsers can't
// resolve id→address otherwise: "Not enough addresses").
let lookup = MemoryLookup::new();
let endpoint = match Endpoint::builder(presets::N0)
.address_lookup(lookup.clone())
.bind()
.await
{
Ok(e) => e,
Err(e) => {
eprintln!("tethercore[iroh]: bind failed: {e}");
return;
}
};
endpoint.online().await;
let my_id = endpoint.id().to_string();
let my_relay = endpoint
.addr()
.relay_urls()
.next()
.map(|u| u.to_string())
.unwrap_or_default();
*this.id.lock().unwrap() = Some(my_id.clone());
eprintln!("tethercore[iroh]: online id={my_id} relay={my_relay}");
let gossip = Gossip::builder().spawn(endpoint.clone());
let store = MemStore::new();
let blobs_proto = BlobsProtocol::new(&store, None);
let router = Router::builder(endpoint.clone())
.accept(GOSSIP_ALPN, gossip.clone())
.accept(iroh_blobs::ALPN, blobs_proto)
.spawn();
*this.blobs.lock().unwrap() = Some(Blobs {
store: store.clone(),
endpoint: endpoint.clone(),
my_id: my_id.clone(),
});
// Bootstrap: env override (tests) + the rendezvous (zero-config).
eprintln!("tethercore[iroh]: transport ready; registering with rendezvous {}", this.cfg.server);
let mut addrs: Vec<EndpointAddr> =
env_bootstrap_ids().into_iter().map(EndpointAddr::new).collect();
addrs.extend(
rendezvous_register(&this.cfg.server, &this.cfg.room, &my_id, &my_relay).await,
);
// Seed peer addresses so the dial works without discovery.
for a in &addrs {
lookup.add_endpoint_info(a.clone());
}
let boot: Vec<EndpointId> = addrs.iter().map(|a| a.id).collect();
eprintln!("tethercore[iroh]: bootstrapping with {} peer(s)", boot.len());
// Keep re-registering so late-joining devices can bootstrap off us
// (rendezvous TTL is 90s; refresh well under it). Crucially, this also
// actively pulls newly-seen peers into our gossip active view: a node
// that registered first bootstrapped *alone* (empty subscribe_and_join),
// so without re-joining it would have a live magicsock path to a
// late-joiner but never form a gossip neighbor — messages would go
// nowhere. Seeding the lookup + join_peers each round fixes that.
{
let server = this.cfg.server.clone();
let room = this.cfg.room.clone();
let id = my_id.clone();
let relay = my_relay.clone();
let running = running.clone();
let lookup = lookup.clone();
let sender_slot = this.sender.clone();
spawner.spawn(async move {
while running.load(Ordering::SeqCst) {
n0_future::time::sleep(std::time::Duration::from_secs(15)).await;
let peers = rendezvous_register(&server, &room, &id, &relay).await;
for a in &peers {
lookup.add_endpoint_info(a.clone());
}
let ids: Vec<EndpointId> = peers.iter().map(|a| a.id).collect();
if !ids.is_empty() {
let sender = sender_slot.lock().unwrap().clone();
if let Some(sender) = sender {
let _ = sender.join_peers(ids).await;
}
}
}
});
}
let topic = topic_for(&this.cfg.room);
let sub = match gossip.subscribe_and_join(topic, boot).await {
Ok(s) => s,
Err(e) => {
eprintln!("tethercore[iroh]: topic join failed: {e}");
return;
}
};
let (sender, mut receiver) = sub.split();
*this.sender.lock().unwrap() = Some(sender);
let _ = this.events.send(MeshEvent::Status(true));
eprintln!("tethercore[iroh]: joined topic {topic}");
while running.load(Ordering::SeqCst) {
match receiver.try_next().await {
Ok(Some(Event::Received(msg))) => {
let Ok(m) = serde_json::from_slice::<Message>(&msg.content) else {
continue;
};
if m.from == this.cfg.from {
continue; // our own broadcast
}
if m.kind == "blob" {
// File announcement → fetch the bytes P2P, then emit File.
if let Ok(ann) = serde_json::from_str::<BlobAnnounce>(&m.text) {
if let Some(blobs) = this.blobs.lock().unwrap().clone() {
let events = this.events.clone();
spawner.spawn(Self::fetch_blob(blobs, events, ann));
}
}
} else {
let _ = this.events.send(MeshEvent::Message(m));
}
}
Ok(Some(Event::NeighborUp(id))) => {
eprintln!("tethercore[iroh]: neighbor up {id}");
let _ = this.events.send(MeshEvent::DirectUp(id.to_string()));
}
Ok(Some(Event::NeighborDown(id))) => {
eprintln!("tethercore[iroh]: neighbor down {id}");
let _ = this.events.send(MeshEvent::DirectDown(id.to_string()));
}
Ok(Some(_)) => {} // Lagged, etc.
Ok(None) => break, // stream ended
Err(e) => {
eprintln!("tethercore[iroh]: recv error: {e}");
break;
}
}
}
drop(router);
});
}
fn publish(&self, rt: &Spawner, msg: Message) {
// Clipboard + receipts ride gossip; other kinds are local-only.
if !(msg.kind.is_empty() || msg.kind == "clipboard" || msg.kind == "receipt") {
return;
}
let sender = self.sender.lock().unwrap().clone();
if let Some(sender) = sender {
let body = match serde_json::to_vec(&msg) {
Ok(b) => b,
Err(_) => return,
};
rt.spawn(async move {
if let Err(e) = sender.broadcast(body.into()).await {
eprintln!("tethercore[iroh]: broadcast failed: {e}");
}
});
}
}
/// Add the (already-sealed) bytes to the local blob store and broadcast a
/// blob announcement over gossip; peers fetch the bytes P2P by hash.
fn send_file(&self, rt: &Spawner, _id: String, name: String, mime: String, data: Vec<u8>) {
let blobs = self.blobs.lock().unwrap().clone();
let sender = self.sender.lock().unwrap().clone();
let cfg = self.cfg.clone();
let (Some(blobs), Some(sender)) = (blobs, sender) else {
return;
};
rt.spawn(async move {
let tag = match blobs.store.add_slice(&data).await {
Ok(t) => t,
Err(e) => {
eprintln!("tethercore[iroh]: blob add failed: {e}");
return;
}
};
let ann = BlobAnnounce {
hash: tag.hash.to_string(),
name,
mime,
provider: blobs.my_id.clone(),
};
let msg = Message {
kind: "blob".into(),
text: serde_json::to_string(&ann).unwrap_or_default(),
from: cfg.from.clone(),
to: String::new(),
role: String::new(),
source: cfg.source.clone(),
room: cfg.room.clone(),
ts: 0,
};
if let Ok(body) = serde_json::to_vec(&msg) {
let _ = sender.broadcast(body.into()).await;
}
});
}
}

312
core/src/lan.rs Normal file
View File

@@ -0,0 +1,312 @@
//! LAN transport — serverless same-network sync + file transfer over mDNS + TCP.
//!
//! Each engine advertises `_tether._tcp.local.` (TXT room/from/source/name/
//! account) and connects to peers in the same room — or sharing a non-empty
//! account — over a length-prefixed TCP framing:
//! [u32 BE len][u8 tag][payload]
//! tags: `H` hello · `J` JSON Message · `M`/`C`/`E` file frames (shared w/ RTC).
//! Lower `from` dials, to avoid double-connects.
//!
//! Scope/caveats: room/account is the only access control and the link is
//! plaintext — fine for a trusted LAN; rustls keyed on a room secret is the
//! follow-up. iOS needs the multicast entitlement for mDNS (inert until then).
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::{TcpListener, TcpStream};
use crate::Spawner;
use tokio::sync::Mutex;
use crate::{Config, EventTx, MeshEvent, Message, Peer, Transport};
const SERVICE: &str = "_tether._tcp.local.";
type Writer = Arc<Mutex<OwnedWriteHalf>>; // per-peer write half (lock per peer, not the map)
pub(crate) struct LanTransport {
cfg: Config,
peers: Arc<Mutex<HashMap<String, Writer>>>, // peer `from` → its write half
incoming: Arc<Mutex<HashMap<String, crate::IncomingFile>>>, // in-flight inbound files
events: EventTx, // emit discovered/direct/message/file/transfer up to the engine
}
impl LanTransport {
pub(crate) fn new(cfg: Config, events: EventTx) -> Self {
Self {
cfg,
peers: Arc::new(Mutex::new(HashMap::new())),
incoming: Arc::new(Mutex::new(HashMap::new())),
events,
}
}
}
async fn write_frame(w: &mut OwnedWriteHalf, tag: u8, payload: &[u8]) -> std::io::Result<()> {
let len = (1 + payload.len()) as u32;
w.write_all(&len.to_be_bytes()).await?;
w.write_all(&[tag]).await?;
w.write_all(payload).await
}
async fn read_frame(r: &mut OwnedReadHalf) -> std::io::Result<(u8, Vec<u8>)> {
let mut lenb = [0u8; 4];
r.read_exact(&mut lenb).await?;
let len = u32::from_be_bytes(lenb) as usize;
if len < 1 || len > 64 * 1024 * 1024 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad frame len"));
}
let mut tag = [0u8; 1];
r.read_exact(&mut tag).await?;
let mut payload = vec![0u8; len - 1];
r.read_exact(&mut payload).await?;
Ok((tag[0], payload))
}
impl Transport for LanTransport {
fn name(&self) -> &'static str {
"lan"
}
fn direct(&self) -> bool {
true
}
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
let this = self;
rt.spawn(async move {
if let Err(e) = this.run(running).await {
eprintln!("tethercore[lan]: {e}");
}
});
}
fn publish(&self, rt: &Spawner, msg: Message) {
let peers = self.peers.clone();
rt.spawn(async move {
let body = match serde_json::to_vec(&msg) {
Ok(b) => b,
Err(_) => return,
};
let writers: Vec<Writer> = peers.lock().await.values().cloned().collect();
for w in writers {
let mut g = w.lock().await;
let _ = write_frame(&mut g, b'J', &body).await;
}
});
}
fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec<u8>) {
let peers = self.peers.clone();
let events = self.events.clone();
rt.spawn(async move {
let writers: Vec<Writer> = peers.lock().await.values().cloned().collect();
if writers.is_empty() {
return;
}
let total = data.len() as u64;
crate::emit_transfer(&events, &id, &name, 0, total, false, false);
let frames = crate::file_frames(&id, &name, &mime, &data);
for w in writers {
let mut g = w.lock().await;
let mut sent = 0u64;
for (tag, payload) in &frames {
if write_frame(&mut g, *tag, payload).await.is_err() {
break;
}
if *tag == b'C' {
sent += (payload.len() - 16) as u64;
crate::emit_transfer(&events, &id, &name, sent, total, false, false);
}
}
}
crate::emit_transfer(&events, &id, &name, total, total, false, true);
});
}
}
impl LanTransport {
async fn run(self: &Arc<Self>, running: Arc<AtomicBool>) -> Result<(), String> {
let listener = TcpListener::bind("0.0.0.0:0")
.await
.map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let daemon = ServiceDaemon::new().map_err(|e| e.to_string())?;
let instance = sanitize(&self.cfg.from);
let props = [
("room", self.cfg.room.as_str()),
("from", self.cfg.from.as_str()),
("source", self.cfg.source.as_str()),
("name", self.cfg.name.as_str()),
("account", self.cfg.account.as_str()),
];
let info = ServiceInfo::new(
SERVICE,
&instance,
&format!("{instance}.local."),
"",
port,
&props[..],
)
.map_err(|e| e.to_string())?
.enable_addr_auto();
daemon.register(info).map_err(|e| e.to_string())?;
let browse = daemon.browse(SERVICE).map_err(|e| e.to_string())?;
// Accept inbound dials.
{
let this = self.clone();
let running = running.clone();
tokio::spawn(async move {
while running.load(Ordering::SeqCst) {
if let Ok((stream, _)) = listener.accept().await {
let this = this.clone();
tokio::spawn(async move { this.handle(stream).await });
}
}
});
}
// Discover peers; the lower `from` dials.
while running.load(Ordering::SeqCst) {
match browse.recv_async().await {
Ok(ServiceEvent::ServiceResolved(info)) => {
let room = info.get_property_val_str("room").unwrap_or("").to_string();
let from = info.get_property_val_str("from").unwrap_or("").to_string();
if from.is_empty() || from == self.cfg.from {
continue;
}
let name = info
.get_property_val_str("name")
.filter(|s| !s.is_empty())
.unwrap_or(&from)
.to_string();
let source = info.get_property_val_str("source").unwrap_or("").to_string();
let account = info.get_property_val_str("account").unwrap_or("").to_string();
let _ = self.events.send(MeshEvent::Discovered(Peer {
id: from.clone(),
name,
source,
room: room.clone(),
account: account.clone(),
}));
// Pair if same room OR same (non-empty) account; lower id dials.
let same_account =
!self.cfg.account.is_empty() && account == self.cfg.account;
if (room != self.cfg.room && !same_account)
|| self.cfg.from.as_str() >= from.as_str()
{
continue;
}
if self.peers.lock().await.contains_key(&from) {
continue;
}
let ip = info
.get_addresses_v4()
.into_iter()
.next()
.map(std::net::IpAddr::V4)
.or_else(|| info.get_addresses().iter().next().map(|s| s.to_ip_addr()));
if let Some(addr) = ip {
let sockaddr = SocketAddr::new(addr, info.get_port());
if let Ok(stream) = TcpStream::connect(sockaddr).await {
let this = self.clone();
tokio::spawn(async move { this.handle(stream).await });
}
}
}
Ok(_) => {}
Err(_) => break,
}
}
Ok(())
}
/// Handle one connection: hello handshake, then read frames — JSON messages
/// to the sink, file frames reassembled to the file sink.
async fn handle(self: &Arc<Self>, stream: TcpStream) {
let (mut rd, mut wr) = stream.into_split();
let hello = json!({
"from": self.cfg.from, "room": self.cfg.room, "account": self.cfg.account
})
.to_string();
if write_frame(&mut wr, b'H', hello.as_bytes()).await.is_err() {
return;
}
// Accept if we share a room OR a non-empty account.
let (tag, payload) = match read_frame(&mut rd).await {
Ok(f) => f,
Err(_) => return,
};
if tag != b'H' {
return;
}
let Ok(v) = serde_json::from_slice::<Value>(&payload) else {
return;
};
let same_room = v["room"].as_str() == Some(self.cfg.room.as_str());
let same_account =
!self.cfg.account.is_empty() && v["account"].as_str() == Some(self.cfg.account.as_str());
if !same_room && !same_account {
return;
}
let peer_from = v["from"].as_str().unwrap_or("").to_string();
if peer_from.is_empty() || peer_from == self.cfg.from {
return;
}
{
let mut map = self.peers.lock().await;
if map.contains_key(&peer_from) {
return; // already connected to this peer
}
map.insert(peer_from.clone(), Arc::new(Mutex::new(wr)));
}
let _ = self.events.send(MeshEvent::DirectUp(peer_from.clone())); // direct path up
eprintln!("tethercore[lan]: peer {peer_from} connected");
loop {
let (tag, payload) = match read_frame(&mut rd).await {
Ok(f) => f,
Err(_) => break,
};
match tag {
b'J' => {
if let Ok(m) = serde_json::from_slice::<Message>(&payload) {
if m.from != self.cfg.from {
let _ = self.events.send(MeshEvent::Message(m)); // engine routes + de-dups
}
}
}
b'M' | b'C' | b'E' => {
let done = {
let mut map = self.incoming.lock().await;
crate::apply_file_frame(&mut map, &self.events, tag, &payload)
};
if let Some((name, mime, bytes)) = done {
eprintln!("tethercore[lan]: received file {name:?} ({} bytes)", bytes.len());
let _ = self.events.send(MeshEvent::File { name, mime, data: bytes });
}
}
_ => {}
}
}
self.peers.lock().await.remove(&peer_from);
let _ = self.events.send(MeshEvent::DirectDown(peer_from.clone())); // relay needed again
eprintln!("tethercore[lan]: peer {peer_from} disconnected");
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}

920
core/src/lib.rs Normal file
View File

@@ -0,0 +1,920 @@
//! tethercore — the shared sync engine.
//!
//! Owns the wire protocol (codec + transports + reconnect/backoff + send) and
//! nothing else: no clipboard, no UI. Exposed via UniFFI behind a 4-method seam:
//!
//! Engine::new(server, room, from, source)
//! engine.start(handler) // inbound clipboard → handler.on_message
//! engine.send(text)
//! engine.stop()
//!
//! Transports are pluggable (see `Transport`). SSE ships today; RTC and BT slot
//! in beside it without touching the engine. The engine multiplexes outbound
//! across all transports and de-duplicates inbound so the same clipboard
//! arriving over two channels surfaces once.
// With the iroh-transport feature, the legacy SSE/RTC/LAN transports aren't
// constructed (IrohTransport replaces them); they still compile, so quiet the
// expected dead-code noise. Fully dropping their heavy deps (webrtc/mdns) for
// the iroh/wasm build is a follow-up.
#![cfg_attr(feature = "iroh-transport", allow(dead_code))]
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(not(target_arch = "wasm32"))]
use std::time::{Duration, Instant};
// On wasm, std's Instant::now() panics; web_time backs it with Performance.now().
#[cfg(target_arch = "wasm32")]
use web_time::{Duration, Instant};
#[cfg(not(target_arch = "wasm32"))]
use futures_util::StreamExt; // only the native SSE stream uses this
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
/// Cross-target task spawner. Native wraps a tokio runtime `Handle`; wasm uses
/// the browser event loop via `spawn_local`. This is what lets the engine +
/// transports compile on `wasm32` (no OS threads / tokio runtime there) — call
/// `rt.spawn(fut)` the same way on both. Futures are `Send` on native (thread
/// pool) and `?Send` on wasm (single JS thread).
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
pub(crate) struct Spawner(tokio::runtime::Handle);
#[cfg(not(target_arch = "wasm32"))]
impl Spawner {
pub(crate) fn spawn<F>(&self, fut: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
self.0.spawn(fut);
}
}
#[cfg(target_arch = "wasm32")]
#[derive(Clone)]
pub(crate) struct Spawner;
#[cfg(target_arch = "wasm32")]
impl Spawner {
pub(crate) fn spawn<F>(&self, fut: F)
where
F: std::future::Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(fut);
}
}
mod crypto;
#[cfg(feature = "iroh-transport")]
mod iroh;
// Legacy transports use OS sockets (tokio net / mdns / webrtc) — native only.
#[cfg(not(target_arch = "wasm32"))]
mod lan;
#[cfg(not(target_arch = "wasm32"))]
mod rtc;
// The browser's wasm-bindgen surface (iroh-only build).
#[cfg(all(target_arch = "wasm32", feature = "iroh-transport"))]
mod wasm;
use crypto::Crypto;
// UniFFI is the native FFI surface (Swift/Kotlin). On wasm the bindings come
// from wasm-bindgen instead, so gate all of it out for wasm32.
#[cfg(not(target_arch = "wasm32"))]
uniffi::setup_scaffolding!();
/// 1:1 with the Go server `Message`, minus the RTC `signal` blob.
/// `type` is renamed to `kind` (reserved word in the target languages).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))]
pub struct Message {
#[serde(rename = "type", default)]
pub kind: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub from: String,
#[serde(default)]
pub to: String,
#[serde(default)]
pub role: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub room: String,
#[serde(default)]
pub ts: i64,
}
/// Implemented on the foreign side (Swift/Kotlin) or natively (desktop agent).
/// Callbacks fire on the engine's runtime thread — marshal to the UI thread
/// on the consumer side.
// Native: Send + Sync (engine runs on a tokio thread pool) + a UniFFI callback.
// wasm: single JS thread, so no Send/Sync — a JS callback (js_sys::Function)
// isn't Send, and the engine spawns via spawn_local where it isn't needed.
#[cfg(not(target_arch = "wasm32"))]
#[uniffi::export(callback_interface)]
pub trait MessageHandler: Send + Sync {
fn on_message(&self, msg: Message);
fn on_status(&self, connected: bool);
/// A file/image received P2P (RTC data channel), reassembled from chunks.
fn on_file(&self, name: String, mime: String, data: Vec<u8>);
}
#[cfg(target_arch = "wasm32")]
pub trait MessageHandler {
fn on_message(&self, msg: Message);
fn on_status(&self, connected: bool);
fn on_file(&self, name: String, mime: String, data: Vec<u8>);
}
/// A tether device discovered on the local network (mDNS). Surfaced to the UI
/// for an AirDrop-style "nearby devices" list. `room` lets the UI offer to pair
/// (join that device's room).
#[derive(Clone, Debug)]
#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))]
pub struct Peer {
pub id: String, // stable device id (`from`)
pub name: String, // friendly name, e.g. "Patrick's MacBook"
pub source: String, // platform: macos / ios / android / agent
pub room: String, // the room this device is currently in
pub account: String, // shared identity (Apple/Google sub); empty if signed out
}
/// Discovered-peer table, shared between the LAN transport (writer) and the
/// engine's `nearby()` (reader). Instant is last-seen for staleness pruning.
type Discovered = Arc<Mutex<HashMap<String, (Peer, Instant)>>>;
/// A delivery acknowledgement: device `from` (named `name`) received a clipboard
/// whose text matches `text`. Lets the sender show "delivered to <name>" instead
/// of asking. Auto-sent by the engine on every inbound clipboard.
#[derive(Clone, Debug)]
#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))]
pub struct Receipt {
pub from: String, // device that received it
pub name: String, // its friendly name
pub source: String, // its platform
pub text: String, // the clipboard text it received (for matching)
pub ts: i64,
}
type Receipts = Arc<Mutex<Vec<(Receipt, Instant)>>>;
/// Peers reachable over a direct channel right now (RTC data channel or LAN TCP),
/// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether
/// the relay is still needed.
type DirectSet = Arc<Mutex<HashSet<String>>>;
/// Peers currently in the room, by `from`, with their Peer info and last-seen
/// (via presence chirps). Drives both `send`'s coverage check and `present()`.
type PresentSet = Arc<Mutex<HashMap<String, (Peer, Instant)>>>;
/// Short content fingerprint — a receipt carries this, never the clipboard text,
/// so acks can't leak content (sender matches by computing the same fingerprint).
fn fingerprint(text: &str) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(text.as_bytes())[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
/// True when every present peer is reachable over a direct channel.
fn directly_covered(direct: &DirectSet, present: &PresentSet) -> bool {
let d = direct.lock().unwrap();
let now = Instant::now();
let mut p = present.lock().unwrap();
p.retain(|_, (_, t)| now.duration_since(*t) < Duration::from_secs(20));
!p.is_empty() && p.keys().all(|x| d.contains(x))
}
/// Canonical room id for a shared identity: sha256(account)[..4] as 8 hex chars.
/// Defined once here and exported so every client (Swift/Kotlin/agent) derives
/// the SAME room — that's what puts all of one account's devices together across
/// LAN, relay, and RTC. Empty account → empty (caller falls back to a device id).
#[cfg_attr(not(target_arch = "wasm32"), uniffi::export)]
pub fn room_for_account(account: String) -> String {
use sha2::{Digest, Sha256};
if account.is_empty() {
return String::new();
}
let digest = Sha256::digest(account.as_bytes());
digest[..4].iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(Clone)]
struct Config {
server: String,
room: String,
from: String,
source: String,
name: String, // friendly device name advertised over mDNS
account: String, // shared identity; same-account peers pair on the LAN
}
/// The Mesh→Sync seam. Transports emit these *up* to the engine; they never
/// touch engine state (presence, reachability, discovery, transfers) directly.
/// The engine owns all derived state and reacts to these events on one loop —
/// which is what lets a whole transport (e.g. Iroh) be swapped in as just
/// another event source without disturbing the Sync/App tiers above.
pub(crate) enum MeshEvent {
/// Inbound message (clipboard/receipt) — engine decrypts, de-dups, acks, delivers.
Message(Message),
/// Link/connection status (today: the SSE stream coming up or going down).
Status(bool),
/// A complete inbound file, already reassembled (still sealed; engine decrypts).
File { name: String, mime: String, data: Vec<u8> },
/// A peer announced presence in our room (RTC presence chirp).
Present(Peer),
/// A direct channel to a peer came up / went down (RTC data channel · LAN TCP).
DirectUp(String),
DirectDown(String),
/// A device was discovered on the LAN (mDNS), for the nearby() picker.
Discovered(Peer),
/// A file-transfer progress snapshot for the UI, keyed by file id.
Transfer { id: String, t: Transfer },
}
/// Channel a transport holds to emit `MeshEvent`s to the engine's event loop.
pub(crate) type EventTx = UnboundedSender<MeshEvent>;
/// A file being reassembled from inbound chunks (keyed by its id). Shared by the
/// RTC and LAN transports, which use the same M/C/E frame protocol. `total` is
/// the declared size (from the meta frame) so progress can be emitted without
/// reading any engine-side state.
pub(crate) struct IncomingFile {
pub name: String,
pub mime: String,
pub total: u64,
pub data: Vec<u8>,
}
/// Progress of an in-flight file transfer, for the UI.
#[derive(Clone, Debug)]
#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))]
pub struct Transfer {
pub name: String,
pub received: u64, // bytes transferred so far
pub total: u64, // total bytes
pub incoming: bool, // true = receiving, false = sending
pub done: bool,
}
/// Transfer table, keyed by file id, with last-update time for pruning.
pub(crate) type Transfers = Arc<Mutex<HashMap<String, (Transfer, Instant)>>>;
/// Build the frames for a file: M<json meta>, then C<16-byte id><chunk>…, then
/// E<16-byte id>. Each is (tag, payload); the transport adds its own envelope
/// (RTC: tag-prefixed binary message; LAN: length-prefixed).
pub(crate) fn file_frames(id: &str, name: &str, mime: &str, data: &[u8]) -> Vec<(u8, Vec<u8>)> {
let mut out = Vec::new();
let meta = serde_json::json!({ "id": id, "name": name, "mime": mime, "size": data.len() }).to_string();
out.push((b'M', meta.into_bytes()));
let idb = id.as_bytes();
for chunk in data.chunks(16 * 1024) {
let mut p = Vec::with_capacity(16 + chunk.len());
p.extend_from_slice(idb);
p.extend_from_slice(chunk);
out.push((b'C', p));
}
out.push((b'E', idb.to_vec()));
out
}
/// Emit a transfer-progress snapshot to the engine. Both transports build these
/// from locally-known values (no engine state read), keeping the Mesh→Sync seam
/// one-directional. A dropped receiver (engine stopped) is ignored.
pub(crate) fn emit_transfer(
events: &EventTx,
id: &str,
name: &str,
received: u64,
total: u64,
incoming: bool,
done: bool,
) {
let _ = events.send(MeshEvent::Transfer {
id: id.to_string(),
t: Transfer { name: name.to_string(), received, total, incoming, done },
});
}
/// Apply one inbound file frame to the transport-local reassembly map, emitting
/// a transfer-progress event off `events`; returns the completed (name, mime,
/// data) on the 'E' frame. Progress is derived from the buffer we already hold,
/// so the transport never reads engine state.
pub(crate) fn apply_file_frame(
map: &mut HashMap<String, IncomingFile>,
events: &EventTx,
tag: u8,
rest: &[u8],
) -> Option<(String, String, Vec<u8>)> {
match tag {
b'M' => {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(rest) {
let id = v["id"].as_str().unwrap_or("").to_string();
if !id.is_empty() {
let name = v["name"].as_str().unwrap_or("file").to_string();
let total = v["size"].as_u64().unwrap_or(0);
map.insert(
id.clone(),
IncomingFile {
name: name.clone(),
mime: v["mime"].as_str().unwrap_or("application/octet-stream").to_string(),
total,
data: Vec::new(),
},
);
emit_transfer(events, &id, &name, 0, total, true, false);
}
}
None
}
b'C' if rest.len() >= 16 => {
let id = String::from_utf8_lossy(&rest[..16]).to_string();
if let Some(f) = map.get_mut(&id) {
f.data.extend_from_slice(&rest[16..]);
emit_transfer(events, &id, &f.name, f.data.len() as u64, f.total, true, false);
}
None
}
b'E' if rest.len() >= 16 => {
let id = String::from_utf8_lossy(&rest[..16]).to_string();
let done = map.remove(&id).map(|f| (f.name, f.mime, f.total, f.data));
if let Some((name, _, total, _)) = &done {
emit_transfer(events, &id, name, *total, *total, true, true);
}
done.map(|(name, mime, _, data)| (name, mime, data))
}
_ => None,
}
}
/// A pluggable delivery channel. The engine owns one or more; today SSE, RTC,
/// and LAN. BT (BLE GATT) / QUIC implement the same trait. A transport emits
/// inbound traffic and link state as `MeshEvent`s (handed an `EventTx` at
/// construction) and never touches engine state directly.
trait Transport: Send + Sync {
fn name(&self) -> &'static str;
/// True for a *direct* peer channel (RTC data channel, LAN TCP) — i.e. one
/// that can carry files and that satisfies the relay-suppression coverage
/// check. Typed capability, so the engine never matches on `name()`.
fn direct(&self) -> bool {
false
}
/// True for the relay floor (SSE) — content rides it only when no direct
/// channel already covers every present peer.
fn is_relay(&self) -> bool {
false
}
/// Spawn the inbound loop on `rt`; emit traffic/link/file events off the
/// `EventTx` held since construction. Runs until `running` clears.
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>);
/// Publish an outbound message. Fire-and-forget.
fn publish(&self, rt: &Spawner, msg: Message);
/// Send a file/image. Only direct transports implement this; others no-op
/// (files are never relayed).
fn send_file(&self, _rt: &Spawner, _id: String, _name: String, _mime: String, _data: Vec<u8>) {}
}
#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))]
pub struct Engine {
cfg: Config,
// Native owns a tokio runtime; wasm uses the browser event loop (no field).
#[cfg(not(target_arch = "wasm32"))]
rt: Arc<tokio::runtime::Runtime>,
running: Arc<AtomicBool>,
transports: Vec<Arc<dyn Transport>>,
dedup: Arc<Mutex<Dedup>>,
discovered: Discovered,
receipts: Receipts,
direct: DirectSet,
present: PresentSet,
file_counter: AtomicU64,
transfers: Transfers,
crypto: Crypto,
/// Kept alive so the event loop's channel never closes while the engine
/// lives; transports hold clones to emit on.
_events_tx: EventTx,
/// Moved into the event loop on the first `start()`.
events_rx: Mutex<Option<UnboundedReceiver<MeshEvent>>>,
/// The event loop is spawned once and survives stop()/start() cycles.
loop_started: AtomicBool,
/// Current message handler, swapped in on each `start()` (latest wins, as
/// before) and read by the event loop per event.
handler: Arc<Mutex<Option<Arc<dyn MessageHandler>>>>,
/// This node's iroh EndpointId once online (for bootstrap wiring in tests).
#[cfg(feature = "iroh-transport")]
iroh_id: Arc<Mutex<Option<String>>>,
}
// Internal (non-FFI) helpers — kept out of the uniffi::export impl so the
// non-FFI `Spawner` return type doesn't leak into the bindings.
impl Engine {
/// A task spawner for this engine — tokio runtime on native, the JS event
/// loop on wasm.
fn spawner(&self) -> Spawner {
#[cfg(not(target_arch = "wasm32"))]
return Spawner(self.rt.handle().clone());
#[cfg(target_arch = "wasm32")]
return Spawner;
}
}
#[cfg_attr(not(target_arch = "wasm32"), uniffi::export)]
impl Engine {
/// `server` is the bus base URL, e.g. "http://192.168.11.233:8765".
/// `from` is this device's stable id (echo suppression); `source` a label.
/// `name` is the friendly device name shown in other devices' nearby lists.
/// `account` is a shared identity (Apple/Google `sub`); devices with the
/// same non-empty account auto-pair on the LAN regardless of room. Pass ""
/// when signed out.
#[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)]
pub fn new(
server: String,
room: String,
from: String,
source: String,
name: String,
account: String,
) -> Arc<Self> {
// Native runs the engine on a tokio runtime; wasm uses the JS event loop.
#[cfg(not(target_arch = "wasm32"))]
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("tokio runtime");
let crypto = Crypto::from_secret(&account);
let cfg = Config { server, room, from, source, name, account };
let discovered: Discovered = Arc::new(Mutex::new(HashMap::new()));
let direct: DirectSet = Arc::new(Mutex::new(HashSet::new()));
let present: PresentSet = Arc::new(Mutex::new(HashMap::new()));
let transfers: Transfers = Arc::new(Mutex::new(HashMap::new()));
// The Mesh→Sync event channel: transports emit, the engine's loop owns
// all state and reacts. Transports get a sender at construction; the
// engine keeps one clone alive so the loop never sees the channel close.
let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel::<MeshEvent>();
// M4: with the iroh-transport feature, one iroh-backed transport replaces
// the hand-rolled SSE/RTC/LAN stack. Default build is unchanged.
#[cfg(feature = "iroh-transport")]
let iroh_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
#[cfg(feature = "iroh-transport")]
let transports: Vec<Arc<dyn Transport>> = vec![Arc::new(iroh::IrohTransport::new(
cfg.clone(),
events_tx.clone(),
iroh_id.clone(),
))];
#[cfg(not(feature = "iroh-transport"))]
let transports: Vec<Arc<dyn Transport>> = vec![
Arc::new(SseTransport {
cfg: cfg.clone(),
http: reqwest::Client::new(),
events: events_tx.clone(),
}),
Arc::new(rtc::RtcTransport::new(cfg.clone(), events_tx.clone())),
Arc::new(lan::LanTransport::new(cfg.clone(), events_tx.clone())),
];
Arc::new(Self {
cfg,
#[cfg(not(target_arch = "wasm32"))]
rt: Arc::new(rt),
running: Arc::new(AtomicBool::new(false)),
transports,
dedup: Arc::new(Mutex::new(Dedup::default())),
discovered,
receipts: Arc::new(Mutex::new(Vec::new())),
direct,
present,
file_counter: AtomicU64::new(0),
transfers,
crypto,
_events_tx: events_tx,
events_rx: Mutex::new(Some(events_rx)),
loop_started: AtomicBool::new(false),
handler: Arc::new(Mutex::new(None)),
#[cfg(feature = "iroh-transport")]
iroh_id,
})
}
/// In-flight file transfers (send + receive) for a progress UI. Completed
/// ones linger ~6s so the UI can show "done", then are pruned.
pub fn transfers(&self) -> Vec<Transfer> {
let now = Instant::now();
let mut map = self.transfers.lock().unwrap();
map.retain(|_, (t, ts)| !(t.done && now.duration_since(*ts) > Duration::from_secs(6)));
map.values().map(|(t, _)| t.clone()).collect()
}
/// Send a file/image to the room. Direct (RTC) only — files are never
/// relayed. Chunked + reassembled on the receiver, surfaced via on_file.
pub fn send_file(&self, name: String, mime: String, data: Vec<u8>) {
let n = self.file_counter.fetch_add(1, Ordering::SeqCst);
let id = format!("{:016x}", n);
let data = self.crypto.seal(&data); // E2E: chunks carry ciphertext
let rt = self.spawner();
for t in &self.transports {
// Direct transports only — files are never relayed over SSE.
if t.direct() {
t.send_file(&rt, id.clone(), name.clone(), mime.clone(), data.clone());
}
}
}
/// Delivery acks received for clipboards we sent — "seen by <name>". Pruned
/// after 2 minutes. Poll alongside your sent items to show read state.
pub fn receipts(&self) -> Vec<Receipt> {
let now = Instant::now();
let mut v = self.receipts.lock().unwrap();
v.retain(|(_, t)| now.duration_since(*t) < Duration::from_secs(120));
v.iter().map(|(r, _)| r.clone()).collect()
}
/// Nearby tether devices seen on the local network (mDNS), for an
/// AirDrop-style picker. Stale entries (>30s unseen) are pruned. Empty on
/// iOS until the multicast entitlement lands.
pub fn nearby(&self) -> Vec<Peer> {
let now = Instant::now();
let mut map = self.discovered.lock().unwrap();
map.retain(|_, (_, seen)| now.duration_since(*seen) < Duration::from_secs(30));
map.values().map(|(p, _)| p.clone()).collect()
}
/// Devices currently in your room (online via presence chirps), across any
/// network — for a "who's here" indicator. Pruned after 20s unseen.
pub fn present(&self) -> Vec<Peer> {
let now = Instant::now();
let mut p = self.present.lock().unwrap();
p.retain(|_, (_, t)| now.duration_since(*t) < Duration::from_secs(20));
p.values().map(|(peer, _)| peer.clone()).collect()
}
/// Begin streaming on every transport. Idempotent while already running.
pub fn start(&self, handler: Box<dyn MessageHandler>) {
// Latest handler wins (preserves pre-event-loop semantics on reconnect).
*self.handler.lock().unwrap() = Some(Arc::from(handler));
if self.running.swap(true, Ordering::SeqCst) {
return;
}
// Spawn the single event loop once. It survives stop()/start() cycles and
// is the sole owner/writer of engine state (presence, reachability,
// discovery, transfers, dedup, receipts) — transports only emit events.
if !self.loop_started.swap(true, Ordering::SeqCst) {
let mut rx = self
.events_rx
.lock()
.unwrap()
.take()
.expect("event receiver is taken exactly once");
let handler = self.handler.clone();
let dedup = self.dedup.clone();
let receipts = self.receipts.clone();
let cfg = self.cfg.clone();
let transports = self.transports.clone();
let spawner = self.spawner();
let direct = self.direct.clone();
let present = self.present.clone();
let discovered = self.discovered.clone();
let transfers = self.transfers.clone();
let crypto = self.crypto.clone();
self.spawner().spawn(async move {
// File de-dup: the same file can arrive over both RTC and LAN.
let mut file_seen: VecDeque<(String, Instant)> = VecDeque::new();
while let Some(ev) = rx.recv().await {
match ev {
// ── connectivity / presence / discovery: just update state ──
MeshEvent::Status(c) => {
let h = handler.lock().unwrap().clone();
if let Some(h) = h {
h.on_status(c);
}
}
MeshEvent::DirectUp(id) => {
direct.lock().unwrap().insert(id);
}
MeshEvent::DirectDown(id) => {
direct.lock().unwrap().remove(&id);
}
MeshEvent::Present(p) => {
present.lock().unwrap().insert(p.id.clone(), (p, Instant::now()));
}
MeshEvent::Discovered(p) => {
discovered.lock().unwrap().insert(p.id.clone(), (p, Instant::now()));
}
MeshEvent::Transfer { id, t } => {
transfers.lock().unwrap().insert(id, (t, Instant::now()));
}
// ── inbound message: receipt-record OR decrypt+dedup+ack+deliver ──
MeshEvent::Message(mut m) => {
if m.kind == "receipt" {
if m.to == cfg.from {
let mut v = receipts.lock().unwrap();
if !v.iter().any(|(r, _)| r.from == m.from && r.text == m.text) {
v.push((
Receipt {
from: m.from,
name: m.role, // sender packs its name in `role`
source: m.source,
text: m.text,
ts: m.ts,
},
Instant::now(),
));
}
}
continue;
}
// Clipboard: undo the always-on base64 layer, then
// decrypt if enabled (open_b64 passes through when not).
// Symmetric with send's unconditional seal_b64.
match crypto.open_b64(&m.text) {
Some(plain) => m.text = plain,
None => continue, // not for us / wrong key
}
if m.text.is_empty() {
continue;
}
if dedup.lock().unwrap().seen(&m) {
continue; // already delivered via another transport
}
if !m.from.is_empty() && m.from != cfg.from {
let ack = Message {
kind: "receipt".into(),
text: fingerprint(&m.text), // hash, not the content
from: cfg.from.clone(),
to: m.from.clone(),
role: cfg.name.clone(), // our friendly name for the receipt
source: cfg.source.clone(),
room: cfg.room.clone(),
ts: 0,
};
let covered = directly_covered(&direct, &present);
for t in &transports {
if covered && t.is_relay() {
continue; // keep the ack off the relay when P2P covers
}
t.publish(&spawner, ack.clone());
}
}
let h = handler.lock().unwrap().clone();
if let Some(h) = h {
h.on_message(m);
}
}
// ── inbound file: dedup across transports, decrypt, deliver ──
MeshEvent::File { name, mime, data } => {
use sha2::{Digest, Sha256};
let mut hh = Sha256::new();
hh.update(name.as_bytes());
hh.update(&data);
let key: String = hh.finalize().iter().map(|b| format!("{b:02x}")).collect();
let now = Instant::now();
file_seen.retain(|(_, t)| now.duration_since(*t) < Duration::from_secs(15));
if file_seen.iter().any(|(k, _)| k == &key) {
continue;
}
file_seen.push_back((key, now));
// Decrypt the reassembled payload (chunks carried ciphertext).
let plain = match crypto.open(&data) {
Some(d) => d,
None => continue, // not for us / wrong key
};
let h = handler.lock().unwrap().clone();
if let Some(h) = h {
h.on_file(name, mime, plain);
}
}
}
}
});
}
for t in &self.transports {
t.clone().start(self.spawner(), self.running.clone());
}
}
/// Publish clipboard text to the room. Always sent over the direct
/// transports (RTC/LAN); sent over the SSE relay ONLY when a direct path
/// doesn't already reach every present peer — so once P2P is up the server
/// never sees the payload (it stays just signaling + fallback).
pub fn send(&self, text: String) {
let msg = Message {
kind: "clipboard".into(),
text: self.crypto.seal_b64(&text), // E2E: every transport carries ciphertext
from: self.cfg.from.clone(),
to: String::new(),
role: String::new(),
source: self.cfg.source.clone(),
room: self.cfg.room.clone(),
ts: 0, // server stamps authoritative ts
};
let covered = self.directly_covered();
let rt = self.spawner();
for t in &self.transports {
if covered && t.is_relay() {
continue; // direct channels reach everyone → keep content off the relay
}
t.publish(&rt, msg.clone());
}
}
/// True when every peer currently present in the room is reachable over a
/// direct channel — i.e. the relay isn't needed for delivery. False if we
/// don't yet know who's present (be safe, relay).
fn directly_covered(&self) -> bool {
directly_covered(&self.direct, &self.present)
}
/// Stop all transports at their next checkpoint.
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
}
}
/// Non-FFI helpers (iroh feature only) — used by the M4 integration example to
/// wire bootstrap between two in-process engines.
#[cfg(feature = "iroh-transport")]
impl Engine {
/// This node's iroh EndpointId once the transport is online, else None.
pub fn iroh_id(&self) -> Option<String> {
self.iroh_id.lock().unwrap().clone()
}
}
/// Short-window de-dup so a clipboard echoed over both SSE and the RTC data
/// channel surfaces once. Keyed on (from, text) — NOT ts: the SSE copy carries
/// the server's stamp while the data-channel copy has none, so ts must be
/// ignored. The 3s window is the cost: identical text re-sent within it is
/// suppressed, which for clipboard is an acceptable trade.
#[derive(Default)]
struct Dedup {
recent: VecDeque<(String, String, Instant)>,
}
impl Dedup {
fn seen(&mut self, m: &Message) -> bool {
let now = Instant::now();
self.recent
.retain(|(_, _, t)| now.duration_since(*t) < Duration::from_secs(3));
if self
.recent
.iter()
.any(|(f, x, _)| f == &m.from && x == &m.text)
{
return true;
}
self.recent.push_back((m.from.clone(), m.text.clone(), now));
false
}
}
// ---------------------------------------------------------------------------
// SSE transport — the reliable floor: server relay over text/event-stream.
// Native only (reqwest streaming / bytes_stream isn't on the wasm fetch backend).
// ---------------------------------------------------------------------------
#[cfg(not(target_arch = "wasm32"))]
struct SseTransport {
cfg: Config,
http: reqwest::Client,
events: EventTx,
}
#[cfg(not(target_arch = "wasm32"))]
impl Transport for SseTransport {
fn name(&self) -> &'static str {
"sse"
}
fn is_relay(&self) -> bool {
true
}
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
let this = self;
rt.spawn(async move {
let min = Duration::from_millis(500);
let max = Duration::from_secs(10);
let mut backoff = min;
// Report a drop / log an error only once per outage streak — a dead
// or absent server (LAN-only mode) must not spam every retry.
let mut down_reported = false;
while running.load(Ordering::SeqCst) {
match this.stream_once(&running).await {
Ok(()) => {
backoff = min; // clean EOF — reconnect promptly
down_reported = false;
}
Err(e) => {
if !down_reported {
let _ = this.events.send(MeshEvent::Status(false));
eprintln!("tethercore[{}]: {e}", this.name());
down_reported = true;
}
}
}
if running.load(Ordering::SeqCst) {
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(max);
}
}
});
}
fn publish(&self, rt: &Spawner, msg: Message) {
let http = self.http.clone();
let cfg = self.cfg.clone();
rt.spawn(async move {
// A failed send is already implied by the connection-state log;
// don't log per-send (every clipboard change would spam offline).
let _ = post(&http, &cfg, &msg).await;
});
}
}
#[cfg(not(target_arch = "wasm32"))]
impl SseTransport {
async fn stream_once(&self, running: &AtomicBool) -> Result<(), String> {
let url = format!(
"{}/api/stream?room={}",
self.cfg.server.trim_end_matches('/'),
self.cfg.room
);
let resp = self
.http
.get(&url)
.header("X-Tether-Client", &self.cfg.from)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let _ = self.events.send(MeshEvent::Status(true));
// Byte-buffer the stream; lines split on '\n' (ASCII, so chunk-safe).
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
let mut data: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
if !running.load(Ordering::SeqCst) {
return Ok(());
}
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
while let Some(nl) = buf.iter().position(|&b| b == b'\n') {
let mut line: Vec<u8> = buf.drain(..=nl).collect();
line.pop(); // '\n'
if line.last() == Some(&b'\r') {
line.pop();
}
if line.is_empty() {
if !data.is_empty() {
if let Ok(m) = serde_json::from_slice::<Message>(&data) {
// Clipboard + receipts go to the engine; presence/signal
// chirps belong to the RTC layer, not here.
let kind = &m.kind;
if m.from != self.cfg.from
&& (kind.is_empty() || kind == "clipboard" || kind == "receipt")
{
let _ = self.events.send(MeshEvent::Message(m)); // engine routes + de-dups
}
}
data.clear();
}
} else if let Some(rest) = line.strip_prefix(b"data:") {
let rest = rest.strip_prefix(b" ").unwrap_or(rest);
data.extend_from_slice(rest);
}
// ':' comments (keepalives) and event:/id: lines are ignored.
}
}
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn post(http: &reqwest::Client, cfg: &Config, msg: &Message) -> Result<(), String> {
let url = format!("{}/api/send", cfg.server.trim_end_matches('/'));
let kind = if msg.kind.is_empty() { "clipboard" } else { msg.kind.as_str() };
// Full envelope so receipts (type/to/role) survive the relay, not just text.
let body = serde_json::json!({
"type": kind,
"text": msg.text,
"from": msg.from,
"to": msg.to,
"role": msg.role,
"source": msg.source,
"room": cfg.room,
});
http.post(&url)
.header("X-Tether-Source", &cfg.source)
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}

593
core/src/rtc.rs Normal file
View File

@@ -0,0 +1,593 @@
//! RTC transport — direct P2P clipboard over a WebRTC data channel.
//!
//! Signaling rides the existing server bus (`type:"signal"` / `"presence"`),
//! so this interoperates with the browser web client byte-for-byte:
//! - presence chirp: {type:"presence", role, from, room}
//! - deterministic initiator: the lower `from` id creates the offer
//! - signal: {type:"signal", to, from, signal:{kind, sdp|candidate}}
//! - data channel "tether" carries RAW clipboard text, both directions
//!
//! The data channel bypasses the relay entirely; the engine's dedup collapses
//! the SSE and RTC copies of the same clipboard into one.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use bytes::Bytes;
use futures_util::StreamExt;
use serde_json::{json, Value};
use crate::Spawner;
use tokio::sync::Mutex;
use webrtc::api::media_engine::MediaEngine;
use webrtc::api::{APIBuilder, API};
use webrtc::data_channel::data_channel_message::DataChannelMessage;
use webrtc::data_channel::RTCDataChannel;
use webrtc::ice_transport::ice_candidate::{RTCIceCandidate, RTCIceCandidateInit};
use webrtc::ice_transport::ice_server::RTCIceServer;
use webrtc::peer_connection::configuration::RTCConfiguration;
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
use webrtc::peer_connection::RTCPeerConnection;
use crate::{Config, EventTx, MeshEvent, Message, Transport};
struct Peer {
pc: Arc<RTCPeerConnection>,
dc: Mutex<Option<Arc<RTCDataChannel>>>,
offerer: bool,
}
pub(crate) struct RtcTransport {
cfg: Config,
http: reqwest::Client,
api: Arc<API>,
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
incoming: Arc<Mutex<HashMap<String, crate::IncomingFile>>>, // in-flight inbound files
ice: StdMutex<Vec<RTCIceServer>>, // refreshed from /api/turn-cred at start
events: EventTx, // emit presence/direct/message/file/transfer up to the engine
}
fn default_ice() -> Vec<RTCIceServer> {
vec![RTCIceServer {
urls: vec!["stun:stun.l.google.com:19302".to_owned()],
..Default::default()
}]
}
impl RtcTransport {
pub(crate) fn new(cfg: Config, events: EventTx) -> Self {
// Data-channel-only: a bare media engine, no codecs/interceptors needed.
let api = APIBuilder::new()
.with_media_engine(MediaEngine::default())
.build();
RtcTransport {
cfg,
http: reqwest::Client::new(),
api: Arc::new(api),
peers: Arc::new(Mutex::new(HashMap::new())),
incoming: Arc::new(Mutex::new(HashMap::new())),
ice: StdMutex::new(default_ice()),
events,
}
}
fn rtc_config(&self) -> RTCConfiguration {
RTCConfiguration {
ice_servers: self.ice.lock().unwrap().clone(),
..Default::default()
}
}
/// Fetch server-issued ICE servers (STUN + short-lived TURN creds) from
/// `/api/turn-cred`. This is what makes cross-network P2P actually connect
/// when hole-punching fails (symmetric NAT / CGNAT). Falls back to STUN.
async fn refresh_ice(&self) {
let url = format!("{}/api/turn-cred", self.cfg.server.trim_end_matches('/'));
let resp = match self.http.get(&url).send().await {
Ok(r) if r.status().is_success() => r,
_ => return, // keep the STUN fallback
};
let Ok(v) = resp.json::<serde_json::Value>().await else {
return;
};
let Some(arr) = v["iceServers"].as_array() else {
return;
};
let mut servers = Vec::new();
for s in arr {
let urls: Vec<String> = s["urls"]
.as_array()
.map(|a| a.iter().filter_map(|u| u.as_str().map(String::from)).collect())
.unwrap_or_default();
if urls.is_empty() {
continue;
}
servers.push(RTCIceServer {
urls,
username: s["username"].as_str().unwrap_or("").to_string(),
credential: s["credential"].as_str().unwrap_or("").to_string(),
..Default::default()
});
}
if !servers.is_empty() {
*self.ice.lock().unwrap() = servers;
}
}
// ── bus helpers ────────────────────────────────────────────────────────
async fn post(&self, body: Value) {
let url = format!("{}/api/send", self.cfg.server.trim_end_matches('/'));
let _ = self
.http
.post(&url)
.header("X-Tether-Source", &self.cfg.source)
.json(&body)
.send()
.await;
}
async fn post_presence(&self) {
self.post(json!({
"type": "presence", "role": self.cfg.source,
"from": self.cfg.from, "source": self.cfg.source,
"text": self.cfg.name, // friendly name for the "who's here" list
"room": self.cfg.room,
}))
.await;
}
async fn post_signal(&self, to: &str, signal: Value) {
self.post(json!({
"type": "signal", "to": to, "signal": signal,
"from": self.cfg.from, "room": self.cfg.room,
}))
.await;
}
// ── peer setup ─────────────────────────────────────────────────────────
/// Build a peer connection, wire ICE/state/data-channel callbacks, register
/// it in the map, and return it.
async fn new_peer(self: &Arc<Self>, remote: &str, offerer: bool) -> Arc<Peer> {
let pc = Arc::new(
self.api
.new_peer_connection(self.rtc_config())
.await
.expect("peer connection"),
);
// Trickle ICE → post each candidate as it gathers.
{
let this = self.clone();
let remote = remote.to_owned();
pc.on_ice_candidate(Box::new(move |c: Option<RTCIceCandidate>| {
let this = this.clone();
let remote = remote.clone();
Box::pin(async move {
if let Some(c) = c {
if let Ok(init) = c.to_json() {
this.post_signal(
&remote,
json!({"kind": "ice", "candidate": {
"candidate": init.candidate,
"sdpMid": init.sdp_mid,
"sdpMLineIndex": init.sdp_mline_index,
"usernameFragment": init.username_fragment,
}}),
)
.await;
}
}
})
}));
}
// Drop the peer when the connection dies.
{
let peers = self.peers.clone();
let events = self.events.clone();
let remote = remote.to_owned();
pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
let peers = peers.clone();
let events = events.clone();
let remote = remote.clone();
Box::pin(async move {
if matches!(
s,
RTCPeerConnectionState::Failed
| RTCPeerConnectionState::Closed
| RTCPeerConnectionState::Disconnected
) {
peers.lock().await.remove(&remote);
let _ = events.send(MeshEvent::DirectDown(remote)); // relay needed again
}
})
}));
}
let peer = Arc::new(Peer {
pc: pc.clone(),
dc: Mutex::new(None),
offerer,
});
if offerer {
// We create the channel; the answerer receives it via on_data_channel.
let dc = pc
.create_data_channel("tether", None)
.await
.expect("data channel");
self.wire_channel(remote, dc, &peer).await;
} else {
let this = self.clone();
let remote = remote.to_owned();
let peer_ref = peer.clone();
pc.on_data_channel(Box::new(move |dc: Arc<RTCDataChannel>| {
let this = this.clone();
let remote = remote.clone();
let peer_ref = peer_ref.clone();
Box::pin(async move {
this.wire_channel(&remote, dc, &peer_ref).await;
})
}));
}
self.peers
.lock()
.await
.insert(remote.to_owned(), peer.clone());
peer
}
/// Reassemble inbound file frames (M meta · C chunks · E end) → File event.
async fn handle_file_frame(self: &Arc<Self>, data: &[u8]) {
let Some((&tag, rest)) = data.split_first() else {
return;
};
let done = {
let mut map = self.incoming.lock().await;
crate::apply_file_frame(&mut map, &self.events, tag, rest)
};
if let Some((name, mime, bytes)) = done {
eprintln!("tethercore[rtc]: received file {name:?} ({} bytes)", bytes.len());
let _ = self.events.send(MeshEvent::File { name, mime, data: bytes });
}
}
/// Attach handlers to a data channel: inbound text → sink, binary → files.
async fn wire_channel(self: &Arc<Self>, remote: &str, dc: Arc<RTCDataChannel>, peer: &Arc<Peer>) {
{
let remote_log = remote.to_owned();
let events = self.events.clone();
dc.on_open(Box::new(move || {
let remote_log = remote_log.clone();
let events = events.clone();
Box::pin(async move {
let _ = events.send(MeshEvent::DirectUp(remote_log.clone())); // now P2P-reachable
eprintln!("tethercore[rtc]: data channel open ↔ {remote_log}");
})
}));
}
let this = self.clone();
let remote_id = remote.to_owned();
dc.on_message(Box::new(move |msg: DataChannelMessage| {
let this = this.clone();
let remote_id = remote_id.clone();
Box::pin(async move {
if msg.is_string {
// Clipboard text (string channel message).
if let Ok(text) = String::from_utf8(msg.data.to_vec()) {
if !text.is_empty() {
// from = peer id, matching its SSE copy so dedup collapses them.
let _ = this.events.send(MeshEvent::Message(Message {
kind: "clipboard".into(),
text,
from: remote_id.clone(),
to: String::new(),
role: String::new(),
source: remote_id,
room: this.cfg.room.clone(),
ts: 0,
}));
}
}
} else {
// Binary file frame.
this.handle_file_frame(&msg.data).await;
}
})
}));
*peer.dc.lock().await = Some(dc);
}
// ── signaling handlers ───────────────────────────────────────────────────
async fn initiate_offer(self: &Arc<Self>, remote: &str) {
if self.peers.lock().await.contains_key(remote) {
return;
}
let peer = self.new_peer(remote, true).await;
let offer = match peer.pc.create_offer(None).await {
Ok(o) => o,
Err(_) => return,
};
if peer.pc.set_local_description(offer.clone()).await.is_err() {
return;
}
self.post_signal(remote, json!({"kind": "offer", "sdp": {"type": "offer", "sdp": offer.sdp}}))
.await;
}
async fn handle_offer(self: &Arc<Self>, remote: &str, sdp: &str) {
// Glare tie-break: if we're already offering and we sort lower, ignore
// their offer — they should answer ours.
{
let map = self.peers.lock().await;
if let Some(p) = map.get(remote) {
if p.offerer && self.cfg.from.as_str() < remote {
return;
}
}
}
if let Some(p) = self.peers.lock().await.remove(remote) {
let _ = p.pc.close().await;
}
let peer = self.new_peer(remote, false).await;
let desc = match RTCSessionDescription::offer(sdp.to_owned()) {
Ok(d) => d,
Err(_) => return,
};
if peer.pc.set_remote_description(desc).await.is_err() {
return;
}
let answer = match peer.pc.create_answer(None).await {
Ok(a) => a,
Err(_) => return,
};
if peer.pc.set_local_description(answer.clone()).await.is_err() {
return;
}
self.post_signal(remote, json!({"kind": "answer", "sdp": {"type": "answer", "sdp": answer.sdp}}))
.await;
}
async fn handle_answer(&self, remote: &str, sdp: &str) {
let peer = self.peers.lock().await.get(remote).cloned();
if let Some(p) = peer {
if p.offerer {
if let Ok(desc) = RTCSessionDescription::answer(sdp.to_owned()) {
let _ = p.pc.set_remote_description(desc).await;
}
}
}
}
async fn handle_ice(&self, remote: &str, candidate: &Value) {
let peer = self.peers.lock().await.get(remote).cloned();
if let Some(p) = peer {
let init = RTCIceCandidateInit {
candidate: candidate["candidate"].as_str().unwrap_or("").to_owned(),
sdp_mid: candidate["sdpMid"].as_str().map(str::to_owned),
sdp_mline_index: candidate["sdpMLineIndex"].as_u64().map(|x| x as u16),
username_fragment: candidate["usernameFragment"].as_str().map(str::to_owned),
};
let _ = p.pc.add_ice_candidate(init).await;
}
}
/// One pass of the signaling SSE subscription; returns on stream end/error.
async fn signal_stream(self: &Arc<Self>, running: &AtomicBool) -> Result<(), String> {
let url = format!(
"{}/api/stream?room={}",
self.cfg.server.trim_end_matches('/'),
self.cfg.room
);
let resp = self
.http
.get(&url)
.header("X-Tether-Client", format!("{}-rtc", self.cfg.from))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
let mut data: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
if !running.load(Ordering::SeqCst) {
return Ok(());
}
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
while let Some(nl) = buf.iter().position(|&b| b == b'\n') {
let mut line: Vec<u8> = buf.drain(..=nl).collect();
line.pop();
if line.last() == Some(&b'\r') {
line.pop();
}
if line.is_empty() {
if !data.is_empty() {
if let Ok(v) = serde_json::from_slice::<Value>(&data) {
self.dispatch(&v).await;
}
data.clear();
}
} else if let Some(rest) = line.strip_prefix(b"data:") {
let rest = rest.strip_prefix(b" ").unwrap_or(rest);
data.extend_from_slice(rest);
}
}
}
Ok(())
}
async fn dispatch(self: &Arc<Self>, v: &Value) {
let from = v["from"].as_str().unwrap_or("");
if from.is_empty() || from == self.cfg.from {
return;
}
match v["type"].as_str().unwrap_or("") {
"presence" => {
// Track who's in the room (with their name) for send()'s coverage
// check and the engine's present() "who's here" list.
let name = v["text"].as_str().filter(|s| !s.is_empty()).unwrap_or(from);
let source = v["source"].as_str().unwrap_or("").to_string();
let room = v["room"].as_str().unwrap_or("").to_string();
let _ = self.events.send(MeshEvent::Present(crate::Peer {
id: from.to_string(),
name: name.to_string(),
source,
room,
account: String::new(),
}));
// Deterministic initiator: the smaller id offers.
if self.cfg.from.as_str() < from {
self.initiate_offer(from).await;
}
}
"signal" => {
let to = v["to"].as_str().unwrap_or("");
if !to.is_empty() && to != self.cfg.from {
return;
}
let p = &v["signal"];
match p["kind"].as_str() {
Some("offer") => {
if let Some(sdp) = p["sdp"]["sdp"].as_str() {
self.handle_offer(from, sdp).await;
}
}
Some("answer") => {
if let Some(sdp) = p["sdp"]["sdp"].as_str() {
self.handle_answer(from, sdp).await;
}
}
Some("ice") => self.handle_ice(from, &p["candidate"]).await,
_ => {}
}
}
_ => {}
}
}
}
impl Transport for RtcTransport {
fn name(&self) -> &'static str {
"rtc"
}
fn direct(&self) -> bool {
true
}
fn start(self: Arc<Self>, rt: Spawner, running: Arc<AtomicBool>) {
// Presence chirp — announce ourselves so peers initiate.
{
let this = self.clone();
let running = running.clone();
rt.spawn(async move {
while running.load(Ordering::SeqCst) {
this.post_presence().await;
tokio::time::sleep(Duration::from_secs(8)).await;
}
});
}
// Signaling receive loop with reconnect/backoff.
rt.spawn(async move {
// Pull TURN creds before any peer is built so they're in the ICE
// config (presence→offer takes a beat, so this lands in time).
self.refresh_ice().await;
let min = Duration::from_millis(500);
let max = Duration::from_secs(10);
let mut backoff = min;
// Log a signaling error only once per outage streak (LAN-only / dead
// server must not spam).
let mut logged = false;
while running.load(Ordering::SeqCst) {
match self.signal_stream(&running).await {
Ok(()) => {
backoff = min;
logged = false;
}
Err(e) => {
if !logged {
eprintln!("tethercore[rtc]: {e}");
logged = true;
}
}
}
if running.load(Ordering::SeqCst) {
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(max);
}
}
});
}
fn publish(&self, rt: &Spawner, msg: Message) {
// The "tether" data channel carries raw clipboard text only; receipts
// and other typed messages go over SSE/LAN.
if !(msg.kind.is_empty() || msg.kind == "clipboard") {
return;
}
let peers = self.peers.clone();
rt.spawn(async move {
let map = peers.lock().await;
for peer in map.values() {
if let Some(dc) = peer.dc.lock().await.as_ref() {
let _ = dc.send_text(msg.text.clone()).await;
}
}
});
}
/// Stream a file over each open data channel as binary frames:
/// M<json meta> · C<16-byte id><chunk>… · E<16-byte id>
/// The channel is reliable+ordered, so chunks reassemble in arrival order.
fn send_file(&self, rt: &Spawner, id: String, name: String, mime: String, data: Vec<u8>) {
let peers = self.peers.clone();
let events = self.events.clone();
rt.spawn(async move {
let dcs: Vec<Arc<RTCDataChannel>> = {
let map = peers.lock().await;
let mut v = Vec::new();
for peer in map.values() {
if let Some(dc) = peer.dc.lock().await.as_ref() {
v.push(dc.clone());
}
}
v
};
if dcs.is_empty() {
return;
}
let total = data.len() as u64;
crate::emit_transfer(&events, &id, &name, 0, total, false, false);
// Each frame is sent as one binary message: tag byte + payload.
let frames = crate::file_frames(&id, &name, &mime, &data);
for dc in dcs {
let mut sent = 0u64;
for (tag, payload) in &frames {
let mut f = Vec::with_capacity(1 + payload.len());
f.push(*tag);
f.extend_from_slice(payload);
if dc.send(&Bytes::from(f)).await.is_err() {
break;
}
if *tag == b'C' {
sent += (payload.len() - 16) as u64;
crate::emit_transfer(&events, &id, &name, sent, total, false, false);
}
}
}
crate::emit_transfer(&events, &id, &name, total, total, false, true);
});
}
}

97
core/src/wasm.rs Normal file
View File

@@ -0,0 +1,97 @@
//! wasm-bindgen surface — the browser's entry point to tethercore.
//!
//! On native the engine is exposed via UniFFI (Swift/Kotlin); in the browser it's
//! the same `Engine`, wrapped for JS here. A browser peer is relay-only (no
//! holepunch in the sandbox), but otherwise identical: same gossip topic, same
//! E2E, same rendezvous bootstrap.
//!
//! JS usage:
//! import init, { WasmEngine, roomForAccount } from "./tethercore.js";
//! await init();
//! const room = roomForAccount("you@example.com");
//! const e = new WasmEngine("https://rendezvous.example", room, deviceId, "you@example.com");
//! e.start((text) => { /* inbound clipboard */ });
//! e.send("hello from the browser");
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use crate::{Engine, Message, MessageHandler};
/// The clipboard engine, for the browser.
#[wasm_bindgen]
pub struct WasmEngine {
inner: Arc<Engine>,
}
#[wasm_bindgen]
impl WasmEngine {
/// `server` = rendezvous URL, `room` = account-derived room (see
/// `roomForAccount`), `from` = a stable device id, `account` = the shared
/// identity/secret (same on all your devices).
#[wasm_bindgen(constructor)]
pub fn new(server: String, room: String, from: String, account: String) -> WasmEngine {
init_diagnostics();
let inner = Engine::new(server, room, from, "web".into(), "Web".into(), account);
WasmEngine { inner }
}
/// Start syncing. `on_message` is invoked with each inbound clipboard's text.
pub fn start(&self, on_message: js_sys::Function) {
self.inner.start(Box::new(JsHandler { on_message }));
}
/// Publish clipboard text to the room.
pub fn send(&self, text: String) {
self.inner.send(text);
}
/// Stop syncing.
pub fn stop(&self) {
self.inner.stop();
}
/// This node's iroh EndpointId once online (for debugging), else `null`.
#[wasm_bindgen(js_name = irohId)]
pub fn iroh_id(&self) -> Option<String> {
self.inner.iroh_id()
}
}
/// `account` → account-derived room id. Re-exported so the browser derives the
/// SAME room as the native clients.
#[wasm_bindgen(js_name = roomForAccount)]
pub fn room_for_account(account: String) -> String {
crate::room_for_account(account)
}
/// Route Rust panics + iroh's tracing logs to the browser console (once).
/// Capped at INFO: iroh's DEBUG/TRACE (per-packet NAT probes, relay pings, the
/// harmless "Not enough addresses" holepunch-upgrade misses) is a firehose; INFO
/// keeps warnings/errors and high-level connection events. Bump to DEBUG when
/// debugging the gossip mesh.
fn init_diagnostics() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
console_error_panic_hook::set_once();
let mut cfg = tracing_wasm::WASMLayerConfigBuilder::new();
cfg.set_max_level(tracing::Level::INFO);
tracing_wasm::set_as_global_default_with_config(cfg.build());
});
}
/// Bridges inbound clipboards to a JS callback. Single-threaded (the engine runs
/// on the JS event loop), so the `js_sys::Function` never crosses a thread.
struct JsHandler {
on_message: js_sys::Function,
}
impl MessageHandler for JsHandler {
fn on_message(&self, m: Message) {
let _ = self.on_message.call1(&JsValue::NULL, &JsValue::from_str(&m.text));
}
fn on_status(&self, _connected: bool) {}
fn on_file(&self, _name: String, _mime: String, _data: Vec<u8>) {}
}

1
core/web/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/pkg/

80
core/web/index.html Normal file
View File

@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>tether — web (wasm)</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
input, button, textarea { font: inherit; padding: .4rem .5rem; }
.row { display: flex; gap: .5rem; margin: .5rem 0; }
.row input { flex: 1; }
#feed { width: 100%; height: 12rem; }
#status { color: #666; font-size: 13px; }
.dot { display:inline-block;width:10px;height:10px;border-radius:50%;background:#bbb;vertical-align:middle }
.dot.on { background:#2ecc40 }
</style>
</head>
<body>
<h1>tether <small>· web</small> <span id="dot" class="dot"></span></h1>
<p>The same Rust core as the native apps, compiled to wasm — a relay-only iroh
peer in the browser. Point it at your rendezvous and account.</p>
<div class="row"><input id="server" value="https://tether.pecord.io" placeholder="rendezvous URL" /></div>
<div class="row">
<input id="account" value="you@example.com" placeholder="account" />
<button id="connect">Connect</button>
</div>
<div id="status">not connected</div>
<textarea id="feed" readonly placeholder="inbound clipboards appear here…"></textarea>
<div class="row">
<input id="msg" placeholder="type and Send to the room…" />
<button id="send" disabled>Send</button>
</div>
<script type="module">
import init, { WasmEngine, roomForAccount } from "./pkg/tethercore.js";
const $ = (id) => document.getElementById(id);
let engine = null;
$("connect").onclick = async () => {
await init();
const server = $("server").value.trim();
const account = $("account").value.trim();
const room = roomForAccount(account);
const from = "web-" + Math.random().toString(36).slice(2, 10);
engine = new WasmEngine(server, room, from, account);
window.engine = engine; // expose for debugging
engine.start((text) => {
$("feed").value += "← " + text + "\n";
$("feed").scrollTop = $("feed").scrollHeight;
});
$("status").textContent = `connecting… room=${room} from=${from}`;
$("dot").classList.add("on");
$("send").disabled = false;
// surface the iroh id once online
const tick = setInterval(() => {
const id = engine.irohId();
if (id) { $("status").textContent = `online · room=${room} · id=${id.slice(0,16)}`; clearInterval(tick); }
}, 500);
};
$("send").onclick = () => {
const text = $("msg").value;
if (engine && text) {
try {
engine.send(text);
$("feed").value += "→ " + text + "\n"; $("msg").value = "";
} catch (e) {
console.error("send failed:", e);
}
}
};
$("msg").addEventListener("keydown", (e) => { if (e.key === "Enter") $("send").click(); });
</script>
</body>
</html>

24
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# SwiftPM / Xcode build products
.build/
build/
DerivedData/
*.xcodeproj/xcuserdata/
*.xcodeproj/project.xcworkspace/xcuserdata/
*.xcworkspace/xcuserdata/
# Generated Xcode projects (XcodeGen owns project.yml as source of truth)
# Tracked intentionally so cloning without xcodegen still opens — keep pbxproj.
.DS_Store
# Generated by core/build-ios.sh — regenerate, don't commit
TetherApp/Vendor/
TetherApp/Sources/tethercore.swift
# editor state
*.xcuserstate
xcuserdata/
.swiftpm/
# macOS app generated artifacts (core/build-apple.sh)
MacTetherApp/Vendor/
MacTetherApp/Sources/tethercore.swift

View File

@@ -0,0 +1,374 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
5AE0D6380E8D23C337DD0EB0 /* TetherKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7A65056F38679ECD9498103E /* TetherKit */; };
9386BB2E2BBBCF245A7E605D /* MacTetherStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010C4E8B3DC255487AE12A75 /* MacTetherStore.swift */; };
9617ED2468FED9FC38A37FD8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826E98E1B2A0C7A9AC4ED879 /* ContentView.swift */; };
B41AF236C08FA6BD15F00807 /* TetherCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF6782E994649B6E18C307E8 /* TetherCore.xcframework */; };
C18BF415C7B42C5FF3235F5E /* tethercore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A8D10256F25095F3FF5047E /* tethercore.swift */; };
CB143A31027F3CDB3CF5E894 /* MenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF5D8809D1EC415AB35059B /* MenuView.swift */; };
E1F3C377CB6A58F791A83EAA /* MacTetherApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7FA20BA797A0117F705E7 /* MacTetherApp.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
010C4E8B3DC255487AE12A75 /* MacTetherStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTetherStore.swift; sourceTree = "<group>"; };
2EA7FA20BA797A0117F705E7 /* MacTetherApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTetherApp.swift; sourceTree = "<group>"; };
6C53AB32EB02089B09AD31D6 /* MacTetherApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTetherApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
6F7F88910D568FDE283249B4 /* TetherKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TetherKit; path = ../TetherKit; sourceTree = SOURCE_ROOT; };
7D193BC76AB5D363A4CB1F6C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
826E98E1B2A0C7A9AC4ED879 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
8A8D10256F25095F3FF5047E /* tethercore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tethercore.swift; sourceTree = "<group>"; };
BDF5D8809D1EC415AB35059B /* MenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuView.swift; sourceTree = "<group>"; };
EF6782E994649B6E18C307E8 /* TetherCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TetherCore.xcframework; path = Vendor/TetherCore.xcframework; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
2E5F624BEFA01FFDE63654A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5AE0D6380E8D23C337DD0EB0 /* TetherKit in Frameworks */,
B41AF236C08FA6BD15F00807 /* TetherCore.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
B17B25DB8865282691E0B024 /* Products */ = {
isa = PBXGroup;
children = (
6C53AB32EB02089B09AD31D6 /* MacTetherApp.app */,
);
name = Products;
sourceTree = "<group>";
};
C74C31B92D5DD6D8CCD0F873 /* Frameworks */ = {
isa = PBXGroup;
children = (
EF6782E994649B6E18C307E8 /* TetherCore.xcframework */,
);
name = Frameworks;
sourceTree = "<group>";
};
D08DCB1AE8C0173418D178ED /* Sources */ = {
isa = PBXGroup;
children = (
826E98E1B2A0C7A9AC4ED879 /* ContentView.swift */,
7D193BC76AB5D363A4CB1F6C /* Info.plist */,
2EA7FA20BA797A0117F705E7 /* MacTetherApp.swift */,
010C4E8B3DC255487AE12A75 /* MacTetherStore.swift */,
BDF5D8809D1EC415AB35059B /* MenuView.swift */,
8A8D10256F25095F3FF5047E /* tethercore.swift */,
);
path = Sources;
sourceTree = "<group>";
};
E0AC15A5A587EEB6DB5D715F /* Packages */ = {
isa = PBXGroup;
children = (
6F7F88910D568FDE283249B4 /* TetherKit */,
);
name = Packages;
sourceTree = "<group>";
};
E91265DF5258B5F642EBDFEC = {
isa = PBXGroup;
children = (
E0AC15A5A587EEB6DB5D715F /* Packages */,
D08DCB1AE8C0173418D178ED /* Sources */,
C74C31B92D5DD6D8CCD0F873 /* Frameworks */,
B17B25DB8865282691E0B024 /* Products */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
DF53DF6A402A0F4985D125E6 /* MacTetherApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 04A5EC9A9899CC4040F690F3 /* Build configuration list for PBXNativeTarget "MacTetherApp" */;
buildPhases = (
45D151270EEF801923C101BE /* Sources */,
2E5F624BEFA01FFDE63654A3 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = MacTetherApp;
packageProductDependencies = (
7A65056F38679ECD9498103E /* TetherKit */,
);
productName = MacTetherApp;
productReference = 6C53AB32EB02089B09AD31D6 /* MacTetherApp.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
75142B138CD13CB28DFE8614 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
DF53DF6A402A0F4985D125E6 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 0C5288A62A776D2D55345DF0 /* Build configuration list for PBXProject "MacTetherApp" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = E91265DF5258B5F642EBDFEC;
minimizedProjectReferenceProxies = 1;
packageReferences = (
AECCD2CEB9003CD2BFF2B541 /* XCLocalSwiftPackageReference "../TetherKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = B17B25DB8865282691E0B024 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
DF53DF6A402A0F4985D125E6 /* MacTetherApp */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
45D151270EEF801923C101BE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9617ED2468FED9FC38A37FD8 /* ContentView.swift in Sources */,
E1F3C377CB6A58F791A83EAA /* MacTetherApp.swift in Sources */,
9386BB2E2BBBCF245A7E605D /* MacTetherStore.swift in Sources */,
CB143A31027F3CDB3CF5E894 /* MenuView.swift in Sources */,
C18BF415C7B42C5FF3235F5E /* tethercore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
405AB20151E495DF641A394F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
A601B9B6C923845A753ABA8D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A95D061C896899435D7681DC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\"Vendor\"",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Sources/Info.plist;
INFOPLIST_KEY_NSAppTransportSecurity = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1.0;
OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -framework Network -framework CoreFoundation -framework Foundation -lobjc -liconv -lresolv";
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether.mac;
SDKROOT = macosx;
};
name = Debug;
};
ED576A3E5FACC81E9E95AA23 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\"Vendor\"",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Sources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1.0;
OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -framework Network -framework CoreFoundation -framework Foundation -lobjc -liconv -lresolv";
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether.mac;
SDKROOT = macosx;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
04A5EC9A9899CC4040F690F3 /* Build configuration list for PBXNativeTarget "MacTetherApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A95D061C896899435D7681DC /* Debug */,
ED576A3E5FACC81E9E95AA23 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
0C5288A62A776D2D55345DF0 /* Build configuration list for PBXProject "MacTetherApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A601B9B6C923845A753ABA8D /* Debug */,
405AB20151E495DF641A394F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
AECCD2CEB9003CD2BFF2B541 /* XCLocalSwiftPackageReference "../TetherKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../TetherKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
7A65056F38679ECD9498103E /* TetherKit */ = {
isa = XCSwiftPackageProductDependency;
productName = TetherKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 75142B138CD13CB28DFE8614 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,255 @@
import SwiftUI
struct ContentView: View {
@Bindable var store: MacTetherStore
@State private var draft = ""
var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty {
nearbyStrip
Divider()
}
if !store.transfers.isEmpty {
transfersRow
Divider()
}
feedArea
Divider()
if !store.receipts.isEmpty {
seenByBar
Divider()
}
composer
}
.frame(minWidth: 480, minHeight: 400)
.task { store.connectIfSaved() }
}
// MARK: - Delivery receipts
private var seenByBar: some View {
let names = Array(Set(store.receipts.map(\.name))).sorted()
return HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green).font(.caption)
Text("Seen by \(names.joined(separator: ", "))")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
// MARK: - File transfers (progress)
private var transfersRow: some View {
VStack(spacing: 4) {
ForEach(store.transfers, id: \.name) { t in
let frac = t.total > 0 ? Double(t.received) / Double(t.total) : 0
HStack(spacing: 8) {
Image(systemName: t.incoming ? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(t.name).font(.caption).lineLimit(1).frame(maxWidth: 140, alignment: .leading)
ProgressView(value: frac).frame(width: 120)
Text(t.done ? "done" : "\(Int(frac * 100))%")
.font(.caption2).foregroundStyle(.secondary)
Spacer()
}
}
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
// MARK: - Presence ("who's here")
private var presenceRow: some View {
HStack(spacing: 12) {
ForEach(store.present, id: \.id) { peer in
HStack(spacing: 5) {
Circle().fill(.green).frame(width: 7, height: 7)
Image(systemName: sourceIcon(peer.source)).font(.caption2)
Text(peer.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
// MARK: - Nearby devices (AirDrop-style picker)
private var nearbyStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Label("Nearby", systemImage: "dot.radiowaves.left.and.right")
.font(.caption).foregroundStyle(.secondary)
.padding(.trailing, 4)
ForEach(store.nearby, id: \.id) { peer in
let here = peer.room == store.room
Button {
store.pair(with: peer)
} label: {
HStack(spacing: 6) {
Image(systemName: sourceIcon(peer.source))
VStack(alignment: .leading, spacing: 1) {
Text(peer.name).font(.caption).lineLimit(1)
Text(here ? "in your room" : "tap to join")
.font(.caption2)
.foregroundStyle(here ? .green : .secondary)
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.primary.opacity(0.06), in: Capsule())
}
.buttonStyle(.plain)
.disabled(here)
.help(here ? "Already in this room" : "Join \(peer.name)'s room")
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
}
// MARK: - Toolbar
private var toolbar: some View {
HStack(spacing: 10) {
Circle()
.fill(store.connected ? Color.green : Color.secondary.opacity(0.5))
.frame(width: 10, height: 10)
.animation(.easeInOut, value: store.connected)
if store.connected {
Text("Room \(store.room)").font(.callout).foregroundStyle(.secondary)
} else {
TextField("Server URL", text: $store.serverURL)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
}
Spacer()
if let err = store.lastError, store.hasAttemptedConnect {
Text(err).font(.caption).foregroundStyle(.red)
}
Button(store.connected ? "Disconnect" : "Connect") {
store.connected ? store.disconnect() : store.connect()
}
.buttonStyle(.borderedProminent)
.tint(store.connected ? .secondary : .accentColor)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// MARK: - Feed
private var feedArea: some View {
List(store.feed) { msg in
feedRow(msg)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
}
.listStyle(.plain)
.overlay {
if store.feed.isEmpty {
ContentUnavailableView(
"No messages yet",
systemImage: "link.circle",
description: Text(store.connected
? "Copy anything on a connected device — it'll appear here."
: "Connect to your tether server to start syncing.")
)
}
}
}
private func feedRow(_ item: FeedItem) -> some View {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
if let image = item.image {
Image(nsImage: image)
.resizable().scaledToFit()
.frame(maxWidth: 280, maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else if item.isFile {
Label(item.text, systemImage: "doc.fill")
.font(.body).lineLimit(1)
} else {
Text(item.text)
.font(.body).lineLimit(6).textSelection(.enabled)
}
HStack {
Label(item.isFile ? "file" : item.source,
systemImage: item.isFile ? "arrow.down.circle" : sourceIcon(item.source))
.font(.caption).foregroundStyle(.secondary)
Spacer()
Text(item.date, style: .time)
.font(.caption).foregroundStyle(.secondary)
}
}
Button {
if let url = item.fileURL {
NSWorkspace.shared.activateFileViewerSelecting([url]) // reveal in Finder
} else {
store.copyToPasteboard(item)
}
} label: {
Image(systemName: item.isFile ? "magnifyingglass" : "doc.on.doc")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(item.isFile ? "Reveal in Finder" : "Copy to clipboard")
}
.padding(10)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
}
private func sourceIcon(_ source: String) -> String {
let s = source
if s.contains("iphone") || s.contains("ios") { return "iphone" }
if s.contains("ipad") { return "ipad" }
if s.contains("macos") || s.contains("mac") { return "laptopcomputer" }
if s.contains("windows") { return "pc" }
return "desktopcomputer"
}
// MARK: - Composer
private var composer: some View {
HStack(spacing: 10) {
Button {
store.attachFile()
} label: {
Image(systemName: "paperclip")
}
.buttonStyle(.plain)
.disabled(!store.connected)
.help("Send a file or image")
TextField("Type a message…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit {
guard !draft.isEmpty else { return }
store.send(draft); draft = ""
}
Button {
store.send(draft); draft = ""
} label: {
Image(systemName: "paperplane.fill")
}
.keyboardShortcut(.return, modifiers: .command)
.buttonStyle(.borderedProminent)
.disabled(!store.connected || draft.isEmpty)
}
.padding(12)
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>tether connects to your clipboard relay on the local network.</string>
</dict>
</plist>

View File

@@ -0,0 +1,20 @@
import SwiftUI
@main
struct MacTetherApp: App {
@State private var store = MacTetherStore()
var body: some Scene {
WindowGroup {
ContentView(store: store)
}
.windowResizability(.contentMinSize)
.defaultSize(width: 560, height: 520)
MenuBarExtra("tether", systemImage: store.connected ? "link.circle.fill" : "link.circle") {
MenuView(store: store)
.frame(width: 320)
}
.menuBarExtraStyle(.window)
}
}

View File

@@ -0,0 +1,198 @@
import AppKit
import Foundation
import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clashing
// with the engine's Message (compiled into this target)
// Engine + Message + MessageHandler come from the generated tethercore.swift.
/// UI-facing feed item: a text clipboard, or a received file/image.
struct FeedItem: Identifiable {
let id = UUID()
let text: String // clipboard text, or the file name for files
let source: String
let date: Date
var image: NSImage? = nil // preview for image files
var fileURL: URL? = nil // saved location for any received file
var isFile: Bool { fileURL != nil }
}
/// macOS side: drives the shared `tethercore.Engine` (SSE + RTC). Unlike iOS we
/// CAN poll NSPasteboard in the background, so clipboard changes auto-send.
@MainActor @Observable
final class MacTetherStore {
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io"
// Prefilled shared identity for now it's just us, so all devices pair.
var account: String = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
// Room follows the account all our devices share one room everywhere.
var room: String = {
let a = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
return a.isEmpty ? RoomIdentity.derive() : roomForAccount(account: a)
}()
var feed: [FeedItem] = []
var nearby: [Peer] = [] // tether devices on the LAN, for the picker
var present: [Peer] = [] // devices online in the room ("who's here")
var receipts: [Receipt] = [] // delivery acks "seen by <device>"
var transfers: [Transfer] = [] // in-flight file transfers
var connected = false
var lastError: String?
var hasAttemptedConnect = false
private let deviceID = Host.current().localizedName ?? UUID().uuidString
private var engine: Engine?
private var bridge: EngineBridge?
private var pollTask: Task<Void, Never>?
private var nearbyTask: Task<Void, Never>?
private var lastChangeCount = NSPasteboard.general.changeCount
func connectIfSaved() {
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
connect()
}
func connect() {
let url = serverURL.trimmingCharacters(in: .whitespaces)
guard !url.isEmpty else {
hasAttemptedConnect = true
lastError = "Invalid server URL."
return
}
hasAttemptedConnect = true
UserDefaults.standard.set(serverURL, forKey: "serverURL")
UserDefaults.standard.set(account, forKey: "account")
disconnect()
let b = EngineBridge(store: self)
let e = Engine(server: url, room: room, from: deviceID, source: "macos", name: deviceID, account: account)
e.start(handler: b)
engine = e
bridge = b
connected = true
lastError = nil
// Poll the engine's mDNS-discovered device list for the picker.
nearbyTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name }
self.present = engine.present().sorted { $0.name < $1.name }
self.receipts = engine.receipts()
self.transfers = engine.transfers()
}
}
// Send: poll NSPasteboard for local changes push to room.
lastChangeCount = NSPasteboard.general.changeCount
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
guard let self else { return }
let count = NSPasteboard.general.changeCount
guard count != self.lastChangeCount else { continue }
self.lastChangeCount = count
if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty {
self.engine?.send(text: text)
}
}
}
}
func disconnect() {
engine?.stop()
engine = nil
bridge = nil
pollTask?.cancel()
pollTask = nil
nearbyTask?.cancel()
nearbyTask = nil
nearby = []
connected = false
}
/// Join a discovered device's room the "pair" action from the picker.
func pair(with peer: Peer) {
room = peer.room
connect()
}
func send(_ text: String) {
engine?.send(text: text)
}
/// Pick a file and send it P2P.
func attachFile() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
guard panel.runModal() == .OK, let url = panel.url,
let data = try? Data(contentsOf: url) else { return }
engine?.sendFile(name: url.lastPathComponent, mime: Self.mime(for: url.pathExtension), data: data)
}
static func mime(for ext: String) -> String {
switch ext.lowercased() {
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "heic": return "image/heic"
case "pdf": return "application/pdf"
case "txt": return "text/plain"
default: return "application/octet-stream"
}
}
/// A received file: save to Downloads, preview if it's an image.
fileprivate func ingestFile(name: String, mime: String, data: Data, date: Date) {
let image: NSImage? = mime.hasPrefix("image/") ? NSImage(data: data) : nil
var url: URL?
if let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first {
let dest = downloads.appendingPathComponent(name.isEmpty ? "tether-file" : name)
try? data.write(to: dest)
url = dest
}
feed.insert(FeedItem(text: name, source: "file", date: date, image: image, fileURL: url), at: 0)
if feed.count > 100 { feed.removeLast() }
}
func copyToPasteboard(_ item: FeedItem) {
writePasteboard(item.text)
}
private func writePasteboard(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
lastChangeCount = NSPasteboard.general.changeCount // don't echo our own write back
}
// Called from the engine bridge, already on the main actor.
fileprivate func ingest(_ item: FeedItem) {
feed.insert(item, at: 0)
if feed.count > 100 { feed.removeLast() }
writePasteboard(item.text) // mirror received clipboard onto this Mac
}
fileprivate func setConnected(_ value: Bool) {
connected = value
}
}
/// Adapts the engine's callback interface (fired on the Rust runtime thread)
/// onto the main actor for SwiftUI.
private final class EngineBridge: MessageHandler {
weak var store: MacTetherStore?
init(store: MacTetherStore) { self.store = store }
func onMessage(msg: Message) {
// RTC-delivered messages carry ts=0 (no server stamp) use now.
let date = msg.ts > 0 ? Date(timeIntervalSince1970: Double(msg.ts) / 1000.0) : Date()
let item = FeedItem(text: msg.text, source: msg.source.isEmpty ? "unknown" : msg.source, date: date)
Task { @MainActor in self.store?.ingest(item) }
}
func onStatus(connected: Bool) {
Task { @MainActor in self.store?.setConnected(connected) }
}
func onFile(name: String, mime: String, data: Data) {
Task { @MainActor in self.store?.ingestFile(name: name, mime: mime, data: data, date: Date()) }
}
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
struct MenuView: View {
@Bindable var store: MacTetherStore
@State private var draft = ""
var body: some View {
VStack(spacing: 0) {
header
Divider()
feedList
Divider()
composer
}
.padding(8)
.task { store.connectIfSaved() }
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Circle()
.fill(store.connected ? Color.green : Color.secondary)
.frame(width: 8, height: 8)
Text(store.connected ? "connected · \(store.room)" : "disconnected")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Button(store.connected ? "Disconnect" : "Connect") {
store.connected ? store.disconnect() : store.connect()
}.font(.caption)
}
if !store.connected {
TextField("http://tether.lan:8765", text: $store.serverURL)
.textFieldStyle(.roundedBorder).font(.caption)
Button("Connect") { store.connect() }
.buttonStyle(.borderedProminent).controlSize(.small)
.frame(maxWidth: .infinity)
}
if store.hasAttemptedConnect, let err = store.lastError {
Text(err).font(.caption2).foregroundStyle(.red)
}
}
.padding(.bottom, 6)
}
private var feedList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
if store.feed.isEmpty {
Text("No messages yet — clipboard changes auto-send when connected.")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 20)
} else {
ForEach(store.feed) { msg in
feedRow(msg)
}
}
}
.padding(.vertical, 6)
}
.frame(minHeight: 120, maxHeight: 240)
}
private func feedRow(_ item: FeedItem) -> some View {
HStack(alignment: .top, spacing: 6) {
VStack(alignment: .leading, spacing: 2) {
Text(item.text)
.font(.caption).lineLimit(3)
HStack {
Text(item.source).font(.caption2).foregroundStyle(.secondary)
Spacer()
Text(item.date, style: .time).font(.caption2).foregroundStyle(.secondary)
}
}
Button { store.copyToPasteboard(item) } label: {
Image(systemName: "doc.on.doc").font(.caption2)
}
.buttonStyle(.plain).foregroundStyle(.secondary)
}
.padding(6)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 6))
}
private var composer: some View {
HStack(spacing: 6) {
TextField("Send text…", text: $draft)
.textFieldStyle(.roundedBorder).font(.caption)
.onSubmit { if !draft.isEmpty { store.send(draft); draft = "" } }
Button {
store.send(draft); draft = ""
} label: {
Image(systemName: "paperplane.fill")
}
.disabled(!store.connected || draft.isEmpty)
.buttonStyle(.borderedProminent).controlSize(.small)
}
.padding(.top, 6)
}
}

View File

@@ -0,0 +1,42 @@
name: MacTetherApp
options:
bundleIdPrefix: io.pecord
deploymentTarget:
macOS: "14.0"
createIntermediateGroups: true
packages:
TetherKit:
path: ../TetherKit
targets:
MacTetherApp:
type: application
platform: macOS
sources:
- Sources
dependencies:
- package: TetherKit # RoomIdentity only — networking now lives in TetherCore
- framework: Vendor/TetherCore.xcframework
embed: false # static lib, nothing to embed
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: io.pecord.tether.mac
# Rust staticlib pulls in SecRandomCopyBytes (ring) + system config
# (reqwest) + iroh's netwatch (Network.framework) + objc2 bindings
# (CoreFoundation/Foundation/objc) + iconv.
OTHER_LDFLAGS: "-framework Security -framework SystemConfiguration -framework Network -framework CoreFoundation -framework Foundation -lobjc -liconv -lresolv"
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
GENERATE_INFOPLIST_FILE: true
DEVELOPMENT_TEAM: ""
CODE_SIGN_STYLE: Automatic
configs:
Debug:
INFOPLIST_KEY_NSAppTransportSecurity: ""
info:
path: Sources/Info.plist
properties:
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
NSLocalNetworkUsageDescription: "tether connects to your clipboard relay on the local network."

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>tether connects to your clipboard relay on the local network.</string>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>

View File

@@ -0,0 +1,218 @@
import SwiftUI
import UniformTypeIdentifiers
struct RoomView: View {
@Bindable var store: TetherStore
@State private var draft = ""
@State private var showImporter = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
connectionBar
Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty {
nearbyStrip
Divider()
}
if !store.transfers.isEmpty {
transfersRow
Divider()
}
feedList
Divider()
if !store.receipts.isEmpty {
seenByBar
Divider()
}
composer
}
.navigationTitle("tether")
.navigationBarTitleDisplayMode(.inline)
.task { store.connect() } // auto-connect on launch (defaults are production-ready)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Circle()
.fill(store.connected ? .green : .secondary)
.frame(width: 10, height: 10)
}
}
}
}
private var connectionBar: some View {
VStack(spacing: 8) {
TextField("https://tether.pecord.io", text: $store.serverURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
HStack {
TextField("room id (8 hex)", text: $store.room)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button(store.connected ? "Disconnect" : "Connect") {
store.connected ? store.disconnect() : store.connect()
}
.buttonStyle(.borderedProminent)
}
if let err = store.lastError {
Text(err).font(.caption).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.textFieldStyle(.roundedBorder)
.padding()
}
// MARK: - Presence ("who's here")
private var presenceRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(store.present, id: \.id) { peer in
HStack(spacing: 5) {
Circle().fill(.green).frame(width: 7, height: 7)
Image(systemName: sourceIcon(peer.source)).font(.caption2)
Text(peer.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
}
// MARK: - Nearby devices (AirDrop-style picker)
private var nearbyStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Label("Nearby", systemImage: "dot.radiowaves.left.and.right")
.font(.caption).foregroundStyle(.secondary)
ForEach(store.nearby, id: \.id) { peer in
let here = peer.room == store.room
Button {
store.pair(with: peer)
} label: {
HStack(spacing: 6) {
Image(systemName: sourceIcon(peer.source))
VStack(alignment: .leading, spacing: 1) {
Text(peer.name).font(.caption).lineLimit(1)
Text(here ? "in your room" : "tap to join")
.font(.caption2)
.foregroundStyle(here ? .green : .secondary)
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.primary.opacity(0.06), in: Capsule())
}
.buttonStyle(.plain)
.disabled(here)
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
}
private var transfersRow: some View {
VStack(spacing: 4) {
ForEach(store.transfers, id: \.name) { t in
let frac = t.total > 0 ? Double(t.received) / Double(t.total) : 0
HStack(spacing: 8) {
Image(systemName: t.incoming ? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(t.name).font(.caption).lineLimit(1)
ProgressView(value: frac).frame(maxWidth: 120)
Text(t.done ? "done" : "\(Int(frac * 100))%")
.font(.caption2).foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
private var seenByBar: some View {
let names = Array(Set(store.receipts.map(\.name))).sorted()
return HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green).font(.caption)
Text("Seen by \(names.joined(separator: ", "))")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
private func sourceIcon(_ source: String) -> String {
if source.contains("iphone") || source.contains("ios") { return "iphone" }
if source.contains("ipad") { return "ipad" }
if source.contains("mac") { return "laptopcomputer" }
if source.contains("windows") { return "pc" }
return "desktopcomputer"
}
private var feedList: some View {
List(store.feed) { msg in
VStack(alignment: .leading, spacing: 4) {
if let image = msg.image {
Image(uiImage: image)
.resizable().scaledToFit()
.frame(maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else if msg.isFile {
Label(msg.text, systemImage: "doc.fill").lineLimit(1)
} else {
Text(msg.text).lineLimit(4)
}
HStack {
Text(msg.isFile ? "file" : msg.source).font(.caption2)
Spacer()
Text(msg.date, style: .time).font(.caption2)
}
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture { if !msg.isFile { store.copyToPasteboard(msg) } }
.swipeActions {
if !msg.isFile {
Button("Copy") { store.copyToPasteboard(msg) }.tint(.blue)
}
}
}
.listStyle(.plain)
.overlay {
if store.feed.isEmpty {
ContentUnavailableView("No messages yet",
systemImage: "doc.on.clipboard",
description: Text("Connect, then send from any device in the room."))
}
}
}
private var composer: some View {
HStack {
Button { showImporter = true } label: {
Image(systemName: "paperclip").font(.title2)
}
.disabled(!store.connected)
.fileImporter(isPresented: $showImporter, allowedContentTypes: [.item]) { result in
if case .success(let url) = result { store.sendFile(at: url) }
}
TextField("Type or paste…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
Button {
if draft.isEmpty { store.sendClipboard() }
else { store.send(draft); draft = "" }
} label: {
Image(systemName: draft.isEmpty ? "doc.on.clipboard" : "paperplane.fill")
.font(.title2)
}
.disabled(!store.connected)
}
.padding()
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
@main
struct TetherApp: App {
@State private var store = TetherStore()
var body: some Scene {
WindowGroup {
RoomView(store: store)
}
}
}

View File

@@ -0,0 +1,186 @@
import Foundation
import SwiftUI
import UIKit
import enum TetherKit.RoomIdentity // selective: avoids TetherKit.Message clashing
// with the engine's Message (compiled into this target)
// Engine + Message + MessageHandler come from the generated tethercore.swift,
// built directly into this module no `import` needed.
/// UI-facing clipboard item. Decouples the view from the FFI `Message` record
/// and supplies the `id`/`date` SwiftUI's List needs.
struct FeedItem: Identifiable {
let id = UUID()
let text: String // clipboard text, or the file name for files
let source: String
let date: Date
var image: UIImage? = nil // preview for image files
var fileURL: URL? = nil // saved location for any received file
var isFile: Bool { fileURL != nil }
}
/// Observable bridge between the shared `tethercore.Engine` and SwiftUI.
/// The engine owns the wire protocol; this owns the clipboard side-effects.
@MainActor @Observable
final class TetherStore {
var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? "https://tether.pecord.io"
// Prefilled shared identity for now it's just us, so all devices pair.
// Becomes the Sign-in-with-Apple `sub` once the dev program clears.
var account: String = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
// Room follows the account, so all our devices share one room everywhere
// (LAN, relay, RTC). Falls back to a device id only when signed out.
var room: String = {
let a = UserDefaults.standard.string(forKey: "account") ?? "pecord@gmail.com"
return a.isEmpty ? RoomIdentity.derive() : roomForAccount(account: a)
}()
var feed: [FeedItem] = []
var nearby: [Peer] = [] // tether devices on the LAN (empty on iOS w/o multicast entitlement)
var present: [Peer] = [] // devices online in the room ("who's here")
var receipts: [Receipt] = [] // delivery acks "seen by <device>"
var transfers: [Transfer] = [] // in-flight file transfers
var connected = false
var lastError: String?
private let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
private var engine: Engine?
private var bridge: EngineBridge?
private var nearbyTask: Task<Void, Never>?
func connectIfSaved() {
guard UserDefaults.standard.string(forKey: "serverURL") != nil else { return }
connect()
}
func connect() {
let url = serverURL.trimmingCharacters(in: .whitespaces)
guard !url.isEmpty, !room.isEmpty else {
lastError = "Set a server URL first."
return
}
UserDefaults.standard.set(serverURL, forKey: "serverURL")
UserDefaults.standard.set(account, forKey: "account")
disconnect()
let b = EngineBridge(store: self)
let e = Engine(server: url, room: room, from: deviceID, source: "ios", name: UIDevice.current.name, account: account)
e.start(handler: b)
engine = e
bridge = b
lastError = nil
// Poll the engine's mDNS-discovered device list for the picker.
nearbyTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name }
self.present = engine.present().sorted { $0.name < $1.name }
self.receipts = engine.receipts()
self.transfers = engine.transfers()
}
}
}
func disconnect() {
engine?.stop()
engine = nil
bridge = nil
nearbyTask?.cancel()
nearbyTask = nil
nearby = []
connected = false
}
/// Join a discovered device's room the "pair" action from the picker.
func pair(with peer: Peer) {
room = peer.room
connect()
}
/// Read the system pasteboard (foreground only iOS bans background reads)
/// and push it to the room.
func sendClipboard() {
guard let text = UIPasteboard.general.string, !text.isEmpty else {
lastError = "Clipboard is empty."
return
}
send(text)
}
func send(_ text: String) {
engine?.send(text: text)
}
/// Send a picked file P2P.
func sendFile(at url: URL) {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url) else { return }
engine?.sendFile(name: url.lastPathComponent, mime: Self.mime(for: url.pathExtension), data: data)
}
static func mime(for ext: String) -> String {
switch ext.lowercased() {
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "heic": return "image/heic"
case "pdf": return "application/pdf"
case "txt": return "text/plain"
default: return "application/octet-stream"
}
}
/// A received file: save to Documents, preview if it's an image.
fileprivate func ingestFile(name: String, mime: String, data: Data, date: Date) {
let image: UIImage? = mime.hasPrefix("image/") ? UIImage(data: data) : nil
var url: URL?
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let dest = docs.appendingPathComponent(name.isEmpty ? "tether-file" : name)
try? data.write(to: dest)
url = dest
}
feed.insert(FeedItem(text: name, source: "file", date: date, image: image, fileURL: url), at: 0)
if feed.count > 100 { feed.removeLast() }
}
/// Copy a received message back onto the local pasteboard.
func copyToPasteboard(_ item: FeedItem) {
UIPasteboard.general.string = item.text
}
// Called from the engine bridge, already hopped onto the main actor.
fileprivate func ingest(_ item: FeedItem) {
feed.insert(item, at: 0)
if feed.count > 100 { feed.removeLast() }
}
fileprivate func setConnected(_ value: Bool) {
connected = value
}
}
/// Adapts the engine's callback interface (fired on the Rust runtime thread)
/// onto the main actor for SwiftUI.
private final class EngineBridge: MessageHandler {
weak var store: TetherStore?
init(store: TetherStore) { self.store = store }
func onMessage(msg: Message) {
// RTC-delivered messages carry ts=0 (no server stamp) use now.
let date = msg.ts > 0 ? Date(timeIntervalSince1970: Double(msg.ts) / 1000.0) : Date()
let item = FeedItem(
text: msg.text,
source: msg.source.isEmpty ? "unknown" : msg.source,
date: date
)
Task { @MainActor in self.store?.ingest(item) }
}
func onStatus(connected: Bool) {
Task { @MainActor in self.store?.setConnected(connected) }
}
func onFile(name: String, mime: String, data: Data) {
Task { @MainActor in self.store?.ingestFile(name: name, mime: mime, data: data, date: Date()) }
}
}

View File

@@ -0,0 +1,376 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
74D3E9952DD8E0913BE6B299 /* TetherApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59E41534347FCDEDED407D6 /* TetherApp.swift */; };
A0B34D3F9849F9BC614CC058 /* TetherStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1001BBAABF813AB29AD45 /* TetherStore.swift */; };
B00ECEE60ABBEFEDFF548750 /* TetherKit in Frameworks */ = {isa = PBXBuildFile; productRef = 214441C31F40D721B6B47F10 /* TetherKit */; };
CCA52ACB37EF809880064207 /* RoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373509C266AF62E742C792A /* RoomView.swift */; };
EEEAB6E2132E74568361D04E /* TetherCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 20AC83BF63EB7D2A826BFF91 /* TetherCore.xcframework */; };
FF510853346E00FA9D3B7552 /* tethercore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5C7A947C4BBA8AF0381677 /* tethercore.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
20AC83BF63EB7D2A826BFF91 /* TetherCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TetherCore.xcframework; path = Vendor/TetherCore.xcframework; sourceTree = "<group>"; };
331B3970CED74956D9BE87FF /* TetherApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = TetherApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
41B6BEFA7CFACAAA3507391B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
6D5C7A947C4BBA8AF0381677 /* tethercore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tethercore.swift; sourceTree = "<group>"; };
B59E41534347FCDEDED407D6 /* TetherApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetherApp.swift; sourceTree = "<group>"; };
C4E3CFAF660DDE2C62CD1996 /* TetherKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = TetherKit; path = ../TetherKit; sourceTree = SOURCE_ROOT; };
D7D1001BBAABF813AB29AD45 /* TetherStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetherStore.swift; sourceTree = "<group>"; };
F373509C266AF62E742C792A /* RoomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A97B508FAD5DC69149AA5DB8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B00ECEE60ABBEFEDFF548750 /* TetherKit in Frameworks */,
EEEAB6E2132E74568361D04E /* TetherCore.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0565A8CEDDE5C5C0BE113DA3 /* Sources */ = {
isa = PBXGroup;
children = (
41B6BEFA7CFACAAA3507391B /* Info.plist */,
F373509C266AF62E742C792A /* RoomView.swift */,
B59E41534347FCDEDED407D6 /* TetherApp.swift */,
6D5C7A947C4BBA8AF0381677 /* tethercore.swift */,
D7D1001BBAABF813AB29AD45 /* TetherStore.swift */,
);
path = Sources;
sourceTree = "<group>";
};
47410593516FEA684761565A /* Frameworks */ = {
isa = PBXGroup;
children = (
20AC83BF63EB7D2A826BFF91 /* TetherCore.xcframework */,
);
name = Frameworks;
sourceTree = "<group>";
};
485FAD0A0B0809694D9B7B77 = {
isa = PBXGroup;
children = (
5EF696014E5E1F874C5B729B /* Packages */,
0565A8CEDDE5C5C0BE113DA3 /* Sources */,
47410593516FEA684761565A /* Frameworks */,
717BFFF9FC0CFF5C5EFFF376 /* Products */,
);
sourceTree = "<group>";
};
5EF696014E5E1F874C5B729B /* Packages */ = {
isa = PBXGroup;
children = (
C4E3CFAF660DDE2C62CD1996 /* TetherKit */,
);
name = Packages;
sourceTree = "<group>";
};
717BFFF9FC0CFF5C5EFFF376 /* Products */ = {
isa = PBXGroup;
children = (
331B3970CED74956D9BE87FF /* TetherApp.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AC341A4B4EDFDC4C3AD2F0C5 /* TetherApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 032827B25A14759DD2501508 /* Build configuration list for PBXNativeTarget "TetherApp" */;
buildPhases = (
6EF56AF088F40C274BCA5B54 /* Sources */,
A97B508FAD5DC69149AA5DB8 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = TetherApp;
packageProductDependencies = (
214441C31F40D721B6B47F10 /* TetherKit */,
);
productName = TetherApp;
productReference = 331B3970CED74956D9BE87FF /* TetherApp.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
76822E0F7563E5868E7A89FA /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
AC341A4B4EDFDC4C3AD2F0C5 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = EC9400412735B9FCCDE4DED9 /* Build configuration list for PBXProject "TetherApp" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 485FAD0A0B0809694D9B7B77;
minimizedProjectReferenceProxies = 1;
packageReferences = (
05E8667CA64523CECB82791D /* XCLocalSwiftPackageReference "../TetherKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 717BFFF9FC0CFF5C5EFFF376 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
AC341A4B4EDFDC4C3AD2F0C5 /* TetherApp */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
6EF56AF088F40C274BCA5B54 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CCA52ACB37EF809880064207 /* RoomView.swift in Sources */,
74D3E9952DD8E0913BE6B299 /* TetherApp.swift in Sources */,
A0B34D3F9849F9BC614CC058 /* TetherStore.swift in Sources */,
FF510853346E00FA9D3B7552 /* tethercore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
34B8C4EB3F519898D07EFD9B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\"Vendor\"",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Sources/Info.plist;
INFOPLIST_KEY_NSAppTransportSecurity = "";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "tether connects to your clipboard relay on the local network.";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.0;
OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -framework Network -framework CoreFoundation -framework Foundation -lobjc -liconv -lresolv";
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
370C8005542C3C6CA5C95685 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
C43F83B7E47FEBE11D45EE58 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\"Vendor\"",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Sources/Info.plist;
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "tether connects to your clipboard relay on the local network.";
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.0;
OTHER_LDFLAGS = "-framework Security -framework SystemConfiguration -framework Network -framework CoreFoundation -framework Foundation -lobjc -liconv -lresolv";
PRODUCT_BUNDLE_IDENTIFIER = io.pecord.tether;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
F5F0867AAFA961DC5B7FC432 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
032827B25A14759DD2501508 /* Build configuration list for PBXNativeTarget "TetherApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
34B8C4EB3F519898D07EFD9B /* Debug */,
C43F83B7E47FEBE11D45EE58 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
EC9400412735B9FCCDE4DED9 /* Build configuration list for PBXProject "TetherApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
370C8005542C3C6CA5C95685 /* Debug */,
F5F0867AAFA961DC5B7FC432 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
05E8667CA64523CECB82791D /* XCLocalSwiftPackageReference "../TetherKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../TetherKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
214441C31F40D721B6B47F10 /* TetherKit */ = {
isa = XCSwiftPackageProductDependency;
productName = TetherKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 76822E0F7563E5868E7A89FA /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

47
ios/TetherApp/project.yml Normal file
View File

@@ -0,0 +1,47 @@
name: TetherApp
options:
bundleIdPrefix: io.pecord
deploymentTarget:
iOS: "26.0"
createIntermediateGroups: true
packages:
TetherKit:
path: ../TetherKit
targets:
TetherApp:
type: application
platform: iOS
sources:
- Sources
dependencies:
- package: TetherKit # RoomIdentity only — networking now lives in TetherCore
- framework: Vendor/TetherCore.xcframework
embed: false # static lib, nothing to embed
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: io.pecord.tether
# Rust staticlib pulls in SecRandomCopyBytes (ring) + system config
# (reqwest) + iroh's netwatch (Network.framework) + objc2 bindings
# (CoreFoundation/Foundation/objc) + iconv.
OTHER_LDFLAGS: "-framework Security -framework SystemConfiguration -framework Network -framework CoreFoundation -framework Foundation -lobjc -liconv -lresolv"
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
GENERATE_INFOPLIST_FILE: true
# Free Apple ID signing for on-device runs; blank is fine for Simulator.
DEVELOPMENT_TEAM: ""
CODE_SIGN_STYLE: Automatic
# http:// to the LAN server — allow cleartext on local network.
INFOPLIST_KEY_NSLocalNetworkUsageDescription: "tether connects to your clipboard relay on the local network."
INFOPLIST_KEY_UILaunchScreen_Generation: true
configs:
Debug:
INFOPLIST_KEY_NSAppTransportSecurity: ""
info:
path: Sources/Info.plist
properties:
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
NSLocalNetworkUsageDescription: "tether connects to your clipboard relay on the local network."
UILaunchScreen: {}

View File

@@ -0,0 +1,18 @@
// swift-tools-version: 6.0
import PackageDescription
// Reduced to just `RoomIdentity` the wire protocol moved to the shared Rust
// `tethercore` engine (UniFFI). This package only supplies the platform-native
// room-id derivation the engine doesn't (and shouldn't) know about.
let package = Package(
name: "TetherKit",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "TetherKit", targets: ["TetherKit"]),
],
targets: [
.target(name: "TetherKit", linkerSettings: [
.linkedFramework("IOKit", .when(platforms: [.macOS])),
]),
]
)

View File

@@ -0,0 +1,56 @@
import CryptoKit
import Foundation
import Security
#if os(macOS)
import IOKit
#endif
/// Derives a stable 8-hex room ID with no user configuration.
/// Priority:
/// 1. macOS: IOKit hardware UUID durable to this machine
/// 2. All platforms: Keychain-stored UUID generated once, survives reinstalls
/// SiwA/iCloud identity will be added at v0.6 when dev program entitlements land.
public enum RoomIdentity {
public static func derive() -> String {
hardwareDerived() ?? keychainOrNew()
}
private static func hardwareDerived() -> String? {
#if os(macOS)
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("IOPlatformExpertDevice"))
guard service != 0 else { return nil }
defer { IOObjectRelease(service) }
guard let raw = IORegistryEntryCreateCFProperty(
service, "IOPlatformUUID" as CFString, kCFAllocatorDefault, 0),
let uuid = raw.takeRetainedValue() as? String
else { return nil }
return sha8(Data(uuid.utf8))
#else
return nil // iOS: fall through to Keychain UUID
#endif
}
private static func keychainOrNew() -> String {
let account = "io.pecord.tether.roomID"
let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecReturnData: true]
var ref: AnyObject?
if SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess,
let data = ref as? Data, let stored = String(data: data, encoding: .utf8) {
return stored
}
let new = String(UUID().uuidString.lowercased().filter(\.isHexDigit).prefix(8))
let add: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecValueData: Data(new.utf8)]
SecItemAdd(add as CFDictionary, nil)
return new
}
private static func sha8(_ data: Data) -> String {
SHA256.hash(data: data).prefix(4).map { String(format: "%02x", $0) }.joined()
}
}

18
ios/build-and-run.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Waits for the iOS sim runtime, then builds, installs, boots and screenshots TetherApp.
set -euo pipefail
cd "$(dirname "$0")/TetherApp"
echo "waiting for iOS simulator runtime…"
until xcrun simctl list runtimes 2>/dev/null | grep -qi "iOS"; do sleep 30; done
echo "runtime ready: $(xcrun simctl list runtimes | grep -i iOS | tail -1)"
DEV=$(xcrun simctl create tether-test "iPhone 16 Pro" 2>/dev/null || \
xcrun simctl list devices available | grep -i iphone | head -1 | grep -oE '[0-9A-F-]{36}')
xcrun simctl boot "$DEV" 2>/dev/null || true
open -a Simulator
xcodebuild build -scheme TetherApp -destination "id=$DEV" -derivedDataPath /tmp/tether-dd | tail -3
APP=$(find /tmp/tether-dd/Build/Products -name "TetherApp.app" -maxdepth 3 | head -1)
xcrun simctl install "$DEV" "$APP"
xcrun simctl launch "$DEV" io.pecord.tether
sleep 3
xcrun simctl io "$DEV" screenshot /tmp/tether-launch.png
echo "DONE device=$DEV shot=/tmp/tether-launch.png"

1
rendezvous/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

561
rendezvous/Cargo.lock generated Normal file
View File

@@ -0,0 +1,561 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bytes"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tether-rendezvous"
version = "0.1.0"
dependencies = [
"axum",
"serde",
"serde_json",
"tokio",
"tower-http",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"http",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

17
rendezvous/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "tether-rendezvous"
version = "0.1.0"
edition = "2021"
description = "Tiny bootstrap rendezvous for tether's iroh gossip: room → {EndpointId}. Metadata-only, content never touches it. The one thing the supernode does for zero-config discovery."
[[bin]]
name = "tether-rendezvous"
path = "src/main.rs"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# CORS so the browser (wasm) client's fetch to the rendezvous is allowed.
tower-http = { version = "0.6", features = ["cors"] }

83
rendezvous/README.md Normal file
View File

@@ -0,0 +1,83 @@
# tether-rendezvous
The one piece of server tether still needs once it's on iroh: a **bootstrap
rendezvous**. To join the gossip topic a device needs to reach ≥1 peer already
in it; this hands it one. After that, everything is peer-to-peer.
It is deliberately tiny and **content-blind**:
- maps `room → {EndpointId: last_seen}` in memory, TTL 90s
- `room` is already `sha256(account)[..4]`, so the account is never exposed
- it only ever sees iroh public keys + timestamps — **never clipboard content**
(that's E2E between peers)
This is "supernode #1, the boring half." The other half is a self-hosted
`iroh-relay` (for holepunch-assist / relay fallback). Neither replaces the old
Go server's role in *content* — there is none anymore.
## API
```
POST /rendezvous/register {"room":"<hex>","id":"<endpoint-id>"}
→ {"peers":["<other-id>", ...]} # current members minus the caller
GET /rendezvous/peers?room=<hex> → {"peers":[...]}
GET /healthz → "ok"
```
Clients register on a short interval (well under the 90s TTL) so the member list
reflects who's actually online.
## Run
```
cargo run --release # binds 0.0.0.0:8765
PORT=9000 cargo run --release
```
## Deploy (homelab)
It needs to be **publicly reachable** for cross-network (cellular/CGNAT) devices
to bootstrap — same requirement as the iroh-relay. On the homelab:
1. Run it on a CT/VM (systemd unit below).
2. Front it with Caddy for TLS, e.g. `rendezvous.pecord.io`:
```
rendezvous.pecord.io {
reverse_proxy 127.0.0.1:8765
}
```
(Plain HTTP reverse-proxy is fine — unlike the iroh-relay, there's no
UDP/QUIC path here, just small JSON over HTTPS.)
3. Point the tether clients' server field at `https://rendezvous.pecord.io`.
### systemd unit
```ini
[Unit]
Description=tether rendezvous
After=network-online.target
[Service]
ExecStart=/opt/tether/tether-rendezvous
Environment=PORT=8765
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
```
## Auth
Set `TETHER_RENDEZVOUS_TOKEN` to require `Authorization: Bearer <token>` on
`register`/`peers` (`healthz` stays open). Clients send it via the same env var
(`TETHER_RENDEZVOUS_TOKEN`), so the agent/CLI pick it up automatically; the iOS
app needs a field for it (small Swift follow-up). **Empty token = open** — fine
for a LAN/test run, but set one before exposing it publicly. Without a token,
anyone who knows a `room` (a hash) can register/enumerate — metadata only, content
stays E2E, but still worth gating.
Remaining hardening (follow-up): per-IP rate-limiting (the `MAX_PER_ROOM` cap is
only a flood blunt); behind Caddy use `X-Forwarded-For` for the real client IP.

163
rendezvous/src/main.rs Normal file
View File

@@ -0,0 +1,163 @@
//! tether-rendezvous — the only thing the server still does once tether is on
//! iroh: hand a joining device one current peer so it can bootstrap into the
//! gossip topic. After that, everything is peer-to-peer.
//!
//! It maps `room → {EndpointId: last_seen}`, in memory, with a short TTL. The
//! room is already sha256(account)[..4], so the account is never exposed; and it
//! only ever sees iroh public keys + timestamps — **never content** (that's E2E
//! between peers). This is "supernode #1, the boring part"; the iroh-relay is
//! the other part.
//!
//! Run: tether-rendezvous # binds 0.0.0.0:8765
//! PORT=9000 tether-rendezvous
//! Behind Caddy at e.g. rendezvous.pecord.io. Clients point cfg.server at it.
//!
//! API:
//! POST /rendezvous/register {"room": "...", "id": "<endpoint-id>"}
//! → {"peers": ["<other-id>", ...]} (current members minus the caller)
//! GET /rendezvous/peers?room=... → {"peers": [...]}
//! GET /healthz → "ok"
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
/// Members are forgotten this long after their last register — so a member list
/// reflects who's actually online (clients re-register on a shorter interval).
const TTL: Duration = Duration::from_secs(90);
/// Cap members per room to blunt a junk-registration flood.
const MAX_PER_ROOM: usize = 64;
// room → id → (relay_url, last_seen). The relay url lets a browser peer (which
// can't resolve id→address via discovery) dial the member directly.
type Rooms = Arc<Mutex<HashMap<String, HashMap<String, (String, Instant)>>>>;
/// Shared state: the room table + an optional bearer token. When the token is
/// non-empty, register/peers require `Authorization: Bearer <token>`; when empty
/// (default) the endpoints are open — fine for a LAN/test run, but set a token
/// before exposing it publicly.
#[derive(Clone)]
struct AppState {
rooms: Rooms,
token: Arc<String>,
}
/// Constant-time-ish bearer check (length + byte compare). Empty token → open.
fn authorized(headers: &HeaderMap, token: &str) -> bool {
if token.is_empty() {
return true;
}
let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) else {
return false;
};
let presented = auth.strip_prefix("Bearer ").unwrap_or("");
presented.len() == token.len()
&& presented
.bytes()
.zip(token.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
}
#[derive(Deserialize)]
struct Register {
room: String,
id: String,
#[serde(default)]
relay: String, // the member's iroh relay URL (so browsers can dial it)
}
#[derive(Deserialize)]
struct PeersQuery {
room: String,
}
#[derive(Serialize, Clone)]
struct PeerInfo {
id: String,
relay: String,
}
#[derive(Serialize)]
struct Peers {
peers: Vec<PeerInfo>,
}
fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec<PeerInfo> {
let now = Instant::now();
let mut map = rooms.lock().unwrap();
let members = map.entry(room.to_string()).or_default();
members.retain(|_, (_, seen)| now.duration_since(*seen) < TTL);
members
.iter()
.filter(|(id, _)| Some(id.as_str()) != exclude)
.map(|(id, (relay, _))| PeerInfo { id: id.clone(), relay: relay.clone() })
.collect()
}
async fn register(
State(st): State<AppState>,
headers: HeaderMap,
Json(r): Json<Register>,
) -> Result<Json<Peers>, StatusCode> {
if !authorized(&headers, &st.token) {
return Err(StatusCode::UNAUTHORIZED);
}
if !r.room.is_empty() && !r.id.is_empty() {
let now = Instant::now();
let mut map = st.rooms.lock().unwrap();
let members = map.entry(r.room.clone()).or_default();
members.retain(|_, (_, seen)| now.duration_since(*seen) < TTL);
if members.len() < MAX_PER_ROOM || members.contains_key(&r.id) {
members.insert(r.id.clone(), (r.relay.clone(), now));
}
}
Ok(Json(Peers { peers: live_peers(&st.rooms, &r.room, Some(&r.id)) }))
}
async fn peers(
State(st): State<AppState>,
headers: HeaderMap,
Query(q): Query<PeersQuery>,
) -> Result<Json<Peers>, StatusCode> {
if !authorized(&headers, &st.token) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(Json(Peers { peers: live_peers(&st.rooms, &q.room, None) }))
}
#[tokio::main]
async fn main() {
let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8765);
let token = std::env::var("TETHER_RENDEZVOUS_TOKEN").unwrap_or_default();
let state = AppState {
rooms: Arc::new(Mutex::new(HashMap::new())),
token: Arc::new(token.clone()),
};
let app = Router::new()
.route("/rendezvous/register", post(register))
.route("/rendezvous/peers", get(peers))
.route("/healthz", get(|| async { "ok" }))
.with_state(state)
// Allow the browser (wasm) client to fetch us cross-origin.
.layer(tower_http::cors::CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await.unwrap();
let auth = if token.is_empty() { "OPEN (no token — set TETHER_RENDEZVOUS_TOKEN before public exposure)" } else { "token-gated" };
println!("tether-rendezvous listening on 0.0.0.0:{port}{auth}");
axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await
.unwrap();
}