Files
tether/core/examples/iroh_engine.rs
Patrick Ecord 2c1419d18c core+rendezvous: zero-config bootstrap + iroh on iOS
Two pieces that unblock the on-device cross-CGNAT test:

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:27:03 -05:00

104 lines
3.8 KiB
Rust

//! 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();
}