diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 3685c05..2194faf 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -172,6 +172,21 @@ impl ledger_gui::owner::Owner for ViewerOwner { fn upgrade_format(&self, book: &str, passphrase: &str) -> Result, String> { ledger_host::commands::upgrade_format(&self.0, book, passphrase) } + + fn update_waiting(&self) -> Option { + ledger_host::commands::update_waiting(&self.0).map(|u| ledger_gui::owner::UpdateWaiting { + version: u.version, + staged_at: u.staged_at, + }) + } + + fn check_for_updates(&self) -> Result, String> { + ledger_host::commands::check_for_updates(&self.0).map(|l| vec![l]) + } + + fn apply_update(&self) -> Result, String> { + ledger_host::commands::apply_update(&self.0).map(|l| vec![l]) + } } /// `wanderbooks gui`: the viewer, in this process. For books of the data diff --git a/crates/ledger-cli/tests/translocation.rs b/crates/ledger-cli/tests/translocation.rs new file mode 100644 index 0000000..c4fed96 --- /dev/null +++ b/crates/ledger-cli/tests/translocation.rs @@ -0,0 +1,133 @@ +//! A macOS app run translocated refuses to connect an agent (P11a step 6): +//! Gatekeeper runs a quarantined download from a read-only copy under +//! `/private/var/folders/…/AppTranslocation/…`, and that path goes away +//! with the app; an agent pointed at it would lose the books. The owner +//! moves the app out of Downloads first. +//! +//! The check is on the path the program would give the agent +//! (`ledger_host::start::this_executable`), so a test can stand in for +//! macOS on any Unix: `APPIMAGE` names a file there, and a path with an +//! `AppTranslocation` part is refused just as the real one is. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const KNOBS: &str = + "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; + +struct Home { + dir: tempfile::TempDir, +} + +impl Home { + fn new() -> Self { + let dir = tempfile::Builder::new() + .prefix("wb-translocation-") + .tempdir() + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["setup", "--home"]) + .arg(dir.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + Self { dir } + } + + fn root(&self) -> PathBuf { + ledger_store::root::default_root(self.dir.path()) + } + + /// The program with a root of its own and `PATH` holding the stand-in + /// agent. Nothing here touches the account's real data root. + fn cmd(&self, mode: &str, path: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + c.arg(mode) + .arg("--root") + .arg(self.root()) + .args(["--test-knobs", KNOBS]) + .env_clear() + .env("PATH", format!("{}:/usr/bin:/bin", path.display())) + .env("HOME", self.dir.path()) + .stdin(Stdio::null()); + c + } +} + +/// A stand-in `claude` that leaves a mark when it is run, so the test can +/// say the agent never was. +fn fake_agent(home: &Home) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let bin = home.dir.path().join("agent-bin"); + std::fs::create_dir_all(&bin).unwrap(); + let script = bin.join("claude"); + std::fs::write( + &script, + format!( + "#!/bin/sh\ntouch '{}'\nexit 0\n", + home.dir.path().join("agent-ran").display() + ), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin +} + +#[test] +fn translocated_app_refuses_to_connect() { + let home = Home::new(); + let out = home + .cmd("books", Path::new("/nonexistent")) + .args(["create", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + + let bin = fake_agent(&home); + // What macOS hands the program from Gatekeeper's copy: a path under + // AppTranslocation. A bare file is enough (this_executable only asks + // that it is one). + let translocated = home.dir.path().join( + "private/var/folders/x/AppTranslocation/uuid/d/Wanderbooks.app/Contents/MacOS/wanderbooks", + ); + std::fs::create_dir_all(translocated.parent().unwrap()).unwrap(); + std::fs::write(&translocated, b"").unwrap(); + let out = home + .cmd("connect", &bin) + .env("APPIMAGE", &translocated) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!( + !out.status.success(), + "a translocated app connected an agent" + ); + let said = String::from_utf8_lossy(&out.stderr); + assert!(said.contains("move wanderbooks out of Downloads"), "{said}"); + // Refused before anything was done: no agent ran, no books home was + // made for a path that will vanish. + assert!(!home.dir.path().join("agent-ran").exists(), "the agent ran"); + assert!( + !home.dir.path().join("Documents/Wanderbooks/beta").exists(), + "a books home was made" + ); + + // The same program at an ordinary path connects: it is the + // translocation, not the agent or the books, that was refused. + let ordinary = home.dir.path().join("Wanderbooks.AppImage"); + std::fs::write(&ordinary, b"").unwrap(); + let out = home + .cmd("connect", &bin) + .env("APPIMAGE", &ordinary) + .args(["claude", "--book", "beta"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + home.dir.path().join("agent-ran").exists(), + "the agent wasn't run" + ); +} diff --git a/crates/ledger-cli/tests/update.rs b/crates/ledger-cli/tests/update.rs index 8aaafb4..d752b91 100644 --- a/crates/ledger-cli/tests/update.rs +++ b/crates/ledger-cli/tests/update.rs @@ -1040,3 +1040,137 @@ fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() { "the tampered copy was kept" ); } + +/// What the owner's app does when the owner accepts: one `apply_update` +/// on the host's lease socket (the viewer's `Owner::apply_update`, scripted). +fn owner_accepts(home: &Home) -> Result, String> { + ledger_host::opener::ask_once_within( + &home.root().join("run"), + ledger_host::lease::Ask::Intent(ledger_host::lease::Intent::ApplyUpdate), + Duration::from_secs(90), + ) +} + +#[test] +fn books_start_says_an_update_waits() { + let home = Home::new("start-says"); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "start-says"); + release.publish("9.0.0", &next, |_| {}); + let mut agent = Agent::start(&home, "acme"); + let quiet = agent.call("books.start", json!({})).unwrap(); + assert!(quiet.get("update_waiting").is_none(), "{quiet}"); + + name_feed(&home, &release); + // The host's look is once a day (a test knob makes it soon); a mark + // older than that lets the next poll look now. + let _ = std::fs::remove_file(home.updates().join("checked")); + assert!( + wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")), + "nothing was staged" + ); + let start = agent.call("books.start", json!({})).unwrap(); + assert_eq!(start["update_waiting"]["version"], "9.0.0", "{start}"); + assert!( + start["update_waiting"]["staged_at"] + .as_str() + .unwrap() + .ends_with('Z') + ); + assert!( + start["update_waiting"]["how"] + .as_str() + .unwrap() + .contains("only the owner"), + "{start}" + ); + // An agent is told to tell the human, and is given no way to accept. + let steps = start["next_steps"].as_array().unwrap(); + assert!( + steps + .iter() + .any(|s| s["why"].as_str().unwrap_or_default().contains("9.0.0")), + "{steps:?}" + ); + let text = start.to_string(); + assert!( + !text.contains(&home.dir.path().display().to_string()), + "a path in books.start" + ); +} + +#[test] +fn the_owner_accepts_a_staged_update_in_the_app() { + let home = Home::new("accept"); + home.create("acme"); + let mut agent = Agent::start(&home, "acme"); + agent.add_account("4000").unwrap(); + let host_before = wait_host(&home); + + // Nothing staged: refused, and nothing changes. + match owner_accepts(&home) { + Ok(Some(ledger_host::lease::HostMsg::Refused(_))) => {} + other => panic!("accepting nothing: {other:?}"), + } + + let release = Release::new(); + let next = newer(&home, "accept"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let _ = std::fs::remove_file(home.updates().join("checked")); + assert!( + wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")), + "nothing was staged" + ); + let old = home.program(); + assert_eq!(sha256(&home.program()), sha256(&old)); + + // The owner accepts, in the app: the answer says it is installed. + match owner_accepts(&home) { + Ok(Some(ledger_host::lease::HostMsg::Done { detail })) => { + assert!(detail.unwrap_or_default().contains("9.0.0")); + } + other => panic!("accepting: {other:?}"), + } + assert_eq!(sha256(&home.program()), sha256(&next)); + + // Writes all through the restart onto it: none lost, none made twice, + // the session carries on with no reconnect. + let mut written = vec!["4000".to_string()]; + let deadline = Instant::now() + Duration::from_secs(90); + let mut k = 0u32; + loop { + assert!(Instant::now() < deadline, "the host never restarted"); + let code = format!("{}", 5000 + k); + k += 1; + let r = agent.add_account(&code); + assert!(r.is_ok(), "a write across the accept: {r:?}"); + written.push(code); + if home.pids("host").len() == 1 && home.pids("host")[0] != host_before { + break; + } + } + let host = wait_host(&home); + assert!(wait_for(20, || !home.pids("server").is_empty())); + let exe = |pid: u32| std::fs::read_link(format!("/proc/{pid}/exe")).unwrap(); + assert!(!exe(host).to_string_lossy().contains("(deleted)")); + assert_eq!( + sha256(&std::fs::read(home.updates().join("previous/wanderbooks")).unwrap()), + sha256(&old), + "the old program is kept for rollback" + ); + agent.add_account("4999").unwrap(); + written.push("4999".into()); + let chart = agent.call("books.chart", json!({})).unwrap(); + let mut in_books: Vec = chart["accounts"] + .as_array() + .unwrap() + .iter() + .filter_map(|a| a["code"].as_str().map(str::to_string)) + .filter(|c| c.starts_with('4') || c.starts_with('5')) + .collect(); + in_books.sort(); + written.sort(); + assert_eq!(in_books, written); +} diff --git a/crates/ledger-gui/src/owner.rs b/crates/ledger-gui/src/owner.rs index 53c20d8..14af771 100644 --- a/crates/ledger-gui/src/owner.rs +++ b/crates/ledger-gui/src/owner.rs @@ -132,6 +132,16 @@ pub struct RestorePlan { pub lines: Vec, } +/// An update the host has downloaded and checked, waiting for the owner to +/// accept it (P11a step 4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateWaiting { + /// Its version. + pub version: String, + /// When it was staged (UTC, RFC 3339). + pub staged_at: String, +} + /// The owner's actions, done by the program the viewer runs in (ledger-cli /// over the books server and the agents' own commands). Each blocks: the /// menu calls it off the UI thread. @@ -172,6 +182,21 @@ pub trait Owner: Send + Sync { } /// Hold the books `book` open while the viewer runs (the viewer's lease). fn hold(&self, _book: &str) {} + /// The update waiting for the owner's accept, if one is (read from the + /// data root; cheap, called every few seconds). + fn update_waiting(&self) -> Option { + None + } + /// "Check for updates": look at the release feed now and stage a newer + /// version if there is one. What came of it, in plain words. + fn check_for_updates(&self) -> Result, String> { + Err("not here".into()) + } + /// Accept the waiting update: the books' host installs it and restarts + /// onto it when no call is running, the books staying open. + fn apply_update(&self) -> Result, String> { + Err("not here".into()) + } /// How to run `agent` in the books home of `book` (made if need be): the /// dock starts it on a terminal of its own (D-27). fn agent_start(&self, _agent: Agent, _book: &str) -> Result { @@ -291,8 +316,14 @@ pub struct OwnerMenu { /// The books whose format upgrade was asked for since the viewer /// opened them (INVARIANT 5: once each open). asked_upgrade: Option, + /// The update waiting, as last read, and when (re-read every few + /// seconds and after any job). + update: Option<(std::time::Instant, Option)>, } +/// How long a reading of the waiting update is kept. +const UPDATE_LOOK: std::time::Duration = std::time::Duration::from_secs(5); + impl OwnerMenu { /// The id of the books shown, if they are books of a data root: found /// once for each load. @@ -304,15 +335,73 @@ impl OwnerMenu { self.book.as_ref().and_then(|(_, id)| id.clone()) } - /// The standing notice of a format upgrade the books wait for, above - /// the page, and the go-ahead asked for once each open (INVARIANT 5). - pub fn notice(&mut self, ui: &mut Ui, books: &Arc) { - if owner().is_none() { - return; + /// The update waiting, if any (read now and then, not every frame). + fn update_waiting( + &mut self, + ctx: &egui::Context, + owner: &Arc, + ) -> Option { + if self + .update + .as_ref() + .is_none_or(|(at, _)| at.elapsed() > UPDATE_LOOK) + { + self.update = Some((std::time::Instant::now(), owner.update_waiting())); } + ctx.request_repaint_after(UPDATE_LOOK); + self.update.as_ref().and_then(|(_, u)| u.clone()) + } + + /// The standing notices above the page: an update waiting for the + /// owner's accept, and a format upgrade the books wait for, whose + /// go-ahead is asked for once each open (INVARIANT 5). + pub fn notice(&mut self, ui: &mut Ui, books: &Arc) { + let Some(owner) = owner() else { + return; + }; let Some(book) = self.book_of(books) else { return; }; + if let Some(update) = self.update_waiting(ui.ctx(), &owner) { + let p = crate::theme::pal(ui.ctx()); + let mut start: Option Outcome + Send>> = None; + egui::Frame::new() + .fill(p.warning_soft) + .inner_margin(egui::Margin::symmetric(20, 10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal_wrapped(|ui| { + ui.label( + RichText::new(format!( + "{} Version {} of wanderbooks is ready.", + icon::ARROW_FAT_UP, + update.version + )) + .font(crate::theme::semibold(13.0)) + .color(p.warning), + ); + ui.label( + RichText::new( + "It is downloaded and checked. The books stay open while it starts, and no work in progress is cut off.", + ) + .font(crate::theme::regular(13.0)) + .color(p.text), + ); + let free = self.job.is_none(); + if ui + .add_enabled(free, egui::Button::new("Update now")) + .on_hover_text("Start using the new version") + .clicked() + { + let o = owner.clone(); + start = Some(Box::new(move || o.apply_update())); + } + }); + }); + if let Some(work) = start { + self.start(ui.ctx(), "Update".into(), work); + } + } let Some(upgrade) = books.format_upgrade.clone() else { return; }; @@ -378,6 +467,7 @@ impl OwnerMenu { { self.done = Some((what.clone(), done)); self.job = None; + self.update = None; } let text = if compact { RichText::new(icon::BOOKS).size(16.0).color(p.muted) @@ -391,6 +481,7 @@ impl OwnerMenu { button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0)); } let busy = self.job.is_some(); + let waiting = self.update_waiting(ui.ctx(), &owner); let mut start: Option<(String, Box Outcome + Send>)> = None; let response = ui .add_enabled_ui(!busy, |ui| { @@ -449,6 +540,24 @@ impl OwnerMenu { } } ui.separator(); + if ui + .button(format!("{} Check for updates", icon::ARROWS_CLOCKWISE)) + .on_hover_text("Look for a newer version of wanderbooks now") + .clicked() + { + let o = owner.clone(); + start = Some(("Check for updates".into(), Box::new(move || o.check_for_updates()))); + } + if let Some(u) = &waiting + && ui + .button(format!("{} Update to {}", icon::ARROW_FAT_UP, u.version)) + .on_hover_text("Start using the version that is downloaded and checked: the books stay open") + .clicked() + { + let o = owner.clone(); + start = Some(("Update".into(), Box::new(move || o.apply_update()))); + } + ui.separator(); if ui .button(format!("{} Close the books", icon::POWER)) .on_hover_text("Close the books now: agents' sessions and every other holder let go") diff --git a/crates/ledger-gui/tests/update_notice.rs b/crates/ledger-gui/tests/update_notice.rs new file mode 100644 index 0000000..94a691a --- /dev/null +++ b/crates/ledger-gui/tests/update_notice.rs @@ -0,0 +1,199 @@ +//! The viewer's update notice and its "Check for updates" menu item +//! (owner.rs, P11a step 4): a staged update the owner reports shows a +//! standing notice with an "Update now" button; the menu can look for one; +//! accepting hands the work to the owner, off the UI thread. + +use chrono::NaiveDate; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::Currency; +use ledger_gui::App; +use ledger_gui::books::Books; +use ledger_gui::data::{self, Source}; +use ledger_gui::nav::Route; +use ledger_gui::owner::{Owner, RestorePlan, UpdateWaiting}; +use ledger_store::Store; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +/// An owner with an update that waits once it has been "found". +#[derive(Default)] +struct Fake { + asked: Mutex>, + staged: Mutex, +} + +impl Fake { + fn said(&self, what: &str) { + self.asked + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(what.into()); + } +} + +impl Owner for Fake { + fn close_books(&self) -> Result { + Err("not in these tests".into()) + } + fn new_books(&self, _: &str, _: &str) -> Result { + Err("not in these tests".into()) + } + fn back_up_now(&self, _: &str) -> Result, String> { + Err("not in these tests".into()) + } + fn plan_restore(&self, _: &str, _: Option, _: bool) -> Result { + Err("not in these tests".into()) + } + fn restore(&self, _: &RestorePlan) -> Result, String> { + Err("not in these tests".into()) + } + fn connect(&self, _: ledger_gui::owner::Agent, _: &str) -> Result, String> { + Err("not in these tests".into()) + } + fn upgrade_format(&self, _: &str, _: &str) -> Result, String> { + Err("not in these tests".into()) + } + fn update_waiting(&self) -> Option { + (*self.staged.lock().unwrap()).then(|| UpdateWaiting { + version: "9.1.0".into(), + staged_at: "2026-10-30T08:00:00Z".into(), + }) + } + fn check_for_updates(&self) -> Result, String> { + self.said("check"); + *self.staged.lock().unwrap() = true; + Ok(vec!["Version 9.1.0 is downloaded and checked.".into()]) + } + fn apply_update(&self) -> Result, String> { + self.said("apply"); + *self.staged.lock().unwrap() = false; + Ok(vec!["Version 9.1.0 is installed.".into()]) + } +} + +fn alone() -> std::sync::MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn fake() -> Arc { + static FAKE: OnceLock> = OnceLock::new(); + FAKE.get_or_init(|| { + let f = Arc::new(Fake::default()); + ledger_gui::owner::set_owner(f.clone()); + f + }) + .clone() +} + +fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 10, 15).unwrap() +} + +/// Books in a data root (`root`: a folder holding the root's marker), or +/// outside one (as the demo's are). +fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) { + let dir = tempfile::Builder::new() + .prefix("wb-gui-owner-") + .tempdir() + .unwrap(); + let book = if root { + std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap(); + dir.path().join("books").join("acme") + } else { + dir.path().join("books-data") + }; + std::fs::create_dir_all(&book).unwrap(); + drop(Store::create(&book, Currency::USD).unwrap()); + let b = data::load_once(&Source { + data_dir: book.clone(), + today: Some(today()), + }) + .unwrap(); + (dir, book, b) +} + +fn harness(b: Books) -> Harness<'static, App> { + let mut h = Harness::builder() + .with_size(egui::vec2(1200.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview)); + h.run_steps(3); + h +} + +fn wait_for(h: &mut Harness<'static, App>, f: &Fake, n: usize) { + for _ in 0..200 { + h.run_steps(2); + if f.asked.lock().unwrap().len() >= n { + h.run_steps(4); + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("the owner wasn't asked: {:?}", f.asked.lock().unwrap()); +} + +#[test] +fn the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts() { + let _alone = alone(); + let f = fake(); + let (_dir, _book, b) = books(true); + let mut h = harness(b); + + // Nothing waits: no notice. + assert!(h.query_by_label_contains("is ready.").is_none()); + + // "Check for updates" asks the owner to look; what it finds shows. + h.get_by_label_contains(" Books").click(); + h.run_steps(3); + h.get_by_label_contains("Check for updates").click(); + wait_for(&mut h, &f, 1); + assert_eq!(f.asked.lock().unwrap()[0], "check"); + // The job says so, and the notice says so too: either is the same + // version, so ask for at least one rather than exactly one. + assert!( + h.query_all_by_label_contains("downloaded and checked") + .next() + .is_some() + ); + + // The standing notice names the version; accepting hands it to the + // owner, and the notice goes once it is installed. + for _ in 0..60 { + h.run_steps(2); + if h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready") + .is_some() + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert!( + h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready") + .is_some(), + "no notice" + ); + h.get_by_label("Update now").click(); + wait_for(&mut h, &f, 2); + assert_eq!(f.asked.lock().unwrap()[1], "apply"); + for _ in 0..60 { + h.run_steps(2); + if h.query_by_label_contains("is ready.").is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert!(h.query_by_label_contains("is ready.").is_none()); +} + +#[test] +fn books_outside_a_data_root_have_no_update_notice() { + let _alone = alone(); + let f = fake(); + *f.staged.lock().unwrap() = true; + let (_dir, _book, b) = books(false); + let h = harness(b); + assert!(h.query_by_label_contains("is ready.").is_none()); + *f.staged.lock().unwrap() = false; +} diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index 0646efb..72cb68f 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1031,6 +1031,90 @@ pub fn close_books(common: &Common) -> Result { } } +/// An update the host has downloaded and checked, waiting for the owner +/// (the viewer's notice; P11a step 4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateWaiting { + /// Its version. + pub version: String, + /// When it was staged (UTC, RFC 3339). + pub staged_at: String, +} + +/// The update waiting for the owner's accept, if there is one. +pub fn update_waiting(common: &Common) -> Option { + let found = ledger_store::root::Root::find(common.root.as_deref()).ok()?; + let u = found.staged_update()?; + // One this program already is (or is older than) is not waiting: the + // next look drops it. + let newer = crate::update::parse_version(&u.version) + .zip(crate::update::parse_version( + crate::update::program_version(), + )) + .is_some_and(|(staged, mine)| staged > mine); + newer.then_some(UpdateWaiting { + version: u.version, + staged_at: u.staged_at, + }) +} + +/// "Check for updates": look at the feed now, whatever the daily look +/// last did, and stage a newer release if there is one (it never swaps +/// the program: accepting is [`apply_update`]). What came of it. +pub fn check_for_updates(common: &Common) -> Result { + let found = + ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; + let mine = crate::update::program_version(); + if let crate::update::Install::PackageManaged(manager) = crate::update::this_install()? { + return Err(format!( + "this install is updated by {manager}, not here: use the package manager" + )); + } + match crate::update::stage_if_due(&found, mine, Duration::ZERO) { + Ok(Some(version)) => Ok(format!( + "Version {version} is downloaded and checked. Accept it to start using it." + )), + Ok(None) => Ok(match update_waiting(common) { + Some(u) => format!( + "Version {} is downloaded and checked. Accept it to start using it.", + u.version + ), + None => format!("You have the newest version ({mine})."), + }), + Err(why) => Err(why.to_string()), + } +} + +/// Accept the staged update: the books' host installs it and restarts +/// onto it once no call is running, the books staying open meanwhile +/// (the lease socket's apply_update). With no host running, it is +/// installed here, since no call can be cut off. What came of it. +pub fn apply_update(common: &Common) -> Result { + let found = + ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; + let run = found.run_dir().map_err(|e| e.to_string())?; + let said = |detail: Option| detail.unwrap_or_else(|| "The update is accepted.".into()); + match crate::opener::ask_once_within(&run, Ask::Intent(Intent::ApplyUpdate), UPDATE_WAIT)? { + Some(HostMsg::Done { detail }) => Ok(said(detail)), + Some(HostMsg::Refused(why)) => Err(why), + Some(HostMsg::Uncertain(why)) => Err(why), + Some(other) => Err(format!("the books' host answered oddly: {other:?}")), + None => match crate::update::apply_staged(&found, crate::update::program_version()) { + Ok(Some(version)) => Ok(format!( + "Version {version} is installed; it starts the next time the books open." + )), + Ok(None) => Err( + "no newer version waits, or this install is updated by its package manager".into(), + ), + Err(why) => Err(why.to_string()), + }, + } +} + +/// The longest the host may take to install an accepted update (a copy +/// and a health check of the new program). +const UPDATE_WAIT: Duration = Duration::from_secs(90); + /// The longest an agent's own command may take to add the books. const CONNECT_WAIT: Duration = Duration::from_secs(60); @@ -1178,6 +1262,12 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result Result = vec![ exe.into_os_string(), diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs index 11c1f65..d676f13 100644 --- a/crates/ledger-host/src/host.rs +++ b/crates/ledger-host/src/host.rs @@ -648,6 +648,9 @@ impl Host { Event::Intent(Intent::ConsentFormatUpgrade { book, passphrase }, answer) => { self.consent(book, passphrase.unwrap_or_default(), answer); } + Event::Intent(Intent::ApplyUpdate, answer) => { + self.apply_update(answer); + } Event::Intent(Intent::Close, answer) => { let _ = answer.send(HostMsg::Done { detail: Some("the books are closing".into()), @@ -715,7 +718,7 @@ impl Host { let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone()); std::thread::spawn(move || { match crate::update::stage_if_due(&root, crate::update::program_version(), every) { - Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"), + Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts when you accept it in the app, the next time the books open, or at `wanderbooks update`"), Ok(None) => {} Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"), } @@ -860,6 +863,54 @@ impl Host { } } + /// The owner's accept of the staged update (P11a step 4): install it + /// now, as `wanderbooks update` does, and let the host's watch for a + /// replaced program (INVARIANT 7) restart onto it once no call is + /// running: the server finishes its calls before it closes, and the + /// leases carry on across it. The answer is sent when the file is + /// in place (or why not), from a thread, so the loop never waits. + fn apply_update(&self, answer: oneshot::Sender) { + if !matches!(self.phase, Phase::Open) { + let _ = answer.send(HostMsg::Refused( + "the books are closing or restarting: ask again in a moment".into(), + )); + return; + } + #[cfg(unix)] + { + let root = self.root.clone(); + std::thread::spawn(move || { + let said = match crate::update::apply_staged( + &root, + crate::update::program_version(), + ) { + Ok(Some(version)) => { + eprintln!( + "wanderbooks host: version {version} accepted: restarting onto it when no call is running" + ); + HostMsg::Done { + detail: Some(format!( + "version {version} is installed; the books restart onto it as soon as no call is running, and stay open meanwhile" + )), + } + } + Ok(None) => HostMsg::Refused( + "no newer version waits, or this install is updated by its package manager" + .into(), + ), + Err(why) => HostMsg::Refused(why.to_string()), + }; + let _ = answer.send(said); + }); + } + #[cfg(not(unix))] + { + let _ = answer.send(HostMsg::Refused( + "updating from the app isn't built for this system yet".into(), + )); + } + } + /// Close the server's input: it closes the books (INVARIANT 3). fn close_server(&mut self) { self.server_stdin = None; diff --git a/crates/ledger-host/src/lease.rs b/crates/ledger-host/src/lease.rs index eadeb02..175bb96 100644 --- a/crates/ledger-host/src/lease.rs +++ b/crates/ledger-host/src/lease.rs @@ -16,7 +16,8 @@ //! 1. A connection's first line is one Ask; a Hold keeps the //! connection (the lease lasts until it closes), anything else is //! answered once and the connection closed. -//! 2. Exactly two intents exist: close and consent_format_upgrade; any +//! 2. Exactly three intents exist: close, consent_format_upgrade and +//! apply_update; any //! other request is refused. //! 3. consent_format_upgrade carries the owner passphrase; without one //! it is refused before anything is done. With one, the host hands it @@ -106,7 +107,7 @@ pub enum Ask { Intent(Intent), } -/// The owner's intents: exactly these two (INVARIANT 2). +/// The owner's intents: exactly these three (INVARIANT 2). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum Intent { @@ -120,6 +121,10 @@ pub enum Intent { #[serde(default)] passphrase: Option, }, + /// Accept the update the host has staged (P11a step 4): the host + /// installs it, and restarts onto it once no call is running, the + /// books staying open to every holder across it. + ApplyUpdate, } impl Intent { @@ -258,9 +263,10 @@ mod tests { } #[test] - fn exactly_two_intents_are_understood() { + fn exactly_three_intents_are_understood() { for intent in [ json!("close"), + json!("apply_update"), json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}), ] { let h = parse(json!({"proto": 1, "intent": intent})).unwrap(); diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 931fc2e..a31a16c 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -83,6 +83,21 @@ pub fn this_executable() -> Result { Ok(exe) } +/// Why this program can't point an agent at itself, if it can't (P11a +/// step 6): macOS runs a quarantined app from a read-only copy under +/// `/private/var/folders/…/AppTranslocation/…`, a path that goes away when +/// the app closes. The app may run, but an agent connected from it would +/// lose the books, so the person moves it out of Downloads first. +/// `exe` is the path this program would give the agent +/// ([`this_executable`]). +pub fn translocation_refusal(exe: &Path) -> Option<&'static str> { + exe.components() + .any(|part| part.as_os_str() == "AppTranslocation") + .then_some( + "wanderbooks is running from the copy macOS makes of a quarantined download, and that copy goes away when the app closes: move wanderbooks out of Downloads (Applications, for example), open it there, and connect the agent again", + ) +} + /// The AppImage this process runs from: `APPIMAGE` when it is an /// absolute path to a file. pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option { @@ -633,6 +648,28 @@ mod tests { let exe = this_executable().unwrap(); assert!(exe.is_file(), "{exe:?}"); } + + #[test] + fn only_a_translocated_path_refuses_an_agent() { + assert_eq!( + translocation_refusal(Path::new( + "/Applications/Wanderbooks.app/Contents/MacOS/wanderbooks" + )), + None + ); + let copy = translocation_refusal(Path::new( + "/private/var/folders/xy/T/AppTranslocation/1234/d/Wanderbooks.app/Contents/MacOS/wanderbooks", + )) + .expect("a translocated path is refused"); + assert!(copy.contains("out of Downloads"), "{copy}"); + // A part that only shares letters is not the marker macOS makes. + assert_eq!( + translocation_refusal(Path::new( + "/Applications/AppTranslocation Notes/wanderbooks" + )), + None + ); + } } #[cfg(all(test, windows))] diff --git a/crates/ledger-host/src/update.rs b/crates/ledger-host/src/update.rs index 6d2bf37..0ccede2 100644 --- a/crates/ledger-host/src/update.rs +++ b/crates/ledger-host/src/update.rs @@ -157,7 +157,8 @@ pub fn install_kind(exe: &Path, appimage: Option<&Path>) -> Install { Install::SelfUpdating(exe.to_path_buf()) } -fn this_install() -> Result { +/// How the running program was installed. +pub fn this_install() -> Result { let exe = crate::start::this_executable()?; let image = std::env::var_os("APPIMAGE").map(PathBuf::from); Ok(install_kind(&exe, image.as_deref())) @@ -494,7 +495,10 @@ pub fn apply_staged( let Install::SelfUpdating(target) = this_install()? else { return Ok(None); }; - let Ok(_lock) = take_lock(&updates) else { + // The host's own look at the feed can still hold the lock for a moment + // after it writes `meta.json`; the owner's accept should wait that out, + // not answer "nothing waits" (P11a step 4). + let Ok(_lock) = take_lock_within(&updates, Duration::from_secs(5)) else { return Ok(None); }; let meta: serde_json::Value = std::fs::read(dir.join(META)) diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index cff182f..ea59a77 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -242,6 +242,17 @@ pub struct FormatUpgradeOut { pub how: String, } +/// An update the host has downloaded and checked, waiting for the owner. +#[derive(Debug, Serialize, JsonSchema)] +pub struct UpdateWaitingOut { + /// The version waiting. + pub version: String, + /// When the host staged it (UTC, RFC 3339). + pub staged_at: String, + /// Who can accept it, and how. + pub how: String, +} + /// Something to do next. #[derive(Debug, Serialize, JsonSchema)] pub struct NextStepOut { @@ -391,6 +402,10 @@ pub struct StartOut { /// the owner gives the go-ahead. No tool can: tell the human. #[serde(default, skip_serializing_if = "Option::is_none")] pub format_upgrade: Option, + /// A newer version the host has downloaded and checked (P11a step 4): + /// the owner accepts it in the app. No tool can: tell the human. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub update_waiting: Option, /// Who is asking. #[serde(default, skip_serializing_if = "Option::is_none")] pub you: Option, @@ -963,6 +978,25 @@ impl BooksServer { "Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.", )); } + // A newer version waits for the owner (P11a step 4): said once, + // after a format upgrade, which comes first. + let update_waiting = ledger_store::root::root_of_book_dir(store.data_dir()) + .and_then(ledger_store::root::staged_update_in) + .map(|u| UpdateWaitingOut { + version: u.version, + staged_at: u.staged_at, + how: "only the owner accepts it: in the app (Books, then Update), or `wanderbooks update` at a terminal. No tool can.".to_string(), + }); + if let Some(u) = &update_waiting { + next_steps.push(step( + "books.handbook", + None, + &format!( + "Tell the human: version {} of wanderbooks is downloaded and checked, and waits for them to accept it in the app (Books, then Update). Nothing changes until they do; no tool can.", + u.version + ), + )); + } if onboarding || focus == "onboarding" { next_steps.push(NextStepOut { tool: None, @@ -1240,6 +1274,7 @@ impl BooksServer { title: handbook::title(md).to_string(), }) .collect(), + update_waiting, format_upgrade: upgrade.map(|u| FormatUpgradeOut { guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(), format: u.from.format, diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index bb394fd..c318ec5 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -661,6 +661,12 @@ impl Root { .map_err(|e| self.hide(e)) } + /// The update the host has downloaded, checked and staged under + /// `updates/staged/`, if one waits (P11a step 4). + pub fn staged_update(&self) -> Option { + staged_update_in(&self.path) + } + /// The folder a book lives in. pub fn book_dir(&self, id: &BookId) -> PathBuf { self.path.join(BOOKS_DIR).join(id.as_str()) @@ -2394,3 +2400,42 @@ mod unit { assert_eq!(keys, book.join("keys")); } } + +/// An update waiting in `/updates/staged/`: downloaded, checked and +/// kept by the host, applied when the owner accepts it or the books next +/// start. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StagedUpdate { + /// Its version. + pub version: String, + /// When it was staged, UTC (RFC 3339). + pub staged_at: String, +} + +/// The update staged in the data root `root`, if one waits. What it says +/// is shown to people and agents, so a version that is not plainly one +/// (digits, letters, dots, dashes, at most 40) is no update. +pub fn staged_update_in(root: &Path) -> Option { + let meta = root.join("updates").join("staged").join("meta.json"); + let value: serde_json::Value = serde_json::from_slice(&std::fs::read(&meta).ok()?).ok()?; + let version = value["version"].as_str()?; + let plain = !version.is_empty() + && version.len() <= 40 + && version + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); + if !plain { + return None; + } + let at: chrono::DateTime = std::fs::metadata(&meta).ok()?.modified().ok()?.into(); + Some(StagedUpdate { + version: version.to_string(), + staged_at: at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + }) +} + +/// The data root a book's folder (`/books/`) is in, if it is one. +pub fn root_of_book_dir(book_dir: &Path) -> Option<&Path> { + let root = book_dir.parent()?.parent()?; + root.join(MARKER_FILE).is_file().then_some(root) +} diff --git a/docs/architecture.md b/docs/architecture.md index 40f59e5..0be68b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -417,7 +417,19 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by call is cut off), after a second check against the key, or by `wanderbooks update`, the owner's say-so, which reuses the staged copy. A local release is named in `/updates/feed` (a `file://` address, then - the key file), the same rule as `--trusted-key`. + the key file), the same rule as `--trusted-key`; +- **the owner accepts one in the app**: the viewer reads the staged + `meta.json` (`Owner::update_waiting`) and shows a notice with "Update + now", and Books has "Check for updates" (`Owner::check_for_updates`) and + "Update to X"; accepting is the host's lease intent `apply_update`, which + installs the staged copy as `update` does and lets the host's own + replaced-program watch restart onto it once no call runs. `books.start` + carries the same notice to an agent as `update_waiting`, with a next step + to tell the human; no tool accepts it; +- **a translocated macOS app refuses to connect agents** + (`start::translocation_refusal`): a quarantined download runs from a + read-only copy under `AppTranslocation` that vanishes with the app, so + the person moves it out of Downloads first. `deploy/packaging/` holds the Linux packaging: `packager.toml` (cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD), @@ -428,6 +440,8 @@ throwaway-keyed feed on this machine) and `test-linux-packages.sh`. `.github/workflows/release.yml` builds them on a version tag. The macOS bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5). +A translocated app (Gatekeeper's copy of a quarantined download) may run +but refuses to connect an agent, saying how to move it out of Downloads. ## The result envelope and the guards (`ledger-mcp`, P5) diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 17ffa7a..4212d05 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -124,7 +124,8 @@ settings-and-conventions section says how to use them. codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args }], needs_attention: { items: [{ id, severity, kind, title, tool?, args? }], counts: { kind: n } }, truncated?, handbook_sections, - format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool | + format_upgrade?: { guard, format, adds, how }, update_waiting?: { version, + staged_at, how }, next_steps: [{ tool | prompt, args?, why, required }], isolation }`. The connect text names it as the first call. `book.url` is the book's path on this server (`/b/ID/mcp`), never a path on a disk. `you` is the token's own facts @@ -145,6 +146,12 @@ settings-and-conventions section says how to use them. (below); its next step comes first, before anything else, at every start until it is done: `books.handbook {section: "format-upgrade"}`, "Tell the human first: …". + `update_waiting` is there only while the host has downloaded and checked + a newer version of wanderbooks (`/updates/staged/`): its version, + when it was staged (UTC, RFC 3339) and how it may be accepted. Only the + owner can accept it, in the app (Books, then Update) or with + `wanderbooks update` at a terminal; no tool can. Its next step asks the + agent to tell the human, and nothing changes until they accept. `isolation` is `same_user (advisory)` locally (D-32): the books are hidden from agents in the owner's own account, not locked against them. On the owner's own machine, where the server runs as a separate account diff --git a/docs/packaging.md b/docs/packaging.md index 8e5c03f..136fecc 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -135,8 +135,17 @@ subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs the CI run (Actions, release, Run workflow, this branch) and then a Mac. Open items for the trial on a Mac: whether `wanderbooks update` replacing the program inside a signed `.app` breaks its signature (it will: step 5 -must decide, probably by updating a whole `.app`); the translocated-app -check (`translocated_app_refuses_to_connect`) is not built. +must decide, probably by updating a whole `.app`). **Translocation +(P11a step 6):** a `.dmg` downloaded from the internet keeps its +quarantine bit, so Gatekeeper may run the app from a read-only copy under +`/private/var/folders/…/AppTranslocation/…` that goes away when it +closes. `wanderbooks connect` (and the viewer's Connect, which is the same +function) refuses there, before a token is made or an agent's list is +touched, and says to move the app out of Downloads and open it again: +`ledger_host::start::translocation_refusal` reads the path the program +would give the agent. Checked on Linux by standing in for that path +(`translocated_app_refuses_to_connect`, `only_a_translocated_path_refuses_an_agent`); +the real Gatekeeper copy still needs the trial on a Mac. ## Updates staged by the host (step 4) @@ -148,9 +157,23 @@ first; `wanderbooks update` applies it too and reuses the staged copy; `update --check` says "already downloaded". A local release is named by `/updates/feed` (a `file://` address, then the key file). Without a compiled-in release key and without that file, the look does nothing but -log why. Not built: the in-app "restart now to update" choice and the -viewer's notice, which need an admin-socket intent and a place in the -viewer (see the unit's As-built block). +log why. + +The owner accepts a staged update **in the app** (P11a step 4). The +viewer reads `/updates/staged/meta.json` every few seconds +(`Owner::update_waiting`) and shows a standing notice above the page, +"Version X of wanderbooks is ready", with "Update now"; Books also has +"Check for updates" (`Owner::check_for_updates`, which looks at the feed +at once whatever the daily look did) and "Update to X" while one waits. +Accepting sends the host's lease intent `apply_update`; the host installs +the staged copy exactly as `wanderbooks update` does and its existing +watch for a replaced program (host.rs INVARIANT 7) restarts onto it once +no call is running, the books staying open and every holder's session +carrying on. With no host running there is no call to cut off, so the +viewer applies it itself. `books.start` tells an agent the same thing +(`update_waiting`) and adds a next step to tell the human; no tool can +accept it. The accepted update replaces the program file, not a whole +`.app`, which step 5 must revisit for a signed bundle. ## What `update` and `rollback` need from a release diff --git a/docs/testing.md b/docs/testing.md index 403f142..4285dcd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3823,6 +3823,51 @@ add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy` (`failed_update_rolls_back`), `install_kind` (the marker test), and `parse_version`'s rejections. +## P11a steps 4 and 6: the in-app accept, the viewer's notice, translocation (2026-11-05) + +The accept is the host's lease intent `apply_update` (the name +`plan/served.md` 8.0.3 allocates); the viewer's names are +`Owner::update_waiting`, `Owner::check_for_updates` and `Owner::apply_update`, +and the agent's is `books.start`'s `update_waiting`. + +- `crates/ledger-cli/tests/update.rs` (Linux): + `books_start_says_an_update_waits` — a quiet `books.start` has no + `update_waiting`; after the host stages a signed 9.0.0, `books.start` + names its version and staged time and says only the owner accepts it, + its next step says to tell the human, and no path appears in the answer; + `the_owner_accepts_a_staged_update_in_the_app` — accepting with nothing + staged is refused and changes nothing; once 9.0.0 is staged, the same + intent (`opener::ask_once_within` with `Intent::ApplyUpdate`, what the + viewer's `apply_update` sends) installs it, the program becomes the + release's bytes, the old version is kept under `updates/previous/`, and + an agent's bridge writes all through the host's restart onto it: every + account is in the chart exactly once. +- `crates/ledger-gui/tests/update_notice.rs`: + `the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts` + (Books, Check for updates asks the owner and its words show; the standing + notice names the version; Update now asks the owner to apply it, and the + notice goes once it is installed) and + `books_outside_a_data_root_have_no_update_notice`. +- `crates/ledger-cli/tests/translocation.rs`: + `translocated_app_refuses_to_connect` — with the program's path standing + in for Gatekeeper's copy (`APPIMAGE` names a file under + `AppTranslocation`), `connect claude` is refused before a token is made + or a home is made, and the stand-in agent never runs; the same program at + an ordinary path connects. `crates/ledger-host/src/start.rs`: + `only_a_translocated_path_refuses_an_agent` (the marker is a path part, + not letters in a name). + +Not checked here: the real Gatekeeper copy and the `.dmg` (needs a Mac, +the owner's trial); the Windows installer job is P11c's, not on this branch. + +**For P12 (mutation targets, none run, D-33):** `update_waiting`'s +"newer than this program" comparison (a mutant that offers the running +version must die on `books_start_says_an_update_waits`'s quiet first +call), `staged_update_in`'s plausibility check (a staged version with a +path separator must not become a notice), and `translocation_refusal`'s +component comparison (a part that merely contains the word must not +refuse). + ## P5: the agent surface (2026-10-29) Every tool result carries the envelope (`next_steps`, `guards`, `warnings`, diff --git a/plan/served.md b/plan/served.md index 4e2966c..bf79eca 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1645,7 +1645,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 #### P11a: packaging and updates (D-19, D-22, D-24, D-25, D-26) -1. **Status:** in progress: step 0 landed 2026-10-29 (`5e220cca`) as far as the macOS store and the portable CI job; its keyring stores are removed by U0e (D-63), and the Windows half of step 0 moved to P11c (D-62). Steps 1 to 7 open, macOS and Linux first. P11 was split 2026-10-26 (D-54): P11a is installers and updates; P11b the agent dock and books homes. +1. **Status:** in progress: steps 0-4 and the macOS half of step 6 landed 2026-10-29 (the AppImage restart, the daily staged update, the unsigned `.dmg` job); the in-app accept, the viewer's update notice and "Check for updates", and the translocation refusal landed 2026-11-05. Step 5 (signing) is deferred by D-64; left: the release workflow's first run (the owner's click), the Windows installer (P11c's), the trials on a Mac and a Windows machine. P11 was split 2026-10-26 (D-54): P11a is installers and updates; P11b the agent dock and books homes. 2. **Depends on:** none in code. From the owner before the signing steps: the Apple Developer ID certificate and notarization credentials as CI secrets; the Windows signing identity (Azure Artifact Signing if eligible, else an OV certificate); GitHub Releases enabled on the public mirror; the release hardware token; clean VMs or runners for the install tests. Q-4 (keep the two-key release manifest of D-25, or minisign alone) before step 5. 3. **Feature and schema:** none. 4. **Build:** @@ -1660,7 +1660,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-host/src/update.rs`, `deploy/packaging/*`, `.github/workflows/release.yml`, `docs/packaging.md`; touches `commands.rs`, `crates/ledger-gui/src/owner.rs`, `mise.toml`. 6. **Tests and proofs:** named above. 7. **Done when:** (1) on a clean Linux VM the AppImage installs, opens, onboards and posts an entry with no terminal and no admin password, and updates itself from a test feed; (2) on Arch, Debian and Fedora the packages install and upgrade with the head unchanged; (3) an update leaves every book's head and goldens unchanged and a bridge session carries on across it; (4) a backup made on one system restores on another; (5) macOS and Windows: the same as (1), once the signing prerequisites exist (record which are done in the As-built block); (6) `just check` green. -8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `/updates//`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILD in Debian trixie, Fedora and Arch containers, hold books open as an ordinary user, upgrade to 0.1.1, host restarted onto the new file, journal head unchanged, `update` refuses: passes on all three. By hand: the AppImage updated itself and rolled back against a feed signed by `cargo packager signer` (`just local-release`). *Done-when so far:* (1) **partly**: the AppImage opens (bare launch) and updates itself from a test feed, but "onboards and posts an entry with no terminal" is false today (the viewer opens only an existing book and `setup`/`books create` need a terminal; not in these steps), and a held-open books' host does not restart onto an updated AppImage (it runs from the old image's mount; fix needs the host to learn the image's path, a trust decision left to the EM); (2) true on Debian, Fedora and Arch containers; (3) true for a hand-placed program (`bridge_survives_update`) and for packages (the container test), not yet for the AppImage's host; (4) not touched; (5) and (6) not done (macOS/Windows need machines and signing; `just check` is the EM's). *Not done, as instructed:* step 4 (daily check, staged update applied at restart), step 5 (signing, Q-4), step 6 (macOS and Windows bundles), step 7 (architecture note is written; the website and `docs/parity.md` are not touched, since nothing is published). **Step 3's AppImage gap, step 4 and the macOS half of step 6 (2026-10-29, branch `P11a`).** *AppImage restart (EM decision):* `start.rs` lets `APPIMAGE` into the host's environment only when it is an absolute file (`own_appimage`; read about the host itself, never told to an agent, I-2), `this_executable()` returns that image, and `host.rs` tells a replaced program by (dev, inode, size, mtime) instead of (dev, inode); test `a_replaced_appimage_restarts_the_books` (the programs run from one copy, `APPIMAGE` names another). Not run against a real FUSE mount. *Step 4:* `update.rs::stage_if_due` (the host's look, at most once per `update_check` knob, default 24 h, noted in `updates/checked`; from a thread of the host's loop; downloads, checks sha256 and signature, writes `/updates/staged/{wanderbooks,meta.json}`; never swaps), `apply_staged` (called by `start.rs::start_host` before it starts a host, so never during a call; re-checks the signature; a staged file that fails or does not start is dropped), `update` reuses a staged copy and `--check` says it is downloaded, `/updates/feed` names a local `file://` feed and its key (INVARIANT 8's rule), `set_program_version` because packages rewrite only the cli's version. Tests in `crates/ledger-cli/tests/update.rs`: `the_host_stages_a_daily_update_and_the_next_start_applies_it`, `a_daily_update_that_is_not_ours_is_not_staged`, `update_installs_the_copy_the_host_staged_without_downloading_again`, `a_staged_update_that_was_tampered_with_is_dropped_not_applied`; all 13 in the file pass; `ledger-host` lib tests pass (74). **Not built, needs a name or a decision:** "applied at the next restart the owner accepts in the app" and the `books.start`/viewer notice. The accept is a new lease/admin intent (`Intent` in `lease.rs`) and the notice a place in the viewer (P11b's files); neither name is allocated in 8.0.3, so the owner accepts today with `wanderbooks update`, and `update --check` and the host log say a version is waiting. *macOS bundle:* `deploy/packaging/{packager-macos.toml,Info.plist,build-macos.sh}`, `just package-macos`, a `macos` job in `release.yml` (`macos-latest`, Apple silicon, uploads the `.dmg` as run artifact `macos-dmg-unsigned`, attaches it on a tag), `docs/packaging.md` macOS rows "built in CI, unsigned; the trial on a Mac is the owner's". Checked on Linux only: the config parses under cargo-packager 0.11.8's strict schema; the `.dmg` itself is unverified until a CI run (the owner: Actions > release > Run workflow > branch `P11a`; the EM pushes the branch first). Not built: translocated-app refusal, "Check for updates" in the viewer's menu, the Intel build, the Windows installer (P11c), signing (step 5). Known issue for step 5: `update` replaces the program inside the `.app`, which breaks a signed bundle's seal; step 5 must update a whole `.app`. +8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `/updates//`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILD in Debian trixie, Fedora and Arch containers, hold books open as an ordinary user, upgrade to 0.1.1, host restarted onto the new file, journal head unchanged, `update` refuses: passes on all three. By hand: the AppImage updated itself and rolled back against a feed signed by `cargo packager signer` (`just local-release`). *Done-when so far:* (1) **partly**: the AppImage opens (bare launch) and updates itself from a test feed, but "onboards and posts an entry with no terminal" is false today (the viewer opens only an existing book and `setup`/`books create` need a terminal; not in these steps), and a held-open books' host does not restart onto an updated AppImage (it runs from the old image's mount; fix needs the host to learn the image's path, a trust decision left to the EM); (2) true on Debian, Fedora and Arch containers; (3) true for a hand-placed program (`bridge_survives_update`) and for packages (the container test), not yet for the AppImage's host; (4) not touched; (5) and (6) not done (macOS/Windows need machines and signing; `just check` is the EM's). *Not done, as instructed:* step 4 (daily check, staged update applied at restart), step 5 (signing, Q-4), step 6 (macOS and Windows bundles), step 7 (architecture note is written; the website and `docs/parity.md` are not touched, since nothing is published). **Step 3's AppImage gap, step 4 and the macOS half of step 6 (2026-10-29, branch `P11a`).** *AppImage restart (EM decision):* `start.rs` lets `APPIMAGE` into the host's environment only when it is an absolute file (`own_appimage`; read about the host itself, never told to an agent, I-2), `this_executable()` returns that image, and `host.rs` tells a replaced program by (dev, inode, size, mtime) instead of (dev, inode); test `a_replaced_appimage_restarts_the_books` (the programs run from one copy, `APPIMAGE` names another). Not run against a real FUSE mount. *Step 4:* `update.rs::stage_if_due` (the host's look, at most once per `update_check` knob, default 24 h, noted in `updates/checked`; from a thread of the host's loop; downloads, checks sha256 and signature, writes `/updates/staged/{wanderbooks,meta.json}`; never swaps), `apply_staged` (called by `start.rs::start_host` before it starts a host, so never during a call; re-checks the signature; a staged file that fails or does not start is dropped), `update` reuses a staged copy and `--check` says it is downloaded, `/updates/feed` names a local `file://` feed and its key (INVARIANT 8's rule), `set_program_version` because packages rewrite only the cli's version. Tests in `crates/ledger-cli/tests/update.rs`: `the_host_stages_a_daily_update_and_the_next_start_applies_it`, `a_daily_update_that_is_not_ours_is_not_staged`, `update_installs_the_copy_the_host_staged_without_downloading_again`, `a_staged_update_that_was_tampered_with_is_dropped_not_applied`; all 13 in the file pass; `ledger-host` lib tests pass (74). **Not built, needs a name or a decision:** "applied at the next restart the owner accepts in the app" and the `books.start`/viewer notice. The accept is a new lease/admin intent (`Intent` in `lease.rs`) and the notice a place in the viewer (P11b's files); neither name is allocated in 8.0.3, so the owner accepts today with `wanderbooks update`, and `update --check` and the host log say a version is waiting. *macOS bundle:* `deploy/packaging/{packager-macos.toml,Info.plist,build-macos.sh}`, `just package-macos`, a `macos` job in `release.yml` (`macos-latest`, Apple silicon, uploads the `.dmg` as run artifact `macos-dmg-unsigned`, attaches it on a tag), `docs/packaging.md` macOS rows "built in CI, unsigned; the trial on a Mac is the owner's". Checked on Linux only: the config parses under cargo-packager 0.11.8's strict schema; the `.dmg` itself is unverified until a CI run (the owner: Actions > release > Run workflow > branch `P11a`; the EM pushes the branch first). Not built: translocated-app refusal, "Check for updates" in the viewer's menu, the Intel build, the Windows installer (P11c), signing (step 5). Known issue for step 5: `update` replaces the program inside the `.app`, which breaks a signed bundle's seal; step 5 must update a whole `.app`. **The in-app accept, the viewer's notice and the translocation refusal (2026-11-05, branch `P11a`).** The accept is the allocated `Intent::ApplyUpdate` on the lease socket (three intents now); `host.rs` installs the staged copy from a thread and lets its replaced-program watch (INVARIANT 7) restart onto it once no call runs, and `commands.rs::apply_update` asks the host, applying directly only when no host runs (no call can be cut off). `owner.rs` reads `/updates/staged/meta.json` through `Owner::update_waiting` (every five seconds), shows a standing notice above the page with "Update now", and Books has "Check for updates" (`Owner::check_for_updates`, which looks at the feed at once whatever the daily look did) and "Update to X". `books.start` gains `update_waiting {version, staged_at, how}` (`kept.rs`) and a next step telling the agent to tell the human; no tool accepts. Tests: `books_start_says_an_update_waits`, `the_owner_accepts_a_staged_update_in_the_app` (`ledger-cli/tests/update.rs`), `the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts`, `books_outside_a_data_root_have_no_update_notice` (`ledger-gui/tests/update_notice.rs`). **Translocation (step 6):** `start.rs::translocation_refusal` refuses `connect_agent` (the CLI's `connect` and the viewer's Connect) when the program's path has an `AppTranslocation` part, before a token or an agent's list is touched; tested on Linux by `translocated_app_refuses_to_connect` (standing in with `APPIMAGE`) and `only_a_translocated_path_refuses_an_agent`. **Still not built:** the real macOS trial, the Windows installer job (P11c), signing (step 5). The pause note expected a Windows installer job in the checkpoint; it held none, and P11c owns the Windows installer. #### P11b: the agent dock and books homes (D-27)