core: nearby-device discovery (AirDrop-style) — friendly names + Engine.nearby()
Each device now advertises a friendly name over mDNS ("Patrick's MacBook"),
and the LAN transport records every tether device on the network — regardless
of room — into a discovered table. New Engine.nearby() -> [Peer] exposes that
list for an AirDrop-style picker; entries prune after 30s unseen. Peer carries
{id, name, source, room} so the UI can offer "pair" (join that device's room).
Engine::new gains a `name` param (5th); all callers updated (apps pass
UIDevice.name / Host.localizedName / Build.MODEL, agent passes hostname or
--name). Auto-connect stays room-scoped; only the *visibility* is broadened.
Proven by examples/nearby.rs: two engines in different rooms discover each
other by name over mDNS — and it picks up real devices on the LAN too.
iOS caveat unchanged: mDNS needs the multicast entitlement, so nearby() is
empty there until that lands. Desktop/Android work today.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -54,11 +54,14 @@ fn main() {
|
|||||||
let room = arg("--room", "default");
|
let room = arg("--room", "default");
|
||||||
let source = arg("--source", "agent");
|
let source = arg("--source", "agent");
|
||||||
let from = arg("--from", &format!("agent-{source}"));
|
let from = arg("--from", &format!("agent-{source}"));
|
||||||
|
// Friendly name for other devices' nearby lists.
|
||||||
|
let host = std::env::var("HOSTNAME").unwrap_or_else(|_| "tether-agent".into());
|
||||||
|
let name = arg("--name", &host);
|
||||||
|
|
||||||
eprintln!("tether-agent → {server} room={room} source={source}");
|
eprintln!("tether-agent → {server} room={room} source={source}");
|
||||||
|
|
||||||
let inbox = Arc::new(Mutex::new(Vec::<String>::new()));
|
let inbox = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
let engine = Engine::new(server, room, from, source);
|
let engine = Engine::new(server, room, from, source, name);
|
||||||
engine.start(Box::new(Inbox(inbox.clone())));
|
engine.start(Box::new(Inbox(inbox.clone())));
|
||||||
|
|
||||||
let mut clip = Clipboard::new().expect("open system clipboard");
|
let mut clip = Clipboard::new().expect("open system clipboard");
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class TetherStore(private val context: Context) {
|
|||||||
fun connect() {
|
fun connect() {
|
||||||
prefs().edit().putString("serverURL", serverURL.value).apply()
|
prefs().edit().putString("serverURL", serverURL.value).apply()
|
||||||
disconnect()
|
disconnect()
|
||||||
val e = Engine(serverURL.value.trim(), room.value, deviceId, "android")
|
val e = Engine(serverURL.value.trim(), room.value, deviceId, "android", android.os.Build.MODEL)
|
||||||
e.start(Bridge())
|
e.start(Bridge())
|
||||||
engine = e
|
engine = e
|
||||||
connected.value = true
|
connected.value = true
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ fn main() {
|
|||||||
let room = "lan-spike".to_string();
|
let room = "lan-spike".to_string();
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let a = Engine::new(dead.clone(), room.clone(), "peer-a".into(), "lan-a".into());
|
let a = Engine::new(dead.clone(), room.clone(), "peer-a".into(), "lan-a".into(), "LAN A".into());
|
||||||
a.start(Box::new(Collector(tx)));
|
a.start(Box::new(Collector(tx)));
|
||||||
|
|
||||||
let b_from = "peer-b";
|
let b_from = "peer-b";
|
||||||
let b = Engine::new(dead, room, b_from.into(), "lan-b".into());
|
let b = Engine::new(dead, room, b_from.into(), "lan-b".into(), "LAN B".into());
|
||||||
b.start(Box::new(Collector(mpsc::channel().0)));
|
b.start(Box::new(Collector(mpsc::channel().0)));
|
||||||
|
|
||||||
eprintln!("waiting for mDNS discovery + TCP connect…");
|
eprintln!("waiting for mDNS discovery + TCP connect…");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ fn main() {
|
|||||||
let room = std::env::args().nth(1).unwrap_or_else(|| "lan-verify".into());
|
let room = std::env::args().nth(1).unwrap_or_else(|| "lan-verify".into());
|
||||||
let send = std::env::args().nth(2);
|
let send = std::env::args().nth(2);
|
||||||
// High `from` so it accepts the agent's dial (lower id dials).
|
// High `from` so it accepts the agent's dial (lower id dials).
|
||||||
let e = Engine::new("http://127.0.0.1:1".into(), room, "zzz-peer".into(), "peer".into());
|
let e = Engine::new("http://127.0.0.1:1".into(), room, "zzz-peer".into(), "peer".into(), "Rust LAN Peer".into());
|
||||||
e.start(Box::new(Printer));
|
e.start(Box::new(Printer));
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_secs(6)); // let mDNS discover + TCP connect
|
std::thread::sleep(Duration::from_secs(6)); // let mDNS discover + TCP connect
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! Proves AirDrop-style discovery: two engines in DIFFERENT rooms still see each
|
||||||
|
//! other in nearby() with friendly names (mDNS, no server).
|
||||||
|
//! cargo run --example nearby
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tethercore::{Engine, MessageHandler, Message};
|
||||||
|
|
||||||
|
struct Noop;
|
||||||
|
impl MessageHandler for Noop {
|
||||||
|
fn on_message(&self, _m: Message) {}
|
||||||
|
fn on_status(&self, _c: bool) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dead = "http://127.0.0.1:1".to_string();
|
||||||
|
let a = Engine::new(dead.clone(), "room-A".into(), "alpha".into(), "macos".into(), "Patrick's MacBook".into());
|
||||||
|
let b = Engine::new(dead, "room-B".into(), "bravo".into(), "ios".into(), "Patrick's iPhone".into());
|
||||||
|
a.start(Box::new(Noop));
|
||||||
|
b.start(Box::new(Noop));
|
||||||
|
|
||||||
|
eprintln!("browsing mDNS for ~5s…");
|
||||||
|
std::thread::sleep(Duration::from_secs(5));
|
||||||
|
|
||||||
|
println!("A.nearby():");
|
||||||
|
for p in a.nearby() {
|
||||||
|
println!(" • {} [{}] room={}", p.name, p.source, p.room);
|
||||||
|
}
|
||||||
|
println!("B.nearby():");
|
||||||
|
for p in b.nearby() {
|
||||||
|
println!(" • {} [{}] room={}", p.name, p.source, p.room);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = a.nearby().iter().any(|p| p.name == "Patrick's iPhone" && p.room == "room-B")
|
||||||
|
&& b.nearby().iter().any(|p| p.name == "Patrick's MacBook" && p.room == "room-A");
|
||||||
|
println!("{}", if ok { "✅ cross-room discovery by name works" } else { "❌ did not discover" });
|
||||||
|
a.stop();
|
||||||
|
b.stop();
|
||||||
|
if !ok { std::process::exit(1); }
|
||||||
|
}
|
||||||
@@ -25,14 +25,14 @@ fn main() {
|
|||||||
let room = "rust-spike".to_string();
|
let room = "rust-spike".to_string();
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let sub = Engine::new(server.clone(), room.clone(), "rx".into(), "rust-rx".into());
|
let sub = Engine::new(server.clone(), room.clone(), "rx".into(), "rust-rx".into(), "Rust RX".into());
|
||||||
sub.start(Box::new(Collector(tx)));
|
sub.start(Box::new(Collector(tx)));
|
||||||
|
|
||||||
// Let the SSE subscription establish before publishing.
|
// Let the SSE subscription establish before publishing.
|
||||||
std::thread::sleep(Duration::from_millis(800));
|
std::thread::sleep(Duration::from_millis(800));
|
||||||
|
|
||||||
let payload = "hello-from-rust-core";
|
let payload = "hello-from-rust-core";
|
||||||
let pubr = Engine::new(server, room, "tx".into(), "rust-tx".into());
|
let pubr = Engine::new(server, room, "tx".into(), "rust-tx".into(), "Rust TX".into());
|
||||||
pubr.send(payload.to_string());
|
pubr.send(payload.to_string());
|
||||||
|
|
||||||
match rx.recv_timeout(Duration::from_secs(5)) {
|
match rx.recv_timeout(Duration::from_secs(5)) {
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ fn main() {
|
|||||||
let room = "rtc-p2p-spike".to_string();
|
let room = "rtc-p2p-spike".to_string();
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let a = Engine::new(server.clone(), room.clone(), "peer-a".into(), "label-a".into());
|
let a = Engine::new(server.clone(), room.clone(), "peer-a".into(), "label-a".into(), "Peer A".into());
|
||||||
a.start(Box::new(Collector(tx)));
|
a.start(Box::new(Collector(tx)));
|
||||||
|
|
||||||
// B's from-id and source-label are deliberately different so we can tell
|
// B's from-id and source-label are deliberately different so we can tell
|
||||||
// which transport delivered.
|
// which transport delivered.
|
||||||
let b_from = "peer-b";
|
let b_from = "peer-b";
|
||||||
let b = Engine::new(server, room, b_from.into(), "label-b-sse".into());
|
let b = Engine::new(server, room, b_from.into(), "label-b-sse".into(), "Peer B".into());
|
||||||
b.start(Box::new(Collector(mpsc::channel().0))); // B needs to be live to negotiate
|
b.start(Box::new(Collector(mpsc::channel().0))); // B needs to be live to negotiate
|
||||||
|
|
||||||
// Give presence + ICE + DTLS time to bring the data channel up.
|
// Give presence + ICE + DTLS time to bring the data channel up.
|
||||||
|
|||||||
+26
-6
@@ -13,6 +13,7 @@ use std::collections::HashMap;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
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::Instant;
|
||||||
|
|
||||||
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -22,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, Message, Sink, Status, Transport};
|
use crate::{Config, Discovered, Message, Peer, Sink, Status, Transport};
|
||||||
|
|
||||||
const SERVICE: &str = "_tether._tcp.local.";
|
const SERVICE: &str = "_tether._tcp.local.";
|
||||||
|
|
||||||
@@ -30,14 +31,16 @@ pub(crate) struct LanTransport {
|
|||||||
cfg: Config,
|
cfg: Config,
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LanTransport {
|
impl LanTransport {
|
||||||
pub(crate) fn new(cfg: Config) -> Self {
|
pub(crate) fn new(cfg: Config, discovered: Discovered) -> 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ impl LanTransport {
|
|||||||
("room", self.cfg.room.as_str()),
|
("room", self.cfg.room.as_str()),
|
||||||
("from", self.cfg.from.as_str()),
|
("from", self.cfg.from.as_str()),
|
||||||
("source", self.cfg.source.as_str()),
|
("source", self.cfg.source.as_str()),
|
||||||
|
("name", self.cfg.name.as_str()),
|
||||||
];
|
];
|
||||||
let info = ServiceInfo::new(
|
let info = ServiceInfo::new(
|
||||||
SERVICE,
|
SERVICE,
|
||||||
@@ -128,13 +132,29 @@ impl LanTransport {
|
|||||||
while running.load(Ordering::SeqCst) {
|
while running.load(Ordering::SeqCst) {
|
||||||
match browse.recv_async().await {
|
match browse.recv_async().await {
|
||||||
Ok(ServiceEvent::ServiceResolved(info)) => {
|
Ok(ServiceEvent::ServiceResolved(info)) => {
|
||||||
let room = info.get_property_val_str("room").unwrap_or("");
|
let room = info.get_property_val_str("room").unwrap_or("").to_string();
|
||||||
let from = info.get_property_val_str("from").unwrap_or("").to_string();
|
let from = info.get_property_val_str("from").unwrap_or("").to_string();
|
||||||
if room != self.cfg.room || from.is_empty() || from == self.cfg.from {
|
if from.is_empty() || from == self.cfg.from {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.cfg.from.as_str() >= from.as_str() {
|
// Record for the nearby list — every tether device on the
|
||||||
continue; // higher id waits to be dialed
|
// network, regardless of room (the UI shows them all).
|
||||||
|
let name = info
|
||||||
|
.get_property_val_str("name")
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(&from)
|
||||||
|
.to_string();
|
||||||
|
let source = info.get_property_val_str("source").unwrap_or("").to_string();
|
||||||
|
self.discovered.lock().unwrap().insert(
|
||||||
|
from.clone(),
|
||||||
|
(
|
||||||
|
Peer { id: from.clone(), name, source, room: room.clone() },
|
||||||
|
Instant::now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Auto-connect only same-room peers; lower id dials.
|
||||||
|
if room != self.cfg.room || self.cfg.from.as_str() >= from.as_str() {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if self.peers.lock().await.contains_key(&from) {
|
if self.peers.lock().await.contains_key(&from) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+35
-4
@@ -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::VecDeque;
|
use std::collections::{HashMap, 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};
|
||||||
@@ -58,12 +58,28 @@ pub trait MessageHandler: Send + Sync {
|
|||||||
fn on_status(&self, connected: bool);
|
fn on_status(&self, connected: bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// (join that device's room).
|
||||||
|
#[derive(Clone, Debug, uniffi::Record)]
|
||||||
|
pub struct Peer {
|
||||||
|
pub id: String, // stable device id (`from`)
|
||||||
|
pub name: String, // friendly name, e.g. "Patrick's MacBook"
|
||||||
|
pub source: String, // platform: macos / ios / android / agent
|
||||||
|
pub room: String, // the room this device is currently in
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discovered-peer table, shared between the LAN transport (writer) and the
|
||||||
|
/// engine's `nearby()` (reader). Instant is last-seen for staleness pruning.
|
||||||
|
type Discovered = Arc<Mutex<HashMap<String, (Peer, Instant)>>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Config {
|
struct Config {
|
||||||
server: String,
|
server: String,
|
||||||
room: String,
|
room: String,
|
||||||
from: String,
|
from: String,
|
||||||
source: String,
|
source: String,
|
||||||
|
name: String, // friendly device name advertised over mDNS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sink/Status closures a transport calls for inbound traffic and link state.
|
/// Sink/Status closures a transport calls for inbound traffic and link state.
|
||||||
@@ -88,27 +104,31 @@ pub struct Engine {
|
|||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
transports: Vec<Arc<dyn Transport>>,
|
transports: Vec<Arc<dyn Transport>>,
|
||||||
dedup: Arc<Mutex<Dedup>>,
|
dedup: Arc<Mutex<Dedup>>,
|
||||||
|
discovered: Discovered,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 (echo suppression); `source` a label.
|
/// `from` is this device's stable id (echo suppression); `source` a label.
|
||||||
|
/// `name` is the friendly device name shown in other devices' nearby lists,
|
||||||
|
/// e.g. "Patrick's MacBook" / "Patrick's 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, name: String) -> Arc<Self> {
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
.worker_threads(2)
|
.worker_threads(2)
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
.expect("tokio runtime");
|
.expect("tokio runtime");
|
||||||
let cfg = Config { server, room, from, source };
|
let cfg = Config { server, room, from, source, name };
|
||||||
|
let discovered: Discovered = 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())),
|
||||||
Arc::new(lan::LanTransport::new(cfg.clone())),
|
Arc::new(lan::LanTransport::new(cfg.clone(), discovered.clone())),
|
||||||
];
|
];
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
cfg,
|
cfg,
|
||||||
@@ -116,9 +136,20 @@ impl Engine {
|
|||||||
running: Arc::new(AtomicBool::new(false)),
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
transports,
|
transports,
|
||||||
dedup: Arc::new(Mutex::new(Dedup::default())),
|
dedup: Arc::new(Mutex::new(Dedup::default())),
|
||||||
|
discovered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nearby tether devices seen on the local network (mDNS), for an
|
||||||
|
/// AirDrop-style picker. Stale entries (>30s unseen) are pruned. Empty on
|
||||||
|
/// iOS until the multicast entitlement lands.
|
||||||
|
pub fn nearby(&self) -> Vec<Peer> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut map = self.discovered.lock().unwrap();
|
||||||
|
map.retain(|_, (_, seen)| now.duration_since(*seen) < Duration::from_secs(30));
|
||||||
|
map.values().map(|(p, _)| p.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Begin streaming on every transport. Idempotent while already running.
|
/// 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) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ final class MacTetherStore {
|
|||||||
disconnect()
|
disconnect()
|
||||||
|
|
||||||
let b = EngineBridge(store: self)
|
let b = EngineBridge(store: self)
|
||||||
let e = Engine(server: url, room: room, from: deviceID, source: "macos")
|
let e = Engine(server: url, room: room, from: deviceID, source: "macos", name: deviceID)
|
||||||
e.start(handler: b)
|
e.start(handler: b)
|
||||||
engine = e
|
engine = e
|
||||||
bridge = b
|
bridge = b
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final class TetherStore {
|
|||||||
disconnect()
|
disconnect()
|
||||||
|
|
||||||
let b = EngineBridge(store: self)
|
let b = EngineBridge(store: self)
|
||||||
let e = Engine(server: url, room: room, from: deviceID, source: "ios")
|
let e = Engine(server: url, room: room, from: deviceID, source: "ios", name: UIDevice.current.name)
|
||||||
e.start(handler: b)
|
e.start(handler: b)
|
||||||
engine = e
|
engine = e
|
||||||
bridge = b
|
bridge = b
|
||||||
|
|||||||
Reference in New Issue
Block a user