From 96f59c81cc4cf8f8bdc1c03f7764f33682b697d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:00:20 +0000 Subject: [PATCH 1/5] ci(deps): bump the actions group across 1 directory with 2 updates Bumps the actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 4 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) Updates `actions/setup-python` from 5 to 7 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/setup-python dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ai-core.yml | 2 +- .github/workflows/atomic.yml | 2 +- .github/workflows/docpath.yml | 2 +- .github/workflows/i18n.yml | 2 +- .github/workflows/keymap.yml | 6 +++--- .github/workflows/media.yml | 4 ++-- .github/workflows/pdf.yml | 14 +++++++------- .github/workflows/print.yml | 2 +- .github/workflows/ui.yml | 4 ++-- .github/workflows/window.yml | 2 +- 10 files changed, 20 insertions(+), 20 deletions(-) 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 bf589f66..083edf44 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -21,7 +21,7 @@ jobs: name: Keymap (Linux) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable @@ -49,7 +49,7 @@ jobs: os: [macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable @@ -62,7 +62,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 355f52d2..c3e16721 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. From fcd3441993ee238144ee56538ad81b4a333f1ed9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 11:56:58 -0400 Subject: [PATCH 2/5] fix(update): drop the dirs crate and move to zip 9 dirs pulls in option-ext (MPL-2.0), which the apps' cargo-deny licence policy rejects, and zip 8.6 carries RUSTSEC-2026-0367, -0368 and -0369. The per-user folders now come from the environment. --- Cargo.lock | 35 +++++++++++++---------- crates/update/Cargo.toml | 3 +- crates/update/src/detect.rs | 8 +++--- crates/update/src/folders.rs | 48 ++++++++++++++++++++++++++++++++ crates/update/src/install.rs | 20 ++++++------- crates/update/src/lib.rs | 1 + crates/update/src/paths.rs | 4 +-- crates/update/src/self_update.rs | 2 +- 8 files changed, 87 insertions(+), 34 deletions(-) create mode 100644 crates/update/src/folders.rs diff --git a/Cargo.lock b/Cargo.lock index 3b425182..f721f833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -974,7 +974,7 @@ name = "craft-settings" version = "0.1.0" dependencies = [ "craft-atomic", - "dirs 7.0.0", + "dirs", "serde", "tempfile", "toml_edit", @@ -1002,7 +1002,6 @@ name = "craft-update" version = "0.1.0" dependencies = [ "chrono", - "dirs 6.0.0", "dunce", "egui", "flate2", @@ -1163,15 +1162,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" @@ -3073,6 +3063,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" @@ -5514,9 +5516,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" @@ -7015,15 +7017,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..8c1b0efb 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"] } +zip = { version = "9", default-features = false, features = ["deflate"] } winsafe = { version = "0.0.29", features = ["kernel", "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..9c666f2f --- /dev/null +++ b/crates/update/src/folders.rs @@ -0,0 +1,48 @@ +//! 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()) +} + +/// Where per-user data lives on this machine. +pub fn data_local_dir() -> Option { + if cfg!(target_os = "windows") { + absolute_env("LOCALAPPDATA") + } 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") { + absolute_env("LOCALAPPDATA") + } 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..eeeb86c3 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; 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 fb5f2ecd..8a14361a 100644 --- a/crates/update/src/self_update.rs +++ b/crates/update/src/self_update.rs @@ -147,7 +147,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())); } From c2adf14ae3d1fd6ab6463c316e5e7343e13c0c03 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 12:16:42 -0400 Subject: [PATCH 3/5] feat(update): an Updater that owns the check, prompt and self-update flow for egui apps One tested implementation of what each app had copied: results are picked up even after the prompt is dismissed, a dropped or panicking worker is an error, the UI is repainted while work runs, a failed install returns to the prompt with its reason, and success asks the app to close through its own close path instead of exiting the process. --- crates/update/src/lib.rs | 2 + crates/update/src/updater.rs | 394 +++++++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 crates/update/src/updater.rs diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs index eeeb86c3..d0fec496 100644 --- a/crates/update/src/lib.rs +++ b/crates/update/src/lib.rs @@ -29,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/updater.rs b/crates/update/src/updater.rs new file mode 100644 index 00000000..48d46e31 --- /dev/null +++ b/crates/update/src/updater.rs @@ -0,0 +1,394 @@ +//! 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, +} + +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, + } + } + + /// 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); + } + + pub fn status(&self) -> &Status { + &self.status + } + + /// Dismisses the outcome of a requested check. + pub fn dismiss_status(&mut self) { + if self.check.is_none() { + 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() { + 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.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.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 }; + 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 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 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); + } +} From 9d2bea410e8da2fd03437daaf74c9760e7521d3c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 12:18:20 -0400 Subject: [PATCH 4/5] feat(update): Updater::offer, and dismissing a running check keeps it quiet --- crates/update/src/updater.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/update/src/updater.rs b/crates/update/src/updater.rs index 48d46e31..d90976de 100644 --- a/crates/update/src/updater.rs +++ b/crates/update/src/updater.rs @@ -91,15 +91,22 @@ impl Updater { 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.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); + } + } + pub fn status(&self) -> &Status { &self.status } - /// Dismisses the outcome of a requested check. + /// 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) { - if self.check.is_none() { - self.status = Status::Idle; - } + self.requested = false; + self.status = Status::Idle; } /// Whether the startup prompt is showing. @@ -303,6 +310,15 @@ mod tests { 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(); From 13ceaf2f31620531f39a54f6b994d581a6082a93 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 12:41:38 -0400 Subject: [PATCH 5/5] fix(update): keep Windows known-folder locations and never start a second install Local AppData comes from the known-folder API (a redirected profile is honoured), with the environment variable as the fallback. Once an update is installed and waits for the app to exit, no check, offer or install starts, so a cancelled close cannot relaunch the app twice. --- crates/update/Cargo.toml | 2 +- crates/update/src/folders.rs | 21 +++++++++++++++++++-- crates/update/src/updater.rs | 34 ++++++++++++++++++++++++++++++---- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml index 8c1b0efb..39934748 100644 --- a/crates/update/Cargo.toml +++ b/crates/update/Cargo.toml @@ -41,5 +41,5 @@ plist = "1" winreg = "0.55" dunce = "1" zip = { version = "9", default-features = false, features = ["deflate"] } -winsafe = { version = "0.0.29", features = ["kernel", "version"] } +winsafe = { version = "0.0.29", features = ["advapi", "kernel", "shell", "version"] } verifysign = "1" diff --git a/crates/update/src/folders.rs b/crates/update/src/folders.rs index 9c666f2f..a144a3ad 100644 --- a/crates/update/src/folders.rs +++ b/crates/update/src/folders.rs @@ -12,10 +12,27 @@ 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") { - absolute_env("LOCALAPPDATA") + local_app_data() } else if cfg!(target_os = "macos") { home_dir().map(|h| h.join("Library").join("Application Support")) } else { @@ -26,7 +43,7 @@ pub fn data_local_dir() -> Option { /// Where per-user caches live on this machine. pub fn cache_dir() -> Option { if cfg!(target_os = "windows") { - absolute_env("LOCALAPPDATA") + local_app_data() } else if cfg!(target_os = "macos") { home_dir().map(|h| h.join("Library").join("Caches")) } else { diff --git a/crates/update/src/updater.rs b/crates/update/src/updater.rs index d90976de..d6107891 100644 --- a/crates/update/src/updater.rs +++ b/crates/update/src/updater.rs @@ -53,6 +53,8 @@ pub struct Updater { 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 { @@ -68,6 +70,7 @@ impl Updater { install: None, prompt: None, restart: false, + installed: false, } } @@ -93,7 +96,7 @@ impl Updater { /// Opens the startup prompt for a release the app found itself. pub fn offer(&mut self, release: Release) { - if self.install.is_none() { + if self.install.is_none() && !self.installed { self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); } } @@ -115,7 +118,7 @@ impl Updater { } fn begin(&mut self, ctx: &egui::Context, requested: bool) { - if self.install.is_some() { + if self.install.is_some() || self.installed { return; } if requested { @@ -148,7 +151,7 @@ impl Updater { match answer { Ok(Some(release)) => { self.status = Status::Idle; - if self.prompt.is_none() && self.install.is_none() { + if self.prompt.is_none() && self.install.is_none() && !self.installed { self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); } } @@ -188,7 +191,10 @@ impl Updater { if let Some(outcome) = outcome { let release = self.install.take().map(|(_, release)| release); match outcome { - Ok(()) => self.restart = true, + 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. @@ -238,6 +244,10 @@ impl Updater { 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; @@ -396,6 +406,22 @@ mod tests { 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();