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"