diff --git a/app/src-tauri/src/bring.rs b/app/src-tauri/src/bring.rs index 5c5813c..30faaa3 100644 --- a/app/src-tauri/src/bring.rs +++ b/app/src-tauri/src/bring.rs @@ -33,6 +33,15 @@ pub struct BringView { pub ignored: Vec, /// The ignores that hide `ignored`, to take back. pub ignores: Vec, + /// This machine is the Primary Rig's (its latest version was made from + /// it), and nothing was asked `from` another machine. + pub primary_here: bool, + /// On the Primary machine: files changed here after the Primary Rig + /// saved them. Not brought over (that would undo the change); "Update + /// Primary Rig" adds them to it instead (decision #126). + pub changed_here: Vec, + /// The latest Primary Rig version, to name it. + pub golden_version: Option, } /// Mirrored in `src/api.ts`. @@ -72,10 +81,19 @@ pub async fn files_to_bring(from: Option) -> Result { cloud: None, ignored: Vec::new(), ignores: Vec::new(), + primary_here: false, + changed_here: Vec::new(), + golden_version: None, }); } let this = this_machine()?; - let (source, files) = list(&this, from.as_deref())?; + let Listed { + source, + files, + changed_here, + primary_here, + golden_version, + } = list(&this, from.as_deref())?; let (ignored, ignores) = match &source { Some(source) => { let store = open(&db)?; @@ -89,10 +107,13 @@ pub async fn files_to_bring(from: Option) -> Result { &target, scan.as_ref(), )?; - ( - bring::drop_same_here(source, scan.as_ref(), ignored), - ignores, - ) + let ignored = bring::drop_same_here(source, scan.as_ref(), ignored); + let ignored = if primary_here { + bring::split_changed_here(ignored).0 + } else { + ignored + }; + (ignored, ignores) } None => Default::default(), }; @@ -103,9 +124,41 @@ pub async fn files_to_bring(from: Option) -> Result { cloud: crate::transfer::cloud_name(), ignored, ignores, + primary_here, + changed_here, + golden_version, }) } +/// "Update Primary Rig" on the Primary machine: a new Primary Rig version +/// with this machine's copies of the changed files (`root/rel` keys, as +/// listed in `changed_here`). Returns the new version. +#[tauri::command] +pub async fn keep_changes_in_golden(app: AppHandle, keys: Vec) -> Result { + let this = this_machine()?; + let listed = list(&this, None)?; + let mut products: Vec = listed + .changed_here + .iter() + .filter(|f| keys.contains(&f.key())) + .filter_map(|f| f.product_id.as_ref().map(|p| p.0.clone())) + .collect(); + products.sort(); + products.dedup(); + if products.is_empty() { + return Err("Those files already match the Primary Rig.".to_owned()); + } + let mut store = open(&db_path()?)?; + let v = patchpilot_local::golden::add_to_golden( + &mut store, + &this, + &products, + patchpilot_local::now(), + )?; + crate::compare::changed_here(&app, &store); + Ok(v.version) +} + /// Bring over the chosen files (`root/rel` keys as listed) and every file /// they need, from the Primary Rig or the linked machine `from`, then rescan. #[tauri::command] @@ -118,7 +171,11 @@ pub async fn bring_files( let rig = load_rig()?.ok_or("Link this machine first, so it can fetch the files.")?; let this = this_machine()?; let machine = open(&db_path()?)?.latest_manifest(&this).map_err(err)?; - let (source, list) = list(&this, from.as_deref())?; + let Listed { + source, + files: list, + .. + } = list(&this, from.as_deref())?; let source = source.ok_or("Nothing to bring over from: make a Primary Rig first.")?; let chosen = bring::with_needs(&list, &keys); @@ -245,22 +302,51 @@ pub async fn bring_files( Ok(result) } +/// What [`list`] found. +#[derive(Default)] +struct Listed { + /// The manifest files come from. + source: Option, + /// What it has that this machine lacks or holds an older copy of. + files: Vec, + /// On the Primary machine, what changed here after the Primary Rig. + changed_here: Vec, + primary_here: bool, + golden_version: Option, +} + /// The manifest files come from (the Primary Rig through this machine's /// profile, or `from`'s latest scan) and what it has that this machine lacks. -fn list(this: &str, from: Option<&str>) -> Result<(Option, Vec), String> { +/// On the Primary Rig's own machine, files changed here since are set apart: +/// they are newer than the Primary Rig's copies, not behind them. +fn list(this: &str, from: Option<&str>) -> Result { let store = open(&db_path()?)?; let machine = store.latest_manifest(this).map_err(err)?; + let golden = store.golden(None).map_err(err)?; + let golden_version = golden.as_ref().map(|g| g.info.version); + let primary_here = from.is_none() + && golden + .as_ref() + .is_some_and(|g| g.info.source_machine_id.as_deref() == Some(this)); let source = match from { Some(id) if id == this => return Err("That's this machine.".to_owned()), Some(id) => store.latest_manifest(id).map_err(err)?, - None => store.golden(None).map_err(err)?.map(|g| g.manifest), + None => golden.map(|g| g.manifest), }; let Some(source) = source else { - return Ok((None, Vec::new())); + return Ok(Listed { + golden_version, + ..Listed::default() + }); }; let (_, profile) = profiles(&store, this, from)?; let files = bring::files_to_bring(&source, &profile, machine.as_ref()); - let mut files = bring::drop_same_here(&source, machine.as_ref(), files); + let files = bring::drop_same_here(&source, machine.as_ref(), files); + let (mut files, changed_here) = if primary_here { + bring::split_changed_here(files) + } else { + (files, Vec::new()) + }; if crate::transfer::cloud_name().is_some() { // Big files come through the cloud folder; only those too big even // for that can't arrive. @@ -274,7 +360,13 @@ fn list(this: &str, from: Option<&str>) -> Result<(Option, Vec) -> Resul /// After a change made on this machine: no alert about it here, upload it, /// and refresh the windows. -fn changed_here(app: &AppHandle, store: &Store) { +pub(crate) fn changed_here(app: &AppHandle, store: &Store) { crate::alerts::rebaseline(app, store); crate::sync::nudge(app); let _ = app.emit("data-changed", ()); diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9248b8c..66ce257 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -156,6 +156,7 @@ pub fn run() { reaper::reaper_startup, bring::files_to_bring, bring::bring_files, + bring::keep_changes_in_golden, transfer::transfer_view, transfer::set_transfer_folder, transfer::send_big_files, diff --git a/app/src/BringOver.tsx b/app/src/BringOver.tsx index fd4f41d..da2c2a1 100644 --- a/app/src/BringOver.tsx +++ b/app/src/BringOver.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { bringFiles, fileKey, filesToBring, ignoreItems, type BringResult, type BringView, type FileToBring } from "./api"; +import { + bringFiles, + fileKey, + filesToBring, + ignoreItems, + keepChangesInGolden, + type BringResult, + type BringView, + type FileToBring, +} from "./api"; import { bringSummary, isReaperScript, isTemplate, Needs } from "./bring"; import { IgnoreButton, IgnoredToggle, pathRules } from "./Ignore"; import { useShell } from "./shell"; @@ -12,6 +21,11 @@ import { Badge, Button, buildTree, Card, Folder, FolderTree, isConflictCopy, Til * #62); anything replaced is backed up first. Anything can be ignored here * (decision #65), and then stops being listed or counted. Loads its own list, * so any screen about this machine can show it. + * + * On the Primary Rig's own machine, files changed here after the Primary Rig + * saved them aren't offered: bringing the older copy over would undo the + * change. They get their own card, which updates the Primary Rig instead + * (decision #126). */ export function BringOver({ onError }: { onError: (e: string) => void }) { const [view, setView] = useState(null); @@ -44,7 +58,12 @@ export function BringOver({ onError }: { onError: (e: string) => void }) { const folders = useFolders("bring-over", (id) => !id.includes("/") && groups.length === 1); if (!view || !view.has_source) return null; - if (files.length === 0 && !result && !view.linked) return null; + const changed = view.changed_here; + const changedCard = changed.length > 0 && ( + + ); + // On the Primary machine with only its own changes, that card says it all. + if (files.length === 0 && !result && (changed.length > 0 || !view.linked)) return changedCard || null; // The catch-all: every Reaper script here, whatever is ticked. const scripts = files.filter(isReaperScript); @@ -100,174 +119,252 @@ export function BringOver({ onError }: { onError: (e: string) => void }) { const summary = result && bringSummary(result, "the Primary machine", leftOut); const summaryLine = groups.map(([app, fs]) => `${app}: ${summaryOf(fs)}`).join(" · "); + return ( + <> + } + title={ + upToDate ? Up to date with the Primary Rig : "Bring over from the Primary Rig" + } + sub={ + + {upToDate + ? view.ignored.length + ? "Everything from the Primary Rig you haven't ignored is on this machine." + : "Every template, script and theme from the Primary Rig is on this machine." + : summaryLine} + + } + actions={ + <> + {!upToDate && ( + + )} + {view.linked && allScripts.length > 0 && allScripts.length < files.length && ( + + )} + {view.linked && allTemplates.length > 0 && allTemplates.length < files.length && ( + + )} + {view.linked && allPresets.length > 0 && allPresets.length < files.length && ( + + )} + {view.linked && chosen.length > 0 && ( + + )} + + } + > + {!view.linked && files.length > 0 && ( +

+ Link this machine with the Primary one to bring these over. + {shell && ( + + )} +

+ )} + + {view.ignores.length > 0 && ( +
+ `${v.files} file${v.files === 1 ? "" : "s"}`} + /> +
+ )} + + {summary && ( +

+ {summary.text} + {summary.warn && {summary.warn}} +

+ )} + + {open && !upToDate && ( +
+
+ {groups.map(([app, fs]) => ( + folders.toggle(app)} + actions={ + <> + {ignoreAction(`All ${app} files`, fs, true)} + {view.linked && } + + } + > + {groupByKind(fs).map(([kind, ks]) => { + const id = `${app}/${kind}`; + const ticked = ks.filter(isChosen).length; + return ( + folders.toggle(id)} + badges={view.linked && ticked < ks.length && {ticked} ticked} + actions={ + <> + {ignoreAction(`${app} ${plural(lower(kind))}`, ks)} + {view.linked && } + + } + > + ( + <> + {ignoreAction(node.name, treeItems(node))} + {view.linked && ( + + )} + + )} + renderItems={(items, indent) => ( +
    + {items.map((f) => ( + setChosen([f], !isChosen(f))} + ignore={ignoreAction(fileName(f.rel), [f])} + /> + ))} +
+ )} + /> +
+ ); + })} +
+ ))} +
+

