- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Encrypt clipboard text and file bytes at the engine with ChaCha20-Poly1305
(nonce‖ciphertext; clipboard base64'd into the text field) using a key derived
from the shared account secret. Sealing happens BEFORE any transport, so LAN,
the SSE relay, and RTC all carry only ciphertext — the server is reduced to pure
signaling (presence/SDP/ICE), never content. RTC stays doubly protected under
DTLS. Receive decrypts in the message + file sinks; undecryptable payloads (wrong
key) are dropped. Plaintext passthrough when signed out (account="").
Honest caveat (in crypto.rs): the key is sha256(account) and the account is an
email — low entropy, so this stops passive eavesdroppers but not someone who
knows the account. The real fix is a high-entropy secret (Sign in with Apple
`sub` / passphrase) → same from_secret() API.
Verified by examples/e2e_demo.rs: B decrypts A's clipboard over LAN; a relayed
send carries ciphertext only (plaintext never on the bus).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add 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>
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>
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>
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>
Add Engine.send_file(name, mime, data) and MessageHandler.on_file. Files stream
over each open RTC data channel as binary frames — M<json meta> · C<16-byte
id><16KB chunk>… · E<id> — and reassemble on the receiver in arrival order
(the channel is reliable+ordered). The data channel now distinguishes string
(clipboard) from binary (file) messages. Files are direct-only — never relayed,
matching prefer-direct.
Transport trait gains start(...files) + a default-noop send_file (only RTC
implements it). The agent saves received files to ~/Downloads; the apps stub
on_file (receive UI is a follow-up). All MessageHandler impls updated.
Proven by examples/file_demo.rs: A sends a 50KB blob (4 chunks), B reassembles
it to identical bytes over RTC.
Follow-ups: file transfer over LAN (same-network without RTC); attach/preview UI;
progress + size cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>