core: account identity — same-account devices auto-pair on the LAN
Add an `account` dimension (Apple/Google `sub`, or any shared id) advertised over mDNS alongside the device name. The LAN transport now connects to a peer when they share a room OR a non-empty account — so your own devices find each other and sync on the network regardless of room, no pairing step. Peer gains `account` so the UI can mark "your devices". Engine::new gains `account` (6th arg, ""=signed out); all callers updated. Apps pass "" until sign-in lands; agent takes --account. Proven by examples/account_pair.rs: two devices in DIFFERENT rooms with the same account sync over LAN with no server. Next: derive the account from Sign in with Apple / Google so this is automatic (the cross-network version = account-derived shared server room). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -57,11 +57,13 @@ fn main() {
|
|||||||
// Friendly name for other devices' nearby lists.
|
// Friendly name for other devices' nearby lists.
|
||||||
let host = std::env::var("HOSTNAME").unwrap_or_else(|_| "tether-agent".into());
|
let host = std::env::var("HOSTNAME").unwrap_or_else(|_| "tether-agent".into());
|
||||||
let name = arg("--name", &host);
|
let name = arg("--name", &host);
|
||||||
|
// Shared identity: same-account devices pair on the LAN regardless of room.
|
||||||
|
let account = arg("--account", "");
|
||||||
|
|
||||||
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, name);
|
let engine = Engine::new(server, room, from, source, name, account);
|
||||||
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", android.os.Build.MODEL)
|
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
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//! Proves account-based LAN pairing: two devices in DIFFERENT rooms but with the
|
||||||
|
//! SAME account auto-connect over the LAN and sync — no shared room, no server.
|
||||||
|
//! cargo run --example account_pair
|
||||||
|
//!
|
||||||
|
//! This is the "same account, same network → just pair" behavior.
|
||||||
|
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tethercore::{Engine, Message, MessageHandler};
|
||||||
|
|
||||||
|
struct Collector(mpsc::Sender<Message>);
|
||||||
|
impl MessageHandler for Collector {
|
||||||
|
fn on_message(&self, msg: Message) {
|
||||||
|
let _ = self.0.send(msg);
|
||||||
|
}
|
||||||
|
fn on_status(&self, _c: bool) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dead = "http://127.0.0.1:1".to_string(); // no server — LAN only
|
||||||
|
let account = "patrick@example.com"; // same identity on both devices
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
// Note the DIFFERENT rooms — they should still pair via the shared account.
|
||||||
|
let a = Engine::new(dead.clone(), "room-alpha".into(), "dev-a".into(), "macos".into(), "Mac".into(), account.into());
|
||||||
|
a.start(Box::new(Collector(tx)));
|
||||||
|
|
||||||
|
let b = Engine::new(dead, "room-bravo".into(), "dev-b".into(), "ios".into(), "Phone".into(), account.into());
|
||||||
|
b.start(Box::new(Collector(mpsc::channel().0)));
|
||||||
|
|
||||||
|
eprintln!("different rooms, same account — waiting for LAN pair…");
|
||||||
|
std::thread::sleep(Duration::from_secs(5));
|
||||||
|
|
||||||
|
let payload = "synced-by-account";
|
||||||
|
b.send(payload.to_string());
|
||||||
|
|
||||||
|
match rx.recv_timeout(Duration::from_secs(6)) {
|
||||||
|
Ok(m) if m.text == payload => {
|
||||||
|
println!("✅ same-account devices in different rooms synced over LAN: {:?}", m.text);
|
||||||
|
}
|
||||||
|
Ok(m) => println!("⚠️ unexpected: {m:?}"),
|
||||||
|
Err(_) => {
|
||||||
|
println!("❌ no sync — account pairing failed");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.stop();
|
||||||
|
b.stop();
|
||||||
|
}
|
||||||
@@ -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(), "LAN A".into());
|
let a = Engine::new(dead.clone(), room.clone(), "peer-a".into(), "lan-a".into(), "LAN A".into(), "".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(), "LAN B".into());
|
let b = Engine::new(dead, room, b_from.into(), "lan-b".into(), "LAN B".into(), "".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(), "Rust LAN Peer".into());
|
let e = Engine::new("http://127.0.0.1:1".into(), room, "zzz-peer".into(), "peer".into(), "Rust LAN Peer".into(), "".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
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ impl MessageHandler for Noop {
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let dead = "http://127.0.0.1:1".to_string();
|
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 a = Engine::new(dead.clone(), "room-A".into(), "alpha".into(), "macos".into(), "Patrick's MacBook".into(), "acct-X".into());
|
||||||
let b = Engine::new(dead, "room-B".into(), "bravo".into(), "ios".into(), "Patrick's iPhone".into());
|
let b = Engine::new(dead, "room-B".into(), "bravo".into(), "ios".into(), "Patrick's iPhone".into(), "acct-X".into());
|
||||||
a.start(Box::new(Noop));
|
a.start(Box::new(Noop));
|
||||||
b.start(Box::new(Noop));
|
b.start(Box::new(Noop));
|
||||||
|
|
||||||
|
|||||||
@@ -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(), "Rust RX".into());
|
let sub = Engine::new(server.clone(), room.clone(), "rx".into(), "rust-rx".into(), "Rust RX".into(), "".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(), "Rust TX".into());
|
let pubr = Engine::new(server, room, "tx".into(), "rust-tx".into(), "Rust TX".into(), "".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(), "Peer A".into());
|
let a = Engine::new(server.clone(), room.clone(), "peer-a".into(), "label-a".into(), "Peer A".into(), "".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(), "Peer B".into());
|
let b = Engine::new(server, room, b_from.into(), "label-b-sse".into(), "Peer B".into(), "".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.
|
||||||
|
|||||||
+31
-8
@@ -100,6 +100,7 @@ impl LanTransport {
|
|||||||
("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()),
|
("name", self.cfg.name.as_str()),
|
||||||
|
("account", self.cfg.account.as_str()),
|
||||||
];
|
];
|
||||||
let info = ServiceInfo::new(
|
let info = ServiceInfo::new(
|
||||||
SERVICE,
|
SERVICE,
|
||||||
@@ -145,15 +146,28 @@ impl LanTransport {
|
|||||||
.unwrap_or(&from)
|
.unwrap_or(&from)
|
||||||
.to_string();
|
.to_string();
|
||||||
let source = info.get_property_val_str("source").unwrap_or("").to_string();
|
let source = info.get_property_val_str("source").unwrap_or("").to_string();
|
||||||
|
let account = info.get_property_val_str("account").unwrap_or("").to_string();
|
||||||
self.discovered.lock().unwrap().insert(
|
self.discovered.lock().unwrap().insert(
|
||||||
from.clone(),
|
from.clone(),
|
||||||
(
|
(
|
||||||
Peer { id: from.clone(), name, source, room: room.clone() },
|
Peer {
|
||||||
|
id: from.clone(),
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
room: room.clone(),
|
||||||
|
account: account.clone(),
|
||||||
|
},
|
||||||
Instant::now(),
|
Instant::now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Auto-connect only same-room peers; lower id dials.
|
// Pair if same room OR same (non-empty) account — your own
|
||||||
if room != self.cfg.room || self.cfg.from.as_str() >= from.as_str() {
|
// devices auto-connect on the LAN regardless of room. Lower
|
||||||
|
// id dials to avoid double-connects.
|
||||||
|
let same_account =
|
||||||
|
!self.cfg.account.is_empty() && account == self.cfg.account;
|
||||||
|
if (room != self.cfg.room && !same_account)
|
||||||
|
|| self.cfg.from.as_str() >= from.as_str()
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.peers.lock().await.contains_key(&from) {
|
if self.peers.lock().await.contains_key(&from) {
|
||||||
@@ -188,19 +202,28 @@ impl LanTransport {
|
|||||||
|
|
||||||
let hello = format!(
|
let hello = format!(
|
||||||
"{}\n",
|
"{}\n",
|
||||||
json!({"t": "hello", "from": self.cfg.from, "room": self.cfg.room})
|
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() {
|
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.
|
||||||
let mut lines = BufReader::new(rd).lines();
|
let mut lines = BufReader::new(rd).lines();
|
||||||
let peer_from = match lines.next_line().await {
|
let peer_from = match lines.next_line().await {
|
||||||
Ok(Some(l)) => match serde_json::from_str::<serde_json::Value>(&l) {
|
Ok(Some(l)) => match serde_json::from_str::<serde_json::Value>(&l) {
|
||||||
Ok(v)
|
Ok(v) if v["t"] == "hello" => {
|
||||||
if v["t"] == "hello"
|
let same_room = v["room"].as_str() == Some(self.cfg.room.as_str());
|
||||||
&& 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()
|
v["from"].as_str().unwrap_or("").to_string()
|
||||||
}
|
}
|
||||||
_ => return,
|
_ => return,
|
||||||
|
|||||||
+20
-9
@@ -63,10 +63,11 @@ pub trait MessageHandler: Send + Sync {
|
|||||||
/// (join that device's room).
|
/// (join that device's room).
|
||||||
#[derive(Clone, Debug, uniffi::Record)]
|
#[derive(Clone, Debug, uniffi::Record)]
|
||||||
pub struct Peer {
|
pub struct Peer {
|
||||||
pub id: String, // stable device id (`from`)
|
pub id: String, // stable device id (`from`)
|
||||||
pub name: String, // friendly name, e.g. "Patrick's MacBook"
|
pub name: String, // friendly name, e.g. "Patrick's MacBook"
|
||||||
pub source: String, // platform: macos / ios / android / agent
|
pub source: String, // platform: macos / ios / android / agent
|
||||||
pub room: String, // the room this device is currently in
|
pub room: String, // the room this device is currently in
|
||||||
|
pub account: String, // shared identity (Apple/Google sub); empty if signed out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discovered-peer table, shared between the LAN transport (writer) and the
|
/// Discovered-peer table, shared between the LAN transport (writer) and the
|
||||||
@@ -79,7 +80,8 @@ struct Config {
|
|||||||
room: String,
|
room: String,
|
||||||
from: String,
|
from: String,
|
||||||
source: String,
|
source: String,
|
||||||
name: String, // friendly device name advertised over mDNS
|
name: String, // friendly device name advertised over mDNS
|
||||||
|
account: String, // shared identity; same-account peers pair on the LAN
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sink/Status closures a transport calls for inbound traffic and link state.
|
/// Sink/Status closures a transport calls for inbound traffic and link state.
|
||||||
@@ -111,16 +113,25 @@ pub struct Engine {
|
|||||||
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,
|
/// `name` is the friendly device name shown in other devices' nearby lists.
|
||||||
/// e.g. "Patrick's MacBook" / "Patrick's iPhone".
|
/// `account` is a shared identity (Apple/Google `sub`); devices with the
|
||||||
|
/// same non-empty account auto-pair on the LAN regardless of room. Pass ""
|
||||||
|
/// when signed out.
|
||||||
#[uniffi::constructor]
|
#[uniffi::constructor]
|
||||||
pub fn new(server: String, room: String, from: String, source: String, name: String) -> Arc<Self> {
|
pub fn new(
|
||||||
|
server: String,
|
||||||
|
room: String,
|
||||||
|
from: String,
|
||||||
|
source: String,
|
||||||
|
name: String,
|
||||||
|
account: 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, name };
|
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 transports: Vec<Arc<dyn Transport>> = vec![
|
let transports: Vec<Arc<dyn Transport>> = vec![
|
||||||
Arc::new(SseTransport {
|
Arc::new(SseTransport {
|
||||||
|
|||||||
@@ -48,7 +48,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", name: deviceID)
|
let e = Engine(server: url, room: room, from: deviceID, source: "macos", name: deviceID, account: "")
|
||||||
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", name: UIDevice.current.name)
|
let e = Engine(server: url, room: room, from: deviceID, source: "ios", name: UIDevice.current.name, account: "")
|
||||||
e.start(handler: b)
|
e.start(handler: b)
|
||||||
engine = e
|
engine = e
|
||||||
bridge = b
|
bridge = b
|
||||||
|
|||||||
Reference in New Issue
Block a user