mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Issue #28: Help ▸ Check for updates, with an opt-in check at start
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 <[email protected]>
This commit is contained in:
1 parent
27f7ef96fe
commit
85a8652de1
11 files changed
+574
-4
No files matched your search
Generated
+160
-4
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Release, String> {
|
||||
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<ureq::tls::RootCerts, String> {
|
||||
let found = rustls_native_certs::load_native_certs();
|
||||
let certs: Vec<ureq::tls::Certificate<'static>> = 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<Release, String> {
|
||||
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("<html>").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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Dialog>,
|
||||
/// How to ask for the latest release (the desktop app sets it; see `updates`).
|
||||
pub update_source: Option<updates::UpdateSource>,
|
||||
/// 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::<ThemeKind>(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::<Vec<Vec<[f32; 2]>>>(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();
|
||||
|
||||
@@ -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<dyn Fn() -> Result<Release, String> + 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::<u64>().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<Result<Release, String>>,
|
||||
manual: bool,
|
||||
},
|
||||
Done(Result<Release, String>),
|
||||
}
|
||||
|
||||
#[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<String> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user