forked from 1b-agent/fieldcraft-upstream
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:
1 parent
69c1440cbe
commit
2e28a69c04
9 files changed
+664
-9
No files matched your search
Generated
+1
@@ -1473,6 +1473,7 @@ dependencies = [
|
||||
"fieldcraft-geometry",
|
||||
"fieldcraft-model",
|
||||
"fieldcraft-render",
|
||||
"fieldcraft-update",
|
||||
"log",
|
||||
"png",
|
||||
"resvg",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user