diff --git a/Cargo.lock b/Cargo.lock index 9b9ae95855..69dc9a660c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,7 +304,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -383,6 +383,7 @@ dependencies = [ "chrono", "cookie_store 0.0.1", "cookie_store_wrapper", + "craft-update", "directories", "dirs", "discord-rich-presence", @@ -553,28 +554,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "assertor" version = "0.0.3" @@ -638,17 +617,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - [[package]] name = "async-io" version = "2.6.0" @@ -678,17 +646,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - [[package]] name = "async-process" version = "2.5.0" @@ -882,6 +839,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.6.0" @@ -1468,9 +1431,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1812,6 +1775,31 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=2a24c10817173d26d0ea4689acbbade36f91b5dc#2a24c10817173d26d0ea4689acbbade36f91b5dc" +dependencies = [ + "chrono", + "dunce", + "flate2", + "getrandom 0.3.1", + "log", + "plist", + "rustix 1.1.5", + "semver", + "serde", + "serde_json", + "sha2 0.10.8", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg 0.55.0", + "winsafe", + "zip", +] + [[package]] name = "crc" version = "3.0.1" @@ -1829,9 +1817,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64" dependencies = [ "cfg-if", ] @@ -2136,7 +2124,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2508,7 +2496,7 @@ dependencies = [ "rustc_version", "toml 0.8.23", "vswhom", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -2698,7 +2686,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]] @@ -2862,6 +2850,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -4768,7 +4766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -5117,7 +5115,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6272,12 +6270,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - [[package]] name = "polycool" version = "0.4.0" @@ -6988,7 +6980,6 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "ashpd", "block2 0.6.2", "dispatch2", "glib-sys", @@ -7000,9 +6991,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -7172,7 +7161,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7192,6 +7181,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -7242,6 +7232,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -8703,6 +8720,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "target-lexicon" version = "0.12.14" @@ -9087,7 +9114,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9597,7 +9624,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9661,6 +9688,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typeid" version = "1.0.2" @@ -9690,7 +9723,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9842,6 +9875,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls 0.23.36", + "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" @@ -9931,6 +9993,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" +[[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" @@ -10046,6 +10114,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6efd25aadc22c756a9012267fae0b130278d335d6ceb512ec02ce82029b5ca9" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version-compare" version = "0.2.0" @@ -11381,6 +11459,22 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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 = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -11943,6 +12037,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711e8c9646791c49b7eb799a19c4ae70a9b8372ddc6a1fa4778defd1e2aa5d5e" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.13.0", + "memchr", + "typed-path", +] + [[package]] name = "zmij" version = "1.0.21" @@ -12010,7 +12117,6 @@ dependencies = [ "endi", "enumflags2", "serde", - "url", "winnow 1.0.0", "zcheapstr", "zvariant_derive", diff --git a/crates/desktop/artcraft/Cargo.toml b/crates/desktop/artcraft/Cargo.toml index 51ff6bcd94..74eefe4928 100644 --- a/crates/desktop/artcraft/Cargo.toml +++ b/crates/desktop/artcraft/Cargo.toml @@ -22,6 +22,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2.6.2", features = [] } [dependencies] +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "2a24c10817173d26d0ea4689acbbade36f91b5dc" } toml.workspace = true browser_emulation.workspace = true cookie_store_wrapper = { workspace = true, features = ["wreq"] } diff --git a/crates/desktop/artcraft/src/core/commands/mod.rs b/crates/desktop/artcraft/src/core/commands/mod.rs index 4baa4c0584..460227085a 100644 --- a/crates/desktop/artcraft/src/core/commands/mod.rs +++ b/crates/desktop/artcraft/src/core/commands/mod.rs @@ -12,4 +12,5 @@ pub mod platform_info_command; pub mod providers; pub mod response; pub mod task_queue; -pub mod deprecated; \ No newline at end of file +pub mod deprecated; +pub mod updates; diff --git a/crates/desktop/artcraft/src/core/commands/updates/check_for_updates_command.rs b/crates/desktop/artcraft/src/core/commands/updates/check_for_updates_command.rs new file mode 100644 index 0000000000..54791e3e57 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/updates/check_for_updates_command.rs @@ -0,0 +1,8 @@ +use crate::updates::{UpdateInfo, Updates}; +use std::sync::Arc; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn check_for_updates_command(app: AppHandle, updates: State<'_, Arc>) -> Result { + updates.inner().run("The update check stopped unexpectedly", move |state| state.check(&app)).await +} diff --git a/crates/desktop/artcraft/src/core/commands/updates/install_update_command.rs b/crates/desktop/artcraft/src/core/commands/updates/install_update_command.rs new file mode 100644 index 0000000000..4346ad6769 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/updates/install_update_command.rs @@ -0,0 +1,9 @@ +use crate::updates::{require_confirmation, Updates}; +use std::sync::Arc; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn install_update_command(app: AppHandle, updates: State<'_, Arc>, confirmed: bool) -> Result<(), String> { + require_confirmation(confirmed)?; + updates.inner().run("The update installer stopped unexpectedly", move |state| state.install(&app)).await +} diff --git a/crates/desktop/artcraft/src/core/commands/updates/mod.rs b/crates/desktop/artcraft/src/core/commands/updates/mod.rs new file mode 100644 index 0000000000..d11e8510a5 --- /dev/null +++ b/crates/desktop/artcraft/src/core/commands/updates/mod.rs @@ -0,0 +1,2 @@ +pub mod check_for_updates_command; +pub mod install_update_command; diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs index 643c671371..64a3f98f22 100644 --- a/crates/desktop/artcraft/src/lib.rs +++ b/crates/desktop/artcraft/src/lib.rs @@ -6,6 +6,11 @@ use crate::core::commands::generate::generate_mesh_command::generate_mesh_comman pub mod core; pub mod services; pub mod version; +pub mod updates; + +use std::sync::Arc; +use crate::core::commands::updates::check_for_updates_command::check_for_updates_command; +use crate::core::commands::updates::install_update_command::install_update_command; use tauri::Manager; @@ -155,11 +160,20 @@ pub fn run() { println!("Initializing backend runtime..."); let builder = tauri::Builder::default() + .manage(Arc::new(updates::Updates::default())) + .on_window_event(|window, event| { + if window.try_state::>().is_some_and(|updates| updates.prevents_exit()) { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + } + } + }) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_upload::init()) .setup(move |app| { + updates::show_previous_outcome(app.handle()); // TODO(bt): This is broken on windows // log_environment_details(); @@ -225,6 +239,8 @@ pub fn run() { // TODO: Break this out into another module, because RustRover/IntelliJ lags with these macros. // My first attempt at naively doing this didn't work because the macros can't find their codegen'd targets. let builder = builder.invoke_handler(tauri::generate_handler![ + check_for_updates_command, + install_update_command, check_sora_session_command, download_directory_reveal_command, download_media_file_command, @@ -290,6 +306,14 @@ pub fn run() { worldlabs_receive_bearer_command, ]); - builder.run(tauri::generate_context!("tauri.conf.json")) - .expect("error while running tauri application"); + let app = builder.build(tauri::generate_context!("tauri.conf.json")) + .expect("error while building tauri application"); + app.run(|handle, event| { + let updates = handle.state::>(); + match event { + tauri::RunEvent::ExitRequested { api, .. } if updates.prevents_exit() => api.prevent_exit(), + tauri::RunEvent::Exit => updates.finish(), + _ => {} + } + }); } diff --git a/crates/desktop/artcraft/src/main.rs b/crates/desktop/artcraft/src/main.rs index 50008bc51b..6b27bc958e 100644 --- a/crates/desktop/artcraft/src/main.rs +++ b/crates/desktop/artcraft/src/main.rs @@ -2,5 +2,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if let Some(result) = artcraft_app_lib::updates::finish_helper() { + let failed = result.is_err(); + if let Err(error) = result { log::error!("Update failed: {error}"); } + std::process::exit(i32::from(failed)); + } artcraft_app_lib::run(); } diff --git a/crates/desktop/artcraft/src/updates.rs b/crates/desktop/artcraft/src/updates.rs new file mode 100644 index 0000000000..74311cb037 --- /dev/null +++ b/crates/desktop/artcraft/src/updates.rs @@ -0,0 +1,565 @@ +//! Manual update checks and verified installation shared with the native Craft apps. +use craft_update::pins::{Choice, Pins}; +use craft_update::self_update::Install; +use craft_update::{self_update, pick_asset, App, Http, Os, PackageKind, Paths, Platform, Release, Stop}; +use enums::tauri::tasks::task_status::TaskStatus; +use serde_derive::Serialize; +use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::DialogExt; +use crate::core::state::task_database::TaskDatabase; +use crate::version::ARTCRAFT_VERSION; + +const APP: App = App { bundle_id_override: Some("ai.artcraft.app"), ..App::new("artcraft", "ArtCraft", "Create images and video", "storytold/artcraft") }; + +#[derive(Default)] +pub struct Updates { + busy: AtomicBool, + installing: AtomicBool, + maybe_release: Mutex>, + maybe_restart: Mutex>, +} + +#[derive(Debug, Serialize)] +pub struct UpdateInfo { + pub current_version: String, + pub maybe_available_version: Option, + pub can_install: bool, + pub tasks_running: bool, + pub releases_url: String, +} + +type Restart = Box Result<(), String> + Send>; + +/// Only effects cross this boundary. Tests exercise the same state transitions without +/// networking, changing an installed copy, reading personal preferences, or exiting a process. +trait Backend { + fn stable_choice(&self) -> Result<(), String>; + fn check(&self) -> Result, String>; + fn idle(&self) -> Result<(), String>; + fn installation(&self) -> Option<(Install, Platform)>; + fn can_replace(&self) -> bool; + fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result; + fn request_exit(&self); +} + +struct NativeBackend<'a>(&'a AppHandle); +struct Installing<'a>(&'a AtomicBool); + +pub(crate) struct Operation { + updates: Arc, +} + +impl Updates { + pub(crate) fn begin(self: &Arc) -> Result { + self.busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).map_err(|_| "An update operation is already running. Please wait.".to_owned())?; + Ok(Operation { updates: Arc::clone(self) }) + } + + /// Runs `work` off the async runtime while it holds the one-operation guard. + pub(crate) async fn run(self: &Arc, stopped: &str, work: impl FnOnce(&Updates) -> Result + Send + 'static) -> Result { + let operation = self.begin()?; + let state = Arc::clone(self); + tauri::async_runtime::spawn_blocking(move || { + let _operation = operation; + work(&state) + }) + .await + .map_err(|error| format!("{stopped}: {error}"))? + } + + pub(crate) fn check(&self, app: &AppHandle) -> Result { + self.check_with(&NativeBackend(app)) + } + + fn check_with(&self, backend: &impl Backend) -> Result { + backend.stable_choice()?; + let maybe_release = backend.check()?; + let tasks_running = backend.idle().is_err(); + let self_updating = maybe_release.as_ref().is_some_and(|release| backend.can_replace() && backend.installation().is_some_and(|(install, platform)| release_fits_copy(release, &install, platform))); + let info = update_info(maybe_release.as_ref(), self_updating, tasks_running); + *self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")? = maybe_release; + Ok(info) + } + + pub(crate) fn install(&self, app: &AppHandle) -> Result<(), String> { + self.install_with(&NativeBackend(app)) + } + + fn install_with(&self, backend: &impl Backend) -> Result<(), String> { + self.installing.store(true, Ordering::Release); + let _installing = Installing(&self.installing); + backend.idle()?; + backend.stable_choice()?; + let release = self.maybe_release.lock().map_err(|_| "The update state is unavailable. Restart ArtCraft and try again.")?.clone().ok_or("Check for updates before installing one.")?; + let (install, platform) = backend.installation().ok_or("This installation cannot update itself. Open Releases or use its package manager.")?; + if !backend.can_replace() || !release_fits_copy(&release, &install, platform) { + return Err("Use Releases or your package manager to update this installation.".into()); + } + // Recheck tasks and launcher choices immediately before replacing any application files. + let before_replace = || backend.idle().and_then(|()| backend.stable_choice()); + let restart = backend.prepare(&install, &release, platform, &before_replace)?; + *self.maybe_restart.lock().map_err(|_| "The restart state is unavailable. Restart ArtCraft manually.")? = Some(restart); + // The helper starts in RunEvent::Exit, after Tauri accepts the normal exit request. + backend.request_exit(); + Ok(()) + } + + /// Keep the native process and window alive until replacement finishes. A prepared helper + /// may request the normal exit itself, so its restart must not be cancelled here. + pub fn prevents_exit(&self) -> bool { + self.installing.load(Ordering::Acquire) && self.maybe_restart.lock().map_or(true, |pending| pending.is_none()) + } + + pub fn finish(&self) { + match self.maybe_restart.lock() { + Ok(mut pending) => { + if let Some(restart) = pending.take() { + if let Err(error) = restart() { + log::error!("Could not start the update helper: {error}"); + } + } + }, + Err(_) => log::error!("Could not read the prepared update helper"), + } + } +} + +impl Drop for Installing<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl Drop for Operation { + fn drop(&mut self) { + self.updates.busy.store(false, Ordering::Release); + } +} + +pub fn finish_helper() -> Option> { + self_update::finish(&APP, &std::env::args_os().skip(1).collect::>()) +} + +pub fn show_previous_outcome(app: &AppHandle) { + let Ok(paths) = Paths::for_app(&APP) else { + return; + }; + if let Some(message) = outcome_message(self_update::take_outcome(&paths, ARTCRAFT_VERSION)) { + app.dialog().message(message).title("ArtCraft update").show(|_| {}); + } +} + +fn outcome_message(outcome: Option) -> Option { + match outcome? { + self_update::Outcome::Installed(version) => Some(format!("ArtCraft was updated to {version}.")), + self_update::Outcome::Failed(error) => Some(format!("The ArtCraft update failed: {error}")), + } +} + +pub(crate) fn require_confirmation(confirmed: bool) -> Result<(), String> { + if confirmed { + Ok(()) + } else { + Err("Confirm that you saved your work before installing the update.".into()) + } +} + +fn stable_choice(pins: &Pins) -> Result<(), String> { + match pins.choice(APP.id) { + Choice::Stable => Ok(()), + _ => Err("ArtCraft Launcher manages this app's version. Change its version there.".into()), + } +} + +fn ensure_idle(app: &AppHandle) -> Result<(), String> { + let db = app.try_state::().ok_or("ArtCraft is still starting. Try checking again in a moment.")?; + let tasks = tauri::async_runtime::block_on(list_tasks_for_frontend(db.get_connection())).map_err(|error| format!("Could not check running tasks: {error}"))?; + no_active_tasks(tasks.tasks.iter().map(|task| task.status)) +} + +fn no_active_tasks(statuses: impl IntoIterator) -> Result<(), String> { + if statuses.into_iter().any(active_task) { + Err("Finish or cancel running tasks before installing an update.".into()) + } else { + Ok(()) + } +} + +fn active_task(status: TaskStatus) -> bool { + matches!(status, TaskStatus::Pending | TaskStatus::Started | TaskStatus::AttemptFailed) +} + +impl Backend for NativeBackend<'_> { + fn stable_choice(&self) -> Result<(), String> { + stable_choice(&Pins::load_default()) + } + + fn check(&self) -> Result, String> { + let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?; + craft_update::check_for_update(&APP, &http, ARTCRAFT_VERSION) + } + + fn idle(&self) -> Result<(), String> { + ensure_idle(self.0) + } + + fn installation(&self) -> Option<(Install, Platform)> { + self_update::current(&APP).zip(Platform::current()) + } + + fn can_replace(&self) -> bool { + self_update::is_self_updating(&APP) + } + + fn prepare(&self, install: &Install, release: &Release, platform: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result { + let http = Http::new(&format!("ArtCraft/{ARTCRAFT_VERSION}"))?; + let paths = Paths::for_app(&APP)?; + let restart = self_update::prepare_update(&APP, install, release, platform, &http, &paths, &Stop::default(), &mut |_| {}, before_replace, Some(ARTCRAFT_VERSION))?; + Ok(Box::new(move || restart.start())) + } + + fn request_exit(&self) { + self.0.exit(0); + } +} + +fn release_fits_copy(release: &Release, install: &Install, platform: Platform) -> bool { + let kind = match install { + Install::MacApp(_) => PackageKind::Dmg, + Install::Msi(_) => PackageKind::Msi, + Install::Portable(_) => PackageKind::PortableZip, + Install::AppImage(_) => PackageKind::AppImage, + Install::Package(kind, _) => *kind, + Install::Flatpak => PackageKind::Flatpak, + // ArtCraft also ships NSIS. It must not be unpacked over as a loose portable copy; + // its registered installer remains its update path until the shared helper supports it. + Install::Standalone(_) if platform.os == Os::Windows => return false, + Install::Standalone(_) => PackageKind::TarGz, + }; + pick_asset(release, &[APP.id], platform, &[kind]).is_some() +} + +fn update_info(maybe_release: Option<&Release>, self_updating: bool, tasks_running: bool) -> UpdateInfo { + UpdateInfo { current_version: ARTCRAFT_VERSION.into(), maybe_available_version: maybe_release.map(Release::version_label), can_install: !tasks_running && self_updating && maybe_release.is_some(), tasks_running, releases_url: APP.releases_url() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_identity_and_display_version_match_the_release_configuration() { + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).expect("release configuration"); + assert_eq!(config["version"].as_str(), Some(ARTCRAFT_VERSION)); + assert_eq!(config["identifier"].as_str(), Some(APP.bundle_id().as_str())); + } + + #[test] + fn tauri_installers_and_flatpaks_are_offered_only_for_the_matching_copy() { + use craft_update::{Arch, Asset}; + let release = Release { assets: ["ArtCraft_0.42.1_x64_en-US.msi", "ArtCraft_0.42.1_universal.dmg", "artcraft-0.42.1-linux-x86_64.flatpak"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() }; + assert!(release_fits_copy(&release, &Install::Msi("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 })); + assert!(release_fits_copy(&release, &Install::MacApp("unused.app".into()), Platform { os: Os::MacOs, arch: Arch::Arm64 })); + assert!(release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::X64 })); + assert!(!release_fits_copy(&release, &Install::Flatpak, Platform { os: Os::Linux, arch: Arch::Arm64 })); + assert!(!release_fits_copy(&release, &Install::Standalone("unused.exe".into()), Platform { os: Os::Windows, arch: Arch::X64 })); + } + + #[test] + fn checks_allow_closing_but_an_unfinished_install_keeps_the_window_alive() { + let state = Arc::new(Updates::default()); + let _check = state.begin().expect("checking"); + assert!(!state.prevents_exit()); + state.installing.store(true, Ordering::Release); + let installing = Installing(&state.installing); + assert!(state.prevents_exit()); + drop(installing); + assert!(!state.prevents_exit()); + } + + #[test] + fn running_and_retrying_tasks_block_an_update_but_completed_tasks_do_not() { + for status in [TaskStatus::Pending, TaskStatus::Started, TaskStatus::AttemptFailed] { + assert!(active_task(status)); + } + for status in [TaskStatus::CompleteSuccess, TaskStatus::CompleteFailure, TaskStatus::Dead, TaskStatus::CancelledByUser, TaskStatus::CancelledByProvider, TaskStatus::CancelledByUs] { + assert!(!active_task(status)); + } + assert!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Dead]).is_ok()); + assert!(no_active_tasks([]).is_ok()); + assert_eq!(no_active_tasks([TaskStatus::CompleteSuccess, TaskStatus::Started]), Err("Finish or cancel running tasks before installing an update.".into())); + } + + #[test] + fn only_the_stable_track_lets_artcraft_update_itself() { + use craft_update::pins::VersionTrack; + let mut pins = Pins::default(); + assert!(stable_choice(&pins).is_ok()); + pins.set(APP.id, &VersionTrack::Pinned("0.41.0".into())); + assert!(stable_choice(&pins).unwrap_err().contains("ArtCraft Launcher manages this app's version")); + pins.set(APP.id, &VersionTrack::Nightly); + assert!(stable_choice(&pins).is_err()); + pins.set(APP.id, &VersionTrack::Stable); + assert!(stable_choice(&pins).is_ok()); + } + + #[test] + fn a_previous_update_outcome_is_reported_once_with_its_version_or_error() { + let installed = craft_update::parse_version("0.42.1").expect("version"); + assert_eq!(outcome_message(Some(self_update::Outcome::Installed(installed))).as_deref(), Some("ArtCraft was updated to 0.42.1.")); + assert_eq!(outcome_message(Some(self_update::Outcome::Failed("disk full".into()))).as_deref(), Some("The ArtCraft update failed: disk full")); + assert_eq!(outcome_message(None), None); + } + + #[test] + fn installing_requires_confirmation_and_an_ordinary_start_is_not_the_helper() { + assert!(require_confirmation(false).unwrap_err().contains("saved your work")); + assert!(require_confirmation(true).is_ok()); + // The test harness's own arguments never carry the helper flag. + assert!(finish_helper().is_none()); + } + + #[test] + fn commands_run_one_operation_at_a_time_and_release_it_after_failures() { + let state = Arc::new(Updates::default()); + let value = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(7))); + assert_eq!(value, Ok(7)); + let failed: Result<(), String> = tauri::async_runtime::block_on(state.run("stopped", |_| Err("offline".into()))); + assert_eq!(failed, Err("offline".into())); + let held = state.begin().expect("guard released after a failure"); + let busy = tauri::async_runtime::block_on(state.run("stopped", |_| Ok(()))); + assert!(busy.unwrap_err().contains("already running")); + drop(held); + let panicked: Result<(), String> = tauri::async_runtime::block_on(state.run("The update check stopped unexpectedly", |_| panic!("worker crashed"))); + assert!(panicked.unwrap_err().starts_with("The update check stopped unexpectedly:")); + assert!(state.begin().is_ok(), "a crashed worker still releases the guard"); + } + + #[test] + fn only_a_replaceable_idle_copy_can_offer_installation() { + let release = Release { tag: "artcraft-v99.0.0".into(), ..Release::default() }; + assert!(!update_info(Some(&release), false, false).can_install); + assert!(!update_info(Some(&release), true, true).can_install); + assert!(!update_info(None, true, false).can_install); + assert_eq!(update_info(Some(&release), false, false).maybe_available_version.as_deref(), Some("artcraft-v99.0.0")); + assert_eq!(APP.bundle_id(), "ai.artcraft.app"); + } + + #[test] + fn operations_are_serialized_and_errors_release_the_guard() { + let state = Arc::new(Updates::default()); + let first = state.begin().expect("first check"); + assert!(state.begin().is_err()); + drop(first); + assert!(state.begin().is_ok()); + assert!(state.maybe_release.lock().expect("state").is_none()); + } + + #[test] + fn a_manual_check_offers_a_matching_idle_install_and_defers_its_helper_until_exit() { + let state = Updates::default(); + let backend = FakeBackend::default(); + let info = state.check_with(&backend).expect("checked"); + assert!(info.can_install); + state.install_with(&backend).expect("prepared"); + assert!(backend.exit_requested.load(Ordering::Acquire)); + assert!(!backend.helper_started.load(Ordering::Acquire)); + assert!(!state.prevents_exit(), "the prepared update may exit normally"); + state.finish(); + assert!(backend.helper_started.load(Ordering::Acquire)); + state.finish(); + assert_eq!(backend.helper_runs.load(Ordering::Acquire), 1, "exit events cannot run a helper twice"); + } + + #[test] + fn a_pin_stops_network_checks_and_replacement() { + let state = Updates::default(); + let backend = FakeBackend::default(); + backend.pinned.store(true, Ordering::Release); + assert!(state.check_with(&backend).unwrap_err().contains("pinned")); + assert!(!backend.checked.load(Ordering::Acquire)); + assert!(state.install_with(&backend).unwrap_err().contains("pinned")); + assert!(!state.prevents_exit()); + assert!(!backend.exit_requested.load(Ordering::Acquire)); + } + + #[test] + fn running_tasks_allow_a_check_but_block_the_install() { + let state = Updates::default(); + let backend = FakeBackend::default(); + backend.busy.store(true, Ordering::Release); + let info = state.check_with(&backend).expect("checking while busy"); + assert!(info.tasks_running); + assert!(!info.can_install); + assert!(state.install_with(&backend).unwrap_err().contains("busy")); + assert!(!state.prevents_exit()); + assert!(!backend.exit_requested.load(Ordering::Acquire)); + } + + #[test] + fn a_pin_or_task_started_during_download_stops_replacement_and_exit() { + for pin in [false, true] { + let state = Updates::default(); + let backend = FakeBackend::default(); + state.check_with(&backend).expect("checked"); + backend.pin_during_download.store(pin, Ordering::Release); + backend.task_during_download.store(!pin, Ordering::Release); + assert!(state.install_with(&backend).is_err()); + assert!(!state.prevents_exit(), "failed installation releases the close guard"); + assert!(!backend.exit_requested.load(Ordering::Acquire)); + assert!(state.maybe_restart.lock().expect("restart state").is_none()); + } + } + + #[test] + fn an_install_requires_a_previous_check_and_a_replaceable_matching_copy() { + let state = Updates::default(); + let backend = FakeBackend::default(); + assert!(state.install_with(&backend).unwrap_err().contains("Check for updates")); + state.check_with(&backend).expect("checked"); + backend.managed.store(true, Ordering::Release); + assert!(!state.check_with(&backend).expect("managed check").can_install); + assert!(state.install_with(&backend).unwrap_err().contains("package manager")); + backend.managed.store(false, Ordering::Release); + backend.missing_install.store(true, Ordering::Release); + assert!(!state.check_with(&backend).expect("unknown install").can_install); + assert!(state.install_with(&backend).unwrap_err().contains("cannot update")); + backend.missing_install.store(false, Ordering::Release); + backend.wrong_arch.store(true, Ordering::Release); + assert!(!state.check_with(&backend).expect("no matching asset").can_install); + assert!(state.install_with(&backend).unwrap_err().contains("package manager")); + assert!(!backend.exit_requested.load(Ordering::Acquire)); + } + + #[test] + fn no_new_release_clears_a_previous_offer_and_a_failed_check_is_reported() { + let state = Updates::default(); + let backend = FakeBackend::default(); + state.check_with(&backend).expect("first check"); + backend.no_release.store(true, Ordering::Release); + assert!(!state.check_with(&backend).expect("current version").can_install); + assert!(state.maybe_release.lock().expect("release").is_none()); + backend.network_error.store(true, Ordering::Release); + assert!(state.check_with(&backend).unwrap_err().contains("offline")); + } + + #[test] + fn a_failed_download_never_requests_exit_or_keeps_the_close_guard() { + let state = Updates::default(); + let backend = FakeBackend::default(); + state.check_with(&backend).expect("check"); + backend.download_error.store(true, Ordering::Release); + assert!(state.install_with(&backend).unwrap_err().contains("download")); + assert!(!state.prevents_exit()); + assert!(!backend.exit_requested.load(Ordering::Acquire)); + } + + #[test] + fn poisoned_state_returns_an_error_and_refuses_an_unsafe_exit() { + let state = Arc::new(Updates::default()); + let other = Arc::clone(&state); + let _ = std::thread::spawn(move || { + let _guard = other.maybe_release.lock().unwrap(); + panic!("simulate a previous worker failure"); + }) + .join(); + let backend = FakeBackend::default(); + assert!(state.check_with(&backend).is_err()); + assert!(state.install_with(&backend).is_err()); + let other = Arc::clone(&state); + let _ = std::thread::spawn(move || { + let _guard = other.maybe_restart.lock().unwrap(); + panic!("simulate a previous helper-state failure"); + }) + .join(); + state.installing.store(true, Ordering::Release); + assert!(state.prevents_exit()); + state.finish(); + } + + #[test] + fn every_supported_install_kind_requires_its_own_asset() { + use craft_update::{Arch, Asset}; + let linux = Platform { os: Os::Linux, arch: Arch::X64 }; + let windows = Platform { os: Os::Windows, arch: Arch::X64 }; + let release = Release { assets: ["artcraft-99.0.0-linux-x86_64.AppImage", "artcraft-99.0.0-linux-x86_64.tar.gz", "artcraft-99.0.0-linux-x86_64.deb", "artcraft-99.0.0-windows-x64-portable.zip"].into_iter().map(|name| Asset { name: name.into(), ..Asset::default() }).collect(), ..Release::default() }; + for install in [Install::AppImage("unused".into()), Install::Standalone("unused".into()), Install::Package(PackageKind::Deb, "unused".into())] { + assert!(release_fits_copy(&release, &install, linux)); + } + assert!(release_fits_copy(&release, &Install::Portable("unused".into()), windows)); + assert!(!release_fits_copy(&release, &Install::Package(PackageKind::Rpm, "unused".into()), linux)); + } + + #[derive(Default)] + struct FakeBackend { + pinned: AtomicBool, + busy: AtomicBool, + managed: AtomicBool, + missing_install: AtomicBool, + wrong_arch: AtomicBool, + no_release: AtomicBool, + network_error: AtomicBool, + download_error: AtomicBool, + pin_during_download: AtomicBool, + task_during_download: AtomicBool, + checked: AtomicBool, + exit_requested: AtomicBool, + helper_started: Arc, + helper_runs: Arc, + } + + impl Backend for FakeBackend { + fn stable_choice(&self) -> Result<(), String> { + if self.pinned.load(Ordering::Acquire) { + Err("pinned".into()) + } else { + Ok(()) + } + } + fn check(&self) -> Result, String> { + self.checked.store(true, Ordering::Release); + if self.network_error.load(Ordering::Acquire) { + return Err("offline".into()); + } + Ok((!self.no_release.load(Ordering::Acquire)).then(|| Release { tag: "artcraft-v99.0.0".into(), assets: vec![craft_update::Asset { name: "artcraft-99.0.0-linux-x86_64.flatpak".into(), ..Default::default() }], ..Default::default() })) + } + fn idle(&self) -> Result<(), String> { + if self.busy.load(Ordering::Acquire) { + Err("busy".into()) + } else { + Ok(()) + } + } + fn installation(&self) -> Option<(Install, Platform)> { + (!self.missing_install.load(Ordering::Acquire)).then(|| (Install::Flatpak, Platform { os: Os::Linux, arch: if self.wrong_arch.load(Ordering::Acquire) { craft_update::Arch::Arm64 } else { craft_update::Arch::X64 } })) + } + fn can_replace(&self) -> bool { + !self.managed.load(Ordering::Acquire) + } + fn prepare(&self, _: &Install, _: &Release, _: Platform, before_replace: &dyn Fn() -> Result<(), String>) -> Result { + if self.download_error.load(Ordering::Acquire) { + return Err("download failed".into()); + } + if self.pin_during_download.load(Ordering::Acquire) { + self.pinned.store(true, Ordering::Release); + } + if self.task_during_download.load(Ordering::Acquire) { + self.busy.store(true, Ordering::Release); + } + before_replace()?; + let started = Arc::clone(&self.helper_started); + let runs = Arc::clone(&self.helper_runs); + Ok(Box::new(move || { + started.store(true, Ordering::Release); + runs.fetch_add(1, Ordering::AcqRel); + Ok(()) + })) + } + fn request_exit(&self) { + self.exit_requested.store(true, Ordering::Release); + } + } +} diff --git a/crates/desktop/artcraft/src/version.rs b/crates/desktop/artcraft/src/version.rs index ecabc86b5b..b959a2445c 100644 --- a/crates/desktop/artcraft/src/version.rs +++ b/crates/desktop/artcraft/src/version.rs @@ -1,4 +1,4 @@ /// The version of the ArtCraft desktop app reported in the UI and via Analytics. /// This should match the CI builds. -pub const ARTCRAFT_VERSION: &str = "0.42.0"; +pub const ARTCRAFT_VERSION: &str = "0.42.1"; diff --git a/crates/desktop/artcraft/tauri.conf.json b/crates/desktop/artcraft/tauri.conf.json index d6c0e71830..8c45b83f0e 100644 --- a/crates/desktop/artcraft/tauri.conf.json +++ b/crates/desktop/artcraft/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2.0.0-rc", "productName": "ArtCraft", - "version": "0.42.0", + "version": "0.42.1", "identifier": "ai.artcraft.app", "build": { "frontendDist": "../../../frontend/apps/artcraft/dist", diff --git a/frontend/libs/components/help-menu/src/lib/help-menu.spec.tsx b/frontend/libs/components/help-menu/src/lib/help-menu.spec.tsx index 3777966730..e69bdd4a8a 100644 --- a/frontend/libs/components/help-menu/src/lib/help-menu.spec.tsx +++ b/frontend/libs/components/help-menu/src/lib/help-menu.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import HelpMenuButton from './help-menu'; @@ -9,4 +9,11 @@ describe('HelpMenuButton', () => { expect(baseElement).toBeTruthy(); }); + it('opens the update dialog from Help', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Help' })); + fireEvent.click(await screen.findByText('Check for Updates…')); + expect(await screen.findByRole('dialog', { name: 'Check for Updates' }, { timeout: 5000 })).toBeTruthy(); + expect(screen.getByText('The web app updates when you reload. Open Releases to get the desktop app.')).toBeTruthy(); + }); }); diff --git a/frontend/libs/components/help-menu/src/lib/help-menu.tsx b/frontend/libs/components/help-menu/src/lib/help-menu.tsx index 298dc3e686..280ba3f67f 100644 --- a/frontend/libs/components/help-menu/src/lib/help-menu.tsx +++ b/frontend/libs/components/help-menu/src/lib/help-menu.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { ArrowRightIcon, BookIcon, ChevronLeftIcon, CircleHelpIcon, NewspaperIcon } from "lucide-react"; +import { ArrowRightIcon, BookIcon, ChevronLeftIcon, CircleHelpIcon, NewspaperIcon, RefreshCwIcon } from "lucide-react"; import { DiscordIcon, GithubIcon, YoutubeIcon } from "@storyteller/icons"; import { OpenUrl } from "@storyteller/tauri-api"; import { Modal } from "@storyteller/ui-modal"; @@ -10,6 +10,8 @@ import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover"; import { getNewsPosts, markdownToHtml } from "@storyteller/markdown-content"; import { clickOnEnterOrSpace } from "@storyteller/common"; +import { UpdateDialog } from "./update-dialog"; + export type HelpMenuButtonProps = { items?: TutorialItem[]; panelTitle?: string; @@ -150,6 +152,7 @@ export function HelpMenuButton({ onOpenChange, }: HelpMenuButtonProps) { const [open, setOpen] = useState(false); + const [updatesOpen, setUpdatesOpen] = useState(false); const tutorials = useMemo(() => items ?? defaultTutorials, [items]); const view = useTutorialModalStore((s) => s.view); const selected = useTutorialModalStore((s) => s.selected); @@ -180,6 +183,7 @@ export function HelpMenuButton({ // Menu items for the popover const menuItems: PopoverItem[] = [ + { label: "Check for Updates…", selected: false, icon: , action: "updates" }, { label: "Tutorials", selected: false, @@ -214,6 +218,9 @@ export function HelpMenuButton({ const handleMenuSelect = (item: PopoverItem) => { switch (item.action) { + case "updates": + setUpdatesOpen(true); + break; case "tutorials": handleOpenTutorials(); break; @@ -340,6 +347,7 @@ export function HelpMenuButton({ return (
+ setUpdatesOpen(false)} /> ({ isTauri: vi.fn() })); +vi.mock("@storyteller/tauri-api", () => ({ CheckForUpdates: vi.fn(), InstallUpdate: vi.fn(), OpenUrl: vi.fn() })); +vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, variant: _variant, ...props }: any) => })); +vi.mock("@storyteller/ui-modal", () => ({ Modal: ({ isOpen, title, onClose, showClose, children }: any) => isOpen ? ( +
{children}
+) : null })); + +const INFO: UpdateInfo = { current_version: "0.42.0", maybe_available_version: "0.42.1", can_install: true, + tasks_running: false, releases_url: "https://github.com/storytold/artcraft/releases" }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(CheckForUpdates).mockResolvedValue(INFO); + vi.mocked(InstallUpdate).mockResolvedValue(undefined); +}); +afterEach(cleanup); + +describe("manual updates", () => { + it("does not check while closed and cancelling never installs", async () => { + const close = vi.fn(); + const view = render(); + expect(CheckForUpdates).not.toHaveBeenCalled(); + view.rerender(); + await screen.findByText("0.42.1 is available."); + expect(screen.getByText("ArtCraft 0.42.0")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(close).toHaveBeenCalledOnce(); + expect(InstallUpdate).not.toHaveBeenCalled(); + }); + + it("shares StrictMode checks while progress remains visible", async () => { + let complete!: (info: UpdateInfo) => void; + vi.mocked(CheckForUpdates).mockReturnValue(new Promise((resolve) => { complete = resolve; })); + render(); + expect(CheckForUpdates).toHaveBeenCalledOnce(); + expect(screen.getByRole("status").textContent).toContain("Checking for updates"); + expect((screen.getByRole("button", { name: "Check again" }) as HTMLButtonElement).disabled).toBe(true); + await act(async () => { complete(INFO); }); + expect(screen.getByText("0.42.1 is available.")).toBeTruthy(); + }); + + it("requires an install click and prevents dismissal during installation", async () => { + vi.mocked(InstallUpdate).mockReturnValue(new Promise(() => {})); + const close = vi.fn(); + render(); + await screen.findByText("0.42.1 is available."); + expect(InstallUpdate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Update and restart" })); + expect(InstallUpdate).toHaveBeenCalledOnce(); + expect(screen.getByRole("status").textContent).toContain("Downloading and verifying"); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + expect((screen.getByRole("button", { name: "Close" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement).disabled).toBe(true); + expect(close).not.toHaveBeenCalled(); + }); + + it("reports a check failure and allows another manual check", async () => { + vi.mocked(CheckForUpdates).mockRejectedValueOnce("Network unavailable"); + render(); + expect((await screen.findByRole("alert")).textContent).toBe("Network unavailable"); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + await screen.findByText("0.42.1 is available."); + expect(CheckForUpdates).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("restores controls after verification fails without closing the app", async () => { + vi.mocked(InstallUpdate).mockRejectedValue("Signature verification failed"); + const close = vi.fn(); + render(); + await screen.findByText("0.42.1 is available."); + fireEvent.click(screen.getByRole("button", { name: "Update and restart" })); + expect((await screen.findByRole("alert")).textContent).toBe("Signature verification failed"); + expect((screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); + expect(close).not.toHaveBeenCalled(); + }); + + it.each([false, true])("cannot install a managed or busy copy (tasks running: %s)", async (running) => { + vi.mocked(CheckForUpdates).mockResolvedValue({ ...INFO, can_install: false, tasks_running: running }); + render(); + await screen.findByText("0.42.1 is available."); + const button = screen.getByRole("button", { name: "Update and restart" }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + fireEvent.click(button); + expect(InstallUpdate).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Releases" })); + expect(OpenUrl).toHaveBeenCalledWith(INFO.releases_url); + }); + + it("offers browser users a reload without invoking native commands", () => { + vi.mocked(isTauri).mockReturnValue(false); + render(); + expect(screen.getByRole("status").textContent).toContain("updates when you reload"); + expect(CheckForUpdates).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Update and restart" })).toBeNull(); + }); +}); diff --git a/frontend/libs/components/help-menu/src/lib/update-dialog.tsx b/frontend/libs/components/help-menu/src/lib/update-dialog.tsx new file mode 100644 index 0000000000..8adc25c7ad --- /dev/null +++ b/frontend/libs/components/help-menu/src/lib/update-dialog.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from "react"; +import { isTauri } from "@tauri-apps/api/core"; +import { CheckForUpdates, InstallUpdate, OpenUrl, type UpdateInfo } from "@storyteller/tauri-api"; +import { Button } from "@storyteller/ui-button"; +import { Modal } from "@storyteller/ui-modal"; + +const RELEASES = "https://github.com/storytold/artcraft/releases"; + +export function UpdateDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + const [checking, setChecking] = useState(false); + const [installing, setInstalling] = useState(false); + const pendingCheck = useRef | null>(null); + const desktop = isTauri(); + + const check = async () => { + setChecking(true); + setError(null); + setInfo(null); + try { + // StrictMode or a repeated click must share one native background check. + const pending = pendingCheck.current ?? CheckForUpdates(); + pendingCheck.current = pending; + setInfo(await pending); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + pendingCheck.current = null; + setChecking(false); + } + }; + + useEffect(() => { + if (isOpen && desktop) void check(); + // A check is manual: reopening this dialog is the only automatic trigger. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, desktop]); + + const install = async () => { + if (!info?.can_install || installing) return; + setInstalling(true); + setError(null); + try { + await InstallUpdate(); + // Success closes the application through its normal native exit path. + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + setInstalling(false); + } + }; + + return ( + { if (!installing) onClose(); }} + title="Check for Updates" + accessibleTitle="Check for Updates" + className="max-w-lg" + showClose={!installing} + closeOnEsc={!installing} + closeOnOutsideClick={!installing} + > +
+

ArtCraft{info ? ` ${info.current_version}` : ""}

+
+ {!desktop ? "The web app updates when you reload. Open Releases to get the desktop app." + : installing ? "Downloading and verifying the update… ArtCraft will restart when it is ready." + : checking ? "Checking for updates…" + : info?.maybe_available_version ? `${info.maybe_available_version} is available.` + : info ? "You're up to date." : null} +
+ {error &&

{error}

} + {info?.maybe_available_version && ( +

+ {info.tasks_running ? "Finish or cancel running tasks, then check again before updating." + : info.can_install ? "Save your work before updating. ArtCraft will close and restart." + : "Use Releases or your package manager to update this installation."} +

+ )} +
+ + {desktop && } + {info?.maybe_available_version && } + {!installing && } +
+
+
+ ); +} diff --git a/frontend/libs/tauri-api/src/index.ts b/frontend/libs/tauri-api/src/index.ts index f36cc0c39d..15fd1c6cff 100644 --- a/frontend/libs/tauri-api/src/index.ts +++ b/frontend/libs/tauri-api/src/index.ts @@ -54,3 +54,5 @@ export * from "./lib/models/modelsStore.js"; export * from "./lib/common/commandErrorMessage.js"; export * from "./lib/generate/GenerateOmni.js"; + +export * from "./lib/system/Updates.js"; diff --git a/frontend/libs/tauri-api/src/lib/system/Updates.ts b/frontend/libs/tauri-api/src/lib/system/Updates.ts new file mode 100644 index 0000000000..dead46aa92 --- /dev/null +++ b/frontend/libs/tauri-api/src/lib/system/Updates.ts @@ -0,0 +1,15 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface UpdateInfo { + current_version: string; + maybe_available_version: string | null; + can_install: boolean; + tasks_running: boolean; + releases_url: string; +} + +export const CheckForUpdates = (): Promise => + invoke("check_for_updates_command"); + +export const InstallUpdate = (): Promise => + invoke("install_update_command", { confirmed: true }); diff --git a/packaging/linux/flatpak-update-test.sh b/packaging/linux/flatpak-update-test.sh new file mode 100644 index 0000000000..a44330a64f --- /dev/null +++ b/packaging/linux/flatpak-update-test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# A bundle must reach releases, honor launcher choices, and invoke the verified host updater. +set -euo pipefail +manifest="$(dirname "$0")/flatpak.yml" +for permission in --share=network --talk-name=org.freedesktop.Flatpak --filesystem=xdg-data/ArtCraft/versions:ro; do + if ! grep -Fxq -- " - $permission" "$manifest"; then + echo "Missing Flatpak update permission: $permission" >&2 + exit 1 + fi +done diff --git a/packaging/linux/flatpak.yml b/packaging/linux/flatpak.yml index 705aa5d345..f9aa2015d2 100644 --- a/packaging/linux/flatpak.yml +++ b/packaging/linux/flatpak.yml @@ -4,6 +4,10 @@ runtime-version: '51' sdk: org.gnome.Sdk command: artcraft finish-args: + # The shared updater inspects and replaces this signed release bundle on the host. + - --talk-name=org.freedesktop.Flatpak + # Honor launcher-pinned versions without access to other host application data. + - --filesystem=xdg-data/ArtCraft/versions:ro - --socket=wayland - --socket=fallback-x11 - --socket=pulseaudio diff --git a/packaging/linux/package-test.sh b/packaging/linux/package-test.sh index a5f4c1daac..e56d9c7338 100755 --- a/packaging/linux/package-test.sh +++ b/packaging/linux/package-test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Integration fixture: real DEB/RPM archives, with no Rust compilation or signing key. set -euo pipefail +bash "$(dirname "$0")/flatpak-update-test.sh" script="$(realpath "$(dirname "$0")/package.sh")" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT