diff --git a/docs/contacts-plan.md b/docs/contacts-plan.md index 98fb439..7bb49b7 100644 --- a/docs/contacts-plan.md +++ b/docs/contacts-plan.md @@ -193,9 +193,31 @@ Rejoin is best-effort (works only while the room is still live + reachable throu stored ticket — reliability is P6/the member-ticket floor, not this list). **Screenshot-verified** (seeded config → 3 recents render with correct ages + fallback). -### P6 — Opt-in discovery — Small -Wire the *discoverable* state to n0 DNS publish (default off, time-boxed). Lookup -path for finding a discoverable friend whose saved address went stale. +### P6 — Opt-in discovery — ✅ DONE 2026-06-16 +Wires the *Discoverable* presence state to n0 DNS publish (default off, 30-min +time-boxed) plus an always-on lookup path. **Decisions (user, 2026-06-16):** 30-min +auto-revert (not sticky); resolver always on in relay-capable modes (the stationary +"looker" is usually in Normal, so lookups must work there). +- **Pure seam (`src/discovery.rs`, +3 tests):** `lookup_plan(network_mode, want_publish) + → LookupPlan { resolver, publisher }` — relay modes always resolve + publish only when + Discoverable; **`DirectOnly` gets neither** (the explicit no-server posture overrides + the toggle). `DISCOVERY_TIMEBOX = 30 min`. +- **iroh edge (`apply_discovery` in `core/mod.rs`):** at runtime, on the bound endpoint, + `clear()` + reinstall the address-lookup services — memory-lookup always, n0 `PkarrResolver` + + `DnsAddressLookup` when `resolver`, `PkarrPublisher` when `publisher`. **No endpoint + rebuild** — toggling publish off drops the publisher (its republish task ends; the + TTL-30s record expires). `build_net_stack` now binds uniformly with `Minimal` + per-mode + relay and calls `apply_discovery` (the old per-mode `presets::N0` build is gone), seeded + by the startup presence mode. +- **Toggle + time-box (core loop):** `SetPresenceMode` re-applies discovery and arms/cancels + a `discovery_deadline`; a `tokio::select!` branch fires at the deadline → revert to Normal + + stop publishing + `UiEvent::PresenceModeReverted` so the GUI mirrors/persists it (status: + "Discoverable timed out — back to Normal"). Re-selecting Discoverable restarts the clock. +- **Verified:** 266 lib tests green, clippy clean (`--all-targets`); runtime smoke-tested — + app binds + runs the new `Minimal`+`apply_discovery` path with no error/panic, both Normal + and Discoverable startup postures (the `PkarrPublisher` build path). ⚠️ **The actual cross- + network publish→lookup (a friend whose saved addr went stale resolving via n0 DNS) and the + live 30-min auto-revert need a 2-machine field test (P7).** ### P7 — Security review + 2-machine field test — Small–Medium Surface: the friends-only listener (confirm non-friends are truly dropped pre-any diff --git a/src/app/mod.rs b/src/app/mod.rs index 199ea5f..6c6ae09 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -735,6 +735,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { UiEvent::FriendPresence { id, presence } => { state.friend_presence.insert(id, presence); } + UiEvent::PresenceModeReverted { mode } => { + // The Discoverable time-box elapsed; core dropped us back to + // `mode` (Normal) and stopped publishing. Mirror + persist so the + // presence picker reflects it, and tell the user why it changed. + state.config.presence_mode = mode; + state.config.save(); + state.status_message = + "Discoverable timed out — back to Normal".to_string(); + } UiEvent::Error(err) => { state.status_message = format!("Error: {}", err); } diff --git a/src/core/messages.rs b/src/core/messages.rs index 434432a..b57b612 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -116,5 +116,11 @@ pub enum UiEvent { /// joinable gathering (with a one-click ticket). Emitted by the outbound ping /// scheduler; absence of a recent event = treat as offline. FriendPresence { id: EndpointId, presence: FriendPresence }, + /// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence + /// posture to the carried `mode` (always `Normal`) and stopped publishing. The + /// GUI must mirror + persist this so its presence picker stops showing + /// Discoverable. Distinct from a user-driven change so the GUI knows to update + /// without having issued the command itself. + PresenceModeReverted { mode: PresenceMode }, Error(String), } diff --git a/src/core/mod.rs b/src/core/mod.rs index b1901c5..04e2de5 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -405,46 +405,73 @@ impl NetStack { } } +/// Install the n0 DNS address-lookup services for a discovery `plan` (W7 P6), at +/// runtime, on an already-bound endpoint. The in-memory lookup (server-free, fed by +/// tickets + gossip) is always re-added; the n0 DNS *resolver* (`PkarrResolver` + +/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the +/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`. +/// +/// Idempotent and reversible: it clears the whole service set and reinstalls exactly +/// what the plan wants, so flipping `publisher` off simply drops the publisher (its +/// republish task ends when the last clone is dropped, and the already-published +/// record TTL-expires within ~30s) without an endpoint rebuild and without disturbing +/// resolution. The brief clear→re-add window is a few synchronous calls; presence +/// toggles are rare, so a concurrent dial racing it is not a practical concern. +fn apply_discovery( + endpoint: &Endpoint, + memory_lookup: &iroh::address_lookup::memory::MemoryLookup, + plan: crate::discovery::LookupPlan, +) -> Result<(), anyhow::Error> { + use iroh::address_lookup::{ + AddressLookupBuilder, dns::DnsAddressLookup, + pkarr::{PkarrPublisher, PkarrResolver}, + }; + let services = endpoint.address_lookup()?; + services.clear(); + // Always keep the local, server-free lookup (this is what ticket/gossip dialing + // depends on — it must survive every posture, including DirectOnly). + services.add(memory_lookup.clone()); + if plan.resolver { + services.add(PkarrResolver::n0_dns().into_address_lookup(endpoint)?); + services.add(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?); + } + if plan.publisher { + services.add(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?); + } + Ok(()) +} + /// Build the persistent network stack for the given identity + relay/discovery /// posture. Binds the endpoint (the per-`NetworkMode` build hoisted out of the old /// per-join path), spawns one gossip instance + one router accepting gossip and /// audio, and kicks off `online()` in the background so app launch isn't blocked on -/// the relay handshake. +/// the relay handshake. `publish` is whether the presence posture is `Discoverable` +/// at build time (W7 P6) — it seeds the initial n0 DNS publish state. async fn build_net_stack( secret_key: SecretKey, network_mode: NetworkMode, memory_lookup: iroh::address_lookup::memory::MemoryLookup, friends_handler: crate::presence_net::Handler, + publish: bool, ) -> Result { - // Build the endpoint per the configured relay/discovery posture. All postures - // keep the in-memory address lookup (fed by tickets and gossip); they differ in - // whether n0's relay and DNS presence beacon are used. `Minimal` sets only the - // mandatory crypto provider and deliberately omits the n0 DNS publish/resolve. - let endpoint = match network_mode { - NetworkMode::N0Full => { - Endpoint::builder(presets::N0) - .secret_key(secret_key.clone()) - .address_lookup(memory_lookup.clone()) - .bind() - .await - } - NetworkMode::RelayNoDiscovery => { - Endpoint::builder(presets::Minimal) - .secret_key(secret_key.clone()) - .relay_mode(RelayMode::Default) - .address_lookup(memory_lookup.clone()) - .bind() - .await - } - NetworkMode::DirectOnly => { - Endpoint::builder(presets::Minimal) - .secret_key(secret_key.clone()) - .relay_mode(RelayMode::Disabled) - .address_lookup(memory_lookup.clone()) - .bind() - .await - } - }?; + // Bind with only the relay posture baked in (`Minimal` = crypto provider only, + // relay on/off per mode); n0 DNS discovery is installed uniformly below via + // `apply_discovery` so the Discoverable publish toggle is independent of the + // network mode and can be flipped later at runtime. The in-memory lookup is added + // here so dialing works even before the first `apply_discovery` (which re-adds it). + let relay_mode = match network_mode { + NetworkMode::DirectOnly => RelayMode::Disabled, + NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => RelayMode::Default, + }; + let endpoint = Endpoint::builder(presets::Minimal) + .secret_key(secret_key.clone()) + .relay_mode(relay_mode) + .address_lookup(memory_lookup.clone()) + .bind() + .await?; + + // Install the n0 DNS services for the current (mode, publish) posture. + apply_discovery(&endpoint, &memory_lookup, crate::discovery::lookup_plan(network_mode, publish))?; // Bring the endpoint online in the background so launch isn't blocked on the // relay handshake; joins/sends just work once it's up (online() is idempotent). @@ -702,7 +729,8 @@ async fn run_core_loop( // it (subscribe a topic + bind the audio router on join, clear on leave); it's // rebuilt only when the network mode or identity changes. Moving `memory_lookup` // in — all later access is via `net.memory_lookup`. - let mut net = match build_net_stack(secret_key.clone(), network_mode, memory_lookup, friends_handler.clone()).await { + let initial_publish = presence_mode.lock().unwrap().publishes_to_discovery(); + let mut net = match build_net_stack(secret_key.clone(), network_mode, memory_lookup, friends_handler.clone(), initial_publish).await { Ok(stack) => stack, Err(e) => { // Only a local socket bind can fail here (the relay handshake is @@ -719,6 +747,11 @@ async fn run_core_loop( // "applies on next join" semantics while keeping the endpoint up while idle. let mut net_rebuild_pending = false; + // When Discoverable is on, the instant it auto-reverts to Normal (W7 P6 time-box). + // `None` = not Discoverable, no pending revert. Set on SetPresenceMode(Discoverable), + // cleared on any other posture, and consumed by the select! branch below. + let mut discovery_deadline: Option = None; + // Tell the GUI the loaded friends list (it renders from this, no longer owning // it). Snapshot under the lock, then release it before the async send. let initial_snapshot = friends.lock().unwrap().list().to_vec(); @@ -758,6 +791,27 @@ async fn run_core_loop( } continue; } + // W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX + // so a publish beacon never stands indefinitely. The branch is disabled + // (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable + // belt-and-braces. On fire: stop publishing, drop to Normal, tell the GUI. + _ = tokio::time::sleep_until( + discovery_deadline.unwrap_or_else(tokio::time::Instant::now), + ), if discovery_deadline.is_some() => { + discovery_deadline = None; + *presence_mode.lock().unwrap() = crate::presence::PresenceMode::Normal; + let plan = crate::discovery::lookup_plan(network_mode, false); + if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) { + crate::log_msg(&format!("discovery: time-box revert failed: {e:#}")); + } + crate::log_msg("discovery: Discoverable time-box elapsed → reverting to Normal"); + let _ = ui_tx + .send(UiEvent::PresenceModeReverted { + mode: crate::presence::PresenceMode::Normal, + }) + .await; + continue; + } }; match cmd { CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => { @@ -782,7 +836,8 @@ async fn run_core_loop( if net_rebuild_pending { let lookup = net.memory_lookup.clone(); net.shutdown().await; - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; + let publish = presence_mode.lock().unwrap().publishes_to_discovery(); + net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; net_rebuild_pending = false; } @@ -1410,7 +1465,8 @@ async fn run_core_loop( if net_rebuild_pending { let lookup = net.memory_lookup.clone(); net.shutdown().await; - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; + let publish = presence_mode.lock().unwrap().publishes_to_discovery(); + net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; net_rebuild_pending = false; } } @@ -1523,7 +1579,8 @@ async fn run_core_loop( if active_session.is_none() { let lookup = net.memory_lookup.clone(); net.shutdown().await; - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; + let publish = presence_mode.lock().unwrap().publishes_to_discovery(); + net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; } else { net_rebuild_pending = true; } @@ -1555,7 +1612,8 @@ async fn run_core_loop( if active_session.is_none() { let lookup = net.memory_lookup.clone(); net.shutdown().await; - net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; + let publish = presence_mode.lock().unwrap().publishes_to_discovery(); + net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; } else { net_rebuild_pending = true; } @@ -1592,6 +1650,21 @@ async fn run_core_loop( CoreCommand::SetPresenceMode(mode) => { *presence_mode.lock().unwrap() = mode; + // W7 P6: re-apply n0 DNS discovery for the new posture (publish on iff + // Discoverable). Runtime — no endpoint rebuild; clears + reinstalls the + // address-lookup services. The resolver stays on regardless so we can + // still look up moved friends. + let plan = crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery()); + if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) { + crate::log_msg(&format!("discovery: apply failed: {e:#}")); + } + // Arm (Discoverable) or cancel (any other posture) the auto-revert + // time-box. Re-selecting Discoverable restarts the clock. + discovery_deadline = if mode == crate::presence::PresenceMode::Discoverable { + Some(tokio::time::Instant::now() + crate::discovery::DISCOVERY_TIMEBOX) + } else { + None + }; } CoreCommand::SetRecordingMode(mode) => { diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..d87c00b --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,95 @@ +//! Opt-in discovery posture (W7 P6) — the pure policy that turns a (network mode, +//! "want to be Discoverable?") pair into *which* n0 DNS address-lookup services to +//! run, plus the Discoverable time-box. The actual iroh wiring (clearing and +//! reinstalling the endpoint's address-lookup services) lives in `core`; this module +//! is the small, testable decision surface so the policy can be unit-tested without +//! standing up an endpoint. +//! +//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16): +//! - **Resolving is always allowed on relay-capable modes** — a stationary friend +//! (typically in `Normal`) must be able to look up a friend who moved networks. A +//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved +//! address is stale and the dial falls through to discovery. +//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover +//! publishes their address to n0 DNS; everyone else just looks it up. +//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish +//! ever touches n0 there, regardless of the Discoverable toggle. + +use crate::config::NetworkMode; +use std::time::Duration; + +/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is +/// a publish-to-n0 beacon, so it auto-expires rather than standing indefinitely; the +/// user can re-toggle to restart the clock. (Open decision #3 settled: 30-minute +/// time-box, not sticky-until-off.) +pub const DISCOVERY_TIMEBOX: Duration = Duration::from_secs(30 * 60); + +/// Which n0 DNS address-lookup services to install for a posture. The in-memory +/// lookup (fed by tickets + gossip, fully server-free) is always present and is +/// deliberately *not* represented here — these flags are only about n0 DNS. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LookupPlan { + /// Resolve a moved friend's address by their (stable) node id via n0 DNS. A + /// passive query — publishes nothing — and only fires when a saved address is + /// stale enough that the dial falls through to discovery. + pub resolver: bool, + /// Publish *our* current address to n0 DNS so friends can find us after we moved + /// networks. The one privacy-costly bit; on only while `Discoverable`. + pub publisher: bool, +} + +/// The n0 DNS services wanted for a `(network_mode, want_publish)` pair. Pure. +/// +/// `want_publish` is `true` exactly when the presence posture is `Discoverable` +/// (see [`crate::presence::PresenceMode::publishes_to_discovery`]). +pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan { + match network_mode { + // The explicit serverless posture: no n0 contact at all, even to resolve. + // A Discoverable toggle here is intentionally inert. + NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false }, + // Relay-capable: always resolve (so a stationary friend can find a mover); + // publish only when the user opted into Discoverable. + NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => { + LookupPlan { resolver: true, publisher: want_publish } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_modes_always_resolve_and_publish_only_when_wanted() { + for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] { + assert_eq!( + lookup_plan(mode, false), + LookupPlan { resolver: true, publisher: false }, + "{mode:?}: resolve always on, no publish when not Discoverable" + ); + assert_eq!( + lookup_plan(mode, true), + LookupPlan { resolver: true, publisher: true }, + "{mode:?}: Discoverable adds publish on top of resolve" + ); + } + } + + #[test] + fn direct_only_never_touches_n0_even_when_discoverable() { + assert_eq!( + lookup_plan(NetworkMode::DirectOnly, false), + LookupPlan { resolver: false, publisher: false } + ); + // The serverless posture overrides the Discoverable request entirely. + assert_eq!( + lookup_plan(NetworkMode::DirectOnly, true), + LookupPlan { resolver: false, publisher: false } + ); + } + + #[test] + fn timebox_is_thirty_minutes() { + assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5d63743..6f5e277 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod screenshare; pub mod sanitize; pub mod avatar; pub mod recents; +pub mod discovery; use std::path::PathBuf; use std::sync::OnceLock;