+ Files that work together are ticked together, so they arrive whole. Anything replaced is saved first. +

+
+ )} +
+ {changedCard} + + ); +} + +/** + * On the Primary machine: files changed here after Primary Rig v was + * saved. They're newer than the Primary Rig's copies, so the way back to + * parity is to update the Primary Rig with them, not to bring the old ones + * over (decision #126). + */ +function ChangedHere({ + files, + version, + onDone, + onError, +}: { + files: FileToBring[]; + version: number | null; + onDone: () => void; + onError: (e: string) => void; +}) { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(null); + const summaryLine = groupByApp(files) + .map(([app, fs]) => `${app}: ${summaryOf(fs)}`) + .join(" · "); + const keep = () => { + setBusy(true); + keepChangesInGolden(files.map(fileKey)) + .then((v) => { + setSaved(v); + onDone(); + }) + .catch((e) => onError(String(e))) + .finally(() => setBusy(false)); + }; return ( } - title={ - upToDate ? Up to date with the Primary Rig : "Bring over from the Primary Rig" - } - sub={ - - {upToDate - ? view.ignored.length - ? "Everything from the Primary Rig you haven't ignored is on this machine." - : "Every template, script and theme from the Primary Rig is on this machine." - : summaryLine} - - } + icon={} + title={`Changed here since Primary Rig${version ? ` v${version}` : ""}`} + sub={{summaryLine} · newer than the Primary Rig's copies} actions={ <> - {!upToDate && ( - - )} - {view.linked && allScripts.length > 0 && allScripts.length < files.length && ( - - )} - {view.linked && allTemplates.length > 0 && allTemplates.length < files.length && ( - - )} - {view.linked && allPresets.length > 0 && allPresets.length < files.length && ( + + {files.some((f) => f.product_id) && ( - )} - {view.linked && chosen.length > 0 && ( - )} } > - {!view.linked && files.length > 0 && ( -

- Link this machine with the Primary one to bring these over. - {shell && ( - - )} -

- )} - - {view.ignores.length > 0 && ( -
- `${v.files} file${v.files === 1 ? "" : "s"}`} - /> -
- )} - - {summary && ( -

- {summary.text} - {summary.warn && {summary.warn}} -

- )} - - {open && !upToDate && ( -
-
- {groups.map(([app, fs]) => ( - folders.toggle(app)} - actions={ - <> - {ignoreAction(`All ${app} files`, fs, true)} - {view.linked && } - - } - > - {groupByKind(fs).map(([kind, ks]) => { - const id = `${app}/${kind}`; - const ticked = ks.filter(isChosen).length; - return ( - folders.toggle(id)} - badges={view.linked && ticked < ks.length && {ticked} ticked} - actions={ - <> - {ignoreAction(`${app} ${plural(lower(kind))}`, ks)} - {view.linked && } - - } - > - ( - <> - {ignoreAction(node.name, treeItems(node))} - {view.linked && ( - - )} - - )} - renderItems={(items, indent) => ( -
    - {items.map((f) => ( - setChosen([f], !isChosen(f))} - ignore={ignoreAction(fileName(f.rel), [f])} - /> - ))} -
- )} - /> -
- ); - })} -
- ))} -
-

