mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-12 04:42:16 +00:00
Add manual verified updates and bump ArtCraft to 0.42.1 (#2835)
ArtCraft’s Help menu has news but no application update check, and its Flatpak cannot invoke the host updater. Add **Help → Check for Updates…** with background checks, current/available versions, explicit Update and restart, progress and failure states, and Releases fallback. Browser users are offered a reload and the desktop releases. Use the same verified `craft-update` core as the native Craft apps, pinned to the immutable reviewed commit in https://github.com/storytold/craft-libs/pull/970. Keep ArtCraft’s existing `ai.artcraft.app` identity and release naming. Honor launcher pins, reject active generation tasks before download and again before replacement, preserve the native window during installation, defer the helper until accepted exit, and report helper outcomes on startup. Registered NSIS copies use Releases; they are not overwritten as portable folders. Grant the host update interface and read-only launcher choices in the existing Flatpak manifest. No frontend network-policy expansion. Add a separate version commit changing the canonical Tauri configuration and display version to **0.42.1**, with a regression keeping them synchronized. Once this and the shared-library prerequisite are green and merged, merge main into the release branch, finish all platform drafts, verify signed checksums/assets, and publish. Validation: native all-target cargo check inside GNOME SDK 51; all six focused Rust updater tests; all ten Help-menu/UI tests; TypeScript project build; production frontend build; Flatpak permission regression red before and green after. All six native SDK tests also pass at the final 0.42.1 version. The existing lockfile needs compatible chrono and crc32fast updates for the shared updater; other dependency revisions are preserved.
This commit is contained in:
1 parent
a787c371f6
commit
4ecd4b7eea
20 files changed
+1039
-75
No files matched your search
Generated
+174
-68
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -12,4 +12,5 @@ pub mod platform_info_command;
|
||||
pub mod providers;
|
||||
pub mod response;
|
||||
pub mod task_queue;
|
||||
pub mod deprecated;
|
||||
pub mod deprecated;
|
||||
pub mod updates;
|
||||
@@ -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<Updates>>) -> Result<UpdateInfo, String> {
|
||||
updates.inner().run("The update check stopped unexpectedly", move |state| state.check(&app)).await
|
||||
}
|
||||
@@ -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<Updates>>, confirmed: bool) -> Result<(), String> {
|
||||
require_confirmation(confirmed)?;
|
||||
updates.inner().run("The update installer stopped unexpectedly", move |state| state.install(&app)).await
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod check_for_updates_command;
|
||||
pub mod install_update_command;
|
||||
@@ -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::<Arc<updates::Updates>>().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::<Arc<updates::Updates>>();
|
||||
match event {
|
||||
tauri::RunEvent::ExitRequested { api, .. } if updates.prevents_exit() => api.prevent_exit(),
|
||||
tauri::RunEvent::Exit => updates.finish(),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<Option<Release>>,
|
||||
maybe_restart: Mutex<Option<Restart>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub current_version: String,
|
||||
pub maybe_available_version: Option<String>,
|
||||
pub can_install: bool,
|
||||
pub tasks_running: bool,
|
||||
pub releases_url: String,
|
||||
}
|
||||
|
||||
type Restart = Box<dyn FnOnce() -> 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<Option<Release>, 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<Restart, String>;
|
||||
fn request_exit(&self);
|
||||
}
|
||||
|
||||
struct NativeBackend<'a>(&'a AppHandle);
|
||||
struct Installing<'a>(&'a AtomicBool);
|
||||
|
||||
pub(crate) struct Operation {
|
||||
updates: Arc<Updates>,
|
||||
}
|
||||
|
||||
impl Updates {
|
||||
pub(crate) fn begin(self: &Arc<Self>) -> Result<Operation, String> {
|
||||
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<T: Send + 'static>(self: &Arc<Self>, stopped: &str, work: impl FnOnce(&Updates) -> Result<T, String> + Send + 'static) -> Result<T, String> {
|
||||
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<UpdateInfo, String> {
|
||||
self.check_with(&NativeBackend(app))
|
||||
}
|
||||
|
||||
fn check_with(&self, backend: &impl Backend) -> Result<UpdateInfo, String> {
|
||||
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<Result<(), String>> {
|
||||
self_update::finish(&APP, &std::env::args_os().skip(1).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
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<self_update::Outcome>) -> Option<String> {
|
||||
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::<TaskDatabase>().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<Item = TaskStatus>) -> 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<Option<Release>, 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<Restart, String> {
|
||||
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<AtomicBool>,
|
||||
helper_runs: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
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<Option<Release>, 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<Restart, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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(<HelpMenuButton />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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: <RefreshCwIcon className="text-base" />, 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 (
|
||||
<div className={className}>
|
||||
<UpdateDialog isOpen={updatesOpen} onClose={() => setUpdatesOpen(false)} />
|
||||
<PopoverMenu
|
||||
items={menuItems}
|
||||
onSelect={handleMenuSelect}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { StrictMode } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { CheckForUpdates, InstallUpdate, OpenUrl, type UpdateInfo } from "@storyteller/tauri-api";
|
||||
import { UpdateDialog } from "./update-dialog";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ 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) => <button {...props}>{children}</button> }));
|
||||
vi.mock("@storyteller/ui-modal", () => ({ Modal: ({ isOpen, title, onClose, showClose, children }: any) => isOpen ? (
|
||||
<section role="dialog" aria-label={title}><button aria-label="Close" disabled={!showClose} onClick={onClose}>×</button>{children}</section>
|
||||
) : 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(<UpdateDialog isOpen={false} onClose={close} />);
|
||||
expect(CheckForUpdates).not.toHaveBeenCalled();
|
||||
view.rerender(<UpdateDialog isOpen onClose={close} />);
|
||||
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(<StrictMode><UpdateDialog isOpen onClose={vi.fn()} /></StrictMode>);
|
||||
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(<UpdateDialog isOpen onClose={close} />);
|
||||
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(<UpdateDialog isOpen onClose={vi.fn()} />);
|
||||
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(<UpdateDialog isOpen onClose={close} />);
|
||||
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(<UpdateDialog isOpen onClose={vi.fn()} />);
|
||||
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(<UpdateDialog isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByRole("status").textContent).toContain("updates when you reload");
|
||||
expect(CheckForUpdates).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Update and restart" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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<UpdateInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const pendingCheck = useRef<Promise<UpdateInfo> | 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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => { if (!installing) onClose(); }}
|
||||
title="Check for Updates"
|
||||
accessibleTitle="Check for Updates"
|
||||
className="max-w-lg"
|
||||
showClose={!installing}
|
||||
closeOnEsc={!installing}
|
||||
closeOnOutsideClick={!installing}
|
||||
>
|
||||
<div className="flex flex-col gap-4 text-base-fg">
|
||||
<p>ArtCraft{info ? ` ${info.current_version}` : ""}</p>
|
||||
<div role="status" aria-live="polite">
|
||||
{!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}
|
||||
</div>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{info?.maybe_available_version && (
|
||||
<p className="text-sm text-base-fg/70">
|
||||
{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."}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="action" disabled={installing} onClick={() => OpenUrl(info?.releases_url ?? RELEASES)}>Releases</Button>
|
||||
{desktop && <Button variant="action" disabled={checking || installing} onClick={() => void check()}>Check again</Button>}
|
||||
{info?.maybe_available_version && <Button variant="primary" disabled={!info.can_install || checking || installing} onClick={() => void install()}>Update and restart</Button>}
|
||||
{!installing && <Button variant="action" onClick={onClose}>Cancel</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<UpdateInfo> =>
|
||||
invoke("check_for_updates_command");
|
||||
|
||||
export const InstallUpdate = (): Promise<void> =>
|
||||
invoke("install_update_command", { confirmed: true });
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user