core: prefer-direct — suppress the relay once P2P covers the room
The engine sent clipboard over every transport at once, so the server always saw the payload even with RTC/LAN up. Now the RTC and LAN transports report their directly-reachable peers (data-channel open / TCP connected) into a shared set, and the RTC transport records who's present from presence chirps. send() skips the SSE relay when every present peer is directly reachable — so once P2P is established the server only ever carries signaling/presence, never content. Falls back to the relay whenever a present peer isn't directly reachable, or before the direct link is up. Also drop empty-text clipboards in the sink (were surfacing as blank "unknown" feed rows). Proven by examples/prefer_direct.rs: two engines link directly on the live server; a send reaches the peer but never appears on the server bus (only presence does). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
49
core/examples/prefer_direct.rs
Normal file
49
core/examples/prefer_direct.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//! Proves the relay carries content only until a direct path exists. A and B
|
||||||
|
//! join a room on the LIVE server and link directly (LAN/RTC). After that, A's
|
||||||
|
//! send must reach B but must NOT appear on the server bus.
|
||||||
|
//! cargo run --example prefer_direct -- <room>
|
||||||
|
//! (run an external SSE listener on the same room to confirm suppression)
|
||||||
|
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tethercore::{Engine, Message, MessageHandler};
|
||||||
|
|
||||||
|
struct Collector(mpsc::Sender<String>);
|
||||||
|
impl MessageHandler for Collector {
|
||||||
|
fn on_message(&self, m: Message) {
|
||||||
|
eprintln!(" B.on_message: kind={:?} text={:?} from={:?}", m.kind, m.text, m.from);
|
||||||
|
let _ = self.0.send(m.text);
|
||||||
|
}
|
||||||
|
fn on_status(&self, _c: bool) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let server = "https://tether.pecord.io".to_string();
|
||||||
|
let room = std::env::args().nth(1).unwrap_or_else(|| "prefer-direct-demo".into());
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let a = Engine::new(server.clone(), room.clone(), "dev-a".into(), "macos".into(), "A".into(), "".into());
|
||||||
|
let b = Engine::new(server, room, "dev-b".into(), "ios".into(), "B".into(), "".into());
|
||||||
|
a.start(Box::new(Collector(mpsc::channel().0)));
|
||||||
|
b.start(Box::new(Collector(tx)));
|
||||||
|
|
||||||
|
// Let presence + the direct (LAN/RTC) link establish.
|
||||||
|
eprintln!("waiting ~12s for presence + direct link…");
|
||||||
|
std::thread::sleep(Duration::from_secs(12));
|
||||||
|
|
||||||
|
let secret = "DIRECT-ONLY-SECRET";
|
||||||
|
eprintln!("A sends — should go P2P, NOT via the relay");
|
||||||
|
a.send(secret.to_string());
|
||||||
|
|
||||||
|
let mut got = false;
|
||||||
|
while let Ok(t) = rx.recv_timeout(Duration::from_secs(4)) {
|
||||||
|
if t == secret { got = true; break; }
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
if got { "✅ B received the secret over the direct path" } else { "❌ B never got the secret" }
|
||||||
|
);
|
||||||
|
a.stop();
|
||||||
|
b.stop();
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ use tokio::net::{TcpListener, TcpStream};
|
|||||||
use tokio::runtime::Handle;
|
use tokio::runtime::Handle;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::{Config, Discovered, Message, Peer, Sink, Status, Transport};
|
use crate::{Config, DirectSet, Discovered, Message, Peer, Sink, Status, Transport};
|
||||||
|
|
||||||
const SERVICE: &str = "_tether._tcp.local.";
|
const SERVICE: &str = "_tether._tcp.local.";
|
||||||
|
|
||||||
@@ -32,15 +32,17 @@ pub(crate) struct LanTransport {
|
|||||||
peers: Arc<Mutex<HashMap<String, OwnedWriteHalf>>>, // peer `from` → its write half
|
peers: Arc<Mutex<HashMap<String, OwnedWriteHalf>>>, // peer `from` → its write half
|
||||||
sink: StdMutex<Option<Sink>>,
|
sink: StdMutex<Option<Sink>>,
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LanTransport {
|
impl LanTransport {
|
||||||
pub(crate) fn new(cfg: Config, discovered: Discovered) -> Self {
|
pub(crate) fn new(cfg: Config, discovered: Discovered, direct: DirectSet) -> Self {
|
||||||
Self {
|
Self {
|
||||||
cfg,
|
cfg,
|
||||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sink: StdMutex::new(None),
|
sink: StdMutex::new(None),
|
||||||
discovered,
|
discovered,
|
||||||
|
direct,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +242,7 @@ impl LanTransport {
|
|||||||
}
|
}
|
||||||
map.insert(peer_from.clone(), wr);
|
map.insert(peer_from.clone(), wr);
|
||||||
}
|
}
|
||||||
|
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 {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
@@ -253,6 +256,7 @@ impl LanTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.peers.lock().await.remove(&peer_from);
|
self.peers.lock().await.remove(&peer_from);
|
||||||
|
self.direct.lock().unwrap().remove(&peer_from); // relay needed again
|
||||||
eprintln!("tethercore[lan]: peer {peer_from} disconnected");
|
eprintln!("tethercore[lan]: peer {peer_from} disconnected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
//! across all transports and de-duplicates inbound so the same clipboard
|
//! across all transports and de-duplicates inbound so the same clipboard
|
||||||
//! arriving over two channels surfaces once.
|
//! arriving over two channels surfaces once.
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -88,6 +88,15 @@ pub struct Receipt {
|
|||||||
|
|
||||||
type Receipts = Arc<Mutex<Vec<(Receipt, Instant)>>>;
|
type Receipts = Arc<Mutex<Vec<(Receipt, Instant)>>>;
|
||||||
|
|
||||||
|
/// Peers reachable over a direct channel right now (RTC data channel or LAN TCP),
|
||||||
|
/// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether
|
||||||
|
/// the relay is still needed.
|
||||||
|
type DirectSet = Arc<Mutex<HashSet<String>>>;
|
||||||
|
/// Peers currently in the room, by `from`, last-seen via presence chirps. Lets
|
||||||
|
/// `send` know the full audience so it only suppresses the relay when *every*
|
||||||
|
/// present peer is directly reachable.
|
||||||
|
type PresentSet = Arc<Mutex<HashMap<String, Instant>>>;
|
||||||
|
|
||||||
/// Canonical room id for a shared identity: sha256(account)[..4] as 8 hex chars.
|
/// Canonical room id for a shared identity: sha256(account)[..4] as 8 hex chars.
|
||||||
/// Defined once here and exported so every client (Swift/Kotlin/agent) derives
|
/// Defined once here and exported so every client (Swift/Kotlin/agent) derives
|
||||||
/// the SAME room — that's what puts all of one account's devices together across
|
/// the SAME room — that's what puts all of one account's devices together across
|
||||||
@@ -136,6 +145,8 @@ pub struct Engine {
|
|||||||
dedup: Arc<Mutex<Dedup>>,
|
dedup: Arc<Mutex<Dedup>>,
|
||||||
discovered: Discovered,
|
discovered: Discovered,
|
||||||
receipts: Receipts,
|
receipts: Receipts,
|
||||||
|
direct: DirectSet,
|
||||||
|
present: PresentSet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -162,13 +173,15 @@ impl Engine {
|
|||||||
.expect("tokio runtime");
|
.expect("tokio runtime");
|
||||||
let cfg = Config { server, room, from, source, name, account };
|
let cfg = Config { server, room, from, source, name, account };
|
||||||
let discovered: Discovered = Arc::new(Mutex::new(HashMap::new()));
|
let discovered: Discovered = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let direct: DirectSet = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let present: PresentSet = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let transports: Vec<Arc<dyn Transport>> = vec![
|
let transports: Vec<Arc<dyn Transport>> = vec![
|
||||||
Arc::new(SseTransport {
|
Arc::new(SseTransport {
|
||||||
cfg: cfg.clone(),
|
cfg: cfg.clone(),
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
}),
|
}),
|
||||||
Arc::new(rtc::RtcTransport::new(cfg.clone())),
|
Arc::new(rtc::RtcTransport::new(cfg.clone(), direct.clone(), present.clone())),
|
||||||
Arc::new(lan::LanTransport::new(cfg.clone(), discovered.clone())),
|
Arc::new(lan::LanTransport::new(cfg.clone(), discovered.clone(), direct.clone())),
|
||||||
];
|
];
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
cfg,
|
cfg,
|
||||||
@@ -178,6 +191,8 @@ impl Engine {
|
|||||||
dedup: Arc::new(Mutex::new(Dedup::default())),
|
dedup: Arc::new(Mutex::new(Dedup::default())),
|
||||||
discovered,
|
discovered,
|
||||||
receipts: Arc::new(Mutex::new(Vec::new())),
|
receipts: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
direct,
|
||||||
|
present,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +247,10 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Clipboard: de-dup, ack the sender, then deliver.
|
// Clipboard: ignore empties, de-dup, ack the sender, then deliver.
|
||||||
|
if m.text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if dedup.lock().unwrap().seen(&m) {
|
if dedup.lock().unwrap().seen(&m) {
|
||||||
return; // already delivered via another transport
|
return; // already delivered via another transport
|
||||||
}
|
}
|
||||||
@@ -267,7 +285,10 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish clipboard text to the room across all transports.
|
/// Publish clipboard text to the room. Always sent over the direct
|
||||||
|
/// transports (RTC/LAN); sent over the SSE relay ONLY when a direct path
|
||||||
|
/// doesn't already reach every present peer — so once P2P is up the server
|
||||||
|
/// never sees the payload (it stays just signaling + fallback).
|
||||||
pub fn send(&self, text: String) {
|
pub fn send(&self, text: String) {
|
||||||
let msg = Message {
|
let msg = Message {
|
||||||
kind: "clipboard".into(),
|
kind: "clipboard".into(),
|
||||||
@@ -279,12 +300,27 @@ impl Engine {
|
|||||||
room: self.cfg.room.clone(),
|
room: self.cfg.room.clone(),
|
||||||
ts: 0, // server stamps authoritative ts
|
ts: 0, // server stamps authoritative ts
|
||||||
};
|
};
|
||||||
|
let covered = self.directly_covered();
|
||||||
let rt = self.rt.handle();
|
let rt = self.rt.handle();
|
||||||
for t in &self.transports {
|
for t in &self.transports {
|
||||||
|
if covered && t.name() == "sse" {
|
||||||
|
continue; // direct channels reach everyone → keep content off the relay
|
||||||
|
}
|
||||||
t.publish(rt, msg.clone());
|
t.publish(rt, msg.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when every peer currently present in the room is reachable over a
|
||||||
|
/// direct channel — i.e. the relay isn't needed for delivery. False if we
|
||||||
|
/// don't yet know who's present (be safe, relay).
|
||||||
|
fn directly_covered(&self) -> bool {
|
||||||
|
let direct = self.direct.lock().unwrap();
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut present = self.present.lock().unwrap();
|
||||||
|
present.retain(|_, t| now.duration_since(*t) < Duration::from_secs(20));
|
||||||
|
!present.is_empty() && present.keys().all(|p| direct.contains(p))
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop all transports at their next checkpoint.
|
/// 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);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex as StdMutex};
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -31,7 +31,7 @@ use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
|
|||||||
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
|
use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
|
||||||
use webrtc::peer_connection::RTCPeerConnection;
|
use webrtc::peer_connection::RTCPeerConnection;
|
||||||
|
|
||||||
use crate::{Config, Message, Sink, Status, Transport};
|
use crate::{Config, DirectSet, Message, PresentSet, Sink, Status, Transport};
|
||||||
|
|
||||||
struct Peer {
|
struct Peer {
|
||||||
pc: Arc<RTCPeerConnection>,
|
pc: Arc<RTCPeerConnection>,
|
||||||
@@ -46,6 +46,8 @@ 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>>,
|
||||||
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
|
||||||
|
present: PresentSet, // peers seen via presence chirps
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_ice() -> Vec<RTCIceServer> {
|
fn default_ice() -> Vec<RTCIceServer> {
|
||||||
@@ -56,7 +58,7 @@ fn default_ice() -> Vec<RTCIceServer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RtcTransport {
|
impl RtcTransport {
|
||||||
pub(crate) fn new(cfg: Config) -> Self {
|
pub(crate) fn new(cfg: Config, direct: DirectSet, present: PresentSet) -> Self {
|
||||||
// Data-channel-only: a bare media engine, no codecs/interceptors needed.
|
// Data-channel-only: a bare media engine, no codecs/interceptors needed.
|
||||||
let api = APIBuilder::new()
|
let api = APIBuilder::new()
|
||||||
.with_media_engine(MediaEngine::default())
|
.with_media_engine(MediaEngine::default())
|
||||||
@@ -68,6 +70,8 @@ impl RtcTransport {
|
|||||||
peers: Arc::new(Mutex::new(HashMap::new())),
|
peers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sink: StdMutex::new(None),
|
sink: StdMutex::new(None),
|
||||||
ice: StdMutex::new(default_ice()),
|
ice: StdMutex::new(default_ice()),
|
||||||
|
direct,
|
||||||
|
present,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +192,11 @@ impl RtcTransport {
|
|||||||
// Drop the peer when the connection dies.
|
// Drop the peer when the connection dies.
|
||||||
{
|
{
|
||||||
let peers = self.peers.clone();
|
let peers = self.peers.clone();
|
||||||
|
let direct = self.direct.clone();
|
||||||
let remote = remote.to_owned();
|
let remote = remote.to_owned();
|
||||||
pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
|
pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
|
||||||
let peers = peers.clone();
|
let peers = peers.clone();
|
||||||
|
let direct = direct.clone();
|
||||||
let remote = remote.clone();
|
let remote = remote.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -200,6 +206,7 @@ impl RtcTransport {
|
|||||||
| RTCPeerConnectionState::Disconnected
|
| RTCPeerConnectionState::Disconnected
|
||||||
) {
|
) {
|
||||||
peers.lock().await.remove(&remote);
|
peers.lock().await.remove(&remote);
|
||||||
|
direct.lock().unwrap().remove(&remote); // relay needed again
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
@@ -243,9 +250,12 @@ impl RtcTransport {
|
|||||||
async fn wire_channel(self: &Arc<Self>, remote: &str, dc: Arc<RTCDataChannel>, peer: &Arc<Peer>) {
|
async fn wire_channel(self: &Arc<Self>, remote: &str, dc: Arc<RTCDataChannel>, peer: &Arc<Peer>) {
|
||||||
{
|
{
|
||||||
let remote_log = remote.to_owned();
|
let remote_log = remote.to_owned();
|
||||||
|
let direct = self.direct.clone();
|
||||||
dc.on_open(Box::new(move || {
|
dc.on_open(Box::new(move || {
|
||||||
let remote_log = remote_log.clone();
|
let remote_log = remote_log.clone();
|
||||||
|
let direct = direct.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
direct.lock().unwrap().insert(remote_log.clone()); // now P2P-reachable
|
||||||
eprintln!("tethercore[rtc]: data channel open ↔ {remote_log}");
|
eprintln!("tethercore[rtc]: data channel open ↔ {remote_log}");
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
@@ -405,6 +415,11 @@ impl RtcTransport {
|
|||||||
}
|
}
|
||||||
match v["type"].as_str().unwrap_or("") {
|
match v["type"].as_str().unwrap_or("") {
|
||||||
"presence" => {
|
"presence" => {
|
||||||
|
// Track who's in the room so send() knows the full audience.
|
||||||
|
self.present
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(from.to_string(), Instant::now());
|
||||||
// Deterministic initiator: the smaller id offers.
|
// Deterministic initiator: the smaller id offers.
|
||||||
if self.cfg.from.as_str() < from {
|
if self.cfg.from.as_str() < from {
|
||||||
self.initiate_offer(from).await;
|
self.initiate_offer(from).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user