UI: update notice, Help › Check for Updates and Reinstall, Settings › Updates (#231)

The window checks at start-up at most once a day when automatic checks are on (Settings and
the first run's question), never while that question is open and never without a host, so the
browser and tests make no network call. A newer release shows a quiet notice in the status bar
("FieldCraft X is available — What's new · Install · Skip"); Install downloads and verifies
through the host, then asks to restart, saving or asking about unsaved changes first, and the
host swaps the release in after the window closes. A .deb/.rpm install offers the system's
installer and the command instead. Skip this version is kept in the preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 01:48:30 -04:00
1 parent 69c1440cbe
commit 2e28a69c04
9 files changed
+664 -9

No files matched your search

Generated
+1
View File
@@ -1473,6 +1473,7 @@ dependencies = [
"fieldcraft-geometry",
"fieldcraft-model",
"fieldcraft-render",
"fieldcraft-update",
"log",
"png",
"resvg",
+2
View File
@@ -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 }
+14 -1
View File
@@ -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();
}
+14 -1
View File
@@ -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)"
)),
}
}
+22 -4
View File
@@ -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<fieldcraft_engine::crash::Summary>),
/// 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 => {}
}
}
+6
View File
@@ -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<privacy::UsageHost>,
/// The consent question was asked in this run.
consent_asked: bool,
/// Checking for and installing updates, when this run may ([`updates`]).
updates: Option<updates::State>,
/// The setup progress the browser marks, and what it was worked out for.
setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc<Vec<fieldcraft_engine::progress::StageState>>),
/// 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);
}
+1
View File
@@ -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),
+45 -3
View File
@@ -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<bool> {
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;
+559
View File
@@ -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<Check, String>,
},
/// Bytes downloaded so far, and the total (0 when not known).
Progress(u64, u64),
Staged(Result<Staged, String>),
}
/// 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<String>,
/// 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<dyn Fn(Job)>,
pub events: Receiver<Event>,
/// Seconds since the Unix epoch.
pub now: Box<dyn Fn() -> u64>,
/// Open a downloaded package with the system's installer.
pub open_file: Box<dyn Fn(&Path) -> 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<Release>,
/// What runs in the background, in words ("Checking for updates…").
busy: Option<String>,
/// 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<String>) {
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<Staged> {
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<Event> = 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<RefCell<Vec<Job>>>, mpsc::Sender<Event>) {
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));
}
}