diff --git a/Cargo.lock b/Cargo.lock index e278722b7..8d8da5df4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,6 +1007,31 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.0", +] + [[package]] name = "craft-window" version = "0.1.0" @@ -1133,6 +1158,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1149,6 +1180,7 @@ name = "designcraft" version = "0.13.0" dependencies = [ "arboard", + "craft-update", "craft-window", "designcraft-engine", "designcraft-ui-egui", @@ -1256,7 +1288,7 @@ dependencies = [ "indd", "kurbo", "log", - "quick-xml", + "quick-xml 0.41.0", "same-file", "serde", "serde_json", @@ -1264,7 +1296,7 @@ dependencies = [ "thiserror 2.0.21", "unicode-segmentation", "wasm-bindgen-test", - "zip", + "zip 3.0.0", ] [[package]] @@ -1274,7 +1306,7 @@ dependencies = [ "chrono", "designcraft-color", "designcraft-doc", - "zip", + "zip 3.0.0", ] [[package]] @@ -1299,7 +1331,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", - "zip", + "zip 3.0.0", ] [[package]] @@ -1325,9 +1357,9 @@ dependencies = [ "designcraft-geom", "designcraft-images", "log", - "quick-xml", + "quick-xml 0.41.0", "thiserror 2.0.21", - "zip", + "zip 3.0.0", ] [[package]] @@ -1340,7 +1372,7 @@ dependencies = [ "flate2", "image", "log", - "quick-xml", + "quick-xml 0.41.0", "resvg", "tiff", "zune-core", @@ -1395,10 +1427,10 @@ dependencies = [ "designcraft-geom", "designcraft-images", "image", - "quick-xml", + "quick-xml 0.41.0", "skrifa 0.47.0", "thiserror 2.0.21", - "zip", + "zip 3.0.0", ] [[package]] @@ -1425,8 +1457,8 @@ name = "designcraft-textimport" version = "0.13.0" dependencies = [ "designcraft-doc", - "quick-xml", - "zip", + "quick-xml 0.41.0", + "zip 3.0.0", ] [[package]] @@ -1446,6 +1478,7 @@ name = "designcraft-ui-egui" version = "0.13.0" dependencies = [ "craft-ui", + "craft-update", "craft-window", "designcraft-color", "designcraft-compose", @@ -1465,7 +1498,7 @@ dependencies = [ "toml", "unicode-bidi", "web-time", - "zip", + "zip 3.0.0", ] [[package]] @@ -3361,6 +3394,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3385,6 +3430,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -4068,6 +4119,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -4140,6 +4204,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4268,6 +4338,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -4750,7 +4829,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4930,6 +5011,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -5672,6 +5757,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -5936,6 +6051,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -6040,6 +6161,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6080,6 +6230,12 @@ dependencies = [ "xmlwriter", ] +[[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" @@ -6177,6 +6333,16 @@ dependencies = [ "vello_common 0.2.0", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -6440,7 +6606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -7065,6 +7231,16 @@ dependencies = [ "xml", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.31" @@ -7490,6 +7666,23 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index afa0d7be9..43c880e9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.1.0", default-features = false } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 1d47ef762..36415546e 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -32,6 +32,7 @@ arboard = { workspace = true } winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } # EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU # that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over # Win32 (MIT); the `user` feature is User32.dll only. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 12f423d79..ac695774b 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -398,6 +398,11 @@ fn control_port_from(source: &str, value: Option) -> Option { } fn main() -> std::process::ExitCode { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::DESIGNCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. let logger = logging::install(); @@ -553,6 +558,7 @@ fn main() -> std::process::ExitCode { session.recovery_dir = profile::recovery_dir(); let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); let mut app = DesignApp::new(session, services()); + app.set_startup_update_check(true); if let Some(line) = restarted.or_else(|| created.status_line()) { app.status(line); } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0a826b69e..3d4a7c6f0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -44,3 +44,6 @@ designcraft-fonts = { workspace = true, features = ["testing"] } [build-dependencies] serde_json = { workspace = true } toml = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95d53b798..f3571d38b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod story_editor; pub mod taskbar; pub mod theme; pub mod toolbar; +pub mod updates_ui; pub mod widgets; use std::collections::HashMap; @@ -836,6 +837,7 @@ pub struct DesignApp { pub cursors: cursors::Images, /// A panel's live preview (a slider being dragged), committed when it is let go. live_preview: LivePreview, + pub(crate) updates_rt: updates_ui::Runtime, } /// The preview a panel opened with `edit.preview`: it stays open while a panel holds it each frame. @@ -935,6 +937,7 @@ impl DesignApp { quitting: None, cursors: cursors::Images::default(), live_preview: LivePreview::default(), + updates_rt: updates_ui::Runtime::default(), } } @@ -1384,6 +1387,7 @@ impl DesignApp { fn logic_tick(&mut self, ctx: &egui::Context) { self.update_screenshot_epoch(ctx); self.drain_control(ctx); + updates_ui::tick(self, ctx); if self.fonts_ready && !self.host_order_pending() { self.drain_inbox(); self.open_os_documents(); @@ -2149,6 +2153,7 @@ impl DesignApp { story_editor::show(self, &ctx); dialogs::show(self, &ctx); about::show(self, &ctx); + updates_ui::show(self, &ctx); menus::palette(self, &ctx); // The IME is observed once, here: after the canvas, the floating panels, the story editor, // dialogs and Quick Apply have all applied this pass's commands and focus changes. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fda15c6bb..e3226044b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -348,6 +348,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{open?: true, tab?: \"about\"|\"contributors\"|\"models\"} — the About window: splash with community links, contributor and model credits (open: false closes it)", ), + ("help.checkForUpdates", "Check for Updates…", None, "{} — checks for newer releases and offers to update"), ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"), ( "window.workspace", @@ -990,6 +991,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:file.newSample", "-", + "ui:help.checkForUpdates", "ui:help.about", ], ), @@ -1911,6 +1913,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::updates_ui::check(app), "help.app" => match p.get("app").and_then(Value::as_str) { Some(slug) if !slug.is_empty() && slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => { let url = format!("{}/apps/{slug}", designcraft_engine::links::WEBSITE); @@ -2418,6 +2421,7 @@ pub enum AppMenuItem { /// The macOS application menu. Settings… is `app.preferences`, which `native_menu_tree` leaves out of Edit. pub const MAC_APP_MENU: &[AppMenuItem] = &[ AppMenuItem::Cmd { id: "dc-about", label: "About DesignCraft", command: "help.about" }, + AppMenuItem::Cmd { id: "dc-updates", label: "Check for Updates…", command: "help.checkForUpdates" }, AppMenuItem::Cmd { id: "dc-discord", label: "Join the ArtCraft Discord…", command: "help.discord" }, AppMenuItem::Sep, AppMenuItem::Cmd { id: "dc-settings", label: "Settings…", command: "app.preferences" }, diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs new file mode 100644 index 000000000..43803c9a5 --- /dev/null +++ b/crates/ui-egui/src/updates_ui.rs @@ -0,0 +1,177 @@ +//! Software updates, background check at startup, and startup prompt modal. + +use serde_json::Value; + +use crate::DesignApp; + +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, Status, Updater}; + +pub(crate) struct Runtime { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, + /// The desktop app turns the check at start on; tests and the web build leave it off. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, + /// The context of the last frame, to wake the UI when a check is queued from a menu callback. + #[cfg(not(target_arch = "wasm32"))] + repaint: Option, + pub(crate) manual_window_open: bool, + pub(crate) status_message: Option, +} + +impl Default for Runtime { + fn default() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::DESIGNCRAFT, env!("CARGO_PKG_VERSION")), + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, + #[cfg(not(target_arch = "wasm32"))] + repaint: None, + manual_window_open: false, + status_message: None, + } + } +} + +impl DesignApp { + /// Whether the app asks for a newer release when it starts (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates_rt.startup_check = on; + } +} + +pub(crate) fn check(app: &mut DesignApp) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.check_requested = true; + if let Some(ctx) = &app.updates_rt.repaint { + ctx.request_repaint(); + } + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + app.updates_rt.manual_window_open = true; + app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into()); + Ok(Value::Null) + } +} + +pub(crate) fn tick(app: &mut DesignApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let rt = &mut app.updates_rt; + rt.repaint = Some(ctx.clone()); + if std::mem::take(&mut rt.check_requested) { + rt.updater.check_now(ctx); + } else if rt.startup_check { + rt.updater.check_at_start(ctx); + } + rt.updater.tick(ctx); + } + #[cfg(target_arch = "wasm32")] + let _ = (app, ctx); +} + +pub(crate) fn show(app: &mut DesignApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = !app.session.documents().iter().any(|d| d.is_dirty()); + match app.updates_rt.updater.show_if(ctx, clean) { + Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::None => {} + } + // The window answers Help ▸ Check for Updates, so it follows the updater's status. + let rt = &mut app.updates_rt; + (rt.manual_window_open, rt.status_message) = match rt.updater.status() { + Status::Idle => (false, None), + Status::Checking => (true, Some(crate::i18n::tr(&app.ui.language, "Checking for updates…").to_string())), + Status::UpToDate => ( + true, + Some( + crate::i18n::tr(&app.ui.language, "DesignCraft {version} is currently the newest version available.") + .replace("{version}", env!("CARGO_PKG_VERSION")), + ), + ), + Status::Failed(e) => (true, Some(crate::i18n::tr(&app.ui.language, "Could not check for updates:\n{error}").replace("{error}", e))), + }; + } + + if app.updates_rt.manual_window_open { + let mut open = true; + let title = crate::i18n::tr(&app.ui.language, "DesignCraft Updates"); + let ok_label = crate::i18n::tr(&app.ui.language, "OK"); + egui::Window::new(title) + .open(&mut open) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + if let Some(msg) = &app.updates_rt.status_message { + ui.label(msg); + } + ui.add_space(8.0); + if ui.button(ok_label).clicked() { + app.updates_rt.manual_window_open = false; + } + }); + if !open { + app.updates_rt.manual_window_open = false; + } + } + + // Closing the window dismisses the outcome it showed. + #[cfg(not(target_arch = "wasm32"))] + if !app.updates_rt.manual_window_open { + app.updates_rt.updater.dismiss_status(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use designcraft_engine::Session; + + #[test] + fn default_runtime_is_idle() { + let rt = Runtime::default(); + assert!(!rt.manual_window_open); + #[cfg(not(target_arch = "wasm32"))] + { + assert!(!rt.startup_check); + assert!(!rt.updater.prompt_open()); + assert_eq!(rt.updater.status(), &Status::Idle); + } + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn startup_prompt_shows_and_closes() { + let mut app = DesignApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let release = craft_update::Release { + tag: "v99.0.0".into(), + version: craft_update::parse_version("99.0.0"), + name: "DesignCraft 99.0.0".into(), + html_url: "https://github.com/storytold/designcraft/releases/tag/v99.0.0".into(), + ..Default::default() + }; + app.updates_rt.updater.offer(release); + + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui.ctx()); + }); + output.textures_delta.clear(); + assert!(app.updates_rt.updater.prompt_open()); + } +}