core: make the engine multi-transport (Transport trait + inbound dedup)

Restructure tethercore so the engine owns a list of pluggable Transports
instead of a hardcoded SSE path. SSE moves behind the trait unchanged;
RTC (webrtc-rs data channel) and BT (BLE GATT) now have a clean socket to
implement the same interface — start(sink, status) / publish(msg).

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:48:01 -05:00
parent 2c375632a5
commit 19f5eaccdf

View File

@@ -1,23 +1,26 @@
//! tethercore — the shared sync engine. //! tethercore — the shared sync engine.
//! //!
//! Owns the wire protocol (codec + SSE client + reconnect/backoff + send) and //! Owns the wire protocol (codec + transports + reconnect/backoff + send) and
//! nothing else: no clipboard, no UI. Those stay native on each platform and //! nothing else: no clipboard, no UI. Exposed via UniFFI behind a 4-method seam:
//! talk to this engine through a 4-method seam exported via UniFFI:
//! //!
//! Engine::new(server, room, from, source) //! Engine::new(server, room, from, source)
//! engine.start(handler) // inbound clipboard → handler.on_message //! engine.start(handler) // inbound clipboard → handler.on_message
//! engine.send(text) //! engine.send(text)
//! engine.stop() //! engine.stop()
//! //!
//! Desktop links it directly; iOS binds the .xcframework, Android the .aar. //! Transports are pluggable (see `Transport`). SSE ships today; RTC and BT slot
//! RTC mesh (the `signal` envelope) is intentionally out of scope for v1. //! 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.
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use futures_util::StreamExt; use futures_util::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::runtime::Handle;
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();
@@ -44,13 +47,11 @@ pub struct Message {
} }
/// Implemented on the foreign side (Swift/Kotlin) or natively (desktop agent). /// Implemented on the foreign side (Swift/Kotlin) or natively (desktop agent).
/// All callbacks fire on the engine's runtime thread — marshal to the UI thread /// Callbacks fire on the engine's runtime thread — marshal to the UI thread
/// on the consumer side. /// on the consumer side.
#[uniffi::export(callback_interface)] #[uniffi::export(callback_interface)]
pub trait MessageHandler: Send + Sync { pub trait MessageHandler: Send + Sync {
/// A clipboard message from another device in the room (self-echo filtered).
fn on_message(&self, msg: Message); fn on_message(&self, msg: Message);
/// Connection state changed: true once subscribed, false on drop/error.
fn on_status(&self, connected: bool); fn on_status(&self, connected: bool);
} }
@@ -62,19 +63,34 @@ struct Config {
source: String, source: String,
} }
/// Sink/Status closures a transport calls for inbound traffic and link state.
type Sink = Arc<dyn Fn(Message) + Send + Sync>;
type Status = Arc<dyn Fn(bool) + Send + Sync>;
/// A pluggable delivery channel. The engine owns one or more; today just SSE.
/// RTC (WebRTC data channel) and BT (BLE GATT) implement the same trait.
trait Transport: Send + Sync {
fn name(&self) -> &'static str;
/// Spawn the inbound loop on `rt`; deliver received messages via `sink`
/// and link transitions via `status`. Runs until `running` clears.
fn start(self: Arc<Self>, rt: Handle, running: Arc<AtomicBool>, sink: Sink, status: Status);
/// Publish an outbound message. Fire-and-forget.
fn publish(&self, rt: &Handle, msg: Message);
}
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct Engine { pub struct Engine {
cfg: Config, cfg: Config,
http: reqwest::Client,
rt: Arc<tokio::runtime::Runtime>, rt: Arc<tokio::runtime::Runtime>,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
transports: Vec<Arc<dyn Transport>>,
dedup: Arc<Mutex<Dedup>>,
} }
#[uniffi::export] #[uniffi::export]
impl Engine { impl Engine {
/// `server` is the bus base URL, e.g. "http://192.168.11.233:8765". /// `server` is the bus base URL, e.g. "http://192.168.11.233:8765".
/// `from` is this device's stable id (used for echo suppression); /// `from` is this device's stable id (echo suppression); `source` a label.
/// `source` is a human label like "macos" / "iphone".
#[uniffi::constructor] #[uniffi::constructor]
pub fn new(server: String, room: String, from: String, source: String) -> Arc<Self> { pub fn new(server: String, room: String, from: String, source: String) -> Arc<Self> {
let rt = tokio::runtime::Builder::new_multi_thread() let rt = tokio::runtime::Builder::new_multi_thread()
@@ -82,125 +98,198 @@ impl Engine {
.enable_all() .enable_all()
.build() .build()
.expect("tokio runtime"); .expect("tokio runtime");
Arc::new(Self { let cfg = Config { server, room, from, source };
cfg: Config { server, room, from, source }, let transports: Vec<Arc<dyn Transport>> = vec![Arc::new(SseTransport {
cfg: cfg.clone(),
http: reqwest::Client::new(), http: reqwest::Client::new(),
})];
Arc::new(Self {
cfg,
rt: Arc::new(rt), rt: Arc::new(rt),
running: Arc::new(AtomicBool::new(false)), running: Arc::new(AtomicBool::new(false)),
transports,
dedup: Arc::new(Mutex::new(Dedup::default())),
}) })
} }
/// Begin streaming. Idempotent: a second call while running is a no-op. /// Begin streaming on every transport. Idempotent while already running.
pub fn start(&self, handler: Box<dyn MessageHandler>) { pub fn start(&self, handler: Box<dyn MessageHandler>) {
if self.running.swap(true, Ordering::SeqCst) { if self.running.swap(true, Ordering::SeqCst) {
return; return;
} }
let handler: Arc<dyn MessageHandler> = Arc::from(handler); let handler: Arc<dyn MessageHandler> = Arc::from(handler);
let http = self.http.clone(); let dedup = self.dedup.clone();
let cfg = self.cfg.clone(); let h_msg = handler.clone();
let running = self.running.clone(); let sink: Sink = Arc::new(move |m| {
self.rt.spawn(async move { if dedup.lock().unwrap().seen(&m) {
run_loop(http, cfg, running, handler).await; return; // same clipboard already delivered via another transport
});
}
/// Publish clipboard text to the room. Fire-and-forget.
pub fn send(&self, text: String) {
let http = self.http.clone();
let cfg = self.cfg.clone();
self.rt.spawn(async move {
if let Err(e) = post(&http, &cfg, &text).await {
eprintln!("tethercore: send failed: {e}");
} }
h_msg.on_message(m);
}); });
let h_status = handler.clone();
let status: Status = Arc::new(move |c| h_status.on_status(c));
for t in &self.transports {
t.clone().start(
self.rt.handle().clone(),
self.running.clone(),
sink.clone(),
status.clone(),
);
}
} }
/// Stop streaming. The reconnect loop exits at its next checkpoint. /// Publish clipboard text to the room across all transports.
pub fn send(&self, text: String) {
let msg = Message {
kind: "clipboard".into(),
text,
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 rt = self.rt.handle();
for t in &self.transports {
t.publish(rt, msg.clone());
}
}
/// Stop all transports at their next checkpoint.
pub fn stop(&self) { pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst); self.running.store(false, Ordering::SeqCst);
} }
} }
async fn run_loop( /// Short-window de-dup keyed on the fields the server preserves. Once RTC lands,
http: reqwest::Client, /// a clipboard echoed over both SSE and the data channel collapses to one.
cfg: Config, #[derive(Default)]
running: Arc<AtomicBool>, struct Dedup {
handler: Arc<dyn MessageHandler>, recent: VecDeque<(String, i64, String)>,
) {
let min = Duration::from_millis(500);
let max = Duration::from_secs(10);
let mut backoff = min;
while running.load(Ordering::SeqCst) {
match connect_stream(&http, &cfg, &running, &handler).await {
Ok(()) => backoff = min, // clean EOF — reconnect promptly
Err(e) => {
handler.on_status(false);
eprintln!("tethercore: stream error: {e}");
}
}
if running.load(Ordering::SeqCst) {
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(max);
}
}
handler.on_status(false);
} }
async fn connect_stream( impl Dedup {
http: &reqwest::Client, fn seen(&mut self, m: &Message) -> bool {
cfg: &Config, let key = (m.from.clone(), m.ts, m.text.clone());
running: &AtomicBool, if self.recent.contains(&key) {
handler: &Arc<dyn MessageHandler>, return true;
) -> Result<(), String> { }
let url = format!( self.recent.push_back(key);
"{}/api/stream?room={}", if self.recent.len() > 256 {
cfg.server.trim_end_matches('/'), self.recent.pop_front();
cfg.room }
); false
let resp = http
.get(&url)
.header("X-Tether-Client", &cfg.from)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
} }
handler.on_status(true); }
// Byte-buffer the SSE stream; lines split on '\n' (ASCII, so chunk-safe). // ---------------------------------------------------------------------------
let mut stream = resp.bytes_stream(); // SSE transport — the reliable floor: server relay over text/event-stream.
let mut buf: Vec<u8> = Vec::new(); // ---------------------------------------------------------------------------
let mut data: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await { struct SseTransport {
if !running.load(Ordering::SeqCst) { cfg: Config,
return Ok(()); http: reqwest::Client,
} }
buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
while let Some(nl) = buf.iter().position(|&b| b == b'\n') { impl Transport for SseTransport {
let mut line: Vec<u8> = buf.drain(..=nl).collect(); fn name(&self) -> &'static str {
line.pop(); // '\n' "sse"
if line.last() == Some(&b'\r') { }
line.pop();
} fn start(self: Arc<Self>, rt: Handle, running: Arc<AtomicBool>, sink: Sink, status: Status) {
if line.is_empty() { let this = self;
// Event boundary: flush accumulated data field. rt.spawn(async move {
if !data.is_empty() { let min = Duration::from_millis(500);
if let Ok(m) = serde_json::from_slice::<Message>(&data) { let max = Duration::from_secs(10);
if m.from != cfg.from { let mut backoff = min;
handler.on_message(m); while running.load(Ordering::SeqCst) {
} match this.stream_once(&running, &sink, &status).await {
} Ok(()) => backoff = min, // clean EOF — reconnect promptly
data.clear(); Err(e) => {
} status(false);
} else if let Some(rest) = line.strip_prefix(b"data:") { eprintln!("tethercore[{}]: {e}", this.name());
let rest = rest.strip_prefix(b" ").unwrap_or(rest); }
data.extend_from_slice(rest); }
} if running.load(Ordering::SeqCst) {
// ':' comments (keepalives) and event:/id: lines are ignored. tokio::time::sleep(backoff).await;
} backoff = (backoff * 2).min(max);
}
}
status(false);
});
}
fn publish(&self, rt: &Handle, msg: Message) {
let http = self.http.clone();
let cfg = self.cfg.clone();
let name = self.name();
rt.spawn(async move {
if let Err(e) = post(&http, &cfg, &msg.text).await {
eprintln!("tethercore[{name}]: send failed: {e}");
}
});
}
}
impl SseTransport {
async fn stream_once(
&self,
running: &AtomicBool,
sink: &Sink,
status: &Status,
) -> 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()));
}
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) {
if m.from != self.cfg.from {
sink(m); // engine de-dups + forwards to handler
}
}
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(())
} }
Ok(())
} }
async fn post(http: &reqwest::Client, cfg: &Config, text: &str) -> Result<(), String> { async fn post(http: &reqwest::Client, cfg: &Config, text: &str) -> Result<(), String> {