Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b1774512b | ||
|
|
e2754898d8 | ||
|
|
53a233d687 |
No files matched your search
@@ -172,6 +172,21 @@ impl ledger_gui::owner::Owner for ViewerOwner {
|
||||
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
|
||||
ledger_host::commands::upgrade_format(&self.0, book, passphrase)
|
||||
}
|
||||
|
||||
fn update_waiting(&self) -> Option<ledger_gui::owner::UpdateWaiting> {
|
||||
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<Vec<String>, String> {
|
||||
ledger_host::commands::check_for_updates(&self.0).map(|l| vec![l])
|
||||
}
|
||||
|
||||
fn apply_update(&self) -> Result<Vec<String>, String> {
|
||||
ledger_host::commands::apply_update(&self.0).map(|l| vec![l])
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks gui`: the viewer, in this process. For books of the data
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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<Option<ledger_host::lease::HostMsg>, 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<String> = 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);
|
||||
}
|
||||
@@ -132,6 +132,16 @@ pub struct RestorePlan {
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<UpdateWaiting> {
|
||||
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<Vec<String>, 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<Vec<String>, 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<AgentStart, String> {
|
||||
@@ -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<String>,
|
||||
/// The update waiting, as last read, and when (re-read every few
|
||||
/// seconds and after any job).
|
||||
update: Option<(std::time::Instant, Option<UpdateWaiting>)>,
|
||||
}
|
||||
|
||||
/// 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<Books>) {
|
||||
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<dyn Owner>,
|
||||
) -> Option<UpdateWaiting> {
|
||||
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<Books>) {
|
||||
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<Box<dyn FnOnce() -> 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<dyn FnOnce() -> 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")
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
staged: Mutex<bool>,
|
||||
}
|
||||
|
||||
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<String, String> {
|
||||
Err("not in these tests".into())
|
||||
}
|
||||
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
|
||||
Err("not in these tests".into())
|
||||
}
|
||||
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not in these tests".into())
|
||||
}
|
||||
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
|
||||
Err("not in these tests".into())
|
||||
}
|
||||
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
|
||||
Err("not in these tests".into())
|
||||
}
|
||||
fn connect(&self, _: ledger_gui::owner::Agent, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not in these tests".into())
|
||||
}
|
||||
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
|
||||
Err("not in these tests".into())
|
||||
}
|
||||
fn update_waiting(&self) -> Option<UpdateWaiting> {
|
||||
(*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<Vec<String>, 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<Vec<String>, 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<Fake> {
|
||||
static FAKE: OnceLock<Arc<Fake>> = 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;
|
||||
}
|
||||
@@ -1031,6 +1031,90 @@ pub fn close_books(common: &Common) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<UpdateWaiting> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String>| 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<Vec<Str
|
||||
"{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)"
|
||||
)
|
||||
})?;
|
||||
// A translocated macOS app's path (P11a step 6) is not a place to point
|
||||
// an agent: the copy goes away with the app.
|
||||
let exe = crate::start::this_executable()?;
|
||||
if let Some(why) = crate::start::translocation_refusal(&exe) {
|
||||
return Err(why.to_string());
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
// The token: kept in its file, or made now.
|
||||
let found =
|
||||
@@ -1195,7 +1285,6 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
|
||||
));
|
||||
}
|
||||
// The agent's own list: the books' entry made anew.
|
||||
let exe = crate::start::this_executable()?;
|
||||
let name = format!("wanderbooks-{book}");
|
||||
let mut serve: Vec<std::ffi::OsString> = vec![
|
||||
exe.into_os_string(),
|
||||
|
||||
@@ -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<HostMsg>) {
|
||||
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;
|
||||
|
||||
@@ -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<ledger_mcp::admin::Passphrase>,
|
||||
},
|
||||
/// 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();
|
||||
|
||||
@@ -83,6 +83,21 @@ pub fn this_executable() -> Result<PathBuf, String> {
|
||||
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<std::ffi::OsString> {
|
||||
@@ -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))]
|
||||
|
||||
@@ -157,7 +157,8 @@ pub fn install_kind(exe: &Path, appimage: Option<&Path>) -> Install {
|
||||
Install::SelfUpdating(exe.to_path_buf())
|
||||
}
|
||||
|
||||
fn this_install() -> Result<Install, String> {
|
||||
/// How the running program was installed.
|
||||
pub fn this_install() -> Result<Install, String> {
|
||||
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))
|
||||
|
||||
@@ -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<FormatUpgradeOut>,
|
||||
/// 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<UpdateWaitingOut>,
|
||||
/// Who is asking.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub you: Option<YouOut>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<StagedUpdate> {
|
||||
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 `<root>/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<StagedUpdate> {
|
||||
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<chrono::Utc> = 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 (`<root>/books/<id>`) 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)
|
||||
}
|
||||
+15
-1
@@ -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 `<root>/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)
|
||||
|
||||
|
||||
@@ -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 (`<root>/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
|
||||
|
||||
+28
-5
@@ -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
|
||||
`<root>/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 `<root>/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
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+2
-2
@@ -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 `<root>/updates/<version>/`, 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 PKGBUILLine truncated
|
||||
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 `<root>/updates/<version>/`, 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 PKGBUILLine truncated
|
||||
|
||||
#### P11b: the agent dock and books homes (D-27)
|
||||
|
||||
|
||||
Reference in new issue
Block a user