From a8498f32072e395ef88848325a93249d48191dad 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 | 101 +++-
src-tauri/Cargo.lock | 61 +-
src-tauri/Cargo.toml | 3 +
src-tauri/Info.plist | 6 +
src-tauri/src/lib.rs | 3 +
src-tauri/src/sync/mod.rs | 803 ++++++++++++++++++++++++--
src-tauri/src/sync/nearby.rs | 415 +++++++++++++
src-tauri/src/sync/pairing.rs | 394 +++++++++++++
src-tauri/src/sync/protocol.rs | 52 +-
src-tauri/src/sync/round.rs | 8 +-
src-tauri/src/sync/session.rs | 15 +-
src/lib/shell/ConfirmPopover.svelte | 64 +-
src/lib/shell/Icon.svelte | 5 +
src/lib/shell/icons.ts | 1 +
src/lib/shell/settings/SyncTab.svelte | 283 +++++++--
src/lib/state/confirm.svelte.ts | 7 +-
src/lib/state/sync-log.test.ts | 7 +
src/lib/state/sync-log.ts | 15 +
src/lib/state/sync.svelte.ts | 56 +-
src/routes/+layout.svelte | 16 +-
20 files changed, 2200 insertions(+), 115 deletions(-)
create mode 100644 src-tauri/src/sync/nearby.rs
diff --git a/docs/sync.md b/docs/sync.md
index 31fe9d6..7d2328f 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,47 @@ 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:** one button under Settings → Sync, **Pair nearby**, pressed on both
+ devices. It turns on two things on that device, each of which the backend keeps separately:
+ - **Discoverable** (`sync_set_discoverable`) lets other devices on the network learn this one's
+ name (`Request::Introduce`) and ask to pair (`Knock`). Off, both are refused
+ (`not_discoverable`) without a log line.
+ - **Searching** (`sync_search_nearby`) asks every device heard over mDNS who it is, every
+ `SEARCH_EVERY`, and lists the discoverable ones. A device that stops being discoverable drops
+ off the list.
+
+ Neither is saved. Both turn off after `DISCOVERABLE_WINDOW` / `SEARCH_WINDOW` (two minutes), on
+ Stop, on leaving the Sync page, on a reload or restart, and on both devices once a pairing
+ succeeds.
+
+ Picking a device on the list starts a three-step exchange (`pairing::Knocks`), and each device
+ saves the other only once someone has clicked Allow **on it**:
+ 1. `Knock` carries a commitment to the picker's random nonce; the device picked answers with its
+ own nonce (`Knocked`).
+ 2. `Reveal` opens the commitment, and both devices work out the same six **check digits**
+ (`pairing::check_digits`, from both ids and both nonces). The device picked shows them with
+ **"Pair with <name>?"**. The picker doesn't show them: it asks for them to be **typed
+ in**, read off the other device, and counts a wrong number as turning it down
+ (`digits_mismatch`). The device picked answers `Allowed` once someone clicks Allow there.
+ 3. `ConfirmKnock` follows once the right digits are typed on the picker and Allow clicked, and
+ only then does the device picked save it; the picker saves it on `Paired`. A device picked
+ that already has the picker (its `Paired` was lost last time) goes through all three steps
+ again and replies `Paired` without saving it twice. A picker never takes `Paired` before
+ its own prompt is answered.
+
+ So:
+ - A device that says it's yours can't pair with you on its own say-so: your device asks you
+ too, and the device you meant shows no prompt at all.
+ - Names are whatever a device calls itself (cleaned of control and invisible characters,
+ `pairing::device_name`), so a stranger can take yours. Neither device chose its nonce knowing
+ the other's, so a device in the middle can't make its digits match yours except by a one in a
+ million chance. Typing them in, rather than comparing them, means nobody can allow it without
+ looking.
+ - Only a discoverable device takes a knock, and only from a device it hears over mDNS.
+ - Turning one down, or leaving it unanswered, hides the device (ids cost nothing, so a stranger
+ would just knock again as someone else); pressing Pair nearby again allows one more prompt.
+ - 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 +369,37 @@ 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 listens over mDNS (`nearby.rs`) for `_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.** A device announces itself only when someone could want to find it: it has a
+ paired device, or it is discoverable or searching (`Inner::announcing`). Pairing, unpairing and
+ either switch change this while sync runs, by swapping in a new mDNS lookup (`nearby::Lookup`)
+ rather than restarting the endpoint. A
+ new lookup hears everyone again, so the devices the old one heard are forgotten (it can't say
+ which of them left). Then anyone on the network can see that a Set
+ device is there and its endpoint id, which is stable, so the device can be recognised across
+ networks. The device name isn't in the announcement; another Set device can learn it only while
+ this one is discoverable.
+- **Bounded.** At most `MAX_HEARD` unpaired devices are tracked, and replies from unpaired devices
+ are read up to `MAX_CLAIM` (`session::request_small`), so a host announcing many ids can't make
+ a search dial or read without limit.
+- **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.
+
## 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..c94260f 100644
--- a/src-tauri/Info.plist
+++ b/src-tauri/Info.plist
@@ -4,5 +4,11 @@
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..3302470 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;
@@ -22,6 +23,7 @@ use std::time::Duration;
use iroh::endpoint::Connection;
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
use iroh::{Endpoint, EndpointAddr, EndpointId};
+use iroh_mdns_address_lookup::MdnsAddressLookup;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
@@ -31,6 +33,8 @@ use state::Config;
/// How long "Sync now" waits to reach a device before reporting it offline.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
+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,10 @@ pub struct Status {
pub pairing_code: Option,
pub peers: Vec,
+ pub discoverable: bool,
+ /// Milliseconds since the epoch.
+ pub searching_until: Option,
+ pub nearby: Vec,
pub last_error: Option,
}
@@ -57,6 +65,7 @@ pub struct PeerStatus {
pub id: String,
pub name: String,
pub connected: bool,
+ pub nearby: bool,
pub syncing: bool,
pub last_synced_at: Option,
@@ -68,6 +77,14 @@ pub struct PeerStatus {
pub too_big: Vec,
}
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NearbyDevice {
+ pub id: String,
+ pub name: String,
+ pub compatible: bool,
+}
+
#[derive(Clone, Debug, Serialize)]
pub struct TooBigFile {
pub path: String,
@@ -110,6 +127,8 @@ struct Inner {
notes_root: Mutex
+ {#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}
+
+ Type the number {device.name} shows, and click Allow on both.
+
+ {/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}
@@ -371,16 +496,63 @@
gap: 0.4rem;
}
- .row {
+ .looking {
+ color: var(--text-subtle);
+ }
+
+ .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;
+ }
+ .mode .chip,
+ .mode .peers {
+ background: var(--bg);
+ }
+ .mode input.chip:hover {
+ background: var(--bg);
+ }
+
+ .controls {
display: flex;
align-items: center;
+ justify-content: flex-end;
gap: 0.4rem;
-
- max-width: 34rem;
+ min-width: 0;
+ }
+ .controls > :global(.btn) {
+ min-width: 5.5rem;
}
.chip {
- flex: 1;
+ flex: none;
+ width: 16rem;
min-width: 0;
box-sizing: border-box;
height: 1.9rem;
@@ -403,9 +575,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 +620,11 @@
padding-top: 0.9rem;
border-top: 1px solid var(--border);
}
- .sync .set-field-stack + .set-field-stack {
- margin-top: 0.9rem;
+ .sync .set-hint + :global(.set-field) {
+ margin-top: 0.75rem;
+ }
+ .nearby {
+ margin-top: 0.45rem;
}
.empty {
diff --git a/src/lib/state/confirm.svelte.ts b/src/lib/state/confirm.svelte.ts
index a152a80..768fed3 100644
--- a/src/lib/state/confirm.svelte.ts
+++ b/src/lib/state/confirm.svelte.ts
@@ -18,8 +18,9 @@ export interface ConfirmRequest {
onDismiss?: () => void;
/** Nothing brings this back, so the popover opens on Cancel instead. */
permanent: boolean;
+ entry?: { label: string; digits: number };
/** `asked` is false when the confirmation was skipped; the caller then shows the undo toast. */
- onConfirm: (asked: boolean) => void;
+ onConfirm: (asked: boolean, entered?: string) => void;
onCancel?: () => void;
}
@@ -36,12 +37,12 @@ class Confirms {
this.request = request;
}
- confirm(dontAskAgain: boolean): void {
+ confirm(dontAskAgain: boolean, entered?: string): void {
const request = this.request;
this.request = null;
if (!request) return;
if (dontAskAgain && request.remember) settings.setConfirmDelete(false);
- request.onConfirm(true);
+ request.onConfirm(true, entered);
}
cancel(): void {
diff --git a/src/lib/state/sync-log.test.ts b/src/lib/state/sync-log.test.ts
index 9f1086f..37c97a2 100644
--- a/src/lib/state/sync-log.test.ts
+++ b/src/lib/state/sync-log.test.ts
@@ -130,6 +130,13 @@ 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("pair.refused", [...PEER, ["reason", "declined_nearby"]], "warn")),
+ describe(entry("pair.refused", [...PEER, ["reason", "knock_mismatch"]], "warn")),
+ describe(entry("pair.rejected", [...PEER, ["reason", "knock_expired"]], "error")),
+ describe(entry("pair.rejected", [...PEER, ["reason", "declined_here"]], "error")),
+ describe(entry("pair.refused", [...PEER, ["reason", "pairing_busy"]], "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..72edd1a 100644
--- a/src/lib/state/sync-log.ts
+++ b/src/lib/state/sync-log.ts
@@ -35,6 +35,13 @@ 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",
+ declined_nearby: "it wasn't allowed",
+ declined_here: "it wasn't allowed here",
+ digits_mismatch: "the number typed here wasn't the one on the other device",
+ knock_expired: "the request ran out of time",
+ knock_mismatch: "the other device's answer didn't check out",
+ pairing_busy: "another pairing prompt was open",
transport: "the connection dropped",
scan: "the notes folder couldn't be read",
apply: "a file couldn't be written",
@@ -72,6 +79,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..514aecd 100644
--- a/src/lib/state/sync.svelte.ts
+++ b/src/lib/state/sync.svelte.ts
@@ -11,6 +11,7 @@ export interface SyncPeer {
id: string;
name: string;
connected: boolean;
+ nearby: boolean;
syncing: boolean;
lastSyncedAt: number | null;
lastError: string | null;
@@ -20,6 +21,12 @@ export interface SyncPeer {
tooBig: TooBigFile[];
}
+export interface NearbyDevice {
+ id: string;
+ name: string;
+ compatible: boolean;
+}
+
export interface TooBigFile {
path: string;
bytes: number;
@@ -34,13 +41,20 @@ export interface SyncStatus {
protocolVersion: number;
pairingCode: string | null;
peers: SyncPeer[];
+ discoverable: boolean;
+ /** ms since the epoch. */
+ searchingUntil: number | null;
+ nearby: NearbyDevice[];
lastError: string | null;
}
-/** Another device used this one's code and is waiting for Allow. */
export interface PairRequest {
id: number;
name: string;
+ checkDigits?: string;
+ /** This device picked the other off its nearby list, so it types the digits in instead. */
+ asking?: boolean;
+ digitCount?: number;
}
/** Nothing has changed on either device yet. */
@@ -71,6 +85,9 @@ const OFF: SyncStatus = {
protocolVersion: 0,
pairingCode: null,
peers: [],
+ discoverable: false,
+ searchingUntil: null,
+ nearby: [],
lastError: null,
};
@@ -131,13 +148,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,12 +215,37 @@ class SyncState {
});
}
- async answerPair(allow: boolean): Promise {
+ 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 });
+ }
+
+ /** Both looking and findable, so it doesn't matter which device starts. */
+ async openNearby(): Promise {
+ await this.setDiscoverable(true);
+ await this.searchNearby(true);
+ }
+
+ async leaveNearby(): Promise {
+ if (!this.ready) return;
+ await Promise.allSettled([this.setDiscoverable(false), this.searchNearby(false)]);
+ }
+
+ async pairNearby(id: string): Promise {
+ await this.run(async () => {
+ this.status = await invoke("sync_pair_nearby", { id });
+ });
+ }
+
+ async answerPair(allow: boolean, digits?: string): Promise {
const request = this.pairRequest;
this.pairRequest = null;
if (!request) return;
try {
- await invoke("sync_answer_pair", { id: request.id, allow });
+ await invoke("sync_answer_pair", { id: request.id, allow, digits: digits ?? null });
} catch {
// unanswered, the claim times out on its own
}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index f543b39..3e31ede 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -75,15 +75,23 @@
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.asking
+ ? `Pairing with “${request.name}” gives it all your notes. Type the number 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.`
+ : `“${request.name}” found this device nearby and wants to pair, which gives it all ` +
+ `your notes. Allow it only if you're pairing it, then type ${request.checkDigits} ` +
+ `on it.`,
+ entry: request.asking
+ ? { label: `The number on ${request.name}`, digits: request.digitCount ?? 6 }
+ : undefined,
confirmLabel: "Allow",
cancelLabel: "Don't allow",
danger: false,
remember: false,
permanent: false,
- onConfirm: () => void sync.answerPair(true),
+ onConfirm: (_asked, entered) => void sync.answerPair(true, entered),
onCancel: () => void sync.answerPair(false),
onDismiss: () => void sync.answerPair(false),
});