feat: accept a staged update in the app
The host has staged a downloaded, signature-checked update since step 4, but the only way to apply it was `wanderbooks update` at a terminal, and nothing in the viewer or on an agent's first call said one waited. The owner's accept is the name plan 8.0.3 allocated for it: the lease socket gains `Intent::ApplyUpdate`, and the host installs the staged copy from a thread and lets its existing watch for a replaced program (host.rs INVARIANT 7) restart onto it once no call runs, so the books stay open and every holder's session carries on. With no host running there is no call to cut off, so the viewer applies it itself. The viewer reads `<root>/updates/staged/meta.json` every few seconds and shows a standing notice with "Update now"; Books gains "Check for updates" (which looks at the feed at once, whatever the daily look last did) and "Update to X". `books.start` gains the allocated `update_waiting` field and a next step telling the agent to tell the human; no tool can accept. `apply_staged` waits up to five seconds for the update lock: running the new tests showed the host's own look can hold it for a moment after it writes `meta.json`, and the owner's accept should wait that out rather than answer "nothing waits". Tests: `books_start_says_an_update_waits` and `the_owner_accepts_a_staged_update_in_the_app` in `crates/ledger-cli/tests/update.rs` (a bridge writes all through the restart, each account in the chart exactly once); `the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts` and `books_outside_a_data_root_have_no_update_notice` in `crates/ledger-gui/tests/update_notice.rs`.
This commit is contained in:
1 parent
812d6f203e
commit
53a233d687
10 files changed
+693
-11
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user