From 04121fb020d3c9b8de98fa4d7c91d9159944f7dd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 09:53:34 -0400 Subject: [PATCH 01/11] feat: startup update prompt modal and direct self-updating Integrate `craft-update` pinned to storytold/craft-libs fe61243 to check for updates on startup, display the standard suite update modal with changelog notes, and support one-click in-app updating and restart. Ref: storytold/craft-libs#319 --- Cargo.toml | 1 + crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/lib.rs | 10 ++ crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/src/updates_ui.rs | 235 +++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 crates/ui-egui/src/updates_ui.rs diff --git a/Cargo.toml b/Cargo.toml index 74175e376..98af94ec1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "fe61243ebfd7f9a7bea4cc326028e859d6aab052", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 3419c1a2c..7a634c502 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -47,6 +47,9 @@ chrono = { version = "0.4.45", default-features = false, features = ["clock"] } # The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). js-sys = "0.3" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } + [dev-dependencies] cfb = { workspace = true } # Producer tests simulate a globally exhausted IME revision allocator through the engine's diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f546dead9..da820325d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -83,6 +83,7 @@ pub mod table_pen; pub mod table_resize; pub mod table_ruler; pub mod theme; +pub mod updates_ui; pub mod widgets; pub mod window_close; pub mod window_geometry; @@ -568,6 +569,7 @@ pub struct WordApp { pub mac_titlebar: chrome::MacTitleBar, /// Focus (#1678): the ribbon is dropped down from the Focus bar, over the page. pub(crate) focus_ribbon: bool, + pub(crate) updates_rt: updates_ui::Runtime, control_rx: Option>, /// Admitted `ui.screenshot` requests: count/byte budgets, retries and completion slots. shots: control::Shots, @@ -735,6 +737,7 @@ impl WordApp { integrated_titlebar: false, mac_titlebar: chrome::MacTitleBar::default(), focus_ribbon: false, + updates_rt: updates_ui::Runtime::default(), control_rx: None, shots: control::Shots::default(), capture_epoch: 0, @@ -1628,6 +1631,10 @@ impl WordApp { fn ui_command(&mut self, id: &str, p: &Value) -> Option> { let s = |k: &str| p.get(k).and_then(Value::as_str); Some(Ok(match id { + "app.checkForUpdates" | "help.checkForUpdates" => { + crate::updates_ui::check(self); + Value::Null + } "ui.tab" => { let tab = s("tab").unwrap_or("Home").to_string(); if tab == "File" { @@ -2244,6 +2251,7 @@ impl WordApp { /// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops. pub fn logic(&mut self, ctx: &egui::Context) { + updates_ui::tick(self, ctx); let lang = i18n::Lang::from_pref(&self.ui.language); i18n::set_current(lang); // New documents are written in the interface's language when WordCraft can proof it @@ -2637,6 +2645,7 @@ impl WordApp { fn show_overlays(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { content_controls::popup(self, ctx); dialogs::show(self, ctx); + updates_ui::show(self, ctx); #[cfg(not(target_arch = "wasm32"))] file_browser::show(self, ctx); colors::show(self, ctx); @@ -3018,6 +3027,7 @@ pub(crate) fn works_without_document(id: &str) -> bool { | "ui.discord" | "ui.caretAnimation" | "ui.startScreen" + | "app.checkForUpdates" ) || id.starts_with("help.") } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f0feb3204..833fab8de 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2431,6 +2431,7 @@ fn outlining(app: &mut WordApp, ui: &mut Ui) { fn help(app: &mut WordApp, ui: &mut Ui) { group(ui, "Help", None, app, |ui, app| { big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); + big(ui, app, "checkForUpdates", "Check for\nUpdates…", "app.checkForUpdates", json!({}), false); big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false); }); group(ui, "Agents", None, app, |ui, app| { diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs new file mode 100644 index 000000000..6cd3f155e --- /dev/null +++ b/crates/ui-egui/src/updates_ui.rs @@ -0,0 +1,235 @@ +//! Software updates, background check at startup, and startup prompt modal. + +use crate::WordApp; + +pub(crate) struct Runtime { + startup_checked: bool, + manual_checked: bool, + pub(crate) manual_window_open: bool, + pub(crate) status_message: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) prompt: Option, + #[cfg(not(target_arch = "wasm32"))] + rx: Option, String>>>, + #[cfg(not(target_arch = "wasm32"))] + checking: bool, +} + +impl Default for Runtime { + fn default() -> Self { + Self { + startup_checked: false, + manual_checked: false, + manual_window_open: false, + status_message: None, + #[cfg(not(target_arch = "wasm32"))] + prompt: None, + #[cfg(not(target_arch = "wasm32"))] + rx: None, + #[cfg(not(target_arch = "wasm32"))] + checking: false, + } + } +} + +pub(crate) fn check(app: &mut WordApp) { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.manual_checked = true; + app.updates_rt.manual_window_open = false; + app.updates_rt.status_message = None; + start_check(app); + } + #[cfg(target_arch = "wasm32")] + { + app.updates_rt.manual_window_open = true; + app.updates_rt.status_message = Some(tl!("Update check is not supported in the web browser.").to_string()); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn start_check(app: &mut WordApp) { + if app.updates_rt.checking { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + app.updates_rt.rx = Some(rx); + app.updates_rt.checking = true; + + std::thread::spawn(move || { + let http = match craft_update::Http::new("WordCraft/Update") { + Ok(h) => h, + Err(e) => { + let _ = tx.send(Err(e)); + return; + } + }; + let res = craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")); + let _ = tx.send(res); + }); +} + +pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + if !app.updates_rt.startup_checked { + app.updates_rt.startup_checked = true; + start_check(app); + } + + let mut completed = None; + if let Some(rx) = &app.updates_rt.rx { + if let Ok(res) = rx.try_recv() { + completed = Some(res); + } + } + + if let Some(res) = completed { + app.updates_rt.rx = None; + app.updates_rt.checking = false; + match res { + Ok(Some(release)) => { + let mut prompt = + craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); + prompt.open = true; + app.updates_rt.prompt = Some(prompt); + ctx.request_repaint(); + } + Ok(None) => { + if app.updates_rt.manual_checked { + app.updates_rt.manual_checked = false; + app.updates_rt.status_message = + Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION"))); + app.updates_rt.manual_window_open = true; + ctx.request_repaint(); + } + } + Err(e) => { + if app.updates_rt.manual_checked { + app.updates_rt.manual_checked = false; + app.updates_rt.status_message = Some(format!("Could not check for updates:\n{e}")); + app.updates_rt.manual_window_open = true; + ctx.request_repaint(); + } + } + } + } + } +} + +pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let mut download_url = None; + if let Some(prompt) = &mut app.updates_rt.prompt { + match prompt.show(ctx) { + craft_update::StartupPromptResponse::Pending => {} + craft_update::StartupPromptResponse::UpdateAndRestart => { + prompt.is_installing = true; + if let Some(install) = craft_update::self_update::current(&craft_update::suite::WORDCRAFT) { + let mut release = prompt.release.clone(); + std::thread::spawn(move || { + let http = match craft_update::Http::new("WordCraft/Update") { + Ok(h) => h, + Err(e) => { + log::warn!("could not initialize HTTP: {e}"); + return; + } + }; + if release.assets.is_empty() { + if let Ok(Some(full)) = craft_update::check_for_update( + &craft_update::suite::WORDCRAFT, + &http, + env!("CARGO_PKG_VERSION"), + ) { + release = full; + } + } + let paths = match craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) { + Ok(p) => p, + Err(e) => { + log::warn!("could not find paths: {e}"); + return; + } + }; + let stop = craft_update::Stop::default(); + let platform = match craft_update::Platform::current() { + Some(p) => p, + None => { + log::warn!("unsupported platform for self update"); + return; + } + }; + let last = || Ok(()); + match craft_update::self_update::update( + &craft_update::suite::WORDCRAFT, + &install, + &release, + platform, + &http, + &paths, + &stop, + &mut |_| {}, + &last, + Some(env!("CARGO_PKG_VERSION")), + ) { + Ok(()) => { + std::process::exit(0); + } + Err(e) => { + log::warn!("WordCraft self-update failed: {e}"); + } + } + }); + } else { + prompt.status_message = + Some("Cannot auto-update this installation type. Please download manually.".to_string()); + prompt.is_installing = false; + } + } + craft_update::StartupPromptResponse::Download => { + download_url = Some(prompt.release.html_url.clone()); + prompt.open = false; + } + craft_update::StartupPromptResponse::Later => { + prompt.open = false; + } + } + if !prompt.open { + app.updates_rt.prompt = None; + } + } + + if let Some(url) = download_url { + app.updates_rt.prompt = None; + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + } + + if app.updates_rt.manual_window_open { + let mut open = true; + let mut close = false; + egui::Window::new(tl!("Check for Updates")) + .open(&mut open) + .resizable(false) + .collapsible(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + if let Some(msg) = &app.updates_rt.status_message { + ui.label(msg); + } else { + ui.label(tl!("Checking for updates…")); + } + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Close")).clicked() { + close = true; + } + }); + }); + if !open || close { + app.updates_rt.manual_window_open = false; + app.updates_rt.status_message = None; + } + } +} From b39c1ba1a3aae4f69d5268379a282dfe6b9145fe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 11:28:12 -0400 Subject: [PATCH 02/11] fix(updates): report self-update results to the prompt and finish through the app's close path Keeps the worker's failure visible instead of leaving the prompt installing, repaints while a check runs, and no longer calls process::exit past the unsaved-work prompt. --- crates/ui-egui/src/updates_ui.rs | 102 +++++++++++++++---------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 6cd3f155e..33c83d928 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -13,6 +13,8 @@ pub(crate) struct Runtime { rx: Option, String>>>, #[cfg(not(target_arch = "wasm32"))] checking: bool, + #[cfg(not(target_arch = "wasm32"))] + update_rx: Option>>, } impl Default for Runtime { @@ -28,6 +30,8 @@ impl Default for Runtime { rx: None, #[cfg(not(target_arch = "wasm32"))] checking: false, + #[cfg(not(target_arch = "wasm32"))] + update_rx: None, } } } @@ -77,6 +81,10 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { start_check(app); } + if app.updates_rt.checking { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + let mut completed = None; if let Some(rx) = &app.updates_rt.rx { if let Ok(res) = rx.try_recv() { @@ -89,8 +97,7 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { app.updates_rt.checking = false; match res { Ok(Some(release)) => { - let mut prompt = - craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); + let mut prompt = craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); prompt.open = true; app.updates_rt.prompt = Some(prompt); ctx.request_repaint(); @@ -122,68 +129,61 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { { let mut download_url = None; if let Some(prompt) = &mut app.updates_rt.prompt { + if let Some(result) = app.updates_rt.update_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + app.updates_rt.update_rx = None; + match result { + // The app's close path asks about unsaved work; the restart helper relaunches once the process exits. + Ok(()) => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Err(e) => { + prompt.status_message = Some(e); + prompt.is_installing = false; + } + } + } match prompt.show(ctx) { craft_update::StartupPromptResponse::Pending => {} craft_update::StartupPromptResponse::UpdateAndRestart => { prompt.is_installing = true; if let Some(install) = craft_update::self_update::current(&craft_update::suite::WORDCRAFT) { let mut release = prompt.release.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + app.updates_rt.update_rx = Some(rx); + let ctx = ctx.clone(); std::thread::spawn(move || { - let http = match craft_update::Http::new("WordCraft/Update") { - Ok(h) => h, - Err(e) => { - log::warn!("could not initialize HTTP: {e}"); - return; - } - }; - if release.assets.is_empty() { - if let Ok(Some(full)) = craft_update::check_for_update( - &craft_update::suite::WORDCRAFT, - &http, - env!("CARGO_PKG_VERSION"), - ) { + let mut run = || -> Result<(), String> { + let http = craft_update::Http::new("WordCraft/Update").map_err(|e| format!("could not initialize HTTP: {e}"))?; + if release.assets.is_empty() + && let Some(full) = + craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")) + .map_err(|e| format!("could not fetch release assets: {e}"))? + { release = full; } - } - let paths = match craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) { - Ok(p) => p, - Err(e) => { - log::warn!("could not find paths: {e}"); - return; - } + let paths = craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) + .map_err(|e| format!("could not find paths: {e}"))?; + let platform = craft_update::Platform::current().ok_or("unsupported platform for self update")?; + craft_update::self_update::update( + &craft_update::suite::WORDCRAFT, + &install, + &release, + platform, + &http, + &paths, + &craft_update::Stop::default(), + &mut |_| {}, + &|| Ok(()), + Some(env!("CARGO_PKG_VERSION")), + ) }; - let stop = craft_update::Stop::default(); - let platform = match craft_update::Platform::current() { - Some(p) => p, - None => { - log::warn!("unsupported platform for self update"); - return; - } - }; - let last = || Ok(()); - match craft_update::self_update::update( - &craft_update::suite::WORDCRAFT, - &install, - &release, - platform, - &http, - &paths, - &stop, - &mut |_| {}, - &last, - Some(env!("CARGO_PKG_VERSION")), - ) { - Ok(()) => { - std::process::exit(0); - } - Err(e) => { - log::warn!("WordCraft self-update failed: {e}"); - } + let result = run(); + if let Err(e) = &result { + log::warn!("self-update failed: {e}"); } + let _ = tx.send(result); + ctx.request_repaint(); }); } else { - prompt.status_message = - Some("Cannot auto-update this installation type. Please download manually.".to_string()); + prompt.status_message = Some("Cannot auto-update this installation type. Please download manually.".to_string()); prompt.is_installing = false; } } From a91e1b5ea47fdda92433905b809cd95ff6a1f0e4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 11:28:57 -0400 Subject: [PATCH 03/11] build: merge the craft-update dependency into the existing non-wasm table --- Cargo.lock | 229 ++++++++++++++++++++++++++++++++++++-- crates/ui-egui/Cargo.toml | 4 +- 2 files changed, 223 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e883847e1..94e348833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,32 @@ dependencies = [ "egui", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fe61243ebfd7f9a7bea4cc326028e859d6aab052#fe61243ebfd7f9a7bea4cc326028e859d6aab052" +dependencies = [ + "chrono", + "dirs", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 8.6.0", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1163,6 +1189,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" @@ -1209,6 +1241,27 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -3057,6 +3110,12 @@ dependencies = [ "memchr", ] +[[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" @@ -3422,6 +3481,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -3694,6 +3759,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" @@ -3778,6 +3856,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" @@ -3887,6 +3971,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" @@ -4149,6 +4242,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "regex" version = "1.13.1" @@ -4328,7 +4431,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", @@ -4490,6 +4595,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" @@ -5233,6 +5342,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-path" version = "0.12.0" @@ -5474,6 +5613,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typenum" version = "1.20.1" @@ -5566,6 +5711,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" @@ -5584,6 +5758,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" @@ -5634,6 +5814,16 @@ dependencies = [ "vello_common", ] +[[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" @@ -5858,7 +6048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -6483,6 +6673,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" @@ -6617,7 +6817,7 @@ dependencies = [ "image", "log", "proptest", - "quick-xml", + "quick-xml 0.41.0", "sha1 0.11.0", "sha2 0.11.0", "thiserror 2.0.21", @@ -6625,7 +6825,7 @@ dependencies = [ "wordcraft-doc", "wordcraft-geom", "zeroize", - "zip", + "zip 3.0.0", ] [[package]] @@ -6656,7 +6856,7 @@ dependencies = [ "wordcraft-proof", "wordcraft-render", "zeroize", - "zip", + "zip 3.0.0", ] [[package]] @@ -6684,10 +6884,10 @@ dependencies = [ "js-sys", "log", "proptest", - "quick-xml", + "quick-xml 0.41.0", "wordcraft-doc", "wordcraft-geom", - "zip", + "zip 3.0.0", ] [[package]] @@ -6791,6 +6991,7 @@ dependencies = [ "chrono", "craft-i18n", "craft-ui", + "craft-update", "egui", "egui_kittest", "image", @@ -6809,7 +7010,7 @@ dependencies = [ "wordcraft-render", "wordcraft-zotero", "zbus", - "zip", + "zip 3.0.0", ] [[package]] @@ -7242,6 +7443,20 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7a634c502..1a257045f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -42,14 +42,12 @@ zbus = "=5.19.0" # Comment times in the user's own time zone (`local_time.rs`); the web app asks the browser. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +craft-update = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). js-sys = "0.3" -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-update = { workspace = true } - [dev-dependencies] cfb = { workspace = true } # Producer tests simulate a globally exhausted IME revision allocator through the engine's From 67ffe00b32d0cd815c7edd44b6340e85bdc38ad3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 11:58:13 -0400 Subject: [PATCH 04/11] build: pin craft-update to the dirs-free, zip 9 build Satisfies cargo-deny: no MPL-2.0 option-ext, no zip 8 advisories, and a versioned git dependency instead of a wildcard. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 98af94ec1..74a647b5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "fe61243ebfd7f9a7bea4cc326028e859d6aab052", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "fcd3441993ee238144ee56538ad81b4a333f1ed9", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. From 4148fb11715a49b9bf3bfd4aa0c354a90977ca69 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 13:31:04 -0400 Subject: [PATCH 05/11] refactor(updates): drive the flow with craft-update's Updater Startup check is opt-in for the desktop app, the Windows update helper is dispatched before argument parsing, and a successful update closes through the app's own close path. --- Cargo.toml | 2 +- apps/wordcraft/Cargo.toml | 3 + apps/wordcraft/src/main.rs | 6 + crates/ui-egui/src/updates_ui.rs | 217 ++++++++----------------------- 4 files changed, 61 insertions(+), 167 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 74a647b5f..4beaf32b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "fcd3441993ee238144ee56538ad81b4a333f1ed9", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "13ceaf2f31620531f39a54f6b994d581a6082a93", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 828066e33..f1d27bc58 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -83,3 +83,6 @@ pollster = "0.4" arboard = { workspace = true, features = ["image-data"] } # The control server's random key. getrandom = "0.3" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2073b5069..248696019 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -337,6 +337,11 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { /// geometry, opens requested documents or the Start screen, starts the control server when /// asked, and runs the eframe event loop. fn main() -> eframe::Result { + // 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::WORDCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); logging::install_panic_hook(); @@ -445,6 +450,7 @@ fn main() -> eframe::Result { let mut session = Session::new(doc); session.system_region = region; let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx)); + app.set_startup_update_check(true); load_prefs(&mut app); load_user_dictionary(&mut app); // A region chosen in File › Options replaces the system's for the first blank diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 33c83d928..4a429aa8e 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,47 +2,48 @@ use crate::WordApp; +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, Status, Updater}; + pub(crate) struct Runtime { - startup_checked: bool, - manual_checked: bool, + #[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, pub(crate) manual_window_open: bool, pub(crate) status_message: Option, - #[cfg(not(target_arch = "wasm32"))] - pub(crate) prompt: Option, - #[cfg(not(target_arch = "wasm32"))] - rx: Option, String>>>, - #[cfg(not(target_arch = "wasm32"))] - checking: bool, - #[cfg(not(target_arch = "wasm32"))] - update_rx: Option>>, } impl Default for Runtime { fn default() -> Self { Self { - startup_checked: false, - manual_checked: false, + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::WORDCRAFT, env!("CARGO_PKG_VERSION")), + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, manual_window_open: false, status_message: None, - #[cfg(not(target_arch = "wasm32"))] - prompt: None, - #[cfg(not(target_arch = "wasm32"))] - rx: None, - #[cfg(not(target_arch = "wasm32"))] - checking: false, - #[cfg(not(target_arch = "wasm32"))] - update_rx: None, } } } +impl WordApp { + /// 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 WordApp) { #[cfg(not(target_arch = "wasm32"))] { - app.updates_rt.manual_checked = true; - app.updates_rt.manual_window_open = false; - app.updates_rt.status_message = None; - start_check(app); + app.updates_rt.check_requested = true; } #[cfg(target_arch = "wasm32")] { @@ -51,159 +52,37 @@ pub(crate) fn check(app: &mut WordApp) { } } -#[cfg(not(target_arch = "wasm32"))] -fn start_check(app: &mut WordApp) { - if app.updates_rt.checking { - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - app.updates_rt.rx = Some(rx); - app.updates_rt.checking = true; - - std::thread::spawn(move || { - let http = match craft_update::Http::new("WordCraft/Update") { - Ok(h) => h, - Err(e) => { - let _ = tx.send(Err(e)); - return; - } - }; - let res = craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")); - let _ = tx.send(res); - }); -} - pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - if !app.updates_rt.startup_checked { - app.updates_rt.startup_checked = true; - start_check(app); - } - - if app.updates_rt.checking { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } - - let mut completed = None; - if let Some(rx) = &app.updates_rt.rx { - if let Ok(res) = rx.try_recv() { - completed = Some(res); - } - } - - if let Some(res) = completed { - app.updates_rt.rx = None; - app.updates_rt.checking = false; - match res { - Ok(Some(release)) => { - let mut prompt = craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); - prompt.open = true; - app.updates_rt.prompt = Some(prompt); - ctx.request_repaint(); - } - Ok(None) => { - if app.updates_rt.manual_checked { - app.updates_rt.manual_checked = false; - app.updates_rt.status_message = - Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION"))); - app.updates_rt.manual_window_open = true; - ctx.request_repaint(); - } - } - Err(e) => { - if app.updates_rt.manual_checked { - app.updates_rt.manual_checked = false; - app.updates_rt.status_message = Some(format!("Could not check for updates:\n{e}")); - app.updates_rt.manual_window_open = true; - ctx.request_repaint(); - } - } - } + let rt = &mut app.updates_rt; + 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 WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - let mut download_url = None; - if let Some(prompt) = &mut app.updates_rt.prompt { - if let Some(result) = app.updates_rt.update_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { - app.updates_rt.update_rx = None; - match result { - // The app's close path asks about unsaved work; the restart helper relaunches once the process exits. - Ok(()) => ctx.send_viewport_cmd(egui::ViewportCommand::Close), - Err(e) => { - prompt.status_message = Some(e); - prompt.is_installing = false; - } - } - } - match prompt.show(ctx) { - craft_update::StartupPromptResponse::Pending => {} - craft_update::StartupPromptResponse::UpdateAndRestart => { - prompt.is_installing = true; - if let Some(install) = craft_update::self_update::current(&craft_update::suite::WORDCRAFT) { - let mut release = prompt.release.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - app.updates_rt.update_rx = Some(rx); - let ctx = ctx.clone(); - std::thread::spawn(move || { - let mut run = || -> Result<(), String> { - let http = craft_update::Http::new("WordCraft/Update").map_err(|e| format!("could not initialize HTTP: {e}"))?; - if release.assets.is_empty() - && let Some(full) = - craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")) - .map_err(|e| format!("could not fetch release assets: {e}"))? - { - release = full; - } - let paths = craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) - .map_err(|e| format!("could not find paths: {e}"))?; - let platform = craft_update::Platform::current().ok_or("unsupported platform for self update")?; - craft_update::self_update::update( - &craft_update::suite::WORDCRAFT, - &install, - &release, - platform, - &http, - &paths, - &craft_update::Stop::default(), - &mut |_| {}, - &|| Ok(()), - Some(env!("CARGO_PKG_VERSION")), - ) - }; - let result = run(); - if let Err(e) = &result { - log::warn!("self-update failed: {e}"); - } - let _ = tx.send(result); - ctx.request_repaint(); - }); - } else { - prompt.status_message = Some("Cannot auto-update this installation type. Please download manually.".to_string()); - prompt.is_installing = false; - } - } - craft_update::StartupPromptResponse::Download => { - download_url = Some(prompt.release.html_url.clone()); - prompt.open = false; - } - craft_update::StartupPromptResponse::Later => { - prompt.open = false; - } - } - if !prompt.open { - app.updates_rt.prompt = None; - } - } - - if let Some(url) = download_url { - app.updates_rt.prompt = None; - ctx.open_url(egui::OpenUrl::new_tab(url)); + match app.updates_rt.updater.show(ctx) { + 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, None), + Status::UpToDate => (true, Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), + Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), + }; } if app.updates_rt.manual_window_open { @@ -232,4 +111,10 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { app.updates_rt.status_message = None; } } + + // 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(); + } } From e6954843bc7b3e0b509482f2a2c8f16bf0edcbc4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 13:39:32 -0400 Subject: [PATCH 06/11] build: pin craft-update to craft-libs main (b9ba47f) --- Cargo.lock | 66 ++++++++++++++++++------------------------------------ Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94e348833..5343ab2e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,10 +1039,9 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=fe61243ebfd7f9a7bea4cc326028e859d6aab052#fe61243ebfd7f9a7bea4cc326028e859d6aab052" +source = "git+https://github.com/storytold/craft-libs?rev=b9ba47f58902e839a8f102b824bb9f0beaac335c#b9ba47f58902e839a8f102b824bb9f0beaac335c" dependencies = [ "chrono", - "dirs", "dunce", "egui", "flate2", @@ -1059,7 +1058,7 @@ dependencies = [ "verifysign", "winreg", "winsafe", - "zip 8.6.0", + "zip 9.1.0", ] [[package]] @@ -1241,27 +1240,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.59.0", -] - [[package]] name = "dispatch" version = "0.2.0" @@ -3095,6 +3073,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" @@ -3481,12 +3471,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "orbclient" version = "0.3.55" @@ -4242,16 +4226,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "redox_users" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" -dependencies = [ - "libredox", - "thiserror 2.0.21", -] - [[package]] name = "regex" version = "1.13.1" @@ -5615,9 +5589,9 @@ dependencies = [ [[package]] name = "typed-path" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typenum" @@ -6731,6 +6705,7 @@ dependencies = [ "chrono", "clipboard-win", "craft-print", + "craft-update", "dispatch2", "eframe", "egui", @@ -7445,15 +7420,18 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" dependencies = [ "crc32fast", "flate2", + "getrandom 0.4.3", "indexmap", "memchr", + "nix", "typed-path", + "windows-sys 0.61.2", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 4beaf32b0..9279fe181 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "13ceaf2f31620531f39a54f6b994d581a6082a93", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b9ba47f58902e839a8f102b824bb9f0beaac335c", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. From e3d93581b763b2f602ae02cd839a00de2b75622c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:33:47 -0400 Subject: [PATCH 07/11] refactor(updates): drive the flow with craft-update's Updater Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/updates_ui.rs | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5343ab2e4..9e768c15f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b9ba47f58902e839a8f102b824bb9f0beaac335c#b9ba47f58902e839a8f102b824bb9f0beaac335c" +source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 9279fe181..aa1b6d372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b9ba47f58902e839a8f102b824bb9f0beaac335c", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 4a429aa8e..6f31ece2f 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -70,7 +70,9 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - match app.updates_rt.updater.show(ctx) { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = !(app.session.dirty || app.save_unconfirmed()); + 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 => {} From dd2e2c860571b59ef4b6748392942ff5ed3155aa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:37:40 -0400 Subject: [PATCH 08/11] fix(updates): give the update command a ui. id so the ribbon button is enabled --- crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/ribbon.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da820325d..3a77229a7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1631,7 +1631,7 @@ impl WordApp { fn ui_command(&mut self, id: &str, p: &Value) -> Option> { let s = |k: &str| p.get(k).and_then(Value::as_str); Some(Ok(match id { - "app.checkForUpdates" | "help.checkForUpdates" => { + "ui.checkForUpdates" | "help.checkForUpdates" => { crate::updates_ui::check(self); Value::Null } @@ -3027,7 +3027,7 @@ pub(crate) fn works_without_document(id: &str) -> bool { | "ui.discord" | "ui.caretAnimation" | "ui.startScreen" - | "app.checkForUpdates" + | "ui.checkForUpdates" ) || id.starts_with("help.") } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 833fab8de..0f498994b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2431,7 +2431,7 @@ fn outlining(app: &mut WordApp, ui: &mut Ui) { fn help(app: &mut WordApp, ui: &mut Ui) { group(ui, "Help", None, app, |ui, app| { big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); - big(ui, app, "checkForUpdates", "Check for\nUpdates…", "app.checkForUpdates", json!({}), false); + big(ui, app, "checkForUpdates", "Check for\nUpdates…", "ui.checkForUpdates", json!({}), false); big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false); }); group(ui, "Agents", None, app, |ui, app| { From f01a107a40cb7f64ade8e1880b132431ce4d9b40 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:03:15 -0400 Subject: [PATCH 09/11] build: pin craft-update to craft-libs main (db19c37) --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e768c15f..ed1d6710d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" dependencies = [ "chrono", "dunce", @@ -1545,7 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3516,7 +3516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4018,7 +4018,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4395,7 +4395,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4454,7 +4454,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5056,7 +5056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5241,7 +5241,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5607,7 +5607,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6278,7 +6278,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index aa1b6d372..bedb55537 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. From 62736d93dfe5e393c2d6f1458e2ad032de702770 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:17:06 -0400 Subject: [PATCH 10/11] fix(updates): address review feedback Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved. --- crates/ui-egui/src/updates_ui.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 6f31ece2f..56cff7ea3 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -13,6 +13,9 @@ pub(crate) struct Runtime { 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, } @@ -26,6 +29,8 @@ impl Default for Runtime { 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, } @@ -44,6 +49,9 @@ pub(crate) fn check(app: &mut WordApp) { #[cfg(not(target_arch = "wasm32"))] { app.updates_rt.check_requested = true; + if let Some(ctx) = &app.updates_rt.repaint { + ctx.request_repaint(); + } } #[cfg(target_arch = "wasm32")] { @@ -56,6 +64,7 @@ pub(crate) fn tick(app: &mut WordApp, 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 { @@ -71,7 +80,7 @@ pub(crate) fn show(app: &mut WordApp, 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.dirty || app.save_unconfirmed()); + let clean = !(app.session.dirty || app.save_unconfirmed() || app.printing.busy() || crate::panes::has_comment_drafts(app)); 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), From 57ba4b9ed84e68c0193607e1c47eb9a36eab3048 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:34:12 -0400 Subject: [PATCH 11/11] fix(updates): localize the update status messages and format the sources --- crates/ui-egui/src/updates_ui.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 56cff7ea3..c8aece1ff 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -91,8 +91,10 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { (rt.manual_window_open, rt.status_message) = match rt.updater.status() { Status::Idle => (false, None), Status::Checking => (true, None), - Status::UpToDate => (true, Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), - Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), + Status::UpToDate => { + (true, Some(tl!("WordCraft {version} is currently the newest version available.").replace("{version}", env!("CARGO_PKG_VERSION")))) + } + Status::Failed(e) => (true, Some(tl!("Could not check for updates:\n{error}").replace("{error}", e))), }; }