mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-03 09:11:10 +00:00
Merge remote-tracking branch 'storytold/main' into migration/egui-037-20261024
This commit is contained in:
19 files changed
+563
-55
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+20
-15
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -147,7 +147,7 @@ pub mod macos {
|
||||
pub fn candidates(app: &App) -> Vec<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
absolute_env("LOCALAPPDATA")
|
||||
}
|
||||
|
||||
/// Where per-user data lives on this machine.
|
||||
pub fn data_local_dir() -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,9 +243,9 @@ fn destination(
|
||||
|
||||
fn selected_folder(folder: &str, writable: bool) -> Result<PathBuf, String> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ fn detect(app: &App, exe: &Path) -> Option<Install> {
|
||||
// registry) or by default %LOCALAPPDATA%\Programs\<App Name>.
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -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<dyn Fn() -> Result<Option<Release>, 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<Option<Release>, String>;
|
||||
|
||||
pub struct Updater {
|
||||
app: &'static App,
|
||||
version: String,
|
||||
source: Option<CheckSource>,
|
||||
status: Status,
|
||||
/// The check running (or queued behind it) was asked for by the user.
|
||||
requested: bool,
|
||||
started: bool,
|
||||
check: Option<Receiver<Checked>>,
|
||||
install: Option<(Receiver<Result<(), String>>, Release)>,
|
||||
prompt: Option<StartupPrompt>,
|
||||
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<String>) -> 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::<Result<(), String>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user