Merge pull request #1817 from dylanmtaylor/feat/startup-update-prompt

feat: startup update prompt modal and direct self-updating
This commit is contained in:
Dylan M. Taylor
2026-10-25 15:58:05 -04:00
committed by GitHub
8 changed files with 403 additions and 13 deletions
Generated
+206 -13
View File
@@ -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"
+1
View File
@@ -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" }
+1
View File
@@ -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.
+6
View File
@@ -398,6 +398,11 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
}
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::<Vec<_>>()) {
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);
}
+3
View File
@@ -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 }
+5
View File
@@ -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.
+4
View File
@@ -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<Result<Value
app.ui.about = p.get("open").and_then(Value::as_bool).unwrap_or(true);
Ok(json!(app.ui.about))
}
"help.checkForUpdates" => 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" },
+177
View File
@@ -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<egui::Context>,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
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<Value, String> {
#[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());
}
}