core: file transfer over LAN; share file frames between RTC and LAN

Rework the LAN transport from newline-delimited JSON to length-prefixed framing
([u32 len][u8 tag][payload]; tags H/J/M/C/E) so it can carry binary file chunks
alongside clipboard messages. send_file now goes over BOTH direct transports
(RTC + LAN) — so same-network file transfer works even when RTC isn't the path
(e.g. dead/unreachable server). Per-peer write locks instead of a whole-map lock
so a large file send doesn't stall clipboard.

Extract the M/C/E frame build/parse + IncomingFile into shared crate helpers
(file_frames / apply_file_frame), used by both RTC and LAN.

Since a file can now arrive over both transports, de-dup in the engine's file
sink (sha256 of name+bytes, 15s window) so on_file fires once.

Verified: clipboard still flows over the new LAN framing; a file transfers over
LAN with a dead server (RTC can't connect); RTC file transfer still works; no
duplicate delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 12:39:58 -05:00
parent 0195b1e872
commit 479dda22d6
3 changed files with 225 additions and 124 deletions

View File

@@ -1,13 +1,15 @@
//! LAN transport — serverless same-network clipboard over mDNS + plain TCP.
//! LAN transport — serverless same-network sync + file transfer over mDNS + TCP.
//!
//! Each engine advertises `_tether._tcp.local.` with TXT {room, from, source}
//! and browses for the same service. Peers in the *same room* connect directly
//! over TCP (lower `from` dials, to avoid double-connects) and exchange
//! newline-delimited JSON `Message`s. No server, no internet — just the LAN.
//! 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: room id is the only access control and the link is plaintext; fine
//! for a trusted LAN, a rustls layer keyed on a room secret is the follow-up.
//! iOS needs the multicast entitlement for mDNS — there this transport is inert.
//! 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;
@@ -16,9 +18,9 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::tcp::OwnedWriteHalf;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::{TcpListener, TcpStream};
use tokio::runtime::Handle;
use tokio::sync::Mutex;
@@ -26,11 +28,14 @@ use tokio::sync::Mutex;
use crate::{Config, DirectSet, Discovered, FileSink, Message, Peer, Sink, Status, 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, OwnedWriteHalf>>>, // peer `from` → its write half
peers: Arc<Mutex<HashMap<String, Writer>>>, // peer `from` → its write half
sink: StdMutex<Option<Sink>>,
files: StdMutex<Option<FileSink>>,
incoming: Arc<Mutex<HashMap<String, crate::IncomingFile>>>, // in-flight inbound files
discovered: Discovered, // nearby devices (any room), for the engine's nearby()
direct: DirectSet, // LAN-connected peers (a direct path; suppresses relay)
}
@@ -41,6 +46,8 @@ impl LanTransport {
cfg,
peers: Arc::new(Mutex::new(HashMap::new())),
sink: StdMutex::new(None),
files: StdMutex::new(None),
incoming: Arc::new(Mutex::new(HashMap::new())),
discovered,
direct,
}
@@ -49,6 +56,31 @@ impl LanTransport {
fn sink(&self) -> Option<Sink> {
self.sink.lock().unwrap().clone()
}
fn file_sink(&self) -> Option<FileSink> {
self.files.lock().unwrap().clone()
}
}
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 {
@@ -62,9 +94,10 @@ impl Transport for LanTransport {
running: Arc<AtomicBool>,
sink: Sink,
_status: Status,
_files: FileSink,
files: FileSink,
) {
*self.sink.lock().unwrap() = Some(sink);
*self.files.lock().unwrap() = Some(files);
let this = self;
rt.spawn(async move {
if let Err(e) = this.run(running).await {
@@ -76,19 +109,33 @@ impl Transport for LanTransport {
fn publish(&self, rt: &Handle, msg: Message) {
let peers = self.peers.clone();
rt.spawn(async move {
let line = match serde_json::to_string(&msg) {
Ok(s) => format!("{s}\n"),
let body = match serde_json::to_vec(&msg) {
Ok(b) => b,
Err(_) => return,
};
let mut map = peers.lock().await;
let mut dead = Vec::new();
for (id, w) in map.iter_mut() {
if w.write_all(line.as_bytes()).await.is_err() {
dead.push(id.clone());
}
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;
}
for id in dead {
map.remove(&id);
});
}
fn send_file(&self, rt: &Handle, id: String, name: String, mime: String, data: Vec<u8>) {
let peers = self.peers.clone();
rt.spawn(async move {
let writers: Vec<Writer> = peers.lock().await.values().cloned().collect();
if writers.is_empty() {
return;
}
let frames = crate::file_frames(&id, &name, &mime, &data);
for w in writers {
let mut g = w.lock().await;
for (tag, payload) in &frames {
if write_frame(&mut g, *tag, payload).await.is_err() {
break;
}
}
}
});
}
@@ -101,7 +148,6 @@ impl LanTransport {
.map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
// Advertise + browse over mDNS.
let daemon = ServiceDaemon::new().map_err(|e| e.to_string())?;
let instance = sanitize(&self.cfg.from);
let props = [
@@ -147,8 +193,6 @@ impl LanTransport {
if from.is_empty() || from == self.cfg.from {
continue;
}
// Record for the nearby list — every tether device on the
// network, regardless of room (the UI shows them all).
let name = info
.get_property_val_str("name")
.filter(|s| !s.is_empty())
@@ -169,9 +213,7 @@ impl LanTransport {
Instant::now(),
),
);
// Pair if same room OR same (non-empty) account — your own
// devices auto-connect on the LAN regardless of room. Lower
// id dials to avoid double-connects.
// 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)
@@ -182,7 +224,6 @@ impl LanTransport {
if self.peers.lock().await.contains_key(&from) {
continue;
}
// Prefer IPv4; fall back to any resolved address.
let ip = info
.get_addresses_v4()
.into_iter()
@@ -204,41 +245,37 @@ impl LanTransport {
Ok(())
}
/// Handle one connection (dialed or accepted): hello handshake, then read
/// `Message` lines into the sink. The write half is stored for `publish`.
/// 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 (rd, mut wr) = stream.into_split();
let (mut rd, mut wr) = stream.into_split();
let hello = format!(
"{}\n",
json!({
"t": "hello",
"from": self.cfg.from,
"room": self.cfg.room,
"account": self.cfg.account,
})
);
if wr.write_all(hello.as_bytes()).await.is_err() {
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 the connection if we share a room OR a non-empty account.
let mut lines = BufReader::new(rd).lines();
let peer_from = match lines.next_line().await {
Ok(Some(l)) => match serde_json::from_str::<serde_json::Value>(&l) {
Ok(v) if v["t"] == "hello" => {
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;
}
v["from"].as_str().unwrap_or("").to_string()
}
_ => return,
},
_ => 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;
}
@@ -247,18 +284,39 @@ impl LanTransport {
if map.contains_key(&peer_from) {
return; // already connected to this peer
}
map.insert(peer_from.clone(), wr);
map.insert(peer_from.clone(), Arc::new(Mutex::new(wr)));
}
self.direct.lock().unwrap().insert(peer_from.clone()); // direct path up
eprintln!("tethercore[lan]: peer {peer_from} connected");
while let Ok(Some(line)) = lines.next_line().await {
if let Ok(m) = serde_json::from_str::<Message>(&line) {
if m.from != self.cfg.from {
if let Some(sink) = self.sink() {
sink(m);
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 {
if let Some(sink) = self.sink() {
sink(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, tag, &payload)
};
if let Some((name, mime, bytes)) = done {
eprintln!("tethercore[lan]: received file {name:?} ({} bytes)", bytes.len());
if let Some(fs) = self.file_sink() {
fs(name, mime, bytes);
}
}
}
_ => {}
}
}

View File

@@ -147,6 +147,71 @@ type Status = Arc<dyn Fn(bool) + Send + Sync>;
/// Called when a transport reassembles a complete inbound file (name, mime, bytes).
type FileSink = Arc<dyn Fn(String, String, Vec<u8>) + Send + Sync>;
/// 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.
pub(crate) struct IncomingFile {
pub name: String,
pub mime: String,
pub data: Vec<u8>,
}
/// 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
}
/// Apply one inbound file frame to the in-progress map; returns the completed
/// (name, mime, data) on the terminating 'E' frame.
pub(crate) fn apply_file_frame(
map: &mut HashMap<String, IncomingFile>,
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() {
map.insert(
id,
IncomingFile {
name: v["name"].as_str().unwrap_or("file").to_string(),
mime: v["mime"].as_str().unwrap_or("application/octet-stream").to_string(),
data: Vec::new(),
},
);
}
}
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..]);
}
None
}
b'E' if rest.len() >= 16 => {
let id = String::from_utf8_lossy(&rest[..16]).to_string();
map.remove(&id).map(|f| (f.name, f.mime, f.data))
}
_ => None,
}
}
/// 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 {
@@ -238,7 +303,8 @@ impl Engine {
let id = format!("{:016x}", n);
let rt = self.rt.handle();
for t in &self.transports {
if t.name() == "rtc" {
// Direct transports only — files are never relayed over SSE.
if t.name() == "rtc" || t.name() == "lan" {
t.send_file(rt, id.clone(), name.clone(), mime.clone(), data.clone());
}
}
@@ -337,8 +403,26 @@ impl Engine {
});
let h_status = handler.clone();
let status: Status = Arc::new(move |c| h_status.on_status(c));
// A file can arrive over both RTC and LAN — de-dup so on_file fires once.
let h_file = handler.clone();
let files: FileSink = Arc::new(move |name, mime, data| h_file.on_file(name, mime, data));
let file_seen: Arc<Mutex<VecDeque<(String, Instant)>>> = Arc::new(Mutex::new(VecDeque::new()));
let files: FileSink = Arc::new(move |name: String, mime: String, data: Vec<u8>| {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(name.as_bytes());
h.update(&data);
let key: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
{
let now = Instant::now();
let mut seen = file_seen.lock().unwrap();
seen.retain(|(_, t)| now.duration_since(*t) < Duration::from_secs(15));
if seen.iter().any(|(k, _)| k == &key) {
return;
}
seen.push_back((key, now));
}
h_file.on_file(name, mime, data);
});
for t in &self.transports {
t.clone().start(

View File

@@ -40,13 +40,6 @@ struct Peer {
offerer: bool,
}
/// A file being reassembled from inbound chunks (keyed by its id).
struct IncomingFile {
name: String,
mime: String,
data: Vec<u8>,
}
pub(crate) struct RtcTransport {
cfg: Config,
http: reqwest::Client,
@@ -54,7 +47,7 @@ pub(crate) struct RtcTransport {
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
sink: StdMutex<Option<Sink>>,
files: StdMutex<Option<FileSink>>,
incoming: Arc<Mutex<HashMap<String, IncomingFile>>>, // in-flight inbound files
incoming: Arc<Mutex<HashMap<String, crate::IncomingFile>>>, // in-flight inbound files
ice: StdMutex<Vec<RTCIceServer>>, // refreshed from /api/turn-cred at start
direct: DirectSet, // peers with an open data channel
present: PresentSet, // peers seen via presence chirps
@@ -269,38 +262,15 @@ impl RtcTransport {
let Some((&tag, rest)) = data.split_first() else {
return;
};
match tag {
b'M' => {
if let Ok(v) = serde_json::from_slice::<Value>(rest) {
let id = v["id"].as_str().unwrap_or("").to_string();
if !id.is_empty() {
self.incoming.lock().await.insert(
id,
IncomingFile {
name: v["name"].as_str().unwrap_or("file").to_string(),
mime: v["mime"].as_str().unwrap_or("application/octet-stream").to_string(),
data: Vec::new(),
},
);
}
}
let done = {
let mut map = self.incoming.lock().await;
crate::apply_file_frame(&mut map, tag, rest)
};
if let Some((name, mime, bytes)) = done {
eprintln!("tethercore[rtc]: received file {name:?} ({} bytes)", bytes.len());
if let Some(fs) = self.file_sink() {
fs(name, mime, bytes);
}
b'C' if rest.len() >= 16 => {
let id = String::from_utf8_lossy(&rest[..16]).to_string();
if let Some(f) = self.incoming.lock().await.get_mut(&id) {
f.data.extend_from_slice(&rest[16..]);
}
}
b'E' if rest.len() >= 16 => {
let id = String::from_utf8_lossy(&rest[..16]).to_string();
if let Some(f) = self.incoming.lock().await.remove(&id) {
eprintln!("tethercore[rtc]: received file {:?} ({} bytes)", f.name, f.data.len());
if let Some(fs) = self.file_sink() {
fs(f.name, f.mime, f.data);
}
}
}
_ => {}
}
}
@@ -627,28 +597,17 @@ impl Transport for RtcTransport {
if dcs.is_empty() {
return;
}
let meta = json!({ "id": id, "name": name, "mime": mime, "size": data.len() }).to_string();
let mut mframe = Vec::with_capacity(1 + meta.len());
mframe.push(b'M');
mframe.extend_from_slice(meta.as_bytes());
let idb = id.as_bytes(); // 16 ascii hex chars
// Each frame is sent as one binary message: tag byte + payload.
let frames = crate::file_frames(&id, &name, &mime, &data);
for dc in dcs {
if dc.send(&Bytes::from(mframe.clone())).await.is_err() {
continue;
}
for chunk in data.chunks(16 * 1024) {
let mut c = Vec::with_capacity(1 + 16 + chunk.len());
c.push(b'C');
c.extend_from_slice(idb);
c.extend_from_slice(chunk);
if dc.send(&Bytes::from(c)).await.is_err() {
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;
}
}
let mut e = Vec::with_capacity(17);
e.push(b'E');
e.extend_from_slice(idb);
let _ = dc.send(&Bytes::from(e)).await;
}
});
}