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 @@
-
+
NSMicrophoneUsageDescriptionSet 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
+ {#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}
+
+ 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}