diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 9ce213d..35c0848 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.58 - 2026-11-06 ### New 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 d285b15..cbf5c01 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 @@ export 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 93c7aad..96393be 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -127,3 +127,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 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 | | 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 |