Merge remote-tracking branch 'storytold/main' into migration/egui-037-20261024

This commit is contained in:
Dylan M. Taylor committed 2026-10-25 12:54:10 -04:00
commit 6ff2a458b4
19 files changed
+563 -55

No files matched your search

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+7 -7
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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",
]
+2 -3
View File
@@ -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"
+4 -4
View File
@@ -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()))
}
+65
View 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()));
}
}
}
+10 -10
View File
@@ -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());
}
+3
View File
@@ -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};
+2 -2
View File
@@ -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 })
}
+1 -1
View File
@@ -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()));
}
+436
View File
@@ -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);
}
}