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)}
-
{codeCopied ? "Copied" : "Copy"}
-
New code
+
+
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}
+ searchNearby(false)}>Stop
{:else}
- Starting up…
+ searchNearby(true)} disabled={sync.busy}
+ >Find nearby devices
{/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}
+ pairNearby(device)}
+ disabled={sync.busy || !device.compatible}
+ >{knocking?.id === device.id ? "Waiting" : "Pair"}
+
+ {#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)}
+
+
+
+
+
New code
+ {: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()}
- />
-
- {pairing ? "Waiting" : "Pair"}
-
-
+
+
+ e.key === "Enter" && pairDevice()}
+ />
+
+ {pairing ? "Waiting" : "Pair"}
+
+
+
{#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,