diff --git a/.github/workflows/ai-core.yml b/.github/workflows/ai-core.yml index 5f2204f7..2dd03168 100644 --- a/.github/workflows/ai-core.yml +++ b/.github/workflows/ai-core.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/atomic.yml b/.github/workflows/atomic.yml index d2112768..9cda9f28 100644 --- a/.github/workflows/atomic.yml +++ b/.github/workflows/atomic.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/docpath.yml b/.github/workflows/docpath.yml index 3aaaa97e..3cae8fb5 100644 --- a/.github/workflows/docpath.yml +++ b/.github/workflows/docpath.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 70f0f71e..7a887649 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml index 4dc3fe18..51ff2ba0 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -23,7 +23,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - name: Checkout test fonts @@ -60,7 +60,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/.test-inputs/craft-fonts steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - name: Checkout test fonts @@ -80,7 +80,7 @@ jobs: name: Keymap WASM runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable diff --git a/.github/workflows/media.yml b/.github/workflows/media.yml index a9fa3213..5d519b0a 100644 --- a/.github/workflows/media.yml +++ b/.github/workflows/media.yml @@ -21,7 +21,7 @@ jobs: name: Media lint, feature sets, WASM runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable @@ -43,7 +43,7 @@ jobs: name: Media tests with ffmpeg oracles runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index 45b2b363..2ff73a7a 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -26,14 +26,14 @@ jobs: env: RUSTFLAGS: --cfg craft_pdf_unicode_fixtures steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-pdf - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Provision licensed PDF test fonts @@ -50,7 +50,7 @@ jobs: name: PDF vendored contracts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: @@ -63,14 +63,14 @@ jobs: RUSTFLAGS: --cfg craft_pdf_unicode_fixtures CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-pdf-wasm - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Provision licensed PDF test fonts @@ -90,14 +90,14 @@ jobs: name: Plate tests and craft-prepress runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-plate-test - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Provision licensed PDF test fonts diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 270ac75e..9e144a04 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -17,7 +17,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index 3db33478..fdeeda92 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -25,7 +25,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install software Vulkan renderer if: runner.os == 'Linux' run: | @@ -48,7 +48,7 @@ jobs: name: UI WASM runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown diff --git a/.github/workflows/window.yml b/.github/workflows/window.yml index 80aa86b6..b384d61b 100644 --- a/.github/workflows/window.yml +++ b/.github/workflows/window.yml @@ -20,7 +20,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # The token isn't needed after checkout; cargo runs build scripts and proc macros from # dependencies, so don't leave it in .git/config for that third-party code to read. diff --git a/Cargo.lock b/Cargo.lock index 8c3e4764..70c29ac2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,7 +922,7 @@ name = "craft-settings" version = "0.1.0" dependencies = [ "craft-atomic", - "dirs 7.0.0", + "dirs", "serde", "tempfile", "toml_edit", @@ -950,7 +950,6 @@ name = "craft-update" version = "0.1.0" dependencies = [ "chrono", - "dirs 6.0.0", "dunce", "egui", "flate2", @@ -1111,15 +1110,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - [[package]] name = "dirs" version = "7.0.0" @@ -2992,6 +2982,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -5379,9 +5381,9 @@ dependencies = [ [[package]] name = "typed-path" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typenum" @@ -6725,15 +6727,18 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" dependencies = [ "crc32fast", "flate2", + "getrandom 0.4.3", "indexmap", "memchr", + "nix", "typed-path", + "windows-sys 0.61.2", "zopfli", ] diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml index 353e8f33..39934748 100644 --- a/crates/update/Cargo.toml +++ b/crates/update/Cargo.toml @@ -20,7 +20,6 @@ egui = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } log = "0.4" -dirs = "6" getrandom = "0.3" tempfile = "3" sha2 = "0.10" @@ -41,6 +40,6 @@ plist = "1" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.55" dunce = "1" -zip = { version = "8", default-features = false, features = ["deflate"] } -winsafe = { version = "0.0.29", features = ["kernel", "version"] } +zip = { version = "9", default-features = false, features = ["deflate"] } +winsafe = { version = "0.0.29", features = ["advapi", "kernel", "shell", "version"] } verifysign = "1" diff --git a/crates/update/src/detect.rs b/crates/update/src/detect.rs index 840245a1..437e315d 100644 --- a/crates/update/src/detect.rs +++ b/crates/update/src/detect.rs @@ -147,7 +147,7 @@ pub mod macos { pub fn candidates(app: &App) -> Vec { let bundle = format!("{}.app", app.name); let mut out = vec![Path::new("/Applications").join(&bundle)]; - if let Some(home) = dirs::home_dir() { + if let Some(home) = crate::folders::home_dir() { out.push(home.join("Applications").join(&bundle)); } out @@ -356,13 +356,13 @@ pub mod linux { /// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention). pub fn appimage_dir() -> Option { - dirs::home_dir().map(|h| h.join("Applications")) + crate::folders::home_dir().map(|h| h.join("Applications")) } /// Every folder searched for AppImages, the launcher's own first: `~/Applications`, /// `~/AppImages` (Gear Lever's default) and `~/.local/bin`. pub fn appimage_dirs() -> Vec { - let Some(home) = dirs::home_dir() else { return Vec::new() }; + let Some(home) = crate::folders::home_dir() else { return Vec::new() }; vec![home.join("Applications"), home.join("AppImages"), home.join(".local").join("bin")] } @@ -423,7 +423,7 @@ pub mod linux { fn find_binary(id: &str) -> Option { let on_path = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file())); - let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(dirs::home_dir().map(|h| h.join(".local/bin"))); + let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(crate::folders::home_dir().map(|h| h.join(".local/bin"))); on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file())) } diff --git a/crates/update/src/folders.rs b/crates/update/src/folders.rs new file mode 100644 index 00000000..a144a3ad --- /dev/null +++ b/crates/update/src/folders.rs @@ -0,0 +1,65 @@ +//! The per-user folders, from the environment, so no extra crate (and its licence) is needed. + +use std::path::PathBuf; + +/// The user's home folder. +pub fn home_dir() -> Option { + std::env::home_dir().filter(|h| !h.as_os_str().is_empty()) +} + +/// An absolute folder from the environment variable `name` (an XDG one must be absolute). +fn absolute_env(name: &str) -> Option { + std::env::var_os(name).map(PathBuf::from).filter(|p| p.is_absolute()) +} + +/// The known folder Local AppData, which follows a redirected profile (the environment variable +/// is only the fallback). +#[cfg(windows)] +fn local_app_data() -> Option { + use winsafe::{SHGetKnownFolderPath, co}; + SHGetKnownFolderPath(&co::KNOWNFOLDERID::LocalAppData, co::KF::DEFAULT, None) + .ok() + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| absolute_env("LOCALAPPDATA")) +} + +#[cfg(not(windows))] +fn local_app_data() -> Option { + absolute_env("LOCALAPPDATA") +} + +/// Where per-user data lives on this machine. +pub fn data_local_dir() -> Option { + if cfg!(target_os = "windows") { + local_app_data() + } else if cfg!(target_os = "macos") { + home_dir().map(|h| h.join("Library").join("Application Support")) + } else { + absolute_env("XDG_DATA_HOME").or_else(|| home_dir().map(|h| h.join(".local").join("share"))) + } +} + +/// Where per-user caches live on this machine. +pub fn cache_dir() -> Option { + if cfg!(target_os = "windows") { + local_app_data() + } else if cfg!(target_os = "macos") { + home_dir().map(|h| h.join("Library").join("Caches")) + } else { + absolute_env("XDG_CACHE_HOME").or_else(|| home_dir().map(|h| h.join(".cache"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_folders_are_absolute_when_there_is_a_home() { + if home_dir().is_some() { + assert!(data_local_dir().is_some_and(|p| p.is_absolute())); + assert!(cache_dir().is_some_and(|p| p.is_absolute())); + } + } +} diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs index a92379f6..c63c107c 100644 --- a/crates/update/src/install.rs +++ b/crates/update/src/install.rs @@ -243,9 +243,9 @@ fn destination( fn selected_folder(folder: &str, writable: bool) -> Result { let path = if folder == "~" { - dirs::home_dir().ok_or("no home folder")? + crate::folders::home_dir().ok_or("no home folder")? } else if let Some(relative) = folder.strip_prefix("~/") { - dirs::home_dir().ok_or("no home folder")?.join(relative) + crate::folders::home_dir().ok_or("no home folder")?.join(relative) } else { PathBuf::from(folder) }; @@ -599,7 +599,7 @@ fn prepare_home(home: &Path) { for (_, dir) in home_env(home).iter().skip(1) { let _ = std::fs::create_dir_all(dir); } - let Some(real) = dirs::home_dir() else { return }; + let Some(real) = crate::folders::home_dir() else { return }; if real == home { return; } @@ -1117,9 +1117,9 @@ pub(crate) mod linux { if let Some(dir) = std::env::var_os("HOST_XDG_DATA_HOME").map(PathBuf::from).filter(|p| p.is_absolute()) { return Some(dir); } - return dirs::home_dir().map(|h| h.join(".local").join("share")); + return crate::folders::home_dir().map(|h| h.join(".local").join("share")); } - dirs::data_dir() + crate::folders::data_local_dir() } /// The app's 128 px icon, as bundled in `assets/app-icons`. @@ -1240,7 +1240,7 @@ pub mod macos { let _ = std::fs::remove_file(&probe); return Ok(system); } - let user = dirs::home_dir().ok_or("no home folder")?.join("Applications"); + let user = crate::folders::home_dir().ok_or("no home folder")?.join("Applications"); std::fs::create_dir_all(&user).map_err(|e| format!("couldn't create {} ({e})", user.display()))?; Ok(user) } @@ -1408,7 +1408,7 @@ pub mod macos { /// Moves an app to the user's Trash (so it can be put back). pub fn move_to_trash(path: &Path) -> Result<(), String> { - let trash = dirs::home_dir().ok_or("no home folder")?.join(".Trash"); + let trash = crate::folders::home_dir().ok_or("no home folder")?.join(".Trash"); let name = path.file_name().ok_or("not an app")?; let mut dest = trash.join(name); if dest.exists() { @@ -1562,7 +1562,7 @@ mod freebsd { run_as_root(&[OsStr::new("/bin/cp"), OsStr::new("-R"), &source, prefix.as_os_str()])?; return Ok((prefix.join("bin").join(app.id), rel.iter().map(|r| prefix.join(r)).collect(), true)); } - let prefix = dirs::home_dir().ok_or("no home folder")?.join(".local"); + let prefix = crate::folders::home_dir().ok_or("no home folder")?.join(".local"); std::fs::create_dir_all(&prefix).map_err(|e| e.to_string())?; let status = process::command("/bin/cp").arg("-R").arg(&source).arg(&prefix).status().map_err(|e| e.to_string())?; if !status.success() { @@ -1593,7 +1593,7 @@ mod freebsd { if files.is_empty() { return Err("the launcher has no record of these files".into()); } - let prefix = if needs_root { PathBuf::from("/usr/local") } else { dirs::home_dir().ok_or("no home folder")?.join(".local") }; + let prefix = if needs_root { PathBuf::from("/usr/local") } else { crate::folders::home_dir().ok_or("no home folder")?.join(".local") }; let mut safe_files = Vec::new(); for f in files { // Refuse paths that climb or escape install prefix (CWE-59). @@ -2168,7 +2168,7 @@ mod tests { #[test] fn remove_files_refuses_escaping_paths() { assert!(freebsd::remove_files(&[PathBuf::from("/etc/shadow")], false).is_err()); - if let Some(home) = dirs::home_dir() { + if let Some(home) = crate::folders::home_dir() { let prefix = home.join(".local"); assert!(freebsd::remove_files(&[prefix.join("../escaped")], false).is_err()); } diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs index ab61612e..d0fec496 100644 --- a/crates/update/src/lib.rs +++ b/crates/update/src/lib.rs @@ -6,6 +6,7 @@ pub mod app; pub mod desktop_entry; pub mod detect; pub mod distro; +mod folders; pub mod http; pub mod install; pub mod manifest; @@ -28,6 +29,8 @@ pub mod version; #[cfg(feature = "egui")] pub mod dialog; +#[cfg(feature = "egui")] +pub mod updater; pub use app::App; pub use http::{Http, Stop}; diff --git a/crates/update/src/paths.rs b/crates/update/src/paths.rs index b43feb15..7dab89e1 100644 --- a/crates/update/src/paths.rs +++ b/crates/update/src/paths.rs @@ -39,8 +39,8 @@ impl Paths { /// The per-user folders named `dir` (`ArtCraft Launcher` on macOS and Windows, /// `artcraft-launcher` elsewhere, by convention). pub fn per_user(dir: &str) -> Result { - let data = dirs::data_local_dir().ok_or("no per-user data folder on this system")?.join(dir); - let cache = dirs::cache_dir().ok_or("no per-user cache folder on this system")?.join(dir); + let data = crate::folders::data_local_dir().ok_or("no per-user data folder on this system")?.join(dir); + let cache = crate::folders::cache_dir().ok_or("no per-user cache folder on this system")?.join(dir); Ok(Self { data, cache }) } diff --git a/crates/update/src/self_update.rs b/crates/update/src/self_update.rs index 0b41cbb3..8babc380 100644 --- a/crates/update/src/self_update.rs +++ b/crates/update/src/self_update.rs @@ -192,7 +192,7 @@ fn detect(app: &App, exe: &Path) -> Option { // registry) or by default %LOCALAPPDATA%\Programs\. let canonical = |p: PathBuf| p.canonicalize().unwrap_or(p); let recorded = msi_install_dir(app).map(canonical); - let default = dirs::data_local_dir().map(|d| canonical(d.join("Programs").join(app.name))); + let default = crate::folders::data_local_dir().map(|d| canonical(d.join("Programs").join(app.name))); if [recorded, default].into_iter().flatten().any(|d| d == dir) { return Some(Install::Msi(exe.to_path_buf())); } diff --git a/crates/update/src/updater.rs b/crates/update/src/updater.rs new file mode 100644 index 00000000..d6107891 --- /dev/null +++ b/crates/update/src/updater.rs @@ -0,0 +1,436 @@ +//! The update flow of an egui app in one place: a check at start or on request, the startup prompt, +//! and the self-update worker. An app creates one [`Updater`], calls [`Updater::tick`] and +//! [`Updater::show`] each frame, and acts on the [`Action`] it returns, so every app gets the same +//! handling of failures, dropped workers, repaints and quitting. + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use std::time::Duration; + +use crate::dialog::{StartupPrompt, StartupPromptResponse}; +use crate::release::Release; +use crate::{App, Http, Paths, Platform, Stop, check_for_update, self_update}; + +/// Asks for the newest release (blocking; it runs on its own thread). Tests stand in for the network +/// with one; without it the check asks GitHub. +pub type CheckSource = Arc Result, String> + Send + Sync>; + +/// What a requested check came to, for a window that answers Help ▸ Check for Updates. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + /// Nothing to report. + Idle, + /// A requested check is running. + Checking, + /// A requested check found nothing newer. + UpToDate, + /// A requested check failed. + Failed(String), +} + +/// What the app should do after [`Updater::show`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + None, + /// Open this page in the browser. + OpenUrl(String), + /// Close the app through its normal close path (which asks about unsaved work): the update is + /// installed and a helper restarts the app once it has exited. + Close, +} + +type Checked = Result, String>; + +pub struct Updater { + app: &'static App, + version: String, + source: Option, + status: Status, + /// The check running (or queued behind it) was asked for by the user. + requested: bool, + started: bool, + check: Option>, + install: Option<(Receiver>, Release)>, + prompt: Option, + restart: bool, + /// An update is installed and waits for the app to exit; nothing starts a second one. + installed: bool, +} + +impl Updater { + pub fn new(app: &'static App, version: impl Into) -> Self { + Self { + app, + version: version.into(), + source: None, + status: Status::Idle, + requested: false, + started: false, + check: None, + install: None, + prompt: None, + restart: false, + installed: false, + } + } + + /// Replaces the network with `source` (tests). + pub fn with_source(mut self, source: CheckSource) -> Self { + self.source = Some(source); + self + } + + /// The check at start, once. Later calls do nothing. + pub fn check_at_start(&mut self, ctx: &egui::Context) { + if !std::mem::replace(&mut self.started, true) { + self.begin(ctx, false); + } + } + + /// Help ▸ Check for Updates: a check whose outcome [`Self::status`] reports. A check already + /// running becomes a requested one; nothing starts while an update installs. + pub fn check_now(&mut self, ctx: &egui::Context) { + self.started = true; + self.begin(ctx, true); + } + + /// Opens the startup prompt for a release the app found itself. + pub fn offer(&mut self, release: Release) { + if self.install.is_none() && !self.installed { + self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); + } + } + + pub fn status(&self) -> &Status { + &self.status + } + + /// Dismisses the outcome of a requested check. One still running carries on, and reports + /// like the check at start (a prompt if there is a release, otherwise nothing). + pub fn dismiss_status(&mut self) { + self.requested = false; + self.status = Status::Idle; + } + + /// Whether the startup prompt is showing. + pub fn prompt_open(&self) -> bool { + self.prompt.is_some() + } + + fn begin(&mut self, ctx: &egui::Context, requested: bool) { + if self.install.is_some() || self.installed { + return; + } + if requested { + self.requested = true; + self.status = Status::Checking; + } + if self.check.is_some() { + return; + } + let (tx, rx) = channel(); + let (source, app, version, ctx) = (self.source.clone(), self.app, self.version.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || { + let ask = || match source { + Some(ask) => ask(), + None => Http::new(&format!("{}/Update", app.name)).and_then(|http| check_for_update(app, &http, &version)), + }; + let answer = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(ask)).unwrap_or_else(|_| Err("the update check failed unexpectedly".into())); + let _ = tx.send(answer); + ctx.request_repaint(); + }); + match spawned { + Ok(_) => self.check = Some(rx), + Err(e) => self.finish_check(Err(format!("couldn't start the update check ({e})"))), + } + } + + fn finish_check(&mut self, answer: Checked) { + let requested = std::mem::take(&mut self.requested); + match answer { + Ok(Some(release)) => { + self.status = Status::Idle; + if self.prompt.is_none() && self.install.is_none() && !self.installed { + self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); + } + } + Ok(None) => self.status = if requested { Status::UpToDate } else { Status::Idle }, + Err(e) => { + log::warn!("{} update check failed: {e}", self.app.name); + self.status = if requested { Status::Failed(e) } else { Status::Idle }; + } + } + } + + /// Picks up a finished check or installation. Call once per frame, before [`Self::show`]. It + /// keeps the UI awake while either is running. + pub fn tick(&mut self, ctx: &egui::Context) { + if let Some(rx) = &self.check { + match rx.try_recv() { + Ok(answer) => { + self.check = None; + self.finish_check(answer); + } + Err(TryRecvError::Empty) => ctx.request_repaint_after(Duration::from_millis(100)), + Err(TryRecvError::Disconnected) => { + self.check = None; + self.finish_check(Err("the update check stopped unexpectedly".into())); + } + } + } + if let Some((rx, _)) = &self.install { + let outcome = match rx.try_recv() { + Ok(outcome) => Some(outcome), + Err(TryRecvError::Empty) => { + ctx.request_repaint_after(Duration::from_millis(100)); + None + } + Err(TryRecvError::Disconnected) => Some(Err("the update stopped unexpectedly".into())), + }; + if let Some(outcome) = outcome { + let release = self.install.take().map(|(_, release)| release); + match outcome { + Ok(()) => { + self.installed = true; + self.restart = true; + } + Err(e) => { + log::warn!("{} self-update failed: {e}", self.app.name); + // The prompt may have been dismissed meanwhile: bring it back with the reason. + if self.prompt.is_none() + && let Some(release) = release + { + self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); + } + if let Some(prompt) = &mut self.prompt { + prompt.is_installing = false; + prompt.status_message = Some(e); + } + } + } + } + } + } + + /// Draws the startup prompt and starts what it asks for. + pub fn show(&mut self, ctx: &egui::Context) -> Action { + if std::mem::take(&mut self.restart) { + if let Some(prompt) = &mut self.prompt { + prompt.is_installing = false; + prompt.status_message = Some(format!("The update is installed. Close {} to finish; it restarts by itself.", self.app.name)); + } + return Action::Close; + } + let Some(prompt) = &mut self.prompt else { return Action::None }; + match prompt.show(ctx) { + StartupPromptResponse::Pending => Action::None, + StartupPromptResponse::UpdateAndRestart => { + self.start_install(ctx); + Action::None + } + StartupPromptResponse::Download => { + let url = prompt.release.html_url.clone(); + self.prompt = None; + Action::OpenUrl(url) + } + StartupPromptResponse::Later => { + // An installation carries on and reports through `tick`. + self.prompt = None; + Action::None + } + } + } + + fn start_install(&mut self, ctx: &egui::Context) { + let Some(prompt) = &mut self.prompt else { return }; + if self.installed { + prompt.status_message = Some(format!("The update is installed. Close {} to finish; it restarts by itself.", self.app.name)); + return; + } + let Some(installed) = self_update::current(self.app) else { + prompt.status_message = Some("Cannot auto-update this installation type. Please download manually.".into()); + prompt.is_installing = false; + return; + }; + prompt.is_installing = true; + prompt.status_message = None; + let (tx, rx) = channel(); + let (app, version, ctx, release) = (self.app, self.version.clone(), ctx.clone(), prompt.release.clone()); + let worker_release = release.clone(); + let spawned = std::thread::Builder::new().name("self-update".into()).spawn(move || { + let run = || install(app, &installed, worker_release, &version); + let outcome = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).unwrap_or_else(|_| Err("the update failed unexpectedly".into())); + let _ = tx.send(outcome); + ctx.request_repaint(); + }); + match spawned { + Ok(_) => self.install = Some((rx, release)), + Err(e) => { + prompt.is_installing = false; + prompt.status_message = Some(format!("couldn't start the update ({e})")); + } + } + } +} + +fn install(app: &'static App, installed: &self_update::Install, mut release: Release, version: &str) -> Result<(), String> { + let http = Http::new(&format!("{}/Update", app.name)).map_err(|e| format!("could not initialize HTTP: {e}"))?; + if release.assets.is_empty() + && let Some(full) = check_for_update(app, &http, version).map_err(|e| format!("could not fetch release assets: {e}"))? + { + release = full; + } + let paths = Paths::for_app(app).map_err(|e| format!("could not find paths: {e}"))?; + let platform = Platform::current().ok_or("unsupported platform for self update")?; + self_update::update(app, installed, &release, platform, &http, &paths, &Stop::default(), &mut |_| {}, &|| Ok(()), Some(version)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::suite::PHOTOCRAFT; + + fn updater(answer: Checked) -> Updater { + Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(move || answer.clone())) + } + + /// Ticks until the running check has reported. + fn settle(updater: &mut Updater, ctx: &egui::Context) { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while updater.check.is_some() && std::time::Instant::now() < deadline { + updater.tick(ctx); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn newer() -> Release { + Release { tag: "v9.0.0".into(), version: crate::version::parse("9.0.0"), ..Release::default() } + } + + #[test] + fn a_requested_check_reports_an_up_to_date_app() { + let (ctx, mut u) = (egui::Context::default(), updater(Ok(None))); + u.check_now(&ctx); + assert_eq!(u.status(), &Status::Checking); + settle(&mut u, &ctx); + assert_eq!(u.status(), &Status::UpToDate); + u.dismiss_status(); + assert_eq!(u.status(), &Status::Idle); + } + + #[test] + fn dismissing_a_running_check_keeps_it_quiet() { + let (ctx, mut u) = (egui::Context::default(), updater(Ok(None))); + u.check_now(&ctx); + u.dismiss_status(); + settle(&mut u, &ctx); + assert_eq!(u.status(), &Status::Idle); + } + + #[test] + fn a_requested_check_reports_a_failure_but_the_startup_check_stays_quiet() { + let ctx = egui::Context::default(); + let mut u = updater(Err("offline".into())); + u.check_now(&ctx); + settle(&mut u, &ctx); + assert_eq!(u.status(), &Status::Failed("offline".into())); + + let mut u = updater(Err("offline".into())); + u.check_at_start(&ctx); + settle(&mut u, &ctx); + assert_eq!(u.status(), &Status::Idle); + } + + #[test] + fn a_newer_release_opens_the_prompt_and_later_closes_it() { + let (ctx, mut u) = (egui::Context::default(), updater(Ok(Some(newer())))); + u.check_at_start(&ctx); + settle(&mut u, &ctx); + assert!(u.prompt_open()); + assert_eq!(u.status(), &Status::Idle); + u.prompt = None; + assert!(!u.prompt_open()); + } + + #[test] + fn the_startup_check_runs_once() { + let (ctx, mut u) = (egui::Context::default(), updater(Ok(None))); + u.check_at_start(&ctx); + settle(&mut u, &ctx); + u.check_at_start(&ctx); + assert!(u.check.is_none()); + } + + #[test] + fn a_request_during_the_startup_check_is_not_lost() { + let (ctx, mut u) = (egui::Context::default(), updater(Ok(None))); + u.check_at_start(&ctx); + u.check_now(&ctx); + settle(&mut u, &ctx); + assert_eq!(u.status(), &Status::UpToDate); + } + + #[test] + fn a_panicking_check_is_an_error_not_a_hang() { + let ctx = egui::Context::default(); + let mut u = Updater::new(&PHOTOCRAFT, "0.1.0").with_source(Arc::new(|| std::panic::panic_any("boom"))); + u.check_now(&ctx); + settle(&mut u, &ctx); + assert!(matches!(u.status(), Status::Failed(_))); + assert!(u.check.is_none()); + } + + #[test] + fn a_failed_install_returns_to_the_prompt_even_after_it_was_dismissed() { + let ctx = egui::Context::default(); + let mut u = updater(Ok(None)); + let (tx, rx) = channel(); + u.install = Some((rx, newer())); + assert!(tx.send(Err("disk full".into())).is_ok()); + u.tick(&ctx); + let prompt = u.prompt.as_ref().map(|p| (p.is_installing, p.status_message.clone())); + assert_eq!(prompt, Some((false, Some("disk full".into())))); + assert!(u.install.is_none()); + } + + #[test] + fn a_dropped_install_worker_is_an_error() { + let ctx = egui::Context::default(); + let mut u = updater(Ok(None)); + let (tx, rx) = channel::>(); + u.install = Some((rx, newer())); + drop(tx); + u.tick(&ctx); + assert!(u.prompt.as_ref().is_some_and(|p| p.status_message.is_some())); + } + + #[test] + fn nothing_starts_once_an_update_is_installed() { + let ctx = egui::Context::default(); + let mut u = updater(Ok(Some(newer()))); + let (tx, rx) = channel(); + u.install = Some((rx, newer())); + assert!(tx.send(Ok(())).is_ok()); + u.tick(&ctx); + assert_eq!(u.show(&ctx), Action::Close); + u.prompt = None; + u.check_now(&ctx); + assert!(u.check.is_none(), "no check after an install"); + u.offer(newer()); + assert!(!u.prompt_open(), "no second offer after an install"); + } + + #[test] + fn a_finished_install_asks_the_app_to_close_once() { + let ctx = egui::Context::default(); + let mut u = updater(Ok(None)); + let (tx, rx) = channel(); + u.install = Some((rx, newer())); + assert!(tx.send(Ok(())).is_ok()); + u.tick(&ctx); + assert_eq!(u.show(&ctx), Action::Close); + assert_eq!(u.show(&ctx), Action::None); + } +}