From aa2fa537e399dd0267ad92f595a67d162d2d03c2 Mon Sep 17 00:00:00 2001 From: Aavash Date: Sun, 4 Oct 2026 15:44:50 +0200 Subject: [PATCH] feat(sync): local discovery --- docs/sync.md | 73 +++- src-tauri/Cargo.lock | 61 +++- src-tauri/Cargo.toml | 3 + src-tauri/Info.plist | 9 +- src-tauri/src/lib.rs | 3 + src-tauri/src/sync/mod.rs | 461 +++++++++++++++++++++++++- src-tauri/src/sync/nearby.rs | 364 ++++++++++++++++++++ src-tauri/src/sync/pairing.rs | 67 ++++ src-tauri/src/sync/protocol.rs | 33 ++ src-tauri/src/sync/round.rs | 4 +- src/lib/shell/Icon.svelte | 5 + src/lib/shell/icons.ts | 1 + src/lib/shell/settings/SyncTab.svelte | 316 +++++++++++++++--- src/lib/state/sync-log.test.ts | 2 + src/lib/state/sync-log.ts | 9 + src/lib/state/sync.svelte.ts | 59 +++- src/routes/+layout.svelte | 8 +- 17 files changed, 1393 insertions(+), 85 deletions(-) create mode 100644 src-tauri/src/sync/nearby.rs diff --git a/docs/sync.md b/docs/sync.md index 31fe9d6..5d56cf4 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -11,21 +11,22 @@ can't read something, it leaves it alone on both devices. ## Modules -| Module | Job | -| ------------- | --------------------------------------------------------------------- | +| Module | Job | +| ------------- | ---------------------------------------------------------------------- | | `manifest.rs` | what a folder holds, what couldn't be read, and what counts as content | -| `plan.rs` | what a round does, for both devices at once (pure) | -| `merge.rs` | three-way merge of a note | -| `preview.rs` | a plan as someone can read it before it runs, and a note's line diff | -| `apply.rs` | carrying out one device's half of a plan, safely | -| `round.rs` | one round, from both ends: the coordinator and the device answering | -| `ids.rs` | repairing two notes that carry one page id, the way the app does | -| `session.rs` | the transport: streams, message limits, silence | -| `protocol.rs` | wire messages | -| `pairing.rs` | pairing codes and claims | -| `state.rs` | identity, paired devices, baselines, lineage, ancestors, spool | -| `mod.rs` | endpoint, peer tasks, Tauri commands | -| `e2e/` | real devices over loopback: scenarios, and seeded random work (fuzz) | +| `plan.rs` | what a round does, for both devices at once (pure) | +| `merge.rs` | three-way merge of a note | +| `preview.rs` | a plan as someone can read it before it runs, and a note's line diff | +| `apply.rs` | carrying out one device's half of a plan, safely | +| `round.rs` | one round, from both ends: the coordinator and the device answering | +| `ids.rs` | repairing two notes that carry one page id, the way the app does | +| `session.rs` | the transport: streams, message limits, silence | +| `protocol.rs` | wire messages | +| `pairing.rs` | pairing codes and claims | +| `nearby.rs` | finding devices on the same network over mDNS | +| `state.rs` | identity, paired devices, baselines, lineage, ancestors, spool | +| `mod.rs` | endpoint, peer tasks, Tauri commands | +| `e2e/` | real devices over loopback: scenarios, and seeded random work (fuzz) | `plan.rs` has no disk or network access (three manifests and two lineages in, operations out), so its rules are tested as cases, and `e2e/` tests them again as whole rounds between real devices. @@ -316,6 +317,27 @@ path. doesn't. Prompts are handled one at a time and recheck the secret, so a second device with the same code is refused as "used or replaced". If a freshly pasted code is refused, the claiming device removes the pairing. +- **Nearby, without a code:** works like Bluetooth, with two switches under Settings → Sync → + "Pair with a nearby device": + - **Discoverable** (`sync_set_discoverable`) lets other devices on the network learn this one's + name (`Request::Introduce`) and ask to pair (`Knock`, a `Claim` with no secret). Off, both are + refused (`not_discoverable`). It is never saved: leaving the Sync page turns it off, and so do + a reload and a restart. + - **Find nearby devices** (`sync_search_nearby`) asks every device heard over mDNS who it is, + every `SEARCH_EVERY` for `SEARCH_WINDOW` (or until Stop or leaving the page), and lists the + discoverable ones. Searching doesn't make a device discoverable, nor the other way round. + + Picking a device on the list sends the `Knock`, and the device picked asks **"Pair with + <name>?"** exactly as for a code. That click is the whole gate, so: + - Only a discoverable device takes a knock, and only from a device it hears over mDNS, so nobody + can ask from across the internet, or from the network while nobody is pairing. + - Both devices show the same six **check digits** (`pairing::check_digits`, from both endpoint + ids) while the prompt is up. Names are whatever a device calls itself, so a stranger can take + yours; matching digits mean each device is talking to the other. Allow only if they match. + - One turned down can't knock again until `COOLDOWN` is over (`Throttle::block`), and unanswered + knocks count toward `MAX_ATTEMPTS`, so a stranger can't keep a prompt on screen. + - The pairing code isn't involved and isn't rotated. + - **Allowlist:** iroh authenticates the peer's key before any data is read, so a paired id is allowed and anything else isn't. An unpaired peer can only send a `Claim` (capped at `MAX_CLAIM`). @@ -327,6 +349,29 @@ path. synced folder. A malformed `sync.json` is reported, not regenerated, because regenerating would change the device's identity. +## Devices on the same network + +A device is dialed by its endpoint id. The addresses saved at pairing go stale as soon as either +device changes network, and iroh's own lookup (n0's DNS and relay) needs the internet. So with sync on, +each device also announces itself over mDNS (`nearby.rs`) as `_set-sync._udp.local`, under its own +service name rather than iroh's shared one so Set only hears from Set, and iroh resolves a paired +device's id from those announcements alongside DNS. Two devices on one Wi-Fi then sync directly, with +no internet, wherever DHCP has moved them. + +- **Nothing more is trusted.** An announcement is an endpoint id and its addresses: a pairing code + without the secret. Being nearby grants nothing; the allowlist and someone clicking Allow are + still the only ways in (see Pairing and trust). +- **What it reveals.** Anyone on the network can see that a Set device is there and its endpoint id, + which is stable. The device name isn't in the announcement; another Set device on the network can + learn it only while this one is discoverable. +- **Optional.** Where the network allows no multicast, or macOS hasn't been allowed Local Network + access (`NSLocalNetworkUsageDescription` in `Info.plist`; refused sends fail with "No route to + host"), the lookup is logged as `sync.mdns_unavailable` or simply hears nothing, and sync goes + through the relay as before. +- **Shown.** A paired device announcing itself is marked `nearby` in its status, and Settings → Sync + says "on this network" beside it. Pairing with a device found this way is under Pairing and + trust. + ## Transport - Incoming streams are each served on their own task (`session::serve_streams`). At most `IN_FLIGHT` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1e0fe05..9f79ffa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str", + "sync_wrapper", + "tokio", + "tracing", +] + [[package]] name = "adler2" version = "2.0.1" @@ -1117,7 +1132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -2730,6 +2745,26 @@ dependencies = [ "url", ] +[[package]] +name = "iroh-mdns-address-lookup" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7afb03c296022fcb724a7bb76dbb8dcca6aac4f4eb65df161e084b7276c93d8c" +dependencies = [ + "data-encoding", + "derive_more", + "futures-util", + "iroh", + "iroh-base", + "n0-error", + "n0-future", + "n0-watcher", + "swarm-discovery", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "iroh-metrics" version = "1.0.1" @@ -5153,6 +5188,7 @@ dependencies = [ "cpal", "diffy", "iroh", + "iroh-mdns-address-lookup", "iroh-tickets", "notify", "postcard", @@ -5169,6 +5205,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tokio", + "tokio-stream", "unicode-normalization", "ureq", "uuid", @@ -5292,6 +5329,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + [[package]] name = "socket2" version = "0.6.4" @@ -5455,6 +5498,21 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swarm-discovery" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b4877f20f064de34b77d79237c9caa37c393590af8ef5a2f3253d59be5a338b" +dependencies = [ + "acto", + "hickory-proto", + "rand", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -5473,7 +5531,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 932b9f0..c759feb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,6 +60,9 @@ iroh = "1" # pair mismatched lines. Fixed in Cargo.lock; if `cargo update` brings it back, `cargo update -p # wmi`. scripts/check-lockfile.mjs guards it. +# Paired devices on the same network, found without the relay (src/sync/nearby.rs). +iroh-mdns-address-lookup = "0.6" +tokio-stream = "0.1" # `EndpointTicket`: endpoint id plus how to reach it, as one pasteable string. iroh-tickets = "1" postcard = { version = "1", features = ["alloc"] } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 770b2d0..445a120 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -1,8 +1,15 @@ - + NSMicrophoneUsageDescription Set uses the microphone for dictation. Your voice is transcribed on this Mac and never leaves it. + NSLocalNetworkUsageDescription + Set finds your paired devices on this network so it can sync with them directly. + NSBonjourServices + + _set-sync._udp + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8edee4a..5633425 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -324,6 +324,9 @@ pub fn run() { sync::sync_regenerate_pairing_code, sync::sync_answer_pair, sync::sync_pair, + sync::sync_search_nearby, + sync::sync_set_discoverable, + sync::sync_pair_nearby, sync::sync_unpair, sync::sync_now, sync::sync_answer_preview, diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 24f79a3..3f7d23f 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -3,6 +3,7 @@ pub mod ids; pub mod log; pub mod manifest; pub mod merge; +pub mod nearby; pub mod pairing; pub mod plan; pub mod preview; @@ -31,6 +32,9 @@ use state::Config; /// How long "Sync now" waits to reach a device before reporting it offline. const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +/// How long a search waits on one nearby device to say who it is. +const INTRODUCE_TIMEOUT: Duration = Duration::from_secs(5); + /// Both devices' round locks are held while a preview is up. const REVIEW_TIMEOUT: Duration = Duration::from_secs(10 * 60); @@ -47,6 +51,12 @@ pub struct Status { pub pairing_code: Option, pub peers: Vec, + /// Other devices on this network can see this one and ask to pair. + pub discoverable: bool, + /// When "Find nearby devices" stops looking, while it is (milliseconds since the epoch). + pub searching_until: Option, + /// Unpaired, discoverable devices on this network, while searching. + pub nearby: Vec, pub last_error: Option, } @@ -57,6 +67,8 @@ pub struct PeerStatus { pub id: String, pub name: String, pub connected: bool, + /// Announcing itself on this network, so it's reached directly. + pub nearby: bool, pub syncing: bool, pub last_synced_at: Option, @@ -68,6 +80,17 @@ pub struct PeerStatus { pub too_big: Vec, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NearbyDevice { + pub id: String, + pub name: String, + /// Shown on both devices while one asks the other (`pairing::check_digits`). + pub check_digits: String, + /// Speaks this device's sync protocol, so the two can pair. + pub compatible: bool, +} + #[derive(Clone, Debug, Serialize)] pub struct TooBigFile { pub path: String, @@ -110,6 +133,7 @@ struct Inner { notes_root: Mutex>, peers: Mutex>, + nearby: Mutex, last_error: Mutex>, running: tokio::sync::Mutex>, @@ -133,6 +157,8 @@ struct Inner { struct Running { endpoint: Endpoint, router: Router, + /// Follows mDNS announcements; `None` where the network allows no multicast. + nearby: Option>, } impl Sync { @@ -152,6 +178,7 @@ impl Sync { config: Mutex::new(config), notes_root: Mutex::new(None), peers: Mutex::new(HashMap::new()), + nearby: Mutex::new(nearby::Nearby::default()), last_error: Mutex::new(error), running: tokio::sync::Mutex::new(None), round_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -180,6 +207,8 @@ impl Inner { let config = self.config(); let code = self.pairing_code(endpoint); let live = self.peers.lock().expect("sync peers poisoned"); + let nearby = self.nearby.lock().expect("sync nearby poisoned"); + let searching = nearby.searching(now_ms()); Status { enabled: config.enabled, running: endpoint.is_some(), @@ -197,6 +226,7 @@ impl Inner { id: d.id.to_string(), name: d.name.clone(), connected: l.is_some_and(|l| l.connected), + nearby: nearby.contains(&d.id), syncing: l.is_some_and(|l| l.syncing), last_synced_at: l.and_then(|l| l.last_synced_at), last_error: l.and_then(|l| l.last_error.clone()), @@ -213,6 +243,24 @@ impl Inner { } }) .collect(), + discoverable: nearby.discoverable(), + searching_until: searching.map(|s| s.until), + nearby: { + let me = config.endpoint_id(); + let mut devices: Vec = nearby + .introduced() + .filter(|_| searching.is_some()) + .filter(|(id, _)| !config.is_paired(id)) + .map(|(id, intro)| NearbyDevice { + id: id.to_string(), + name: intro.name.clone(), + check_digits: pairing::check_digits(&me, id), + compatible: intro.version == protocol::VERSION, + }) + .collect(); + devices.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + devices + }, last_error: self.last_error.lock().expect("sync error poisoned").clone(), } } @@ -267,6 +315,10 @@ impl Inner { .iter() .find(|d| d.id == *peer) .map(|d| d.name.clone()) + .or_else(|| { + let nearby = self.nearby.lock().expect("sync nearby poisoned"); + nearby.name(peer).map(str::to_string) + }) .unwrap_or_else(|| format!("device {}", &peer.to_string()[..8])) } } @@ -279,12 +331,14 @@ impl Inner { } let config = self.config(); - let endpoint = match Endpoint::builder(iroh::endpoint::presets::N0) + let mdns = nearby::lookup(config.endpoint_id()); + let mut builder = Endpoint::builder(iroh::endpoint::presets::N0) .secret_key(config.secret_key.clone()) - .alpns(vec![protocol::ALPN.to_vec()]) - .bind() - .await - { + .alpns(vec![protocol::ALPN.to_vec()]); + if let Some(mdns) = &mdns { + builder = builder.address_lookup(mdns.clone()); + } + let endpoint = match builder.bind().await { Ok(endpoint) => endpoint, Err(e) => { let msg = format!("starting sync: {e}"); @@ -297,7 +351,27 @@ impl Inner { .accept(protocol::ALPN, Handler(Arc::downgrade(self))) .spawn(); - *running = Some(Running { endpoint, router }); + let nearby = mdns.map(|mdns| { + let weak = Arc::downgrade(self); + tauri::async_runtime::spawn(nearby::watch(mdns, move |event| { + let Some(inner) = weak.upgrade() else { return }; + let change = inner + .nearby + .lock() + .expect("sync nearby poisoned") + .apply(event); + // A search asks newcomers who they are on its next round. + if change != nearby::Change::Nothing { + tauri::async_runtime::spawn(async move { inner.broadcast().await }); + } + })) + }); + + *running = Some(Running { + endpoint, + router, + nearby, + }); drop(running); // A round cut off by a crash left files staged under hidden names. @@ -323,10 +397,14 @@ impl Inner { let taken = self.running.lock().await.take(); let was_running = taken.is_some(); if let Some(running) = taken { + if let Some(nearby) = running.nearby { + nearby.abort(); + } let _ = running.router.shutdown().await; running.endpoint.close().await; } self.peers.lock().expect("sync peers poisoned").clear(); + *self.nearby.lock().expect("sync nearby poisoned") = nearby::Nearby::default(); if was_running { log::info("sync.stopped").emit(); } @@ -393,6 +471,117 @@ impl Inner { self.broadcast().await; } + /// Asks an unpaired device on this network who it is, for the nearby list. It answers only + /// while it is discoverable. + async fn introduce(self: &Arc, endpoint: &Endpoint, peer: EndpointId) { + if self.config().is_paired(&peer) { + return; + } + let asked = tokio::time::timeout(INTRODUCE_TIMEOUT, async { + let connection = endpoint + .connect(EndpointAddr::new(peer), protocol::ALPN) + .await + .map_err(|e| e.to_string())?; + let reply = session::request(&connection, &Request::Introduce).await; + connection.close(0u32.into(), b"introduced"); + reply + }) + .await; + let heard = match asked { + Ok(Ok(Response::Introduced { + version, + device_name, + })) => Some(nearby::Introduction { + name: match device_name.trim() { + "" => format!("Device {}", &peer.to_string()[..8]), + named => named.to_string(), + }, + version, + }), + // Refused, or an older Set that doesn't know the question: not discoverable. + Ok(Ok(_)) => None, + // Unreachable for now; asked again next time round. + _ => return, + }; + let changed = self + .nearby + .lock() + .expect("sync nearby poisoned") + .heard(&peer, heard); + if changed { + self.broadcast().await; + } + } + + /// "Find nearby devices": until `SEARCH_WINDOW` runs out or someone stops it, keeps asking + /// every device on the network that hasn't answered yet, so one made discoverable meanwhile + /// still turns up. Pressing it again starts the window over. + async fn search(self: &Arc) -> Result<(), String> { + let endpoint = self.endpoint().await?; + let search = self + .nearby + .lock() + .expect("sync nearby poisoned") + .search(now_ms()); + log::info("nearby.searching").emit(); + self.broadcast().await; + + let inner = Arc::downgrade(self); + tauri::async_runtime::spawn(async move { + loop { + let Some(inner) = inner.upgrade() else { return }; + let (still, unnamed) = { + let nearby = inner.nearby.lock().expect("sync nearby poisoned"); + let still = nearby.searching(now_ms()).map(|s| s.id) == Some(search.id); + (still, nearby.unnamed()) + }; + if !still { + inner.stop_search(Some(search.id)).await; + return; + } + let mut asking = tokio::task::JoinSet::new(); + for peer in unnamed { + let inner = inner.clone(); + let endpoint = endpoint.clone(); + asking.spawn(async move { inner.introduce(&endpoint, peer).await }); + } + drop(inner); + asking.join_all().await; + tokio::time::sleep(nearby::SEARCH_EVERY).await; + } + }); + Ok(()) + } + + /// `id`: only if that search is still the one running. + async fn stop_search(self: &Arc, id: Option) { + let stopped = self + .nearby + .lock() + .expect("sync nearby poisoned") + .stop_search(id); + if stopped { + self.broadcast().await; + } + } + + async fn set_discoverable(self: &Arc, on: bool) { + let changed = self + .nearby + .lock() + .expect("sync nearby poisoned") + .set_discoverable(on); + if changed { + log::info(if on { + "nearby.discoverable" + } else { + "nearby.hidden" + }) + .emit(); + self.broadcast().await; + } + } + /// Dials a paired device and redeems its pairing claim if one is pending. async fn connect( self: &Arc, @@ -851,6 +1040,34 @@ impl Inner { ) .await; } + if let Request::Knock { + version, + device_name, + notes_id, + } = &request + { + return self + .knock(peer, connection, *version, device_name, notes_id.as_deref()) + .await; + } + if let Request::Introduce = request { + let open = self + .nearby + .lock() + .expect("sync nearby poisoned") + .open_to(&peer); + if !open { + // Asked every few seconds by every searching device: not worth a log line. + return Response::Refused { + code: pairing::Refused::NotDiscoverable.code().to_string(), + message: pairing::Refused::NotDiscoverable.to_string(), + }; + } + return Response::Introduced { + version: protocol::VERSION, + device_name: self.config().device_name, + }; + } let paired = self.config().is_paired(&peer); if !paired { @@ -918,7 +1135,7 @@ impl Inner { if !self.config().pairing_secret.same_as(secret) { return self.refuse(peer, pairing::Refused::Wrong).await; } - match self.ask(connection, &name).await { + match self.ask(connection, &name, None).await { Some(true) => {} Some(false) => { self.use_up_code(); @@ -926,7 +1143,92 @@ impl Inner { } None => return self.refuse(peer, pairing::Refused::NoAnswer).await, } + self.admit(peer, name, notes_id, now, true).await + } + /// A device picked off this one's nearby list, asking to pair without a code. Only someone + /// clicking Allow here lets it in, so it is only asked of devices on this network, and one + /// turned down can't ask again until the cooldown is over. + async fn knock( + self: &Arc, + peer: EndpointId, + connection: &Connection, + version: u32, + device_name: &str, + notes_id: Option<&str>, + ) -> Response { + if let Err(refused) = pairing::may_claim(version) { + return self.refuse(peer, refused).await; + } + if self.config().is_paired(&peer) { + return Response::Paired { + notes_id: self.config().notes_id, + }; + } + let now = now_ms(); + let open = self + .nearby + .lock() + .expect("sync nearby poisoned") + .open_to(&peer); + if !open { + return self.refuse(peer, pairing::Refused::NotDiscoverable).await; + } + + let allowed = self + .throttle + .lock() + .expect("pairing throttle poisoned") + .allows(&peer, now); + if let Err(refused) = allowed { + return self.refuse(peer, refused).await; + } + if let Err(refused) = self.joining(peer, notes_id) { + return self.refuse(peer, refused).await; + } + + let name = match device_name.trim() { + "" => format!("Device {}", &peer.to_string()[..8]), + named => named.to_string(), + }; + let digits = pairing::check_digits(&self.config().endpoint_id(), &peer); + + let _turn = self.approving.lock().await; + if self.config().is_paired(&peer) { + return Response::Paired { + notes_id: self.config().notes_id, + }; + } + match self.ask(connection, &name, Some(&digits)).await { + Some(true) => {} + Some(false) => { + self.throttle + .lock() + .expect("pairing throttle poisoned") + .block(peer, now_ms()); + return self.refuse(peer, pairing::Refused::DeclinedNearby).await; + } + None => { + self.throttle + .lock() + .expect("pairing throttle poisoned") + .failed(peer, now_ms()); + return self.refuse(peer, pairing::Refused::NoAnswer).await; + } + } + self.admit(peer, name, notes_id, now, false).await + } + + /// Someone clicked Allow: `peer` joins the allowlist. A code pairs one device, so pairing by + /// code (`rotate`) spends it. + async fn admit( + self: &Arc, + peer: EndpointId, + name: String, + notes_id: Option<&str>, + now: f64, + rotate: bool, + ) -> Response { let joined = match self.joining(peer, notes_id) { Ok(joined) => joined, Err(refused) => return self.refuse(peer, refused).await, @@ -946,8 +1248,9 @@ impl Inner { config.paired.push(device.clone()); config.notes_id = joined.clone(); - // A code pairs one device. - config.pairing_secret = pairing::Secret::generate(); + if rotate { + config.pairing_secret = pairing::Secret::generate(); + } if let Err(e) = state::save(&config) { return Response::Error(e); } @@ -959,12 +1262,14 @@ impl Inner { .succeeded(&peer); log::info("pair.accepted") .peer(peer, &name) + .field("by", if rotate { "code" } else { "nearby" }) .field("notes", &joined) .field("moved_notes", moved_notes) .emit(); - let _ = self - .app - .emit("sync:paired", serde_json::json!({ "name": name })); + let _ = self.app.emit( + "sync:paired", + serde_json::json!({ "name": name, "byCode": rotate }), + ); self.broadcast().await; Response::Paired { notes_id: joined } @@ -981,13 +1286,19 @@ impl Inner { } /// `None` when nobody answered in time or the peer hung up. - async fn ask(&self, connection: &Connection, name: &str) -> Option { + /// `check_digits` when the device was picked off the nearby list rather than given a code. + async fn ask( + &self, + connection: &Connection, + name: &str, + check_digits: Option<&str>, + ) -> Option { let id: u32 = rand::random(); let (answer, answered) = tokio::sync::oneshot::channel(); *self.approval.lock().expect("sync approval poisoned") = Some((id, answer)); let _ = self.app.emit( "sync:pair-request", - serde_json::json!({ "id": id, "name": name }), + serde_json::json!({ "id": id, "name": name, "checkDigits": check_digits }), ); let outcome = tokio::select! { @@ -1221,6 +1532,128 @@ pub async fn sync_pair(sync: tauri::State<'_, Sync>, code: String) -> Result, on: bool) -> Result { + let inner = sync.engine().clone(); + if on { + inner.search().await?; + } else { + inner.stop_search(None).await; + } + sync_status(sync).await +} + +/// Lets other devices on this network see this one and ask to pair. Never saved: Settings turns +/// it off again when it closes, and so does a restart. +#[tauri::command] +pub async fn sync_set_discoverable( + sync: tauri::State<'_, Sync>, + on: bool, +) -> Result { + let inner = sync.engine().clone(); + if on && inner.endpoint().await.is_err() { + return Err("turn on sync first".to_string()); + } + inner.set_discoverable(on).await; + sync_status(sync).await +} + +/// Pairs with a device on the nearby list, once someone clicks Allow on it. +#[tauri::command] +pub async fn sync_pair_nearby(sync: tauri::State<'_, Sync>, id: String) -> Result { + let inner = sync.engine().clone(); + let peer: EndpointId = id.parse().map_err(|e| format!("not a device id: {e}"))?; + let config = inner.config(); + if config.is_paired(&peer) { + return Err("that device is already paired".to_string()); + } + let name = inner + .nearby + .lock() + .expect("sync nearby poisoned") + .name(&peer) + .map(str::to_string) + .ok_or_else(|| { + "that device isn't on the list anymore. Click Find nearby devices again".to_string() + })?; + let endpoint = inner.endpoint().await?; + + let connection = match tokio::time::timeout( + CONNECT_TIMEOUT, + endpoint.connect(EndpointAddr::new(peer), protocol::ALPN), + ) + .await + { + Ok(Ok(connection)) => connection, + Ok(Err(e)) => return Err(format!("couldn't reach {name}: {e}")), + Err(_) => return Err(format!("couldn't reach {name}. Is Set open on it?")), + }; + let settled = !config.paired.is_empty(); + let knock = Request::Knock { + version: protocol::VERSION, + device_name: config.device_name.clone(), + notes_id: settled.then(|| config.notes_id.clone()), + }; + let reply = session::request(&connection, &knock).await; + connection.close(0u32.into(), b"paired"); + + let error = match reply { + Ok(Response::Paired { notes_id }) => { + { + let mut config = inner.config.lock().expect("sync config poisoned"); + if !config.is_paired(&peer) { + config.paired.push(state::PairedDevice { + id: peer, + name: name.clone(), + paired_at: now_ms(), + addrs: std::collections::BTreeSet::new(), + claim: None, + }); + state::save(&config)?; + } + } + let joined = inner.join_notes(notes_id.clone()); + log::info("pair.confirmed") + .peer(peer, &name) + .field("by", "nearby") + .field("notes", ¬es_id) + .field("joined", joined) + .emit(); + inner.broadcast().await; + return sync_status(sync).await; + } + Ok(Response::Refused { code, message }) => { + if code == pairing::Refused::NotDiscoverable.code() { + // Off the list until it says otherwise. + inner + .nearby + .lock() + .expect("sync nearby poisoned") + .heard(&peer, None); + inner.broadcast().await; + } + (code, message) + } + Ok(Response::Error(e)) => ("peer_error".to_string(), e), + Ok(other) => ( + "unexpected_reply".to_string(), + format!( + "the other device didn't understand this pairing request. The two are probably \ + running different versions of Set. Reply was {other:?}" + ), + ), + Err(e) => ("unreachable".to_string(), e), + }; + log::error("pair.rejected") + .peer(peer, &name) + .field("by", "nearby") + .field("reason", &error.0) + .field("error", &error.1) + .emit(); + Err(error.1) +} + #[tauri::command] pub async fn sync_unpair(sync: tauri::State<'_, Sync>, id: String) -> Result { let inner = sync.engine().clone(); diff --git a/src-tauri/src/sync/nearby.rs b/src-tauri/src/sync/nearby.rs new file mode 100644 index 0000000..2b8ee29 --- /dev/null +++ b/src-tauri/src/sync/nearby.rs @@ -0,0 +1,364 @@ +//! Devices on the same network, found over mDNS, so a paired device is reached directly +//! (no relay, no internet) and wherever its address has moved to since pairing. +//! +//! Only reachability: what a device announces is its endpoint id and addresses, the same as a +//! pairing code minus the secret. Being nearby grants nothing; the allowlist and the claim are +//! still the only ways in. +//! +//! Pairing with a nearby device is another matter, so it follows Bluetooth: a device tells others its +//! name, and can be asked to pair, only while someone has made it discoverable, and a device lists +//! others only while someone is searching ("Find nearby devices"). + +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::time::Duration; + +use iroh::EndpointId; +use iroh_mdns_address_lookup::{DiscoveryEvent, MdnsAddressLookup}; +use tokio_stream::StreamExt; + +/// How long "Find nearby devices" keeps looking. +pub const SEARCH_WINDOW: Duration = Duration::from_secs(3 * 60); + +/// How often a search asks the devices on the network that haven't answered yet, so one made +/// discoverable mid-search turns up. +pub const SEARCH_EVERY: Duration = Duration::from_secs(4); + +/// Ours rather than iroh's shared `irohv1`, so Set only hears from Set. At most 15 bytes. +pub const SERVICE: &str = "set-sync"; + +/// Announces this device and listens for others. `None` when the network allows neither IPv4 +/// nor IPv6 multicast, in which case sync carries on through the relay alone. +pub fn lookup(me: EndpointId) -> Option { + match MdnsAddressLookup::builder().service_name(SERVICE).build(me) { + Ok(mdns) => Some(mdns), + Err(e) => { + super::log::warn("sync.mdns_unavailable") + .field("error", e.to_string()) + .emit(); + None + } + } +} + +/// What a nearby device says about itself when asked (`Request::Introduce`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Introduction { + pub name: String, + pub version: u32, +} + +/// What an mDNS event did to the list. +#[derive(Debug, PartialEq, Eq)] +pub enum Change { + /// A device not on the list before, still to be introduced. + Arrived(EndpointId), + Left, + Nothing, +} + +/// Which devices are announcing themselves on this network right now, and, while this device is +/// searching, which of them are discoverable and what they are called. +/// +/// Two switches, as with Bluetooth: being **discoverable** lets other devices on the network see +/// this one's name and ask to pair (still only with Allow clicked here), and **searching** lists the +/// devices that are discoverable. Neither needs the other. +#[derive(Default)] +pub struct Nearby { + devices: HashMap>, + discoverable: bool, + search: Option, + searches: u64, +} + +/// One press of "Find nearby devices". +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Search { + /// Which press, so the timer of an earlier one can't stop a later one. + pub id: u64, + /// Milliseconds since the epoch. + pub until: f64, +} + +impl Nearby { + pub fn contains(&self, id: &EndpointId) -> bool { + self.devices.contains_key(id) + } + + pub fn set_discoverable(&mut self, on: bool) -> bool { + let changed = self.discoverable != on; + self.discoverable = on; + changed + } + + pub fn discoverable(&self) -> bool { + self.discoverable + } + + /// Whether `peer` may learn this device's name and ask it to pair: it is on this network, and + /// this device is discoverable. + pub fn open_to(&self, peer: &EndpointId) -> bool { + self.discoverable && self.contains(peer) + } + + /// Starts searching, or starts the search over. + pub fn search(&mut self, now: f64) -> Search { + self.searches += 1; + let search = Search { + id: self.searches, + until: now + SEARCH_WINDOW.as_millis() as f64, + }; + self.search = Some(search); + search + } + + /// Stops searching, if `id` is still the search running (`None`: whichever), and forgets the + /// names heard. Whether anything stopped. + pub fn stop_search(&mut self, id: Option) -> bool { + let running = match (self.search, id) { + (Some(search), Some(id)) => search.id == id, + (Some(_), None) => true, + (None, _) => false, + }; + if running { + self.search = None; + for intro in self.devices.values_mut() { + *intro = None; + } + } + running + } + + pub fn searching(&self, now: f64) -> Option { + self.search.filter(|s| now < s.until) + } + + /// Devices on the network not yet heard to be discoverable, to ask again. + pub fn unnamed(&self) -> Vec { + self.devices + .iter() + .filter(|(_, intro)| intro.is_none()) + .map(|(id, _)| *id) + .collect() + } + + pub fn apply(&mut self, event: DiscoveryEvent) -> Change { + match event { + DiscoveryEvent::Discovered { endpoint_info, .. } => { + let id = endpoint_info.endpoint_id; + match self.devices.entry(id) { + Entry::Occupied(_) => Change::Nothing, + Entry::Vacant(entry) => { + entry.insert(None); + Change::Arrived(id) + } + } + } + DiscoveryEvent::Expired { endpoint_id } => match self.devices.remove(&endpoint_id) { + Some(_) => Change::Left, + None => Change::Nothing, + }, + _ => Change::Nothing, + } + } + + /// `Some`: the device said who it is, so it is discoverable. `None`: it turned out not to be + /// (any more). Whether the list changed. + pub fn heard(&mut self, id: &EndpointId, introduction: Option) -> bool { + if self.search.is_none() { + return false; + } + match self.devices.get_mut(id) { + Some(slot) if *slot != introduction => { + *slot = introduction; + true + } + _ => false, + } + } + + pub fn introduced(&self) -> impl Iterator { + self.devices + .iter() + .filter_map(|(id, intro)| intro.as_ref().map(|intro| (id, intro))) + } + + pub fn name(&self, id: &EndpointId) -> Option<&str> { + self.devices + .get(id)? + .as_ref() + .map(|intro| intro.name.as_str()) + } +} + +/// Feeds every announcement and expiry to `changed` until the lookup goes away. +pub async fn watch(mdns: MdnsAddressLookup, mut changed: impl FnMut(DiscoveryEvent)) { + let mut events = mdns.subscribe().await; + while let Some(event) = events.next().await { + changed(event); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use iroh::endpoint::presets; + use iroh::{Endpoint, EndpointAddr, SecretKey}; + + use super::*; + + async fn bind() -> (Endpoint, MdnsAddressLookup) { + let key = SecretKey::generate(); + let mdns = lookup(key.public()).expect("mdns"); + let endpoint = Endpoint::builder(presets::Minimal) + .secret_key(key) + .alpns(vec![b"set/test".to_vec()]) + .address_lookup(mdns.clone()) + .bind() + .await + .expect("bind"); + (endpoint, mdns) + } + + /// Needs multicast on a real interface, which CI runners and sandboxes may not allow. + #[tokio::test] + #[ignore] + async fn finds_a_device_by_id_alone() { + let (a, mdns) = bind().await; + let (b, _b_mdns) = bind().await; + let mut nearby = Nearby::default(); + let seen = tokio::time::timeout(Duration::from_secs(15), async { + let mut events = mdns.subscribe().await; + while let Some(event) = events.next().await { + nearby.apply(event); + if nearby.contains(&b.id()) { + return; + } + } + }) + .await; + assert!(seen.is_ok(), "b never announced itself"); + + let accept = tokio::spawn({ + let b = b.clone(); + async move { b.accept().await.expect("incoming").await.expect("accepted") } + }); + // No addresses: the only way to b is what mDNS heard. + let connected = tokio::time::timeout( + Duration::from_secs(15), + a.connect(EndpointAddr::new(b.id()), b"set/test"), + ) + .await; + assert!(matches!(connected, Ok(Ok(_))), "{connected:?}"); + accept.await.unwrap(); + } + + const NOW: f64 = 1_720_000_000_000.0; + + fn found(nearby: &mut Nearby) -> EndpointId { + let id = SecretKey::generate().public(); + nearby.apply(DiscoveryEvent::Discovered { + endpoint_info: iroh::address_lookup::EndpointInfo::new(id), + last_updated: None, + }); + id + } + + fn laptop() -> Introduction { + Introduction { + name: "Laptop".to_string(), + version: 5, + } + } + + #[test] + fn only_a_discoverable_device_is_open_to_its_neighbours() { + let mut nearby = Nearby::default(); + let id = found(&mut nearby); + assert!(!nearby.open_to(&id), "not discoverable yet"); + assert!(nearby.set_discoverable(true)); + assert!(nearby.open_to(&id)); + assert!( + !nearby.open_to(&SecretKey::generate().public()), + "not on this network" + ); + assert!(!nearby.set_discoverable(true), "already on"); + nearby.set_discoverable(false); + assert!(!nearby.open_to(&id)); + } + + #[test] + fn a_search_lists_names_until_it_stops() { + let mut nearby = Nearby::default(); + let id = found(&mut nearby); + assert!(!nearby.heard(&id, Some(laptop())), "not searching"); + + let first = nearby.search(NOW); + assert!(nearby.searching(NOW + 1000.0).is_some()); + assert!(nearby.heard(&id, Some(laptop()))); + assert!(!nearby.heard(&id, Some(laptop())), "nothing new"); + assert_eq!(nearby.name(&id), Some("Laptop")); + assert!(nearby.unnamed().is_empty()); + + // Made undiscoverable mid-search: off the list, and asked again. + assert!(nearby.heard(&id, None)); + assert_eq!(nearby.unnamed(), vec![id]); + nearby.heard(&id, Some(laptop())); + + let window = SEARCH_WINDOW.as_millis() as f64; + assert!( + nearby.searching(NOW + window).is_none(), + "the search ran out" + ); + + // Searching again starts over, and the first search's timer leaves the new one running. + let second = nearby.search(NOW + window); + assert!(!nearby.stop_search(Some(first.id))); + assert!(nearby.searching(NOW + window + 1000.0).is_some()); + + assert!(nearby.stop_search(Some(second.id))); + assert_eq!( + nearby.name(&id), + None, + "names are forgotten when the search stops" + ); + assert!(nearby.contains(&id), "but the device is still nearby"); + } + + #[test] + fn follows_announcements_and_expiries() { + let id = SecretKey::generate().public(); + let found = || DiscoveryEvent::Discovered { + endpoint_info: iroh::address_lookup::EndpointInfo::new(id), + last_updated: None, + }; + let mut nearby = Nearby::default(); + assert_eq!( + nearby.apply(DiscoveryEvent::Expired { endpoint_id: id }), + Change::Nothing + ); + assert_eq!(nearby.apply(found()), Change::Arrived(id)); + assert_eq!(nearby.apply(found()), Change::Nothing); + assert!(nearby.contains(&id)); + assert_eq!(nearby.introduced().count(), 0); + + let intro = Introduction { + name: "Laptop".to_string(), + version: 5, + }; + nearby.search(NOW); + assert!(nearby.heard(&id, Some(intro.clone()))); + assert_eq!(nearby.name(&id), Some("Laptop")); + // Announcing again doesn't forget the name. + nearby.apply(found()); + assert_eq!(nearby.name(&id), Some("Laptop")); + + assert_eq!( + nearby.apply(DiscoveryEvent::Expired { endpoint_id: id }), + Change::Left + ); + assert!(!nearby.contains(&id)); + assert!(!nearby.heard(&id, Some(intro)), "gone devices aren't named"); + } +} diff --git a/src-tauri/src/sync/pairing.rs b/src-tauri/src/sync/pairing.rs index 549b891..6ecb5f9 100644 --- a/src-tauri/src/sync/pairing.rs +++ b/src-tauri/src/sync/pairing.rs @@ -126,6 +126,13 @@ pub enum Refused { /// The code was right and nobody answered the prompt in time. NoAnswer, + + /// Pairing without a code is only for a device this one hears on its own network, and only + /// while someone has made it discoverable. + NotDiscoverable, + + /// Someone clicked "Don't allow" on a device picked from the nearby list. + DeclinedNearby, } impl Refused { @@ -137,6 +144,8 @@ impl Refused { Refused::Version { .. } => "version_mismatch", Refused::Declined => "declined", Refused::NoAnswer => "no_answer", + Refused::NotDiscoverable => "not_discoverable", + Refused::DeclinedNearby => "declined", } } } @@ -169,10 +178,35 @@ impl std::fmt::Display for Refused { f, "nobody clicked Allow on the other device. Try again and answer the prompt there" ), + Refused::NotDiscoverable => write!( + f, + "the other device isn't discoverable. Turn on Discoverable in its Sync settings and try again" + ), + Refused::DeclinedNearby => write!( + f, + "the other device didn't allow it. You can ask again in a few minutes" + ), } } } +/// Six digits both devices show while one asks the other to pair without a code. Picking a device +/// by the name it gives itself proves nothing; matching digits mean each is talking to the other. +pub fn check_digits(a: &EndpointId, b: &EndpointId) -> String { + let (first, second) = if a.as_bytes() <= b.as_bytes() { + (a, b) + } else { + (b, a) + }; + let mut hasher = blake3::Hasher::new(); + hasher.update(b"set/sync/check-digits"); + hasher.update(first.as_bytes()); + hasher.update(second.as_bytes()); + let bytes = hasher.finalize(); + let n = u32::from_le_bytes(bytes.as_bytes()[..4].try_into().expect("four bytes")) % 1_000_000; + format!("{:03} {:03}", n / 1000, n % 1000) +} + pub fn may_claim(their_version: u32) -> Result<(), Refused> { if their_version != super::protocol::VERSION { return Err(Refused::Version { @@ -235,6 +269,15 @@ impl Throttle { failures.latest = now; } + /// A device turned down from the nearby list waits out the whole cooldown before it can ask + /// again, so a stranger on the network can't keep a prompt on screen. + pub fn block(&mut self, peer: EndpointId, now: f64) { + self.failed(peer, now); + if let Some(failures) = self.0.get_mut(&peer) { + failures.count = failures.count.max(MAX_ATTEMPTS); + } + } + pub fn succeeded(&mut self, peer: &EndpointId) { self.0.remove(peer); } @@ -471,4 +514,28 @@ mod tests { ); } } + + #[test] + fn both_devices_show_the_same_check_digits() { + let (a, b) = (peer(), peer()); + let digits = check_digits(&a, &b); + assert_eq!(digits, check_digits(&b, &a)); + assert_eq!(digits.len(), 7); + assert_ne!(digits, check_digits(&a, &peer())); + } + + #[test] + fn a_device_turned_down_waits_out_the_cooldown() { + let mut throttle = Throttle::default(); + let p = peer(); + throttle.block(p, NOW); + assert_eq!( + throttle.allows(&p, NOW + 1000.0), + Err(Refused::TooManyAttempts) + ); + assert_eq!( + throttle.allows(&p, NOW + COOLDOWN.as_millis() as f64), + Ok(()) + ); + } } diff --git a/src-tauri/src/sync/protocol.rs b/src-tauri/src/sync/protocol.rs index 346fa12..c12ef6c 100644 --- a/src-tauri/src/sync/protocol.rs +++ b/src-tauri/src/sync/protocol.rs @@ -83,6 +83,18 @@ pub enum Request { Lineage { pages: Vec<(String, Option)>, }, + + /// Asks a device on the same network who it is, for the nearby list. Answered without pairing, + /// but only while it is discoverable; otherwise refused (`not_discoverable`). + Introduce, + + /// A claim without a code, from a device picked off the nearby list. Pairs only once someone + /// clicks Allow on the device asked. + Knock { + version: u32, + device_name: String, + notes_id: Option, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -129,6 +141,11 @@ pub enum Response { actor: String, stamps: Vec>, }, + + Introduced { + version: u32, + device_name: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -317,6 +334,15 @@ mod tests { 9 ); assert_eq!(index(&Request::Lineage { pages: Vec::new() }), 10); + assert_eq!(index(&Request::Introduce), 11); + assert_eq!( + index(&Request::Knock { + version: VERSION, + device_name: String::new(), + notes_id: None, + }), + 12 + ); } #[test] @@ -372,6 +398,13 @@ mod tests { }), 11 ); + assert_eq!( + index(&Response::Introduced { + version: VERSION, + device_name: String::new(), + }), + 12 + ); } #[test] diff --git a/src-tauri/src/sync/round.rs b/src-tauri/src/sync/round.rs index cf92b53..a870a66 100644 --- a/src-tauri/src/sync/round.rs +++ b/src-tauri/src/sync/round.rs @@ -1019,8 +1019,8 @@ impl Session { Response::Ok } - Request::Claim { .. } => { - return Err("a pairing claim was routed to the wrong place".to_string()) + Request::Claim { .. } | Request::Introduce | Request::Knock { .. } => { + return Err("a pairing request was routed to the wrong place".to_string()) } }) } diff --git a/src/lib/shell/Icon.svelte b/src/lib/shell/Icon.svelte index 1c2d624..b1d2860 100644 --- a/src/lib/shell/Icon.svelte +++ b/src/lib/shell/Icon.svelte @@ -51,6 +51,11 @@ {:else if name === "context"} + {:else if name === "copy"} + + {:else if name === "date"} diff --git a/src/lib/shell/icons.ts b/src/lib/shell/icons.ts index c48339c..7a926dc 100644 --- a/src/lib/shell/icons.ts +++ b/src/lib/shell/icons.ts @@ -8,6 +8,7 @@ export type IconName = | "close" | "code" | "context" + | "copy" | "date" | "divider" | "download" diff --git a/src/lib/shell/settings/SyncTab.svelte b/src/lib/shell/settings/SyncTab.svelte index 4343e11..7051ee0 100644 --- a/src/lib/shell/settings/SyncTab.svelte +++ b/src/lib/shell/settings/SyncTab.svelte @@ -1,6 +1,12 @@ @@ -233,54 +290,148 @@

