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:
+123
-65
@@ -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}
|
//! Each engine advertises `_tether._tcp.local.` (TXT room/from/source/name/
|
||||||
//! and browses for the same service. Peers in the *same room* connect directly
|
//! account) and connects to peers in the same room — or sharing a non-empty
|
||||||
//! over TCP (lower `from` dials, to avoid double-connects) and exchange
|
//! account — over a length-prefixed TCP framing:
|
||||||
//! newline-delimited JSON `Message`s. No server, no internet — just the LAN.
|
//! [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
|
//! Scope/caveats: room/account is the only access control and the link is
|
||||||
//! for a trusted LAN, a rustls layer keyed on a room secret is the follow-up.
|
//! plaintext — fine for a trusted LAN; rustls keyed on a room secret is the
|
||||||
//! iOS needs the multicast entitlement for mDNS — there this transport is inert.
|
//! follow-up. iOS needs the multicast entitlement for mDNS (inert until then).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -16,9 +18,9 @@ use std::sync::{Arc, Mutex as StdMutex};
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::tcp::OwnedWriteHalf;
|
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::runtime::Handle;
|
use tokio::runtime::Handle;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -26,11 +28,14 @@ use tokio::sync::Mutex;
|
|||||||
use crate::{Config, DirectSet, Discovered, FileSink, Message, Peer, Sink, Status, Transport};
|
use crate::{Config, DirectSet, Discovered, FileSink, Message, Peer, Sink, Status, Transport};
|
||||||
|
|
||||||
const SERVICE: &str = "_tether._tcp.local.";
|
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 {
|
pub(crate) struct LanTransport {
|
||||||
cfg: Config,
|
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>>,
|
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()
|
discovered: Discovered, // nearby devices (any room), for the engine's nearby()
|
||||||
direct: DirectSet, // LAN-connected peers (a direct path; suppresses relay)
|
direct: DirectSet, // LAN-connected peers (a direct path; suppresses relay)
|
||||||
}
|
}
|
||||||
@@ -41,6 +46,8 @@ impl LanTransport {
|
|||||||
cfg,
|
cfg,
|
||||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sink: StdMutex::new(None),
|
sink: StdMutex::new(None),
|
||||||
|
files: StdMutex::new(None),
|
||||||
|
incoming: Arc::new(Mutex::new(HashMap::new())),
|
||||||
discovered,
|
discovered,
|
||||||
direct,
|
direct,
|
||||||
}
|
}
|
||||||
@@ -49,6 +56,31 @@ impl LanTransport {
|
|||||||
fn sink(&self) -> Option<Sink> {
|
fn sink(&self) -> Option<Sink> {
|
||||||
self.sink.lock().unwrap().clone()
|
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 {
|
impl Transport for LanTransport {
|
||||||
@@ -62,9 +94,10 @@ impl Transport for LanTransport {
|
|||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
sink: Sink,
|
sink: Sink,
|
||||||
_status: Status,
|
_status: Status,
|
||||||
_files: FileSink,
|
files: FileSink,
|
||||||
) {
|
) {
|
||||||
*self.sink.lock().unwrap() = Some(sink);
|
*self.sink.lock().unwrap() = Some(sink);
|
||||||
|
*self.files.lock().unwrap() = Some(files);
|
||||||
let this = self;
|
let this = self;
|
||||||
rt.spawn(async move {
|
rt.spawn(async move {
|
||||||
if let Err(e) = this.run(running).await {
|
if let Err(e) = this.run(running).await {
|
||||||
@@ -76,19 +109,33 @@ impl Transport for LanTransport {
|
|||||||
fn publish(&self, rt: &Handle, msg: Message) {
|
fn publish(&self, rt: &Handle, msg: Message) {
|
||||||
let peers = self.peers.clone();
|
let peers = self.peers.clone();
|
||||||
rt.spawn(async move {
|
rt.spawn(async move {
|
||||||
let line = match serde_json::to_string(&msg) {
|
let body = match serde_json::to_vec(&msg) {
|
||||||
Ok(s) => format!("{s}\n"),
|
Ok(b) => b,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
let mut map = peers.lock().await;
|
let writers: Vec<Writer> = peers.lock().await.values().cloned().collect();
|
||||||
let mut dead = Vec::new();
|
for w in writers {
|
||||||
for (id, w) in map.iter_mut() {
|
let mut g = w.lock().await;
|
||||||
if w.write_all(line.as_bytes()).await.is_err() {
|
let _ = write_frame(&mut g, b'J', &body).await;
|
||||||
dead.push(id.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
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 daemon = ServiceDaemon::new().map_err(|e| e.to_string())?;
|
||||||
let instance = sanitize(&self.cfg.from);
|
let instance = sanitize(&self.cfg.from);
|
||||||
let props = [
|
let props = [
|
||||||
@@ -147,8 +193,6 @@ impl LanTransport {
|
|||||||
if from.is_empty() || from == self.cfg.from {
|
if from.is_empty() || from == self.cfg.from {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Record for the nearby list — every tether device on the
|
|
||||||
// network, regardless of room (the UI shows them all).
|
|
||||||
let name = info
|
let name = info
|
||||||
.get_property_val_str("name")
|
.get_property_val_str("name")
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -169,9 +213,7 @@ impl LanTransport {
|
|||||||
Instant::now(),
|
Instant::now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Pair if same room OR same (non-empty) account — your own
|
// Pair if same room OR same (non-empty) account; lower id dials.
|
||||||
// devices auto-connect on the LAN regardless of room. Lower
|
|
||||||
// id dials to avoid double-connects.
|
|
||||||
let same_account =
|
let same_account =
|
||||||
!self.cfg.account.is_empty() && account == self.cfg.account;
|
!self.cfg.account.is_empty() && account == self.cfg.account;
|
||||||
if (room != self.cfg.room && !same_account)
|
if (room != self.cfg.room && !same_account)
|
||||||
@@ -182,7 +224,6 @@ impl LanTransport {
|
|||||||
if self.peers.lock().await.contains_key(&from) {
|
if self.peers.lock().await.contains_key(&from) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Prefer IPv4; fall back to any resolved address.
|
|
||||||
let ip = info
|
let ip = info
|
||||||
.get_addresses_v4()
|
.get_addresses_v4()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -204,41 +245,37 @@ impl LanTransport {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one connection (dialed or accepted): hello handshake, then read
|
/// Handle one connection: hello handshake, then read frames — JSON messages
|
||||||
/// `Message` lines into the sink. The write half is stored for `publish`.
|
/// to the sink, file frames reassembled to the file sink.
|
||||||
async fn handle(self: &Arc<Self>, stream: TcpStream) {
|
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!(
|
let hello = json!({
|
||||||
"{}\n",
|
"from": self.cfg.from, "room": self.cfg.room, "account": self.cfg.account
|
||||||
json!({
|
})
|
||||||
"t": "hello",
|
.to_string();
|
||||||
"from": self.cfg.from,
|
if write_frame(&mut wr, b'H', hello.as_bytes()).await.is_err() {
|
||||||
"room": self.cfg.room,
|
|
||||||
"account": self.cfg.account,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if wr.write_all(hello.as_bytes()).await.is_err() {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept the connection if we share a room OR a non-empty account.
|
// Accept if we share a room OR a non-empty account.
|
||||||
let mut lines = BufReader::new(rd).lines();
|
let (tag, payload) = match read_frame(&mut rd).await {
|
||||||
let peer_from = match lines.next_line().await {
|
Ok(f) => f,
|
||||||
Ok(Some(l)) => match serde_json::from_str::<serde_json::Value>(&l) {
|
Err(_) => return,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
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 {
|
if peer_from.is_empty() || peer_from == self.cfg.from {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,18 +284,39 @@ impl LanTransport {
|
|||||||
if map.contains_key(&peer_from) {
|
if map.contains_key(&peer_from) {
|
||||||
return; // already connected to this peer
|
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
|
self.direct.lock().unwrap().insert(peer_from.clone()); // direct path up
|
||||||
eprintln!("tethercore[lan]: peer {peer_from} connected");
|
eprintln!("tethercore[lan]: peer {peer_from} connected");
|
||||||
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
loop {
|
||||||
if let Ok(m) = serde_json::from_str::<Message>(&line) {
|
let (tag, payload) = match read_frame(&mut rd).await {
|
||||||
if m.from != self.cfg.from {
|
Ok(f) => f,
|
||||||
if let Some(sink) = self.sink() {
|
Err(_) => break,
|
||||||
sink(m);
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+86
-2
@@ -147,6 +147,71 @@ type Status = Arc<dyn Fn(bool) + Send + Sync>;
|
|||||||
/// Called when a transport reassembles a complete inbound file (name, mime, bytes).
|
/// Called when a transport reassembles a complete inbound file (name, mime, bytes).
|
||||||
type FileSink = Arc<dyn Fn(String, String, Vec<u8>) + Send + Sync>;
|
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.
|
/// 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.
|
/// RTC (WebRTC data channel) and BT (BLE GATT) implement the same trait.
|
||||||
trait Transport: Send + Sync {
|
trait Transport: Send + Sync {
|
||||||
@@ -238,7 +303,8 @@ impl Engine {
|
|||||||
let id = format!("{:016x}", n);
|
let id = format!("{:016x}", n);
|
||||||
let rt = self.rt.handle();
|
let rt = self.rt.handle();
|
||||||
for t in &self.transports {
|
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());
|
t.send_file(rt, id.clone(), name.clone(), mime.clone(), data.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,8 +403,26 @@ impl Engine {
|
|||||||
});
|
});
|
||||||
let h_status = handler.clone();
|
let h_status = handler.clone();
|
||||||
let status: Status = Arc::new(move |c| h_status.on_status(c));
|
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 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 {
|
for t in &self.transports {
|
||||||
t.clone().start(
|
t.clone().start(
|
||||||
|
|||||||
+16
-57
@@ -40,13 +40,6 @@ struct Peer {
|
|||||||
offerer: bool,
|
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 {
|
pub(crate) struct RtcTransport {
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
@@ -54,7 +47,7 @@ pub(crate) struct RtcTransport {
|
|||||||
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
|
peers: Arc<Mutex<HashMap<String, Arc<Peer>>>>,
|
||||||
sink: StdMutex<Option<Sink>>,
|
sink: StdMutex<Option<Sink>>,
|
||||||
files: StdMutex<Option<FileSink>>,
|
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
|
ice: StdMutex<Vec<RTCIceServer>>, // refreshed from /api/turn-cred at start
|
||||||
direct: DirectSet, // peers with an open data channel
|
direct: DirectSet, // peers with an open data channel
|
||||||
present: PresentSet, // peers seen via presence chirps
|
present: PresentSet, // peers seen via presence chirps
|
||||||
@@ -269,38 +262,15 @@ impl RtcTransport {
|
|||||||
let Some((&tag, rest)) = data.split_first() else {
|
let Some((&tag, rest)) = data.split_first() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match tag {
|
let done = {
|
||||||
b'M' => {
|
let mut map = self.incoming.lock().await;
|
||||||
if let Ok(v) = serde_json::from_slice::<Value>(rest) {
|
crate::apply_file_frame(&mut map, tag, rest)
|
||||||
let id = v["id"].as_str().unwrap_or("").to_string();
|
};
|
||||||
if !id.is_empty() {
|
if let Some((name, mime, bytes)) = done {
|
||||||
self.incoming.lock().await.insert(
|
eprintln!("tethercore[rtc]: received file {name:?} ({} bytes)", bytes.len());
|
||||||
id,
|
if let Some(fs) = self.file_sink() {
|
||||||
IncomingFile {
|
fs(name, mime, bytes);
|
||||||
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(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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() {
|
if dcs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let meta = json!({ "id": id, "name": name, "mime": mime, "size": data.len() }).to_string();
|
// Each frame is sent as one binary message: tag byte + payload.
|
||||||
let mut mframe = Vec::with_capacity(1 + meta.len());
|
let frames = crate::file_frames(&id, &name, &mime, &data);
|
||||||
mframe.push(b'M');
|
|
||||||
mframe.extend_from_slice(meta.as_bytes());
|
|
||||||
let idb = id.as_bytes(); // 16 ascii hex chars
|
|
||||||
for dc in dcs {
|
for dc in dcs {
|
||||||
if dc.send(&Bytes::from(mframe.clone())).await.is_err() {
|
for (tag, payload) in &frames {
|
||||||
continue;
|
let mut f = Vec::with_capacity(1 + payload.len());
|
||||||
}
|
f.push(*tag);
|
||||||
for chunk in data.chunks(16 * 1024) {
|
f.extend_from_slice(payload);
|
||||||
let mut c = Vec::with_capacity(1 + 16 + chunk.len());
|
if dc.send(&Bytes::from(f)).await.is_err() {
|
||||||
c.push(b'C');
|
|
||||||
c.extend_from_slice(idb);
|
|
||||||
c.extend_from_slice(chunk);
|
|
||||||
if dc.send(&Bytes::from(c)).await.is_err() {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut e = Vec::with_capacity(17);
|
|
||||||
e.push(b'E');
|
|
||||||
e.extend_from_slice(idb);
|
|
||||||
let _ = dc.send(&Bytes::from(e)).await;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user