Compare commits
71 Commits
main
...
ios-client
| Author | SHA1 | Date | |
|---|---|---|---|
|
6d76c7093d
|
|||
|
3d4af095b1
|
|||
|
1135b7d27b
|
|||
|
ad026e8871
|
|||
|
39ec4bc216
|
|||
|
2ec5d892aa
|
|||
|
7859184a48
|
|||
|
3b1244ad06
|
|||
|
9febee6a60
|
|||
|
55340fe8f6
|
|||
|
d855bbbfa7
|
|||
|
5dfb2aa133
|
|||
|
1538cc64b3
|
|||
|
3f023402e3
|
|||
|
3e4781c4af
|
|||
|
e3d70e17f1
|
|||
|
1487ac4cb9
|
|||
|
33c577357c
|
|||
|
eefd58cf5b
|
|||
|
2c1419d18c
|
|||
|
3cb916ee5e
|
|||
|
b377a6708a
|
|||
|
8797ac2670
|
|||
|
6a96046a9c
|
|||
|
2c54a67eac
|
|||
|
069641be4e
|
|||
|
106e1283cc
|
|||
|
6645f6f37f
|
|||
|
1788bf7a37
|
|||
|
68738dd82c
|
|||
|
da0d0365e4
|
|||
|
61f4fbe678
|
|||
|
b85df3fadd
|
|||
|
e84f1b7d65
|
|||
|
33d0fb7f7e
|
|||
|
c9546ac222
|
|||
|
479dda22d6
|
|||
|
0195b1e872
|
|||
|
b2a1e06f56
|
|||
|
d3dd785921
|
|||
|
fdbc99d40e
|
|||
|
88ae0e6958
|
|||
|
ee938600c3
|
|||
|
7ccb32bb64
|
|||
|
cd0ac012e0
|
|||
|
cb0df2af81
|
|||
|
290284f4e1
|
|||
|
1fbe989675
|
|||
|
d1750a95d6
|
|||
|
f7bc8eed4b
|
|||
|
c3b9b7940f
|
|||
|
049456521f
|
|||
|
6e24d19a58
|
|||
|
e52e20c2c5
|
|||
|
0e0b504808
|
|||
|
377aa67d4d
|
|||
|
f9a7f38bb7
|
|||
|
09d63651ab
|
|||
|
38b9b55ac8
|
|||
|
3581c1e86c
|
|||
|
1caa44e253
|
|||
|
db64b3b9a6
|
|||
|
fdd65eea7c
|
|||
|
a700a4b54b
|
|||
|
19f5eaccdf
|
|||
|
2c375632a5
|
|||
|
b39fcc37e8
|
|||
|
c73108a126
|
|||
|
c4334143b0
|
|||
|
6c2dcdd603
|
|||
|
14b6be7548
|
510
ARCHITECTURE.md
Normal file
510
ARCHITECTURE.md
Normal 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 M1–M3). 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.
|
||||||
50
README.md
50
README.md
@@ -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
1
agent/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
6746
agent/Cargo.lock
generated
Normal file
6746
agent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
agent/Cargo.toml
Normal file
19
agent/Cargo.toml
Normal 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
20
agent/README.md
Normal 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
397
agent/src/main.rs
Normal 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('&', "&").replace('<', "<").replace('>', ">");
|
||||||
|
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
11
android/.gitignore
vendored
Normal 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
35
android/README.md
Normal 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.
|
||||||
48
android/app/build.gradle.kts
Normal file
48
android/app/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
24
android/app/src/main/AndroidManifest.xml
Normal file
24
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
86
android/app/src/main/java/io/pecord/tether/MainActivity.kt
Normal file
86
android/app/src/main/java/io/pecord/tether/MainActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
android/app/src/main/java/io/pecord/tether/RoomIdentity.kt
Normal file
17
android/app/src/main/java/io/pecord/tether/RoomIdentity.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
106
android/app/src/main/java/io/pecord/tether/TetherStore.kt
Normal file
106
android/app/src/main/java/io/pecord/tether/TetherStore.kt
Normal 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
5
android/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
16
android/settings.gradle.kts
Normal file
16
android/settings.gradle.kts
Normal 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
4
core/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/target/
|
||||||
|
/bindings/
|
||||||
|
/build-xcframework/
|
||||||
|
/*.xcframework
|
||||||
6502
core/Cargo.lock
generated
Normal file
6502
core/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
core/Cargo.toml
Normal file
89
core/Cargo.toml
Normal 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
37
core/build-android.sh
Executable 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
60
core/build-apple.sh
Executable 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
34
core/build-web.sh
Executable 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
|
||||||
51
core/examples/account_pair.rs
Normal file
51
core/examples/account_pair.rs
Normal 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
66
core/examples/e2e_demo.rs
Normal 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();
|
||||||
|
}
|
||||||
56
core/examples/file_demo.rs
Normal file
56
core/examples/file_demo.rs
Normal 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
83
core/examples/iroh_e2e.rs
Normal 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();
|
||||||
|
}
|
||||||
103
core/examples/iroh_engine.rs
Normal file
103
core/examples/iroh_engine.rs
Normal 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();
|
||||||
|
}
|
||||||
133
core/examples/iroh_gossip.rs
Normal file
133
core/examples/iroh_gossip.rs
Normal 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
120
core/examples/iroh_p2p.rs
Normal 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
101
core/examples/iroh_relay.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
36
core/examples/iroh_send.rs
Normal file
36
core/examples/iroh_send.rs
Normal 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");
|
||||||
|
}
|
||||||
93
core/examples/iroh_spike.rs
Normal file
93
core/examples/iroh_spike.rs
Normal 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
56
core/examples/lan_p2p.rs
Normal 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
33
core/examples/lan_peer.rs
Normal 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
41
core/examples/nearby.rs
Normal 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); }
|
||||||
|
}
|
||||||
50
core/examples/prefer_direct.rs
Normal file
50
core/examples/prefer_direct.rs
Normal 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();
|
||||||
|
}
|
||||||
44
core/examples/progress_demo.rs
Normal file
44
core/examples/progress_demo.rs
Normal 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();
|
||||||
|
}
|
||||||
40
core/examples/receipt_demo.rs
Normal file
40
core/examples/receipt_demo.rs
Normal 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();
|
||||||
|
}
|
||||||
54
core/examples/roundtrip.rs
Normal file
54
core/examples/roundtrip.rs
Normal 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
62
core/examples/rtc_p2p.rs
Normal 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();
|
||||||
|
}
|
||||||
7
core/src/bin/uniffi-bindgen.rs
Normal file
7
core/src/bin/uniffi-bindgen.rs
Normal 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
75
core/src/crypto.rs
Normal 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
388
core/src/iroh.rs
Normal 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
312
core/src/lan.rs
Normal 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
920
core/src/lib.rs
Normal 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
593
core/src/rtc.rs
Normal 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
97
core/src/wasm.rs
Normal 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
1
core/web/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/pkg/
|
||||||
80
core/web/index.html
Normal file
80
core/web/index.html
Normal 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
24
ios/.gitignore
vendored
Normal 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
|
||||||
374
ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj
Normal file
374
ios/MacTetherApp/MacTetherApp.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/MacTetherApp/MacTetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/MacTetherApp/MacTetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
255
ios/MacTetherApp/Sources/ContentView.swift
Normal file
255
ios/MacTetherApp/Sources/ContentView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ios/MacTetherApp/Sources/Info.plist
Normal file
29
ios/MacTetherApp/Sources/Info.plist
Normal 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>
|
||||||
20
ios/MacTetherApp/Sources/MacTetherApp.swift
Normal file
20
ios/MacTetherApp/Sources/MacTetherApp.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
198
ios/MacTetherApp/Sources/MacTetherStore.swift
Normal file
198
ios/MacTetherApp/Sources/MacTetherStore.swift
Normal 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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
100
ios/MacTetherApp/Sources/MenuView.swift
Normal file
100
ios/MacTetherApp/Sources/MenuView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
ios/MacTetherApp/project.yml
Normal file
42
ios/MacTetherApp/project.yml
Normal 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."
|
||||||
31
ios/TetherApp/Sources/Info.plist
Normal file
31
ios/TetherApp/Sources/Info.plist
Normal 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>
|
||||||
218
ios/TetherApp/Sources/RoomView.swift
Normal file
218
ios/TetherApp/Sources/RoomView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ios/TetherApp/Sources/TetherApp.swift
Normal file
11
ios/TetherApp/Sources/TetherApp.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TetherApp: App {
|
||||||
|
@State private var store = TetherStore()
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RoomView(store: store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
ios/TetherApp/Sources/TetherStore.swift
Normal file
186
ios/TetherApp/Sources/TetherStore.swift
Normal 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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
376
ios/TetherApp/TetherApp.xcodeproj/project.pbxproj
Normal file
376
ios/TetherApp/TetherApp.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/TetherApp/TetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/TetherApp/TetherApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal 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
47
ios/TetherApp/project.yml
Normal 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: {}
|
||||||
18
ios/TetherKit/Package.swift
Normal file
18
ios/TetherKit/Package.swift
Normal 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])),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
)
|
||||||
56
ios/TetherKit/Sources/TetherKit/RoomIdentity.swift
Normal file
56
ios/TetherKit/Sources/TetherKit/RoomIdentity.swift
Normal 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
18
ios/build-and-run.sh
Executable 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
1
rendezvous/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
561
rendezvous/Cargo.lock
generated
Normal file
561
rendezvous/Cargo.lock
generated
Normal 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
17
rendezvous/Cargo.toml
Normal 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
83
rendezvous/README.md
Normal 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
163
rendezvous/src/main.rs
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user