From 85a8652de11bfbdd873358bb3376950fb01f66ab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 01:16:00 -0400 Subject: [PATCH] =?UTF-8?q?Issue=20#28:=20Help=20=E2=96=B8=20Check=20for?= =?UTF-8?q?=20updates,=20with=20an=20opt-in=20check=20at=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrintCraft asks GitHub for the latest release and, when it is newer, offers its release page to download from. Nothing is downloaded or installed automatically (installing in place needs signed release artifacts). - ui-egui: updates module (version comparison, background check, dialog); no network code: the desktop app supplies the source, and without one (web build, tests) the command opens the releases page. - desktop: one HTTPS request (ureq over rustls with the OS's trusted roots, 10 s timeout, 1 MiB cap). Only a page under the releases list is offered. No bundled root list: webpki-roots/webpki-root-certs are CDLA-licensed. - The check at start is opt-in and silent unless there is a newer version. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 164 ++++++++++++++++++++++++- apps/printcraft/Cargo.toml | 4 + apps/printcraft/src/main.rs | 3 + apps/printcraft/src/updates.rs | 72 +++++++++++ crates/engine/src/commands.rs | 1 + crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/lib.rs | 14 +++ crates/ui-egui/src/updates.rs | 205 ++++++++++++++++++++++++++++++++ crates/ui-egui/tests/updates.rs | 101 ++++++++++++++++ parity/acrobat-features.toml | 12 ++ 11 files changed, 574 insertions(+), 4 deletions(-) create mode 100644 apps/printcraft/src/updates.rs create mode 100644 crates/ui-egui/src/updates.rs create mode 100644 crates/ui-egui/tests/updates.rs diff --git a/Cargo.lock b/Cargo.lock index 7a0bfb32..3085b1a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,6 +480,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -1932,6 +1938,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2287,6 +2304,22 @@ dependencies = [ "digest", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -3296,6 +3329,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3735,7 +3774,9 @@ dependencies = [ "eframe", "egui", "printcraft-ui-egui", + "rustls-native-certs", "serde_json", + "ureq", "winresource", ] @@ -3763,7 +3804,7 @@ dependencies = [ name = "printcraft-automation" version = "0.1.1" dependencies = [ - "base64", + "base64 0.22.1", "image", "png 0.18.1", "printcraft-engine", @@ -3776,7 +3817,7 @@ dependencies = [ name = "printcraft-cli" version = "0.1.1" dependencies = [ - "base64", + "base64 0.22.1", "printcraft-automation", "printcraft-cos", "printcraft-engine", @@ -4037,7 +4078,7 @@ name = "printcraft-ui-egui" version = "0.1.1" dependencies = [ "arboard", - "base64", + "base64 0.22.1", "eframe", "egui", "egui_extras", @@ -4417,6 +4458,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.2" @@ -4611,6 +4666,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4644,6 +4746,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5453,6 +5564,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5471,7 +5615,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "imagesize", @@ -5493,6 +5637,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5609,6 +5759,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" diff --git a/apps/printcraft/Cargo.toml b/apps/printcraft/Cargo.toml index bbb31a76..0a07903b 100644 --- a/apps/printcraft/Cargo.toml +++ b/apps/printcraft/Cargo.toml @@ -9,6 +9,10 @@ license.workspace = true workspace = true [dependencies] +# Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's +# trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed). +ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } +rustls-native-certs = "0.8" printcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] } egui = { workspace = true } diff --git a/apps/printcraft/src/main.rs b/apps/printcraft/src/main.rs index 475ae55f..798c2bc6 100644 --- a/apps/printcraft/src/main.rs +++ b/apps/printcraft/src/main.rs @@ -15,6 +15,8 @@ use printcraft_ui_egui::PrintCraftApp; +mod updates; + /// Freedesktop app id: the `.desktop` file name and the hicolor icon name. const APP_ID: &str = "ai.storyteller.printcraft"; @@ -82,6 +84,7 @@ fn main() -> eframe::Result { app.restore(&json); } app.integrated_titlebar = integrated; + app.update_source = Some(std::sync::Arc::new(updates::latest_release)); app.keychain_ids = cfg!(target_os = "macos"); if let Some(file) = &control_file { let client = app.attach_control(&cc.egui_ctx); diff --git a/apps/printcraft/src/updates.rs b/apps/printcraft/src/updates.rs new file mode 100644 index 00000000..708065f4 --- /dev/null +++ b/apps/printcraft/src/updates.rs @@ -0,0 +1,72 @@ +//! Asks GitHub for the latest PrintCraft release (Help ▸ Check for updates, issue #28). + +use std::time::Duration; + +use printcraft_ui_egui::updates::{RELEASES_PAGE, Release}; + +const LATEST: &str = "https://api.github.com/repos/storytold/printcraft/releases/latest"; + +/// The latest release. The answer is untrusted: its size is capped, and only a page under +/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list). +pub fn latest_release() -> Result { + let agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(10))) + .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build()) + .build() + .new_agent(); + let mut response = agent + .get(LATEST) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", concat!("PrintCraft/", env!("CARGO_PKG_VERSION"))) + .call() + .map_err(|e| format!("couldn't reach GitHub ({e})"))?; + let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?; + parse(&body) +} + +/// The certificate authorities the operating system trusts. +fn os_roots() -> Result { + let found = rustls_native_certs::load_native_certs(); + let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect(); + if certs.is_empty() { + return Err("no trusted certificates found on this system".into()); + } + Ok(ureq::tls::RootCerts::new_with_certs(&certs)) +} + +fn parse(body: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?; + let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string(); + let url = v["html_url"] + .as_str() + .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\']))) + .unwrap_or(RELEASES_PAGE) + .to_string(); + Ok(Release { version, url }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn answers_are_read_and_only_our_release_pages_are_offered() { + let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/printcraft/releases/tag/v0.2.0"}"#).unwrap(); + assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/printcraft/releases/tag/v0.2.0".into() }); + for elsewhere in ["https://example.com/printcraft.exe", "https://github.com/storytold/printcraft/releases.evil/x", "javascript:alert(1)"] { + let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap(); + assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}"); + } + assert!(parse(r#"{"message":"Not Found"}"#).is_err()); + assert!(parse("").is_err()); + } + + /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p printcraft -- --ignored`). + #[test] + #[ignore = "needs network access"] + fn github_answers_with_the_latest_release() { + let r = latest_release().unwrap(); + assert!(printcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}"); + assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}"); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 8a03e5ac..2862d5eb 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -300,6 +300,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("help.app_page", "PrintCraft web page", HELP, None, Nothing, "globe"), c("help.github", "PrintCraft on GitHub", HELP, None, Nothing, "code-xml"), c("help.website", "ArtCraft website", HELP, None, Nothing, "external-link"), + c("help.check_updates", "Check for updates…", HELP, None, Nothing, "cloud"), c("help.about", "About PrintCraft", HELP, None, Nothing, "info"), ]; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 1a7a4d9a..724a51a7 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -460,6 +460,7 @@ impl PrintCraftApp { "page.split" => self.dialog = Some(Dialog::Split), "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), "help.about" => self.dialog = Some(Dialog::About), + "help.check_updates" => self.check_for_updates(true), _ => return false, } true diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a9fe5572..d69d88cd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -12,6 +12,7 @@ const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); + crate::updates::dialog(app, ctx); let Some(dialog) = app.dialog else { app.props_draft = None; app.view_draft = None; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 99d1343c..f5164ce8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -60,6 +60,7 @@ pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, Se mod protect; mod recovery; pub mod theme; +pub mod updates; mod widgets; use printcraft_engine::{DocId, Session}; @@ -280,6 +281,11 @@ pub struct PrintCraftApp { /// Follow the operating system's light/dark setting. pub follow_system_theme: bool, pub dialog: Option, + /// How to ask for the latest release (the desktop app sets it; see `updates`). + pub update_source: Option, + /// Check for updates when PrintCraft starts (opt-in; persisted). + pub check_updates_at_start: bool, + pub(crate) updates: updates::Updates, pub palette_open: bool, pub palette_query: String, pub all_tools_expanded: bool, @@ -449,6 +455,9 @@ impl PrintCraftApp { theme: ThemeKind::Light, follow_system_theme: false, dialog: None, + update_source: None, + check_updates_at_start: false, + updates: updates::Updates::default(), palette_open: false, palette_query: String::new(), all_tools_expanded: false, @@ -824,6 +833,7 @@ impl PrintCraftApp { "custom_stamps": stamps_ui::encode(&self.custom_stamps), "javascript": self.session.javascript(), "actions": actions_ui::encode(&self.custom_actions), + "check_updates_at_start": self.check_updates_at_start, }) .to_string() } @@ -840,6 +850,9 @@ impl PrintCraftApp { if let Ok(t) = serde_json::from_value::(v["theme"].clone()) { self.theme = t; } + if let Some(b) = v["check_updates_at_start"].as_bool() { + self.check_updates_at_start = b; + } if let Ok(s) = serde_json::from_value::>>(v["signature"].clone()) && s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite()))) { @@ -1123,6 +1136,7 @@ impl eframe::App for PrintCraftApp { self.guard_quit(ctx); let now = ctx.input(|i| i.time); self.autosave_tick(now); + self.poll_updates(); self.shortcuts(ctx); self.process_pending_edits(); self.poll_export(); diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs new file mode 100644 index 00000000..e24bbb9d --- /dev/null +++ b/crates/ui-egui/src/updates.rs @@ -0,0 +1,205 @@ +//! Help ▸ Check for updates (issue #28): ask for the latest release and offer its download page. +//! +//! The desktop app supplies how to ask ([`PrintCraftApp::update_source`]), so this crate has no +//! network code; without a source (the web build, tests) the command opens the releases page. +//! PrintCraft never downloads or installs anything itself: the user downloads the new version. +//! A check when PrintCraft starts is opt-in (it contacts GitHub without being asked) and stays +//! silent unless a newer version exists. + +use std::sync::Arc; + +use egui::{Align, Layout}; + +use crate::{PrintCraftApp, theme, widgets}; + +/// Where every PrintCraft release is listed. +pub const RELEASES_PAGE: &str = "https://github.com/storytold/printcraft/releases"; + +/// The latest published release. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Release { + /// Its version tag, such as `v0.2.0`. + pub version: String, + /// Its page on [`RELEASES_PAGE`], where the downloads are. + pub url: String, +} + +/// Asks for the latest release (blocking; it runs on its own thread). +pub type UpdateSource = Arc Result + Send + Sync>; + +/// Whether release `latest` (a tag such as `v0.2.0`) is newer than version `current` (`0.1.1`). +/// Pre-release and build suffixes are ignored; a version that doesn't parse is never newer. +pub fn is_newer(latest: &str, current: &str) -> bool { + matches!((parse(latest), parse(current)), (Some(l), Some(c)) if l > c) +} + +fn parse(v: &str) -> Option<(u64, u64, u64)> { + let v = v.trim().trim_start_matches(['v', 'V']); + let core = v.split(['-', '+']).next()?; + let mut parts = core.split('.'); + let mut next = |required: bool| match parts.next() { + Some(p) => p.parse::().ok(), + None if required => None, + None => Some(0), + }; + let version = (next(true)?, next(false)?, next(false)?); + parts.next().is_none().then_some(version) +} + +/// Where a check is. +#[derive(Default)] +pub(crate) enum Check { + #[default] + Idle, + #[cfg(not(target_arch = "wasm32"))] + Running { + rx: std::sync::mpsc::Receiver>, + manual: bool, + }, + Done(Result), +} + +#[derive(Default)] +pub(crate) struct Updates { + pub(crate) check: Check, + /// The Updates dialog is showing. + pub(crate) open: bool, + /// The check at start has been considered (first frame, once the context is known). + started: bool, +} + +impl PrintCraftApp { + /// Check for a newer release. `manual` (Help ▸ Check for updates) shows the outcome, whatever + /// it is; otherwise (at start) the dialog only appears when a newer version exists. + pub fn check_for_updates(&mut self, manual: bool) { + let Some(source) = self.update_source.clone() else { + if manual { + self.open_url(RELEASES_PAGE); + } + return; + }; + #[cfg(not(target_arch = "wasm32"))] + { + if matches!(self.updates.check, Check::Running { .. }) { + self.updates.open |= manual; + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = self.ctx.clone(); + std::thread::spawn(move || { + // The receiver may be gone (the app quit): nothing to report to then. + let _ = tx.send(source()); + if let Some(ctx) = ctx { + ctx.request_repaint(); + } + }); + self.updates.check = Check::Running { rx, manual }; + self.updates.open = manual; + } + #[cfg(target_arch = "wasm32")] + { + let _ = source; + if manual { + self.open_url(RELEASES_PAGE); + } + } + } + + /// Start the opt-in check at start (first frame), and pick up a finished check (each frame). + pub(crate) fn poll_updates(&mut self) { + if !self.updates.started { + self.updates.started = true; + if self.check_updates_at_start { + self.check_for_updates(false); + } + } + #[cfg(not(target_arch = "wasm32"))] + if let Check::Running { rx, manual } = &self.updates.check { + let manual = *manual; + let result = match rx.try_recv() { + Ok(r) => r, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("the update check stopped unexpectedly".into()), + }; + let newer = result.as_ref().is_ok_and(|r| is_newer(&r.version, env!("CARGO_PKG_VERSION"))); + self.updates.open |= manual || newer; + self.updates.check = Check::Done(result); + } + } +} + +/// The Updates dialog. +pub(crate) fn dialog(app: &mut PrintCraftApp, ctx: &egui::Context) { + if !app.updates.open { + return; + } + let t = theme::Tokens::get(ctx); + let current = env!("CARGO_PKG_VERSION"); + let mut close = false; + let mut download: Option = None; + let modal = egui::Modal::new(egui::Id::new("updates")).show(ctx, |ui| { + ui.set_width(420.0); + ui.horizontal(|ui| { + ui.add(crate::icons::image("cloud", 22.0, t.accent)); + ui.label(egui::RichText::new("Check for updates").font(theme::semibold(16.0))); + }); + ui.add_space(8.0); + match &app.updates.check { + Check::Idle => { + ui.label("No check has run yet."); + } + #[cfg(not(target_arch = "wasm32"))] + Check::Running { .. } => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Checking for a newer version…"); + }); + } + Check::Done(Ok(r)) if is_newer(&r.version, current) => { + let version = r.version.trim_start_matches(['v', 'V']); + ui.label(egui::RichText::new(format!("PrintCraft {version} is available.")).strong()); + ui.label( + egui::RichText::new(format!("You have version {current}. Download the new version from its release page.")).color(t.text_muted), + ); + download = Some(r.url.clone()); + } + Check::Done(Ok(_)) => { + ui.label(format!("PrintCraft {current} is up to date.")); + } + Check::Done(Err(e)) => { + ui.label(format!("Couldn't check for updates: {e}")); + ui.label(egui::RichText::new(format!("You have version {current}. All releases are listed at {RELEASES_PAGE}.")).color(t.text_muted)); + } + } + ui.add_space(10.0); + ui.scope(|ui| { + // The light theme's field colour is the dialog's own white: outline the box. + ui.visuals_mut().widgets.inactive.bg_stroke = egui::Stroke::new(1.0, t.text_muted); + ui.checkbox(&mut app.check_updates_at_start, "Check for updates when PrintCraft starts"); + }); + ui.label( + egui::RichText::new("Asks GitHub for the latest release. Nothing is downloaded or installed automatically.").color(t.text_muted).small(), + ); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if let Some(url) = download.take() { + let get = widgets::pill_button(ui, "Download", true).clicked(); + let later = widgets::pill_button(ui, "Later", false).clicked(); + close = get || later; + download = get.then_some(url); + } else if widgets::pill_button(ui, "Close", true).clicked() { + close = true; + } + }); + }); + if modal.should_close() { + close = true; + download = None; + } + if close { + app.updates.open = false; + if let Some(url) = download { + app.open_url(&url); + } + } +} diff --git a/crates/ui-egui/tests/updates.rs b/crates/ui-egui/tests/updates.rs new file mode 100644 index 00000000..fb6d79a8 --- /dev/null +++ b/crates/ui-egui/tests/updates.rs @@ -0,0 +1,101 @@ +//! Help ▸ Check for updates (issue #28), with stand-in release sources (no network). + +use std::sync::Arc; + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::PrintCraftApp; +use printcraft_ui_egui::updates::{Release, UpdateSource, is_newer}; + +fn source(answer: Result<&str, &str>) -> UpdateSource { + let answer = answer.map(str::to_string).map_err(str::to_string); + Arc::new(move || answer.clone().map(|v| Release { url: format!("https://github.com/storytold/printcraft/releases/tag/{v}"), version: v })) +} + +fn harness(answer: Result<&str, &str>, at_start: bool) -> Harness<'static, PrintCraftApp> { + Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { + let mut app = PrintCraftApp::new(); + app.update_source = Some(source(answer)); + app.check_updates_at_start = at_start; + app + }) +} + +/// Run frames until the background check has reported (or give up). +fn settle(h: &mut Harness<'static, PrintCraftApp>) { + for _ in 0..200 { + h.run_steps(2); + if h.query_by_label_contains("Checking for a newer version").is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); +} + +#[test] +fn versions_compare_by_number() { + assert!(is_newer("v0.2.0", "0.1.1")); + assert!(is_newer("v0.1.10", "0.1.9")); + assert!(is_newer("1", "0.9.9")); + assert!(!is_newer("v0.1.1", "0.1.1")); + assert!(!is_newer("v0.1.0", "0.1.1")); + assert!(!is_newer("v0.1.1-beta.2", "0.1.1"), "suffixes are ignored"); + assert!(!is_newer("nightly", "0.1.1"), "a tag that isn't a version is never newer"); + assert!(!is_newer("v1.2.3.4", "0.1.1")); + assert!(!is_newer("v99999999999999999999.0.0", "0.1.1"), "out of range"); +} + +#[test] +fn a_newer_release_is_offered_for_download() { + let mut h = harness(Ok("v99.0.0"), false); + h.state_mut().execute("help.check_updates"); + settle(&mut h); + h.get_by_label_contains("PrintCraft 99.0.0 is available"); + h.get_by_label("Download"); + h.get_by_label("Later").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("is available").is_none(), "Later closes the dialog"); +} + +#[test] +fn an_up_to_date_or_failed_check_says_so() { + let mut h = harness(Ok(concat!("v", env!("CARGO_PKG_VERSION"))), false); + h.state_mut().execute("help.check_updates"); + settle(&mut h); + h.get_by_label_contains("is up to date"); + assert!(h.query_by_label("Download").is_none()); + + let mut h = harness(Err("couldn't reach GitHub"), false); + h.state_mut().execute("help.check_updates"); + settle(&mut h); + h.get_by_label_contains("Couldn't check for updates: couldn't reach GitHub"); +} + +#[test] +fn the_check_at_start_is_opt_in_and_quiet_unless_there_is_news() { + // Off (the default): nothing is asked. + let mut h = harness(Ok("v99.0.0"), false); + settle(&mut h); + assert!(h.query_by_label_contains("is available").is_none()); + // On, with a newer release: the dialog appears by itself. + let mut h = harness(Ok("v99.0.0"), true); + settle(&mut h); + h.get_by_label_contains("PrintCraft 99.0.0 is available"); + // On, up to date or failing: silent. + for answer in [Ok(concat!("v", env!("CARGO_PKG_VERSION"))), Err("offline")] { + let mut h = harness(answer, true); + settle(&mut h); + for message in ["is up to date", "Couldn't check for updates", "is available"] { + assert!(h.query_by_label_contains(message).is_none(), "{answer:?}: {message}"); + } + } + // The preference is remembered. + let mut app = PrintCraftApp::new(); + app.check_updates_at_start = true; + let mut restored = PrintCraftApp::new(); + restored.restore(&app.persist()); + assert!(restored.check_updates_at_start); + restored.restore("{}"); + assert!(restored.check_updates_at_start, "missing keys change nothing"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bda808f9..8880ca92 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1685,6 +1685,18 @@ tools = [] evidence = ["crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented"] notes = "" +[[feature]] +id = "view.check-for-updates" +title = "Help ▸ Check for updates (and an optional check at start)" +area = "B" +tier = "P2" +milestone = "M3" +status = "partial" +commands = ["help.check_updates"] +tools = [] +evidence = ["crates/ui-egui/tests/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/updates.rs::the_check_at_start_is_opt_in_and_quiet_unless_there_is_news", "apps/printcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"] +notes = "asks GitHub Releases and offers the release page; the check at start is opt-in. Missing: installing the update in place (needs signed release artifacts)" + [[feature]] id = "view.single-key-accelerators" title = "Single-key tool accelerators"