Skip to content

Commit aa2fa53

Browse files
committed
feat(sync): local discovery
1 parent b7e876e commit aa2fa53

17 files changed

Lines changed: 1393 additions & 85 deletions

File tree

‎docs/sync.md‎

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,22 @@ can't read something, it leaves it alone on both devices.
1111

1212
## Modules
1313

14-
| Module | Job |
15-
| ------------- | --------------------------------------------------------------------- |
14+
| Module | Job |
15+
| ------------- | ---------------------------------------------------------------------- |
1616
| `manifest.rs` | what a folder holds, what couldn't be read, and what counts as content |
17-
| `plan.rs` | what a round does, for both devices at once (pure) |
18-
| `merge.rs` | three-way merge of a note |
19-
| `preview.rs` | a plan as someone can read it before it runs, and a note's line diff |
20-
| `apply.rs` | carrying out one device's half of a plan, safely |
21-
| `round.rs` | one round, from both ends: the coordinator and the device answering |
22-
| `ids.rs` | repairing two notes that carry one page id, the way the app does |
23-
| `session.rs` | the transport: streams, message limits, silence |
24-
| `protocol.rs` | wire messages |
25-
| `pairing.rs` | pairing codes and claims |
26-
| `state.rs` | identity, paired devices, baselines, lineage, ancestors, spool |
27-
| `mod.rs` | endpoint, peer tasks, Tauri commands |
28-
| `e2e/` | real devices over loopback: scenarios, and seeded random work (fuzz) |
17+
| `plan.rs` | what a round does, for both devices at once (pure) |
18+
| `merge.rs` | three-way merge of a note |
19+
| `preview.rs` | a plan as someone can read it before it runs, and a note's line diff |
20+
| `apply.rs` | carrying out one device's half of a plan, safely |
21+
| `round.rs` | one round, from both ends: the coordinator and the device answering |
22+
| `ids.rs` | repairing two notes that carry one page id, the way the app does |
23+
| `session.rs` | the transport: streams, message limits, silence |
24+
| `protocol.rs` | wire messages |
25+
| `pairing.rs` | pairing codes and claims |
26+
| `nearby.rs` | finding devices on the same network over mDNS |
27+
| `state.rs` | identity, paired devices, baselines, lineage, ancestors, spool |
28+
| `mod.rs` | endpoint, peer tasks, Tauri commands |
29+
| `e2e/` | real devices over loopback: scenarios, and seeded random work (fuzz) |
2930

3031
`plan.rs` has no disk or network access (three manifests and two lineages in, operations out), so
3132
its rules are tested as cases, and `e2e/` tests them again as whole rounds between real devices.
@@ -316,6 +317,27 @@ path.
316317
doesn't. Prompts are handled one at a time and recheck the secret, so a second device with the
317318
same code is refused as "used or replaced". If a freshly pasted code is refused, the claiming
318319
device removes the pairing.
320+
- **Nearby, without a code:** works like Bluetooth, with two switches under Settings → Sync →
321+
"Pair with a nearby device":
322+
- **Discoverable** (`sync_set_discoverable`) lets other devices on the network learn this one's
323+
name (`Request::Introduce`) and ask to pair (`Knock`, a `Claim` with no secret). Off, both are
324+
refused (`not_discoverable`). It is never saved: leaving the Sync page turns it off, and so do
325+
a reload and a restart.
326+
- **Find nearby devices** (`sync_search_nearby`) asks every device heard over mDNS who it is,
327+
every `SEARCH_EVERY` for `SEARCH_WINDOW` (or until Stop or leaving the page), and lists the
328+
discoverable ones. Searching doesn't make a device discoverable, nor the other way round.
329+
330+
Picking a device on the list sends the `Knock`, and the device picked asks **"Pair with
331+
<name>?"** exactly as for a code. That click is the whole gate, so:
332+
- Only a discoverable device takes a knock, and only from a device it hears over mDNS, so nobody
333+
can ask from across the internet, or from the network while nobody is pairing.
334+
- Both devices show the same six **check digits** (`pairing::check_digits`, from both endpoint
335+
ids) while the prompt is up. Names are whatever a device calls itself, so a stranger can take
336+
yours; matching digits mean each device is talking to the other. Allow only if they match.
337+
- One turned down can't knock again until `COOLDOWN` is over (`Throttle::block`), and unanswered
338+
knocks count toward `MAX_ATTEMPTS`, so a stranger can't keep a prompt on screen.
339+
- The pairing code isn't involved and isn't rotated.
340+
319341
- **Allowlist:** iroh authenticates the peer's key before any data is read, so a paired id is
320342
allowed and anything else isn't. An unpaired peer can only send a `Claim` (capped at
321343
`MAX_CLAIM`).
@@ -327,6 +349,29 @@ path.
327349
synced folder. A malformed `sync.json` is reported, not regenerated, because regenerating would
328350
change the device's identity.
329351

