From 7dd50a3ca408b4996af0db110ada50bef1ed9835 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:35:54 -0400 Subject: [PATCH] App: run update jobs in the background and swap the release in after closing (#231) The desktop app checks, downloads, verifies and stages on its own threads for the window, and once the window has closed (autosave closed) swaps a staged release in and starts it. Staging folders an earlier update left are removed at start-up. Automatic checks stay off under --control automation and CI unless FIELDCRAFT_UPDATE_HOST names a test server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- Cargo.lock | 1 + apps/fieldcraft/Cargo.toml | 1 + apps/fieldcraft/src/main.rs | 8 +++ apps/fieldcraft/src/update.rs | 105 ++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 apps/fieldcraft/src/update.rs diff --git a/Cargo.lock b/Cargo.lock index aa643cf..f4fd446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1278,6 +1278,7 @@ dependencies = [ "fieldcraft-engine", "fieldcraft-io", "fieldcraft-ui-egui", + "fieldcraft-update", "getrandom 0.3.4", "log", "rfd", diff --git a/apps/fieldcraft/Cargo.toml b/apps/fieldcraft/Cargo.toml index 59198de..4153ad1 100644 --- a/apps/fieldcraft/Cargo.toml +++ b/apps/fieldcraft/Cargo.toml @@ -13,6 +13,7 @@ workspace = true fieldcraft-engine = { workspace = true } fieldcraft-ui-egui = { workspace = true } fieldcraft-io = { workspace = true } +fieldcraft-update = { workspace = true } rfd = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } egui = { workspace = true } diff --git a/apps/fieldcraft/src/main.rs b/apps/fieldcraft/src/main.rs index 37b457e..68f5af2 100644 --- a/apps/fieldcraft/src/main.rs +++ b/apps/fieldcraft/src/main.rs @@ -12,6 +12,7 @@ #![forbid(unsafe_code)] mod control_server; +mod update; mod usage; use fieldcraft_engine::autosave::{self, Autosaver}; @@ -89,6 +90,10 @@ impl eframe::App for App { if let Some(u) = self.usage.take() { u.finish(); } + // An update the user chose to install: swapped in now that the window has closed. + if let Some(staged) = self.app.take_staged_update() { + update::finish(&staged); + } log::info!("FieldCraft closed"); crash::end_session(); } @@ -252,6 +257,9 @@ fn main() -> eframe::Result { } None => None, }; + // Updates (docs/updates.md): checked at most daily when on, installed only on a click. + fieldcraft_engine::enable_update_checks(true); + app = app.with_updates(update::host(cc.egui_ctx.clone(), args.control.is_some())); app.custom_titlebar = CUSTOM_TITLEBAR; app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(rs) = &cc.wgpu_render_state { diff --git a/apps/fieldcraft/src/update.rs b/apps/fieldcraft/src/update.rs new file mode 100644 index 0000000..6e8a692 --- /dev/null +++ b/apps/fieldcraft/src/update.rs @@ -0,0 +1,105 @@ +//! The desktop app's side of updates (docs/updates.md): it runs the window's update jobs +//! ([`fieldcraft_ui_egui::updates`]) on background threads with [`fieldcraft_update`], and swaps +//! a staged release in after the window has closed ([`finish`]). +//! +//! Automatic checks are off in runs driven by automation (`--control`) and under CI, so test +//! and agent runs never reach GitHub on their own; `FIELDCRAFT_UPDATE_HOST` (a local test +//! server) turns them back on. Help › Check for Updates works in every run. + +use std::cell::Cell; +use std::path::Path; +use std::sync::mpsc; + +use fieldcraft_ui_egui::updates::{Event, Job, UpdateHost}; +use fieldcraft_update::install::{self, Finish, Install}; +use fieldcraft_update::net::{Client, Which}; +use fieldcraft_update::stage::{self, Options}; +use fieldcraft_update::{Release, Staged}; + +/// What the window needs for updates. +pub fn host(ctx: egui::Context, automation: bool) -> UpdateHost { + let here = install::here(); + log::info!("updates: {}", here.describe()); + stage::clean_stale(&here); + let env = |k: &str| std::env::var_os(k).is_some_and(|v| !v.is_empty()); + let automatic = env(fieldcraft_update::HOST_ENV) || !(automation || env("CI")); + let (tx, events) = mpsc::channel::(); + let unsupported = here.unsupported(); + let start = move |job: Job| { + let (tx, ctx, here) = (tx.clone(), ctx.clone(), here.clone()); + let spawned = std::thread::Builder::new().name("fieldcraft update".into()).spawn(move || { + let send = |e: Event| { + let _ = tx.send(e); + ctx.request_repaint(); + }; + let client = Client::from_env(); + match job { + Job::Check { manual } => send(Event::Checked { manual, result: client.check().map_err(|e| e.to_string()) }), + Job::Stage(release) => send(Event::Staged(stage_release(&client, &release, &here, &send))), + Job::Reinstall => { + let staged = client + .release(&Which::Version(fieldcraft_update::current())) + .map_err(|e| e.to_string()) + .and_then(|r| stage_release(&client, &r, &here, &send)); + send(Event::Staged(staged)); + } + } + }); + if let Err(e) = spawned { + log::warn!("updates: no thread: {e}"); + } + }; + UpdateHost { unsupported, automatic, start: Box::new(start), events, now: Box::new(now), open_file: Box::new(open_file) } +} + +/// Download, verify and stage `release`, reporting each whole percent. +fn stage_release(client: &Client, release: &Release, here: &Install, send: &dyn Fn(Event)) -> Result { + let downloads = fieldcraft_io::recovery::data_dir().map(|d| d.join("updates")).unwrap_or_else(|| std::env::temp_dir().join("fieldcraft-updates")); + let last = Cell::new(u64::MAX); + let progress = |done: u64, total: u64| { + let step = done.saturating_mul(100).checked_div(total).unwrap_or(done / (1024 * 1024)); + if last.replace(step) != step { + send(Event::Progress(done, total)); + } + }; + let options = Options { key: fieldcraft_update::PUBLIC_KEY, downloads: &downloads, arch: std::env::consts::ARCH, progress: &progress }; + let staged = stage::stage(client, release, here, &options).map_err(|e| e.to_string()); + match &staged { + Ok(s) => log::info!("updates: FieldCraft {} verified and staged at {}", s.version, s.payload.display()), + Err(e) => log::warn!("updates: FieldCraft {} not staged: {e}", release.version), + } + staged +} + +/// Swap `staged` in and start it: the window has closed, its autosave is closed. +pub fn finish(staged: &Staged) { + match install::plan(staged, true) { + Ok(Finish::Swap(plan)) => match stage::apply(&plan) { + Ok(()) => log::info!("updates: FieldCraft {} installed ({})", staged.version, staged.install.describe()), + Err(e) => log::error!("updates: FieldCraft {} not installed, the previous version is kept: {e}", staged.version), + }, + Ok(Finish::Package { .. }) => {} + Err(e) => log::error!("updates: {e}"), + } +} + +/// Seconds since the Unix epoch. +fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// Open a downloaded package with the system's handler for it (Linux: the software installer). +fn open_file(path: &Path) -> Result<(), String> { + let opener = if cfg!(target_os = "macos") { + "open" + } else if cfg!(windows) { + "explorer" + } else { + "xdg-open" + }; + let mut child = std::process::Command::new(opener).arg(path).spawn().map_err(|e| format!("{opener}: {e}"))?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +}