diff --git a/Cargo.lock b/Cargo.lock index e90c2d9..aa643cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,6 +1473,7 @@ dependencies = [ "fieldcraft-geometry", "fieldcraft-model", "fieldcraft-render", + "fieldcraft-update", "log", "png", "resvg", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 9660412..a3daebe 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -15,6 +15,8 @@ fieldcraft-render = { workspace = true } fieldcraft-explicit = { workspace = true } fieldcraft-geometry = { workspace = true } fieldcraft-decks = { workspace = true } +# Releases and staged installs, for the update notice (the host does the downloading). +fieldcraft-update = { workspace = true } egui = { workspace = true } craft-ui = { workspace = true } egui-wgpu = { workspace = true } diff --git a/crates/ui-egui/src/appbar.rs b/crates/ui-egui/src/appbar.rs index 288845e..544fbee 100644 --- a/crates/ui-egui/src/appbar.rs +++ b/crates/ui-egui/src/appbar.rs @@ -340,10 +340,23 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) { .button("Report a Bug…") .on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it") .clicked(); - let settings = ui.button("Settings…").on_hover_text("Privacy: anonymous usage sharing").clicked(); + let (mut check, mut reinstall) = (false, false); + if app.updates_available() { + check = + ui.button("Check for Updates").on_hover_text("Ask GitHub Releases whether a newer FieldCraft is published (app.update.check)").clicked(); + reinstall = ui + .button("Reinstall FieldCraft…") + .on_hover_text("Download this version again, verify it and swap it in, to repair a damaged install") + .clicked(); + } + let settings = ui.button("Settings…").on_hover_text("Privacy and updates").clicked(); ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim)); if report { app.report_bug(None); + } else if check { + app.check_for_updates(); + } else if reinstall { + app.reinstall(); } else if settings { app.open_settings(); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 124caa4..bc727ba 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -210,6 +210,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> crate::files::Prompt::Unsaved(next) => json!({"unsaved": match next { crate::files::Replace::File(p) => p.clone(), crate::files::Replace::Sample(n) => format!("sample {n}"), + crate::files::Replace::Update => "update".to_string(), }}), crate::files::Prompt::Recover => json!("recover"), crate::files::Prompt::Samples => json!("samples"), @@ -220,8 +221,10 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> crate::files::Prompt::ReportBug => json!({"report_bug": app.report.as_ref().and_then(|f| f.drafted_json())}), crate::files::Prompt::Consent => json!({"consent": crate::privacy::QUESTION}), crate::files::Prompt::Settings => json!("settings"), + crate::files::Prompt::Update => json!({"update": app.update_json()}), }), "privacy": privacy_json(app), + "updates": app.update_json(), "recent": app.recent.paths, "version": crate::VERSION, })), @@ -411,6 +414,16 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> app.answer_consent(a == "share"); ok(privacy_json(app)) } + (crate::files::Prompt::Settings, Some("set")) if p.get("update_check").is_some() || p.get("update_skip").is_some() => { + let automatic = p.get("update_check").and_then(Value::as_bool).unwrap_or(app.prefs().update_check); + let skip = p.get("update_skip").and_then(Value::as_str).map(str::to_string); + app.set_update_prefs(automatic, skip); + ok(app.update_json()) + } + (crate::files::Prompt::Update, Some(a @ ("restart" | "later"))) => { + app.answer_update(a == "restart"); + ok(app.update_json()) + } (crate::files::Prompt::Settings, Some("set")) => { if !app.usage_available() { return err("this run cannot share usage (no analytics key, or FIELDCRAFT_TELEMETRY=0, CI or automation)"); @@ -437,7 +450,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> None => err("missing `id` (from file.recovery.list)"), }, (_, a) => err(format!( - "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close; consent: share, decline; settings: set with usage_sharing/usage_automation, close)" + "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close; consent: share, decline; settings: set with usage_sharing/usage_automation or update_check/update_skip, close; update: restart, later)" )), } } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 0c437d7..39ee0db 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -214,6 +214,12 @@ pub struct Prefs { pub usage_automation: bool, /// The random id usage is sent under, made when the user first agrees; empty before. pub install_id: String, + /// Check for updates at start-up, at most once a day ([`crate::updates`]). + pub update_check: bool, + /// When the last automatic check was made (seconds since the Unix epoch; 0: never). + pub update_checked_at: u64, + /// The version the user skipped (the automatic check does not offer it); empty for none. + pub update_skip: String, } impl Default for Prefs { @@ -224,6 +230,9 @@ impl Default for Prefs { usage_sharing: None, usage_automation: true, install_id: String::new(), + update_check: true, + update_checked_at: 0, + update_skip: String::new(), } } } @@ -279,6 +288,8 @@ pub enum Replace { File(String), /// A sample (`file.sample`). Sample(&'static str), + /// Restarting to finish installing an update ([`crate::updates`]). + Update, } impl Replace { @@ -286,6 +297,7 @@ impl Replace { match self { Replace::File(p) => file_name(p), Replace::Sample(n) => fieldcraft_engine::sample::find(n).map_or_else(|| (*n).to_string(), |s| format!("the {} sample", s.label)), + Replace::Update => "the update".into(), } } } @@ -310,8 +322,10 @@ pub enum Prompt { Delete(crate::menu::DeleteAsk), /// The first run asks once whether to share anonymous usage ([`crate::privacy`]). Consent, - /// Help › Settings: the privacy choices ([`crate::privacy`]). + /// Help › Settings: the privacy and update choices ([`crate::privacy`]). Settings, + /// An update is downloaded and verified: restart to finish it, or later ([`crate::updates`]). + Update, /// The last run crashed (its new crash reports, newest first): report it, or not now. Crashed(Vec), /// Help › Report a Bug ([`crate::report`]; the form is [`ModelApp::report`]). @@ -485,7 +499,7 @@ impl ModelApp { } /// Whether replacing the model would lose work: unsaved changes made by commands. - fn has_unsaved_work(&self) -> bool { + pub(crate) fn has_unsaved_work(&self) -> bool { self.session.is_dirty() && !self.session.journal.is_empty() } @@ -765,6 +779,7 @@ impl ModelApp { } } Replace::Sample(name) => self.load_sample(name), + Replace::Update => self.close_to_update(), } } @@ -879,7 +894,10 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { Some(Prompt::Unsaved(next)) => { let mut answer = None; crate::frame::window(ctx, "Unsaved changes", crate::frame::Width::Normal).id(egui::Id::new("fc_unsaved")).show(ctx, |ui| { - ui.label(format!("Save the changes to {} before opening {}?", app.model_name(), next.name())); + ui.label(match next { + Replace::Update => format!("Save the changes to {} before restarting to update?", app.model_name()), + _ => format!("Save the changes to {} before opening {}?", app.model_name(), next.name()), + }); ui.label(egui::RichText::new("Without saving, they are lost.").color(t.text_dim)); ui.add_space(6.0); ui.horizontal(|ui| { @@ -1044,7 +1062,7 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { } } // Drawn by `crate::report::show` and `crate::privacy::show`. - Some(Prompt::Crashed(_) | Prompt::ReportBug | Prompt::Consent | Prompt::Settings) | None => {} + Some(Prompt::Crashed(_) | Prompt::ReportBug | Prompt::Consent | Prompt::Settings | Prompt::Update) | None => {} } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 24484b9..1484650 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,7 @@ pub mod theme; pub mod timeline; pub mod titlebar; pub mod toolbar; +pub mod updates; pub mod viewport; use std::hash::{Hash, Hasher}; @@ -388,6 +389,8 @@ pub struct ModelApp { usage: Option, /// The consent question was asked in this run. consent_asked: bool, + /// Checking for and installing updates, when this run may ([`updates`]). + updates: Option, /// The setup progress the browser marks, and what it was worked out for. setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc>), /// The file opening in the background, and files waiting to open after it. @@ -477,6 +480,7 @@ impl ModelApp { prefs: files::Prefs::default(), usage: None, consent_asked: false, + updates: None, setup: (None, std::sync::Arc::default()), loading: None, queued: std::collections::VecDeque::new(), @@ -1350,6 +1354,7 @@ impl ModelApp { ctx.request_repaint_after(std::time::Duration::from_millis(50)); } self.dropped_files(ctx); + self.poll_updates(ctx); self.shortcuts(ctx); self.cam.fov = if self.ui.perspective { PERSPECTIVE_FOV } else { 0.0 }; if !self.synthetic.is_empty() { @@ -1477,6 +1482,7 @@ impl ModelApp { report::show(self, ui.ctx()); self.ask_consent(); privacy::show(self, ui.ctx()); + updates::show(self, ui.ctx()); if self.custom_titlebar { titlebar::resize_zones(ui); } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 2d6aeb7..30acef1 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -156,6 +156,7 @@ pub fn command_line(app: &mut ModelApp, ui: &mut egui::Ui) { ui.separator(); ui.label(RichText::new(format!("{nodes} nodes {elements} elements")).size(11.5).color(t.text_dim)); ui.separator(); + crate::updates::notice(app, ui); let (text, color) = match &app.status { Some(s) => (s.text.as_str(), if s.error { t.error } else { t.text }), None => ("Ready", t.text_dim), diff --git a/crates/ui-egui/src/privacy.rs b/crates/ui-egui/src/privacy.rs index 381ae28..8706981 100644 --- a/crates/ui-egui/src/privacy.rs +++ b/crates/ui-egui/src/privacy.rs @@ -126,7 +126,7 @@ impl ModelApp { r } - fn open_link(&mut self, ctx: &egui::Context, url: &str) { + pub(crate) fn open_link(&mut self, ctx: &egui::Context, url: &str) { match &self.services.open_url { Some(open) => { if let Err(e) = open(url) { @@ -143,7 +143,7 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { let t = crate::theme::Tokens::get(app.ui.dark); match app.prompt { Some(Prompt::Consent) => { - let (mut share, mut decline, mut link) = (false, false, false); + let (mut share, mut decline, mut link, mut updates) = (false, false, false, None); crate::frame::window(ctx, "Help Improve FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| { ui.label(QUESTION); ui.add_space(4.0); @@ -151,6 +151,10 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { ui.add_space(2.0); ui.label(RichText::new("You can change this any time in Help › Settings.").color(t.text_dim).size(11.0)); link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked(); + if app.updates_available() { + ui.add_space(4.0); + updates = update_check(ui, &t, app.prefs.update_check); + } ui.add_space(6.0); ui.horizontal(|ui| { share = ui.button("Share Anonymous Usage").clicked(); @@ -160,6 +164,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { if link { app.open_link(ctx, PRIVACY_URL); } + if let Some(on) = updates { + app.set_update_prefs(on, None); + } if share { app.answer_consent(true); } else if decline || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { @@ -169,8 +176,20 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { Some(Prompt::Settings) => { let available = app.usage_available(); let (mut sharing, mut automation) = (app.prefs.usage_sharing == Some(true), app.prefs.usage_automation); - let (mut close, mut link) = (false, false); + let (mut close, mut link, mut updates, mut unskip, mut check) = (false, false, None, false, false); crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| { + if app.updates_available() { + crate::frame::menu_heading(ui, &t, "UPDATES"); + updates = update_check(ui, &t, app.prefs.update_check); + ui.horizontal(|ui| { + check = ui.button("Check Now").on_hover_text("Help › Check for Updates").clicked(); + if !app.prefs.update_skip.is_empty() { + ui.label(RichText::new(format!("Skipping FieldCraft {}", app.prefs.update_skip)).color(t.text_dim).size(11.0)); + unskip = ui.small_button("Offer It Again").clicked(); + } + }); + ui.add_space(6.0); + } crate::frame::menu_heading(ui, &t, "PRIVACY"); ui.add_enabled(available, egui::Checkbox::new(&mut sharing, "Share anonymous usage")).on_hover_text(QUESTION); ui.indent("fc_usage_automation", |ui| { @@ -199,6 +218,12 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { if available && (sharing != (app.prefs.usage_sharing == Some(true)) || automation != app.prefs.usage_automation) { app.set_usage(sharing, automation); } + if updates.is_some() || unskip { + app.set_update_prefs(updates.unwrap_or(app.prefs.update_check), unskip.then(String::new)); + } + if check { + app.check_for_updates(); + } if link { app.open_link(ctx, PRIVACY_URL); } @@ -210,6 +235,23 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { } } +/// The automatic update check's checkbox; the new value when it was clicked. +fn update_check(ui: &mut egui::Ui, t: &crate::theme::Tokens, on: bool) -> Option { + let mut checked = on; + let changed = ui + .checkbox(&mut checked, "Check for updates automatically") + .on_hover_text( + "At start-up, at most once a day, FieldCraft asks GitHub Releases for the latest version. Nothing installs until you choose Install.", + ) + .changed(); + ui.label( + RichText::new("The check sends only the request (your IP address and the FieldCraft version reach GitHub); off means no check.") + .color(t.text_dim) + .size(11.0), + ); + changed.then_some(checked) +} + #[cfg(test)] mod tests { use fieldcraft_engine::Session; diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs new file mode 100644 index 0000000..e60b8be --- /dev/null +++ b/crates/ui-egui/src/updates.rs @@ -0,0 +1,559 @@ +//! In-app updates (docs/updates.md): the check at start-up (at most once a day, when the user +//! leaves it on) and from Help › Check for Updates, the quiet notice in the status bar +//! ("FieldCraft 0.5.9 is available — What's new · Install"), Help › Reinstall FieldCraft, and +//! the question that finishes an install. The host does the network and file work +//! ([`UpdateHost`], the desktop app's `update.rs`) on its own threads; the window only asks and +//! shows. Without a host (the browser, tests) nothing here runs and no network call is made. +//! +//! Nothing installs without the user's click: Install downloads and verifies the release, then +//! asks to restart. Restarting saves or asks about unsaved changes first, as opening another +//! model does; the swap happens after the window has closed. + +use std::path::Path; +use std::sync::mpsc::Receiver; + +use egui::RichText; +use fieldcraft_update::{Check, Finish, Release, Staged}; + +use crate::ModelApp; +use crate::files::{Prompt, Replace}; + +/// What the host is asked to do, on its own thread. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Job { + /// Ask for the latest release (`manual`: the user asked, so the answer is shown even when + /// there is nothing new or the check fails). + Check { manual: bool }, + /// Download, verify and stage this release. + Stage(Release), + /// Download, verify and stage the running version again (Help › Reinstall FieldCraft). + Reinstall, +} + +/// What the host reports back. +#[derive(Clone, Debug)] +pub enum Event { + Checked { + manual: bool, + result: Result, + }, + /// Bytes downloaded so far, and the total (0 when not known). + Progress(u64, u64), + Staged(Result), +} + +/// What the desktop app provides for updates. +pub struct UpdateHost { + /// Why this copy cannot install updates itself (a development build, an unknown layout); + /// `None` when it can. It still checks. + pub unsupported: Option, + /// Automatic checks may run in this run (not under CI or automation, unless a test server + /// is set). Help › Check for Updates works either way. + pub automatic: bool, + /// Start a job; its events come back on `events`. + pub start: Box, + pub events: Receiver, + /// Seconds since the Unix epoch. + pub now: Box u64>, + /// Open a downloaded package with the system's installer. + pub open_file: Box Result<(), String>>, +} + +/// The window's update state. +pub(crate) struct State { + host: UpdateHost, + /// A newer release the notice offers (not skipped, or asked for by hand). + available: Option, + /// What runs in the background, in words ("Checking for updates…"). + busy: Option, + /// The verified release, ready to finish, and how. + staged: Option<(Staged, Finish)>, + /// The automatic check of this run has been decided. + auto_done: bool, + /// Close the window to finish the install (sent with the next frame's context). + close: bool, +} + +impl ModelApp { + /// Updates are available in this run (the desktop app). + pub fn with_updates(mut self, host: UpdateHost) -> Self { + self.updates = Some(State { host, available: None, busy: None, staged: None, auto_done: false, close: false }); + self + } + + /// Whether this run checks for updates at all. + pub fn updates_available(&self) -> bool { + self.updates.is_some() + } + + /// The automatic check, once per run, when it is on, due (a day since the last), allowed in + /// this run, and the first run's question has been answered. Off means no network call. + fn auto_check(&mut self) { + let Some(u) = &mut self.updates else { return }; + if u.auto_done || self.prompt == Some(Prompt::Consent) { + return; + } + u.auto_done = true; + let now = (u.host.now)(); + if !u.host.automatic || !fieldcraft_update::due(self.prefs.update_check, self.prefs.update_checked_at, now) { + return; + } + (u.host.start)(Job::Check { manual: false }); + self.prefs.update_checked_at = now; + self.prefs.save(self.services.prefs.as_ref()); + } + + /// Help › Check for Updates. + pub fn check_for_updates(&mut self) { + self.ui.menu = None; + let Some(u) = &mut self.updates else { + self.set_status("This build does not check for updates", true); + return; + }; + u.busy = Some("Checking for updates…".into()); + (u.host.start)(Job::Check { manual: true }); + self.set_status("Checking for updates…", false); + } + + /// Help › Reinstall FieldCraft: download, verify and swap in the running version again. + pub fn reinstall(&mut self) { + self.ui.menu = None; + let Some(u) = &mut self.updates else { return }; + if let Some(why) = u.host.unsupported.clone() { + self.set_status(format!("Cannot reinstall: {why}"), true); + return; + } + u.busy = Some(format!("Downloading FieldCraft {}…", crate::VERSION)); + (u.host.start)(Job::Reinstall); + self.set_status(format!("Downloading FieldCraft {} to reinstall it…", crate::VERSION), false); + } + + /// The notice's Install: download and verify, then ask to restart. A copy that cannot + /// install itself, or a release that is not signed, opens the release page instead. + pub fn install_update(&mut self, ctx: &egui::Context) { + let Some(u) = &mut self.updates else { return }; + let Some(release) = u.available.clone() else { return }; + let blocked = u.host.unsupported.clone().or_else(|| (!release.signed()).then(|| format!("FieldCraft {} is not signed", release.version))); + if let Some(why) = blocked { + self.set_status(format!("{why}: opening the download page"), true); + self.open_link(ctx, &release.url); + return; + } + u.busy = Some(format!("Downloading FieldCraft {}…", release.version)); + (u.host.start)(Job::Stage(release.clone())); + self.set_status(format!("Downloading FieldCraft {}…", release.version), false); + } + + /// Skip the offered version: the automatic check no longer offers it. + pub fn skip_update(&mut self) { + let Some(u) = &mut self.updates else { return }; + if let Some(r) = u.available.take() { + self.prefs.update_skip = r.version.to_string(); + self.prefs.save(self.services.prefs.as_ref()); + } + } + + /// Settings › Updates: automatic checks on or off, and the skipped version. + pub fn set_update_prefs(&mut self, automatic: bool, skip: Option) { + self.prefs.update_check = automatic; + if let Some(skip) = skip { + self.prefs.update_skip = skip; + } + self.prefs.save(self.services.prefs.as_ref()); + } + + /// The release the notice offers. + pub fn update_offered(&self) -> Option<&Release> { + self.updates.as_ref()?.available.as_ref() + } + + /// Restart to finish the install (or, `false`, finish it when the window closes). Unsaved + /// changes are saved or asked about first, as when opening another model. + pub fn answer_update(&mut self, restart: bool) { + if self.prompt == Some(Prompt::Update) { + self.prompt = None; + } + if !restart { + if let Some(u) = &self.updates + && let Some((s, Finish::Swap(_))) = &u.staged + { + let v = s.version.to_string(); + self.set_status(format!("FieldCraft {v} installs when you quit"), false); + } + return; + } + if self.has_unsaved_work() { + self.prompt = Some(Prompt::Unsaved(Replace::Update)); + return; + } + self.close_to_update(); + } + + /// Close the window; the host swaps the staged release in after it has closed. + pub(crate) fn close_to_update(&mut self) { + if let Some(u) = &mut self.updates { + u.close = true; + } + } + + /// The verified release to swap in once the window has closed (the desktop app's exit). + pub fn take_staged_update(&mut self) -> Option { + let u = self.updates.as_mut()?; + match &u.staged { + Some((_, Finish::Swap(_))) => u.staged.take().map(|(s, _)| s), + _ => None, + } + } + + /// Take the host's reports, run the automatic check when due, and close the window when + /// an install is waiting on it. + pub(crate) fn poll_updates(&mut self, ctx: &egui::Context) { + self.auto_check(); + let Some(u) = &mut self.updates else { return }; + if std::mem::take(&mut u.close) { + self.quit_requested = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + } + let events: Vec = u.host.events.try_iter().collect(); + if u.busy.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + for e in events { + self.update_event(e); + } + } + + fn update_event(&mut self, e: Event) { + let Some(u) = &mut self.updates else { return }; + match e { + Event::Checked { manual, result } => { + u.busy = None; + match result { + Ok(check) if check.newer() && (manual || self.prefs.update_skip != check.latest.version.to_string()) => { + let v = check.latest.version.to_string(); + u.available = Some(check.latest); + if manual { + self.set_status(format!("FieldCraft {v} is available"), false); + } + } + Ok(check) => { + // Nothing newer (or skipped): a notice from an earlier check goes. + u.available = None; + if manual { + self.set_status(format!("FieldCraft {} is up to date", check.current), false); + } + } + Err(e) if manual => self.set_status(format!("Could not check for updates: {e}"), true), + // An automatic check fails silently (logged). + Err(e) => log::info!("update check: {e}"), + } + } + Event::Progress(done, total) => { + if let Some(b) = &mut u.busy { + let head = b.split(" (").next().unwrap_or_default().to_string(); + *b = match done.saturating_mul(100).checked_div(total) { + Some(percent) => format!("{head} ({percent}%)"), + None => format!("{head} ({} MB)", done / (1024 * 1024)), + }; + } + } + Event::Staged(Ok(staged)) => { + u.busy = None; + match fieldcraft_update::install::plan(&staged, true) { + Ok(finish) => { + u.staged = Some((staged, finish)); + self.status = None; + self.prompt = Some(Prompt::Update); + } + Err(e) => self.set_status(format!("Cannot install: {e}"), true), + } + } + Event::Staged(Err(e)) => { + u.busy = None; + self.set_status(format!("Update not installed: {e}"), true); + } + } + } + + /// For the control channel's `ui.inspect`. + pub fn update_json(&self) -> serde_json::Value { + let Some(u) = &self.updates else { return serde_json::Value::Null }; + serde_json::json!({ + "automatic": self.prefs.update_check, + "checked_at": self.prefs.update_checked_at, + "skip": self.prefs.update_skip, + "available": u.available.as_ref().map(|r| r.version.to_string()), + "busy": u.busy, + "staged": u.staged.as_ref().map(|(s, _)| s.version.to_string()), + "unsupported": u.host.unsupported, + }) + } +} + +/// The status bar's notice: an update offered, or one downloading. +pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(app.ui.dark); + let Some(u) = &app.updates else { return }; + if let Some(busy) = &u.busy { + ui.label(RichText::new(busy).size(11.5).color(t.text_dim)); + ui.separator(); + return; + } + let Some(release) = u.available.clone() else { return }; + // Right to left: the links, then the sentence. + let small = |s: &str| RichText::new(s).size(11.5); + let skip = ui.add(egui::Link::new(small("Skip"))).on_hover_text("Don't offer this version again (Help › Settings)").clicked(); + ui.label(small("·").color(t.text_dim)); + let install = ui.add(egui::Link::new(small("Install"))).on_hover_text("Download, verify and install it; you choose when to restart").clicked(); + ui.label(small("·").color(t.text_dim)); + let notes = ui.add(egui::Link::new(small("What's new"))).on_hover_text(release.url.as_str()).clicked(); + ui.label(small(&format!("FieldCraft {} is available —", release.version)).color(t.text)); + ui.separator(); + if notes { + app.open_link(ui.ctx(), &release.url); + } + if install { + app.install_update(ui.ctx()); + } + if skip { + app.skip_update(); + } +} + +/// The question that finishes an install ([`Prompt::Update`]). +pub fn show(app: &mut ModelApp, ctx: &egui::Context) { + if app.prompt != Some(Prompt::Update) { + return; + } + let t = crate::theme::Tokens::get(app.ui.dark); + let Some((staged, finish)) = app.updates.as_ref().and_then(|u| u.staged.clone()) else { + app.prompt = None; + return; + }; + let reinstall = staged.version.to_string() == crate::VERSION; + let (mut restart, mut later, mut open) = (false, false, false); + crate::frame::window(ctx, "Update FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_update")).show(ctx, |ui| match &finish { + Finish::Swap(_) => { + ui.label(if reinstall { + format!("FieldCraft {} was downloaded again and verified. Restart to reinstall it?", staged.version) + } else { + format!("FieldCraft {} is downloaded and verified. Restart now to finish installing it?", staged.version) + }); + ui.label( + RichText::new(format!("It replaces {}. Unsaved changes are saved or asked about first.", staged.install.describe())) + .color(t.text_dim) + .size(11.0), + ); + ui.add_space(6.0); + ui.horizontal(|ui| { + restart = ui.button("Restart Now").clicked(); + later = ui.button("Later").on_hover_text("It installs when you quit FieldCraft").clicked(); + }); + } + Finish::Package { file, command } => { + ui.label(format!("FieldCraft {} is downloaded and verified:", staged.version)); + ui.label(RichText::new(file.display().to_string()).monospace().size(11.0)); + ui.label("Install it with your system's package manager:"); + let mut text = command.clone(); + ui.add(egui::TextEdit::singleline(&mut text).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY)); + ui.add_space(6.0); + ui.horizontal(|ui| { + open = ui.button("Open with Installer").clicked(); + later = ui.button("Close").clicked(); + }); + } + }); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + later = true; + } + if open && let Finish::Package { file, .. } = &finish { + let opened = app.updates.as_ref().map(|u| (u.host.open_file)(file)); + if let Some(Err(e)) = opened { + app.set_status(format!("Could not open the installer: {e}"), true); + } + app.prompt = None; + } + if restart { + app.answer_update(true); + } else if later { + app.answer_update(false); + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::mpsc; + + use fieldcraft_engine::Session; + use fieldcraft_update::{Install, Version}; + use serde_json::json; + + use super::*; + use crate::files::{PrefsStore, Services}; + + const DAY: u64 = fieldcraft_update::CHECK_INTERVAL; + + fn release(v: &str) -> Release { + Release::from_github(&json!({"tag_name": format!("v{v}"), "html_url": "https://example.invalid/r", "assets": [ + {"name": "SHA256SUMS.txt", "browser_download_url": "https://example.invalid/s"}, + {"name": "SHA256SUMS.txt.minisig", "browser_download_url": "https://example.invalid/m"}, + ]})) + .unwrap() + } + + /// An app with a host that records the jobs it is given; the sender plays the host's thread. + fn app(prefs: &Path, now: u64, automatic: bool) -> (ModelApp, Rc>>, mpsc::Sender) { + let jobs = Rc::new(RefCell::new(Vec::new())); + let (tx, rx) = mpsc::channel(); + let j = jobs.clone(); + let host = UpdateHost { + unsupported: None, + automatic, + start: Box::new(move |job| j.borrow_mut().push(job)), + events: rx, + now: Box::new(move || now), + open_file: Box::new(|_| Ok(())), + }; + let a = ModelApp::new(Session::default()) + .with_services(Services { prefs: Some(PrefsStore::file(prefs.to_path_buf())), ..Default::default() }) + .with_updates(host); + (a, jobs, tx) + } + + fn temp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("fieldcraft-updates-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("prefs.json") + } + + /// At most one automatic check a day, none when the user turned them off or the run does + /// not allow them, and none while the first run's question is open. + #[test] + fn automatic_checks_are_daily_and_none_when_off() { + let ctx = egui::Context::default(); + let file = temp("auto"); + let (mut a, jobs, _tx) = app(&file, 10 * DAY, true); + a.poll_updates(&ctx); + a.poll_updates(&ctx); + assert_eq!(*jobs.borrow(), [Job::Check { manual: false }], "once per run"); + // The next run on the same day does not check; a day later it does. + let (mut a, jobs, _tx) = app(&file, 10 * DAY + 3600, true); + a.poll_updates(&ctx); + assert!(jobs.borrow().is_empty()); + let (mut a, jobs, _tx) = app(&file, 11 * DAY, true); + a.poll_updates(&ctx); + assert_eq!(jobs.borrow().len(), 1); + + // Off in Settings: no network call, ever; Check for Updates still asks. + a.set_update_prefs(false, None); + let (mut a, jobs, _tx) = app(&file, 30 * DAY, true); + a.poll_updates(&ctx); + assert!(jobs.borrow().is_empty(), "off means no check"); + a.check_for_updates(); + assert_eq!(*jobs.borrow(), [Job::Check { manual: true }]); + + // A run that may not check automatically (CI, automation) does not. + let (mut a, jobs, _tx) = app(&temp("ci"), 30 * DAY, false); + a.poll_updates(&ctx); + assert!(jobs.borrow().is_empty()); + + // Not while the first run's question is open; after it is answered. + let (mut a, jobs, _tx) = app(&temp("consent"), 30 * DAY, true); + a.prompt = Some(Prompt::Consent); + a.poll_updates(&ctx); + assert!(jobs.borrow().is_empty()); + a.prompt = None; + a.poll_updates(&ctx); + assert_eq!(jobs.borrow().len(), 1); + // Without a host (the browser, tests) there is nothing to call. + let mut plain = ModelApp::new(Session::default()); + plain.poll_updates(&ctx); + assert!(!plain.updates_available()); + } + + /// A newer release is offered (unless skipped, when the check was automatic); a manual + /// check says when FieldCraft is up to date or why the check failed. + #[test] + fn checks_offer_newer_releases_and_skip_skipped_ones() { + let ctx = egui::Context::default(); + let (mut a, _jobs, tx) = app(&temp("offer"), DAY, false); + let current = Version::parse(crate::VERSION).unwrap(); + tx.send(Event::Checked { manual: false, result: Ok(Check { current: current.clone(), latest: release("99.0.0") }) }).unwrap(); + a.poll_updates(&ctx); + assert_eq!(a.update_offered().map(|r| r.version.to_string()), Some("99.0.0".into())); + a.skip_update(); + assert_eq!((a.update_offered(), a.prefs.update_skip.as_str()), (None, "99.0.0")); + tx.send(Event::Checked { manual: false, result: Ok(Check { current: current.clone(), latest: release("99.0.0") }) }).unwrap(); + a.poll_updates(&ctx); + assert_eq!(a.update_offered(), None, "skipped"); + tx.send(Event::Checked { manual: true, result: Ok(Check { current: current.clone(), latest: release("99.0.0") }) }).unwrap(); + a.poll_updates(&ctx); + assert!(a.update_offered().is_some(), "asked for by hand"); + + tx.send(Event::Checked { manual: true, result: Ok(Check { current: current.clone(), latest: release(crate::VERSION) }) }).unwrap(); + a.poll_updates(&ctx); + assert_eq!(a.status.as_ref().map(|s| s.text.clone()), Some(format!("FieldCraft {} is up to date", crate::VERSION))); + assert_eq!(a.update_offered(), None, "the earlier offer goes"); + tx.send(Event::Checked { manual: false, result: Err("offline".into()) }).unwrap(); + a.status = None; + a.poll_updates(&ctx); + assert!(a.status.is_none(), "an automatic check fails silently"); + tx.send(Event::Checked { manual: true, result: Err("offline".into()) }).unwrap(); + a.poll_updates(&ctx); + assert!(a.status.as_ref().is_some_and(|s| s.error && s.text.contains("offline"))); + } + + /// Install downloads first; restarting asks about unsaved changes, then closes the window + /// and hands the staged release to the host's exit. + #[test] + fn installing_asks_to_restart_and_saves_first() { + let ctx = egui::Context::default(); + let dir = temp("install"); + let (mut a, jobs, tx) = app(&dir, DAY, false); + let current = Version::parse(crate::VERSION).unwrap(); + tx.send(Event::Checked { manual: true, result: Ok(Check { current, latest: release("99.0.0") }) }).unwrap(); + a.poll_updates(&ctx); + a.install_update(&ctx); + assert_eq!(jobs.borrow().last(), Some(&Job::Stage(release("99.0.0")))); + let staged = Staged { + install: Install::TarGz { root: "/opt/fieldcraft".into() }, + version: Version::parse("99.0.0").unwrap(), + dir: "/opt/.fieldcraft-update-99.0.0".into(), + payload: "/opt/.fieldcraft-update-99.0.0/new/x".into(), + entries: vec![], + }; + tx.send(Event::Staged(Ok(staged.clone()))).unwrap(); + a.poll_updates(&ctx); + assert_eq!(a.prompt, Some(Prompt::Update)); + + // Unsaved work: asked first; Cancel keeps the window open. + let _ = a.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})); + a.answer_update(true); + assert_eq!(a.prompt, Some(Prompt::Unsaved(Replace::Update))); + a.answer_unsaved(crate::files::Answer::Cancel); + a.poll_updates(&ctx); + assert!(!a.quit_requested); + a.prompt = Some(Prompt::Update); + a.answer_update(true); + a.answer_unsaved(crate::files::Answer::Discard); + a.poll_updates(&ctx); + assert!(a.quit_requested, "the window closes"); + assert_eq!(a.take_staged_update(), Some(staged)); + + // A copy that cannot install itself opens the release page instead of downloading. + let (mut a, jobs, tx) = app(&temp("dev"), DAY, false); + a.updates.as_mut().unwrap().host.unsupported = Some("a development build".into()); + tx.send(Event::Checked { manual: true, result: Ok(Check { current: Version::parse(crate::VERSION).unwrap(), latest: release("99.0.0") }) }) + .unwrap(); + a.poll_updates(&ctx); + a.install_update(&ctx); + assert!(jobs.borrow().iter().all(|j| !matches!(j, Job::Stage(_)))); + assert!(a.status.as_ref().is_some_and(|s| s.text.contains("development build"))); + a.reinstall(); + assert!(jobs.borrow().iter().all(|j| *j != Job::Reinstall)); + } +}