From ad026e8871f7994e8d44ea65c921ee92d061bf88 Mon Sep 17 00:00:00 2001 From: Patrick Ecord Date: Tue, 23 Jun 2026 17:54:53 -0500 Subject: [PATCH] =?UTF-8?q?wasm:=20wasm-bindgen=20surface=20=E2=80=94=20Wa?= =?UTF-8?q?smEngine=20+=20roomForAccount=20for=20the=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/wasm.rs exposes the engine to JS (gated to wasm32 + iroh-transport): a WasmEngine wrapping Arc with new(server, room, from, account) / start( on_message) / send / stop / irohId, plus roomForAccount so the browser derives the same room as native clients. Inbound clipboards bridge to a JS callback via a JsHandler. MessageHandler's Send+Sync bound is relaxed on wasm (single JS thread; a js_sys::Function isn't Send and the engine spawns via spawn_local where it isn't needed). Adds wasm-bindgen + js-sys (wasm-only deps). Native (UniFFI) + wasm both compile clean. Next: generate the JS bindings with wasm-bindgen-cli and a browser smoke test. Co-Authored-By: Claude Opus 4.8 --- core/Cargo.lock | 2 ++ core/Cargo.toml | 2 ++ core/src/lib.rs | 15 ++++++++- core/src/wasm.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 core/src/wasm.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index f9173ba..ca5dfb7 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -4857,6 +4857,7 @@ dependencies = [ "iroh", "iroh-blobs", "iroh-gossip", + "js-sys", "mdns-sd", "n0-future", "reqwest 0.12.28", @@ -4865,6 +4866,7 @@ dependencies = [ "sha2 0.11.0", "tokio", "uniffi", + "wasm-bindgen", "wasm-bindgen-futures", "webrtc", ] diff --git a/core/Cargo.toml b/core/Cargo.toml index b32ad41..c3dbb7f 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -59,6 +59,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json"] } 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" # 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.) diff --git a/core/src/lib.rs b/core/src/lib.rs index bb84a5a..be92b6c 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -67,6 +67,9 @@ mod iroh; 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; @@ -101,13 +104,23 @@ pub struct Message { /// 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. -#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(callback_interface))] +// 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); } +#[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); +} /// 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 diff --git a/core/src/wasm.rs b/core/src/wasm.rs new file mode 100644 index 0000000..5742846 --- /dev/null +++ b/core/src/wasm.rs @@ -0,0 +1,80 @@ +//! 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, +} + +#[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 { + 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 { + 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) +} + +/// 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) {} +}