- Files that work together are ticked together, so they arrive whole. Anything replaced is saved first. -

-
+ {saved !== null &&

Primary Rig v{saved} has this machine's copies now.

} + {open && ( +
    + {files.map((f) => ( +
  • + + {fileName(f.rel)} + + + {f.app} {lower(f.kind)} + +
  • + ))} +
)}
); diff --git a/app/src/api.ts b/app/src/api.ts index 159c4f0..c8d7d43 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -317,6 +317,8 @@ export interface FileToBring { size: number | null; /** This machine has a different copy, backed up before it is replaced. */ replaces: boolean; + /** This machine's different copy was saved after this one (or either time is unknown). */ + newer_here: boolean; too_large: boolean; /** Its product, to match it with comparison rows. */ product_id: string | null; @@ -337,6 +339,12 @@ export interface BringView { ignored: FileToBring[]; /** The ignores behind `ignored`. */ ignores: IgnoreView[]; + /** This machine is the Primary Rig's own (and nothing was asked `from` another). */ + primary_here: boolean; + /** On the Primary machine: files changed here after the Primary Rig saved them (decision #126). */ + changed_here: FileToBring[]; + /** The latest Primary Rig version. */ + golden_version: number | null; } export interface BringResult { @@ -360,6 +368,8 @@ export const filesToBring = (from?: string) => invoke("files_to_bring /** Bring these files and every file they need; from the Primary Rig unless `from` names a linked machine. */ export const bringFiles = (keys: string[], from?: string) => invoke("bring_files", { keys, from: from ?? null }); +/** On the Primary machine: put this machine's copies of these changed files into a new Primary Rig version. */ +export const keepChangesInGolden = (keys: string[]) => invoke("keep_changes_in_golden", { keys }); // Big-file hand-off (decision #64); mirrors src-tauri/src/transfer.rs and patchpilot_local::transfer. diff --git a/app/src/screens/Compare.tsx b/app/src/screens/Compare.tsx index 87a1b7f..255aeec 100644 --- a/app/src/screens/Compare.tsx +++ b/app/src/screens/Compare.tsx @@ -546,9 +546,10 @@ function MachineItem({ + {/* Primary first: the line is cut short in the narrow list. */} + {m.golden_source ? "Primary · " : ""} {m.os === "macos" ? "macOS" : "Windows"} {m.is_this_machine ? " · this machine" : ""} - {m.golden_source ? " · Primary machine" : ""} {m.profile ? ` · ${m.profile.name}` : ""} diff --git a/crates/local/src/backup.rs b/crates/local/src/backup.rs index 7442e23..afd0cd9 100644 --- a/crates/local/src/backup.rs +++ b/crates/local/src/backup.rs @@ -880,6 +880,7 @@ pub fn restore_list( hash: v.hash.clone(), size: Some(v.size), replaces: false, + newer_here: false, too_large: false, product_id: None, needs: Vec::new(), diff --git a/crates/local/src/bring.rs b/crates/local/src/bring.rs index 7b25e1a..3f2892e 100644 --- a/crates/local/src/bring.rs +++ b/crates/local/src/bring.rs @@ -206,6 +206,10 @@ pub struct FileToBring { /// True when this machine has a different file at that path (it is backed /// up before being replaced). pub replaces: bool, + /// This machine's different copy was saved after the one listed here (or + /// either time is unknown). On the Primary Rig's own machine such a file + /// is a change to keep, not one to overwrite: see [`split_changed_here`]. + pub newer_here: bool, /// Over [`MAX_FILE_BYTES`], so it can't travel over the link. pub too_large: bool, /// Its product, to find it from the comparison's rows. @@ -323,12 +327,12 @@ pub fn files_to_bring( wanted.insert(k, (item.platform, item)); } } - let here: BTreeMap<(String, String), ContentHash> = machine + let here: BTreeMap<(String, String), (ContentHash, Option)> = machine .into_iter() .flat_map(|m| &m.items) .filter_map(|i| { let (_, root, rel, _) = classify(&i.path)?; - Some((key(root, rel), copy_hash(i)?)) + Some((key(root, rel), (copy_hash(i)?, i.mtime))) }) .collect(); @@ -336,10 +340,13 @@ pub fn files_to_bring( .into_iter() .filter_map(|(k, (_, item))| { let hash = copy_hash(item)?; - let replaces = match here.get(&k) { - Some(h) if *h == hash => return None, - Some(_) => true, - None => false, + let (replaces, newer_here) = match here.get(&k) { + Some((h, _)) if *h == hash => return None, + Some((_, mtime)) => ( + true, + !matches!((mtime, item.mtime), (Some(here), Some(there)) if *here <= there), + ), + None => (false, false), }; let (shared, root, rel, kind) = classify(&item.path)?; // Presets group by their folder: "FabFilter presets". @@ -353,6 +360,7 @@ pub fn files_to_bring( size: item.size, hash, replaces, + newer_here, product_id: item.product_id.clone(), // Every file it needs, listed or not; narrowed below. needs: item @@ -368,6 +376,17 @@ pub fn files_to_bring( link(out) } +/// On the machine the Primary Rig is made from, `files` (from the Primary +/// Rig) split into what to bring over and what this machine changed after +/// the Primary Rig saved it. Those changes are newer than the Primary Rig's +/// copies: bringing the Primary Rig's over would undo them, so they go to +/// the Primary Rig instead ("Update Primary Rig"). +pub fn split_changed_here(files: Vec) -> (Vec, Vec) { + let (changed, bring): (Vec<_>, Vec<_>) = + files.into_iter().partition(|f| f.replaces && f.newer_here); + (link(bring), changed) +} + /// Narrow each file's `needs` to files in the list (the rest are here /// already), spelled as listed, and mark the files that can't arrive whole. fn link(mut files: Vec) -> Vec { @@ -833,6 +852,45 @@ mod tests { ); } + #[test] + fn the_primary_machine_keeps_what_it_changed_since() { + let dated = |rel: &str, body: &str, mtime: Option| Item { + mtime, + ..template(Os::Windows, rel, body) + }; + let dir = r"C:\Users\a\AppData\Roaming\REAPER"; + let golden = manifest( + Os::Windows, + dir, + vec![ + dated("reaper-kb.ini", "keys v1", Some(100)), + dated("reaper-menu.ini", "menus v1", None), + dated("FXChains/Bus.RfxChain", "bus from the Mac", Some(300)), + dated("FXChains/New.RfxChain", "added from the Mac", Some(300)), + ], + ); + let pc = manifest( + Os::Windows, + dir, + vec![ + dated("reaper-kb.ini", "keys v2", Some(200)), + dated("reaper-menu.ini", "menus v2", Some(200)), + dated("FXChains/Bus.RfxChain", "bus", Some(100)), + ], + ); + let list = files_to_bring(&golden, &TargetProfile::default(), Some(&pc)); + let (bring, changed) = split_changed_here(list); + let rels = |fs: &[FileToBring]| fs.iter().map(|f| f.rel.clone()).collect::>(); + // Edited here after the Primary Rig saved them (or when is unknown): + // the Primary Rig's copy is older, so it isn't offered. + assert_eq!(rels(&changed), ["reaper-kb.ini", "reaper-menu.ini"]); + // Newer in the Primary Rig (added from another machine), or missing here. + assert_eq!( + rels(&bring), + ["FXChains/Bus.RfxChain", "FXChains/New.RfxChain"] + ); + } + #[test] fn collection_names_merge_into_this_machines_library_cfg() { let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/local/src/golden.rs b/crates/local/src/golden.rs index 759107f..77358a2 100644 --- a/crates/local/src/golden.rs +++ b/crates/local/src/golden.rs @@ -14,7 +14,9 @@ const SEEN_FILE: &str = "alerts-seen.json"; const OFF_FILE: &str = "drift-alerts-off"; /// Add `products` (IDs) from `machine_id`'s latest scan to a new Primary Rig -/// version, noting where they came from. +/// version, noting where they came from. The Primary machine stays the one +/// the Primary Rig was made from: adding a plug-in from another machine +/// doesn't make that machine the Primary one (decision #126). pub fn add_to_golden( store: &mut Store, machine_id: &str, @@ -39,8 +41,13 @@ pub fn add_to_golden( many => format!("{} products", many.len()), }; let note = format!("Added {what} from {}", scan.machine.name); + let primary = golden + .info + .source_machine_id + .as_deref() + .unwrap_or(machine_id); store - .save_golden(&next, Some(machine_id), Some(¬e), now) + .save_golden(&next, Some(primary), Some(¬e), now) .map_err(err) } @@ -226,6 +233,11 @@ mod tests { .product(&ProductId("acme-delay".into())) .is_some() ); + // Adding from the laptop leaves the studio the Primary machine. + assert_eq!( + g.info.source_machine_id.as_deref(), + Some(studio.machine.id.as_str()) + ); // The studio now lacks Acme Delay: one drift alert, about itself. let alerts = check_alerts(&store, app, this).unwrap(); diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 9dc5b4f..69cc24a 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -10,7 +10,7 @@ mod migrations; mod paths; mod restore_points; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use patchpilot_core::{ @@ -441,7 +441,8 @@ impl Store { |r| Ok((golden_info(r)?, r.get::<_, i64>(5)?)), ) .optional()?; - row.map(|(info, snapshot_id)| { + row.map(|(mut info, snapshot_id)| { + info.source_machine_id = self.primary_machines()?.remove(&info.version).flatten(); let mut manifest = self.snapshot_manifest(snapshot_id)?; // Versions saved before the rename (decision #60) are stored as // "Golden Rig v". @@ -458,7 +459,50 @@ impl Store { FROM golden_versions WHERE golden_id = ?1 ORDER BY version DESC", )?; let rows = stmt.query_map([GOLDEN_ID], golden_info)?; - Ok(rows.collect::>()?) + let mut primary = self.primary_machines()?; + Ok(rows + .map(|r| { + r.map(|mut info| { + info.source_machine_id = primary.remove(&info.version).flatten(); + info + }) + }) + .collect::>()?) + } + + /// Each version's Primary machine: the machine it was made or updated + /// from. A version that only added products from another machine + /// ("Added Acme Delay from Studio Mac") keeps its parent's: Patch Pilot + /// before 0.4.59 saved the adding machine there, which made that machine + /// the Primary one (decision #126). + fn primary_machines(&self) -> Result>> { + let mut stmt = self.conn.prepare( + "SELECT version, source_machine_id, note FROM golden_versions + WHERE golden_id = ?1 ORDER BY version", + )?; + let rows = stmt.query_map([GOLDEN_ID], |r| { + Ok(( + r.get::<_, u32>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, Option>(2)?, + )) + })?; + let mut out = BTreeMap::new(); + let mut last: Option = None; + for row in rows { + let (version, source, note) = row?; + let added = note + .as_deref() + .is_some_and(|n| n.starts_with("Added ") && n.contains(" from ")); + let primary = if added && last.is_some() { + last.clone() + } else { + source + }; + last = primary.clone(); + out.insert(version, primary); + } + Ok(out) } /// Bundle these machines' latest scans (and their profile assignments), diff --git a/crates/store/tests/store.rs b/crates/store/tests/store.rs index c8e198c..22a36fa 100644 --- a/crates/store/tests/store.rs +++ b/crates/store/tests/store.rs @@ -363,6 +363,53 @@ fn golden_versions_are_kept_and_chained() { assert_eq!(store.snapshots(&studio.machine.id).unwrap().len(), 0); } +#[test] +fn adding_from_another_machine_keeps_the_primary_machine() { + // Before 0.4.59 an "Added ... from " version saved the adding + // machine as its source, which made it the Primary one (decision #126). + let studio = fixture_manifest(); + let mut store = Store::open_in_memory().unwrap(); + store.save_golden(&studio, Some("pc"), None, 100).unwrap(); + let mut added = studio.clone(); + added.items[0].version = Some("9.9.9".into()); + store + .save_golden(&added, Some("mac"), Some("Added Acme Delay from Mac"), 200) + .unwrap(); + let mut imported = added.clone(); + imported.items[0].version = Some("9.9.10".into()); + store + .save_golden( + &imported, + Some("mac"), + Some("Added MS-20 V from Mac (imported: v3 from Mac)"), + 300, + ) + .unwrap(); + + let latest = store.golden(None).unwrap().unwrap(); + assert_eq!(latest.info.version, 3); + assert_eq!(latest.info.source_machine_id.as_deref(), Some("pc")); + let sources: Vec<_> = store + .golden_history() + .unwrap() + .into_iter() + .map(|v| v.source_machine_id) + .collect(); + assert_eq!( + sources, + [Some("pc".into()), Some("pc".into()), Some("pc".into())] + ); + + // Updating from the Mac's whole scan does make it the Primary machine. + let mut whole = imported.clone(); + whole.items[0].version = Some("10.0.0".into()); + store + .save_golden(&whole, Some("mac"), Some("Updated from Mac"), 400) + .unwrap(); + let latest = store.golden(None).unwrap().unwrap(); + assert_eq!(latest.info.source_machine_id.as_deref(), Some("mac")); +} + #[test] fn migrates_a_v1_database_to_latest() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/decisions.md b/docs/decisions.md index 138bea0..26041a3 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -130,3 +130,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 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 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 | | 125 | 2026-11-06 | **Home-network beacons also go straight onto every home subnet** (amends #94; #123 and #124 deferred it; Brian: sync should work with a VPN on). Each beacon still goes to 255.255.255.255, and now also to the directed broadcast address of every Wi-Fi/Ethernet subnet (192.168.1.255; `lan_diagnosis::direct_broadcasts`, `Subnet::broadcast`). On Windows the limited broadcast leaves through the lowest-metric route, which with a VPN (or a Hyper-V/WSL adapter) on is usually that adapter, so machines on the same Wi-Fi never heard each other; a directed broadcast follows its subnet's own connected route out of the Wi-Fi/Ethernet adapter, past the VPN's default route (checked on Brian's laptop: 10.0.0.255 leaves on Wi-Fi and is received by a socket bound to 0.0.0.0). Older builds receive these as they are. Replies were already unicast and pulls TCP to the peer's address, which take the same subnet route. VPN adapters are left out, as are /31 and /32. On Windows only adapters Windows has described count (many VPNs name theirs "Ethernet 3" and say what they are only in the description), so no beacon goes into a work VPN: each home-looking adapter is described once per launch (PowerShell at background priority, one run at a time, retried 10 min after a failure); if PowerShell is blocked, nothing is sent directly and it works as before. The subnet list is one interface listing, worked out again at most every minute, when the internet route's source changes (another network), after looking at the network, and on picking Home network again; no new timer: it rides the existing 10 s beacon. A VPN that blocks local traffic (a kill switch, "block LAN") still stops it; nothing here can get past that. So the diagnosis changes: with direct sends, a VPN on the broadcast route is no longer certain to be the reason, only a guess (`Cause::VpnBlocks`: "Patch Pilot also sends straight onto your home network, so the VPN is most likely blocking local traffic"), ranked after the sure causes (Local Network off; a Public network, now read from the home adapters direct sends use, and only when Windows says Public of all of them) and left out while a missing machine may be elsewhere or hears this one, and never given as why a machine here can't hear this one (local traffic evidently gets through). A virtual adapter there is no reason at all, and doesn't stop "blocked"/"it hears this machine" being named. Direct sends that all fail count as a failed broadcast (macOS Local Network); one the OS accepted means the local network is usable, whatever happened to 255.255.255.255. A Hyper-V external switch with a VPN on gets no direct sends (the switch looks virtual without the internet route), as before. Reports (#124) say `direct`, so the other machine words its VPN the same way (`Why::TheirVpnOn`, after "another network", and not while it hears this machine) and doesn't name its virtual adapter. | The most common reason home-network sync failed for a linked pair was a VPN on one machine; most consumer VPNs and many work ones allow local traffic and only take the default route, which these beacons now step around. Two or three extra small UDP packets per 10 s beacon. | Proposed | +| 126 | 2026-11-06 | **The Primary machine never offers to bring over from itself** (Joshua: his Windows PC, the Primary Rig, showed "Bring over from the Primary Rig" for its Reaper key map and menus). Two causes, both fixed. (1) "Add" on a suggestion from another machine saved that machine as the new version's source, so one added plug-in made it the Primary machine. `add_to_golden` now keeps the previous version's source, and `Store::golden`/`golden_history` read an older "Added ... from ..." version (also imported ones) as its parent's Primary machine, so existing rigs are right again without a migration. Only a whole save (Make Primary, Update Primary Rig from this machine) moves it. (2) On the Primary machine itself, Bring over listed every file whose copy here differs from the Primary Rig's, including ones edited here (or placed by Keep Files in Sync) after the version was saved, so bringing them over would undo the newer change. `FileToBring.newer_here` says this machine's copy is newer (or a time is unknown), `bring::split_changed_here` sets those apart on the Primary machine (Bring over and Compare's Bring over lines no longer offer them), and a "Changed here since Primary Rig v" card with **Update Primary Rig** adds this machine's copies to a new version (`keep_changes_in_golden`, through `add_to_golden`, so plug-ins the Primary Rig has from elsewhere stay). A file only the Primary Rig has, or whose Primary Rig copy is newer (added from another machine), is still brought over. Compare's machine list puts "Primary" first so the narrow line doesn't cut it off. | The Primary Rig is the reference: on its own machine, a difference means the rig moved on here, so the fix is to record it, not to roll the machine back. Reading the old notes avoids a schema change and corrects machines that already synced the wrong source. | Accepted |