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/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..aef7d0a 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); 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/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) +}