From d6bb391027eb07293a6eb071f50a4f6da48d8650 Mon Sep 17 00:00:00 2001 From: bfieser Date: Fri, 6 Nov 2026 10:47:25 -0600 Subject: [PATCH 1/6] Say why home-network sync can't find a linked machine (#216) When Home network sync can't find a linked machine, the Sync & backup line now names it and, when this machine can tell, why: a VPN or virtual adapter carrying the broadcasts, no network, Windows calling the network Public, macOS Local Network access off, another Patch Pilot version, or another rig's Patch Pilot on the network. It no longer says "In sync" while nobody is here. The details list each machine and a checklist. - patchpilot_local::lan_diagnosis: pure diagnose() over gathered facts, adapter classification (name and Windows description), egress kind, bind error wording; tested with hand-built facts. - patchpilot_local::netinfo: read-only probes (UDP connect + local_addr, sysinfo interfaces, PowerShell adapter description and profile with a 5 s deadline at below-normal priority). - lan::read tells this rig, another version, another rig and noise apart. - sync.rs keeps the relay's list of linked machines, so machines added from an export file or removed never show as missing. - lan.rs gathers facts on the existing 10 s beacon thread, looks at the network only while a machine is missing and none is here, and tells the UI only when the diagnosis changes. Decision #123 (amends #94). Co-Authored-By: Claude Opus 5.5 --- app/CHANGELOG.md | 6 + app/src-tauri/src/lan.rs | 293 ++++++++++- app/src-tauri/src/sync.rs | 43 +- app/src/Backup.tsx | 111 +++- app/src/api.ts | 25 +- crates/local/Cargo.toml | 5 +- crates/local/src/lan.rs | 75 ++- crates/local/src/lan_diagnosis.rs | 812 ++++++++++++++++++++++++++++++ crates/local/src/lib.rs | 2 + crates/local/src/netinfo.rs | 187 +++++++ docs/decisions.md | 1 + 11 files changed, 1532 insertions(+), 28 deletions(-) create mode 100644 crates/local/src/lan_diagnosis.rs create mode 100644 crates/local/src/netinfo.rs diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 7c138ba..bbddda1 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -4,6 +4,12 @@ What changed in each version of the Patch Pilot app. The section for a version becomes its release notes, which the app shows when it offers that update (see `docs/releasing.md`). Write for users, not developers. +## Unreleased + +### Improved + +- When you sync over your home network and Patch Pilot can't find one of your linked machines, it now says which one and, when it can tell, why: a VPN or virtual network adapter is carrying its messages, Windows has the network set to Public, your Mac isn't letting it use the local network, this machine isn't on a network, or the other machine runs a different Patch Pilot version. Open Sync & backup to see each machine and a short list of what to check. It no longer says "In sync" while none of your other machines are on the network. + ## 0.4.56 - 2026-11-01 ### Fixed diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index 09beb4d..b5c98e7 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -9,6 +9,12 @@ //! The sockets open the first time the home network is picked (or at launch //! when it already is) and stay open until Patch Pilot quits; while another //! way of syncing is picked they send nothing and answer nobody. +//! +//! When a linked machine can't be found, the same 10 s loop works out why +//! (decision #123): `patchpilot_local::lan_diagnosis` decides from facts +//! gathered here, and `patchpilot_local::netinfo` looks at the network only +//! while a machine is missing, so an idle machine with everyone found does +//! no extra work. use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}; @@ -18,9 +24,11 @@ use std::time::{Duration, Instant}; use patchpilot_local::backup::{self, BackupSettings}; use patchpilot_local::github::FileIndex; -use patchpilot_local::lan::{self, Beacon, Connection, Progress, PullReport}; +use patchpilot_local::lan::{self, Beacon, Connection, Heard, Progress, PullReport}; +use patchpilot_local::lan_diagnosis::{self, Diagnosis, Egress, Expected, Facts, Profile, Seen}; +use patchpilot_local::netinfo::{self, AdapterInfo}; use serde::Serialize; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Listener, Manager}; use crate::backup::BACKUP_CHANGED; use crate::sync::{app_dir, load_rig, this_machine}; @@ -34,6 +42,10 @@ const GONE: Duration = Duration::from_secs(45); const RETRY: Duration = Duration::from_secs(60); /// Linked machines answered at once; more wait to connect. const MAX_CONNECTIONS: usize = 8; +/// While a machine is missing, look at this machine's network this often. +const NET_EVERY: Duration = Duration::from_secs(30); +/// Ask Windows about an adapter (PowerShell, about a second) this often. +const ADAPTER_EVERY: Duration = Duration::from_secs(600); /// Emitted with a [`Progress`] while a pull runs, then with null. pub const LAN_PROGRESS: &str = "lan-progress"; @@ -62,8 +74,36 @@ struct LanState { connections: Arc, /// Why the network can't be used, or the last pull didn't finish. error: Mutex>, + /// The machine the error is about, when it's a pull's: once that + /// machine is gone, the error goes too. + error_from: Mutex>, progress: Mutex>, last: Mutex>, + /// Since when this machine has listened, for "Looking for…" first. + listening_since: Mutex>, + /// When a beacon of this rig came in that this version can't read. + other_version: Mutex>, + /// When a Patch Pilot beacon under another rig key came in. + other_rig: Mutex>, + /// The OS error of the last broadcast that didn't go out. + broadcast_error: Mutex>, + /// Every known machine's name by id, from the store. + names: Mutex>, + net: Mutex, + /// The diagnosis last told to the UI. + diagnosis: Mutex>, +} + +/// This machine's network as last looked at, while a machine was missing. +#[derive(Default, Clone)] +struct NetCache { + checked: Option, + broadcast_egress: Option, + route_egress: Option, + has_lan: Option, + profile: Option, + /// What Windows said about each adapter, by name, and when. + adapters: HashMap)>, } /// Mirrored in `src/api.ts`. @@ -78,7 +118,7 @@ pub struct LastPull { pub struct LanPeer { pub machine_name: String, pub seen_at: i64, - /// Heard from in the last minute or so. + /// Heard from in the last 45 s. pub here: bool, } @@ -90,6 +130,8 @@ pub struct LanView { pub error: Option, pub progress: Option, pub last: Option, + /// Who's here, who's missing and why (decision #123). + pub diagnosis: Diagnosis, } pub fn setup(app: &AppHandle) { @@ -97,18 +139,38 @@ pub fn setup(app: &AppHandle) { if let Ok(dir) = app_dir() { *app.state::().index.lock().unwrap() = lan::load_index(&dir); } + // Machines' names, for "Can't find Brian's Laptop": a new machine's scan + // arrives over the link as `data-changed`. + let handle = app.clone(); + app.listen_any("data-changed", move |_| { + let handle = handle.clone(); + std::thread::spawn(move || refresh_names(&handle)); + }); let handle = app.clone(); std::thread::spawn(move || { + refresh_names(&handle); loop { if in_use() { start(&handle); announce(&handle, None); + check(&handle); } std::thread::sleep(BEACON_EVERY); } }); } +/// Every known machine's name by id, from the store. +fn refresh_names(app: &AppHandle) { + let Ok(Ok(store)) = crate::rig::db_path().map(|db| crate::rig::open(&db)) else { + return; + }; + if let Ok(machines) = store.machines() { + *app.state::().names.lock().unwrap() = + machines.into_iter().map(|m| (m.id, m.name)).collect(); + } +} + /// The backup folder is the home-network copy. pub fn in_use() -> bool { app_dir().is_ok_and(|d| uses_lan(&backup::load_settings(&d))) @@ -131,9 +193,10 @@ fn start(app: &AppHandle) { Err(e) => { set_error( app, - Some(format!( - "Can't listen for your other machines: another app is using network port {} ({e}).", - lan::PORT + Some(lan_diagnosis::bind_error_text( + e.kind(), + e.raw_os_error(), + &e.to_string(), )), ); state.started.store(false, Ordering::SeqCst); @@ -158,6 +221,11 @@ fn start(app: &AppHandle) { }; *state.port.lock().unwrap() = tcp.local_addr().ok().map(|a| a.port()); *state.udp.lock().unwrap() = Some(udp.clone()); + state + .listening_since + .lock() + .unwrap() + .get_or_insert_with(Instant::now); let handle = app.clone(); std::thread::spawn(move || { @@ -173,8 +241,12 @@ fn start(app: &AppHandle) { let Ok(Some(rig)) = load_rig() else { continue; }; - if let Some(beacon) = lan::read_beacon(&rig, &buf[..n]) { - heard(&handle, beacon, from.ip()); + let state = handle.state::(); + match lan::read(&rig, &buf[..n]) { + Heard::Ours(beacon) => heard(&handle, beacon, from.ip()), + Heard::OtherVersion => *state.other_version.lock().unwrap() = Some(Instant::now()), + Heard::OtherRig => *state.other_rig.lock().unwrap() = Some(Instant::now()), + Heard::Noise => {} } } }); @@ -242,8 +314,14 @@ fn announce(app: &AppHandle, to: Option) { }; targets.sort(); targets.dedup(); + let broadcast = IpAddr::V4(Ipv4Addr::BROADCAST); for ip in targets { - let _ = udp.send_to(&bytes, (ip, lan::PORT)); + let sent = udp.send_to(&bytes, (ip, lan::PORT)); + // Only the broadcast says something about this machine's network: a + // machine heard before that left answers "unreachable" too. + if ip == broadcast { + *state.broadcast_error.lock().unwrap() = sent.err().and_then(|e| e.raw_os_error()); + } } } @@ -407,6 +485,7 @@ fn pull_from(app: &AppHandle, id: &str, wait: bool) -> Result { .err() .map(|e| format!("Couldn't copy from {name}: {e}")), ); + *state.error_from.lock().unwrap() = result.is_err().then(|| id.to_owned()); let changed = result.as_ref().is_ok_and(|r| r.copied + r.removed > 0); if let (true, Ok(r)) = (changed, &result) { *state.last.lock().unwrap() = Some(LastPull { @@ -421,9 +500,189 @@ fn pull_from(app: &AppHandle, id: &str, wait: bool) -> Result { } fn set_error(app: &AppHandle, error: Option) { - if let Ok(mut e) = app.state::().error.lock() { + let state = app.state::(); + if let Ok(mut e) = state.error.lock() { *e = error; } + if let Ok(mut from) = state.error_from.lock() { + *from = None; + } +} + +/// Every beacon: drop a pull error about a machine that's gone, look at the +/// network if a machine is missing, and tell the UI when what it shows +/// would change. No store, no command, nothing at all while everyone's here. +fn check(app: &AppHandle) { + let state = app.state::(); + if clear_stale_error(&state) { + let _ = app.emit(BACKUP_CHANGED, ()); + } + let mut d = lan_diagnosis::diagnose(&facts(app)); + let net_due = state + .net + .lock() + .unwrap() + .checked + .is_none_or(|t| t.elapsed() >= NET_EVERY); + // Only while nobody is here: otherwise this machine's network works, + // and diagnose doesn't look at it. + if !d.missing.is_empty() && d.here.is_empty() && !d.looking && net_due { + look_at_network(&state); + d = lan_diagnosis::diagnose(&facts(app)); + } + let changed = { + let mut last = state.diagnosis.lock().unwrap(); + let changed = last.as_ref() != Some(&d); + *last = Some(d); + changed + }; + if changed { + let _ = app.emit(BACKUP_CHANGED, ()); + } +} + +/// Drop a pull error about a machine that has left. The check and the clear +/// happen under the error's own lock, so a newer error from another machine +/// that lands meanwhile stays. +fn clear_stale_error(state: &LanState) -> bool { + let Ok(mut from) = state.error_from.lock() else { + return false; + }; + let gone = from.as_ref().is_some_and(|id| { + state + .peers + .lock() + .unwrap() + .get(id) + .is_none_or(|p| p.seen.elapsed() >= GONE) + }); + if gone { + *from = None; + if let Ok(mut e) = state.error.lock() { + *e = None; + } + } + gone +} + +/// Which way this machine's packets leave, and what Windows calls the +/// network. At background priority: it can run PowerShell. +fn look_at_network(state: &LanState) { + let _quiet = patchpilot_local::quiet::background(); + let mut net = state.net.lock().unwrap().clone(); + let mut ifaces = netinfo::interfaces(); + let broadcast = netinfo::broadcast_source(); + let route = netinfo::route_source(); + // What Windows says about the adapters the packets leave on, since many + // VPNs call theirs "Ethernet 4" and say what they are only there. + let mut broadcast_info = None; + for (src, is_broadcast) in [(broadcast, true), (route, false)] { + let Some(name) = src + .and_then(|ip| netinfo::interface_of(ip, &ifaces)) + .map(str::to_owned) + else { + continue; + }; + let fresh = net + .adapters + .get(&name) + .filter(|(at, _)| at.elapsed() < ADAPTER_EVERY) + .map(|(_, info)| info.clone()); + let info = fresh.unwrap_or_else(|| { + let info = netinfo::adapter_info(&name); + net.adapters + .insert(name.clone(), (Instant::now(), info.clone())); + info + }); + if let Some(i) = ifaces.iter_mut().find(|i| i.name == name) { + i.description = info.as_ref().and_then(|a| a.description.clone()); + } + if is_broadcast { + broadcast_info = info; + } + } + net.broadcast_egress = broadcast.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); + net.route_egress = route.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); + // Windows lists no adapter at all when none is connected; with no route + // anywhere either, this machine isn't on a network. + net.has_lan = if !ifaces.is_empty() { + Some(lan_diagnosis::has_lan(&ifaces, route)) + } else if broadcast.is_none() && route.is_none() { + Some(false) + } else { + None + }; + // The profile only matters for the home network itself. + net.profile = match net.broadcast_egress { + Some(Egress::Lan) => broadcast_info.and_then(|a| a.profile), + _ => None, + }; + net.checked = Some(Instant::now()); + *state.net.lock().unwrap() = net; +} + +/// What [`lan_diagnosis::diagnose`] goes on, from what's already known: +/// quick, so the UI can ask any time. +fn facts(app: &AppHandle) -> Facts { + let state = app.state::(); + let recent = + |slot: &Mutex>| slot.lock().unwrap().is_some_and(|t| t.elapsed() < GONE); + let seen: Vec = state + .peers + .lock() + .unwrap() + .iter() + .map(|(id, p)| Seen { + id: id.clone(), + name: p.name.clone(), + here: p.seen.elapsed() < GONE, + seen_at: p.seen_at, + }) + .collect(); + let net = state.net.lock().unwrap().clone(); + Facts { + listening_for: state + .listening_since + .lock() + .unwrap() + .map_or(Duration::ZERO, |t| t.elapsed()), + expected: expected(app), + seen, + other_version: recent(&state.other_version), + other_rig: recent(&state.other_rig), + broadcast_error: *state.broadcast_error.lock().unwrap(), + broadcast_egress: net.broadcast_egress, + route_egress: net.route_egress, + has_lan: net.has_lan, + profile: net.profile, + macos: cfg!(target_os = "macos"), + } +} + +/// The machines this one should find: the ones the relay lists as linked +/// (machines added from an export file never run Patch Pilot with this rig) +/// that are still in the store (a removed machine stays on the relay). +/// Before the first listing, every other machine in the store. None at all +/// while this machine isn't linked. +fn expected(app: &AppHandle) -> Vec { + if !matches!(load_rig(), Ok(Some(_))) { + return Vec::new(); + } + let names = app.state::().names.lock().unwrap().clone(); + let this = this_machine().ok(); + let ids = crate::sync::linked_machines(app).unwrap_or_else(|| names.keys().cloned().collect()); + let mut out: Vec = ids + .into_iter() + .filter(|id| Some(id) != this.as_ref()) + .filter_map(|id| { + Some(Expected { + name: names.get(&id)?.clone(), + id, + }) + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out } /// For the Sync & backup line, while the home network is in use. @@ -449,6 +708,7 @@ pub fn view(app: &AppHandle) -> Option { error: state.error.lock().unwrap().clone(), progress: state.progress.lock().unwrap().clone(), last: state.last.lock().unwrap().clone(), + diagnosis: lan_diagnosis::diagnose(&facts(app)), }) } @@ -470,6 +730,19 @@ pub async fn lan_use(app: AppHandle) -> Result<(), String> { }; backup::save_settings(&dir, &settings)?; set_error(&app, None); + // Picking the home network (again) starts the "Looking for…" time over. + let state = app.state::(); + *state.listening_since.lock().unwrap() = Some(Instant::now()); + *state.diagnosis.lock().unwrap() = None; + { + // Look at the network afresh; what Windows said about each adapter + // keeps. + let mut net = state.net.lock().unwrap(); + *net = NetCache { + adapters: std::mem::take(&mut net.adapters), + ..NetCache::default() + }; + } start(&app); let _ = app.emit(BACKUP_CHANGED, ()); // Get the other machines' backups first, then make this one's. diff --git a/app/src-tauri/src/sync.rs b/app/src-tauri/src/sync.rs index 8c9f8d3..94d3d47 100644 --- a/app/src-tauri/src/sync.rs +++ b/app/src-tauri/src/sync.rs @@ -48,6 +48,9 @@ pub struct SyncStatus { pub struct Sync { status: Mutex, on_relay: Mutex, + /// Ids of the other machines on the relay: the ones actually linked, + /// unlike machines added from an export file. None until listed. + linked: Mutex>>, running: AtomicBool, nudge: Notify, stop: Notify, @@ -144,10 +147,24 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri upload(&relay, state, &this, rig).await?; let mut ws = relay.live().await?; let mut changed = false; - for o in relay.list("machine/").await? { - if patchpilot_local::sync::is_other_machine(&o.name, &this) { - changed |= pull(&relay, &this, rig, &o.name).await?; - } + let others: Vec = relay + .list("machine/") + .await? + .into_iter() + .map(|o| o.name) + .filter(|name| patchpilot_local::sync::is_other_machine(name, &this)) + .collect(); + // Known before pulling, so one object that won't open doesn't hide which + // machines are linked. + *state.linked.lock().expect("linked lock") = Some( + others + .iter() + .filter_map(|name| name.strip_prefix("machine/")) + .map(str::to_owned) + .collect(), + ); + for name in &others { + changed |= pull(&relay, &this, rig, name).await?; } if changed { let _ = app.emit("data-changed", ()); @@ -170,9 +187,14 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri let Ok(event) = serde_json::from_str::(&text) else { continue }; if event.kind == "changed" && patchpilot_local::sync::is_other_machine(&event.name, &this) - && pull(&relay, &this, rig, &event.name).await? { - let _ = app.emit("data-changed", ()); + if let Some(id) = event.name.strip_prefix("machine/") { + let mut linked = state.linked.lock().expect("linked lock"); + linked.get_or_insert_default().insert(id.to_owned()); + } + if pull(&relay, &this, rig, &event.name).await? { + let _ = app.emit("data-changed", ()); + } } } () = state.nudge.notified() => upload(&relay, state, &this, rig).await?, @@ -292,6 +314,14 @@ fn set(app: &AppHandle, state: &Sync, f: impl FnOnce(&mut SyncStatus)) { let _ = app.emit("sync-status", status); } +/// The ids of the other machines linked to this one, as the relay last +/// listed them; None before the first listing this run (or after unlinking). +pub fn linked_machines(app: &AppHandle) -> Option> { + let state = app.state::(); + let linked = state.linked.lock().expect("linked lock"); + linked.as_ref().map(|l| l.iter().cloned().collect()) +} + #[tauri::command] pub fn sync_status(state: State<'_, SyncState>) -> SyncStatus { state.status.lock().expect("sync status lock").clone() @@ -354,6 +384,7 @@ pub async fn link_join( #[tauri::command] pub fn unlink(state: State<'_, SyncState>) -> Result<(), String> { patchpilot_local::sync::forget_rig(&app_dir()?)?; + *state.linked.lock().expect("linked lock") = None; state.stop.notify_waiters(); Ok(()) } diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index 085bd88..acbc567 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -28,6 +28,7 @@ import { type BackupView, type FileToBring, type GitHubProgress, + type LanCause, type LanProgress, type LanView, type RestoreReport, @@ -221,6 +222,35 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan `Nothing was taken from ${who}'s latest backup: it isn't signed by your linked machines. If you reinstalled an older Patch Pilot there, update it.`, ]; } + // Home network (decision #123): never "in sync" while nobody is here, and + // a machine that's missing while another is here is named after the line. + const d = lan?.diagnosis; + if (d && d.missing.length > 0) { + const who = andList(d.missing.map((m) => m.name)); + const isnt = d.missing.length === 1 ? "isn't" : "aren't"; + if (d.looking) + return d.here.length > 0 + ? withNote(filesLine(sync, where, github, lan), `looking for ${who}…`) + : ["accent", `Looking for ${who} on this network…`]; + if (d.cause) return ["warn", `Can't find ${who} on this network: ${lanCauseText(d.cause)}`]; + if (d.here.length > 0) return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); + if (d.missing.every((m) => m.seen_at)) { + const latest = Math.max(...d.missing.map((m) => m.seen_at ?? 0)); + return ["accent", `${who} ${isnt} on this network now (seen ${timeAgo(latest)}).`]; + } + return ["warn", `Can't find ${who} on this network. Show what to check.`]; + } + return filesLine(sync, where, github, lan); +} + +/** A line with a note after it: "In sync with Desktop … · Laptop isn't on this network now." */ +function withNote([tone, text]: [Tone, string], note: string): [Tone, string] { + return [tone, `${text.replace(/[.…]$/u, "")} · ${note}`]; +} + +/** What the other machines' backups brought, or why nothing came yet. */ +function filesLine(sync: SyncView, where: string, github: string | null, lan: LanView | null): [Tone, string] { + const last = sync.last; const sources = (last?.sources ?? []).filter((s) => s.linked && !s.unsigned); if (!last) return ["ok", `In sync through ${where}. Checking your other machines' files…`]; if (sources.length === 0 && lan && !lan.peers.some((p) => p.here)) @@ -244,6 +274,79 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan return ["ok", `In sync with ${names(() => true)} through ${where}, checked ${timeAgo(last.looked_at)}.`]; } +/** What a home-network cause means and what to do, after "Can't find X on this network: ". */ +function lanCauseText(c: LanCause): string { + switch (c.kind) { + case "other_version": + return "a Patch Pilot on another version is on this network. Update Patch Pilot on all your machines."; + case "no_lan": + return "this machine isn't connected to Wi-Fi or a wired network."; + case "vpn": + return `a VPN${c.adapter ? ` (${c.adapter})` : ""} is on, so Patch Pilot's messages go through it instead of your home network. Turn it off, or let it allow local network access.`; + case "virtual_adapter": + return `Patch Pilot's messages go to a virtual network on this machine (${c.name}) instead of your home network.`; + case "local_network_denied": + return "macOS isn't letting Patch Pilot use the local network. Turn it on in System Settings › Privacy & Security › Local Network."; + case "public_network": + return "Windows has this network set to Public, so its firewall keeps your other machines out. Set it to Private in Settings › Network & internet."; + case "other_rig": + return "a Patch Pilot that isn't linked to your machines is on this network. If it's yours, link it again from Machines linked."; + case "vpn_on": + return `a VPN${c.adapter ? ` (${c.adapter})` : ""} is on. If it blocks local network traffic, turn it off or let it allow local network access.`; + } +} + +/** "A", "A and B", "A, B and C". */ +function andList(items: string[]): string { + return items.length <= 1 ? (items[0] ?? "") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; +} + +/** Each linked machine on the home network, and what to check while one can't be found. */ +function LanMachines({ lan }: { lan: LanView }) { + const d = lan.diagnosis; + if (d.here.length === 0 && d.missing.length === 0) return null; + const cause = d.cause?.kind; + const items: [string, boolean][] = [ + ["Both machines are on the same Wi-Fi or wired network.", false], + ["No VPN is on, or it allows local network access.", cause === "vpn" || cause === "vpn_on"], + ["On Windows, the network is set to Private (Settings › Network & internet).", cause === "public_network"], + ["On Windows, Patch Pilot is allowed through Windows Firewall on private networks.", false], + ["On a Mac, Patch Pilot is on in System Settings › Privacy & Security › Local Network.", cause === "local_network_denied"], + ["Patch Pilot is open on the other machine, with Home network picked under Sync & backup.", false], + ["Both machines run the same Patch Pilot version.", cause === "other_version"], + ]; + return ( +
+ {d.here.map((n, i) => ( +

+ {n} · on this network +

+ ))} + {d.missing.map((m, i) => ( +

+ {m.name} ·{" "} + {m.seen_at ? `last on this network ${timeAgo(m.seen_at)}` : d.looking ? "looking…" : "not found on this network"} +

+ ))} + {d.missing.length > 0 && !d.looking && ( + <> + {/* The line above is cut to one line; the reason is here in full. */} + {d.cause &&

Likely reason: {lanCauseText(d.cause)}

} +

Can't find a machine? Check that:

+
    + {items.map(([text, likely]) => ( +
  • + {text} + {likely && " This looks like the reason."} +
  • + ))} +
+ + )} +
+ ); +} + /** How far a long GitHub sync has got, while one runs. */ function useGitHubProgress(view: BackupView | null) { const [progress, setProgress] = useState(null); @@ -534,7 +637,7 @@ function Details({ className="min-w-0 flex-1 truncate" title={`Linked machines on this network copy files straight from each other, encrypted. This machine's copy: ${view.dir}`} > - Home network{view.lan.peers.length > 0 ? ` · ${view.lan.peers.map(peerText).join(", ")}` : ""} + Home network ) : ( @@ -562,6 +665,7 @@ function Details({ )} + {view.lan && } {last && (

{last.unchanged @@ -1002,11 +1106,6 @@ function placeName(view: BackupView): string { return f.split(/[\\/]/).filter(Boolean).pop() ?? "your folder"; } -/** "MacBook" while it's on this network, "MacBook (seen 2 h ago)" after. */ -function peerText(p: LanView["peers"][number]): string { - return p.here ? p.machine_name : `${p.machine_name} (seen ${timeAgo(p.seen_at)})`; -} - function when(unixSeconds: number): string { const s = Date.now() / 1000 - unixSeconds; if (s < 86400) return timeAgo(unixSeconds); diff --git a/app/src/api.ts b/app/src/api.ts index 31a1ccc..575092c 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -569,16 +569,39 @@ export interface LanProgress { export interface LanPeer { machine_name: string; seen_at: number; - /** Heard from in the last minute or so. */ + /** Heard from in the last 45 s. */ here: boolean; } +/** Why a linked machine can't be found (decision #123); mirrors patchpilot_local::lan_diagnosis::Cause. */ +export type LanCause = + | { kind: "other_version" } + | { kind: "no_lan" } + | { kind: "vpn"; adapter?: string } + | { kind: "virtual_adapter"; name: string } + | { kind: "local_network_denied" } + | { kind: "public_network" } + | { kind: "other_rig" } + | { kind: "vpn_on"; adapter?: string }; + +/** Who's on this network, who's missing and why; mirrors patchpilot_local::lan_diagnosis::Diagnosis. */ +export interface LanDiagnosis { + /** Linked machines on this network now, by name. */ + here: string[]; + /** Linked machines not on this network now; `seen_at` if heard since launch. */ + missing: { name: string; seen_at?: number }[]; + /** Still in the first 30 s: "Looking for…", not "Can't find…". */ + looking: boolean; + cause?: LanCause; +} + export interface LanView { /** Linked machines heard on this network, most recent first. */ peers: LanPeer[]; error: string | null; progress: LanProgress | null; last: { at: number; report: { machines: string[]; copied: number; removed: number } } | null; + diagnosis: LanDiagnosis; } export const lanUse = () => invoke("lan_use"); diff --git a/crates/local/Cargo.toml b/crates/local/Cargo.toml index e47c74b..0c9c0d0 100644 --- a/crates/local/Cargo.toml +++ b/crates/local/Cargo.toml @@ -12,8 +12,9 @@ blake3 = "1.8.7" flate2 = "1.1.10" # Drive free space without unsafe code (decision #101). fs4 = "1.1.0" -# Every local drive, with Windows labels and Finder's free space (decision #102). -sysinfo = { version = "0.38.4", default-features = false, features = ["disk"] } +# Every local drive, with Windows labels and Finder's free space (decision #102), +# and network interfaces for "why can't it find my other machine" (decision #123). +sysinfo = { version = "0.38.4", default-features = false, features = ["disk", "network"] } notify = "8.2.0" patchpilot-catalog = { path = "../catalog" } patchpilot-core = { path = "../core" } diff --git a/crates/local/src/lan.rs b/crates/local/src/lan.rs index 93d8118..da55aa9 100644 --- a/crates/local/src/lan.rs +++ b/crates/local/src/lan.rs @@ -5,7 +5,7 @@ //! leaves the network. Backup, restore and keeping files in sync work on the //! copy exactly as on a cloud folder; only how files travel changes. //! -//! How machines find each other: every few seconds each one broadcasts a +//! How machines find each other: every 10 s each one broadcasts a //! small beacon on UDP [`PORT`], sealed with the rig key, saying who it is, //! which TCP port it answers on and a short [`state`] of its copy. A machine //! that hears a linked machine's beacon with a state it hasn't pulled asks @@ -91,10 +91,49 @@ pub fn beacon_bytes(key: &RigKey, beacon: &Beacon) -> Vec { out } +/// What came in on the beacon port (decision #123). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Heard { + /// A linked machine's beacon. + Ours(Beacon), + /// This rig's beacon, or a newer envelope, that this version can't + /// read: Patch Pilot differs between the machines. + OtherVersion, + /// A Patch Pilot beacon sealed under another rig key. + OtherRig, + /// Anything else. + Noise, +} + +/// A sealed envelope's version byte, nonce and tag: nothing shorter is one. +const SEALED_MIN: usize = 1 + 24 + 16; +/// The envelope version this build seals with (`patchpilot_sync::envelope`). +const ENVELOPE_VERSION: u8 = 1; + +/// Tell a linked machine's beacon from another version's, another rig's +/// and noise. +pub fn read(key: &RigKey, bytes: &[u8]) -> Heard { + let Some(sealed) = bytes.strip_prefix(MAGIC) else { + return Heard::Noise; + }; + if sealed.len() < SEALED_MIN { + return Heard::Noise; + } + if sealed[0] > ENVELOPE_VERSION { + return Heard::OtherVersion; + } + match key.open_lan("beacon", sealed) { + Ok(json) => serde_json::from_slice(&json).map_or(Heard::OtherVersion, Heard::Ours), + Err(_) => Heard::OtherRig, + } +} + /// A linked machine's beacon; `None` for anything else on the port. pub fn read_beacon(key: &RigKey, bytes: &[u8]) -> Option { - let sealed = bytes.strip_prefix(MAGIC)?; - serde_json::from_slice(&key.open_lan("beacon", sealed).ok()?).ok() + match read(key, bytes) { + Heard::Ours(b) => Some(b), + _ => None, + } } /// The copy in a few characters: it changes whenever any folder gets a new @@ -637,6 +676,36 @@ mod tests { assert_eq!(read_beacon(&rig, b"PPLAN1junk"), None); } + #[test] + fn beacons_tell_another_rig_and_another_version_from_noise() { + let rig = RigKey::generate(); + let b = Beacon { + machine_id: "pc".into(), + machine_name: "Studio PC".into(), + port: PORT, + state: "abc".into(), + }; + let bytes = beacon_bytes(&rig, &b); + assert_eq!(read(&rig, &bytes), Heard::Ours(b)); + assert_eq!(read(&RigKey::generate(), &bytes), Heard::OtherRig); + assert_eq!( + read(&rig, b"PPLAN1junk"), + Heard::Noise, + "too short to be sealed" + ); + assert_eq!(read(&rig, b"hello"), Heard::Noise); + assert_eq!(read(&rig, b""), Heard::Noise); + + // Sealed by this rig, but not a beacon this version understands. + let mut odd = MAGIC.to_vec(); + odd.extend(rig.seal_lan("beacon", br#"{"machine_id":"pc","v":2}"#)); + assert_eq!(read(&rig, &odd), Heard::OtherVersion); + // A newer envelope. + let mut newer = bytes.clone(); + newer[MAGIC.len()] = ENVELOPE_VERSION + 1; + assert_eq!(read(&rig, &newer), Heard::OtherVersion); + } + #[test] fn pulls_over_a_real_connection_and_strangers_get_nothing() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs new file mode 100644 index 0000000..1359e84 --- /dev/null +++ b/crates/local/src/lan_diagnosis.rs @@ -0,0 +1,812 @@ +//! Why the home network can't find a linked machine (decision #123, amends +//! #94). Pure: the app collects the facts (who's expected, who was heard, +//! which way this machine's packets leave, what Windows calls the network) +//! and [`diagnose`] turns them into what the Sync & backup line says. Nothing +//! here opens a socket or runs a command; [`crate::netinfo`] does the looking. +//! +//! A cause is only named when this machine can tell. "The other machine is +//! on a different Wi-Fi" or "its firewall blocks us" can't be seen from here, +//! so those stay on the checklist the details show. + +use std::io::ErrorKind; +use std::net::IpAddr; +use std::time::Duration; + +use serde::Serialize; + +use crate::lan::PORT; + +/// How long the line says "Looking for…" before it says "Can't find…". The +/// app checks every beacon (10 s), so in practice 30-40 s. +pub const GRACE: Duration = Duration::from_secs(30); + +/// A machine the relay lists as linked to this one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Expected { + pub id: String, + pub name: String, +} + +/// A linked machine heard on this network since Patch Pilot started. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Seen { + pub id: String, + pub name: String, + /// Heard from in the last 45 s. + pub here: bool, + pub seen_at: i64, +} + +/// A network interface: its name as the OS shows it ("Wi-Fi", "en0"), its +/// addresses with their prefix lengths, and on Windows the adapter's +/// description when it was looked up ("PANGP Virtual Ethernet Adapter"): +/// many VPNs name their adapter "Ethernet 4" and say what they are only there. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Iface { + pub name: String, + pub ips: Vec<(IpAddr, u8)>, + pub description: Option, +} + +impl Iface { + pub fn kind(&self) -> AdapterKind { + match &self.description { + Some(d) => classify_adapter(&format!("{} {d}", self.name)), + None => classify_adapter(&self.name), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdapterKind { + /// Wi-Fi, Ethernet: the home network. + Lan, + Vpn, + /// Hyper-V, WSL, Docker, VirtualBox, VMware: a network inside this machine. + Virtual, +} + +/// Which way packets leave this machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Egress { + Lan, + Vpn { + adapter: Option, + }, + Virtual { + name: String, + }, + /// The probe failed, or the address is on no interface while there's no + /// home-network address to compare with. + Unknown, +} + +/// What Windows calls the network (Settings › Network › Network profile). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Profile { + /// Windows' firewall blocks other machines from reaching this one. + Public, + /// Private, or a work domain. + Private, +} + +/// Everything [`diagnose`] goes on. The app fills it; tests build it by hand. +#[derive(Debug, Clone, Default)] +pub struct Facts { + /// How long this machine has listened on the home network. + pub listening_for: Duration, + /// Machines linked to this one (from the relay), this one left out. + pub expected: Vec, + /// Linked machines heard since launch. + pub seen: Vec, + /// A beacon of this rig that couldn't be read came in lately: another + /// Patch Pilot version. + pub other_version: bool, + /// A Patch Pilot beacon under another rig key came in lately. + pub other_rig: bool, + /// The OS error code of the last broadcast that failed to go out. + pub broadcast_error: Option, + /// Which way a broadcast leaves. + pub broadcast_egress: Option, + /// Which way internet traffic leaves. + pub route_egress: Option, + /// This machine has an address on a home network (Wi-Fi, Ethernet). + /// None: couldn't tell. + pub has_lan: Option, + /// Windows only; None elsewhere or when unknown. + pub profile: Option, + pub macos: bool, +} + +/// What the Sync & backup line says about the home network. Mirrored in +/// `app/src/api.ts`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Diagnosis { + /// Linked machines on this network now, by name. + pub here: Vec, + /// Linked machines not on this network now. + pub missing: Vec, + /// Still in the first [`GRACE`]: "Looking for…", not "Can't find…". + pub looking: bool, + /// The most likely reason this machine can see, while one is missing. + #[serde(skip_serializing_if = "Option::is_none")] + pub cause: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Missing { + pub name: String, + /// When it was last heard on this network, since launch. + #[serde(skip_serializing_if = "Option::is_none")] + pub seen_at: Option, +} + +/// Mirrored in `app/src/api.ts`; the words are in `Backup.tsx`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Cause { + /// A beacon of this rig came in that this version can't read. + OtherVersion, + /// No Wi-Fi or Ethernet address: not on a network at all. + NoLan, + /// Broadcasts leave through a VPN instead of the home network. + Vpn { + #[serde(skip_serializing_if = "Option::is_none")] + adapter: Option, + }, + /// Broadcasts leave into a virtual network inside this machine. + VirtualAdapter { name: String }, + /// macOS refused to send on the local network: almost surely Local + /// Network access is off for Patch Pilot. + LocalNetworkDenied, + /// Windows calls this network Public, so its firewall keeps other + /// machines out. + PublicNetwork, + /// A Patch Pilot that isn't linked to these machines is on the network. + OtherRig, + /// A VPN carries internet traffic; broadcasts still leave on the home + /// network, but many VPNs block local traffic. A guess. + VpnOn { + #[serde(skip_serializing_if = "Option::is_none")] + adapter: Option, + }, +} + +/// macOS's EHOSTUNREACH: what a send to the local network gets while Local +/// Network access is off (or not answered yet). +const EHOSTUNREACH_MACOS: i32 = 65; + +pub fn diagnose(f: &Facts) -> Diagnosis { + let mut here: Vec = f + .seen + .iter() + .filter(|s| s.here) + .map(|s| s.name.clone()) + .collect(); + here.sort(); + let missing: Vec = f + .expected + .iter() + .filter(|e| !f.seen.iter().any(|s| s.here && s.id == e.id)) + .map(|e| { + let seen = f.seen.iter().find(|s| s.id == e.id); + Missing { + // The name it last broadcast is the freshest. + name: seen.map_or_else(|| e.name.clone(), |s| s.name.clone()), + seen_at: seen.map(|s| s.seen_at), + } + }) + .collect(); + let looking = !missing.is_empty() && f.listening_for < GRACE; + let cause = if missing.is_empty() || looking { + None + } else { + cause(f, !here.is_empty()) + }; + Diagnosis { + here, + missing, + looking, + cause, + } +} + +/// The first cause that fits, surest first. While another linked machine is +/// here, this machine's network works, so only causes on the other side +/// (another version, another rig) are named. +fn cause(f: &Facts, someone_here: bool) -> Option { + if f.other_version { + return Some(Cause::OtherVersion); + } + if !someone_here { + if f.has_lan == Some(false) { + return Some(Cause::NoLan); + } + match &f.broadcast_egress { + Some(Egress::Vpn { adapter }) => { + return Some(Cause::Vpn { + adapter: adapter.clone(), + }); + } + Some(Egress::Virtual { name }) => { + return Some(Cause::VirtualAdapter { name: name.clone() }); + } + _ => {} + } + // With no default route macOS refuses a broadcast with the same + // error, so a working route to the internet is needed to tell. + if f.macos + && f.broadcast_error == Some(EHOSTUNREACH_MACOS) + && f.has_lan == Some(true) + && f.route_egress.is_some() + { + return Some(Cause::LocalNetworkDenied); + } + if f.profile == Some(Profile::Public) { + return Some(Cause::PublicNetwork); + } + } + if f.other_rig { + return Some(Cause::OtherRig); + } + if !someone_here && let Some(Egress::Vpn { adapter }) = &f.route_egress { + return Some(Cause::VpnOn { + adapter: adapter.clone(), + }); + } + None +} + +/// What kind of interface a name is. Names come from the OS: Windows' +/// adapter names ("Wi-Fi", "NordLynx", "vEthernet (WSL)") or the BSD names +/// on a Mac ("en0", "utun3", "bridge100"). +pub fn classify_adapter(name: &str) -> AdapterKind { + let n = name.to_lowercase(); + const VIRTUAL: &[&str] = &[ + "vethernet", + "hyper-v", + "wsl", + "docker", + "virtualbox", + "vboxnet", + "vmware", + "vmnet", + "bridge1", + "anpi", + ]; + const VPN: &[&str] = &[ + "vpn", + "wireguard", + "wintun", + "nordlynx", + "tailscale", + "zerotier", + "openvpn", + "tap-windows", + "tap0901", + "utun", + "ipsec", + "mullvad", + "proton", + "surfshark", + "anyconnect", + "forti", + "globalprotect", + "pangp", + "pulse secure", + "cloudflare warp", + "hamachi", + "juniper", + "sonicwall", + "netextender", + "check point", + "zscaler", + "nord", + "expressvpn", + "ppp", + ]; + let starts = |p: &str| n.starts_with(p); + if VIRTUAL.iter().any(|p| n.contains(p)) { + AdapterKind::Virtual + } else if VPN.iter().any(|p| n.contains(p)) || starts("tun") || starts("tap") || starts("wg") { + AdapterKind::Vpn + } else { + AdapterKind::Lan + } +} + +/// An IPv4 address a home network would give: not loopback or link-local. +fn lan_v4(ip: &IpAddr) -> bool { + matches!(ip, IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified()) +} + +/// What kind an interface is here. A "virtual" one carrying the internet +/// route is the home network itself: a Hyper-V external switch or bridged +/// WSL moves the machine's real address onto a "vEthernet" adapter, while an +/// internal switch never holds the default route. +fn kind_with_route(i: &Iface, route: Option) -> AdapterKind { + match i.kind() { + AdapterKind::Virtual if route.is_some_and(|r| i.ips.iter().any(|(ip, _)| *ip == r)) => { + AdapterKind::Lan + } + k => k, + } +} + +/// True when some Wi-Fi or Ethernet interface has a home-network address. +/// `route`: the address internet traffic leaves from, if known. +pub fn has_lan(ifaces: &[Iface], route: Option) -> bool { + ifaces.iter().any(|i| { + kind_with_route(i, route) == AdapterKind::Lan && i.ips.iter().any(|(ip, _)| lan_v4(ip)) + }) +} + +/// Which way packets from `source` leave. An address on no listed interface +/// is a VPN's when there's a home-network address to compare with: Windows +/// leaves VPN adapters without a hardware address (WireGuard, Wintun) out +/// of the interface list. +pub fn egress_kind(source: IpAddr, ifaces: &[Iface], route: Option) -> Egress { + if source.is_unspecified() || source.is_loopback() { + return Egress::Unknown; + } + match ifaces + .iter() + .find(|i| i.ips.iter().any(|(ip, _)| *ip == source)) + { + Some(i) => match kind_with_route(i, route) { + AdapterKind::Lan => Egress::Lan, + AdapterKind::Vpn => Egress::Vpn { + adapter: Some(i.name.clone()), + }, + AdapterKind::Virtual => Egress::Virtual { + name: i.name.clone(), + }, + }, + None if has_lan(ifaces, route) => Egress::Vpn { adapter: None }, + None => Egress::Unknown, + } +} + +/// What the Sync & backup line says when the home-network port can't be +/// opened, by what actually went wrong. +pub fn bind_error_text(kind: ErrorKind, raw_os_error: Option, detail: &str) -> String { + // WSAEACCES: on Windows, usually a port range Hyper-V, WSL or Docker + // reserved (`netsh int ipv4 show excludedportrange protocol=udp`). + if raw_os_error == Some(10013) { + return format!( + "Can't listen for your other machines: Windows has reserved network port {PORT}, often for Hyper-V, WSL or Docker ({detail})." + ); + } + match kind { + ErrorKind::AddrInUse => format!( + "Can't listen for your other machines: another app is using network port {PORT} ({detail})." + ), + ErrorKind::PermissionDenied => format!( + "Can't listen for your other machines: Patch Pilot isn't allowed to use network port {PORT} ({detail})." + ), + _ => format!("Can't listen for your other machines on network port {PORT} ({detail})."), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + fn iface(name: &str, addr: IpAddr) -> Iface { + Iface { + name: name.into(), + ips: vec![(addr, 24)], + description: None, + } + } + + fn laptop() -> Expected { + Expected { + id: "laptop".into(), + name: "Brian's Laptop".into(), + } + } + + /// Listening long enough, the laptop expected, nobody heard. + fn alone() -> Facts { + Facts { + listening_for: GRACE, + expected: vec![laptop()], + has_lan: Some(true), + broadcast_egress: Some(Egress::Lan), + route_egress: Some(Egress::Lan), + ..Facts::default() + } + } + + #[test] + fn looks_for_a_while_before_saying_it_cant_find() { + let f = Facts { + listening_for: GRACE - Duration::from_secs(1), + broadcast_egress: Some(Egress::Vpn { adapter: None }), + ..alone() + }; + let d = diagnose(&f); + assert!(d.looking); + assert_eq!(d.cause, None, "no cause while still looking"); + assert_eq!(d.missing[0].name, "Brian's Laptop"); + + let d = diagnose(&Facts { + listening_for: GRACE, + ..f + }); + assert!(!d.looking); + assert_eq!(d.cause, Some(Cause::Vpn { adapter: None })); + } + + #[test] + fn nothing_to_say_when_no_machine_is_expected() { + let d = diagnose(&Facts { + expected: vec![], + ..alone() + }); + assert!(d.missing.is_empty()); + assert!(!d.looking); + assert_eq!(d.cause, None); + } + + #[test] + fn no_cause_it_can_see_leaves_the_checklist() { + let d = diagnose(&alone()); + assert_eq!(d.missing.len(), 1); + assert_eq!(d.cause, None); + } + + #[test] + fn a_machine_that_left_keeps_when_it_was_seen_and_its_latest_name() { + let f = Facts { + seen: vec![Seen { + id: "laptop".into(), + name: "Laptop (renamed)".into(), + here: false, + seen_at: 1_700_000_000, + }], + ..alone() + }; + let d = diagnose(&f); + assert_eq!( + d.missing, + vec![Missing { + name: "Laptop (renamed)".into(), + seen_at: Some(1_700_000_000) + }] + ); + } + + #[test] + fn one_here_one_missing_names_only_causes_on_the_other_side() { + let desk = Seen { + id: "desk".into(), + name: "Desktop".into(), + here: true, + seen_at: 1, + }; + let f = Facts { + expected: vec![ + laptop(), + Expected { + id: "desk".into(), + name: "Desktop".into(), + }, + ], + seen: vec![desk], + profile: Some(Profile::Public), + broadcast_egress: Some(Egress::Vpn { adapter: None }), + ..alone() + }; + let d = diagnose(&f); + assert_eq!(d.here, vec!["Desktop".to_string()]); + assert_eq!(d.missing.len(), 1); + assert_eq!( + d.cause, None, + "this machine's network works: Desktop is here" + ); + let d = diagnose(&Facts { + other_rig: true, + ..f + }); + assert_eq!(d.cause, Some(Cause::OtherRig)); + } + + #[test] + fn a_heard_machine_not_yet_expected_still_counts_as_here() { + let d = diagnose(&Facts { + expected: vec![], + seen: vec![Seen { + id: "mac".into(), + name: "MacBook".into(), + here: true, + seen_at: 1, + }], + ..alone() + }); + assert_eq!(d.here, vec!["MacBook".to_string()]); + assert!(d.missing.is_empty()); + } + + #[test] + fn causes_come_surest_first() { + let base = Facts { + other_rig: true, + profile: Some(Profile::Public), + route_egress: Some(Egress::Vpn { + adapter: Some("NordLynx".into()), + }), + ..alone() + }; + // Another version beats everything: its beacon got here. + let d = diagnose(&Facts { + other_version: true, + has_lan: Some(false), + ..base.clone() + }); + assert_eq!(d.cause, Some(Cause::OtherVersion)); + let d = diagnose(&Facts { + has_lan: Some(false), + ..base.clone() + }); + assert_eq!(d.cause, Some(Cause::NoLan)); + let d = diagnose(&Facts { + broadcast_egress: Some(Egress::Vpn { + adapter: Some("NordLynx".into()), + }), + ..base.clone() + }); + assert_eq!( + d.cause, + Some(Cause::Vpn { + adapter: Some("NordLynx".into()) + }) + ); + let d = diagnose(&Facts { + broadcast_egress: Some(Egress::Virtual { + name: "vEthernet (WSL)".into(), + }), + ..base.clone() + }); + assert_eq!( + d.cause, + Some(Cause::VirtualAdapter { + name: "vEthernet (WSL)".into() + }) + ); + assert_eq!(diagnose(&base).cause, Some(Cause::PublicNetwork)); + let d = diagnose(&Facts { + profile: Some(Profile::Private), + ..base.clone() + }); + assert_eq!(d.cause, Some(Cause::OtherRig)); + let d = diagnose(&Facts { + profile: Some(Profile::Private), + other_rig: false, + ..base + }); + assert_eq!( + d.cause, + Some(Cause::VpnOn { + adapter: Some("NordLynx".into()) + }), + "a VPN on the internet route is only a guess, so it comes last" + ); + } + + #[test] + fn local_network_denied_only_on_a_mac_with_a_lan_address() { + let mac = Facts { + macos: true, + broadcast_error: Some(65), + ..alone() + }; + assert_eq!(diagnose(&mac).cause, Some(Cause::LocalNetworkDenied)); + let no_gateway = Facts { + route_egress: None, + ..mac.clone() + }; + assert_eq!( + diagnose(&no_gateway).cause, + None, + "with no default route macOS refuses broadcasts the same way" + ); + let pc = Facts { + macos: false, + ..mac.clone() + }; + assert_eq!( + diagnose(&pc).cause, + None, + "65 means something else on Windows" + ); + let off_network = Facts { + has_lan: None, + ..mac + }; + assert_eq!(diagnose(&off_network).cause, None); + } + + #[test] + fn adapters_are_told_apart_by_name() { + for lan in [ + "Wi-Fi", + "Ethernet", + "Ethernet 2", + "en0", + "en7", + "WLAN", + "Local Area Connection", + ] { + assert_eq!(classify_adapter(lan), AdapterKind::Lan, "{lan}"); + } + for vpn in [ + "NordLynx", + "WireGuard Tunnel", + "Tailscale", + "ProtonVPN TUN", + "OpenVPN Data Channel Offload", + "TAP-Windows Adapter V9", + "utun3", + "ppp0", + "ipsec0", + "wg0", + "Mullvad", + ] { + assert_eq!(classify_adapter(vpn), AdapterKind::Vpn, "{vpn}"); + } + for virt in [ + "vEthernet (WSL)", + "vEthernet (Default Switch)", + "VirtualBox Host-Only Network", + "VMware Network Adapter VMnet8", + "docker0", + "bridge100", + ] { + assert_eq!(classify_adapter(virt), AdapterKind::Virtual, "{virt}"); + } + } + + #[test] + fn egress_is_the_interface_holding_the_source_address() { + let ifaces = vec![ + iface("Wi-Fi", ip(192, 168, 1, 20)), + iface("vEthernet (WSL)", ip(172, 29, 0, 1)), + iface("ProtonVPN TUN", ip(10, 2, 0, 2)), + ]; + assert_eq!(egress_kind(ip(192, 168, 1, 20), &ifaces, None), Egress::Lan); + assert_eq!( + egress_kind(ip(172, 29, 0, 1), &ifaces, None), + Egress::Virtual { + name: "vEthernet (WSL)".into() + } + ); + assert_eq!( + egress_kind(ip(10, 2, 0, 2), &ifaces, None), + Egress::Vpn { + adapter: Some("ProtonVPN TUN".into()) + } + ); + // WireGuard-style adapters aren't listed at all. + assert_eq!( + egress_kind(ip(10, 5, 0, 2), &ifaces, None), + Egress::Vpn { adapter: None } + ); + assert_eq!(egress_kind(ip(0, 0, 0, 0), &ifaces, None), Egress::Unknown); + // Nothing to compare with: can't tell. + let no_lan = vec![iface("ProtonVPN TUN", ip(10, 2, 0, 2))]; + assert_eq!(egress_kind(ip(10, 5, 0, 2), &no_lan, None), Egress::Unknown); + } + + #[test] + fn a_vpn_named_like_ethernet_is_told_by_its_description() { + // GlobalProtect calls its adapter "Ethernet 4". + let mut gp = iface("Ethernet 4", ip(10, 20, 0, 7)); + let ifaces = vec![iface("Wi-Fi", ip(192, 168, 1, 20)), gp.clone()]; + assert_eq!( + egress_kind(ip(10, 20, 0, 7), &ifaces, None), + Egress::Lan, + "the name alone can't tell" + ); + gp.description = Some("PANGP Virtual Ethernet Adapter Secure #2".into()); + let ifaces = vec![iface("Wi-Fi", ip(192, 168, 1, 20)), gp]; + assert_eq!( + egress_kind(ip(10, 20, 0, 7), &ifaces, None), + Egress::Vpn { + adapter: Some("Ethernet 4".into()) + } + ); + let hyperv = Iface { + description: Some("Hyper-V Virtual Ethernet Adapter".into()), + ..iface("vEthernet (Default Switch)", ip(172, 20, 0, 1)) + }; + assert_eq!(hyperv.kind(), AdapterKind::Virtual); + let wifi = Iface { + description: Some("RZ616 Wi-Fi 6E 160MHz".into()), + ..iface("Wi-Fi", ip(192, 168, 1, 20)) + }; + assert_eq!(wifi.kind(), AdapterKind::Lan); + } + + #[test] + fn a_virtual_switch_carrying_the_internet_is_the_home_network() { + // Hyper-V external switch: the real address moves onto vEthernet. + let ext = Iface { + description: Some("Hyper-V Virtual Ethernet Adapter".into()), + ..iface("vEthernet (External Switch)", ip(192, 168, 1, 20)) + }; + let wsl = iface("vEthernet (WSL)", ip(172, 29, 0, 1)); + let ifaces = vec![ext, wsl]; + let route = Some(ip(192, 168, 1, 20)); + assert!(has_lan(&ifaces, route)); + assert!( + !has_lan(&ifaces, None), + "without the route it looks virtual" + ); + assert_eq!( + egress_kind(ip(192, 168, 1, 20), &ifaces, route), + Egress::Lan + ); + // An internal switch still counts as virtual. + assert_eq!( + egress_kind(ip(172, 29, 0, 1), &ifaces, route), + Egress::Virtual { + name: "vEthernet (WSL)".into() + } + ); + } + + #[test] + fn a_home_network_address_is_ipv4_on_wifi_or_ethernet() { + assert!(has_lan(&[iface("Wi-Fi", ip(192, 168, 1, 20))], None)); + assert!( + !has_lan(&[iface("Wi-Fi", ip(169, 254, 3, 4))], None), + "link-local" + ); + assert!(!has_lan(&[iface("Loopback", ip(127, 0, 0, 1))], None)); + assert!(!has_lan(&[iface("NordLynx", ip(10, 5, 0, 2))], None)); + assert!(!has_lan(&[], None)); + } + + #[test] + fn bind_errors_say_what_went_wrong() { + let reserved = bind_error_text(ErrorKind::PermissionDenied, Some(10013), "os error 10013"); + assert!( + reserved.contains("Windows has reserved network port 47219"), + "{reserved}" + ); + let taken = bind_error_text(ErrorKind::AddrInUse, Some(10048), "os error 10048"); + assert!( + taken.contains("another app is using network port 47219"), + "{taken}" + ); + let denied = bind_error_text(ErrorKind::PermissionDenied, Some(13), "os error 13"); + assert!(denied.contains("isn't allowed"), "{denied}"); + let other = bind_error_text(ErrorKind::Other, None, "boom"); + assert!(other.ends_with("(boom)."), "{other}"); + } + + #[test] + fn the_line_gets_optional_fields_only_when_set() { + let d = diagnose(&alone()); + let json = serde_json::to_value(&d).unwrap(); + assert!(json.get("cause").is_none()); + assert!(json["missing"][0].get("seen_at").is_none()); + let c = serde_json::to_value(Cause::Vpn { adapter: None }).unwrap(); + assert_eq!(c, serde_json::json!({ "kind": "vpn" })); + let c = serde_json::to_value(Cause::VirtualAdapter { + name: "docker0".into(), + }) + .unwrap(); + assert_eq!( + c, + serde_json::json!({ "kind": "virtual_adapter", "name": "docker0" }) + ); + } +} diff --git a/crates/local/src/lib.rs b/crates/local/src/lib.rs index 9102e50..f9d135f 100644 --- a/crates/local/src/lib.rs +++ b/crates/local/src/lib.rs @@ -18,7 +18,9 @@ pub mod gitpack; pub mod golden; pub mod ignore; pub mod lan; +pub mod lan_diagnosis; pub mod mac_keys; +pub mod netinfo; pub mod open; pub mod quiet; pub mod reaper_ext; diff --git a/crates/local/src/netinfo.rs b/crates/local/src/netinfo.rs new file mode 100644 index 0000000..9fe9cff --- /dev/null +++ b/crates/local/src/netinfo.rs @@ -0,0 +1,187 @@ +//! What this machine's network looks like, for [`crate::lan_diagnosis`] +//! (decision #123). Read-only and sends nothing: connecting a UDP socket only +//! asks the OS which address it would send from. Every probe gives `None` +//! when it can't tell, never a guess. + +use std::net::{IpAddr, Ipv4Addr, UdpSocket}; + +use crate::lan::PORT; +use crate::lan_diagnosis::{Iface, Profile}; + +/// The address a home-network broadcast leaves from: on Windows, the +/// interface with the lowest-metric route, which is often a VPN's. +pub fn broadcast_source() -> Option { + let s = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + // Connecting to the broadcast address needs this, or Windows refuses. + s.set_broadcast(true).ok()?; + s.connect((Ipv4Addr::BROADCAST, PORT)).ok()?; + source(&s) +} + +/// The address internet traffic leaves from (the default route). +pub fn route_source() -> Option { + let s = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + s.connect((Ipv4Addr::new(1, 1, 1, 1), 53)).ok()?; + source(&s) +} + +fn source(s: &UdpSocket) -> Option { + Some(s.local_addr().ok()?.ip()).filter(|ip| !ip.is_unspecified()) +} + +/// The network interfaces the OS lists, with their addresses. On Windows, +/// VPN adapters without a hardware address (WireGuard, Wintun) aren't listed. +pub fn interfaces() -> Vec { + let networks = sysinfo::Networks::new_with_refreshed_list(); + let mut out: Vec = networks + .iter() + .map(|(name, data)| Iface { + name: name.clone(), + ips: data + .ip_networks() + .iter() + .map(|n| (n.addr, n.prefix)) + .collect(), + description: None, + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +/// The interface holding `ip`, by name. +pub fn interface_of(ip: IpAddr, ifaces: &[Iface]) -> Option<&str> { + ifaces + .iter() + .find(|i| i.ips.iter().any(|(a, _)| *a == ip)) + .map(|i| i.name.as_str()) +} + +/// What Windows says about the adapter named `alias`: its description +/// ("PANGP Virtual Ethernet Adapter") and whether it calls the network +/// Public or Private. Runs PowerShell (a second or so), so call it rarely +/// and off the UI's path. None on other systems, or when Windows doesn't say. +#[cfg(windows)] +pub fn adapter_info(alias: &str) -> Option { + use std::io::Read; + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + // No console window flashing up, and below a DAW's priority: the + // calling thread's background mode doesn't pass to a child (#114). + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x0000_4000; + // WMI can hang; never wait on it longer than this. + const DEADLINE: Duration = Duration::from_secs(5); + // The name goes in through an environment variable, never into the + // command text, and is compared exactly (-Name would take it as a + // wildcard pattern). + let script = "$n = $env:PATCHPILOT_IFACE; \ + $a = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object Name -eq $n; \ + $p = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object InterfaceAlias -eq $n; \ + \"$($a.InterfaceDescription)|$($p.NetworkCategory)\""; + let mut child = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("PATCHPILOT_IFACE", alias) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS) + .spawn() + .ok()?; + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if started.elapsed() < DEADLINE => { + std::thread::sleep(Duration::from_millis(100)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + // One short line, far under a pipe's buffer, so the child never blocks + // on it before exiting. + let mut out = String::new(); + child.stdout.take()?.read_to_string(&mut out).ok()?; + parse_adapter_info(&out) +} + +#[cfg(not(windows))] +pub fn adapter_info(_alias: &str) -> Option { + None +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdapterInfo { + pub description: Option, + pub profile: Option, +} + +/// `description|category` from [`adapter_info`]'s script. The category is +/// Public, Private or DomainAuthenticated (a work network, which the +/// firewall treats as trusted). +pub fn parse_adapter_info(out: &str) -> Option { + let line = out.lines().map(str::trim).find(|l| l.contains('|'))?; + let (description, category) = line.rsplit_once('|')?; + let profile = match category.trim() { + "Public" => Some(Profile::Public), + "Private" | "DomainAuthenticated" => Some(Profile::Private), + _ => None, + }; + let description = Some(description.trim().to_owned()).filter(|d| !d.is_empty()); + (description.is_some() || profile.is_some()).then_some(AdapterInfo { + description, + profile, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probes_answer_or_say_they_cant_tell() { + // Whatever network CI has; only that nothing panics or sends. + let _ = broadcast_source(); + let _ = route_source(); + let ifaces = interfaces(); + if let Some(ip) = route_source() { + let _ = interface_of(ip, &ifaces); + } + } + + #[test] + fn adapter_info_reads_from_powershell_output() { + assert_eq!( + parse_adapter_info("RZ616 Wi-Fi 6E 160MHz|Private\r\n"), + Some(AdapterInfo { + description: Some("RZ616 Wi-Fi 6E 160MHz".into()), + profile: Some(Profile::Private) + }) + ); + assert_eq!( + parse_adapter_info("\r\nPANGP Virtual Ethernet Adapter Secure #2|Public\r\n"), + Some(AdapterInfo { + description: Some("PANGP Virtual Ethernet Adapter Secure #2".into()), + profile: Some(Profile::Public) + }) + ); + assert_eq!( + parse_adapter_info("Intel(R) Ethernet|DomainAuthenticated").and_then(|a| a.profile), + Some(Profile::Private) + ); + assert_eq!( + parse_adapter_info("Wintun Userspace Tunnel|"), + Some(AdapterInfo { + description: Some("Wintun Userspace Tunnel".into()), + profile: None + }) + ); + assert_eq!(parse_adapter_info("|"), None); + assert_eq!(parse_adapter_info(""), None); + } +} diff --git a/docs/decisions.md b/docs/decisions.md index 0820e4e..14dfc4a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -126,3 +126,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 119 | 2026-11-01 | **The tray popover opens in the taskbar corner on Windows** (amends #35; Joshua: clicking Patch Pilot in the tray opens it in an awkward spot). Centring it above the icon left it floating mid-screen when the icon sat in the hidden-icons overflow, which opens above the taskbar. On Windows it now sits in the corner of the icon's screen's work area on the taskbar's side (found from where the work area stops short of the screen; an auto-hidden taskbar goes by the icon's half of the screen), right-aligned, 12 px from the taskbar and the edge like Windows 11's flyouts; top, left and right taskbars get the matching corner. macOS is unchanged: centred under the menu bar icon, inside the screen. Positions are worked out at the target screen's scale and allow for the invisible resize border Windows counts in a window's position (`tray::place`, unit-tested). | Accepted | | 120 | 2026-11-03 | **Releases also show on the main repo** (Joshua made `PatchPilotSync/patch-pilot` public and asked for releases on its page; amends #32). After the Release workflow publishes to `patch-pilot-releases`, a new job calls `.github/workflows/mirror-release.yml`, which copies the release to the main repo with the same tag, title and notes, attaching only the installers people download (`*_aarch64.dmg`, `*_x64.dmg`, `*_x64-setup.exe`). The updater endpoint, update packages, `latest.json` and `catalog.json` stay on `patch-pilot-releases`. The copy is made as a draft, then published once every installer is attached; it is marked Latest only when its tag is the releases repo's latest, so backfilling an older tag (run the workflow from the Actions tab) never displaces the newest. It uses only the built-in token (the releases repo is public). The README's badge and download link now point to the main repo's releases. | Installed apps already read `patch-pilot-releases/releases/latest/download/latest.json`, so moving the updater would strand every existing install; copying keeps them working while the public repo's page shows each release. Installers only keeps the page clean. | Accepted | | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | +| 123 | 2026-11-06 | **Home network says why it can't find a machine** (amends #94; Brian: his machines were linked but home-network sync never found the laptop, which was on another Wi-Fi and a VPN, and nothing said why). Linking only uses the relay, so it worked; the beacons (UDP broadcast to 255.255.255.255) never reached the other machine, and "heard nobody" wasn't an error: the line said "Looking for your other machines…" forever, or a green "In sync…". Now the Sync & backup line names the linked machines it expects and says Looking for X for the first 30 s after the home network is picked (checked with each 10 s beacon, so 30-40 s), then Can't find X on this network, with a cause when this machine can tell; a machine that was here and left reads "isn't on this network now (seen 2 h ago)" instead of "In sync", and one missing while another is here is added to the In sync line. The opened details list each machine (on this network / last on it / not found) and a checklist (same network, VPN, Windows Private network and firewall, macOS Local Network, Patch Pilot open with Home network on the other machine, same version), the detected cause marked. **Expected machines** are the other `machine/` objects the relay lists when live sync starts, plus `changed` events (no extra relay reads, #113), named from the store; machines added from an export file or an earlier rig, and machines removed from the store (their object stays on the relay), never show as missing. Before the first listing, every other machine in the store; while this machine isn't linked, none. **Causes**, surest first, in `patchpilot_local::lan_diagnosis::diagnose` (pure, tested with hand-built facts): a beacon sealed by this rig that this version can't read, or a newer envelope (other version); no Wi-Fi/Ethernet IPv4 address; broadcasts leaving through a VPN or a virtual adapter (Hyper-V, WSL, Docker, VMware); macOS refusing the broadcast with EHOSTUNREACH while a default route works (Local Network access off: a strong guess, macOS has no API to ask; with no gateway macOS refuses broadcasts the same way); Windows calling the network Public; a Patch Pilot beacon under another rig key heard in the last 45 s; and last, a guess, a VPN holding the internet route. While another linked machine is here, only the other-version and other-rig causes are named, since this machine's network works. What one machine can't see (the other on another Wi-Fi, client isolation, the other's firewall, Home network off there) stays on the checklist. **How it looks** (`patchpilot_local::netinfo`, read-only, sends nothing): a UDP socket with broadcast on, connected to 255.255.255.255:47219, and one connected to 1.1.1.1:53, give the source address of each; sysinfo's `network` feature (unsafe stays inside sysinfo) lists interfaces, and an address on none of them while a home-network address exists is a VPN's, since Windows leaves MAC-less WireGuard/Wintun adapters out. A "virtual" adapter carrying the internet route is the home network itself (a Hyper-V external switch or bridged WSL moves the real address onto vEthernet). Adapters are told apart by name and, on Windows, by the description PowerShell's `Get-NetAdapter` gives, because many VPNs (GlobalProtect's "PANGP Virtual Ethernet Adapter") name theirs "Ethernet 4"; the same call reads `Get-NetConnectionProfile` (DomainAuthenticated counts as Private), with the name in an environment variable and compared exactly (not as a wildcard), no window, below-normal priority (#114; the thread's background mode doesn't pass to a child), killed after 5 s (WMI can hang), cached 10 min per adapter. All of it runs on the existing beacon thread, only while a machine is missing, none is here and the grace is over, at most every 30 s, and the UI is told only when the diagnosis changes; `view()` only reads what's known. Only the broadcast's send result counts (a machine that left answers unicast with "unreachable" too). A pull error goes once its machine is gone. Picking the home network again restarts the 30 s and looks at the network afresh. The port error is worded by its cause (Windows' reserved port ranges give 10013, not "another app"). **Later, separately:** each machine telling the others its network over the relay ("Laptop is on a different network"), and beacons out of every interface so sync works despite a VPN. | The first time a user linked two machines, home-network sync failed with nothing to go on; the fix was a minute's work once the cause was known. Saying only what this machine can actually tell keeps the line trustworthy; the checklist covers the rest. | Proposed | From db252a1b80166a73f382fbbc32659cbcf5c68ad7 Mon Sep 17 00:00:00 2001 From: bfieser Date: Fri, 6 Nov 2026 15:01:45 -0600 Subject: [PATCH 2/6] Home network: compare both machines' networks over the relay (#219) Each linked machine keeps a small sealed net/ report on the relay: Home network on, its subnets, whether its broadcasts leave through a VPN, and which linked machines it hears. Sent on change and, only while a machine is missing or can't hear this one, every 5 minutes. The others use it to say why they can't find that machine: Home network off there, asleep or closed, VPN there, a different network, one-way hearing (firewall), or the same network with neither hearing the other. Decision #124 (Proposed). Co-Authored-By: Claude Opus 5.5 --- app/CHANGELOG.md | 1 + app/src-tauri/src/lan.rs | 114 ++++++++-- app/src-tauri/src/sync.rs | 74 +++++++ app/src/Backup.tsx | 84 +++++++- app/src/api.ts | 15 +- crates/local/src/lan_diagnosis.rs | 343 +++++++++++++++++++++++++++++- crates/local/src/lib.rs | 1 + crates/local/src/net_report.rs | 245 +++++++++++++++++++++ crates/local/src/sync.rs | 45 +++- crates/sync/src/lib.rs | 2 +- crates/sync/src/rig.rs | 40 ++++ docs/decisions.md | 1 + 12 files changed, 934 insertions(+), 31 deletions(-) create mode 100644 crates/local/src/net_report.rs diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 35c0848..728f480 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -9,6 +9,7 @@ becomes its release notes, which the app shows when it offers that update ### Improved - When you sync over your home network and Patch Pilot can't find one of your linked machines, it now says which one and, when it can tell, why: a VPN or virtual network adapter is carrying its messages, Windows has the network set to Public, your Mac isn't letting it use the local network, this machine isn't on a network, or the other machine runs a different Patch Pilot version. Open Sync & backup to see each machine and a short list of what to check. It no longer says "In sync" while none of your other machines are on the network. +- Your linked machines now tell each other about their home network, so Patch Pilot can also say when the other machine is on a different network, has a VPN on, isn't using Home network sync, hasn't checked in for a while (asleep or closed), or can hear this machine but not the other way round (usually a firewall). It names a reason only when both machines agree. ## 0.4.58 - 2026-11-06 diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index b5c98e7..ab99c6d 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -15,6 +15,11 @@ //! gathered here, and `patchpilot_local::netinfo` looks at the network only //! while a machine is missing, so an idle machine with everyone found does //! no extra work. +//! +//! The same loop keeps this machine's home-network report on the relay +//! (decision #124): sent when it changes and, while a machine is missing or +//! can't hear this one, every few minutes. The other machines' reports come +//! back through `sync` and name what only they can see. use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}; @@ -26,6 +31,7 @@ use patchpilot_local::backup::{self, BackupSettings}; use patchpilot_local::github::FileIndex; use patchpilot_local::lan::{self, Beacon, Connection, Heard, Progress, PullReport}; use patchpilot_local::lan_diagnosis::{self, Diagnosis, Egress, Expected, Facts, Profile, Seen}; +use patchpilot_local::net_report::{self, NetReport, Subnet}; use patchpilot_local::netinfo::{self, AdapterInfo}; use serde::Serialize; use tauri::{AppHandle, Emitter, Listener, Manager}; @@ -54,6 +60,8 @@ struct Peer { addr: SocketAddr, seen: Instant, seen_at: i64, + /// When this stretch of hearing it began (Unix seconds). + since: i64, /// Its copy's state when last heard. state: String, /// The state last pulled in full. @@ -92,6 +100,8 @@ struct LanState { net: Mutex, /// The diagnosis last told to the UI. diagnosis: Mutex>, + /// The report last given to `sync` to send, and when. + sent: Mutex>, } /// This machine's network as last looked at, while a machine was missing. @@ -102,6 +112,8 @@ struct NetCache { route_egress: Option, has_lan: Option, profile: Option, + /// The Wi-Fi and Ethernet subnets, for this machine's report. + subnets: Vec, /// What Windows said about each adapter, by name, and when. adapters: HashMap)>, } @@ -155,6 +167,7 @@ pub fn setup(app: &AppHandle) { announce(&handle, None); check(&handle); } + share(&handle); std::thread::sleep(BEACON_EVERY); } }); @@ -349,6 +362,7 @@ fn heard(app: &AppHandle, beacon: Beacon, ip: IpAddr) { addr, seen: Instant::now(), seen_at: 0, + since: 0, state: String::new(), pulled: None, failed: None, @@ -357,6 +371,9 @@ fn heard(app: &AppHandle, beacon: Beacon, ip: IpAddr) { peer.addr = addr; peer.seen = Instant::now(); peer.seen_at = patchpilot_local::now(); + if new { + peer.since = peer.seen_at; + } peer.state = beacon.state; let wanted = peer.pulled.as_ref() != Some(&peer.state) && peer.failed.is_none_or(|f| f.elapsed() > RETRY); @@ -524,10 +541,12 @@ fn check(app: &AppHandle) { .unwrap() .checked .is_none_or(|t| t.elapsed() >= NET_EVERY); - // Only while nobody is here: otherwise this machine's network works, - // and diagnose doesn't look at it. - if !d.missing.is_empty() && d.here.is_empty() && !d.looking && net_due { - look_at_network(&state); + // Only while something is wrong. With nobody here, ask Windows about + // the adapters too; with someone here this machine's network works, + // and only its subnets and which way it sends matter. + let wrong = (!d.missing.is_empty() && !d.looking) || !d.deaf.is_empty(); + if wrong && net_due { + look_at_network(&state, d.here.is_empty()); d = lan_diagnosis::diagnose(&facts(app)); } let changed = { @@ -565,9 +584,10 @@ fn clear_stale_error(state: &LanState) -> bool { gone } -/// Which way this machine's packets leave, and what Windows calls the -/// network. At background priority: it can run PowerShell. -fn look_at_network(state: &LanState) { +/// Which way this machine's packets leave, its subnets, and what Windows +/// calls the network. At background priority: with `deep` it can run +/// PowerShell; without, it goes on what Windows said before. +fn look_at_network(state: &LanState, deep: bool) { let _quiet = patchpilot_local::quiet::background(); let mut net = state.net.lock().unwrap().clone(); let mut ifaces = netinfo::interfaces(); @@ -586,14 +606,18 @@ fn look_at_network(state: &LanState) { let fresh = net .adapters .get(&name) - .filter(|(at, _)| at.elapsed() < ADAPTER_EVERY) + .filter(|(at, _)| !deep || at.elapsed() < ADAPTER_EVERY) .map(|(_, info)| info.clone()); - let info = fresh.unwrap_or_else(|| { - let info = netinfo::adapter_info(&name); - net.adapters - .insert(name.clone(), (Instant::now(), info.clone())); - info - }); + let info = if !deep { + fresh.flatten() + } else { + fresh.unwrap_or_else(|| { + let info = netinfo::adapter_info(&name); + net.adapters + .insert(name.clone(), (Instant::now(), info.clone())); + info + }) + }; if let Some(i) = ifaces.iter_mut().find(|i| i.name == name) { i.description = info.as_ref().and_then(|a| a.description.clone()); } @@ -603,6 +627,7 @@ fn look_at_network(state: &LanState) { } net.broadcast_egress = broadcast.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); net.route_egress = route.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); + net.subnets = lan_diagnosis::subnets(&ifaces, route); // Windows lists no adapter at all when none is connected; with no route // anywhere either, this machine isn't on a network. net.has_lan = if !ifaces.is_empty() { @@ -637,6 +662,7 @@ fn facts(app: &AppHandle) -> Facts { name: p.name.clone(), here: p.seen.elapsed() < GONE, seen_at: p.seen_at, + since: p.since, }) .collect(); let net = state.net.lock().unwrap().clone(); @@ -656,6 +682,66 @@ fn facts(app: &AppHandle) -> Facts { has_lan: net.has_lan, profile: net.profile, macos: cfg!(target_os = "macos"), + this_id: this_machine().unwrap_or_default(), + now: patchpilot_local::now(), + relay_online: crate::sync::online(app), + subnets: net.subnets, + reports: crate::sync::reports(app), + } +} + +/// What this machine tells the others about its home network: on and +/// listening, its subnets and whether its broadcasts go astray as last +/// looked at, and who it hears now. Quick: no store, no command. +pub(crate) fn own_report(app: &AppHandle) -> NetReport { + let state = app.state::(); + let now = patchpilot_local::now(); + if !in_use() || state.udp.lock().unwrap().is_none() { + return NetReport::off(now); + } + let net = state.net.lock().unwrap().clone(); + let mut hears: Vec = state + .peers + .lock() + .unwrap() + .iter() + .filter(|(_, p)| p.seen.elapsed() < GONE) + .map(|(id, _)| id.clone()) + .collect(); + hears.sort(); + NetReport { + version: net_report::REPORT_VERSION, + at: now, + on: true, + subnets: net.subnets, + vpn: match net.broadcast_egress { + Some(Egress::Lan) => Some(false), + Some(Egress::Vpn { .. } | Egress::Virtual { .. }) => Some(true), + Some(Egress::Unknown) | None => None, + }, + hears, + } +} + +/// Every beacon: have `sync` send this machine's report when it says +/// something new, or, while a machine is missing or can't hear this one, +/// when the others should hear it's still running. Not linked, `sync` +/// isn't running and nothing goes out. +fn share(app: &AppHandle) { + let state = app.state::(); + let report = own_report(app); + let heartbeat = report.on + && state + .diagnosis + .lock() + .unwrap() + .as_ref() + .is_some_and(|d| !d.missing.is_empty() || !d.deaf.is_empty()); + let mut sent = state.sent.lock().unwrap(); + let last = sent.as_ref().map(|(r, at)| (r, at.elapsed())); + if net_report::should_send(&report, last, heartbeat) { + *sent = Some((report, Instant::now())); + crate::sync::share_report(app); } } diff --git a/app/src-tauri/src/sync.rs b/app/src-tauri/src/sync.rs index 94d3d47..1935205 100644 --- a/app/src-tauri/src/sync.rs +++ b/app/src-tauri/src/sync.rs @@ -5,16 +5,22 @@ //! Idle cost: one hibernating WebSocket and a ping every 50 s. Uploads happen //! after a changed scan (`rig::RIG_CHANGED`), on start, and after making Primary Rig. //! +//! Each machine also keeps a small `net/` report there about its home +//! network (decision #124), sent by `lan` when it changes or while a machine +//! is missing; the others read it to say why they can't find that machine. +//! //! The relay's Durable Objects bill by rows read (5 million a day free), and a //! rig holds one row per shared file, often tens of thousands. So nothing here //! lists every object: the start lists `machine/` only, and uploads ask about //! files this run hasn't already seen on the relay. +use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; +use patchpilot_local::net_report::NetReport; use patchpilot_sync::{LinkCode, RigKey, link}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Listener, Manager, State}; @@ -51,6 +57,10 @@ pub struct Sync { /// Ids of the other machines on the relay: the ones actually linked, /// unlike machines added from an export file. None until listed. linked: Mutex>>, + /// The other machines' home-network reports, by id. + reports: Mutex>, + /// Send this machine's report (`share_report`). + report_nudge: Notify, running: AtomicBool, nudge: Notify, stop: Notify, @@ -166,6 +176,15 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri for name in &others { changed |= pull(&relay, &this, rig, name).await?; } + send_report(app, &relay, &this, rig).await; + // Their home-network reports: one read each, only for linked machines. + let mut reports = BTreeMap::new(); + for id in others.iter().filter_map(|n| n.strip_prefix("machine/")) { + if let Some(r) = fetch_report(&relay, rig, id).await { + reports.insert(id.to_owned(), r); + } + } + *state.reports.lock().expect("reports lock") = reports; if changed { let _ = app.emit("data-changed", ()); } @@ -195,9 +214,16 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri if pull(&relay, &this, rig, &event.name).await? { let _ = app.emit("data-changed", ()); } + } else if event.kind == "changed" + && let Some(id) = patchpilot_local::sync::other_report(&event.name, &this) + && let Some(r) = fetch_report(&relay, rig, id).await + { + // `lan` looks at it within one beacon. + state.reports.lock().expect("reports lock").insert(id.to_owned(), r); } } () = state.nudge.notified() => upload(&relay, state, &this, rig).await?, + () = state.report_nudge.notified() => send_report(app, &relay, &this, rig).await, _ = ping.tick() => ws.send(Message::text("ping")).await.map_err(|e| e.to_string())?, } } @@ -295,6 +321,29 @@ pub(crate) async fn fetch_file( } } +/// Put this machine's home-network report on the relay, made now: one kept +/// from before going offline could say it hears nobody. One that doesn't go +/// up waits for the next change or session; it never takes live sync +/// offline. +async fn send_report(app: &AppHandle, relay: &Relay, this: &str, rig: &RigKey) { + let report = crate::lan::own_report(app); + let sent = match patchpilot_local::sync::outgoing_report(this, rig, &report) { + Ok((object, sealed)) => relay.put(&object, sealed).await, + Err(e) => Err(e), + }; + if let Err(e) = sent { + eprintln!("sync: home-network report not sent: {e}"); + } +} + +/// Another machine's home-network report; None when it has none (an older +/// Patch Pilot) or it won't open. +async fn fetch_report(relay: &Relay, rig: &RigKey, id: &str) -> Option { + let object = patchpilot_sync::net_object(id).ok()?; + let sealed = relay.get(&object).await.ok()??; + patchpilot_local::sync::incoming_report(rig, &object, &sealed) +} + /// Fetch and merge another machine's object. True if anything changed. async fn pull(relay: &Relay, this: &str, rig: &RigKey, object: &str) -> Result { let Some(sealed) = relay.get(object).await? else { @@ -314,6 +363,30 @@ fn set(app: &AppHandle, state: &Sync, f: impl FnOnce(&mut SyncStatus)) { let _ = app.emit("sync-status", status); } +/// Send this machine's home-network report now. Offline, the next session +/// sends one at its start anyway. +pub fn share_report(app: &AppHandle) { + app.state::().report_nudge.notify_one(); +} + +/// The other machines' home-network reports, by id, as last read. +pub fn reports(app: &AppHandle) -> BTreeMap { + app.state::() + .reports + .lock() + .expect("reports lock") + .clone() +} + +/// Connected to the relay now. +pub fn online(app: &AppHandle) -> bool { + app.state::() + .status + .lock() + .expect("sync status lock") + .online +} + /// The ids of the other machines linked to this one, as the relay last /// listed them; None before the first listing this run (or after unlinking). pub fn linked_machines(app: &AppHandle) -> Option> { @@ -385,6 +458,7 @@ pub async fn link_join( pub fn unlink(state: State<'_, SyncState>) -> Result<(), String> { patchpilot_local::sync::forget_rig(&app_dir()?)?; *state.linked.lock().expect("linked lock") = None; + state.reports.lock().expect("reports lock").clear(); state.stop.notify_waiters(); Ok(()) } diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index cbf5c01..8812a79 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -29,6 +29,7 @@ import { type FileToBring, type GitHubProgress, type LanCause, + type LanWhy, type LanProgress, type LanView, type RestoreReport, @@ -224,7 +225,18 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan } // Home network (decision #123): never "in sync" while nobody is here, and // a machine that's missing while another is here is named after the line. + // What only the other machine knows comes from its report (#124). const d = lan?.diagnosis; + const deaf = d?.deaf ?? []; + if (d && d.missing.length === 0 && deaf.length > 0) { + const who = andList(deaf); + return [ + "warn", + `${who} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}: ${ + d.cause ? lanCauseText(d.cause) : `allow Patch Pilot through the firewall on ${who}.` + }`, + ]; + } if (d && d.missing.length > 0) { const who = andList(d.missing.map((m) => m.name)); const isnt = d.missing.length === 1 ? "isn't" : "aren't"; @@ -234,6 +246,13 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan : ["accent", `Looking for ${who} on this network…`]; if (d.cause) return ["warn", `Can't find ${who} on this network: ${lanCauseText(d.cause)}`]; if (d.here.length > 0) return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); + const only = d.missing.length === 1 ? d.missing[0] : null; + if (only?.why) + return [ + only.why.kind === "quiet" ? "accent" : "warn", + `Can't find ${only.name} on this network: ${lanWhyText(only.why, only.name)}`, + ]; + if (d.missing.some((m) => m.why)) return ["warn", `Can't find ${who} on this network. Show why.`]; if (d.missing.every((m) => m.seen_at)) { const latest = Math.max(...d.missing.map((m) => m.seen_at ?? 0)); return ["accent", `${who} ${isnt} on this network now (seen ${timeAgo(latest)}).`]; @@ -296,6 +315,42 @@ function lanCauseText(c: LanCause): string { } } +/** What a missing machine's own report says, after "Can't find X on this network: ". */ +function lanWhyText(w: LanWhy, name: string): string { + switch (w.kind) { + case "off": + return `Home network sync isn't on there. On ${name}, pick Home network… under Sync & backup.`; + case "quiet": + return `it last checked in ${timeAgo(w.since)}. Make sure it's awake, with Patch Pilot open and online.`; + case "their_vpn": + return "a VPN is on there, so its messages go through it instead of your home network. Turn it off there, or let it allow local network access."; + case "one_way": + return "it hears this machine, but nothing it sends gets here. Allow Patch Pilot through the firewall on this machine."; + case "other_network": + return "it's on a different network. Connect both machines to the same Wi-Fi or wired network."; + case "blocked": + return "both are on the same network, but neither hears the other. A firewall may be blocking Patch Pilot, or the router keeps devices apart (a guest network, or “client isolation” in its settings)."; + } +} + +/** The same, short, for the machine's row in the details. */ +function lanWhyShort(w: LanWhy): string { + switch (w.kind) { + case "off": + return "Home network isn't on there"; + case "quiet": + return `last checked in ${timeAgo(w.since)}`; + case "their_vpn": + return "a VPN is on there"; + case "one_way": + return "hears this machine, but nothing from it gets here"; + case "other_network": + return "on a different network"; + case "blocked": + return "on this network too, but neither hears the other"; + } +} + /** "A", "A and B", "A, B and C". */ function andList(items: string[]): string { return items.length <= 1 ? (items[0] ?? "") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`; @@ -306,32 +361,51 @@ function LanMachines({ lan }: { lan: LanView }) { const d = lan.diagnosis; if (d.here.length === 0 && d.missing.length === 0) return null; const cause = d.cause?.kind; + const deaf = d.deaf ?? []; + const whys = new Set(d.missing.map((m) => m.why?.kind)); const items: [string, boolean][] = [ - ["Both machines are on the same Wi-Fi or wired network.", false], - ["No VPN is on, or it allows local network access.", cause === "vpn" || cause === "vpn_on"], + ["Both machines are on the same Wi-Fi or wired network.", whys.has("other_network")], + ["No VPN is on, or it allows local network access.", cause === "vpn" || cause === "vpn_on" || whys.has("their_vpn")], ["On Windows, the network is set to Private (Settings › Network & internet).", cause === "public_network"], - ["On Windows, Patch Pilot is allowed through Windows Firewall on private networks.", false], + [ + "On Windows, Patch Pilot is allowed through Windows Firewall on private networks.", + whys.has("one_way") || whys.has("blocked") || (deaf.length > 0 && !cause), + ], ["On a Mac, Patch Pilot is on in System Settings › Privacy & Security › Local Network.", cause === "local_network_denied"], - ["Patch Pilot is open on the other machine, with Home network picked under Sync & backup.", false], + [ + "Patch Pilot is open on the other machine, with Home network picked under Sync & backup.", + whys.has("off") || whys.has("quiet"), + ], ["Both machines run the same Patch Pilot version.", cause === "other_version"], ]; + // Reasons from the other machines' reports, in full: the line is cut. + const reasons = d.missing.flatMap((m) => (m.why ? [`${m.name}: ${lanWhyText(m.why, m.name)}`] : [])); + const troubled = (d.missing.length > 0 && !d.looking) || deaf.length > 0; return (

{d.here.map((n, i) => (

{n} · on this network + {deaf.includes(n) && ", but it can't hear this machine"}

))} {d.missing.map((m, i) => (

{m.name} ·{" "} {m.seen_at ? `last on this network ${timeAgo(m.seen_at)}` : d.looking ? "looking…" : "not found on this network"} + {m.why && !d.looking && ` · ${lanWhyShort(m.why)}`}

))} - {d.missing.length > 0 && !d.looking && ( + {troubled && ( <> {/* The line above is cut to one line; the reason is here in full. */} {d.cause &&

Likely reason: {lanCauseText(d.cause)}

} + {!d.looking && + reasons.map((r, i) => ( +

+ {r} +

+ ))}

Can't find a machine? Check that:

    {items.map(([text, likely]) => ( diff --git a/app/src/api.ts b/app/src/api.ts index 575092c..1aeda66 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -584,15 +584,26 @@ export type LanCause = | { kind: "other_rig" } | { kind: "vpn_on"; adapter?: string }; +/** Why one machine can't be found, from its own report over the relay; mirrors patchpilot_local::lan_diagnosis::Why. */ +export type LanWhy = + | { kind: "off" } + | { kind: "quiet"; since: number } + | { kind: "their_vpn" } + | { kind: "one_way" } + | { kind: "other_network" } + | { kind: "blocked" }; + /** Who's on this network, who's missing and why; mirrors patchpilot_local::lan_diagnosis::Diagnosis. */ export interface LanDiagnosis { /** Linked machines on this network now, by name. */ here: string[]; - /** Linked machines not on this network now; `seen_at` if heard since launch. */ - missing: { name: string; seen_at?: number }[]; + /** Linked machines not on this network now; `seen_at` if heard since launch, `why` from its own report. */ + missing: { name: string; seen_at?: number; why?: LanWhy }[]; /** Still in the first 30 s: "Looking for…", not "Can't find…". */ looking: boolean; cause?: LanCause; + /** Machines heard here that say they don't hear this one, by name. */ + deaf?: string[]; } export interface LanView { diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs index 1359e84..a35cdde 100644 --- a/crates/local/src/lan_diagnosis.rs +++ b/crates/local/src/lan_diagnosis.rs @@ -4,10 +4,12 @@ //! and [`diagnose`] turns them into what the Sync & backup line says. Nothing //! here opens a socket or runs a command; [`crate::netinfo`] does the looking. //! -//! A cause is only named when this machine can tell. "The other machine is -//! on a different Wi-Fi" or "its firewall blocks us" can't be seen from here, -//! so those stay on the checklist the details show. +//! A cause is only named when this machine can tell. What only the other +//! machine knows (it's on another Wi-Fi, its VPN is on, it never hears this +//! one) comes from its [`NetReport`] over the relay (decision #124) and is +//! named per machine as a [`Why`], only while that report is fresh. +use std::collections::BTreeMap; use std::io::ErrorKind; use std::net::IpAddr; use std::time::Duration; @@ -15,6 +17,7 @@ use std::time::Duration; use serde::Serialize; use crate::lan::PORT; +use crate::net_report::{NetReport, Subnet, share_a_network}; /// How long the line says "Looking for…" before it says "Can't find…". The /// app checks every beacon (10 s), so in practice 30-40 s. @@ -35,8 +38,16 @@ pub struct Seen { /// Heard from in the last 45 s. pub here: bool, pub seen_at: i64, + /// When this stretch of hearing it began (Unix seconds). + pub since: i64, } +/// A machine that's heard here only counts as unable to hear this one once +/// its report was made this long after it was first heard: time enough for +/// several beacons and the hello back. Seconds; allows for clocks a little +/// apart. +const DEAF_AFTER_SECS: i64 = 90; + /// A network interface: its name as the OS shows it ("Wi-Fi", "en0"), its /// addresses with their prefix lengths, and on Windows the adapter's /// description when it was looked up ("PANGP Virtual Ethernet Adapter"): @@ -116,6 +127,17 @@ pub struct Facts { /// Windows only; None elsewhere or when unknown. pub profile: Option, pub macos: bool, + /// This machine's id, to find it in the others' reports. + pub this_id: String, + /// Unix seconds now. + pub now: i64, + /// Connected to the relay, so a quiet machine is really quiet. + pub relay_online: bool, + /// This machine's home-network subnets, as last looked at; empty when + /// it hasn't looked. + pub subnets: Vec, + /// The other machines' latest reports, by id (decision #124). + pub reports: BTreeMap, } /// What the Sync & backup line says about the home network. Mirrored in @@ -131,6 +153,10 @@ pub struct Diagnosis { /// The most likely reason this machine can see, while one is missing. #[serde(skip_serializing_if = "Option::is_none")] pub cause: Option, + /// Machines heard here that say they don't hear this one, by name: they + /// get nothing from this machine. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub deaf: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -139,6 +165,29 @@ pub struct Missing { /// When it was last heard on this network, since launch. #[serde(skip_serializing_if = "Option::is_none")] pub seen_at: Option, + /// What its own report says is wrong. + #[serde(skip_serializing_if = "Option::is_none")] + pub why: Option, +} + +/// Why one machine can't be found, from its own report. Mirrored in +/// `app/src/api.ts`; the words are in `Backup.tsx`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Why { + /// Home network sync isn't picked there. + Off, + /// No report for a while: asleep, Patch Pilot closed, or offline. + Quiet { since: i64 }, + /// Its broadcasts leave through a VPN or a virtual adapter. + TheirVpn, + /// It hears this machine, but nothing it sends arrives here. + OneWay, + /// It's on another network. + OtherNetwork, + /// The same network, both listening, no VPN, and neither hears the + /// other: a firewall, or the router keeping devices apart. + Blocked, } /// Mirrored in `app/src/api.ts`; the words are in `Backup.tsx`. @@ -194,30 +243,89 @@ pub fn diagnose(f: &Facts) -> Diagnosis { // The name it last broadcast is the freshest. name: seen.map_or_else(|| e.name.clone(), |s| s.name.clone()), seen_at: seen.map(|s| s.seen_at), + why: why(f, &e.id), } }) .collect(); + let mut deaf: Vec = f + .seen + .iter() + .filter(|s| s.here && f.expected.iter().any(|e| e.id == s.id)) + .filter(|s| { + f.reports.get(&s.id).is_some_and(|r| { + r.on && r.is_fresh(f.now) + && r.at >= s.since + DEAF_AFTER_SECS + && !r.hears.contains(&f.this_id) + }) + }) + .map(|s| s.name.clone()) + .collect(); + deaf.sort(); let looking = !missing.is_empty() && f.listening_for < GRACE; - let cause = if missing.is_empty() || looking { + let cause = if (missing.is_empty() || looking) && deaf.is_empty() { None } else { - cause(f, !here.is_empty()) + cause(f, !here.is_empty(), !deaf.is_empty()) }; Diagnosis { here, missing, looking, cause, + deaf, + } +} + +/// What a missing machine's own report says, surest first. Only "off" holds +/// however old the report is: the machine would have said if it turned the +/// home network on. +fn why(f: &Facts, id: &str) -> Option { + let r = f.reports.get(id)?; + if !r.on { + return Some(Why::Off); } + if !r.is_fresh(f.now) { + // Offline here, every report goes stale: that says nothing. + return f.relay_online.then_some(Why::Quiet { since: r.at }); + } + if r.vpn == Some(true) { + return Some(Why::TheirVpn); + } + if r.hears.contains(&f.this_id) { + return Some(Why::OneWay); + } + if r.subnets.is_empty() || f.subnets.is_empty() { + return None; + } + if !share_a_network(&r.subnets, &f.subnets) { + return Some(Why::OtherNetwork); + } + // Same network: blocked only when neither side's packets go astray. + (matches!(f.broadcast_egress, Some(Egress::Lan)) && r.vpn == Some(false)) + .then_some(Why::Blocked) } /// The first cause that fits, surest first. While another linked machine is /// here, this machine's network works, so only causes on the other side -/// (another version, another rig) are named. -fn cause(f: &Facts, someone_here: bool) -> Option { +/// (another version, another rig) are named, or, when a machine here can't +/// hear this one, this machine's packets leaving the wrong way. +fn cause(f: &Facts, someone_here: bool, someone_deaf: bool) -> Option { if f.other_version { return Some(Cause::OtherVersion); } + if someone_here && someone_deaf { + match &f.broadcast_egress { + Some(Egress::Vpn { adapter }) => { + return Some(Cause::Vpn { + adapter: adapter.clone(), + }); + } + Some(Egress::Virtual { name }) => { + return Some(Cause::VirtualAdapter { name: name.clone() }); + } + _ => {} + } + } if !someone_here { if f.has_lan == Some(false) { return Some(Cause::NoLan); @@ -333,6 +441,24 @@ fn kind_with_route(i: &Iface, route: Option) -> AdapterKind { } } +/// This machine's home-network subnets: the Wi-Fi and Ethernet ones, +/// sorted, for its [`NetReport`]. +pub fn subnets(ifaces: &[Iface], route: Option) -> Vec { + let mut out: Vec = ifaces + .iter() + .filter(|i| kind_with_route(i, route) == AdapterKind::Lan) + .flat_map(|i| &i.ips) + .filter(|(ip, _)| lan_v4(ip)) + .filter_map(|(ip, prefix)| match ip { + IpAddr::V4(v4) => Subnet::of(*v4, *prefix), + IpAddr::V6(_) => None, + }) + .collect(); + out.sort(); + out.dedup(); + out +} + /// True when some Wi-Fi or Ethernet interface has a home-network address. /// `route`: the address internet traffic leaves from, if known. pub fn has_lan(ifaces: &[Iface], route: Option) -> bool { @@ -470,6 +596,7 @@ mod tests { name: "Laptop (renamed)".into(), here: false, seen_at: 1_700_000_000, + since: 1_699_999_000, }], ..alone() }; @@ -478,7 +605,8 @@ mod tests { d.missing, vec![Missing { name: "Laptop (renamed)".into(), - seen_at: Some(1_700_000_000) + seen_at: Some(1_700_000_000), + why: None, }] ); } @@ -490,6 +618,7 @@ mod tests { name: "Desktop".into(), here: true, seen_at: 1, + since: 1, }; let f = Facts { expected: vec![ @@ -527,6 +656,7 @@ mod tests { name: "MacBook".into(), here: true, seen_at: 1, + since: 1, }], ..alone() }); @@ -809,4 +939,201 @@ mod tests { serde_json::json!({ "kind": "virtual_adapter", "name": "docker0" }) ); } + + const NOW: i64 = 1_800_000_000; + + fn home() -> Subnet { + Subnet::of(Ipv4Addr::new(192, 168, 1, 20), 24).unwrap() + } + + /// The laptop's report: on, fresh, on the home network, no VPN, hears + /// nobody. + fn report() -> NetReport { + NetReport { + version: 1, + at: NOW - 60, + on: true, + subnets: vec![home()], + vpn: Some(false), + hears: vec![], + } + } + + /// Alone, with the laptop's report in and this machine's subnet known. + fn compared(r: NetReport) -> Facts { + Facts { + this_id: "desk".into(), + now: NOW, + relay_online: true, + subnets: vec![home()], + reports: [("laptop".to_string(), r)].into(), + ..alone() + } + } + + fn why_of(f: &Facts) -> Option { + diagnose(f).missing[0].why.clone() + } + + #[test] + fn the_other_machines_report_says_why_surest_first() { + assert_eq!(why_of(&compared(report())), Some(Why::Blocked)); + let off = NetReport { + at: NOW - 86_400, + ..NetReport::off(0) + }; + assert_eq!( + why_of(&compared(off)), + Some(Why::Off), + "off holds however old" + ); + let old = NetReport { + at: NOW - 3_600, + ..report() + }; + assert_eq!( + why_of(&compared(old.clone())), + Some(Why::Quiet { since: NOW - 3_600 }) + ); + assert_eq!( + why_of(&Facts { + relay_online: false, + ..compared(old) + }), + None, + "offline here, every report looks old" + ); + let vpn = NetReport { + vpn: Some(true), + hears: vec!["desk".into()], + ..report() + }; + assert_eq!(why_of(&compared(vpn)), Some(Why::TheirVpn)); + let hears_us = NetReport { + hears: vec!["desk".into()], + ..report() + }; + assert_eq!(why_of(&compared(hears_us)), Some(Why::OneWay)); + let elsewhere = NetReport { + subnets: vec![Subnet::of(Ipv4Addr::new(10, 0, 0, 7), 24).unwrap()], + ..report() + }; + assert_eq!(why_of(&compared(elsewhere)), Some(Why::OtherNetwork)); + } + + #[test] + fn a_report_names_nothing_it_cant_back() { + // Neither side looked at its network yet. + let unknown = NetReport { + subnets: vec![], + ..report() + }; + assert_eq!(why_of(&compared(unknown)), None); + assert_eq!( + why_of(&Facts { + subnets: vec![], + ..compared(report()) + }), + None + ); + // Same network, but its VPN state unknown, or this machine's + // broadcasts going astray: not "blocked". + let vpn_unknown = NetReport { + vpn: None, + ..report() + }; + assert_eq!(why_of(&compared(vpn_unknown)), None); + assert_eq!( + why_of(&Facts { + broadcast_egress: Some(Egress::Unknown), + ..compared(report()) + }), + None + ); + // No report at all: an older Patch Pilot there. + assert_eq!( + why_of(&Facts { + reports: BTreeMap::new(), + ..compared(report()) + }), + None + ); + // Another machine's report doesn't speak for the laptop. + assert_eq!( + why_of(&Facts { + reports: [("studio".to_string(), NetReport::off(NOW))].into(), + ..compared(report()) + }), + None + ); + } + + #[test] + fn a_machine_heard_here_that_doesnt_hear_this_one_is_deaf() { + let heard = |since: i64| Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: true, + seen_at: NOW, + since, + }; + let f = Facts { + seen: vec![heard(NOW - 600)], + ..compared(report()) + }; + let d = diagnose(&f); + assert!(d.missing.is_empty()); + assert_eq!(d.deaf, vec!["Brian's Laptop".to_string()]); + assert_eq!(d.cause, None, "nothing wrong this machine can see"); + // Its report from before it could have heard this one. + let early = Facts { + seen: vec![heard(NOW - 100)], + ..f.clone() + }; + assert!(diagnose(&early).deaf.is_empty()); + // It does hear this one. + let fine = compared(NetReport { + hears: vec!["desk".into()], + ..report() + }); + assert!( + diagnose(&Facts { + seen: f.seen.clone(), + ..fine + }) + .deaf + .is_empty() + ); + // This machine's broadcasts leave through a VPN: that's why. + let d = diagnose(&Facts { + broadcast_egress: Some(Egress::Vpn { + adapter: Some("NordLynx".into()), + }), + ..f + }); + assert_eq!( + d.cause, + Some(Cause::Vpn { + adapter: Some("NordLynx".into()) + }) + ); + } + + #[test] + fn subnets_come_from_wifi_and_ethernet_only() { + let ifaces = vec![ + Iface { + ips: vec![(ip(192, 168, 1, 20), 24), (ip(192, 168, 1, 21), 24)], + ..iface("Wi-Fi", ip(0, 0, 0, 0)) + }, + iface("NordLynx", ip(10, 5, 0, 2)), + iface("vEthernet (WSL)", ip(172, 29, 0, 1)), + Iface { + ips: vec![(ip(169, 254, 3, 4), 16), ("fe80::1".parse().unwrap(), 64)], + ..iface("Ethernet", ip(0, 0, 0, 0)) + }, + ]; + assert_eq!(subnets(&ifaces, None), vec![home()]); + assert!(subnets(&[], None).is_empty()); + } } diff --git a/crates/local/src/lib.rs b/crates/local/src/lib.rs index f9d135f..b26d8cb 100644 --- a/crates/local/src/lib.rs +++ b/crates/local/src/lib.rs @@ -20,6 +20,7 @@ pub mod ignore; pub mod lan; pub mod lan_diagnosis; pub mod mac_keys; +pub mod net_report; pub mod netinfo; pub mod open; pub mod quiet; diff --git a/crates/local/src/net_report.rs b/crates/local/src/net_report.rs new file mode 100644 index 0000000..1abf9a6 --- /dev/null +++ b/crates/local/src/net_report.rs @@ -0,0 +1,245 @@ +//! What each linked machine tells the others about its home network, over +//! the relay (decision #124, amends #123). One machine alone can't see that +//! the other is on another Wi-Fi, has a VPN on, doesn't use the home network +//! or never hears it; the other machine can say so. Pure: the app seals it +//! as `net/` and moves the bytes. +//! +//! It goes out when what it says changes and, only while a linked machine is +//! missing or can't hear this one, every [`HEARTBEAT`] so the others know +//! this machine is still running. Nothing goes out while everything works. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// Always this when written by this build. Newer fields are added with +/// defaults, so any version reads. +pub const REPORT_VERSION: u32 = 1; +/// While something is wrong, a report goes out at least this often. +pub const HEARTBEAT: Duration = Duration::from_secs(5 * 60); +/// A changed report waits this long after the last one, so a flapping +/// network doesn't keep writing to the relay. +pub const MIN_GAP: Duration = Duration::from_secs(30); +/// A report older than this says nothing about now: the machine is asleep, +/// Patch Pilot is closed, or it's offline. Seconds; over two heartbeats. +pub const FRESH_SECS: i64 = 12 * 60; +/// Biggest object this reads; a report is a few hundred bytes. +const MAX_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct NetReport { + #[serde(default)] + pub version: u32, + /// Unix seconds UTC, on the sending machine's clock. + pub at: i64, + /// Home network sync is picked there and listening. + pub on: bool, + /// Its home-network subnets (Wi-Fi, Ethernet): network address and + /// prefix only. Empty when it hasn't looked. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subnets: Vec, + /// Its broadcasts leave through a VPN or a virtual adapter. None when it + /// hasn't looked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vpn: Option, + /// Linked machines it hears on its network now, by id. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hears: Vec, +} + +impl NetReport { + /// A machine that doesn't use the home network. + pub fn off(at: i64) -> Self { + Self { + version: REPORT_VERSION, + at, + ..Self::default() + } + } + + /// Says the same as `other`, whenever each was made. + pub fn same_news(&self, other: &Self) -> bool { + Self { + at: 0, + ..self.clone() + } == Self { + at: 0, + ..other.clone() + } + } + + /// Made recently enough to say something about now. A clock a little + /// ahead counts as now. + pub fn is_fresh(&self, now: i64) -> bool { + now - self.at.min(now) <= FRESH_SECS + } + + pub fn to_bytes(&self) -> Vec { + serde_json::to_vec(self).expect("a report serializes") + } + + /// None for anything that isn't a report. + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() > MAX_BYTES { + return None; + } + serde_json::from_slice(bytes).ok() + } +} + +/// An IPv4 network: its address with the host bits cleared, and the prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Subnet { + pub net: Ipv4Addr, + pub prefix: u8, +} + +impl Subnet { + /// The network `ip` is on. None for a prefix over 32. + pub fn of(ip: Ipv4Addr, prefix: u8) -> Option { + (prefix <= 32).then(|| Self { + net: mask(ip, prefix), + prefix, + }) + } + + /// One holds the other: the same network, even when two machines were + /// given different prefixes for it. + pub fn overlaps(&self, other: &Self) -> bool { + let p = self.prefix.min(other.prefix).min(32); + mask(self.net, p) == mask(other.net, p) + } +} + +fn mask(ip: Ipv4Addr, prefix: u8) -> Ipv4Addr { + let bits = match prefix { + 0 => 0, + p => u32::MAX << (32 - u32::from(p.min(32))), + }; + Ipv4Addr::from(u32::from(ip) & bits) +} + +/// True when two machines' subnet lists share a network. +pub fn share_a_network(a: &[Subnet], b: &[Subnet]) -> bool { + a.iter().any(|x| b.iter().any(|y| x.overlaps(y))) +} + +/// Whether to send `new` now. `last`: the report last sent and how long ago. +/// `heartbeat`: a linked machine is missing or can't hear this one, so the +/// others should know this machine is still running. +pub fn should_send(new: &NetReport, last: Option<(&NetReport, Duration)>, heartbeat: bool) -> bool { + match last { + None => true, + Some((sent, ago)) => { + (!new.same_news(sent) && ago >= MIN_GAP) || (heartbeat && new.on && ago >= HEARTBEAT) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn net(a: u8, b: u8, c: u8, d: u8, prefix: u8) -> Subnet { + Subnet::of(Ipv4Addr::new(a, b, c, d), prefix).unwrap() + } + + fn on() -> NetReport { + NetReport { + version: REPORT_VERSION, + at: 1_000, + on: true, + subnets: vec![net(192, 168, 1, 20, 24)], + vpn: Some(false), + hears: vec!["desktop".into()], + } + } + + #[test] + fn a_subnet_keeps_only_the_network_part() { + let s = net(192, 168, 1, 20, 24); + assert_eq!(s.net, Ipv4Addr::new(192, 168, 1, 0)); + assert_eq!(net(10, 1, 2, 3, 8).net, Ipv4Addr::new(10, 0, 0, 0)); + assert_eq!(net(10, 1, 2, 3, 32).net, Ipv4Addr::new(10, 1, 2, 3)); + assert_eq!(net(10, 1, 2, 3, 0).net, Ipv4Addr::UNSPECIFIED); + assert_eq!(Subnet::of(Ipv4Addr::LOCALHOST, 33), None); + } + + #[test] + fn networks_match_even_with_different_prefixes() { + let home = net(192, 168, 1, 20, 24); + assert!(home.overlaps(&net(192, 168, 1, 77, 24))); + assert!( + home.overlaps(&net(192, 168, 0, 5, 16)), + "a /16 holds the /24" + ); + assert!(!home.overlaps(&net(192, 168, 2, 5, 24))); + assert!(!home.overlaps(&net(10, 0, 0, 5, 8))); + assert!(share_a_network( + &[net(10, 0, 0, 2, 24), home], + &[net(192, 168, 1, 9, 24)] + )); + assert!(!share_a_network(&[home], &[])); + } + + #[test] + fn a_report_round_trips_and_reads_older_and_newer_ones() { + let r = on(); + assert_eq!(NetReport::from_bytes(&r.to_bytes()), Some(r)); + let json = String::from_utf8(on().to_bytes()).unwrap(); + assert!(json.contains(r#""net":"192.168.1.0""#), "{json}"); + // A newer build's extra field, and a bare one. + let newer = br#"{"version":2,"at":5,"on":true,"wifi":"Home"}"#; + assert_eq!(NetReport::from_bytes(newer).map(|r| r.on), Some(true)); + let bare = NetReport::from_bytes(br#"{"at":5,"on":false}"#).unwrap(); + assert_eq!( + bare, + NetReport { + at: 5, + ..NetReport::default() + } + ); + assert_eq!(NetReport::from_bytes(b"not json"), None); + assert_eq!(NetReport::from_bytes(&vec![b' '; MAX_BYTES + 1]), None); + // Off says nothing else. + let off = String::from_utf8(NetReport::off(5).to_bytes()).unwrap(); + assert_eq!(off, r#"{"version":1,"at":5,"on":false}"#); + } + + #[test] + fn fresh_means_recent_on_the_senders_clock() { + let r = on(); + assert!(r.is_fresh(r.at)); + assert!(r.is_fresh(r.at + FRESH_SECS)); + assert!(!r.is_fresh(r.at + FRESH_SECS + 1)); + assert!(r.is_fresh(r.at - 60), "a clock a little ahead"); + } + + #[test] + fn sends_on_change_with_a_gap_and_heartbeats_only_while_something_is_wrong() { + let sent = on(); + let later = NetReport { at: 2_000, ..on() }; + assert!(should_send(&later, None, false), "the first one"); + assert!( + !should_send(&later, Some((&sent, Duration::from_secs(3600))), false), + "nothing new, nothing wrong" + ); + let moved = NetReport { + subnets: vec![net(10, 0, 0, 2, 24)], + ..later.clone() + }; + assert!( + !should_send(&moved, Some((&sent, MIN_GAP / 2)), false), + "too soon" + ); + assert!(should_send(&moved, Some((&sent, MIN_GAP)), false)); + assert!(!should_send(&later, Some((&sent, HEARTBEAT / 2)), true)); + assert!(should_send(&later, Some((&sent, HEARTBEAT)), true)); + let off = NetReport::off(2_000); + assert!( + !should_send(&off, Some((&NetReport::off(1), HEARTBEAT)), true), + "off needs no heartbeat" + ); + } +} diff --git a/crates/local/src/sync.rs b/crates/local/src/sync.rs index ee9dece..680b026 100644 --- a/crates/local/src/sync.rs +++ b/crates/local/src/sync.rs @@ -5,7 +5,9 @@ use std::path::{Path, PathBuf}; use patchpilot_store::{ImportReport, Store}; -use patchpilot_sync::{RigKey, machine_object}; +use patchpilot_sync::{RigKey, machine_object, net_object}; + +use crate::net_report::NetReport; use crate::now; @@ -100,6 +102,32 @@ pub fn is_other_machine(object: &str, this: &str) -> bool { object.starts_with("machine/") && Some(object) != machine_object(this).ok().as_deref() } +/// This machine's home-network report for the relay, sealed (decision #124). +pub fn outgoing_report( + this: &str, + rig: &RigKey, + report: &NetReport, +) -> Result<(String, Vec), String> { + let object = net_object(this).map_err(|e| e.to_string())?; + let sealed = rig + .seal_object(&object, &report.to_bytes()) + .map_err(|e| e.to_string())?; + Ok((object, sealed)) +} + +/// The machine id when `object` is another machine's home-network report. +pub fn other_report<'a>(object: &'a str, this: &str) -> Option<&'a str> { + object + .strip_prefix("net/") + .filter(|id| *id != this && net_object(id).is_ok()) +} + +/// Open another machine's home-network report. None when it won't open or +/// isn't one. +pub fn incoming_report(rig: &RigKey, object: &str, sealed: &[u8]) -> Option { + NetReport::from_bytes(&rig.open_object(object, sealed).ok()?) +} + /// Open another machine's object and add what's new, with the same add-only /// rules as an export file. pub fn incoming( @@ -205,4 +233,19 @@ mod tests { .is_err() ); } + + #[test] + fn reports_seal_for_linked_machines_only() { + let rig = RigKey::generate(); + let report = NetReport::off(5); + let (object, sealed) = outgoing_report("pc", &rig, &report).unwrap(); + assert_eq!(object, "net/pc"); + assert_eq!(incoming_report(&rig, &object, &sealed), Some(report)); + assert_eq!(incoming_report(&RigKey::generate(), &object, &sealed), None); + assert_eq!(incoming_report(&rig, "net/mac", &sealed), None); + assert_eq!(other_report("net/mac", "pc"), Some("mac")); + assert_eq!(other_report("net/pc", "pc"), None, "this machine's own"); + assert_eq!(other_report("machine/mac", "pc"), None); + assert_eq!(other_report("net/Mac", "pc"), None); + } } diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs index 6363e40..a4c519f 100644 --- a/crates/sync/src/lib.rs +++ b/crates/sync/src/lib.rs @@ -16,7 +16,7 @@ pub mod link; mod rig; pub use code::LinkCode; -pub use rig::{RigKey, content_hash, machine_object}; +pub use rig::{RigKey, content_hash, machine_object, net_object}; #[derive(Debug, thiserror::Error)] pub enum Error { diff --git a/crates/sync/src/rig.rs b/crates/sync/src/rig.rs index c2ba5c4..4cd50ab 100644 --- a/crates/sync/src/rig.rs +++ b/crates/sync/src/rig.rs @@ -134,6 +134,24 @@ impl RigKey { Ok(bytes) } + /// Encrypt a small object for storing under `object` on the relay, such + /// as a machine's home-network report ([`net_object`]). Bound to its + /// name, so it opens under no other. + pub fn seal_object(&self, object: &str, bytes: &[u8]) -> Result> { + check_object(object)?; + Ok(envelope::seal( + &self.encryption_key(), + &self.aad(object), + bytes, + )) + } + + /// Decrypt what [`Self::seal_object`] sealed under `object`. + pub fn open_object(&self, object: &str, sealed: &[u8]) -> Result> { + check_object(object)?; + envelope::open(&self.encryption_key(), &self.aad(object), sealed) + } + /// Encrypt a message between linked machines on the home network /// (decision #94). `label` says what it is (a beacon, a request, the /// answer to one request), so a message replayed as something else fails @@ -197,6 +215,14 @@ pub fn machine_object(machine_id: &str) -> Result { Ok(name) } +/// The relay object holding what a machine says about its home network +/// (decision #124): `net/`. +pub fn net_object(machine_id: &str) -> Result { + let name = format!("net/{machine_id}"); + check_object(&name)?; + Ok(name) +} + /// Lowercase letters, digits, `-` and `/`-separated segments only, so object /// names are safe as relay keys and URL paths on every platform. fn check_object(name: &str) -> Result<()> { @@ -244,6 +270,20 @@ mod tests { b } + #[test] + fn small_objects_open_only_under_their_own_name_and_rig() { + let rig = RigKey::generate(); + let name = net_object("a1").unwrap(); + assert_eq!(name, "net/a1"); + let sealed = rig.seal_object(&name, b"{}").unwrap(); + assert_eq!(rig.open_object(&name, &sealed).unwrap(), b"{}"); + assert!(rig.open_object("net/b2", &sealed).is_err()); + assert!(rig.open_object("machine/a1", &sealed).is_err()); + assert!(RigKey::generate().open_object(&name, &sealed).is_err()); + assert!(net_object("A1").is_err()); + assert!(rig.seal_object("net/../x", b"").is_err()); + } + #[test] fn backup_signatures_check_only_for_the_same_rig_and_bytes() { let rig = RigKey::generate(); diff --git a/docs/decisions.md b/docs/decisions.md index 96393be..953dfac 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,3 +128,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | | 122 | 2026-11-06 | **One "Scripts, templates and presets" guide on every screen that moves them** (Joshua: a first-time tester couldn't tell how to get Reaper scripts and templates working across machines). A `FilesGuide` Notice line (app/src/FilesGuide.tsx) sits in Get Up To Speed (this machine), Set up, This machine and Settings > Sync. Its line names the next thing to do (link the machines, then Keep Files in Sync) with that button, or turns green once both are done; How it works opens the ways in order, each ticked when done here: link your machines, Bring over what's missing (from the Primary Rig), Keep Files in Sync on every machine (same cloud folder, GitHub repo or home network), Choose what syncs, and what's never copied (plug-ins, Reaper extensions, sample libraries; reaper.ini and audio/MIDI settings stay per machine). On a checklist it starts open until syncing is on; elsewhere folded; the toggle is remembered. On This machine it hides once linked and syncing, since Sync & backup says the rest. Buttons reach the Link dialog and other tabs through a small `ShellContext` (app/src/shell.ts) instead of props through every screen. Bring over's "link first" line gets a Link a machine button, Set up's intro and tour step 5 say to link first. | The ways existed but were spread over four places with nothing saying which comes first or that linking is required; one line per screen keeps it compact (decision #68) while answering "how do my scripts get there?" where people look. | Accepted | | 123 | 2026-11-06 | **Home network says why it can't find a machine** (amends #94; Brian: his machines were linked but home-network sync never found the laptop, which was on another Wi-Fi and a VPN, and nothing said why). Linking only uses the relay, so it worked; the beacons (UDP broadcast to 255.255.255.255) never reached the other machine, and "heard nobody" wasn't an error: the line said "Looking for your other machines…" forever, or a green "In sync…". Now the Sync & backup line names the linked machines it expects and says Looking for X for the first 30 s after the home network is picked (checked with each 10 s beacon, so 30-40 s), then Can't find X on this network, with a cause when this machine can tell; a machine that was here and left reads "isn't on this network now (seen 2 h ago)" instead of "In sync", and one missing while another is here is added to the In sync line. The opened details list each machine (on this network / last on it / not found) and a checklist (same network, VPN, Windows Private network and firewall, macOS Local Network, Patch Pilot open with Home network on the other machine, same version), the detected cause marked. **Expected machines** are the other `machine/` objects the relay lists when live sync starts, plus `changed` events (no extra relay reads, #113), named from the store; machines added from an export file or an earlier rig, and machines removed from the store (their object stays on the relay), never show as missing. Before the first listing, every other machine in the store; while this machine isn't linked, none. **Causes**, surest first, in `patchpilot_local::lan_diagnosis::diagnose` (pure, tested with hand-built facts): a beacon sealed by this rig that this version can't read, or a newer envelope (other version); no Wi-Fi/Ethernet IPv4 address; broadcasts leaving through a VPN or a virtual adapter (Hyper-V, WSL, Docker, VMware); macOS refusing the broadcast with EHOSTUNREACH while a default route works (Local Network access off: a strong guess, macOS has no API to ask; with no gateway macOS refuses broadcasts the same way); Windows calling the network Public; a Patch Pilot beacon under another rig key heard in the last 45 s; and last, a guess, a VPN holding the internet route. While another linked machine is here, only the other-version and other-rig causes are named, since this machine's network works. What one machine can't see (the other on another Wi-Fi, client isolation, the other's firewall, Home network off there) stays on the checklist. **How it looks** (`patchpilot_local::netinfo`, read-only, sends nothing): a UDP socket with broadcast on, connected to 255.255.255.255:47219, and one connected to 1.1.1.1:53, give the source address of each; sysinfo's `network` feature (unsafe stays inside sysinfo) lists interfaces, and an address on none of them while a home-network address exists is a VPN's, since Windows leaves MAC-less WireGuard/Wintun adapters out. A "virtual" adapter carrying the internet route is the home network itself (a Hyper-V external switch or bridged WSL moves the real address onto vEthernet). Adapters are told apart by name and, on Windows, by the description PowerShell's `Get-NetAdapter` gives, because many VPNs (GlobalProtect's "PANGP Virtual Ethernet Adapter") name theirs "Ethernet 4"; the same call reads `Get-NetConnectionProfile` (DomainAuthenticated counts as Private), with the name in an environment variable and compared exactly (not as a wildcard), no window, below-normal priority (#114; the thread's background mode doesn't pass to a child), killed after 5 s (WMI can hang), cached 10 min per adapter. All of it runs on the existing beacon thread, only while a machine is missing, none is here and the grace is over, at most every 30 s, and the UI is told only when the diagnosis changes; `view()` only reads what's known. Only the broadcast's send result counts (a machine that left answers unicast with "unreachable" too). A pull error goes once its machine is gone. Picking the home network again restarts the 30 s and looks at the network afresh. The port error is worded by its cause (Windows' reserved port ranges give 10013, not "another app"). **Later, separately:** each machine telling the others its network over the relay ("Laptop is on a different network"), and beacons out of every interface so sync works despite a VPN. | The first time a user linked two machines, home-network sync failed with nothing to go on; the fix was a minute's work once the cause was known. Saying only what this machine can actually tell keeps the line trustworthy; the checklist covers the rest. | Proposed | +| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is on and listening, its Wi-Fi/Ethernet subnets (network address and prefix only, e.g. 192.168.1.0/24), whether its broadcasts leave through a VPN or virtual adapter, and which linked machines it hears now (ids). It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) sends it when what it says changes (at most every 30 s), and, only while a linked machine is missing or can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (a report kept from before going offline could say it hears nobody), sends one at the start of every session, reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is); no report for 12 min while this machine is online (asleep, closed, offline); a VPN is on there; it hears this machine but nothing it sends arrives (a firewall here); it's on a different network (no subnet in common, prefixes compared by containment); same network, both listening, neither side's broadcasts going astray and neither hears the other (a firewall, or the router keeping devices apart). Anything but "off" needs a fresh report, and "different network"/"blocked" need both sides to have looked at their network. A machine heard here whose fresh report, made at least 90 s after it was first heard, doesn't list this one is shown as unable to hear this machine (its files aren't reaching it). While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. Subnets and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | From 633bf3e63b1a4c00280ab65614cd23718d2d74d6 Mon Sep 17 00:00:00 2001 From: bfieser Date: Fri, 6 Nov 2026 15:21:35 -0600 Subject: [PATCH 3/6] Home network compare: fix review findings (#219) - A matching subnet alone no longer counts as the same network: most routers use the same few ranges. The relay now answers a PUT with the client's address, and "blocked" needs the same public address too (same family, neither side's internet through a VPN); a different one means "different network". - Report age is judged by when it reached the relay, on this machine's clock (relay x-age header), never the other machine's clock. - "Can't hear this machine" waits until the other machine had listened 30 s when it made its report, so a quick restart isn't deafness. - Peers count as here by the wall clock (a Mac's Instant stops in sleep). - A port that won't open is "can't listen", not "Home network off". - A definite reason from the other machine beats this machine's guesses; the deaf line names only this machine's own VPN/virtual adapter. - A report counts as sent only once the relay took it, so failures retry. - Network facts older than 90 s are left out of reports, and a machine heard here lately is never called quiet. - sync no longer assumes the home-network state exists at startup. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/lan.rs | 90 ++++++++---- app/src-tauri/src/sync.rs | 122 ++++++++++++++-- app/src/Backup.tsx | 12 +- app/src/api.ts | 1 + crates/local/src/lan_diagnosis.rs | 224 ++++++++++++++++++++++++------ crates/local/src/net_report.rs | 183 +++++++++++++++++++++--- docs/decisions.md | 2 +- relay/src/index.ts | 10 +- relay/test/relay.test.ts | 14 ++ 9 files changed, 555 insertions(+), 103 deletions(-) diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index ab99c6d..bbe2a50 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -52,13 +52,17 @@ const MAX_CONNECTIONS: usize = 8; const NET_EVERY: Duration = Duration::from_secs(30); /// Ask Windows about an adapter (PowerShell, about a second) this often. const ADAPTER_EVERY: Duration = Duration::from_secs(600); +/// A report's network facts older than this are left out: they were last +/// looked at during an earlier problem and may no longer hold. +const NET_FACTS_FOR: Duration = Duration::from_secs(90); /// Emitted with a [`Progress`] while a pull runs, then with null. pub const LAN_PROGRESS: &str = "lan-progress"; struct Peer { name: String, addr: SocketAddr, - seen: Instant, + /// When last heard (Unix seconds). The wall clock, not an `Instant`: a + /// Mac's stops while it sleeps. seen_at: i64, /// When this stretch of hearing it began (Unix seconds). since: i64, @@ -100,8 +104,9 @@ struct LanState { net: Mutex, /// The diagnosis last told to the UI. diagnosis: Mutex>, - /// The report last given to `sync` to send, and when. - sent: Mutex>, + /// When `sync` was last asked to send this machine's report: a put + /// that keeps failing is tried again, but not every beacon. + asked: Mutex>, } /// This machine's network as last looked at, while a machine was missing. @@ -354,13 +359,10 @@ fn heard(app: &AppHandle, beacon: Beacon, ip: IpAddr) { let (new, wanted) = { let mut peers = state.peers.lock().unwrap(); let addr = SocketAddr::new(ip, beacon.port); - let new = peers - .get(&beacon.machine_id) - .is_none_or(|p| p.seen.elapsed() > GONE); + let new = peers.get(&beacon.machine_id).is_none_or(|p| !is_here(p)); let peer = peers.entry(beacon.machine_id.clone()).or_insert(Peer { name: String::new(), addr, - seen: Instant::now(), seen_at: 0, since: 0, state: String::new(), @@ -369,7 +371,6 @@ fn heard(app: &AppHandle, beacon: Beacon, ip: IpAddr) { }); peer.name = beacon.machine_name; peer.addr = addr; - peer.seen = Instant::now(); peer.seen_at = patchpilot_local::now(); if new { peer.since = peer.seen_at; @@ -412,7 +413,7 @@ pub fn sync_now(app: &AppHandle) -> Result<(), String> { .lock() .unwrap() .iter() - .filter(|(_, p)| p.seen.elapsed() < GONE) + .filter(|(_, p)| is_here(p)) .map(|(id, _)| id.clone()) .collect(); for id in ids { @@ -573,7 +574,7 @@ fn clear_stale_error(state: &LanState) -> bool { .lock() .unwrap() .get(id) - .is_none_or(|p| p.seen.elapsed() >= GONE) + .is_none_or(|p| !is_here(p)) }); if gone { *from = None; @@ -660,7 +661,7 @@ fn facts(app: &AppHandle) -> Facts { .map(|(id, p)| Seen { id: id.clone(), name: p.name.clone(), - here: p.seen.elapsed() < GONE, + here: is_here(p), seen_at: p.seen_at, since: p.since, }) @@ -686,40 +687,77 @@ fn facts(app: &AppHandle) -> Facts { now: patchpilot_local::now(), relay_online: crate::sync::online(app), subnets: net.subnets, + public: crate::sync::public(app), reports: crate::sync::reports(app), } } +/// Heard from in the last 45 s. +fn is_here(p: &Peer) -> bool { + patchpilot_local::now() - p.seen_at < GONE.as_secs() as i64 +} + /// What this machine tells the others about its home network: on and /// listening, its subnets and whether its broadcasts go astray as last /// looked at, and who it hears now. Quick: no store, no command. pub(crate) fn own_report(app: &AppHandle) -> NetReport { - let state = app.state::(); let now = patchpilot_local::now(); - if !in_use() || state.udp.lock().unwrap().is_none() { + // Sync can start before the home network is set up. + let Some(state) = app.try_state::() else { return NetReport::off(now); + }; + if !in_use() { + return NetReport::off(now); + } + if state.udp.lock().unwrap().is_none() { + // Picked, but the port wouldn't open: this machine says why itself. + return NetReport { + on: true, + listening: false, + ..NetReport::off(now) + }; } let net = state.net.lock().unwrap().clone(); + // Facts from an earlier problem may no longer hold: say nothing then. + let recent = net.checked.is_some_and(|t| t.elapsed() < NET_FACTS_FOR); + let through_vpn = |e: &Option| match e { + Some(Egress::Lan) => Some(false), + Some(Egress::Vpn { .. } | Egress::Virtual { .. }) => Some(true), + Some(Egress::Unknown) | None => None, + }; let mut hears: Vec = state .peers .lock() .unwrap() .iter() - .filter(|(_, p)| p.seen.elapsed() < GONE) + .filter(|(_, p)| is_here(p)) .map(|(id, _)| id.clone()) .collect(); hears.sort(); + let listening_since = state + .listening_since + .lock() + .unwrap() + .map(|t| now - t.elapsed().as_secs() as i64); NetReport { version: net_report::REPORT_VERSION, at: now, on: true, - subnets: net.subnets, - vpn: match net.broadcast_egress { - Some(Egress::Lan) => Some(false), - Some(Egress::Vpn { .. } | Egress::Virtual { .. }) => Some(true), - Some(Egress::Unknown) | None => None, + listening: true, + listening_since, + subnets: if recent { net.subnets } else { Vec::new() }, + vpn: if recent { + through_vpn(&net.broadcast_egress) + } else { + None + }, + internet_vpn: if recent { + through_vpn(&net.route_egress) + } else { + None }, hears, + public: crate::sync::public(app), } } @@ -737,10 +775,14 @@ fn share(app: &AppHandle) { .unwrap() .as_ref() .is_some_and(|d| !d.missing.is_empty() || !d.deaf.is_empty()); - let mut sent = state.sent.lock().unwrap(); - let last = sent.as_ref().map(|(r, at)| (r, at.elapsed())); - if net_report::should_send(&report, last, heartbeat) { - *sent = Some((report, Instant::now())); + // Against what actually reached the relay, so a failed put is retried. + let last = crate::sync::last_report(app); + if !net_report::should_send(&report, last.as_ref().map(|(r, ago)| (r, *ago)), heartbeat) { + return; + } + let mut asked = state.asked.lock().unwrap(); + if asked.is_none_or(|t| t.elapsed() >= net_report::MIN_GAP) { + *asked = Some(Instant::now()); crate::sync::share_report(app); } } @@ -785,7 +827,7 @@ pub fn view(app: &AppHandle) -> Option { .map(|p| LanPeer { machine_name: p.name.clone(), seen_at: p.seen_at, - here: p.seen.elapsed() < GONE, + here: is_here(p), }) .collect(); peers.sort_by_key(|p| std::cmp::Reverse(p.seen_at)); diff --git a/app/src-tauri/src/sync.rs b/app/src-tauri/src/sync.rs index 1935205..42803a1 100644 --- a/app/src-tauri/src/sync.rs +++ b/app/src-tauri/src/sync.rs @@ -20,7 +20,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; -use patchpilot_local::net_report::NetReport; +use patchpilot_local::net_report::{NetReport, Received}; use patchpilot_sync::{LinkCode, RigKey, link}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Listener, Manager, State}; @@ -58,9 +58,15 @@ pub struct Sync { /// unlike machines added from an export file. None until listed. linked: Mutex>>, /// The other machines' home-network reports, by id. - reports: Mutex>, + reports: Mutex>, /// Send this machine's report (`share_report`). report_nudge: Notify, + /// This machine's report as last put on the relay, and when. Only puts + /// that went through, so one that failed is tried again. + report_put: Mutex>, + /// This machine's public address as the relay last saw it + /// (`net_report::public_tag`), from its answer to an upload. + public: Mutex>, running: AtomicBool, nudge: Notify, stop: Notify, @@ -242,7 +248,8 @@ async fn upload(relay: &Relay, state: &Sync, this: &str, rig: &RigKey) -> Result let Some((object, sealed)) = out else { return Ok(()); }; - relay.put(&object, sealed).await?; + let ip = relay.put(&object, sealed).await?; + remember_public(state, ip); upload_files(relay, rig, &files, &state.on_relay).await } @@ -289,7 +296,7 @@ async fn upload_files( // it doesn't take live sync offline. A few in a row means the // connection is gone, and the next upload starts over. match relay.put(&name, sealed).await { - Ok(()) => remember(on_relay, [name]), + Ok(_) => remember(on_relay, [name]), Err(e) => { eprintln!("sync: {} not shared: {e}", file.rel); failed += 1; @@ -326,22 +333,41 @@ pub(crate) async fn fetch_file( /// up waits for the next change or session; it never takes live sync /// offline. async fn send_report(app: &AppHandle, relay: &Relay, this: &str, rig: &RigKey) { + let state = app.state::(); let report = crate::lan::own_report(app); let sent = match patchpilot_local::sync::outgoing_report(this, rig, &report) { Ok((object, sealed)) => relay.put(&object, sealed).await, Err(e) => Err(e), }; - if let Err(e) = sent { - eprintln!("sync: home-network report not sent: {e}"); + match sent { + Ok(ip) => { + remember_public(&state, ip); + *state.report_put.lock().expect("report lock") = + Some((report, std::time::Instant::now())); + } + Err(e) => eprintln!("sync: home-network report not sent: {e}"), } } -/// Another machine's home-network report; None when it has none (an older -/// Patch Pilot) or it won't open. -async fn fetch_report(relay: &Relay, rig: &RigKey, id: &str) -> Option { +/// Keep the public address the relay saw this machine at. +fn remember_public(state: &Sync, ip: Option) { + if let Some(ip) = ip { + *state.public.lock().expect("public lock") = + Some(patchpilot_local::net_report::public_tag(ip)); + } +} + +/// Another machine's home-network report, with when it reached the relay +/// by this machine's clock; None when it has none (an older Patch Pilot) or +/// it won't open. +async fn fetch_report(relay: &Relay, rig: &RigKey, id: &str) -> Option { let object = patchpilot_sync::net_object(id).ok()?; - let sealed = relay.get(&object).await.ok()??; - patchpilot_local::sync::incoming_report(rig, &object, &sealed) + let (sealed, held_ms) = relay.get_aged(&object).await.ok()??; + let report = patchpilot_local::sync::incoming_report(rig, &object, &sealed)?; + Some(Received { + report, + at: patchpilot_local::net_report::received_at(patchpilot_local::now(), held_ms), + }) } /// Fetch and merge another machine's object. True if anything changed. @@ -370,7 +396,7 @@ pub fn share_report(app: &AppHandle) { } /// The other machines' home-network reports, by id, as last read. -pub fn reports(app: &AppHandle) -> BTreeMap { +pub fn reports(app: &AppHandle) -> BTreeMap { app.state::() .reports .lock() @@ -378,6 +404,25 @@ pub fn reports(app: &AppHandle) -> BTreeMap { .clone() } +/// This machine's report as last put on the relay, and how long ago. +pub fn last_report(app: &AppHandle) -> Option<(NetReport, Duration)> { + app.state::() + .report_put + .lock() + .expect("report lock") + .as_ref() + .map(|(r, at)| (r.clone(), at.elapsed())) +} + +/// This machine's public address as the relay last saw it. +pub fn public(app: &AppHandle) -> Option { + app.try_state::()? + .public + .lock() + .expect("public lock") + .clone() +} + /// Connected to the relay now. pub fn online(app: &AppHandle) -> bool { app.state::() @@ -459,6 +504,7 @@ pub fn unlink(state: State<'_, SyncState>) -> Result<(), String> { patchpilot_local::sync::forget_rig(&app_dir()?)?; *state.linked.lock().expect("linked lock") = None; state.reports.lock().expect("reports lock").clear(); + *state.report_put.lock().expect("report lock") = None; state.stop.notify_waiters(); Ok(()) } @@ -554,6 +600,12 @@ struct LiveEvent { name: String, } +#[derive(Debug, Deserialize)] +struct PutReply { + #[serde(default)] + ip: Option, +} + #[derive(Debug, Deserialize)] struct ObjectInfo { name: String, @@ -586,12 +638,17 @@ impl Relay { } } - async fn put(&self, object: &str, sealed: Vec) -> Result<(), String> { + /// Store one object. Gives the public address the relay saw this + /// machine at, when it says (older relays don't). + async fn put(&self, object: &str, sealed: Vec) -> Result, String> { let url = format!("{}/objects/{object}", self.base); let res = self .send(|| self.http.put(&url).body(sealed.clone())) .await?; - check(res).await.map(|_| ()) + let reply = check(res).await?.bytes().await.unwrap_or_default(); + Ok(serde_json::from_slice::(&reply) + .ok() + .and_then(|r| r.ip?.parse().ok())) } /// Send with the token, again after a pause if the connection drops @@ -614,13 +671,24 @@ impl Relay { } async fn get(&self, object: &str) -> Result>, String> { + Ok(self.get_aged(object).await?.map(|(bytes, _)| bytes)) + } + + /// An object and how long the relay has held it (ms), when it says. + async fn get_aged(&self, object: &str) -> Result, Option)>, String> { let url = format!("{}/objects/{object}", self.base); let res = self.send(|| self.http.get(&url)).await?; if res.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } let res = check(res).await?; - Ok(Some(res.bytes().await.map_err(|e| e.to_string())?.to_vec())) + let held = res + .headers() + .get("x-age") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()); + let bytes = res.bytes().await.map_err(|e| e.to_string())?.to_vec(); + Ok(Some((bytes, held))) } /// The objects whose names start with `prefix`. A relay from before @@ -813,6 +881,30 @@ mod tests { let _ = std::fs::remove_file(path); } + #[tokio::test] + #[ignore = "needs a relay (PATCHPILOT_RELAY)"] + async fn home_network_reports_travel_with_their_age() { + let rig = RigKey::generate(); + let relay = Relay::new(&rig); + // The first put claims the rig; the relay says where it came from. + let ip = relay.put("machine/pc", b"sealed".to_vec()).await.unwrap(); + assert!(ip.is_some(), "the relay answers with this machine's address"); + let report = NetReport { + on: true, + hears: vec!["mac".into()], + ..NetReport::off(patchpilot_local::now()) + }; + let (object, sealed) = + patchpilot_local::sync::outgoing_report("pc", &rig, &report).unwrap(); + relay.put(&object, sealed).await.unwrap(); + tokio::time::sleep(Duration::from_millis(1100)).await; + let got = fetch_report(&relay, &rig, "pc").await.unwrap(); + assert_eq!(got.report, report); + assert!(got.at < patchpilot_local::now(), "held about a second: {}", got.at); + assert!(got.at >= patchpilot_local::now() - 5); + assert!(fetch_report(&relay, &rig, "nobody").await.is_none()); + } + #[tokio::test] #[ignore = "needs a relay (PATCHPILOT_RELAY)"] async fn a_code_still_works_after_keep_alive_pings() { diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index 8812a79..8934821 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -244,9 +244,13 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan return d.here.length > 0 ? withNote(filesLine(sync, where, github, lan), `looking for ${who}…`) : ["accent", `Looking for ${who} on this network…`]; - if (d.cause) return ["warn", `Can't find ${who} on this network: ${lanCauseText(d.cause)}`]; - if (d.here.length > 0) return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); const only = d.missing.length === 1 ? d.missing[0] : null; + // A guess here (a VPN for the internet, another rig nearby) gives way to + // what the missing machine itself says. + const guess = d.cause?.kind === "vpn_on" || d.cause?.kind === "other_rig"; + if (d.cause && !(guess && only?.why)) + return ["warn", `Can't find ${who} on this network: ${lanCauseText(d.cause)}`]; + if (d.here.length > 0) return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); if (only?.why) return [ only.why.kind === "quiet" ? "accent" : "warn", @@ -320,6 +324,8 @@ function lanWhyText(w: LanWhy, name: string): string { switch (w.kind) { case "off": return `Home network sync isn't on there. On ${name}, pick Home network… under Sync & backup.`; + case "cant_listen": + return `Patch Pilot there can't open its network port. Open Sync & backup on ${name} to see why.`; case "quiet": return `it last checked in ${timeAgo(w.since)}. Make sure it's awake, with Patch Pilot open and online.`; case "their_vpn": @@ -338,6 +344,8 @@ function lanWhyShort(w: LanWhy): string { switch (w.kind) { case "off": return "Home network isn't on there"; + case "cant_listen": + return "can't open its network port"; case "quiet": return `last checked in ${timeAgo(w.since)}`; case "their_vpn": diff --git a/app/src/api.ts b/app/src/api.ts index 1aeda66..c763b33 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -587,6 +587,7 @@ export type LanCause = /** Why one machine can't be found, from its own report over the relay; mirrors patchpilot_local::lan_diagnosis::Why. */ export type LanWhy = | { kind: "off" } + | { kind: "cant_listen" } | { kind: "quiet"; since: number } | { kind: "their_vpn" } | { kind: "one_way" } diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs index a35cdde..2e7298a 100644 --- a/crates/local/src/lan_diagnosis.rs +++ b/crates/local/src/lan_diagnosis.rs @@ -17,7 +17,7 @@ use std::time::Duration; use serde::Serialize; use crate::lan::PORT; -use crate::net_report::{NetReport, Subnet, share_a_network}; +use crate::net_report::{FRESH_SECS, Received, Subnet, same_public, share_a_network}; /// How long the line says "Looking for…" before it says "Can't find…". The /// app checks every beacon (10 s), so in practice 30-40 s. @@ -43,10 +43,13 @@ pub struct Seen { } /// A machine that's heard here only counts as unable to hear this one once -/// its report was made this long after it was first heard: time enough for -/// several beacons and the hello back. Seconds; allows for clocks a little -/// apart. +/// its report reached the relay this long after it was first heard here +/// (both on this machine's clock)… const DEAF_AFTER_SECS: i64 = 90; +/// …and it had been listening this long when it made the report (on its +/// own clock): time enough for several beacons and the hello back, so a +/// machine that just restarted isn't taken as deaf. +const LISTEN_FIRST_SECS: i64 = 30; /// A network interface: its name as the OS shows it ("Wi-Fi", "en0"), its /// addresses with their prefix lengths, and on Windows the adapter's @@ -136,8 +139,11 @@ pub struct Facts { /// This machine's home-network subnets, as last looked at; empty when /// it hasn't looked. pub subnets: Vec, + /// This machine's public address as the relay saw it + /// ([`crate::net_report::public_tag`]). + pub public: Option, /// The other machines' latest reports, by id (decision #124). - pub reports: BTreeMap, + pub reports: BTreeMap, } /// What the Sync & backup line says about the home network. Mirrored in @@ -177,16 +183,20 @@ pub struct Missing { pub enum Why { /// Home network sync isn't picked there. Off, + /// It's picked there, but Patch Pilot couldn't open its network port. + CantListen, /// No report for a while: asleep, Patch Pilot closed, or offline. Quiet { since: i64 }, /// Its broadcasts leave through a VPN or a virtual adapter. TheirVpn, /// It hears this machine, but nothing it sends arrives here. OneWay, - /// It's on another network. + /// It's on another network: no home subnet in common, or the same + /// range behind another router (another public address). OtherNetwork, - /// The same network, both listening, no VPN, and neither hears the - /// other: a firewall, or the router keeping devices apart. + /// The same network (same subnet and public address), both listening, + /// no VPN either side, and neither hears the other: a firewall, or the + /// router keeping devices apart. Blocked, } @@ -252,9 +262,12 @@ pub fn diagnose(f: &Facts) -> Diagnosis { .iter() .filter(|s| s.here && f.expected.iter().any(|e| e.id == s.id)) .filter(|s| { - f.reports.get(&s.id).is_some_and(|r| { - r.on && r.is_fresh(f.now) - && r.at >= s.since + DEAF_AFTER_SECS + f.reports.get(&s.id).is_some_and(|rx| { + let r = &rx.report; + r.on && r.listening + && rx.is_fresh(f.now) + && rx.at >= s.since + DEAF_AFTER_SECS + && r.listened_for(LISTEN_FIRST_SECS) && !r.hears.contains(&f.this_id) }) }) @@ -262,10 +275,12 @@ pub fn diagnose(f: &Facts) -> Diagnosis { .collect(); deaf.sort(); let looking = !missing.is_empty() && f.listening_for < GRACE; - let cause = if (missing.is_empty() || looking) && deaf.is_empty() { - None + let cause = if !missing.is_empty() && !looking { + cause(f, !here.is_empty()) + } else if !deaf.is_empty() { + deaf_cause(f) } else { - cause(f, !here.is_empty(), !deaf.is_empty()) + None }; Diagnosis { here, @@ -280,13 +295,22 @@ pub fn diagnose(f: &Facts) -> Diagnosis { /// however old the report is: the machine would have said if it turned the /// home network on. fn why(f: &Facts, id: &str) -> Option { - let r = f.reports.get(id)?; + let rx = f.reports.get(id)?; + let r = &rx.report; if !r.on { return Some(Why::Off); } - if !r.is_fresh(f.now) { - // Offline here, every report goes stale: that says nothing. - return f.relay_online.then_some(Why::Quiet { since: r.at }); + if !rx.is_fresh(f.now) { + // Offline here, every report goes stale; and one heard here lately + // was running, its report just hadn't needed refreshing. + let heard_lately = f + .seen + .iter() + .any(|s| s.id == id && f.now - s.seen_at < FRESH_SECS); + return (f.relay_online && !heard_lately).then_some(Why::Quiet { since: rx.at }); + } + if !r.listening { + return Some(Why::CantListen); } if r.vpn == Some(true) { return Some(Why::TheirVpn); @@ -300,32 +324,45 @@ fn why(f: &Facts, id: &str) -> Option { if !share_a_network(&r.subnets, &f.subnets) { return Some(Why::OtherNetwork); } + // The same range. Most routers hand out the same few, so it's the same + // network only if the public address matches too, and that only says + // something when neither side's internet goes through a VPN. + let direct = matches!(f.route_egress, Some(Egress::Lan)) && r.internet_vpn == Some(false); + let same = match (&r.public, &f.public) { + (Some(a), Some(b)) if direct => same_public(a, b)?, + _ => return None, + }; + if !same { + return Some(Why::OtherNetwork); + } // Same network: blocked only when neither side's packets go astray. (matches!(f.broadcast_egress, Some(Egress::Lan)) && r.vpn == Some(false)) .then_some(Why::Blocked) } +/// Why machines heard here can't hear this one, when this machine can +/// tell: its own packets leave the wrong way, or it runs a version they +/// can't read. +fn deaf_cause(f: &Facts) -> Option { + if f.other_version { + return Some(Cause::OtherVersion); + } + match &f.broadcast_egress { + Some(Egress::Vpn { adapter }) => Some(Cause::Vpn { + adapter: adapter.clone(), + }), + Some(Egress::Virtual { name }) => Some(Cause::VirtualAdapter { name: name.clone() }), + _ => None, + } +} + /// The first cause that fits, surest first. While another linked machine is /// here, this machine's network works, so only causes on the other side -/// (another version, another rig) are named, or, when a machine here can't -/// hear this one, this machine's packets leaving the wrong way. -fn cause(f: &Facts, someone_here: bool, someone_deaf: bool) -> Option { +/// (another version, another rig) are named. +fn cause(f: &Facts, someone_here: bool) -> Option { if f.other_version { return Some(Cause::OtherVersion); } - if someone_here && someone_deaf { - match &f.broadcast_egress { - Some(Egress::Vpn { adapter }) => { - return Some(Cause::Vpn { - adapter: adapter.clone(), - }); - } - Some(Egress::Virtual { name }) => { - return Some(Cause::VirtualAdapter { name: name.clone() }); - } - _ => {} - } - } if !someone_here { if f.has_lan == Some(false) { return Some(Cause::NoLan); @@ -517,6 +554,7 @@ pub fn bind_error_text(kind: ErrorKind, raw_os_error: Option, detail: &str) #[cfg(test)] mod tests { use super::*; + use crate::net_report::NetReport; use std::net::Ipv4Addr; fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr { @@ -941,32 +979,45 @@ mod tests { } const NOW: i64 = 1_800_000_000; + const HOME_IP: &str = "v4:203.0.113.5"; fn home() -> Subnet { Subnet::of(Ipv4Addr::new(192, 168, 1, 20), 24).unwrap() } - /// The laptop's report: on, fresh, on the home network, no VPN, hears - /// nobody. + /// The laptop's report: on and listening for ten minutes, fresh, on the + /// home network behind the home router, no VPN, hears nobody. fn report() -> NetReport { NetReport { version: 1, at: NOW - 60, on: true, + listening_since: Some(NOW - 660), subnets: vec![home()], vpn: Some(false), - hears: vec![], + internet_vpn: Some(false), + public: Some(HOME_IP.into()), + ..NetReport::default() + } + } + + /// As if it reached the relay when it was made. + fn rx(r: NetReport) -> Received { + Received { + at: r.at, + report: r, } } - /// Alone, with the laptop's report in and this machine's subnet known. + /// Alone, with the laptop's report in and this machine's network known. fn compared(r: NetReport) -> Facts { Facts { this_id: "desk".into(), now: NOW, relay_online: true, subnets: vec![home()], - reports: [("laptop".to_string(), r)].into(), + public: Some(HOME_IP.into()), + reports: [("laptop".to_string(), rx(r))].into(), ..alone() } } @@ -998,11 +1049,28 @@ mod tests { assert_eq!( why_of(&Facts { relay_online: false, - ..compared(old) + ..compared(old.clone()) }), None, "offline here, every report looks old" ); + // Heard here a few minutes ago: running, its report just old. + let lately = Facts { + seen: vec![Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: false, + seen_at: NOW - 120, + since: NOW - 7_200, + }], + ..compared(old) + }; + assert_eq!(why_of(&lately), None); + let no_port = NetReport { + listening: false, + ..report() + }; + assert_eq!(why_of(&compared(no_port)), Some(Why::CantListen)); let vpn = NetReport { vpn: Some(true), hears: vec!["desk".into()], @@ -1021,6 +1089,52 @@ mod tests { assert_eq!(why_of(&compared(elsewhere)), Some(Why::OtherNetwork)); } + #[test] + fn the_same_range_behind_another_router_is_another_network() { + // A friend's router hands out 192.168.1.x too. + let friends = NetReport { + public: Some("v4:198.51.100.7".into()), + ..report() + }; + assert_eq!(why_of(&compared(friends.clone())), Some(Why::OtherNetwork)); + // Can't compare: IPv6 to the relay there, IPv4 here. + let v6 = NetReport { + public: Some("v6:2001:db8:1:2::/64".into()), + ..report() + }; + assert_eq!(why_of(&compared(v6)), None); + // Either side's internet through a VPN: its public address is the + // VPN's. + let tunneled = NetReport { + internet_vpn: Some(true), + ..friends.clone() + }; + assert_eq!(why_of(&compared(tunneled)), None); + assert_eq!( + why_of(&Facts { + route_egress: Some(Egress::Vpn { adapter: None }), + ..compared(friends.clone()) + }), + None + ); + // An older relay that doesn't say: same range, nothing to name. + assert_eq!( + why_of(&Facts { + public: None, + ..compared(friends) + }), + None + ); + assert_eq!( + why_of(&compared(NetReport { + public: None, + ..report() + })), + None, + "not blocked without knowing it's the same router" + ); + } + #[test] fn a_report_names_nothing_it_cant_back() { // Neither side looked at its network yet. @@ -1061,7 +1175,7 @@ mod tests { // Another machine's report doesn't speak for the laptop. assert_eq!( why_of(&Facts { - reports: [("studio".to_string(), NetReport::off(NOW))].into(), + reports: [("studio".to_string(), rx(NetReport::off(NOW)))].into(), ..compared(report()) }), None @@ -1085,12 +1199,26 @@ mod tests { assert!(d.missing.is_empty()); assert_eq!(d.deaf, vec!["Brian's Laptop".to_string()]); assert_eq!(d.cause, None, "nothing wrong this machine can see"); - // Its report from before it could have heard this one. + // Its report reached the relay before it could have heard this one. let early = Facts { seen: vec![heard(NOW - 100)], ..f.clone() }; assert!(diagnose(&early).deaf.is_empty()); + // It had only just started (a quick restart, an update) when it + // made the report, though this machine kept hearing it. + let restarted = Facts { + reports: [( + "laptop".to_string(), + rx(NetReport { + listening_since: Some(NOW - 70), + ..report() + }), + )] + .into(), + ..f.clone() + }; + assert!(diagnose(&restarted).deaf.is_empty()); // It does hear this one. let fine = compared(NetReport { hears: vec!["desk".into()], @@ -1104,6 +1232,12 @@ mod tests { .deaf .is_empty() ); + // Another rig nearby has nothing to do with it. + let d = diagnose(&Facts { + other_rig: true, + ..f.clone() + }); + assert_eq!(d.cause, None); // This machine's broadcasts leave through a VPN: that's why. let d = diagnose(&Facts { broadcast_egress: Some(Egress::Vpn { @@ -1129,7 +1263,11 @@ mod tests { iface("NordLynx", ip(10, 5, 0, 2)), iface("vEthernet (WSL)", ip(172, 29, 0, 1)), Iface { - ips: vec![(ip(169, 254, 3, 4), 16), ("fe80::1".parse().unwrap(), 64)], + ips: vec![ + (ip(169, 254, 3, 4), 16), + (ip(10, 9, 9, 9), 0), + ("fe80::1".parse().unwrap(), 64), + ], ..iface("Ethernet", ip(0, 0, 0, 0)) }, ]; diff --git a/crates/local/src/net_report.rs b/crates/local/src/net_report.rs index 1abf9a6..bdbd58d 100644 --- a/crates/local/src/net_report.rs +++ b/crates/local/src/net_report.rs @@ -8,7 +8,7 @@ //! missing or can't hear this one, every [`HEARTBEAT`] so the others know //! this machine is still running. Nothing goes out while everything works. -use std::net::Ipv4Addr; +use std::net::{IpAddr, Ipv4Addr}; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -23,18 +23,29 @@ pub const HEARTBEAT: Duration = Duration::from_secs(5 * 60); pub const MIN_GAP: Duration = Duration::from_secs(30); /// A report older than this says nothing about now: the machine is asleep, /// Patch Pilot is closed, or it's offline. Seconds; over two heartbeats. +/// Measured on this machine's clock from when the report reached the relay, +/// so the other machine's clock never matters. pub const FRESH_SECS: i64 = 12 * 60; +/// A subnet wider than this says nothing about which network it is. +const MIN_PREFIX: u8 = 8; /// Biggest object this reads; a report is a few hundred bytes. const MAX_BYTES: usize = 16 * 1024; -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NetReport { #[serde(default)] pub version: u32, /// Unix seconds UTC, on the sending machine's clock. pub at: i64, - /// Home network sync is picked there and listening. + /// Home network sync is picked there. pub on: bool, + /// It could open its network port. Only meaningful while `on`. + #[serde(default = "yes", skip_serializing_if = "is_true")] + pub listening: bool, + /// Since when it has listened (Unix seconds, its own clock): a machine + /// that only just started can't have heard anyone yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listening_since: Option, /// Its home-network subnets (Wi-Fi, Ethernet): network address and /// prefix only. Empty when it hasn't looked. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -43,9 +54,43 @@ pub struct NetReport { /// hasn't looked. #[serde(default, skip_serializing_if = "Option::is_none")] pub vpn: Option, + /// Its internet traffic leaves through a VPN, so `public` is the VPN's, + /// not its home router's. None when it hasn't looked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub internet_vpn: Option, /// Linked machines it hears on its network now, by id. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hears: Vec, + /// Its public address as the relay saw it ([`public_tag`]): two + /// machines with the same home subnet are on the same network only if + /// this matches too, since most routers hand out the same few ranges. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public: Option, +} + +impl Default for NetReport { + fn default() -> Self { + Self { + version: 0, + at: 0, + on: false, + listening: true, + listening_since: None, + subnets: Vec::new(), + vpn: None, + internet_vpn: None, + hears: Vec::new(), + public: None, + } + } +} + +fn yes() -> bool { + true +} + +fn is_true(b: &bool) -> bool { + *b } impl NetReport { @@ -69,10 +114,11 @@ impl NetReport { } } - /// Made recently enough to say something about now. A clock a little - /// ahead counts as now. - pub fn is_fresh(&self, now: i64) -> bool { - now - self.at.min(now) <= FRESH_SECS + /// It had listened for `secs` when it made this report, by its own + /// clock. False when it doesn't say. + pub fn listened_for(&self, secs: i64) -> bool { + self.listening_since + .is_some_and(|since| self.at - since >= secs) } pub fn to_bytes(&self) -> Vec { @@ -88,6 +134,48 @@ impl NetReport { } } +/// Another machine's report, with when it reached the relay on this +/// machine's clock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Received { + pub report: NetReport, + pub at: i64, +} + +impl Received { + /// Recent enough to say something about now. + pub fn is_fresh(&self, now: i64) -> bool { + now - self.at.min(now) <= FRESH_SECS + } +} + +/// When a report reached the relay, on this machine's clock: now, less how +/// long the relay says it has held it. Without that, now. +pub fn received_at(now: i64, held_ms: Option) -> i64 { + now - held_ms.map_or(0, |ms| ms.max(0) / 1000) +} + +/// How a public address is compared: an IPv4 address whole (one per home +/// router), an IPv6 one by its /64 (one per home network). +pub fn public_tag(ip: IpAddr) -> String { + match ip { + IpAddr::V4(v4) => format!("v4:{v4}"), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => format!("v4:{v4}"), + None => { + let s = v6.segments(); + format!("v6:{:x}:{:x}:{:x}:{:x}::/64", s[0], s[1], s[2], s[3]) + } + }, + } +} + +/// Whether two [`public_tag`]s are the same place. None when they can't be +/// compared: one machine reached the relay over IPv4, the other over IPv6. +pub fn same_public(a: &str, b: &str) -> Option { + (a.get(..3)? == b.get(..3)?).then(|| a == b) +} + /// An IPv4 network: its address with the host bits cleared, and the prefix. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Subnet { @@ -96,9 +184,10 @@ pub struct Subnet { } impl Subnet { - /// The network `ip` is on. None for a prefix over 32. + /// The network `ip` is on. None for a prefix over 32, or one so wide + /// (under /8) it would match nearly anything. pub fn of(ip: Ipv4Addr, prefix: u8) -> Option { - (prefix <= 32).then(|| Self { + (MIN_PREFIX..=32).contains(&prefix).then(|| Self { net: mask(ip, prefix), prefix, }) @@ -150,9 +239,13 @@ mod tests { version: REPORT_VERSION, at: 1_000, on: true, + listening_since: Some(400), subnets: vec![net(192, 168, 1, 20, 24)], vpn: Some(false), + internet_vpn: Some(false), hears: vec!["desktop".into()], + public: Some("v4:203.0.113.5".into()), + ..NetReport::default() } } @@ -162,7 +255,12 @@ mod tests { assert_eq!(s.net, Ipv4Addr::new(192, 168, 1, 0)); assert_eq!(net(10, 1, 2, 3, 8).net, Ipv4Addr::new(10, 0, 0, 0)); assert_eq!(net(10, 1, 2, 3, 32).net, Ipv4Addr::new(10, 1, 2, 3)); - assert_eq!(net(10, 1, 2, 3, 0).net, Ipv4Addr::UNSPECIFIED); + assert_eq!( + Subnet::of(Ipv4Addr::new(10, 1, 2, 3), 0), + None, + "matches anything" + ); + assert_eq!(Subnet::of(Ipv4Addr::new(10, 1, 2, 3), 7), None); assert_eq!(Subnet::of(Ipv4Addr::LOCALHOST, 33), None); } @@ -183,12 +281,42 @@ mod tests { assert!(!share_a_network(&[home], &[])); } + #[test] + fn public_addresses_compare_within_one_family() { + let a = public_tag("203.0.113.5".parse().unwrap()); + assert_eq!(a, "v4:203.0.113.5"); + assert_eq!(public_tag("::ffff:203.0.113.5".parse().unwrap()), a); + let v6 = public_tag("2001:db8:1:2:aaaa::1".parse().unwrap()); + assert_eq!(v6, "v6:2001:db8:1:2::/64"); + assert_eq!( + public_tag("2001:db8:1:2:bbbb::9".parse().unwrap()), + v6, + "the same home network's /64" + ); + assert_eq!(same_public(&a, "v4:203.0.113.5"), Some(true)); + assert_eq!(same_public(&a, "v4:198.51.100.7"), Some(false)); + assert_eq!(same_public(&a, &v6), None, "IPv4 here, IPv6 there"); + assert_eq!(same_public("", &a), None); + } + #[test] fn a_report_round_trips_and_reads_older_and_newer_ones() { let r = on(); - assert_eq!(NetReport::from_bytes(&r.to_bytes()), Some(r)); - let json = String::from_utf8(on().to_bytes()).unwrap(); + assert_eq!(NetReport::from_bytes(&r.to_bytes()), Some(r.clone())); + let json = String::from_utf8(r.to_bytes()).unwrap(); assert!(json.contains(r#""net":"192.168.1.0""#), "{json}"); + assert!( + !json.contains("listening\""), + "listening is the default: {json}" + ); + let deaf_port = NetReport { + listening: false, + ..on() + }; + assert_eq!( + NetReport::from_bytes(&deaf_port.to_bytes()).map(|r| r.listening), + Some(false) + ); // A newer build's extra field, and a bare one. let newer = br#"{"version":2,"at":5,"on":true,"wifi":"Home"}"#; assert_eq!(NetReport::from_bytes(newer).map(|r| r.on), Some(true)); @@ -200,6 +328,7 @@ mod tests { ..NetReport::default() } ); + assert!(bare.listening); assert_eq!(NetReport::from_bytes(b"not json"), None); assert_eq!(NetReport::from_bytes(&vec![b' '; MAX_BYTES + 1]), None); // Off says nothing else. @@ -208,12 +337,32 @@ mod tests { } #[test] - fn fresh_means_recent_on_the_senders_clock() { + fn fresh_means_recently_on_the_relay_by_this_machines_clock() { + let r = Received { + report: on(), + at: 10_000, + }; + assert!(r.is_fresh(10_000)); + assert!(r.is_fresh(10_000 + FRESH_SECS)); + assert!(!r.is_fresh(10_000 + FRESH_SECS + 1)); + assert!(r.is_fresh(10_000 - 60)); + assert_eq!(received_at(10_000, Some(90_500)), 10_000 - 90); + assert_eq!(received_at(10_000, Some(-5)), 10_000); + assert_eq!(received_at(10_000, None), 10_000); + } + + #[test] + fn listened_for_counts_on_the_senders_own_clock() { let r = on(); - assert!(r.is_fresh(r.at)); - assert!(r.is_fresh(r.at + FRESH_SECS)); - assert!(!r.is_fresh(r.at + FRESH_SECS + 1)); - assert!(r.is_fresh(r.at - 60), "a clock a little ahead"); + assert!(r.listened_for(600)); + assert!(!r.listened_for(601)); + assert!( + !NetReport { + listening_since: None, + ..on() + } + .listened_for(0) + ); } #[test] diff --git a/docs/decisions.md b/docs/decisions.md index 953dfac..3edd53d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,4 +128,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | | 122 | 2026-11-06 | **One "Scripts, templates and presets" guide on every screen that moves them** (Joshua: a first-time tester couldn't tell how to get Reaper scripts and templates working across machines). A `FilesGuide` Notice line (app/src/FilesGuide.tsx) sits in Get Up To Speed (this machine), Set up, This machine and Settings > Sync. Its line names the next thing to do (link the machines, then Keep Files in Sync) with that button, or turns green once both are done; How it works opens the ways in order, each ticked when done here: link your machines, Bring over what's missing (from the Primary Rig), Keep Files in Sync on every machine (same cloud folder, GitHub repo or home network), Choose what syncs, and what's never copied (plug-ins, Reaper extensions, sample libraries; reaper.ini and audio/MIDI settings stay per machine). On a checklist it starts open until syncing is on; elsewhere folded; the toggle is remembered. On This machine it hides once linked and syncing, since Sync & backup says the rest. Buttons reach the Link dialog and other tabs through a small `ShellContext` (app/src/shell.ts) instead of props through every screen. Bring over's "link first" line gets a Link a machine button, Set up's intro and tour step 5 say to link first. | The ways existed but were spread over four places with nothing saying which comes first or that linking is required; one line per screen keeps it compact (decision #68) while answering "how do my scripts get there?" where people look. | Accepted | | 123 | 2026-11-06 | **Home network says why it can't find a machine** (amends #94; Brian: his machines were linked but home-network sync never found the laptop, which was on another Wi-Fi and a VPN, and nothing said why). Linking only uses the relay, so it worked; the beacons (UDP broadcast to 255.255.255.255) never reached the other machine, and "heard nobody" wasn't an error: the line said "Looking for your other machines…" forever, or a green "In sync…". Now the Sync & backup line names the linked machines it expects and says Looking for X for the first 30 s after the home network is picked (checked with each 10 s beacon, so 30-40 s), then Can't find X on this network, with a cause when this machine can tell; a machine that was here and left reads "isn't on this network now (seen 2 h ago)" instead of "In sync", and one missing while another is here is added to the In sync line. The opened details list each machine (on this network / last on it / not found) and a checklist (same network, VPN, Windows Private network and firewall, macOS Local Network, Patch Pilot open with Home network on the other machine, same version), the detected cause marked. **Expected machines** are the other `machine/` objects the relay lists when live sync starts, plus `changed` events (no extra relay reads, #113), named from the store; machines added from an export file or an earlier rig, and machines removed from the store (their object stays on the relay), never show as missing. Before the first listing, every other machine in the store; while this machine isn't linked, none. **Causes**, surest first, in `patchpilot_local::lan_diagnosis::diagnose` (pure, tested with hand-built facts): a beacon sealed by this rig that this version can't read, or a newer envelope (other version); no Wi-Fi/Ethernet IPv4 address; broadcasts leaving through a VPN or a virtual adapter (Hyper-V, WSL, Docker, VMware); macOS refusing the broadcast with EHOSTUNREACH while a default route works (Local Network access off: a strong guess, macOS has no API to ask; with no gateway macOS refuses broadcasts the same way); Windows calling the network Public; a Patch Pilot beacon under another rig key heard in the last 45 s; and last, a guess, a VPN holding the internet route. While another linked machine is here, only the other-version and other-rig causes are named, since this machine's network works. What one machine can't see (the other on another Wi-Fi, client isolation, the other's firewall, Home network off there) stays on the checklist. **How it looks** (`patchpilot_local::netinfo`, read-only, sends nothing): a UDP socket with broadcast on, connected to 255.255.255.255:47219, and one connected to 1.1.1.1:53, give the source address of each; sysinfo's `network` feature (unsafe stays inside sysinfo) lists interfaces, and an address on none of them while a home-network address exists is a VPN's, since Windows leaves MAC-less WireGuard/Wintun adapters out. A "virtual" adapter carrying the internet route is the home network itself (a Hyper-V external switch or bridged WSL moves the real address onto vEthernet). Adapters are told apart by name and, on Windows, by the description PowerShell's `Get-NetAdapter` gives, because many VPNs (GlobalProtect's "PANGP Virtual Ethernet Adapter") name theirs "Ethernet 4"; the same call reads `Get-NetConnectionProfile` (DomainAuthenticated counts as Private), with the name in an environment variable and compared exactly (not as a wildcard), no window, below-normal priority (#114; the thread's background mode doesn't pass to a child), killed after 5 s (WMI can hang), cached 10 min per adapter. All of it runs on the existing beacon thread, only while a machine is missing, none is here and the grace is over, at most every 30 s, and the UI is told only when the diagnosis changes; `view()` only reads what's known. Only the broadcast's send result counts (a machine that left answers unicast with "unreachable" too). A pull error goes once its machine is gone. Picking the home network again restarts the 30 s and looks at the network afresh. The port error is worded by its cause (Windows' reserved port ranges give 10013, not "another app"). **Later, separately:** each machine telling the others its network over the relay ("Laptop is on a different network"), and beacons out of every interface so sync works despite a VPN. | The first time a user linked two machines, home-network sync failed with nothing to go on; the fix was a minute's work once the cause was known. Saying only what this machine can actually tell keeps the line trustworthy; the checklist covers the rest. | Proposed | -| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is on and listening, its Wi-Fi/Ethernet subnets (network address and prefix only, e.g. 192.168.1.0/24), whether its broadcasts leave through a VPN or virtual adapter, and which linked machines it hears now (ids). It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) sends it when what it says changes (at most every 30 s), and, only while a linked machine is missing or can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (a report kept from before going offline could say it hears nobody), sends one at the start of every session, reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is); no report for 12 min while this machine is online (asleep, closed, offline); a VPN is on there; it hears this machine but nothing it sends arrives (a firewall here); it's on a different network (no subnet in common, prefixes compared by containment); same network, both listening, neither side's broadcasts going astray and neither hears the other (a firewall, or the router keeping devices apart). Anything but "off" needs a fresh report, and "different network"/"blocked" need both sides to have looked at their network. A machine heard here whose fresh report, made at least 90 s after it was first heard, doesn't list this one is shown as unable to hear this machine (its files aren't reaching it). While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. Subnets and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | +| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts and its internet traffic leave through a VPN or virtual adapter, which linked machines it hears now (ids), and its public address as the relay saw it. Network facts are only included while they were looked at in the last 90 s (they're looked at only while something is wrong), so a report never carries facts from an earlier problem. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine is missing or can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is); picked there but its port won't open; no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline); a VPN is on there; it hears this machine but nothing it sends arrives (a firewall here); a different network: no subnet in common, or the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, and nothing unrelated. A definite reason from the missing machine beats this machine's guesses (a VPN carrying internet traffic, another rig nearby). While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | diff --git a/relay/src/index.ts b/relay/src/index.ts index 45cf5ae..165eb98 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -12,6 +12,12 @@ // // Rig requests carry `Authorization: Bearer `. The first PUT to a // rig claims it; the relay keeps only a SHA-256 of the token. +// +// A PUT answers with the address it came from (`ip`) and a GET says how long +// the relay has held the object (`x-age`, ms): a machine compares its public +// address with its linked machines' and judges how old their reports are on +// its own clock (decision #124). The relay sees the address anyway; it keeps +// nothing more. import { DurableObject } from "cloudflare:workers"; @@ -156,7 +162,8 @@ export class Rig extends DurableObject { updated, ); this.broadcast({ type: "changed", name, updated }); - return Response.json({ name, updated }); + const ip = request.headers.get("CF-Connecting-IP") ?? undefined; + return Response.json({ name, updated, ip }); } case "GET": { if (auth !== "ok") return text(401, "unauthorized"); @@ -171,6 +178,7 @@ export class Rig extends DurableObject { headers: { "content-type": "application/octet-stream", "x-updated": String(row.updated), + "x-age": String(Math.max(0, Date.now() - row.updated)), }, }); } diff --git a/relay/test/relay.test.ts b/relay/test/relay.test.ts index 12f3f3e..49dd305 100644 --- a/relay/test/relay.test.ts +++ b/relay/test/relay.test.ts @@ -46,6 +46,7 @@ describe("rig objects", () => { expect(ok.status).toBe(200); expect(new TextDecoder().decode(await ok.arrayBuffer())).toBe("sealed-1"); expect(Number(ok.headers.get("x-updated"))).toBeGreaterThan(0); + expect(Number(ok.headers.get("x-age"))).toBeGreaterThanOrEqual(0); const nope = await SELF.fetch(`https://relay.test/v1/rigs/${rig}/objects/machine/pc`, { headers: auth(OTHER), @@ -53,6 +54,19 @@ describe("rig objects", () => { expect(nope.status).toBe(401); }); + it("a PUT says which address it came from", async () => { + const rig = "5".repeat(32); + const res = await SELF.fetch(`https://relay.test/v1/rigs/${rig}/objects/net/pc`, { + method: "PUT", + headers: { ...auth(), "CF-Connecting-IP": "203.0.113.5" }, + body: "sealed", + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { name: string; ip?: string }; + expect(body.name).toBe("net/pc"); + expect(body.ip).toBe("203.0.113.5"); + }); + it("an unclaimed rig reveals nothing", async () => { const res = await SELF.fetch(`https://relay.test/v1/rigs/${"2".repeat(32)}/objects`, { headers: auth(), From 98a50243cc80719584b6cc79ce33dfb278e1d440 Mon Sep 17 00:00:00 2001 From: bfieser Date: Fri, 6 Nov 2026 15:46:31 -0600 Subject: [PATCH 4/6] Home network compare: second review round (#219) - A missing machine's report names a reason only if it reached the relay after this machine last heard that machine, and while this machine is online: a report made before it slept, quit or moved said "hears this machine" and was read as a firewall problem. - "It hears this machine" and "blocked" need its own sends known fine (no VPN, no failed broadcast) and a report made since this machine started listening. - A virtual adapter there is its own reason, not "a VPN". - An unknown report age (older relay) names only "off"; "last checked in" counts from when the machine was last heard if that's later. - Machines whose report says Home network is off no longer keep the others heartbeating or looking at their network. - Windows adapter descriptions are looked up whenever the network is looked at (cached 10 min), so a VPN called "Ethernet 4" isn't missed. - Network facts and their schedule use the wall clock; listening_since is fixed once, so a healthy machine's report doesn't look changed. - The public address is forgotten when subnets change, and a report is sent again at once when an upload reveals a new one; no report is asked for while offline (the session start sends one). - Line and details agree on which cause or reason is shown. Co-Authored-By: Claude Opus 5.5 --- app/CHANGELOG.md | 2 +- app/src-tauri/src/lan.rs | 93 ++++++++++------ app/src-tauri/src/sync.rs | 66 +++++++---- app/src/Backup.tsx | 77 +++++++++---- app/src/api.ts | 1 + crates/local/src/lan_diagnosis.rs | 176 +++++++++++++++++++++++++++--- crates/local/src/net_report.rs | 54 ++++++--- docs/decisions.md | 2 +- 8 files changed, 364 insertions(+), 107 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 728f480..220d2a3 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -9,7 +9,7 @@ becomes its release notes, which the app shows when it offers that update ### Improved - When you sync over your home network and Patch Pilot can't find one of your linked machines, it now says which one and, when it can tell, why: a VPN or virtual network adapter is carrying its messages, Windows has the network set to Public, your Mac isn't letting it use the local network, this machine isn't on a network, or the other machine runs a different Patch Pilot version. Open Sync & backup to see each machine and a short list of what to check. It no longer says "In sync" while none of your other machines are on the network. -- Your linked machines now tell each other about their home network, so Patch Pilot can also say when the other machine is on a different network, has a VPN on, isn't using Home network sync, hasn't checked in for a while (asleep or closed), or can hear this machine but not the other way round (usually a firewall). It names a reason only when both machines agree. +- Your linked machines now tell each other about their home network, so Patch Pilot can also say when the other machine is on a different network, has a VPN or virtual network adapter in the way, isn't using Home network sync, hasn't checked in for a while (asleep or closed), or can hear this machine but not the other way round (usually a firewall). It names a reason only when both machines agree. ## 0.4.58 - 2026-11-06 diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index bbe2a50..739028f 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -93,6 +93,9 @@ struct LanState { last: Mutex>, /// Since when this machine has listened, for "Looking for…" first. listening_since: Mutex>, + /// The same as Unix seconds, for this machine's report: fixed once, so + /// the report doesn't look changed every time it's built. + listening_at: Mutex>, /// When a beacon of this rig came in that this version can't read. other_version: Mutex>, /// When a Patch Pilot beacon under another rig key came in. @@ -112,7 +115,10 @@ struct LanState { /// This machine's network as last looked at, while a machine was missing. #[derive(Default, Clone)] struct NetCache { - checked: Option, + /// When it was last looked at (Unix seconds): the wall clock, since a + /// Mac's `Instant` stops while it sleeps and facts from before sleep + /// would pass as fresh after waking somewhere else. + checked_at: Option, broadcast_egress: Option, route_egress: Option, has_lan: Option, @@ -244,6 +250,11 @@ fn start(app: &AppHandle) { .lock() .unwrap() .get_or_insert_with(Instant::now); + state + .listening_at + .lock() + .unwrap() + .get_or_insert_with(patchpilot_local::now); let handle = app.clone(); std::thread::spawn(move || { @@ -540,14 +551,14 @@ fn check(app: &AppHandle) { .net .lock() .unwrap() - .checked - .is_none_or(|t| t.elapsed() >= NET_EVERY); - // Only while something is wrong. With nobody here, ask Windows about - // the adapters too; with someone here this machine's network works, - // and only its subnets and which way it sends matter. - let wrong = (!d.missing.is_empty() && !d.looking) || !d.deaf.is_empty(); + .checked_at + .is_none_or(|t| patchpilot_local::now() - t >= NET_EVERY.as_secs() as i64); + // Only while something is wrong: a machine that might still turn up is + // missing (not one that said it doesn't use the home network), or one + // here can't hear this one. + let wrong = (d.awaiting() && !d.looking) || !d.deaf.is_empty(); if wrong && net_due { - look_at_network(&state, d.here.is_empty()); + look_at_network(app); d = lan_diagnosis::diagnose(&facts(app)); } let changed = { @@ -586,9 +597,10 @@ fn clear_stale_error(state: &LanState) -> bool { } /// Which way this machine's packets leave, its subnets, and what Windows -/// calls the network. At background priority: with `deep` it can run -/// PowerShell; without, it goes on what Windows said before. -fn look_at_network(state: &LanState, deep: bool) { +/// calls the network. At background priority: it can run PowerShell, at +/// most every 10 minutes per adapter. +fn look_at_network(app: &AppHandle) { + let state = app.state::(); let _quiet = patchpilot_local::quiet::background(); let mut net = state.net.lock().unwrap().clone(); let mut ifaces = netinfo::interfaces(); @@ -607,18 +619,16 @@ fn look_at_network(state: &LanState, deep: bool) { let fresh = net .adapters .get(&name) - .filter(|(at, _)| !deep || at.elapsed() < ADAPTER_EVERY) + .filter(|(at, _)| at.elapsed() < ADAPTER_EVERY) .map(|(_, info)| info.clone()); - let info = if !deep { - fresh.flatten() - } else { - fresh.unwrap_or_else(|| { - let info = netinfo::adapter_info(&name); - net.adapters - .insert(name.clone(), (Instant::now(), info.clone())); - info - }) - }; + // Without it a VPN called "Ethernet 4" passes for the home network, + // so ask even while another machine is here. + let info = fresh.unwrap_or_else(|| { + let info = netinfo::adapter_info(&name); + net.adapters + .insert(name.clone(), (Instant::now(), info.clone())); + info + }); if let Some(i) = ifaces.iter_mut().find(|i| i.name == name) { i.description = info.as_ref().and_then(|a| a.description.clone()); } @@ -628,7 +638,13 @@ fn look_at_network(state: &LanState, deep: bool) { } net.broadcast_egress = broadcast.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); net.route_egress = route.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); - net.subnets = lan_diagnosis::subnets(&ifaces, route); + let subnets = lan_diagnosis::subnets(&ifaces, route); + if !net.subnets.is_empty() && subnets != net.subnets { + // Another network: the public address the relay saw belongs to the + // old one until the next upload says. + crate::sync::forget_public(app); + } + net.subnets = subnets; // Windows lists no adapter at all when none is connected; with no route // anywhere either, this machine isn't on a network. net.has_lan = if !ifaces.is_empty() { @@ -643,7 +659,7 @@ fn look_at_network(state: &LanState, deep: bool) { Some(Egress::Lan) => broadcast_info.and_then(|a| a.profile), _ => None, }; - net.checked = Some(Instant::now()); + net.checked_at = Some(patchpilot_local::now()); *state.net.lock().unwrap() = net; } @@ -719,12 +735,19 @@ pub(crate) fn own_report(app: &AppHandle) -> NetReport { } let net = state.net.lock().unwrap().clone(); // Facts from an earlier problem may no longer hold: say nothing then. - let recent = net.checked.is_some_and(|t| t.elapsed() < NET_FACTS_FOR); + let recent = net + .checked_at + .is_some_and(|t| now - t < NET_FACTS_FOR.as_secs() as i64); + // A virtual adapter isn't a VPN: its own field, so the others can say so. let through_vpn = |e: &Option| match e { - Some(Egress::Lan) => Some(false), - Some(Egress::Vpn { .. } | Egress::Virtual { .. }) => Some(true), + Some(Egress::Lan | Egress::Virtual { .. }) => Some(false), + Some(Egress::Vpn { .. }) => Some(true), Some(Egress::Unknown) | None => None, }; + let virtual_adapter = match &net.broadcast_egress { + Some(Egress::Virtual { name }) if recent => Some(name.clone()), + _ => None, + }; let mut hears: Vec = state .peers .lock() @@ -734,11 +757,8 @@ pub(crate) fn own_report(app: &AppHandle) -> NetReport { .map(|(id, _)| id.clone()) .collect(); hears.sort(); - let listening_since = state - .listening_since - .lock() - .unwrap() - .map(|t| now - t.elapsed().as_secs() as i64); + let listening_since = *state.listening_at.lock().unwrap(); + let send_error = state.broadcast_error.lock().unwrap().is_some(); NetReport { version: net_report::REPORT_VERSION, at: now, @@ -751,6 +771,8 @@ pub(crate) fn own_report(app: &AppHandle) -> NetReport { } else { None }, + virtual_adapter, + send_error, internet_vpn: if recent { through_vpn(&net.route_egress) } else { @@ -774,12 +796,16 @@ fn share(app: &AppHandle) { .lock() .unwrap() .as_ref() - .is_some_and(|d| !d.missing.is_empty() || !d.deaf.is_empty()); + .is_some_and(|d| d.awaiting() || !d.deaf.is_empty()); // Against what actually reached the relay, so a failed put is retried. let last = crate::sync::last_report(app); if !net_report::should_send(&report, last.as_ref().map(|(r, ago)| (r, *ago)), heartbeat) { return; } + // Offline, the next session sends one at its start anyway. + if !crate::sync::online(app) { + return; + } let mut asked = state.asked.lock().unwrap(); if asked.is_none_or(|t| t.elapsed() >= net_report::MIN_GAP) { *asked = Some(Instant::now()); @@ -861,6 +887,7 @@ pub async fn lan_use(app: AppHandle) -> Result<(), String> { // Picking the home network (again) starts the "Looking for…" time over. let state = app.state::(); *state.listening_since.lock().unwrap() = Some(Instant::now()); + *state.listening_at.lock().unwrap() = Some(patchpilot_local::now()); *state.diagnosis.lock().unwrap() = None; { // Look at the network afresh; what Windows said about each adapter diff --git a/app/src-tauri/src/sync.rs b/app/src-tauri/src/sync.rs index 42803a1..b6de3eb 100644 --- a/app/src-tauri/src/sync.rs +++ b/app/src-tauri/src/sync.rs @@ -186,7 +186,7 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri // Their home-network reports: one read each, only for linked machines. let mut reports = BTreeMap::new(); for id in others.iter().filter_map(|n| n.strip_prefix("machine/")) { - if let Some(r) = fetch_report(&relay, rig, id).await { + if let Some(r) = fetch_report(&relay, rig, id, false).await { reports.insert(id.to_owned(), r); } } @@ -222,7 +222,7 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri } } else if event.kind == "changed" && let Some(id) = patchpilot_local::sync::other_report(&event.name, &this) - && let Some(r) = fetch_report(&relay, rig, id).await + && let Some(r) = fetch_report(&relay, rig, id, true).await { // `lan` looks at it within one beacon. state.reports.lock().expect("reports lock").insert(id.to_owned(), r); @@ -334,18 +334,38 @@ pub(crate) async fn fetch_file( /// offline. async fn send_report(app: &AppHandle, relay: &Relay, this: &str, rig: &RigKey) { let state = app.state::(); - let report = crate::lan::own_report(app); - let sent = match patchpilot_local::sync::outgoing_report(this, rig, &report) { - Ok((object, sealed)) => relay.put(&object, sealed).await, - Err(e) => Err(e), - }; - match sent { - Ok(ip) => { - remember_public(&state, ip); - *state.report_put.lock().expect("report lock") = - Some((report, std::time::Instant::now())); + // The put's answer may show a new public address (this machine moved): + // then the report it just sent is wrong about it, so send it again. + for _ in 0..2 { + let report = crate::lan::own_report(app); + let sent = match patchpilot_local::sync::outgoing_report(this, rig, &report) { + Ok((object, sealed)) => relay.put(&object, sealed).await, + Err(e) => Err(e), + }; + match sent { + Ok(ip) => { + remember_public(&state, ip); + let moved = ip.map(patchpilot_local::net_report::public_tag) != report.public + && ip.is_some(); + *state.report_put.lock().expect("report lock") = + Some((report, std::time::Instant::now())); + if !moved { + return; + } + } + Err(e) => { + eprintln!("sync: home-network report not sent: {e}"); + return; + } } - Err(e) => eprintln!("sync: home-network report not sent: {e}"), + } +} + +/// This machine moved to another network: the public address the relay +/// last saw is the old one's until the next upload says. +pub fn forget_public(app: &AppHandle) { + if let Some(state) = app.try_state::() { + *state.public.lock().expect("public lock") = None; } } @@ -359,14 +379,14 @@ fn remember_public(state: &Sync, ip: Option) { /// Another machine's home-network report, with when it reached the relay /// by this machine's clock; None when it has none (an older Patch Pilot) or -/// it won't open. -async fn fetch_report(relay: &Relay, rig: &RigKey, id: &str) -> Option { +/// it won't open. `live`: fetched because it just changed. +async fn fetch_report(relay: &Relay, rig: &RigKey, id: &str, live: bool) -> Option { let object = patchpilot_sync::net_object(id).ok()?; let (sealed, held_ms) = relay.get_aged(&object).await.ok()??; let report = patchpilot_local::sync::incoming_report(rig, &object, &sealed)?; Some(Received { report, - at: patchpilot_local::net_report::received_at(patchpilot_local::now(), held_ms), + at: patchpilot_local::net_report::received_at(patchpilot_local::now(), held_ms, live), }) } @@ -888,7 +908,10 @@ mod tests { let relay = Relay::new(&rig); // The first put claims the rig; the relay says where it came from. let ip = relay.put("machine/pc", b"sealed".to_vec()).await.unwrap(); - assert!(ip.is_some(), "the relay answers with this machine's address"); + assert!( + ip.is_some(), + "the relay answers with this machine's address" + ); let report = NetReport { on: true, hears: vec!["mac".into()], @@ -898,11 +921,12 @@ mod tests { patchpilot_local::sync::outgoing_report("pc", &rig, &report).unwrap(); relay.put(&object, sealed).await.unwrap(); tokio::time::sleep(Duration::from_millis(1100)).await; - let got = fetch_report(&relay, &rig, "pc").await.unwrap(); + let got = fetch_report(&relay, &rig, "pc", false).await.unwrap(); assert_eq!(got.report, report); - assert!(got.at < patchpilot_local::now(), "held about a second: {}", got.at); - assert!(got.at >= patchpilot_local::now() - 5); - assert!(fetch_report(&relay, &rig, "nobody").await.is_none()); + let got = got.at.expect("this relay says how long it held it"); + assert!(got < patchpilot_local::now(), "held about a second: {got}"); + assert!(got >= patchpilot_local::now() - 5); + assert!(fetch_report(&relay, &rig, "nobody", false).await.is_none()); } #[tokio::test] diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index 8934821..3f1c5a3 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -29,6 +29,7 @@ import { type FileToBring, type GitHubProgress, type LanCause, + type LanDiagnosis, type LanWhy, type LanProgress, type LanView, @@ -229,13 +230,8 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan const d = lan?.diagnosis; const deaf = d?.deaf ?? []; if (d && d.missing.length === 0 && deaf.length > 0) { - const who = andList(deaf); - return [ - "warn", - `${who} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}: ${ - d.cause ? lanCauseText(d.cause) : `allow Patch Pilot through the firewall on ${who}.` - }`, - ]; + const what = `${andList(deaf)} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}`; + return ["warn", d.cause ? `${what}: ${lanCauseText(d.cause)}` : `${what}. Show what to check.`]; } if (d && d.missing.length > 0) { const who = andList(d.missing.map((m) => m.name)); @@ -245,18 +241,21 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan ? withNote(filesLine(sync, where, github, lan), `looking for ${who}…`) : ["accent", `Looking for ${who} on this network…`]; const only = d.missing.length === 1 ? d.missing[0] : null; - // A guess here (a VPN for the internet, another rig nearby) gives way to - // what the missing machine itself says. - const guess = d.cause?.kind === "vpn_on" || d.cause?.kind === "other_rig"; - if (d.cause && !(guess && only?.why)) - return ["warn", `Can't find ${who} on this network: ${lanCauseText(d.cause)}`]; - if (d.here.length > 0) return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); + const cause = causeGivesWay(d) ? undefined : d.cause; + if (cause) return ["warn", `Can't find ${who} on this network: ${lanCauseText(cause)}`]; + if (d.here.length > 0) { + // Someone here can't hear this machine too: that's not "in sync". + if (deaf.length > 0) { + const [, text] = withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now`); + return ["warn", `${text}, and ${andList(deaf)} can't hear this machine.`]; + } + return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); + } + const allQuiet = d.missing.every((m) => m.why?.kind === "quiet"); if (only?.why) - return [ - only.why.kind === "quiet" ? "accent" : "warn", - `Can't find ${only.name} on this network: ${lanWhyText(only.why, only.name)}`, - ]; - if (d.missing.some((m) => m.why)) return ["warn", `Can't find ${who} on this network. Show why.`]; + return [allQuiet ? "accent" : "warn", `Can't find ${only.name} on this network: ${lanWhyText(only.why, only.name)}`]; + if (d.missing.some((m) => m.why)) + return [allQuiet ? "accent" : "warn", `Can't find ${who} on this network. Show why.`]; if (d.missing.every((m) => m.seen_at)) { const latest = Math.max(...d.missing.map((m) => m.seen_at ?? 0)); return ["accent", `${who} ${isnt} on this network now (seen ${timeAgo(latest)}).`]; @@ -319,6 +318,23 @@ function lanCauseText(c: LanCause): string { } } +/** A guess on this machine: a VPN carrying only internet traffic, or another rig nearby. */ +function isGuess(c: LanCause | undefined): boolean { + return c?.kind === "vpn_on" || c?.kind === "other_rig"; +} + +/** + * Whether this machine's own cause gives way to what the missing machines + * say: a guess gives way to any of their reasons, and any cause gives way + * when every one of them is off the home network or asleep, which holds + * whatever this machine's network does. The line and the details agree. + */ +function causeGivesWay(d: LanDiagnosis): boolean { + if (!d.cause || d.missing.length === 0) return false; + if (isGuess(d.cause) && d.missing.some((m) => m.why)) return true; + return d.missing.every((m) => m.why?.kind === "off" || m.why?.kind === "quiet"); +} + /** What a missing machine's own report says, after "Can't find X on this network: ". */ function lanWhyText(w: LanWhy, name: string): string { switch (w.kind) { @@ -330,6 +346,8 @@ function lanWhyText(w: LanWhy, name: string): string { return `it last checked in ${timeAgo(w.since)}. Make sure it's awake, with Patch Pilot open and online.`; case "their_vpn": return "a VPN is on there, so its messages go through it instead of your home network. Turn it off there, or let it allow local network access."; + case "their_virtual": + return `its messages go to a virtual network on it (${w.name}) instead of your home network.`; case "one_way": return "it hears this machine, but nothing it sends gets here. Allow Patch Pilot through the firewall on this machine."; case "other_network": @@ -350,12 +368,14 @@ function lanWhyShort(w: LanWhy): string { return `last checked in ${timeAgo(w.since)}`; case "their_vpn": return "a VPN is on there"; + case "their_virtual": + return `its messages go to ${w.name}`; case "one_way": return "hears this machine, but nothing from it gets here"; case "other_network": return "on a different network"; case "blocked": - return "on this network too, but neither hears the other"; + return "same network, but neither hears the other"; } } @@ -368,7 +388,11 @@ function andList(items: string[]): string { function LanMachines({ lan }: { lan: LanView }) { const d = lan.diagnosis; if (d.here.length === 0 && d.missing.length === 0) return null; - const cause = d.cause?.kind; + // The same rule as the line: a guess that gave way isn't shown at all; a + // sure cause that gave way is still worth fixing, so it stays, as "also". + const givesWay = causeGivesWay(d); + const shown = givesWay && isGuess(d.cause) ? undefined : d.cause; + const cause = shown?.kind; const deaf = d.deaf ?? []; const whys = new Set(d.missing.map((m) => m.why?.kind)); const items: [string, boolean][] = [ @@ -377,7 +401,7 @@ function LanMachines({ lan }: { lan: LanView }) { ["On Windows, the network is set to Private (Settings › Network & internet).", cause === "public_network"], [ "On Windows, Patch Pilot is allowed through Windows Firewall on private networks.", - whys.has("one_way") || whys.has("blocked") || (deaf.length > 0 && !cause), + whys.has("one_way") || whys.has("blocked"), ], ["On a Mac, Patch Pilot is on in System Settings › Privacy & Security › Local Network.", cause === "local_network_denied"], [ @@ -388,7 +412,8 @@ function LanMachines({ lan }: { lan: LanView }) { ]; // Reasons from the other machines' reports, in full: the line is cut. const reasons = d.missing.flatMap((m) => (m.why ? [`${m.name}: ${lanWhyText(m.why, m.name)}`] : [])); - const troubled = (d.missing.length > 0 && !d.looking) || deaf.length > 0; + const lost = d.missing.length > 0 && !d.looking; + const troubled = lost || deaf.length > 0; return (
    {d.here.map((n, i) => ( @@ -407,14 +432,18 @@ function LanMachines({ lan }: { lan: LanView }) { {troubled && ( <> {/* The line above is cut to one line; the reason is here in full. */} - {d.cause &&

    Likely reason: {lanCauseText(d.cause)}

    } + {shown && ( +

    + {givesWay ? "Also on this machine" : "Likely reason"}: {lanCauseText(shown)} +

    + )} {!d.looking && reasons.map((r, i) => (

    {r}

    ))} -

    Can't find a machine? Check that:

    +

    {lost ? "Can't find a machine?" : "Files not reaching a machine?"} Check that:

      {items.map(([text, likely]) => (
    • diff --git a/app/src/api.ts b/app/src/api.ts index c763b33..e9c7750 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -590,6 +590,7 @@ export type LanWhy = | { kind: "cant_listen" } | { kind: "quiet"; since: number } | { kind: "their_vpn" } + | { kind: "their_virtual"; name: string } | { kind: "one_way" } | { kind: "other_network" } | { kind: "blocked" }; diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs index 2e7298a..69ca40a 100644 --- a/crates/local/src/lan_diagnosis.rs +++ b/crates/local/src/lan_diagnosis.rs @@ -50,6 +50,11 @@ const DEAF_AFTER_SECS: i64 = 90; /// own clock): time enough for several beacons and the hello back, so a /// machine that just restarted isn't taken as deaf. const LISTEN_FIRST_SECS: i64 = 30; +/// A missing machine's report says how things are now only if it reached +/// the relay at least this long after this machine last heard it (a beacon +/// apart): an earlier one was made while it was still here, before it went +/// to sleep, quit or moved. +const AFTER_HEARD_SECS: i64 = 10; /// A network interface: its name as the OS shows it ("Wi-Fi", "en0"), its /// addresses with their prefix lengths, and on Windows the adapter's @@ -187,8 +192,10 @@ pub enum Why { CantListen, /// No report for a while: asleep, Patch Pilot closed, or offline. Quiet { since: i64 }, - /// Its broadcasts leave through a VPN or a virtual adapter. + /// Its broadcasts leave through a VPN. TheirVpn, + /// Its broadcasts leave into a virtual network inside it. + TheirVirtual { name: String }, /// It hears this machine, but nothing it sends arrives here. OneWay, /// It's on another network: no home subnet in common, or the same @@ -253,7 +260,7 @@ pub fn diagnose(f: &Facts) -> Diagnosis { // The name it last broadcast is the freshest. name: seen.map_or_else(|| e.name.clone(), |s| s.name.clone()), seen_at: seen.map(|s| s.seen_at), - why: why(f, &e.id), + why: why(f, &e.id, seen), } }) .collect(); @@ -266,7 +273,7 @@ pub fn diagnose(f: &Facts) -> Diagnosis { let r = &rx.report; r.on && r.listening && rx.is_fresh(f.now) - && rx.at >= s.since + DEAF_AFTER_SECS + && rx.at.is_some_and(|at| at >= s.since + DEAF_AFTER_SECS) && r.listened_for(LISTEN_FIRST_SECS) && !r.hears.contains(&f.this_id) }) @@ -294,20 +301,26 @@ pub fn diagnose(f: &Facts) -> Diagnosis { /// What a missing machine's own report says, surest first. Only "off" holds /// however old the report is: the machine would have said if it turned the /// home network on. -fn why(f: &Facts, id: &str) -> Option { +fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { let rx = f.reports.get(id)?; let r = &rx.report; if !r.on { return Some(Why::Off); } + // How old it is is unknown (an older relay): only "off" holds. + let at = rx.at?; if !rx.is_fresh(f.now) { // Offline here, every report goes stale; and one heard here lately // was running, its report just hadn't needed refreshing. - let heard_lately = f - .seen - .iter() - .any(|s| s.id == id && f.now - s.seen_at < FRESH_SECS); - return (f.relay_online && !heard_lately).then_some(Why::Quiet { since: rx.at }); + let heard_lately = seen.is_some_and(|s| f.now - s.seen_at < FRESH_SECS); + let last = seen.map_or(at, |s| s.seen_at.max(at)); + return (f.relay_online && !heard_lately).then_some(Why::Quiet { since: last }); + } + // Offline here, a newer report could be waiting unseen. And one that + // reached the relay before this machine last heard that machine was + // made while it was still here. + if !f.relay_online || seen.is_some_and(|s| at < s.seen_at + AFTER_HEARD_SECS) { + return None; } if !r.listening { return Some(Why::CantListen); @@ -315,8 +328,17 @@ fn why(f: &Facts, id: &str) -> Option { if r.vpn == Some(true) { return Some(Why::TheirVpn); } + if let Some(name) = &r.virtual_adapter { + return Some(Why::TheirVirtual { name: name.clone() }); + } + // Its own sends go where they should, so what it hears or doesn't says + // something about this machine and the network between. + let sends_fine = r.vpn == Some(false) && !r.send_error; + // What it heard before this machine started listening was an earlier + // run of Patch Pilot here. + let since_listening = at >= f.now - f.listening_for.as_secs() as i64; if r.hears.contains(&f.this_id) { - return Some(Why::OneWay); + return (sends_fine && since_listening).then_some(Why::OneWay); } if r.subnets.is_empty() || f.subnets.is_empty() { return None; @@ -336,10 +358,19 @@ fn why(f: &Facts, id: &str) -> Option { return Some(Why::OtherNetwork); } // Same network: blocked only when neither side's packets go astray. - (matches!(f.broadcast_egress, Some(Egress::Lan)) && r.vpn == Some(false)) + (matches!(f.broadcast_egress, Some(Egress::Lan)) && sends_fine && since_listening) .then_some(Why::Blocked) } +impl Diagnosis { + /// A linked machine is missing that might still turn up: one whose + /// report says it doesn't use the home network won't, and needs + /// nothing more from this machine. + pub fn awaiting(&self) -> bool { + self.missing.iter().any(|m| m.why != Some(Why::Off)) + } +} + /// Why machines heard here can't hear this one, when this machine can /// tell: its own packets leave the wrong way, or it runs a version they /// can't read. @@ -1004,14 +1035,16 @@ mod tests { /// As if it reached the relay when it was made. fn rx(r: NetReport) -> Received { Received { - at: r.at, + at: Some(r.at), report: r, } } - /// Alone, with the laptop's report in and this machine's network known. + /// Alone for an hour, with the laptop's report in and this machine's + /// network known. fn compared(r: NetReport) -> Facts { Facts { + listening_for: Duration::from_secs(3600), this_id: "desk".into(), now: NOW, relay_online: true, @@ -1253,6 +1286,123 @@ mod tests { ); } + fn left(seen_at: i64) -> Seen { + Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: false, + seen_at, + since: seen_at - 3_600, + } + } + + #[test] + fn a_report_from_before_the_machine_left_says_nothing_about_now() { + // It heard this machine, then went to sleep 4 minutes ago; its last + // report is from 8 minutes ago, when it was still here. + let asleep = Facts { + seen: vec![left(NOW - 240)], + ..compared(NetReport { + at: NOW - 480, + hears: vec!["desk".into()], + ..report() + }) + }; + assert_eq!(why_of(&asleep), None); + // A report from after it left does count. + let moved = Facts { + seen: vec![left(NOW - 240)], + ..compared(NetReport { + at: NOW - 60, + vpn: Some(true), + ..report() + }) + }; + assert_eq!(why_of(&moved), Some(Why::TheirVpn)); + // Offline here: a newer report may be waiting unseen. + assert_eq!( + why_of(&Facts { + relay_online: false, + ..moved + }), + None + ); + // Unknown age (an older relay): only "off". + let mut unknown = compared(report()); + unknown.reports.get_mut("laptop").unwrap().at = None; + assert_eq!(why_of(&unknown), None); + unknown.reports.get_mut("laptop").unwrap().report.on = false; + assert_eq!(why_of(&unknown), Some(Why::Off)); + } + + #[test] + fn hearing_this_machine_only_counts_from_this_run_with_its_own_sends_fine() { + let hears_us = NetReport { + hears: vec!["desk".into()], + ..report() + }; + // It heard the Patch Pilot that ran here before this one. + let restarted = Facts { + listening_for: Duration::from_secs(40), + ..compared(hears_us.clone()) + }; + assert_eq!(why_of(&restarted), None); + // Its own VPN state unknown, or its broadcasts failing: this + // machine's firewall isn't the likely reason. + let unknown_vpn = NetReport { + vpn: None, + ..hears_us.clone() + }; + assert_eq!(why_of(&compared(unknown_vpn)), None); + let failing = NetReport { + send_error: true, + ..hears_us.clone() + }; + assert_eq!(why_of(&compared(failing)), None); + let failing_blocked = NetReport { + send_error: true, + ..report() + }; + assert_eq!(why_of(&compared(failing_blocked)), None); + // A virtual adapter there is its own reason, not "a VPN". + let wsl = NetReport { + virtual_adapter: Some("vEthernet (WSL)".into()), + ..hears_us + }; + assert_eq!( + why_of(&compared(wsl)), + Some(Why::TheirVirtual { + name: "vEthernet (WSL)".into() + }) + ); + } + + #[test] + fn quiet_counts_from_when_it_was_last_heard_here() { + let old = NetReport { + at: NOW - 5 * 3_600, + ..report() + }; + let d = Facts { + seen: vec![left(NOW - 13 * 60)], + ..compared(old) + }; + assert_eq!( + why_of(&d), + Some(Why::Quiet { + since: NOW - 13 * 60 + }) + ); + } + + #[test] + fn a_machine_off_the_home_network_isnt_awaited() { + let off = diagnose(&compared(NetReport::off(NOW))); + assert!(!off.awaiting(), "it said it doesn't use the home network"); + assert!(diagnose(&compared(report())).awaiting()); + assert!(diagnose(&alone()).awaiting(), "no report: it might turn up"); + } + #[test] fn subnets_come_from_wifi_and_ethernet_only() { let ifaces = vec![ diff --git a/crates/local/src/net_report.rs b/crates/local/src/net_report.rs index bdbd58d..08fe99a 100644 --- a/crates/local/src/net_report.rs +++ b/crates/local/src/net_report.rs @@ -50,10 +50,17 @@ pub struct NetReport { /// prefix only. Empty when it hasn't looked. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subnets: Vec, - /// Its broadcasts leave through a VPN or a virtual adapter. None when it - /// hasn't looked. + /// Its broadcasts leave through a VPN. None when it hasn't looked. #[serde(default, skip_serializing_if = "Option::is_none")] pub vpn: Option, + /// Its broadcasts leave into a virtual network inside it (WSL, Hyper-V, + /// Docker), by adapter name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub virtual_adapter: Option, + /// Its last broadcast failed to go out (macOS Local Network off, say): + /// nothing it sends reaches anyone, whatever this machine does. + #[serde(default, skip_serializing_if = "is_false")] + pub send_error: bool, /// Its internet traffic leaves through a VPN, so `public` is the VPN's, /// not its home router's. None when it hasn't looked. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -78,6 +85,8 @@ impl Default for NetReport { listening_since: None, subnets: Vec::new(), vpn: None, + virtual_adapter: None, + send_error: false, internet_vpn: None, hears: Vec::new(), public: None, @@ -93,6 +102,10 @@ fn is_true(b: &bool) -> bool { *b } +fn is_false(b: &bool) -> bool { + !*b +} + impl NetReport { /// A machine that doesn't use the home network. pub fn off(at: i64) -> Self { @@ -135,24 +148,30 @@ impl NetReport { } /// Another machine's report, with when it reached the relay on this -/// machine's clock. +/// machine's clock. None when that's unknown: fetched from an older relay +/// that doesn't say how long it held it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Received { pub report: NetReport, - pub at: i64, + pub at: Option, } impl Received { - /// Recent enough to say something about now. + /// Known to be recent enough to say something about now. pub fn is_fresh(&self, now: i64) -> bool { - now - self.at.min(now) <= FRESH_SECS + self.at.is_some_and(|at| now - at.min(now) <= FRESH_SECS) } } -/// When a report reached the relay, on this machine's clock: now, less how -/// long the relay says it has held it. Without that, now. -pub fn received_at(now: i64, held_ms: Option) -> i64 { - now - held_ms.map_or(0, |ms| ms.max(0) / 1000) +/// When a report reached the relay, on this machine's clock. `live`: it +/// just arrived as a change, so now. Otherwise now less how long the relay +/// says it held it, or unknown when it doesn't say. +pub fn received_at(now: i64, held_ms: Option, live: bool) -> Option { + match held_ms { + _ if live => Some(now), + Some(ms) => Some(now - ms.max(0) / 1000), + None => None, + } } /// How a public address is compared: an IPv4 address whole (one per home @@ -340,15 +359,22 @@ mod tests { fn fresh_means_recently_on_the_relay_by_this_machines_clock() { let r = Received { report: on(), - at: 10_000, + at: Some(10_000), }; assert!(r.is_fresh(10_000)); assert!(r.is_fresh(10_000 + FRESH_SECS)); assert!(!r.is_fresh(10_000 + FRESH_SECS + 1)); assert!(r.is_fresh(10_000 - 60)); - assert_eq!(received_at(10_000, Some(90_500)), 10_000 - 90); - assert_eq!(received_at(10_000, Some(-5)), 10_000); - assert_eq!(received_at(10_000, None), 10_000); + let unknown = Received { at: None, ..r }; + assert!(!unknown.is_fresh(10_000), "an unknown age is never fresh"); + assert_eq!(received_at(10_000, Some(90_500), false), Some(10_000 - 90)); + assert_eq!(received_at(10_000, Some(-5), false), Some(10_000)); + assert_eq!(received_at(10_000, None, false), None, "an older relay"); + assert_eq!( + received_at(10_000, None, true), + Some(10_000), + "just arrived" + ); } #[test] diff --git a/docs/decisions.md b/docs/decisions.md index 3edd53d..1203021 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,4 +128,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | | 122 | 2026-11-06 | **One "Scripts, templates and presets" guide on every screen that moves them** (Joshua: a first-time tester couldn't tell how to get Reaper scripts and templates working across machines). A `FilesGuide` Notice line (app/src/FilesGuide.tsx) sits in Get Up To Speed (this machine), Set up, This machine and Settings > Sync. Its line names the next thing to do (link the machines, then Keep Files in Sync) with that button, or turns green once both are done; How it works opens the ways in order, each ticked when done here: link your machines, Bring over what's missing (from the Primary Rig), Keep Files in Sync on every machine (same cloud folder, GitHub repo or home network), Choose what syncs, and what's never copied (plug-ins, Reaper extensions, sample libraries; reaper.ini and audio/MIDI settings stay per machine). On a checklist it starts open until syncing is on; elsewhere folded; the toggle is remembered. On This machine it hides once linked and syncing, since Sync & backup says the rest. Buttons reach the Link dialog and other tabs through a small `ShellContext` (app/src/shell.ts) instead of props through every screen. Bring over's "link first" line gets a Link a machine button, Set up's intro and tour step 5 say to link first. | The ways existed but were spread over four places with nothing saying which comes first or that linking is required; one line per screen keeps it compact (decision #68) while answering "how do my scripts get there?" where people look. | Accepted | | 123 | 2026-11-06 | **Home network says why it can't find a machine** (amends #94; Brian: his machines were linked but home-network sync never found the laptop, which was on another Wi-Fi and a VPN, and nothing said why). Linking only uses the relay, so it worked; the beacons (UDP broadcast to 255.255.255.255) never reached the other machine, and "heard nobody" wasn't an error: the line said "Looking for your other machines…" forever, or a green "In sync…". Now the Sync & backup line names the linked machines it expects and says Looking for X for the first 30 s after the home network is picked (checked with each 10 s beacon, so 30-40 s), then Can't find X on this network, with a cause when this machine can tell; a machine that was here and left reads "isn't on this network now (seen 2 h ago)" instead of "In sync", and one missing while another is here is added to the In sync line. The opened details list each machine (on this network / last on it / not found) and a checklist (same network, VPN, Windows Private network and firewall, macOS Local Network, Patch Pilot open with Home network on the other machine, same version), the detected cause marked. **Expected machines** are the other `machine/` objects the relay lists when live sync starts, plus `changed` events (no extra relay reads, #113), named from the store; machines added from an export file or an earlier rig, and machines removed from the store (their object stays on the relay), never show as missing. Before the first listing, every other machine in the store; while this machine isn't linked, none. **Causes**, surest first, in `patchpilot_local::lan_diagnosis::diagnose` (pure, tested with hand-built facts): a beacon sealed by this rig that this version can't read, or a newer envelope (other version); no Wi-Fi/Ethernet IPv4 address; broadcasts leaving through a VPN or a virtual adapter (Hyper-V, WSL, Docker, VMware); macOS refusing the broadcast with EHOSTUNREACH while a default route works (Local Network access off: a strong guess, macOS has no API to ask; with no gateway macOS refuses broadcasts the same way); Windows calling the network Public; a Patch Pilot beacon under another rig key heard in the last 45 s; and last, a guess, a VPN holding the internet route. While another linked machine is here, only the other-version and other-rig causes are named, since this machine's network works. What one machine can't see (the other on another Wi-Fi, client isolation, the other's firewall, Home network off there) stays on the checklist. **How it looks** (`patchpilot_local::netinfo`, read-only, sends nothing): a UDP socket with broadcast on, connected to 255.255.255.255:47219, and one connected to 1.1.1.1:53, give the source address of each; sysinfo's `network` feature (unsafe stays inside sysinfo) lists interfaces, and an address on none of them while a home-network address exists is a VPN's, since Windows leaves MAC-less WireGuard/Wintun adapters out. A "virtual" adapter carrying the internet route is the home network itself (a Hyper-V external switch or bridged WSL moves the real address onto vEthernet). Adapters are told apart by name and, on Windows, by the description PowerShell's `Get-NetAdapter` gives, because many VPNs (GlobalProtect's "PANGP Virtual Ethernet Adapter") name theirs "Ethernet 4"; the same call reads `Get-NetConnectionProfile` (DomainAuthenticated counts as Private), with the name in an environment variable and compared exactly (not as a wildcard), no window, below-normal priority (#114; the thread's background mode doesn't pass to a child), killed after 5 s (WMI can hang), cached 10 min per adapter. All of it runs on the existing beacon thread, only while a machine is missing, none is here and the grace is over, at most every 30 s, and the UI is told only when the diagnosis changes; `view()` only reads what's known. Only the broadcast's send result counts (a machine that left answers unicast with "unreachable" too). A pull error goes once its machine is gone. Picking the home network again restarts the 30 s and looks at the network afresh. The port error is worded by its cause (Windows' reserved port ranges give 10013, not "another app"). **Later, separately:** each machine telling the others its network over the relay ("Laptop is on a different network"), and beacons out of every interface so sync works despite a VPN. | The first time a user linked two machines, home-network sync failed with nothing to go on; the fix was a minute's work once the cause was known. Saying only what this machine can actually tell keeps the line trustworthy; the checklist covers the rest. | Proposed | -| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts and its internet traffic leave through a VPN or virtual adapter, which linked machines it hears now (ids), and its public address as the relay saw it. Network facts are only included while they were looked at in the last 90 s (they're looked at only while something is wrong), so a report never carries facts from an earlier problem. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine is missing or can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is); picked there but its port won't open; no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline); a VPN is on there; it hears this machine but nothing it sends arrives (a firewall here); a different network: no subnet in common, or the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, and nothing unrelated. A definite reason from the missing machine beats this machine's guesses (a VPN carrying internet traffic, another rig nearby). While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | +| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts leave through a VPN or into a virtual adapter (named), whether its last broadcast failed, whether its internet traffic leaves through a VPN, which linked machines it hears now (ids), and its public address as the relay saw it (forgotten when its subnets change, and the report is sent again at once when an upload reveals a new one). Network facts are only included while they were looked at in the last 90 s by the wall clock (they're looked at only while something is wrong, Windows adapter descriptions at most every 10 min), so a report never carries facts from an earlier problem or from before a Mac slept. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine that might still turn up is missing (not one whose report says Home network is off) or one here can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is, and is the only reason named when the report's age is unknown); no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline; "last checked in" counts from the later of the report and when it was last heard). Every other reason needs a fresh report that reached the relay at least 10 s after this machine last heard that machine (an earlier one was made while it was still here, before it slept, quit or moved), and this machine online: picked there but its port won't open; a VPN is on there; its messages go into a virtual adapter there; it hears this machine but nothing it sends arrives (a firewall here; only when its own sends are known fine: no VPN, no failed broadcast, and the report made after this machine started listening); a different network: no subnet in common, or the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray or failing, the report made after this machine started listening, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, and nothing unrelated. On the line and in the details alike, a guess here (a VPN carrying internet traffic, another rig nearby) gives way to any missing machine's own reason, and any cause here gives way when every missing machine is off the home network or asleep (a sure one stays in the details as "also on this machine"). While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | From 4548ab5af8336fb59374de73ad470b4e1f2d1bec Mon Sep 17 00:00:00 2001 From: bfieser Date: Fri, 6 Nov 2026 16:04:20 -0600 Subject: [PATCH 5/6] Home network compare: close what the verification pass found (#219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "It hears this machine" needs a report made at least 55 s after this machine last heard it and after this run started (its list keeps a machine 45 s); other reasons need 20 s, two beacons, not one. - "Different network" by subnets comes before "it hears this machine", so a machine that moved isn't read as a firewall problem. - "Blocked" and "it hears this machine" also need this machine's own broadcasts to go out fine; a send that fails without an OS code counts. - The public address is forgotten at the start of each relay session. - The live connection counts as offline when a ping goes unanswered, so reports aren't trusted over a connection that died silently; a stale report nudge doesn't put the same report twice. - This machine's network facts older than 90 s are ignored by the diagnosis too; a clock stepped back means "look again". - An adapter Windows couldn't describe counts as unknown (retried each minute), not the home network. - A cause here gives way when every missing machine has a reason on its own side (incl. another network), so a laptop at a café isn't told to make its Wi-Fi Private; one that gave way marks nothing on the checklist. - Missing and deaf at once is said plainly; the deaf cause is found then. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/lan.rs | 53 +++++++++++++-- app/src-tauri/src/sync.rs | 49 +++++++++++++- app/src/Backup.tsx | 26 +++++--- crates/local/src/lan_diagnosis.rs | 107 ++++++++++++++++++++++++------ docs/decisions.md | 2 +- 5 files changed, 196 insertions(+), 41 deletions(-) diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index 739028f..2fce2c9 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -52,6 +52,8 @@ const MAX_CONNECTIONS: usize = 8; const NET_EVERY: Duration = Duration::from_secs(30); /// Ask Windows about an adapter (PowerShell, about a second) this often. const ADAPTER_EVERY: Duration = Duration::from_secs(600); +/// …or this often while it couldn't say (WMI timed out, PowerShell blocked). +const ADAPTER_RETRY: Duration = Duration::from_secs(60); /// A report's network facts older than this are left out: they were last /// looked at during an earlier problem and may no longer hold. const NET_FACTS_FOR: Duration = Duration::from_secs(90); @@ -349,7 +351,8 @@ fn announce(app: &AppHandle, to: Option) { // Only the broadcast says something about this machine's network: a // machine heard before that left answers "unreachable" too. if ip == broadcast { - *state.broadcast_error.lock().unwrap() = sent.err().and_then(|e| e.raw_os_error()); + *state.broadcast_error.lock().unwrap() = + sent.err().map(|e| e.raw_os_error().unwrap_or(-1)); } } } @@ -552,7 +555,7 @@ fn check(app: &AppHandle) { .lock() .unwrap() .checked_at - .is_none_or(|t| patchpilot_local::now() - t >= NET_EVERY.as_secs() as i64); + .is_none_or(|t| !net_age_within(t, NET_EVERY)); // Only while something is wrong: a machine that might still turn up is // missing (not one that said it doesn't use the home network), or one // here can't hear this one. @@ -609,6 +612,7 @@ fn look_at_network(app: &AppHandle) { // What Windows says about the adapters the packets leave on, since many // VPNs call theirs "Ethernet 4" and say what they are only there. let mut broadcast_info = None; + let mut undescribed: Vec = Vec::new(); for (src, is_broadcast) in [(broadcast, true), (route, false)] { let Some(name) = src .and_then(|ip| netinfo::interface_of(ip, &ifaces)) @@ -619,7 +623,14 @@ fn look_at_network(app: &AppHandle) { let fresh = net .adapters .get(&name) - .filter(|(at, _)| at.elapsed() < ADAPTER_EVERY) + .filter(|(at, info)| { + at.elapsed() + < if info.is_some() { + ADAPTER_EVERY + } else { + ADAPTER_RETRY + } + }) .map(|(_, info)| info.clone()); // Without it a VPN called "Ethernet 4" passes for the home network, // so ask even while another machine is here. @@ -632,12 +643,25 @@ fn look_at_network(app: &AppHandle) { if let Some(i) = ifaces.iter_mut().find(|i| i.name == name) { i.description = info.as_ref().and_then(|a| a.description.clone()); } + if info.is_none() { + undescribed.push(name.clone()); + } if is_broadcast { broadcast_info = info; } } - net.broadcast_egress = broadcast.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); - net.route_egress = route.map(|ip| lan_diagnosis::egress_kind(ip, &ifaces, route)); + // On Windows a VPN often calls its adapter "Ethernet 4" and says what it + // is only in the description: without one, the name alone can't tell. + let egress = |ip: IpAddr| { + let kind = lan_diagnosis::egress_kind(ip, &ifaces, route); + let unsure = cfg!(windows) + && kind == Egress::Lan + && netinfo::interface_of(ip, &ifaces) + .is_some_and(|n| undescribed.iter().any(|u| u == n)); + if unsure { Egress::Unknown } else { kind } + }; + net.broadcast_egress = broadcast.map(egress); + net.route_egress = route.map(egress); let subnets = lan_diagnosis::subnets(&ifaces, route); if !net.subnets.is_empty() && subnets != net.subnets { // Another network: the public address the relay saw belongs to the @@ -682,7 +706,14 @@ fn facts(app: &AppHandle) -> Facts { since: p.since, }) .collect(); - let net = state.net.lock().unwrap().clone(); + let mut net = state.net.lock().unwrap().clone(); + if !net + .checked_at + .is_some_and(|t| net_age_within(t, NET_FACTS_FOR)) + { + // From an earlier look that may no longer hold: as if never looked. + net = NetCache::default(); + } Facts { listening_for: state .listening_since @@ -708,6 +739,14 @@ fn facts(app: &AppHandle) -> Facts { } } +/// A wall-clock time at most `within` ago. A clock stepped back makes it +/// "not within", so the network is looked at again and old facts aren't +/// taken as fresh. +fn net_age_within(t: i64, within: Duration) -> bool { + let age = patchpilot_local::now() - t; + (0..within.as_secs() as i64).contains(&age) +} + /// Heard from in the last 45 s. fn is_here(p: &Peer) -> bool { patchpilot_local::now() - p.seen_at < GONE.as_secs() as i64 @@ -737,7 +776,7 @@ pub(crate) fn own_report(app: &AppHandle) -> NetReport { // Facts from an earlier problem may no longer hold: say nothing then. let recent = net .checked_at - .is_some_and(|t| now - t < NET_FACTS_FOR.as_secs() as i64); + .is_some_and(|t| net_age_within(t, NET_FACTS_FOR)); // A virtual adapter isn't a VPN: its own field, so the others can say so. let through_vpn = |e: &Option| match e { Some(Egress::Lan | Egress::Virtual { .. }) => Some(false), diff --git a/app/src-tauri/src/sync.rs b/app/src-tauri/src/sync.rs index b6de3eb..710b9bc 100644 --- a/app/src-tauri/src/sync.rs +++ b/app/src-tauri/src/sync.rs @@ -159,6 +159,10 @@ async fn run(app: &AppHandle, state: &Sync) { async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), String> { let relay = Relay::new(rig); let this = this_machine()?; + // A new session often means a new network (waking somewhere else): the + // address the relay saw before may not be this one. The first upload + // says again. + *state.public.lock().expect("public lock") = None; // Upload first: the first upload claims a new rig, and live needs a claimed one. upload(&relay, state, &this, rig).await?; let mut ws = relay.live().await?; @@ -200,9 +204,17 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri }); let mut ping = tokio::time::interval(PING_EVERY); + // When a ping went out that nothing has answered yet. A connection that + // died silently (sleep, a dropped Wi-Fi) is noticed from that, so this + // machine doesn't keep trusting reports it can no longer see change. + let mut unanswered: Option = None; loop { tokio::select! { + // Messages first, so an answer that came in during a long pull + // is seen before the next ping is judged. + biased; msg = ws.next() => { + unanswered = None; let text = match msg { Some(Ok(Message::Text(t))) => t, Some(Ok(Message::Close(_))) | None => return Ok(()), @@ -229,8 +241,23 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri } } () = state.nudge.notified() => upload(&relay, state, &this, rig).await?, - () = state.report_nudge.notified() => send_report(app, &relay, &this, rig).await, - _ = ping.tick() => ws.send(Message::text("ping")).await.map_err(|e| e.to_string())?, + () = state.report_nudge.notified() => { + // A nudge left from before this session or a failed one: the + // session start just sent the same. + let current = crate::lan::own_report(app); + let just_sent = last_report(app) + .is_some_and(|(r, ago)| ago < patchpilot_local::net_report::MIN_GAP && r.same_news(¤t)); + if !just_sent { + send_report(app, &relay, &this, rig).await; + } + } + _ = ping.tick() => { + if unanswered.is_some_and(|t| t.elapsed() >= PING_EVERY) { + return Err("the relay stopped answering".into()); + } + ws.send(Message::text("ping")).await.map_err(|e| e.to_string())?; + unanswered.get_or_insert_with(std::time::Instant::now); + } } } } @@ -901,6 +928,24 @@ mod tests { let _ = std::fs::remove_file(path); } + #[tokio::test] + #[ignore = "needs a relay (PATCHPILOT_RELAY)"] + async fn the_live_connection_answers_pings() { + // A session counts as offline when a ping goes unanswered. + let rig = RigKey::generate(); + let relay = Relay::new(&rig); + relay.put("machine/pc", b"sealed".to_vec()).await.unwrap(); + let mut ws = relay.live().await.unwrap(); + ws.send(Message::text("ping")).await.unwrap(); + let answer = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .expect("an answer"); + let Some(Ok(Message::Text(t))) = answer else { + panic!("no pong: {answer:?}") + }; + assert_eq!(t.as_str(), "pong"); + } + #[tokio::test] #[ignore = "needs a relay (PATCHPILOT_RELAY)"] async fn home_network_reports_travel_with_their_age() { diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index 3f1c5a3..ad6bee0 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -245,10 +245,11 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan if (cause) return ["warn", `Can't find ${who} on this network: ${lanCauseText(cause)}`]; if (d.here.length > 0) { // Someone here can't hear this machine too: that's not "in sync". - if (deaf.length > 0) { - const [, text] = withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now`); - return ["warn", `${text}, and ${andList(deaf)} can't hear this machine.`]; - } + if (deaf.length > 0) + return [ + "warn", + `${who} ${isnt} on this network now, and ${andList(deaf)} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}. Show what to check.`, + ]; return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); } const allQuiet = d.missing.every((m) => m.why?.kind === "quiet"); @@ -323,16 +324,20 @@ function isGuess(c: LanCause | undefined): boolean { return c?.kind === "vpn_on" || c?.kind === "other_rig"; } +/** Reasons that hold whatever this machine's own network does. */ +const THEIR_SIDE = new Set(["off", "cant_listen", "quiet", "their_vpn", "their_virtual", "other_network"]); + /** * Whether this machine's own cause gives way to what the missing machines * say: a guess gives way to any of their reasons, and any cause gives way - * when every one of them is off the home network or asleep, which holds - * whatever this machine's network does. The line and the details agree. + * when every one of them has a reason on its own side (off, asleep, its VPN, + * another network): a laptop at a café is told it's on another network, not + * to make the café's Wi-Fi Private. The line and the details agree. */ function causeGivesWay(d: LanDiagnosis): boolean { if (!d.cause || d.missing.length === 0) return false; if (isGuess(d.cause) && d.missing.some((m) => m.why)) return true; - return d.missing.every((m) => m.why?.kind === "off" || m.why?.kind === "quiet"); + return d.missing.every((m) => m.why && THEIR_SIDE.has(m.why.kind)); } /** What a missing machine's own report says, after "Can't find X on this network: ". */ @@ -375,7 +380,7 @@ function lanWhyShort(w: LanWhy): string { case "other_network": return "on a different network"; case "blocked": - return "same network, but neither hears the other"; + return "its report says the same network, but neither hears the other"; } } @@ -392,7 +397,8 @@ function LanMachines({ lan }: { lan: LanView }) { // sure cause that gave way is still worth fixing, so it stays, as "also". const givesWay = causeGivesWay(d); const shown = givesWay && isGuess(d.cause) ? undefined : d.cause; - const cause = shown?.kind; + // Only the reason shown as the reason marks the checklist. + const cause = givesWay ? undefined : shown?.kind; const deaf = d.deaf ?? []; const whys = new Set(d.missing.map((m) => m.why?.kind)); const items: [string, boolean][] = [ @@ -434,7 +440,7 @@ function LanMachines({ lan }: { lan: LanView }) { {/* The line above is cut to one line; the reason is here in full. */} {shown && (

      - {givesWay ? "Also on this machine" : "Likely reason"}: {lanCauseText(shown)} + {givesWay ? "Also worth checking" : "Likely reason"}: {lanCauseText(shown)}

      )} {!d.looking && diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs index 69ca40a..75eeb4e 100644 --- a/crates/local/src/lan_diagnosis.rs +++ b/crates/local/src/lan_diagnosis.rs @@ -51,10 +51,15 @@ const DEAF_AFTER_SECS: i64 = 90; /// machine that just restarted isn't taken as deaf. const LISTEN_FIRST_SECS: i64 = 30; /// A missing machine's report says how things are now only if it reached -/// the relay at least this long after this machine last heard it (a beacon -/// apart): an earlier one was made while it was still here, before it went -/// to sleep, quit or moved. -const AFTER_HEARD_SECS: i64 = 10; +/// the relay at least this long after this machine last heard it (two +/// beacons, for one lost and clocks in whole seconds): an earlier one was +/// made while it was still here, before it went to sleep, quit or moved. +const AFTER_HEARD_SECS: i64 = 20; +/// How long a report's `hears` keeps a machine after last hearing it (the +/// app's 45 s "gone"), plus a beacon: "it hears this machine" only means now +/// in a report made at least this long after this machine last heard it, or +/// after this machine started listening (it may have heard the run before). +const HEARS_FOR_SECS: i64 = 55; /// A network interface: its name as the OS shows it ("Wi-Fi", "en0"), its /// addresses with their prefix lengths, and on Windows the adapter's @@ -282,13 +287,14 @@ pub fn diagnose(f: &Facts) -> Diagnosis { .collect(); deaf.sort(); let looking = !missing.is_empty() && f.listening_for < GRACE; - let cause = if !missing.is_empty() && !looking { + let mut cause = if !missing.is_empty() && !looking { cause(f, !here.is_empty()) - } else if !deaf.is_empty() { - deaf_cause(f) } else { None }; + if cause.is_none() && !deaf.is_empty() { + cause = deaf_cause(f); + } Diagnosis { here, missing, @@ -331,21 +337,27 @@ fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { if let Some(name) = &r.virtual_adapter { return Some(Why::TheirVirtual { name: name.clone() }); } - // Its own sends go where they should, so what it hears or doesn't says - // something about this machine and the network between. - let sends_fine = r.vpn == Some(false) && !r.send_error; - // What it heard before this machine started listening was an earlier - // run of Patch Pilot here. - let since_listening = at >= f.now - f.listening_for.as_secs() as i64; + let both_looked = !r.subnets.is_empty() && !f.subnets.is_empty(); + if both_looked && !share_a_network(&r.subnets, &f.subnets) { + return Some(Why::OtherNetwork); + } + // Both sides' sends go where they should, so what it hears or doesn't + // says something about the firewalls and the network between. + let sends_fine = r.vpn == Some(false) + && !r.send_error + && matches!(f.broadcast_egress, Some(Egress::Lan)) + && f.broadcast_error.is_none(); + let listening_from = f.now - f.listening_for.as_secs() as i64; if r.hears.contains(&f.this_id) { - return (sends_fine && since_listening).then_some(Why::OneWay); + // Its `hears` keeps a machine 45 s: it must postdate both this + // machine losing it and this run starting. + let current = at >= listening_from + HEARS_FOR_SECS + && seen.is_none_or(|s| at >= s.seen_at + HEARS_FOR_SECS); + return (sends_fine && current).then_some(Why::OneWay); } - if r.subnets.is_empty() || f.subnets.is_empty() { + if !both_looked { return None; } - if !share_a_network(&r.subnets, &f.subnets) { - return Some(Why::OtherNetwork); - } // The same range. Most routers hand out the same few, so it's the same // network only if the public address matches too, and that only says // something when neither side's internet goes through a VPN. @@ -357,9 +369,9 @@ fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { if !same { return Some(Why::OtherNetwork); } - // Same network: blocked only when neither side's packets go astray. - (matches!(f.broadcast_egress, Some(Egress::Lan)) && sends_fine && since_listening) - .then_some(Why::Blocked) + // Same network: blocked only when neither side's packets go astray, and + // it had time to hear this run's beacons. + (sends_fine && at >= listening_from + AFTER_HEARD_SECS).then_some(Why::Blocked) } impl Diagnosis { @@ -1377,6 +1389,59 @@ mod tests { ); } + #[test] + fn hearing_this_machine_needs_a_report_well_after_it_was_last_heard() { + let hears_us = |at: i64| NetReport { + at, + hears: vec!["desk".into()], + ..report() + }; + // A beacon was lost, then it sent a heartbeat and closed its lid: + // its "hears" still had this machine in it. + let lid = Facts { + seen: vec![left(NOW - 120)], + ..compared(hears_us(NOW - 107)) + }; + assert_eq!(why_of(&lid), None); + let later = Facts { + seen: vec![left(NOW - 120)], + ..compared(hears_us(NOW - 60)) + }; + assert_eq!(why_of(&later), Some(Why::OneWay)); + // It moved to another network while it still listed this machine. + let moved = Facts { + seen: vec![left(NOW - 120)], + ..compared(NetReport { + subnets: vec![Subnet::of(Ipv4Addr::new(10, 0, 0, 7), 24).unwrap()], + ..hears_us(NOW - 100) + }) + }; + assert_eq!(why_of(&moved), Some(Why::OtherNetwork)); + // It heard the run before this one, which quit moments ago. + let rerun = Facts { + listening_for: Duration::from_secs(90), + ..compared(hears_us(NOW - 60)) + }; + assert_eq!(why_of(&rerun), None); + } + + #[test] + fn this_machines_own_failing_sends_rule_out_blocked_and_one_way() { + let failing = Facts { + broadcast_error: Some(10051), + ..compared(report()) + }; + assert_eq!(why_of(&failing), None); + let hears_us = Facts { + broadcast_error: Some(10051), + ..compared(NetReport { + hears: vec!["desk".into()], + ..report() + }) + }; + assert_eq!(why_of(&hears_us), None); + } + #[test] fn quiet_counts_from_when_it_was_last_heard_here() { let old = NetReport { diff --git a/docs/decisions.md b/docs/decisions.md index 1203021..90908c3 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,4 +128,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | | 122 | 2026-11-06 | **One "Scripts, templates and presets" guide on every screen that moves them** (Joshua: a first-time tester couldn't tell how to get Reaper scripts and templates working across machines). A `FilesGuide` Notice line (app/src/FilesGuide.tsx) sits in Get Up To Speed (this machine), Set up, This machine and Settings > Sync. Its line names the next thing to do (link the machines, then Keep Files in Sync) with that button, or turns green once both are done; How it works opens the ways in order, each ticked when done here: link your machines, Bring over what's missing (from the Primary Rig), Keep Files in Sync on every machine (same cloud folder, GitHub repo or home network), Choose what syncs, and what's never copied (plug-ins, Reaper extensions, sample libraries; reaper.ini and audio/MIDI settings stay per machine). On a checklist it starts open until syncing is on; elsewhere folded; the toggle is remembered. On This machine it hides once linked and syncing, since Sync & backup says the rest. Buttons reach the Link dialog and other tabs through a small `ShellContext` (app/src/shell.ts) instead of props through every screen. Bring over's "link first" line gets a Link a machine button, Set up's intro and tour step 5 say to link first. | The ways existed but were spread over four places with nothing saying which comes first or that linking is required; one line per screen keeps it compact (decision #68) while answering "how do my scripts get there?" where people look. | Accepted | | 123 | 2026-11-06 | **Home network says why it can't find a machine** (amends #94; Brian: his machines were linked but home-network sync never found the laptop, which was on another Wi-Fi and a VPN, and nothing said why). Linking only uses the relay, so it worked; the beacons (UDP broadcast to 255.255.255.255) never reached the other machine, and "heard nobody" wasn't an error: the line said "Looking for your other machines…" forever, or a green "In sync…". Now the Sync & backup line names the linked machines it expects and says Looking for X for the first 30 s after the home network is picked (checked with each 10 s beacon, so 30-40 s), then Can't find X on this network, with a cause when this machine can tell; a machine that was here and left reads "isn't on this network now (seen 2 h ago)" instead of "In sync", and one missing while another is here is added to the In sync line. The opened details list each machine (on this network / last on it / not found) and a checklist (same network, VPN, Windows Private network and firewall, macOS Local Network, Patch Pilot open with Home network on the other machine, same version), the detected cause marked. **Expected machines** are the other `machine/` objects the relay lists when live sync starts, plus `changed` events (no extra relay reads, #113), named from the store; machines added from an export file or an earlier rig, and machines removed from the store (their object stays on the relay), never show as missing. Before the first listing, every other machine in the store; while this machine isn't linked, none. **Causes**, surest first, in `patchpilot_local::lan_diagnosis::diagnose` (pure, tested with hand-built facts): a beacon sealed by this rig that this version can't read, or a newer envelope (other version); no Wi-Fi/Ethernet IPv4 address; broadcasts leaving through a VPN or a virtual adapter (Hyper-V, WSL, Docker, VMware); macOS refusing the broadcast with EHOSTUNREACH while a default route works (Local Network access off: a strong guess, macOS has no API to ask; with no gateway macOS refuses broadcasts the same way); Windows calling the network Public; a Patch Pilot beacon under another rig key heard in the last 45 s; and last, a guess, a VPN holding the internet route. While another linked machine is here, only the other-version and other-rig causes are named, since this machine's network works. What one machine can't see (the other on another Wi-Fi, client isolation, the other's firewall, Home network off there) stays on the checklist. **How it looks** (`patchpilot_local::netinfo`, read-only, sends nothing): a UDP socket with broadcast on, connected to 255.255.255.255:47219, and one connected to 1.1.1.1:53, give the source address of each; sysinfo's `network` feature (unsafe stays inside sysinfo) lists interfaces, and an address on none of them while a home-network address exists is a VPN's, since Windows leaves MAC-less WireGuard/Wintun adapters out. A "virtual" adapter carrying the internet route is the home network itself (a Hyper-V external switch or bridged WSL moves the real address onto vEthernet). Adapters are told apart by name and, on Windows, by the description PowerShell's `Get-NetAdapter` gives, because many VPNs (GlobalProtect's "PANGP Virtual Ethernet Adapter") name theirs "Ethernet 4"; the same call reads `Get-NetConnectionProfile` (DomainAuthenticated counts as Private), with the name in an environment variable and compared exactly (not as a wildcard), no window, below-normal priority (#114; the thread's background mode doesn't pass to a child), killed after 5 s (WMI can hang), cached 10 min per adapter. All of it runs on the existing beacon thread, only while a machine is missing, none is here and the grace is over, at most every 30 s, and the UI is told only when the diagnosis changes; `view()` only reads what's known. Only the broadcast's send result counts (a machine that left answers unicast with "unreachable" too). A pull error goes once its machine is gone. Picking the home network again restarts the 30 s and looks at the network afresh. The port error is worded by its cause (Windows' reserved port ranges give 10013, not "another app"). **Later, separately:** each machine telling the others its network over the relay ("Laptop is on a different network"), and beacons out of every interface so sync works despite a VPN. | The first time a user linked two machines, home-network sync failed with nothing to go on; the fix was a minute's work once the cause was known. Saying only what this machine can actually tell keeps the line trustworthy; the checklist covers the rest. | Proposed | -| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts leave through a VPN or into a virtual adapter (named), whether its last broadcast failed, whether its internet traffic leaves through a VPN, which linked machines it hears now (ids), and its public address as the relay saw it (forgotten when its subnets change, and the report is sent again at once when an upload reveals a new one). Network facts are only included while they were looked at in the last 90 s by the wall clock (they're looked at only while something is wrong, Windows adapter descriptions at most every 10 min), so a report never carries facts from an earlier problem or from before a Mac slept. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine that might still turn up is missing (not one whose report says Home network is off) or one here can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is, and is the only reason named when the report's age is unknown); no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline; "last checked in" counts from the later of the report and when it was last heard). Every other reason needs a fresh report that reached the relay at least 10 s after this machine last heard that machine (an earlier one was made while it was still here, before it slept, quit or moved), and this machine online: picked there but its port won't open; a VPN is on there; its messages go into a virtual adapter there; it hears this machine but nothing it sends arrives (a firewall here; only when its own sends are known fine: no VPN, no failed broadcast, and the report made after this machine started listening); a different network: no subnet in common, or the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray or failing, the report made after this machine started listening, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, and nothing unrelated. On the line and in the details alike, a guess here (a VPN carrying internet traffic, another rig nearby) gives way to any missing machine's own reason, and any cause here gives way when every missing machine is off the home network or asleep (a sure one stays in the details as "also on this machine"). While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | +| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts leave through a VPN or into a virtual adapter (named), whether its last broadcast failed, whether its internet traffic leaves through a VPN, which linked machines it hears now (ids), and its public address as the relay saw it (forgotten at the start of every relay session and when its subnets change, and the report is sent again at once when an upload reveals a new one). Network facts are only included while they were looked at in the last 90 s by the wall clock (they're looked at only while something is wrong, Windows adapter descriptions at most every 10 min, or every minute while Windows can't say; an adapter Windows couldn't describe counts as unknown, not the home network), so a report never carries facts from an earlier problem or from before a Mac slept. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine that might still turn up is missing (not one whose report says Home network is off) or one here can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is, and is the only reason named when the report's age is unknown); no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline; "last checked in" counts from the later of the report and when it was last heard). Every other reason needs a fresh report that reached the relay at least 20 s after this machine last heard that machine (an earlier one was made while it was still here, before it slept, quit or moved), and this machine online: picked there but its port won't open; a VPN is on there; its messages go into a virtual adapter there; it's on a different network (no subnet in common); it hears this machine but nothing it sends arrives (a firewall here; only when both sides' sends are known fine: no VPN, no failed broadcast either side, and the report made at least 55 s after both this machine last heard it and this run started, since its list keeps a machine 45 s); a different network: the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray or failing, the report made at least 20 s after this run started, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, and nothing unrelated. On the line and in the details alike, a guess here (a VPN carrying internet traffic, another rig nearby) gives way to any missing machine's own reason, and any cause here gives way when every missing machine has a reason on its own side (off, can't listen, asleep, its VPN or virtual adapter, another network): a laptop at a café is told the desktop is on another network, not to make the café's Wi-Fi Private. A sure cause that gave way stays in the details as "also worth checking", without marking the checklist. The diagnosis also ignores this machine's own network facts older than 90 s, and the live connection counts as offline once a keep-alive ping goes unanswered for 50 s, so reports aren't trusted over a connection that died silently. While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | From 553b036936685b062f41c70df7f814c6383f0a2b Mon Sep 17 00:00:00 2001 From: bfieser Date: Fri, 6 Nov 2026 16:20:42 -0600 Subject: [PATCH 6/6] Home network compare: final check fixes (#219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - This machine's VPN explains a machine here that can't hear it, in its own field; it's no longer named as why another machine is missing. - Advice about this machine's network (Public, a VPN for the internet) is left out while a missing machine reports from a network this machine can't confirm is its own: a laptop at a café isn't told to make the café's Wi-Fi Private, even when the addresses can't be compared. - Waking from sleep (a beacon round over 30 s late) drops the reports read before and restarts the relay session at once; nothing from before sleep counts as current until it's read again. - One unanswered ping is enough to count the connection as dead (timed by the ticks themselves). - A VPN carrying the other machine's internet traffic, on this range and hearing nothing, is named as a likely reason; "it hears this machine" needs no VPN for the internet on either side. - While a machine here can't hear this one, reports go out every minute, and a fresh report that doesn't list this machine prompts a network look straight away, so "a firewall here" shows in about a minute, not six. - A Windows adapter with no description counts as unknown even when its network profile is known. - Machines whose report says Home network is off aren't "in sync"; with someone here, each missing machine's own reason is in the line. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/lan.rs | 31 +++-- app/src-tauri/src/sync.rs | 45 +++++-- app/src/Backup.tsx | 38 +++++- app/src/api.ts | 3 + crates/local/src/lan_diagnosis.rs | 198 ++++++++++++++++++++++++++---- crates/local/src/net_report.rs | 50 ++++++-- docs/decisions.md | 2 +- 7 files changed, 309 insertions(+), 58 deletions(-) diff --git a/app/src-tauri/src/lan.rs b/app/src-tauri/src/lan.rs index 2fce2c9..f60ccf3 100644 --- a/app/src-tauri/src/lan.rs +++ b/app/src-tauri/src/lan.rs @@ -174,7 +174,16 @@ pub fn setup(app: &AppHandle) { let handle = app.clone(); std::thread::spawn(move || { refresh_names(&handle); + let mut last_round = patchpilot_local::now(); loop { + // A round that came much later than it should: the machine slept. + // What the relay said before may be hours old, and the + // connection may be dead without knowing it yet. + let now = patchpilot_local::now(); + if now - last_round > 3 * BEACON_EVERY.as_secs() as i64 { + crate::sync::woke(&handle); + } + last_round = now; if in_use() { start(&handle); announce(&handle, None); @@ -559,7 +568,7 @@ fn check(app: &AppHandle) { // Only while something is wrong: a machine that might still turn up is // missing (not one that said it doesn't use the home network), or one // here can't hear this one. - let wrong = (d.awaiting() && !d.looking) || !d.deaf.is_empty(); + let wrong = (d.awaiting() && !d.looking) || !d.deaf.is_empty() || d.maybe_deaf; if wrong && net_due { look_at_network(app); d = lan_diagnosis::diagnose(&facts(app)); @@ -643,7 +652,7 @@ fn look_at_network(app: &AppHandle) { if let Some(i) = ifaces.iter_mut().find(|i| i.name == name) { i.description = info.as_ref().and_then(|a| a.description.clone()); } - if info.is_none() { + if info.as_ref().and_then(|a| a.description.as_ref()).is_none() { undescribed.push(name.clone()); } if is_broadcast { @@ -829,13 +838,17 @@ pub(crate) fn own_report(app: &AppHandle) -> NetReport { fn share(app: &AppHandle) { let state = app.state::(); let report = own_report(app); - let heartbeat = report.on - && state - .diagnosis - .lock() - .unwrap() - .as_ref() - .is_some_and(|d| d.awaiting() || !d.deaf.is_empty()); + // While a machine here can't hear this one, sooner: the other side + // needs a report from late enough to tell. + let heartbeat = state.diagnosis.lock().unwrap().as_ref().and_then(|d| { + if !d.deaf.is_empty() { + Some(net_report::HEARTBEAT_SOON) + } else if d.awaiting() { + Some(net_report::HEARTBEAT) + } else { + None + } + }); // Against what actually reached the relay, so a failed put is retried. let last = crate::sync::last_report(app); if !net_report::should_send(&report, last.as_ref().map(|(r, ago)| (r, *ago)), heartbeat) { diff --git a/app/src-tauri/src/sync.rs b/app/src-tauri/src/sync.rs index 710b9bc..4db0e72 100644 --- a/app/src-tauri/src/sync.rs +++ b/app/src-tauri/src/sync.rs @@ -61,6 +61,11 @@ pub struct Sync { reports: Mutex>, /// Send this machine's report (`share_report`). report_nudge: Notify, + /// Start a new session: the machine woke from sleep (`woke`). + restart: Notify, + /// Woke from sleep and the reports haven't been read again yet: until + /// then nothing read from the relay counts as current. + woken: AtomicBool, /// This machine's report as last put on the relay, and when. Only puts /// that went through, so one that failed is tried again. report_put: Mutex>, @@ -163,6 +168,8 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri // address the relay saw before may not be this one. The first upload // says again. *state.public.lock().expect("public lock") = None; + // A wake signal meant for the session before this one is handled. + let _ = futures_util::FutureExt::now_or_never(state.restart.notified()); // Upload first: the first upload claims a new rig, and live needs a claimed one. upload(&relay, state, &this, rig).await?; let mut ws = relay.live().await?; @@ -195,6 +202,7 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri } } *state.reports.lock().expect("reports lock") = reports; + state.woken.store(false, Ordering::SeqCst); if changed { let _ = app.emit("data-changed", ()); } @@ -207,7 +215,7 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri // When a ping went out that nothing has answered yet. A connection that // died silently (sleep, a dropped Wi-Fi) is noticed from that, so this // machine doesn't keep trusting reports it can no longer see change. - let mut unanswered: Option = None; + let mut unanswered: Option = None; loop { tokio::select! { // Messages first, so an answer that came in during a long pull @@ -251,12 +259,15 @@ async fn session(app: &AppHandle, state: &Sync, rig: &RigKey) -> Result<(), Stri send_report(app, &relay, &this, rig).await; } } - _ = ping.tick() => { - if unanswered.is_some_and(|t| t.elapsed() >= PING_EVERY) { + () = state.restart.notified() => return Err("woke from sleep".into()), + tick = ping.tick() => { + // Timed by the ticks themselves, so one unanswered ping is + // enough, not two. + if unanswered.is_some_and(|t| tick - t >= PING_EVERY) { return Err("the relay stopped answering".into()); } ws.send(Message::text("ping")).await.map_err(|e| e.to_string())?; - unanswered.get_or_insert_with(std::time::Instant::now); + unanswered.get_or_insert(tick); } } } @@ -388,6 +399,22 @@ async fn send_report(app: &AppHandle, relay: &Relay, this: &str, rig: &RigKey) { } } +/// The machine woke from sleep: forget the reports read before (they may be +/// hours old, and the connection may be dead without knowing it yet) and +/// start a new session, which reads them again. +pub fn woke(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + state.reports.lock().expect("reports lock").clear(); + state.woken.store(true, Ordering::SeqCst); + // A session that's running starts over (and reconnects at once); one + // that isn't reads everything again when it starts. + if state.status.lock().expect("sync status lock").online { + state.restart.notify_one(); + } +} + /// This machine moved to another network: the public address the relay /// last saw is the old one's until the next upload says. pub fn forget_public(app: &AppHandle) { @@ -470,13 +497,11 @@ pub fn public(app: &AppHandle) -> Option { .clone() } -/// Connected to the relay now. +/// Connected to the relay now, with the other machines' reports read since +/// waking. pub fn online(app: &AppHandle) -> bool { - app.state::() - .status - .lock() - .expect("sync status lock") - .online + let state = app.state::(); + !state.woken.load(Ordering::SeqCst) && state.status.lock().expect("sync status lock").online } /// The ids of the other machines linked to this one, as the relay last diff --git a/app/src/Backup.tsx b/app/src/Backup.tsx index ad6bee0..9632f04 100644 --- a/app/src/Backup.tsx +++ b/app/src/Backup.tsx @@ -231,7 +231,7 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan const deaf = d?.deaf ?? []; if (d && d.missing.length === 0 && deaf.length > 0) { const what = `${andList(deaf)} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}`; - return ["warn", d.cause ? `${what}: ${lanCauseText(d.cause)}` : `${what}. Show what to check.`]; + return ["warn", d.deaf_cause ? `${what}: ${lanCauseText(d.deaf_cause)}` : `${what}. Show what to check.`]; } if (d && d.missing.length > 0) { const who = andList(d.missing.map((m) => m.name)); @@ -248,9 +248,12 @@ function syncLine(sync: SyncView, where: string, github: string | null, lan: Lan if (deaf.length > 0) return [ "warn", - `${who} ${isnt} on this network now, and ${andList(deaf)} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}. Show what to check.`, + `${who} ${isnt} on this network now, and ${andList(deaf)} can't hear this machine, so this machine's files aren't reaching ${deaf.length === 1 ? "it" : "them"}${d.deaf_cause ? `: ${lanCauseText(d.deaf_cause)}` : ". Show what to check."}`, ]; - return withNote(filesLine(sync, where, github, lan), `${who} ${isnt} on this network now.`); + const note = d.missing.every((m) => m.why) + ? d.missing.map((m) => `${m.name}: ${m.why ? lanWhyShort(m.why) : ""}`).join("; ") + "." + : `${who} ${isnt} on this network now.`; + return withNote(filesLine(sync, where, github, lan), note); } const allQuiet = d.missing.every((m) => m.why?.kind === "quiet"); if (only?.why) @@ -274,7 +277,10 @@ function withNote([tone, text]: [Tone, string], note: string): [Tone, string] { /** What the other machines' backups brought, or why nothing came yet. */ function filesLine(sync: SyncView, where: string, github: string | null, lan: LanView | null): [Tone, string] { const last = sync.last; - const sources = (last?.sources ?? []).filter((s) => s.linked && !s.unsigned); + // A machine whose report says it doesn't use the home network isn't in + // sync through it, whatever it left in the copy here. + const off = new Set((lan?.diagnosis.missing ?? []).filter((m) => m.why?.kind === "off").map((m) => m.name)); + const sources = (last?.sources ?? []).filter((s) => s.linked && !s.unsigned && !off.has(s.machine_name)); if (!last) return ["ok", `In sync through ${where}. Checking your other machines' files…`]; if (sources.length === 0 && lan && !lan.peers.some((p) => p.here)) return [ @@ -325,7 +331,15 @@ function isGuess(c: LanCause | undefined): boolean { } /** Reasons that hold whatever this machine's own network does. */ -const THEIR_SIDE = new Set(["off", "cant_listen", "quiet", "their_vpn", "their_virtual", "other_network"]); +const THEIR_SIDE = new Set([ + "off", + "cant_listen", + "quiet", + "their_vpn", + "their_virtual", + "their_vpn_on", + "other_network", +]); /** * Whether this machine's own cause gives way to what the missing machines @@ -353,6 +367,8 @@ function lanWhyText(w: LanWhy, name: string): string { return "a VPN is on there, so its messages go through it instead of your home network. Turn it off there, or let it allow local network access."; case "their_virtual": return `its messages go to a virtual network on it (${w.name}) instead of your home network.`; + case "their_vpn_on": + return "a VPN is on there. If it blocks local network traffic, turn it off there or let it allow local network access."; case "one_way": return "it hears this machine, but nothing it sends gets here. Allow Patch Pilot through the firewall on this machine."; case "other_network": @@ -375,6 +391,8 @@ function lanWhyShort(w: LanWhy): string { return "a VPN is on there"; case "their_virtual": return `its messages go to ${w.name}`; + case "their_vpn_on": + return "a VPN is on there"; case "one_way": return "hears this machine, but nothing from it gets here"; case "other_network": @@ -403,7 +421,10 @@ function LanMachines({ lan }: { lan: LanView }) { const whys = new Set(d.missing.map((m) => m.why?.kind)); const items: [string, boolean][] = [ ["Both machines are on the same Wi-Fi or wired network.", whys.has("other_network")], - ["No VPN is on, or it allows local network access.", cause === "vpn" || cause === "vpn_on" || whys.has("their_vpn")], + [ + "No VPN is on, or it allows local network access.", + cause === "vpn" || cause === "vpn_on" || whys.has("their_vpn") || whys.has("their_vpn_on"), + ], ["On Windows, the network is set to Private (Settings › Network & internet).", cause === "public_network"], [ "On Windows, Patch Pilot is allowed through Windows Firewall on private networks.", @@ -443,6 +464,11 @@ function LanMachines({ lan }: { lan: LanView }) { {givesWay ? "Also worth checking" : "Likely reason"}: {lanCauseText(shown)}

      )} + {d.deaf_cause && ( +

      + Why {andList(deaf)} can't hear this machine: {lanCauseText(d.deaf_cause)} +

      + )} {!d.looking && reasons.map((r, i) => (

      diff --git a/app/src/api.ts b/app/src/api.ts index e9c7750..26d1f3b 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -591,6 +591,7 @@ export type LanWhy = | { kind: "quiet"; since: number } | { kind: "their_vpn" } | { kind: "their_virtual"; name: string } + | { kind: "their_vpn_on" } | { kind: "one_way" } | { kind: "other_network" } | { kind: "blocked" }; @@ -606,6 +607,8 @@ export interface LanDiagnosis { cause?: LanCause; /** Machines heard here that say they don't hear this one, by name. */ deaf?: string[]; + /** Why they can't hear this machine, when this machine can tell. */ + deaf_cause?: LanCause; } export interface LanView { diff --git a/crates/local/src/lan_diagnosis.rs b/crates/local/src/lan_diagnosis.rs index 75eeb4e..1693b3d 100644 --- a/crates/local/src/lan_diagnosis.rs +++ b/crates/local/src/lan_diagnosis.rs @@ -173,6 +173,15 @@ pub struct Diagnosis { /// get nothing from this machine. #[serde(skip_serializing_if = "Vec::is_empty")] pub deaf: Vec, + /// Why they can't hear this machine, when this machine can tell. Its + /// own: this machine's VPN says nothing about why another is missing. + #[serde(skip_serializing_if = "Option::is_none")] + pub deaf_cause: Option, + /// A machine heard here has a fresh report that doesn't list this one + /// yet, too soon to call it deaf: worth looking at this machine's + /// network now, so its report says how its sends go. Not shown. + #[serde(skip)] + pub maybe_deaf: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -201,6 +210,9 @@ pub enum Why { TheirVpn, /// Its broadcasts leave into a virtual network inside it. TheirVirtual { name: String }, + /// A VPN carries its internet traffic and it's on this machine's range + /// but hears nothing: many VPNs block local traffic. A guess. + TheirVpnOn, /// It hears this machine, but nothing it sends arrives here. OneWay, /// It's on another network: no home subnet in common, or the same @@ -286,24 +298,68 @@ pub fn diagnose(f: &Facts) -> Diagnosis { .map(|s| s.name.clone()) .collect(); deaf.sort(); + let maybe_deaf = f + .seen + .iter() + .filter(|s| s.here && f.expected.iter().any(|e| e.id == s.id)) + .any(|s| { + f.reports.get(&s.id).is_some_and(|rx| { + rx.report.on + && rx.is_fresh(f.now) + && rx.at.is_some_and(|at| at >= s.since) + && !rx.report.hears.contains(&f.this_id) + }) + }); let looking = !missing.is_empty() && f.listening_for < GRACE; - let mut cause = if !missing.is_empty() && !looking { - cause(f, !here.is_empty()) + // A missing machine that reports from somewhere this machine can't + // confirm is its own network: advice about this network (make it + // Private, a VPN for the internet here) may be about a café's Wi-Fi. + let elsewhere = f.expected.iter().any(|e| { + let seen = f.seen.iter().find(|s| s.id == e.id); + seen.is_none_or(|s| !s.here) + && current(f, &e.id, seen).is_some_and(|(rx, _)| !same_network(f, &rx.report)) + }); + let cause = if !missing.is_empty() && !looking { + cause(f, !here.is_empty(), elsewhere) } else { None }; - if cause.is_none() && !deaf.is_empty() { - cause = deaf_cause(f); - } + let deaf_cause = if deaf.is_empty() { None } else { deaf_cause(f) }; Diagnosis { here, missing, looking, cause, deaf, + deaf_cause, + maybe_deaf, } } +/// A missing machine's report that says how things are now, and when it +/// reached the relay: on, fresh, this machine online (offline, a newer one +/// could be waiting unseen), and reached the relay after this machine last +/// heard that machine (an earlier one was made while it was still here). +fn current<'a>(f: &'a Facts, id: &str, seen: Option<&Seen>) -> Option<(&'a Received, i64)> { + let rx = f.reports.get(id)?; + let at = rx.at?; + (rx.report.on + && rx.is_fresh(f.now) + && f.relay_online + && seen.is_none_or(|s| at >= s.seen_at + AFTER_HEARD_SECS)) + .then_some((rx, at)) +} + +/// The report shows it on this machine's network: a subnet in common and +/// the same public address, compared directly (no VPN for the internet on +/// either side). +fn same_network(f: &Facts, r: &crate::net_report::NetReport) -> bool { + let direct = matches!(f.route_egress, Some(Egress::Lan)) && r.internet_vpn == Some(false); + direct + && share_a_network(&r.subnets, &f.subnets) + && matches!((&r.public, &f.public), (Some(a), Some(b)) if same_public(a, b) == Some(true)) +} + /// What a missing machine's own report says, surest first. Only "off" holds /// however old the report is: the machine would have said if it turned the /// home network on. @@ -322,12 +378,7 @@ fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { let last = seen.map_or(at, |s| s.seen_at.max(at)); return (f.relay_online && !heard_lately).then_some(Why::Quiet { since: last }); } - // Offline here, a newer report could be waiting unseen. And one that - // reached the relay before this machine last heard that machine was - // made while it was still here. - if !f.relay_online || seen.is_some_and(|s| at < s.seen_at + AFTER_HEARD_SECS) { - return None; - } + let (_, at) = current(f, id, seen)?; if !r.listening { return Some(Why::CantListen); } @@ -341,11 +392,14 @@ fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { if both_looked && !share_a_network(&r.subnets, &f.subnets) { return Some(Why::OtherNetwork); } - // Both sides' sends go where they should, so what it hears or doesn't - // says something about the firewalls and the network between. + // Both sides' sends go where they should, and no VPN on either side + // that might drop local traffic, so what it hears or doesn't says + // something about the firewalls and the network between. let sends_fine = r.vpn == Some(false) + && r.internet_vpn == Some(false) && !r.send_error && matches!(f.broadcast_egress, Some(Egress::Lan)) + && matches!(f.route_egress, Some(Egress::Lan)) && f.broadcast_error.is_none(); let listening_from = f.now - f.listening_for.as_secs() as i64; if r.hears.contains(&f.this_id) { @@ -361,6 +415,11 @@ fn why(f: &Facts, id: &str, seen: Option<&Seen>) -> Option { // The same range. Most routers hand out the same few, so it's the same // network only if the public address matches too, and that only says // something when neither side's internet goes through a VPN. + // Its VPN carries its internet traffic and it hears nothing here: many + // VPNs block local traffic too. + if r.internet_vpn == Some(true) && matches!(f.route_egress, Some(Egress::Lan)) { + return Some(Why::TheirVpnOn); + } let direct = matches!(f.route_egress, Some(Egress::Lan)) && r.internet_vpn == Some(false); let same = match (&r.public, &f.public) { (Some(a), Some(b)) if direct => same_public(a, b)?, @@ -401,8 +460,10 @@ fn deaf_cause(f: &Facts) -> Option { /// The first cause that fits, surest first. While another linked machine is /// here, this machine's network works, so only causes on the other side -/// (another version, another rig) are named. -fn cause(f: &Facts, someone_here: bool) -> Option { +/// (another version, another rig) are named. `elsewhere`: a missing machine +/// reports from a network this machine can't confirm is its own, so advice +/// about this network is left out. +fn cause(f: &Facts, someone_here: bool, elsewhere: bool) -> Option { if f.other_version { return Some(Cause::OtherVersion); } @@ -430,14 +491,17 @@ fn cause(f: &Facts, someone_here: bool) -> Option { { return Some(Cause::LocalNetworkDenied); } - if f.profile == Some(Profile::Public) { + if f.profile == Some(Profile::Public) && !elsewhere { return Some(Cause::PublicNetwork); } } if f.other_rig { return Some(Cause::OtherRig); } - if !someone_here && let Some(Egress::Vpn { adapter }) = &f.route_egress { + if !someone_here + && !elsewhere + && let Some(Egress::Vpn { adapter }) = &f.route_egress + { return Some(Cause::VpnOn { adapter: adapter.clone(), }); @@ -1148,13 +1212,14 @@ mod tests { ..report() }; assert_eq!(why_of(&compared(v6)), None); - // Either side's internet through a VPN: its public address is the - // VPN's. + // Its internet through a VPN: its public address is the VPN's, so + // it can't say which network; but on this range and hearing nothing, + // the VPN is the likely reason (many block local traffic). let tunneled = NetReport { internet_vpn: Some(true), ..friends.clone() }; - assert_eq!(why_of(&compared(tunneled)), None); + assert_eq!(why_of(&compared(tunneled)), Some(Why::TheirVpnOn)); assert_eq!( why_of(&Facts { route_egress: Some(Egress::Vpn { adapter: None }), @@ -1291,11 +1356,12 @@ mod tests { ..f }); assert_eq!( - d.cause, + d.deaf_cause, Some(Cause::Vpn { adapter: Some("NordLynx".into()) }) ); + assert_eq!(d.cause, None, "nothing is missing"); } fn left(seen_at: i64) -> Seen { @@ -1442,6 +1508,96 @@ mod tests { assert_eq!(why_of(&hears_us), None); } + #[test] + fn this_machines_vpn_explains_a_deaf_machine_not_a_missing_one() { + // Studio is here but can't hear this machine (its broadcasts go into + // a VPN); the laptop is asleep, with no reason of its own. + let studio = Seen { + id: "studio".into(), + name: "Studio".into(), + here: true, + seen_at: NOW, + since: NOW - 600, + }; + let studio_report = Received { + at: Some(NOW - 30), + report: report(), + }; + let mut f = Facts { + expected: vec![ + laptop(), + Expected { + id: "studio".into(), + name: "Studio".into(), + }, + ], + seen: vec![studio, left(NOW - 300)], + broadcast_egress: Some(Egress::Vpn { adapter: None }), + ..compared(NetReport { + at: NOW - 400, + ..report() + }) + }; + f.reports.insert("studio".into(), studio_report); + let d = diagnose(&f); + assert_eq!(d.deaf, vec!["Studio".to_string()]); + assert_eq!(d.deaf_cause, Some(Cause::Vpn { adapter: None })); + assert_eq!(d.cause, None, "the VPN isn't why the laptop is missing"); + } + + #[test] + fn no_advice_about_this_network_while_the_other_may_be_elsewhere() { + let public = |r: NetReport| Facts { + profile: Some(Profile::Public), + ..compared(r) + }; + // It reports from a network this machine can't confirm is its own + // (another range, or one it can't compare): a café's Wi-Fi + // shouldn't be made Private. + let elsewhere = NetReport { + subnets: vec![Subnet::of(Ipv4Addr::new(10, 0, 0, 7), 24).unwrap()], + ..report() + }; + assert_eq!(diagnose(&public(elsewhere)).cause, None); + let v6 = NetReport { + public: Some("v6:2001:db8:1:2::/64".into()), + ..report() + }; + assert_eq!(diagnose(&public(v6)).cause, None); + // Confirmed on this network: Public is the likely reason. + assert_eq!( + diagnose(&public(report())).cause, + Some(Cause::PublicNetwork) + ); + // No report from it (an older Patch Pilot): as before. + assert_eq!( + diagnose(&Facts { + profile: Some(Profile::Public), + ..alone() + }) + .cause, + Some(Cause::PublicNetwork) + ); + } + + #[test] + fn a_fresh_report_without_this_machine_prompts_a_look_before_deafness() { + let heard = Seen { + id: "laptop".into(), + name: "Brian's Laptop".into(), + here: true, + seen_at: NOW, + since: NOW - 70, + }; + let d = diagnose(&Facts { + seen: vec![heard], + ..compared(report()) + }); + assert!(d.deaf.is_empty(), "too soon to say"); + assert!(d.maybe_deaf); + assert!(!diagnose(&compared(report())).maybe_deaf); + } + #[test] fn quiet_counts_from_when_it_was_last_heard_here() { let old = NetReport { diff --git a/crates/local/src/net_report.rs b/crates/local/src/net_report.rs index 08fe99a..d7ee4aa 100644 --- a/crates/local/src/net_report.rs +++ b/crates/local/src/net_report.rs @@ -18,6 +18,10 @@ use serde::{Deserialize, Serialize}; pub const REPORT_VERSION: u32 = 1; /// While something is wrong, a report goes out at least this often. pub const HEARTBEAT: Duration = Duration::from_secs(5 * 60); +/// …and this often while a machine here can't hear this one, so the other +/// side gets a report late enough to tell (a firewall here, say) in about +/// a minute rather than five. +pub const HEARTBEAT_SOON: Duration = Duration::from_secs(60); /// A changed report waits this long after the last one, so a flapping /// network doesn't keep writing to the relay. pub const MIN_GAP: Duration = Duration::from_secs(30); @@ -234,13 +238,18 @@ pub fn share_a_network(a: &[Subnet], b: &[Subnet]) -> bool { } /// Whether to send `new` now. `last`: the report last sent and how long ago. -/// `heartbeat`: a linked machine is missing or can't hear this one, so the -/// others should know this machine is still running. -pub fn should_send(new: &NetReport, last: Option<(&NetReport, Duration)>, heartbeat: bool) -> bool { +/// `heartbeat`: how often the others should hear this machine is still +/// running, while something is wrong ([`HEARTBEAT`], [`HEARTBEAT_SOON`]). +pub fn should_send( + new: &NetReport, + last: Option<(&NetReport, Duration)>, + heartbeat: Option, +) -> bool { match last { None => true, Some((sent, ago)) => { - (!new.same_news(sent) && ago >= MIN_GAP) || (heartbeat && new.on && ago >= HEARTBEAT) + (!new.same_news(sent) && ago >= MIN_GAP) + || (new.on && heartbeat.is_some_and(|every| ago >= every)) } } } @@ -395,9 +404,9 @@ mod tests { fn sends_on_change_with_a_gap_and_heartbeats_only_while_something_is_wrong() { let sent = on(); let later = NetReport { at: 2_000, ..on() }; - assert!(should_send(&later, None, false), "the first one"); + assert!(should_send(&later, None, None), "the first one"); assert!( - !should_send(&later, Some((&sent, Duration::from_secs(3600))), false), + !should_send(&later, Some((&sent, Duration::from_secs(3600))), None), "nothing new, nothing wrong" ); let moved = NetReport { @@ -405,16 +414,35 @@ mod tests { ..later.clone() }; assert!( - !should_send(&moved, Some((&sent, MIN_GAP / 2)), false), + !should_send(&moved, Some((&sent, MIN_GAP / 2)), None), "too soon" ); - assert!(should_send(&moved, Some((&sent, MIN_GAP)), false)); - assert!(!should_send(&later, Some((&sent, HEARTBEAT / 2)), true)); - assert!(should_send(&later, Some((&sent, HEARTBEAT)), true)); + assert!(should_send(&moved, Some((&sent, MIN_GAP)), None)); + assert!(!should_send( + &later, + Some((&sent, HEARTBEAT / 2)), + Some(HEARTBEAT) + )); + assert!(should_send( + &later, + Some((&sent, HEARTBEAT)), + Some(HEARTBEAT) + )); let off = NetReport::off(2_000); assert!( - !should_send(&off, Some((&NetReport::off(1), HEARTBEAT)), true), + !should_send(&off, Some((&NetReport::off(1), HEARTBEAT)), Some(HEARTBEAT)), "off needs no heartbeat" ); + // While a machine here can't hear this one, sooner. + assert!(should_send( + &later, + Some((&sent, HEARTBEAT_SOON)), + Some(HEARTBEAT_SOON) + )); + assert!(!should_send( + &later, + Some((&sent, HEARTBEAT_SOON / 2)), + Some(HEARTBEAT_SOON) + )); } } diff --git a/docs/decisions.md b/docs/decisions.md index 90908c3..8aa76a4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -128,4 +128,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | | 122 | 2026-11-06 | **One "Scripts, templates and presets" guide on every screen that moves them** (Joshua: a first-time tester couldn't tell how to get Reaper scripts and templates working across machines). A `FilesGuide` Notice line (app/src/FilesGuide.tsx) sits in Get Up To Speed (this machine), Set up, This machine and Settings > Sync. Its line names the next thing to do (link the machines, then Keep Files in Sync) with that button, or turns green once both are done; How it works opens the ways in order, each ticked when done here: link your machines, Bring over what's missing (from the Primary Rig), Keep Files in Sync on every machine (same cloud folder, GitHub repo or home network), Choose what syncs, and what's never copied (plug-ins, Reaper extensions, sample libraries; reaper.ini and audio/MIDI settings stay per machine). On a checklist it starts open until syncing is on; elsewhere folded; the toggle is remembered. On This machine it hides once linked and syncing, since Sync & backup says the rest. Buttons reach the Link dialog and other tabs through a small `ShellContext` (app/src/shell.ts) instead of props through every screen. Bring over's "link first" line gets a Link a machine button, Set up's intro and tour step 5 say to link first. | The ways existed but were spread over four places with nothing saying which comes first or that linking is required; one line per screen keeps it compact (decision #68) while answering "how do my scripts get there?" where people look. | Accepted | | 123 | 2026-11-06 | **Home network says why it can't find a machine** (amends #94; Brian: his machines were linked but home-network sync never found the laptop, which was on another Wi-Fi and a VPN, and nothing said why). Linking only uses the relay, so it worked; the beacons (UDP broadcast to 255.255.255.255) never reached the other machine, and "heard nobody" wasn't an error: the line said "Looking for your other machines…" forever, or a green "In sync…". Now the Sync & backup line names the linked machines it expects and says Looking for X for the first 30 s after the home network is picked (checked with each 10 s beacon, so 30-40 s), then Can't find X on this network, with a cause when this machine can tell; a machine that was here and left reads "isn't on this network now (seen 2 h ago)" instead of "In sync", and one missing while another is here is added to the In sync line. The opened details list each machine (on this network / last on it / not found) and a checklist (same network, VPN, Windows Private network and firewall, macOS Local Network, Patch Pilot open with Home network on the other machine, same version), the detected cause marked. **Expected machines** are the other `machine/` objects the relay lists when live sync starts, plus `changed` events (no extra relay reads, #113), named from the store; machines added from an export file or an earlier rig, and machines removed from the store (their object stays on the relay), never show as missing. Before the first listing, every other machine in the store; while this machine isn't linked, none. **Causes**, surest first, in `patchpilot_local::lan_diagnosis::diagnose` (pure, tested with hand-built facts): a beacon sealed by this rig that this version can't read, or a newer envelope (other version); no Wi-Fi/Ethernet IPv4 address; broadcasts leaving through a VPN or a virtual adapter (Hyper-V, WSL, Docker, VMware); macOS refusing the broadcast with EHOSTUNREACH while a default route works (Local Network access off: a strong guess, macOS has no API to ask; with no gateway macOS refuses broadcasts the same way); Windows calling the network Public; a Patch Pilot beacon under another rig key heard in the last 45 s; and last, a guess, a VPN holding the internet route. While another linked machine is here, only the other-version and other-rig causes are named, since this machine's network works. What one machine can't see (the other on another Wi-Fi, client isolation, the other's firewall, Home network off there) stays on the checklist. **How it looks** (`patchpilot_local::netinfo`, read-only, sends nothing): a UDP socket with broadcast on, connected to 255.255.255.255:47219, and one connected to 1.1.1.1:53, give the source address of each; sysinfo's `network` feature (unsafe stays inside sysinfo) lists interfaces, and an address on none of them while a home-network address exists is a VPN's, since Windows leaves MAC-less WireGuard/Wintun adapters out. A "virtual" adapter carrying the internet route is the home network itself (a Hyper-V external switch or bridged WSL moves the real address onto vEthernet). Adapters are told apart by name and, on Windows, by the description PowerShell's `Get-NetAdapter` gives, because many VPNs (GlobalProtect's "PANGP Virtual Ethernet Adapter") name theirs "Ethernet 4"; the same call reads `Get-NetConnectionProfile` (DomainAuthenticated counts as Private), with the name in an environment variable and compared exactly (not as a wildcard), no window, below-normal priority (#114; the thread's background mode doesn't pass to a child), killed after 5 s (WMI can hang), cached 10 min per adapter. All of it runs on the existing beacon thread, only while a machine is missing, none is here and the grace is over, at most every 30 s, and the UI is told only when the diagnosis changes; `view()` only reads what's known. Only the broadcast's send result counts (a machine that left answers unicast with "unreachable" too). A pull error goes once its machine is gone. Picking the home network again restarts the 30 s and looks at the network afresh. The port error is worded by its cause (Windows' reserved port ranges give 10013, not "another app"). **Later, separately:** each machine telling the others its network over the relay ("Laptop is on a different network"), and beacons out of every interface so sync works despite a VPN. | The first time a user linked two machines, home-network sync failed with nothing to go on; the fix was a minute's work once the cause was known. Saying only what this machine can actually tell keeps the line trustworthy; the checklist covers the rest. | Proposed | -| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts leave through a VPN or into a virtual adapter (named), whether its last broadcast failed, whether its internet traffic leaves through a VPN, which linked machines it hears now (ids), and its public address as the relay saw it (forgotten at the start of every relay session and when its subnets change, and the report is sent again at once when an upload reveals a new one). Network facts are only included while they were looked at in the last 90 s by the wall clock (they're looked at only while something is wrong, Windows adapter descriptions at most every 10 min, or every minute while Windows can't say; an adapter Windows couldn't describe counts as unknown, not the home network), so a report never carries facts from an earlier problem or from before a Mac slept. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine that might still turn up is missing (not one whose report says Home network is off) or one here can't hear this one, every 5 min so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is, and is the only reason named when the report's age is unknown); no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline; "last checked in" counts from the later of the report and when it was last heard). Every other reason needs a fresh report that reached the relay at least 20 s after this machine last heard that machine (an earlier one was made while it was still here, before it slept, quit or moved), and this machine online: picked there but its port won't open; a VPN is on there; its messages go into a virtual adapter there; it's on a different network (no subnet in common); it hears this machine but nothing it sends arrives (a firewall here; only when both sides' sends are known fine: no VPN, no failed broadcast either side, and the report made at least 55 s after both this machine last heard it and this run started, since its list keeps a machine 45 s); a different network: the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray or failing, the report made at least 20 s after this run started, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, and nothing unrelated. On the line and in the details alike, a guess here (a VPN carrying internet traffic, another rig nearby) gives way to any missing machine's own reason, and any cause here gives way when every missing machine has a reason on its own side (off, can't listen, asleep, its VPN or virtual adapter, another network): a laptop at a café is told the desktop is on another network, not to make the café's Wi-Fi Private. A sure cause that gave way stays in the details as "also worth checking", without marking the checklist. The diagnosis also ignores this machine's own network facts older than 90 s, and the live connection counts as offline once a keep-alive ping goes unanswered for 50 s, so reports aren't trusted over a connection that died silently. While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s, no PowerShell), so three-machine rigs get the same reasons. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed | +| 124 | 2026-11-06 | **Linked machines tell each other about their home network** (amends #123; Brian, after #123: one machine alone can't see that the other is on another Wi-Fi, has a VPN on, isn't using the home network, or never hears it). Each machine keeps a small sealed object `net/` on the relay (`patchpilot_local::net_report::NetReport`, sealed with `RigKey::seal_object`, bound to its name): whether Home network sync is picked and whether its port opened, since when it has listened, its Wi-Fi/Ethernet subnets (network address and prefix only, /8 or narrower), whether its broadcasts leave through a VPN or into a virtual adapter (named), whether its last broadcast failed, whether its internet traffic leaves through a VPN, which linked machines it hears now (ids), and its public address as the relay saw it (forgotten at the start of every relay session and when its subnets change, and the report is sent again at once when an upload reveals a new one). Network facts are only included while they were looked at in the last 90 s by the wall clock (they're looked at only while something is wrong, Windows adapter descriptions at most every 10 min, or every minute while Windows can't say; an adapter Windows gave no description for counts as unknown, not the home network), so a report never carries facts from an earlier problem or from before a Mac slept. It's a separate object, not a field in the `machine/` bundle, because the bundle carries the scan and every Primary Rig version and would be re-sent and re-imported on every network change. The beacon loop (`app/src-tauri/src/lan.rs`, every 10 s) has it sent when what it says changes (at most every 30 s) and, only while a linked machine that might still turn up is missing (not one whose report says Home network is off) or one here can't hear this one, every 5 min (every minute while one here can't hear this one, so the other side gets a report late enough to tell) so the others know this machine is still running; nothing goes out while everything works. `sync` builds it fresh at each send (one kept from before going offline could say it hears nobody), sends one at the start of every session, and counts it sent only once the relay took it, so a failed put is retried. It reads the other linked machines' `net/` objects once per session (one row read each, only ids from the `machine/` listing; decision #113) and then on live `changed` events. Older builds ignore `net/` objects; a machine without one simply has no reasons. **Relay change:** a PUT now answers with the address it came from (`ip`, from `CF-Connecting-IP`) and a GET says how long the relay has held the object (`x-age`, ms). The relay sees the address anyway and keeps nothing more; older apps ignore both, and against an older relay the app just has no public address and judges age by when it fetched. Timing never compares two machines' clocks: how old a report is comes from when it reached the relay, on this machine's clock; how long the other machine had listened comes from its own clock; peers count as here by the wall clock, not `Instant` (a Mac's stops while it sleeps). `lan_diagnosis::diagnose` names a reason per missing machine (`Why`), surest first: Home network isn't on there (holds however old the report is, and is the only reason named when the report's age is unknown); no report for 12 min while this machine is online and the machine wasn't heard here in that time (asleep, closed, offline; "last checked in" counts from the later of the report and when it was last heard). Every other reason needs a fresh report that reached the relay at least 20 s after this machine last heard that machine (an earlier one was made while it was still here, before it slept, quit or moved), and this machine online: picked there but its port won't open; a VPN is on there; its messages go into a virtual adapter there; a VPN carries its internet traffic and it's on this range but hears nothing (a guess: many VPNs block local traffic); it's on a different network (no subnet in common); it hears this machine but nothing it sends arrives (a firewall here; only when both sides' sends are known fine: no VPN for broadcasts or internet on either side, no failed broadcast either side, and the report made at least 55 s after both this machine last heard it and this run started, since its list keeps a machine 45 s); a different network: the same range behind another public address; same network (same subnet and the same public address, compared only within IPv4 or IPv6 and only when neither side's internet goes through a VPN), both listening, neither side's broadcasts going astray or failing, the report made at least 20 s after this run started, and neither hears the other: a firewall, or the router keeping devices apart. Most routers hand out the same few ranges (192.168.0/1.x, 10.0.0.x), which is why a matching subnet alone never counts as the same network; a dual-WAN router that gives one network two public addresses could read as "different network", an accepted rarity. A machine heard here counts as unable to hear this one (its files aren't reaching it) only when its fresh report doesn't list this one, reached the relay at least 90 s after it was first heard here, and was made after it had listened for 30 s (a quick restart isn't deafness). Then this machine's own VPN or virtual adapter is named if that's why, in its own field: it says nothing about why another machine is missing. A machine heard here whose fresh report doesn't list this one yet makes this machine look at its network straight away, so its report says how its sends go. On the line and in the details alike, a guess here (a VPN carrying internet traffic, another rig nearby) gives way to any missing machine's own reason, and any cause here gives way when every missing machine has a reason on its own side (off, can't listen, asleep, its VPN or virtual adapter, another network): a laptop at a café is told the desktop is on another network, not to make the café's Wi-Fi Private. A sure cause that gave way stays in the details as "also worth checking", without marking the checklist. The diagnosis also ignores this machine's own network facts older than 90 s, and the live connection counts as offline once a keep-alive ping goes unanswered until the next one (50 s), so reports aren't trusted over a connection that died silently. While a machine is missing and another is here, this machine now looks at its subnets and egress too (every 30 s; PowerShell at most every 10 min per adapter), so three-machine rigs get the same reasons. Advice about this machine's own network (Windows calls it Public, a VPN carries its internet traffic) is left out while a missing machine reports from a network this machine can't confirm is its own (subnet and public address both matching), so a laptop at a café is never told to make the café's Wi-Fi Private. A round of the beacon loop that comes over 30 s late means the machine slept: the reports read before are dropped and the relay session starts over, so nothing from before sleep is judged as current. | Comparing both sides turns the most common cases (different Wi-Fi, VPN on the other machine, other machine asleep or not on Home network) from a checklist into a named reason, and the one-way case (one machine's firewall) becomes visible at all. A wrong reason is worse than none, so each needs both sides' data to agree. Subnets, public addresses and heard ids are only readable by the user's linked machines (E2E, like everything on the relay). Cost while something is wrong: one tiny relay write per machine every 5 min and one read per other machine per write. | Proposed |