{#if sync.status.enabled}
-
- This device's code -
- {#if sync.status.pairingCode} - {shortCode(sync.status.pairingCode)} - - +
+

Pair a device

+
+
+
+ Nearby + + {#if searching} + Looking… + {:else if sync.status.discoverable} + Discoverable + {:else} + Same network + {/if} + +
+ + setDiscoverable(v === "on")} + /> + +

+ {#if sync.status.discoverable} + Your devices on this network can find this one as + {sync.status.deviceName}. It stops when you leave this page. + {:else} + Turn on to let your other device find this one. + {/if} +

+ + {#if searching} + {:else} - Starting up… + {/if} + + {#if !searching} +

+ Lists your devices on this network that are discoverable. +

+ {:else if sync.status.nearby.length === 0} +

+ Turn on Discoverable on the other device. +

+ {:else} +
    + {#each sync.status.nearby as device (device.id)} +
  • +
    + {device.name} + {#if !device.compatible} + Needs the same version of Set + {/if} + +
    + {#if knocking?.id === device.id} +

    + Click Allow on {device.name} if it shows + {device.checkDigits}. +

    + {/if} +
  • + {/each} +
+ {/if} +
+ +
+
+ With a code + Any network
+ +
+ {#if sync.status.pairingCode} +
+ {shortCode(sync.status.pairingCode)} + +
+ + {:else} + Starting up… + {/if} +
+

{#if renewed && sync.lastPaired} {sync.lastPaired.name} used the last code. This one is for your next device. {:else} - One code pairs one device. You'll be asked to allow it here. + For a device that isn't on this network. One code pairs one device, and you'll + be asked to allow it here. {/if}

-
-
- Pair a device -
- e.key === "Enter" && pairDevice()} - /> - -
+ +
+ e.key === "Enter" && pairDevice()} + /> + +
+
{#if pairing}

Click Allow on the other device.

{/if}
+

Devices

{#if sync.status.peers.length > 0} @@ -359,7 +510,6 @@ {logExpanded ? "Show less" : `Show all ${syncLog.length}`} {/if} -

The report never includes your code.

{/if}
{/if} @@ -371,16 +521,68 @@ gap: 0.4rem; } - .row { + .looking { + color: var(--text-subtle); + } + + /* Each way to pair, boxed like the dictation model. */ + .mode { + padding: 0.75rem 0.85rem; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + } + .mode + .mode { + margin-top: 0.6rem; + } + .mode-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.5rem; + } + .mode-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--text); + } + .mode-state { + flex: 0 0 auto; + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; + } + .mode-state.on { + color: var(--accent); + font-weight: 600; + } + /* Fields and lists sit on the page colour inside a box, so they still stand out from it. */ + .mode .chip, + .mode .peers { + background: var(--bg); + } + .mode input.chip:hover { + background: var(--bg); + } + + /* The right-hand side of a field: everything you can press sits against the edge. */ + .controls { display: flex; align-items: center; + justify-content: flex-end; gap: 0.4rem; - - max-width: 34rem; + min-width: 0; + } + /* "New code" and "Pair" take the same room, so the fields beside them start at one edge. */ + .controls > :global(.btn) { + min-width: 5.5rem; } + /* Both code fields are exactly this wide, and never squeezed, so the two rows line up. */ .chip { - flex: 1; + flex: none; + width: 16rem; min-width: 0; box-sizing: border-box; height: 1.9rem; @@ -403,9 +605,27 @@ font-family: var(--font-mono); } - code.chip { + .code-field { + gap: 0.3rem; + padding-right: 0.2rem; + } + .code-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; user-select: text; } + .chip-action { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + font-size: 0.85rem; + } /* The code just changed under the user; draw the eye to it. */ .chip.renewed { border-color: var(--accent); @@ -430,8 +650,12 @@ padding-top: 0.9rem; border-top: 1px solid var(--border); } - .sync .set-field-stack + .set-field-stack { - margin-top: 0.9rem; + /* A field after the previous one's hint. */ + .sync .set-hint + :global(.set-field) { + margin-top: 0.75rem; + } + .nearby { + margin-top: 0.45rem; } .empty { diff --git a/src/lib/state/sync-log.test.ts b/src/lib/state/sync-log.test.ts index 9f1086f..4e415c7 100644 --- a/src/lib/state/sync-log.test.ts +++ b/src/lib/state/sync-log.test.ts @@ -130,6 +130,8 @@ group("what the user reads", () => { entry("peer.unreachable", [...PEER, ["error", "no route to host"]], "warn"), ), describe(entry("pair.refused", [...PEER, ["reason", "wrong_code"]], "warn")), + describe(entry("pair.refused", [...PEER, ["reason", "not_discoverable"]], "warn")), + describe(entry("sync.mdns_unavailable", [["error", "no ipv4"]], "warn")), ]; for (const line of lines) { expect(line).not.toMatch(/_|=|\bpeer\b/); diff --git a/src/lib/state/sync-log.ts b/src/lib/state/sync-log.ts index b2ccd26..a983f74 100644 --- a/src/lib/state/sync-log.ts +++ b/src/lib/state/sync-log.ts @@ -35,6 +35,7 @@ const REASONS: Record = { declined: "it wasn't allowed", no_answer: "nobody answered the prompt in time", throttled: "too many pairing attempts. Try again in a few minutes", + not_discoverable: "it isn't discoverable", transport: "the connection dropped", scan: "the notes folder couldn't be read", apply: "a file couldn't be written", @@ -72,6 +73,14 @@ export function describe(entry: SyncLogEntry): string { return "Sync stopped"; case "sync.start_failed": return withReason("Sync couldn't start", entry); + case "nearby.discoverable": + return "This device was made discoverable"; + case "nearby.hidden": + return "This device stopped being discoverable"; + case "nearby.searching": + return "Looked for nearby devices"; + case "sync.mdns_unavailable": + return "Can't look for devices on this network, so sync goes through the internet"; case "peer.connected": return `Connected to ${who(entry)}`; case "peer.unreachable": diff --git a/src/lib/state/sync.svelte.ts b/src/lib/state/sync.svelte.ts index 9623b98..0bb4cfe 100644 --- a/src/lib/state/sync.svelte.ts +++ b/src/lib/state/sync.svelte.ts @@ -11,6 +11,8 @@ export interface SyncPeer { id: string; name: string; connected: boolean; + /** Announcing itself on this network, so it's reached directly. */ + nearby: boolean; syncing: boolean; lastSyncedAt: number | null; lastError: string | null; @@ -20,6 +22,16 @@ export interface SyncPeer { tooBig: TooBigFile[]; } +/** An unpaired device on this network that said who it is. */ +export interface NearbyDevice { + id: string; + name: string; + /** Shown on both devices while one asks the other; matching means each found the other. */ + checkDigits: string; + /** Speaks this device's sync protocol, so the two can pair. */ + compatible: boolean; +} + export interface TooBigFile { path: string; bytes: number; @@ -34,13 +46,21 @@ export interface SyncStatus { protocolVersion: number; pairingCode: string | null; peers: SyncPeer[]; + /** Other devices on this network can see this one and ask to pair. */ + discoverable: boolean; + /** While "Find nearby devices" is looking, when it stops (ms since the epoch). */ + searchingUntil: number | null; + /** Unpaired, discoverable devices on this network, while searching. */ + nearby: NearbyDevice[]; lastError: string | null; } -/** Another device used this one's code and is waiting for Allow. */ +/** Another device used this one's code, or picked it off its nearby list, and is waiting for Allow. */ export interface PairRequest { id: number; name: string; + /** Set when it was picked off the nearby list: the digits both devices show. */ + checkDigits: string | null; } /** Nothing has changed on either device yet. */ @@ -71,6 +91,9 @@ const OFF: SyncStatus = { protocolVersion: 0, pairingCode: null, peers: [], + discoverable: false, + searchingUntil: null, + nearby: [], lastError: null, }; @@ -131,13 +154,17 @@ class SyncState { await listen<{ id: number }>("sync:preview-closed", (event) => { if (this.previewRequest?.id === event.payload.id) this.previewRequest = null; }), - await listen<{ name: string }>("sync:paired", (event) => { - this.lastPaired = { name: event.payload.name, at: Date.now() }; + await listen<{ name: string; byCode: boolean }>("sync:paired", (event) => { + // Only a code is used up by pairing; one from the nearby list leaves it as it was. + if (event.payload.byCode) + this.lastPaired = { name: event.payload.name, at: Date.now() }; toasts.done(`Paired with ${event.payload.name}`); }), ); // A preview a reloaded window left up has nobody to answer it. await invoke("sync_answer_preview", { id: null, go: false }).catch(() => {}); + // Nor is the Sync page that made this device discoverable still open. + await this.leaveNearby(); await this.refresh(); } @@ -194,6 +221,32 @@ class SyncState { }); } + async setDiscoverable(on: boolean): Promise { + this.status = await invoke("sync_set_discoverable", { on }); + } + + async searchNearby(on: boolean): Promise { + this.status = await invoke("sync_search_nearby", { on }); + } + + /** Leaving the Sync page hides this device again and stops looking. */ + async leaveNearby(): Promise { + if (!this.ready) return; + try { + await this.setDiscoverable(false); + await this.searchNearby(false); + } catch { + // sync is off; there was nothing to stop + } + } + + /** Resolves once the other device allows it; throws if it's turned down or nobody answers. */ + async pairNearby(id: string): Promise { + await this.run(async () => { + this.status = await invoke("sync_pair_nearby", { id }); + }); + } + async answerPair(allow: boolean): Promise { const request = this.pairRequest; this.pairRequest = null; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f543b39..b941631 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -75,9 +75,11 @@ confirms.ask({ anchor: null, title: "Pair with this device?", - message: - `“${request.name}” wants to pair, which gives it all your notes. ` + - `Allow it only if you just pasted this device's code there.`, + message: request.checkDigits + ? `“${request.name}” found this device nearby and wants to pair, which gives it all ` + + `your notes. Allow it only if it shows ${request.checkDigits}.` + : `“${request.name}” wants to pair, which gives it all your notes. ` + + `Allow it only if you just pasted this device's code there.`, confirmLabel: "Allow", cancelLabel: "Don't allow", danger: false,