352+
## Devices on the same network
353+
354+
A device is dialed by its endpoint id. The addresses saved at pairing go stale as soon as either
355+
device changes network, and iroh's own lookup (n0's DNS and relay) needs the internet. So with sync on,
356+
each device also announces itself over mDNS (`nearby.rs`) as `_set-sync._udp.local`, under its own
357+
service name rather than iroh's shared one so Set only hears from Set, and iroh resolves a paired
358+
device's id from those announcements alongside DNS. Two devices on one Wi-Fi then sync directly, with
359+
no internet, wherever DHCP has moved them.
360+
361+
- **Nothing more is trusted.** An announcement is an endpoint id and its addresses: a pairing code
362+
without the secret. Being nearby grants nothing; the allowlist and someone clicking Allow are
363+
still the only ways in (see Pairing and trust).
364+
- **What it reveals.** Anyone on the network can see that a Set device is there and its endpoint id,
365+
which is stable. The device name isn't in the announcement; another Set device on the network can
366+
learn it only while this one is discoverable.
367+
- **Optional.** Where the network allows no multicast, or macOS hasn't been allowed Local Network
368+
access (`NSLocalNetworkUsageDescription` in `Info.plist`; refused sends fail with "No route to
369+
host"), the lookup is logged as `sync.mdns_unavailable` or simply hears nothing, and sync goes
370+
through the relay as before.
371+
- **Shown.** A paired device announcing itself is marked `nearby` in its status, and Settings → Sync
372+
says "on this network" beside it. Pairing with a device found this way is under Pairing and
373+
trust.
374+
330375
## Transport
331376

332377
- Incoming streams are each served on their own task (`session::serve_streams`). At most `IN_FLIGHT`

‎src-tauri/Cargo.lock‎

Lines changed: 59 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src-tauri/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ iroh = "1"
6060
# pair mismatched lines. Fixed in Cargo.lock; if `cargo update` brings it back, `cargo update -p
6161
# wmi`. scripts/check-lockfile.mjs guards it.
6262

63+
# Paired devices on the same network, found without the relay (src/sync/nearby.rs).
64+
iroh-mdns-address-lookup = "0.6"
65+
tokio-stream = "0.1"
6366
# `EndpointTicket`: endpoint id plus how to reach it, as one pasteable string.
6467
iroh-tickets = "1"
6568
postcard = { version = "1", features = ["alloc"] }

‎src-tauri/Info.plist‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<!-- NSMicrophoneUsageDescription is required: macOS kills a process that opens an input device without it. See src/dictation/audio.rs. -->
2+
<!-- NSMicrophoneUsageDescription is required: macOS kills a process that opens an input device without it. See src/dictation/audio.rs.
3+
NSLocalNetworkUsageDescription is the Local Network prompt's text. Until it is allowed, sync's mDNS sends fail with "No route to host" and paired devices are only reached through the relay. See src/sync/nearby.rs. -->
34
<plist version="1.0">
45
<dict>
56
<key>NSMicrophoneUsageDescription</key>
67
<string>Set uses the microphone for dictation. Your voice is transcribed on this Mac and never leaves it.</string>
8+
<key>NSLocalNetworkUsageDescription</key>
9+
<string>Set finds your paired devices on this network so it can sync with them directly.</string>
10+
<key>NSBonjourServices</key>
11+
<array>
12+
<string>_set-sync._udp</string>
13+
</array>
714
</dict>
815
</plist>

‎src-tauri/src/lib.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ pub fn run() {
324324
sync::sync_regenerate_pairing_code,
325325
sync::sync_answer_pair,
326326
sync::sync_pair,
327+
sync::sync_search_nearby,
328+
sync::sync_set_discoverable,
329+
sync::sync_pair_nearby,
327330
sync::sync_unpair,
328331
sync::sync_now,
329332
sync::sync_answer_preview,

0 commit comments

Comments
 (0)