Queue system installs so several apps can install at once

Each install ran its own pkexec call at the same time; all but the first
lost the dpkg lock and failed with error 100. Installs that reach the
system installer now wait their turn ("Waiting for another app…", with
Cancel), and Linux packages waiting together go into one privileged call,
so they share one password prompt. apt waits up to 60 s for another
package tool's lock. On Windows msiexec runs one at a time (error 1618).
The launcher's own update waits for other installs before it restarts.

Ported onto main and onto "Keep the verified download when the install
step fails", with these changes from the original PR:
- A failed job counts as installed when the new version is found
  afterwards only if it failed in a Linux call shared with other apps;
  an MSI, AppImage or single-package error, a pause, and either
  cancellation (including INSTALL_CANCELLED) stand.
- The launcher's update waits for other installs at most 10 minutes and
  can be cancelled from its banner ("Cancel update"); giving up names the
  apps still installing and says to answer or close the password prompt.
- Cancellation toasts say what was called off: "Download cancelled",
  "Install cancelled" (download kept), "Uninstall cancelled" or
  "Update cancelled", instead of a bare "Cancelled".
- The waiting card uses the restyled layout and the app's own colour,
  with no hard-coded sizes.

Fixes #6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RealtaNua 2026-10-10 12:35:08 -04:00 committed by Brandon Thomas
parent 5f74027176
commit 8d855ecbcb
9 changed files with 1044 additions and 81 deletions

View File

@ -79,6 +79,8 @@ pub enum JobPhase {
total: u64,
},
Verifying,
/// The system's installer is busy with another app; this one goes next.
Waiting,
/// Waiting on the OS installer (MSI) or copying files.
Installing,
Removing,

View File

@ -9,7 +9,8 @@ use artcraft_launcher_core::catalog::{LAUNCHER, LAUNCHER_ID};
use artcraft_launcher_core::feed::{Feed, FeedAnswer};
use artcraft_launcher_core::platform::{Arch, Os, Platform, pick_asset};
use artcraft_launcher_core::protocol::{
Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload, Source,
Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload,
Source,
};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::{CraftApp, Installed, PackageKind, SUITE, version};
@ -21,6 +22,9 @@ use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::source::{GitHubSource, ReleaseSource};
/// The longest the launcher's own update waits for other installs to end before it gives up
/// (it can be cancelled sooner).
const RESTART_WAIT: Duration = Duration::from_secs(10 * 60);
/// Automatic checks closer together than this reuse the last answers. The UI asks every five
/// minutes; this stops a burst of requests (refresh, focus, timers) from repeating work.
const FRESH_FOR: i64 = 4 * 60;
@ -51,6 +55,8 @@ struct Shared {
latest: Mutex<HashMap<String, Release>>,
installed: Mutex<HashMap<String, Installed>>,
jobs: Mutex<HashMap<String, Arc<Stop>>>,
/// The launcher is replacing itself and will restart: no new job starts.
restarting: AtomicBool,
manifest: Mutex<Manifest>,
options: Mutex<BackendOptions>,
checking: AtomicBool,
@ -113,6 +119,7 @@ impl NativeBackend {
latest: Mutex::new(HashMap::new()),
installed: Mutex::new(HashMap::new()),
jobs: Mutex::new(HashMap::new()),
restarting: AtomicBool::new(false),
manifest: Mutex::new(Manifest::load(&paths.manifest())),
options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false),
@ -295,12 +302,21 @@ impl NativeBackend {
fn start_job(&self, app_id: &str, uninstall: bool) {
let Some(app) = CraftApp::by_id(app_id) else { return };
let stop = Arc::new(Stop::default());
{
let restarting = {
let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner);
if jobs.contains_key(app.id) {
return;
}
jobs.insert(app.id.to_string(), stop.clone());
let restarting = self.shared.restarting.load(Ordering::SeqCst);
if !restarting {
jobs.insert(app.id.to_string(), stop.clone());
}
restarting
};
if restarting {
let result = Err("ArtCraft Launcher is restarting to finish its update. Try again once it's back.".into());
self.shared.emit(Event::JobFinished { app: app.id.to_string(), result });
return;
}
let shared = self.shared.clone();
let paths = self.paths.clone();
@ -312,8 +328,9 @@ impl NativeBackend {
let record = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).apps.get(app.id).cloned();
let result = if uninstall {
shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Removing });
let mut phase = |phase| shared.emit(Event::Progress { app: app.id.to_string(), phase });
match &existing {
Some(inst) => install::uninstall(app, inst, record.as_ref()).map(|()| {
Some(inst) => install::uninstall(app, inst, record.as_ref(), &stop, &mut phase).map(|()| {
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
if m.apps.remove(app.id).is_some() {
let _ = m.save(&paths.manifest());
@ -336,6 +353,17 @@ impl NativeBackend {
// Look again: the result is whatever is on disk now.
let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take());
// One installer call can cover several apps, and fail after some of them went in.
let latest = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned();
let result = match (result, latest) {
(Err(e), Some(release))
if !uninstall && release.version.as_ref().is_some_and(|v| landed_despite(&e, existing.as_ref(), found.as_ref(), v)) =>
{
log::warn!("{}: {e}; the new version is installed all the same", app.id);
Ok(installed_text(app, existing.is_some(), &release))
}
(result, _) => result,
};
{
let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);
match &found {
@ -379,14 +407,21 @@ impl NativeBackend {
last_emit = Instant::now();
shared.emit(Event::Progress { app: LAUNCHER_ID.to_string(), phase: p });
};
let last = || {
let mut waiting = || shared.emit(Event::Progress { app: LAUNCHER_ID.to_string(), phase: JobPhase::Waiting });
claim_restart(&shared.jobs, &shared.restarting, &stop, RESTART_WAIT, &mut waiting)
};
let result = match http.as_ref() {
Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase),
Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase, &last),
None => Err("secure connections aren't available on this system".into()),
};
shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(LAUNCHER_ID);
match result {
Ok(()) => shared.emit(Event::LauncherRestarting),
Err(e) => shared.emit(Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) }),
Err(e) => {
shared.restarting.store(false, Ordering::SeqCst);
shared.emit(Event::JobFinished { app: LAUNCHER_ID.to_string(), result: Err(e) });
}
}
});
}
@ -447,12 +482,80 @@ fn run_install(
log::warn!("couldn't save the install record: {e}");
}
}
let verb = if existing.is_some() { "updated to" } else { "installed:" };
let note = match verified {
http::Verified::Checksum => "",
http::Verified::SizeOnly => " (this release publishes no checksum, so only its size was checked)",
};
Ok(format!("{} {verb} {}{note}", app.name, release.version_label()))
Ok(format!("{}{note}", installed_text(app, existing.is_some(), &release)))
}
/// What a finished install tells the user.
fn installed_text(app: &CraftApp, update: bool, release: &Release) -> String {
let verb = if update { "updated to" } else { "installed:" };
format!("{} {verb} {}", app.name, release.version_label())
}
/// Whether an install whose job failed with `error` went in all the same. Only a failed call
/// shared by several apps (Linux packages installed together) can have installed some of them;
/// any other error, a cancellation or a pause stands, whatever is found afterwards.
fn landed_despite(error: &str, before: Option<&Installed>, after: Option<&Installed>, latest: &version::Version) -> bool {
error != PAUSED && error != CANCELLED && error != INSTALL_CANCELLED && install::failed_in_shared_call(error) && landed(before, after, latest)
}
/// Whether an install took, whatever its job reported: `latest` is there now and wasn't before.
fn landed(before: Option<&Installed>, after: Option<&Installed>, latest: &version::Version) -> bool {
let has_latest = |copy: Option<&Installed>| copy.and_then(|i| i.version.as_ref()).is_some_and(|v| !version::is_update(latest, v));
has_latest(after) && !has_latest(before)
}
/// For the launcher's own update, before it replaces itself: waits (saying so through `waiting`)
/// until every other job has ended, then sets `restarting`, which keeps new jobs from starting.
/// The restart that follows would otherwise cut their installs short. The user can cancel the
/// wait (`stop`); after `limit` it gives up and names the apps still busy, since one may be
/// stuck behind a password prompt nobody has answered.
fn claim_restart(
jobs: &Mutex<HashMap<String, Arc<Stop>>>,
restarting: &AtomicBool,
stop: &Stop,
limit: Duration,
waiting: &mut dyn FnMut(),
) -> Result<(), String> {
let started = Instant::now();
let mut told = false;
loop {
stop.check()?;
let busy: Vec<&'static str> = {
let jobs = jobs.lock().unwrap_or_else(PoisonError::into_inner);
let busy = jobs.keys().filter(|id| *id != LAUNCHER_ID).map(|id| CraftApp::by_id(id).map_or("another app", |a| a.name)).collect();
if jobs.keys().all(|id| id == LAUNCHER_ID) {
restarting.store(true, Ordering::SeqCst);
return Ok(());
}
busy
};
if started.elapsed() >= limit {
return Err(restart_blocked(busy));
}
if !told {
told = true;
waiting();
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Why the launcher didn't restart: `busy` apps were still installing, and what to do about it.
fn restart_blocked(mut busy: Vec<&str>) -> String {
busy.sort_unstable();
busy.dedup();
let (names, were, them) = match busy.as_slice() {
[one] => ((*one).to_string(), "was", "it"),
_ => (busy.join(", "), "were", "them"),
};
format!(
"ArtCraft Launcher didn't restart to update: {names} {were} still installing. Answer or close any password prompt, \
let {them} finish or cancel {them}, then press Update and restart again."
)
}
/// The file an install of `app` would download now: same format as the installed copy, else
@ -786,6 +889,9 @@ pub fn launcher_download(release: &Release, platform: Platform) -> Option<String
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use artcraft_launcher_core::InstallSource;
use artcraft_launcher_core::protocol::LatestRelease;
@ -817,6 +923,7 @@ mod tests {
latest: Mutex::new(HashMap::new()),
installed: Mutex::new(HashMap::new()),
jobs: Mutex::new(HashMap::new()),
restarting: AtomicBool::new(false),
manifest: Mutex::new(Manifest::default()),
options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false),
@ -1076,4 +1183,99 @@ mod tests {
}
assert_eq!(last_ready(events), Some(false));
}
fn copy(v: &str) -> Installed {
Installed { version: version::parse(v), source: InstallSource::Deb, location: String::new(), package: None }
}
#[test]
fn a_failed_job_counts_as_installed_only_when_the_new_version_arrived() {
let latest = version::parse("0.5.0").unwrap();
// One app of a shared installer call went in before another failed the call.
assert!(landed(Some(&copy("0.4.1")), Some(&copy("0.5.0")), &latest));
assert!(landed(None, Some(&copy("0.5.0")), &latest));
// Nothing changed, so the error stands.
assert!(!landed(Some(&copy("0.4.1")), Some(&copy("0.4.1")), &latest));
assert!(!landed(None, None, &latest));
// It was current before the job ran: the job did not install it.
assert!(!landed(Some(&copy("0.5.0")), Some(&copy("0.5.0")), &latest));
// A copy whose version can't be read proves nothing.
let unreadable = Installed { version: None, ..copy("0.5.0") };
assert!(!landed(Some(&copy("0.4.1")), Some(&unreadable), &latest));
}
#[test]
fn only_a_failed_shared_call_can_count_as_installed() {
let latest = version::parse("0.5.0").unwrap();
let (before, after) = (Some(copy("0.4.1")), Some(copy("0.5.0")));
let landed_after = |error: &str| landed_despite(error, before.as_ref(), after.as_ref(), &latest);
// An app's own failure stands, even if its new version shows up (an MSI or AppImage that
// reported an error, a cancelled or dismissed prompt, a pause).
for error in ["the Windows Installer stopped with error 1603", "couldn't copy the AppImage", CANCELLED, INSTALL_CANCELLED, PAUSED] {
assert!(!landed_after(error), "{error}");
}
// A call shared with other apps may have installed this one before it failed.
let shared = "the system installer stopped with error 100 while handling 2 apps together. Try them one at a time.";
assert_eq!(landed_after(shared), cfg!(target_os = "linux"));
assert!(!landed_despite(shared, before.as_ref(), before.as_ref(), &latest));
}
#[test]
fn the_launcher_gives_up_waiting_and_names_the_busy_apps() {
let jobs = Mutex::new(HashMap::new());
for id in [LAUNCHER_ID, "photocraft"] {
jobs.lock().unwrap().insert(id.to_string(), Arc::new(Stop::default()));
}
let restarting = AtomicBool::new(false);
let error = claim_restart(&jobs, &restarting, &Stop::default(), Duration::from_millis(300), &mut || {}).unwrap_err();
assert!(error.contains("PhotoCraft was still installing") && error.contains("cancel it"), "{error}");
assert!(!restarting.load(Ordering::SeqCst));
assert!(restart_blocked(vec!["WordCraft", "FilmCraft"]).contains("FilmCraft, WordCraft were still installing"));
}
#[test]
fn the_launcher_restarts_only_once_every_other_job_has_ended() {
let jobs = Mutex::new(HashMap::new());
for id in [LAUNCHER_ID, "photocraft", "filmcraft"] {
jobs.lock().unwrap().insert(id.to_string(), Arc::new(Stop::default()));
}
let restarting = AtomicBool::new(false);
let stop = Stop::default();
let (waiting, waits) = mpsc::channel();
std::thread::scope(|scope| {
let (jobs, restarting, stop) = (&jobs, &restarting, &stop);
let claim = scope.spawn(move || claim_restart(jobs, restarting, stop, RESTART_WAIT, &mut || waiting.send(()).unwrap()));
waits.recv().unwrap();
// One install ends; another is still running or queued.
jobs.lock().unwrap().remove("photocraft");
std::thread::sleep(Duration::from_millis(250));
assert!(!claim.is_finished() && !restarting.load(Ordering::SeqCst));
jobs.lock().unwrap().remove("filmcraft");
assert_eq!(claim.join().unwrap(), Ok(()));
});
assert!(restarting.load(Ordering::SeqCst));
assert!(waits.try_recv().is_err(), "it says it is waiting once");
// With nothing else running there is nothing to wait for.
let restarting = AtomicBool::new(false);
assert_eq!(claim_restart(&jobs, &restarting, &stop, RESTART_WAIT, &mut || panic!("it never waits")), Ok(()));
assert!(restarting.load(Ordering::SeqCst));
}
#[test]
fn a_cancelled_launcher_update_stops_waiting_and_claims_nothing() {
let jobs =
Mutex::new(HashMap::from([(LAUNCHER_ID.to_string(), Arc::new(Stop::default())), ("photocraft".to_string(), Arc::new(Stop::default()))]));
let restarting = AtomicBool::new(false);
let stop = Stop::default();
let (waiting, waits) = mpsc::channel();
std::thread::scope(|scope| {
let (jobs, restarting, stop) = (&jobs, &restarting, &stop);
let claim = scope.spawn(move || claim_restart(jobs, restarting, stop, RESTART_WAIT, &mut || waiting.send(()).unwrap()));
waits.recv().unwrap();
stop.cancel();
assert_eq!(claim.join().unwrap(), Err(CANCELLED.to_string()));
});
assert!(!restarting.load(Ordering::SeqCst));
}
}

View File

@ -56,9 +56,9 @@ pub fn install(
#[cfg(target_os = "macos")]
PackageKind::Dmg => macos::install_dmg(app, &file, existing, job.paths).map(|_| None),
#[cfg(windows)]
PackageKind::Msi => windows::run_msi(&file).map(|_| None),
PackageKind::Msi => windows::run_msi(&file, job.stop, &mut *job.phase).map(|_| None),
#[cfg(target_os = "linux")]
PackageKind::Deb | PackageKind::Rpm | PackageKind::Flatpak => linux::install_package(kind, &file).map(|_| None),
PackageKind::Deb | PackageKind::Rpm | PackageKind::Flatpak => linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| None),
#[cfg(target_os = "linux")]
PackageKind::AppImage => {
linux::install_appimage(app, &file, &asset.name, existing).map(|path| Some(record(&version, kind, path, Vec::new(), false)))
@ -191,9 +191,10 @@ pub fn managed_elsewhere(app: &CraftApp, installed: &Installed, uninstall: bool)
}
}
/// Removes an installed copy.
pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>) -> Result<(), String> {
let _ = record;
/// Removes an installed copy. `phase` hears when the removal has to wait for another install;
/// `stop` cancels it while it waits.
pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let _ = (record, stop, &phase);
let path = PathBuf::from(&installed.location);
if let Some(msg) = managed_elsewhere(app, installed, true) {
return Err(msg);
@ -205,11 +206,13 @@ pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>)
}
match installed.source {
#[cfg(windows)]
InstallSource::Msi => windows::uninstall_msi(app),
InstallSource::Msi => windows::uninstall_msi(app, stop, phase),
#[cfg(target_os = "macos")]
InstallSource::MacApp | InstallSource::Launcher => macos::move_to_trash(&path),
#[cfg(target_os = "linux")]
InstallSource::Deb | InstallSource::Rpm | InstallSource::Pacman | InstallSource::Flatpak => linux::remove_package(app, installed),
InstallSource::Deb | InstallSource::Rpm | InstallSource::Pacman | InstallSource::Flatpak => {
linux::remove_package(app, installed, stop, phase)
}
#[cfg(target_os = "freebsd")]
InstallSource::Launcher => match record {
Some(r) => freebsd::remove_files(&r.files, r.needs_root),
@ -310,7 +313,13 @@ fn which(name: &str) -> Option<PathBuf> {
fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {
let pkexec = which("pkexec").ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?;
let status = process::command(pkexec).args(args).status().map_err(|e| format!("couldn't ask for permission ({e})"))?;
match status.code() {
root_outcome(status.code())
}
/// What `pkexec`'s exit code says about the command it ran.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn root_outcome(code: Option<i32>) -> Result<(), String> {
match code {
Some(0) => Ok(()),
// pkexec: 126 = the password prompt was dismissed, 127 = not authorized.
Some(126 | 127) => Err("cancelled".into()),
@ -319,13 +328,91 @@ fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {
}
}
/// How the error of an installer call shared by several apps ends (see `linux::shared_outcome`).
const SHARED_CALL: &str = "apps together. Try them one at a time.";
/// Whether an install failed in one installer call shared with other apps (Linux packages
/// installed together). Such a call can fail after it has installed some of its apps; any other
/// failure is the app's own.
pub fn failed_in_shared_call(error: &str) -> bool {
cfg!(target_os = "linux") && error.ends_with(SHARED_CALL)
}
/// The system's installer runs one job at a time; the others wait their turn here.
#[cfg(any(target_os = "linux", windows))]
static INSTALLER: crate::queue::Queue = crate::queue::Queue::new();
/// For [`INSTALLER`]: reports [`JobPhase::Waiting`] while a job waits, then `working` again.
#[cfg(any(target_os = "linux", windows))]
fn turn<'a>(phase: &'a mut dyn FnMut(JobPhase), working: JobPhase) -> impl FnMut(bool) + 'a {
move |waiting| phase(if waiting { JobPhase::Waiting } else { working })
}
/// For [`INSTALLER`]: whether the user cancelled the job while it waits.
#[cfg(any(target_os = "linux", windows))]
fn cancelled(stop: &Stop) -> impl Fn() -> bool + '_ {
|| stop.check().is_err_and(|e| e == artcraft_launcher_core::protocol::CANCELLED)
}
#[cfg(target_os = "linux")]
pub(crate) mod linux {
use std::ffi::OsStr;
use std::ffi::{OsStr, OsString};
use std::os::unix::fs::PermissionsExt;
use artcraft_launcher_core::protocol::CANCELLED;
use super::*;
/// apt waits this long for another package tool (a software centre, automatic updates) to
/// let go of its lock, instead of failing at once.
const APT_WAITS: &str = "DPkg::Lock::Timeout=60";
/// Where commands that need root go: the installer's queue, then `pkexec`.
pub struct Root<'a> {
pub queue: &'a crate::queue::Queue,
pub run: &'a (dyn Fn(&[&OsStr]) -> Result<(), String> + Sync),
}
const SYSTEM: Root<'static> = Root { queue: &INSTALLER, run: &run_as_root };
/// Runs `command target` as root. Jobs waiting for the same command go into one call
/// (`apt-get install a.deb b.deb`), so the password is asked for once.
fn as_root(
root: &Root<'_>,
command: &[&str],
target: &OsStr,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
working: JobPhase,
) -> Result<(), String> {
let command = command.iter().map(OsString::from).collect();
root.queue.batched(command, target.to_os_string(), &cancelled(stop), &mut turn(phase, working), &|command, targets| {
let args: Vec<&OsStr> = command.iter().chain(targets).map(OsString::as_os_str).collect();
shared_outcome((root.run)(&args), targets.len())
})
}
/// What one call's result means for each of the `count` apps it handled: a failure can't be
/// pinned on one of them, and trying each again by itself would ask for the password each time.
pub fn shared_outcome(result: Result<(), String>, count: usize) -> Result<(), String> {
match result {
Err(e) if count > 1 && e != CANCELLED => Err(format!("{e} while handling {count} {SHARED_CALL}")),
other => other,
}
}
/// The command that installs (or upgrades) a downloaded system package.
pub fn install_command(kind: PackageKind) -> Option<&'static [&'static str]> {
match kind {
// apt resolves dependencies and records the package like any other.
PackageKind::Deb => Some(&["apt-get", "-o", APT_WAITS, "install", "-y", "--allow-downgrades"]),
PackageKind::Rpm if which("dnf").is_some() => Some(&["dnf", "install", "-y"]),
PackageKind::Rpm if which("zypper").is_some() => Some(&["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"]),
PackageKind::Rpm => Some(&["rpm", "-U", "--replacepkgs"]),
_ => None,
}
}
/// What this system can install: its own package format first (when polkit can ask for
/// permission), then Flatpak, then AppImage, which works everywhere.
pub fn available_kinds() -> Vec<PackageKind> {
@ -345,26 +432,16 @@ pub(crate) mod linux {
}
/// Installs (or upgrades) a downloaded package with the system's own tools.
pub fn install_package(kind: PackageKind, file: &Path) -> Result<(), String> {
pub fn install_package(kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
install_package_via(&SYSTEM, kind, file, stop, phase)
}
pub fn install_package_via(root: &Root<'_>, kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let f = file.as_os_str();
if let Some(command) = install_command(kind) {
return as_root(root, command, f, stop, phase, JobPhase::Installing);
}
match kind {
// apt resolves dependencies and records the package like any other.
PackageKind::Deb => run_as_root(&[OsStr::new("apt-get"), OsStr::new("install"), OsStr::new("-y"), OsStr::new("--allow-downgrades"), f]),
PackageKind::Rpm => {
if which("dnf").is_some() {
run_as_root(&[OsStr::new("dnf"), OsStr::new("install"), OsStr::new("-y"), f])
} else if which("zypper").is_some() {
run_as_root(&[
OsStr::new("zypper"),
OsStr::new("--non-interactive"),
OsStr::new("install"),
OsStr::new("--allow-unsigned-rpm"),
f,
])
} else {
run_as_root(&[OsStr::new("rpm"), OsStr::new("-U"), OsStr::new("--replacepkgs"), f])
}
}
PackageKind::Flatpak => {
let status = process::command("flatpak")
.args(["install", "--user", "-y", "--noninteractive", "--bundle"])
@ -381,35 +458,26 @@ pub(crate) mod linux {
}
}
pub fn remove_package(app: &CraftApp, installed: &Installed) -> Result<(), String> {
pub fn remove_package(app: &CraftApp, installed: &Installed, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let mut remove = |command: &[&str], package: &str| as_root(&SYSTEM, command, OsStr::new(package), stop, phase, JobPhase::Removing);
match installed.source {
InstallSource::Deb => run_as_root(&[OsStr::new("apt-get"), OsStr::new("remove"), OsStr::new("-y"), OsStr::new(app.id)]),
InstallSource::Deb => remove(&["apt-get", "-o", APT_WAITS, "remove", "-y"], app.id),
InstallSource::Rpm => {
if which("dnf").is_some() {
run_as_root(&[OsStr::new("dnf"), OsStr::new("remove"), OsStr::new("-y"), OsStr::new(app.id)])
remove(&["dnf", "remove", "-y"], app.id)
} else {
run_as_root(&[OsStr::new("rpm"), OsStr::new("-e"), OsStr::new(app.id)])
remove(&["rpm", "-e"], app.id)
}
}
// The package detected (photocraft, photocraft-bin or photocraft-git).
InstallSource::Pacman => {
let pkg = installed.package.as_deref().unwrap_or(app.id);
run_as_root(&[OsStr::new("pacman"), OsStr::new("-R"), OsStr::new("--noconfirm"), OsStr::new(pkg)])
}
InstallSource::Pacman => remove(&["pacman", "-R", "--noconfirm"], installed.package.as_deref().unwrap_or(app.id)),
InstallSource::Flatpak => {
let id = app.bundle_id();
let user = process::command("flatpak").args(["uninstall", "--user", "-y", "--noninteractive", &id]).status();
if user.is_ok_and(|s| s.success()) {
return Ok(());
}
run_as_root(&[
OsStr::new("flatpak"),
OsStr::new("uninstall"),
OsStr::new("--system"),
OsStr::new("-y"),
OsStr::new("--noninteractive"),
OsStr::new(&id),
])
remove(&["flatpak", "uninstall", "--system", "-y", "--noninteractive"], &id)
}
other => Err(format!("{} isn't a system package", other.label())),
}
@ -654,6 +722,8 @@ mod freebsd {
#[cfg(windows)]
pub mod windows {
use artcraft_launcher_core::protocol::CANCELLED;
use super::*;
fn msiexec() -> PathBuf {
@ -672,29 +742,39 @@ pub mod windows {
}
/// Runs an MSI with a progress window and no restart. Windows asks for permission itself.
pub fn run_msi(msi: &Path) -> Result<(), String> {
let status = process::command(msiexec())
.arg("/i")
.arg(msi)
.args(["/passive", "/norestart"])
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code())
pub fn run_msi(msi: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
in_turn(stop, phase, JobPhase::Installing, || {
let status = process::command(msiexec())
.arg("/i")
.arg(msi)
.args(["/passive", "/norestart"])
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code())
})
}
pub fn uninstall_msi(app: &CraftApp) -> Result<(), String> {
/// Runs `work` once no other Windows Installer job of ours is running (a second one would
/// stop with error 1618). Cancelled while it waits, it never runs.
fn in_turn(stop: &Stop, phase: &mut dyn FnMut(JobPhase), working: JobPhase, work: impl FnOnce() -> Result<(), String>) -> Result<(), String> {
INSTALLER.alone(&cancelled(stop), &mut turn(phase, working), work).unwrap_or_else(|| Err(CANCELLED.into()))
}
pub fn uninstall_msi(app: &CraftApp, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let installs = crate::detect::windows::msi_installs(app);
if installs.is_empty() {
return Err(format!("{} isn't registered with the Windows Installer", app.name));
}
for m in installs {
let status = process::command(msiexec())
.args(["/x", &m.product_code, "/passive", "/norestart"])
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code())?;
}
Ok(())
in_turn(stop, phase, JobPhase::Removing, || {
for m in installs {
let status = process::command(msiexec())
.args(["/x", &m.product_code, "/passive", "/norestart"])
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code())?;
}
Ok(())
})
}
}
@ -811,6 +891,94 @@ mod tests {
assert!(tree::unpack(app, &bad, &dir.path().join("staging")).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn several_debs_fit_one_apt_call_and_share_its_result() {
// The files are appended to this, so it must end where apt expects packages.
assert_eq!(
linux::install_command(PackageKind::Deb),
Some(&["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"][..])
);
assert_eq!(linux::install_command(PackageKind::Flatpak), None);
let failed = || Err("the system installer stopped with error 100".to_string());
assert_eq!(linux::shared_outcome(Ok(()), 3), Ok(()));
assert_eq!(linux::shared_outcome(failed(), 1), failed());
assert_eq!(
linux::shared_outcome(failed(), 3),
Err("the system installer stopped with error 100 while handling 3 apps together. Try them one at a time.".into())
);
// A dismissed password prompt stays a cancellation for every app, not an error.
assert_eq!(linux::shared_outcome(Err("cancelled".into()), 3), Err("cancelled".into()));
}
/// Installs two `.deb` files that reach the installer together (both wait behind a running
/// install), with a stand-in for `pkexec` that exits with `code`. Returns every argument
/// list that reached it, and each job's result.
#[cfg(target_os = "linux")]
fn two_debs_through_pkexec(code: i32) -> (Vec<Vec<String>>, Vec<Result<(), String>>) {
use std::sync::{Mutex, mpsc};
let queue = crate::queue::Queue::new();
let ran = Mutex::new(Vec::new());
let run = |args: &[&std::ffi::OsStr]| {
ran.lock().unwrap().push(args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>());
root_outcome(Some(code))
};
let root = linux::Root { queue: &queue, run: &run };
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (waiting, waits) = mpsc::channel();
let results = std::thread::scope(|scope| {
let (queue, root) = (&queue, &root);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let jobs = ["/downloads/a.deb", "/downloads/b.deb"].map(|file| {
let waiting = waiting.clone();
scope.spawn(move || {
let mut phase = |p: JobPhase| {
if p == JobPhase::Waiting {
waiting.send(()).unwrap();
}
};
linux::install_package_via(root, PackageKind::Deb, Path::new(file), &Stop::default(), &mut phase)
})
});
waits.recv().unwrap();
waits.recv().unwrap();
release.send(()).unwrap();
jobs.map(|j| j.join().unwrap()).to_vec()
});
(ran.into_inner().unwrap(), results)
}
#[cfg(target_os = "linux")]
#[test]
fn two_debs_reach_root_as_one_apt_call() {
let (ran, results) = two_debs_through_pkexec(0);
assert_eq!(ran.len(), 1, "{ran:?}");
let (command, files) = ran[0].split_at(6);
assert_eq!(command, ["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"]);
let mut files = files.to_vec();
files.sort();
assert_eq!(files, ["/downloads/a.deb", "/downloads/b.deb"]);
assert_eq!(results, [Ok(()), Ok(())]);
// The password prompt was dismissed: one call, and both apps are cancelled, not failed.
let (ran, results) = two_debs_through_pkexec(126);
assert_eq!(ran.len(), 1, "{ran:?}");
assert_eq!(results, vec![Err("cancelled".to_string()); 2]);
// apt failed: both apps say so, and how to find the one at fault.
let (ran, results) = two_debs_through_pkexec(100);
assert_eq!(ran.len(), 1, "{ran:?}");
let error = "the system installer stopped with error 100 while handling 2 apps together. Try them one at a time.";
assert_eq!(results, vec![Err(error.to_string()); 2]);
}
#[test]
fn updates_keep_the_installed_format() {
let inst = |source| Installed { version: None, source, location: String::new(), package: None };

View File

@ -17,6 +17,7 @@ pub mod manifest;
pub mod packages;
pub mod paths;
pub mod process;
pub mod queue;
pub mod self_update;
pub mod single_instance;
pub mod source;

512
crates/native/src/queue.rs Normal file
View File

@ -0,0 +1,512 @@
//! One system installer at a time.
//!
//! apt, dnf, rpm and the Windows Installer each hold a system-wide lock: an install started
//! while another runs fails. Jobs wait their turn here instead. Where the installer takes
//! several packages, the jobs waiting for the same command share one call, so the password is
//! asked for once.
use std::ffi::OsString;
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use artcraft_launcher_core::protocol::CANCELLED;
type Outcome = Result<(), String>;
struct Waiting {
id: u64,
command: Vec<OsString>,
target: OsString,
}
struct State {
busy: bool,
next_id: u64,
waiting: Vec<Waiting>,
/// The jobs of the call in progress.
running: Vec<u64>,
finished: Vec<(u64, Outcome)>,
}
pub struct Queue {
state: Mutex<State>,
changed: Condvar,
}
/// Frees the installer when a call ends (even by unwinding) and hands its outcome to the other
/// jobs of the call.
struct Turn<'a> {
queue: &'a Queue,
others: Vec<u64>,
outcome: Outcome,
}
impl Drop for Turn<'_> {
fn drop(&mut self) {
let mut state = self.queue.lock();
state.busy = false;
state.running.clear();
for id in self.others.drain(..) {
state.finished.push((id, self.outcome.clone()));
}
self.queue.changed.notify_all();
}
}
impl Default for Queue {
fn default() -> Self {
Self::new()
}
}
impl Queue {
pub const fn new() -> Self {
let state = State { busy: false, next_id: 0, waiting: Vec::new(), running: Vec::new(), finished: Vec::new() };
Self { state: Mutex::new(state), changed: Condvar::new() }
}
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Until something changes, or long enough to ask again whether the job was cancelled.
fn wait<'a>(&self, state: MutexGuard<'a, State>) -> MutexGuard<'a, State> {
self.changed.wait_timeout(state, Duration::from_millis(100)).unwrap_or_else(PoisonError::into_inner).0
}
/// Runs `work` once no other installer is running. `turn(true)` says it has to wait,
/// `turn(false)` that the wait is over. `None` when `cancelled` (asked a few times a second,
/// so it must not block) said so during the wait; `work` then never ran.
pub fn alone<R>(&self, cancelled: &dyn Fn() -> bool, turn: &mut dyn FnMut(bool), work: impl FnOnce() -> R) -> Option<R> {
let mut state = self.lock();
let mut waited = false;
loop {
if waited && cancelled() {
return None;
}
if !state.busy {
break;
}
if waited {
state = self.wait(state);
} else {
waited = true;
drop(state);
turn(true);
state = self.lock();
}
}
state.busy = true;
drop(state);
if waited {
turn(false);
}
let _turn = Turn { queue: self, others: Vec::new(), outcome: Ok(()) };
Some(work())
}
/// Runs `exec(command, targets)` once no other installer is running, with the targets of
/// every job waiting for the same `command`, and gives each of those jobs its outcome.
/// `turn(true)` says the job has to wait, `turn(false)` that its call has started.
///
/// A job that is waiting leaves with [`CANCELLED`] once `cancelled` (asked a few times a
/// second, so it must not block) says so; once its call has started it sees it through.
pub fn batched(
&self,
command: Vec<OsString>,
target: OsString,
cancelled: &dyn Fn() -> bool,
turn: &mut dyn FnMut(bool),
exec: &dyn Fn(&[OsString], &[OsString]) -> Outcome,
) -> Outcome {
let mut state = self.lock();
let id = state.next_id;
state.next_id = state.next_id.wrapping_add(1);
state.waiting.push(Waiting { id, command: command.clone(), target });
let (mut waited, mut started) = (false, false);
loop {
if let Some(at) = state.finished.iter().position(|(done, _)| *done == id) {
return state.finished.swap_remove(at).1;
}
// Another job took this one into its call.
let running = state.running.contains(&id);
if !running && waited && cancelled() {
state.waiting.retain(|w| w.id != id);
return Err(CANCELLED.into());
}
if !running && !state.busy {
break;
}
let tell = if running { waited && !started } else { !waited };
if tell {
(waited, started) = (true, running);
drop(state);
turn(!running);
state = self.lock();
} else {
state = self.wait(state);
}
}
let (batch, rest): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut state.waiting).into_iter().partition(|w| w.command == command);
state.waiting = rest;
state.busy = true;
state.running = batch.iter().map(|w| w.id).collect();
self.changed.notify_all();
drop(state);
if waited {
turn(false);
}
let others = batch.iter().map(|w| w.id).filter(|other| *other != id).collect();
let targets: Vec<OsString> = batch.into_iter().map(|w| w.target).collect();
let mut call = Turn { queue: self, others, outcome: Err("the system installer was interrupted".into()) };
let outcome = exec(&command, &targets);
call.outcome.clone_from(&outcome);
outcome
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use super::*;
fn os(s: &str) -> OsString {
OsString::from(s)
}
fn line(parts: &[&str]) -> Vec<OsString> {
parts.iter().map(|p| os(p)).collect()
}
fn install() -> Vec<OsString> {
line(&["apt-get", "install", "-y"])
}
fn remove() -> Vec<OsString> {
line(&["apt-get", "remove", "-y"])
}
/// Every call made, as `command + targets` with the targets sorted.
#[derive(Default)]
struct Calls(Mutex<Vec<Vec<OsString>>>);
impl Calls {
fn record(&self, command: &[OsString], targets: &[OsString]) {
let mut targets = targets.to_vec();
targets.sort();
self.0.lock().unwrap().push(command.iter().cloned().chain(targets).collect());
}
fn taken(&self) -> Vec<Vec<OsString>> {
let mut calls = std::mem::take(&mut *self.0.lock().unwrap());
calls.sort();
calls
}
}
/// Starts `jobs` (command, target) while an install is running, so they all wait; lets that
/// install finish once every one of them has said it is waiting. Returns their outcomes.
fn behind_a_running_install(
queue: &Queue,
jobs: &[(Vec<OsString>, &str)],
exec: &(dyn Fn(&[OsString], &[OsString]) -> Outcome + Sync),
) -> Vec<Option<Outcome>> {
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let handles: Vec<_> = jobs
.iter()
.map(|(command, target)| {
let turned = turned.clone();
scope.spawn(move || {
let mut turn = |waiting: bool| {
if waiting {
turned.send(()).unwrap();
}
};
queue.batched(command.clone(), os(target), &|| false, &mut turn, exec)
})
})
.collect();
for _ in jobs {
turns.recv().unwrap();
}
release.send(()).unwrap();
handles.into_iter().map(|h| h.join().ok()).collect()
})
}
#[test]
fn jobs_that_wait_together_share_one_call_per_command() {
let queue = Queue::new();
let calls = Calls::default();
let jobs = [(install(), "a.deb"), (install(), "b.deb"), (remove(), "photocraft"), (install(), "c.deb")];
let results = behind_a_running_install(&queue, &jobs, &|command, targets| {
calls.record(command, targets);
Ok(())
});
assert_eq!(results, vec![Some(Ok(())); 4]);
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "a.deb", "b.deb", "c.deb"]), line(&["apt-get", "remove", "-y", "photocraft"])]);
}
#[test]
fn installers_never_run_at_the_same_time() {
let queue = Queue::new();
let (inside, most, handled) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0));
let enter = || {
most.fetch_max(inside.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(3));
inside.fetch_sub(1, Ordering::SeqCst);
};
std::thread::scope(|scope| {
for i in 0..24 {
let (queue, enter, handled) = (&queue, &enter, &handled);
scope.spawn(move || {
std::thread::sleep(Duration::from_millis(i % 5));
let exec = |_: &[OsString], targets: &[OsString]| {
enter();
handled.fetch_add(targets.len(), Ordering::SeqCst);
Ok(())
};
match i % 3 {
0 => queue.batched(install(), os(&format!("{i}.deb")), &|| false, &mut |_| {}, &exec).unwrap(),
1 => queue.batched(remove(), os(&format!("app{i}")), &|| false, &mut |_| {}, &exec).unwrap(),
_ => queue
.alone(&|| false, &mut |_| {}, || {
enter();
handled.fetch_add(1, Ordering::SeqCst);
})
.unwrap(),
}
});
}
});
assert_eq!(most.load(Ordering::SeqCst), 1);
assert_eq!(handled.load(Ordering::SeqCst), 24);
}
#[test]
fn a_job_arriving_mid_install_waits_then_runs() {
let queue = Queue::new();
let calls = Calls::default();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
let (queue, calls) = (&queue, &calls);
let first = scope.spawn(move || {
queue.batched(install(), os("a.deb"), &|| false, &mut |_| panic!("the first job never waits"), &|command, targets| {
calls.record(command, targets);
started.send(()).unwrap();
released.recv().unwrap();
Ok(())
})
});
has_started.recv().unwrap();
let second = scope.spawn(move || {
queue.batched(install(), os("b.deb"), &|| false, &mut |waiting| turned.send(waiting).unwrap(), &|command, targets| {
calls.record(command, targets);
Ok(())
})
});
assert!(turns.recv().unwrap(), "the second job says it is waiting");
assert_eq!(calls.0.lock().unwrap().len(), 1, "and does not start while the first runs");
release.send(()).unwrap();
assert_eq!(first.join().unwrap(), Ok(()));
assert_eq!(second.join().unwrap(), Ok(()));
assert!(!turns.recv().unwrap(), "then that its turn came");
});
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "a.deb"]), line(&["apt-get", "install", "-y", "b.deb"])]);
}
#[test]
fn a_job_taken_into_another_jobs_call_hears_it_started() {
let queue = Queue::new();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
let installing = AtomicUsize::new(0);
std::thread::scope(|scope| {
let (queue, installing) = (&queue, &installing);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
for file in ["a.deb", "b.deb"] {
let turned = turned.clone();
scope.spawn(move || {
let mut turn = |waiting: bool| {
if !waiting {
installing.fetch_add(1, Ordering::SeqCst);
}
turned.send(waiting).unwrap();
};
// The call lasts until both jobs know it is under way.
let exec = |_: &[OsString], targets: &[OsString]| {
assert_eq!(targets.len(), 2);
while installing.load(Ordering::SeqCst) < 2 {
std::thread::sleep(Duration::from_millis(1));
}
Ok(())
};
assert_eq!(queue.batched(install(), os(file), &|| false, &mut turn, &exec), Ok(()));
});
}
assert!(turns.recv().unwrap() && turns.recv().unwrap());
release.send(()).unwrap();
});
assert_eq!(turns.try_iter().collect::<Vec<_>>(), [false, false]);
}
#[test]
fn a_dismissed_prompt_cancels_every_job_of_the_call() {
let queue = Queue::new();
let calls = Calls::default();
let jobs = [(install(), "a.deb"), (install(), "b.deb"), (install(), "c.deb")];
let results = behind_a_running_install(&queue, &jobs, &|command, targets| {
calls.record(command, targets);
Err("cancelled".into())
});
assert_eq!(calls.taken().len(), 1);
assert_eq!(results, vec![Some(Err("cancelled".to_string())); 3]);
}
#[test]
fn a_waiting_job_can_be_cancelled_and_is_left_out_of_the_call() {
let queue = Queue::new();
let calls = Calls::default();
let cancel = AtomicBool::new(false);
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
std::thread::scope(|scope| {
let (queue, calls, cancel) = (&queue, &calls, &cancel);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let exec = |command: &[OsString], targets: &[OsString]| {
calls.record(command, targets);
Ok(())
};
let turned_too = turned.clone();
let leaving = scope.spawn(move || {
queue.batched(install(), os("a.deb"), &|| cancel.load(Ordering::SeqCst), &mut |waiting| turned.send(waiting).unwrap(), &exec)
});
let staying =
scope.spawn(move || queue.batched(install(), os("b.deb"), &|| false, &mut |waiting| turned_too.send(waiting).unwrap(), &exec));
assert!(turns.recv().unwrap() && turns.recv().unwrap());
cancel.store(true, Ordering::SeqCst);
// It leaves while the other install is still running, without waiting for its turn.
assert_eq!(leaving.join().unwrap(), Err("cancelled".to_string()));
assert!(calls.0.lock().unwrap().is_empty());
release.send(()).unwrap();
assert_eq!(staying.join().unwrap(), Ok(()));
});
assert_eq!(calls.taken(), [line(&["apt-get", "install", "-y", "b.deb"])]);
}
#[test]
fn waiting_alone_for_the_installer_can_be_cancelled() {
let queue = Queue::new();
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
std::thread::scope(|scope| {
let queue = &queue;
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let ran = queue.alone(&|| true, &mut |_| {}, || panic!("a cancelled job never runs"));
assert_eq!(ran, None);
release.send(()).unwrap();
});
}
#[test]
fn a_job_whose_call_has_started_sees_it_through() {
let queue = Queue::new();
let cancel = AtomicBool::new(false);
let (in_call, is_in_call) = mpsc::channel();
let (finish, finished) = mpsc::channel::<()>();
let finished = Mutex::new(finished);
let jobs = [(install(), "a.deb"), (install(), "b.deb")];
std::thread::scope(|scope| {
let (queue, cancel, finished) = (&queue, &cancel, &finished);
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (turned, turns) = mpsc::channel();
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let handles: Vec<_> = jobs
.iter()
.map(|(command, target)| {
let (turned, in_call) = (turned.clone(), in_call.clone());
scope.spawn(move || {
let mut turn = |waiting: bool| {
if waiting {
turned.send(()).unwrap();
}
};
queue.batched(command.clone(), os(target), &|| cancel.load(Ordering::SeqCst), &mut turn, &|_, targets| {
assert_eq!(targets.len(), 2);
in_call.send(()).unwrap();
finished.lock().unwrap().recv().unwrap();
Ok(())
})
})
})
.collect();
turns.recv().unwrap();
turns.recv().unwrap();
release.send(()).unwrap();
is_in_call.recv().unwrap();
// Cancelled mid-call: the job taken into the other's call has time to notice, and must not leave.
cancel.store(true, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(300));
finish.send(()).unwrap();
for h in handles {
assert_eq!(h.join().unwrap(), Ok(()));
}
});
}
#[test]
fn an_installer_that_dies_frees_the_queue_and_fails_its_jobs() {
let queue = Queue::new();
let jobs = [(install(), "a.deb"), (install(), "b.deb")];
let results = behind_a_running_install(&queue, &jobs, &|_, targets| {
assert_eq!(targets.len(), 2);
panic!("the installer died")
});
// The job that made the call unwound; the other was told.
assert!(results.contains(&None), "{results:?}");
assert!(results.contains(&Some(Err("the system installer was interrupted".to_string()))), "{results:?}");
assert_eq!(queue.batched(install(), os("c.deb"), &|| false, &mut |_| {}, &|_, _| Ok(())), Ok(()));
}
}

View File

@ -152,7 +152,9 @@ fn detect(_exe: &Path) -> Option<Install> {
}
/// Downloads `release` for `platform`, puts it in place and starts the helper that relaunches
/// it once this process has exited. On success the caller must quit promptly.
/// it once this process has exited. On success the caller must quit promptly. `last` is called
/// once the download is ready and returns when the launcher may replace itself and restart.
#[allow(clippy::too_many_arguments)]
pub fn update(
install: &Install,
release: &Release,
@ -161,12 +163,15 @@ pub fn update(
paths: &Paths,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
last: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
let (asset, _) = pick_asset(release, &[LAUNCHER.id], platform, &[install.kind()])
.ok_or_else(|| format!("ArtCraft Launcher {} has no {} for {}", release.version_label(), install.kind().label(), platform.label()))?;
let asset = asset.clone();
let mut job = Job { http, paths, stop, phase };
let (file, _) = install::download(release, &asset, &mut job)?;
// The restart ends this process: nothing else may be under way.
last()?;
phase(JobPhase::Installing);
if let Err(e) = crate::paths::write_atomic(&marker(paths), format!("{}\n{}\n", release.tag, crate::now()).as_bytes()) {
log::warn!("couldn't record the self-update: {e}");
@ -189,7 +194,7 @@ pub fn update(
}
#[cfg(target_os = "linux")]
Install::Package(kind, exe) => {
let result = install::linux::install_package(*kind, &file);
let result = install::linux::install_package(*kind, &file, stop, phase);
let _ = std::fs::remove_file(&file);
result?;
relaunch_after_exit(pid, &[exe.as_os_str()])

View File

@ -3,7 +3,7 @@
use std::time::Duration;
use artcraft_launcher_core::protocol::{
Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, LatestRelease, PAUSED, Source,
Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, PAUSED, Source,
};
use artcraft_launcher_core::release::Release;
use artcraft_launcher_core::status::AppStatus;
@ -276,9 +276,9 @@ impl LauncherUi {
false
}
Event::JobFinished { app, result } => {
if app == artcraft_launcher_core::catalog::LAUNCHER_ID {
self.launcher_job = None;
}
let launcher = app == artcraft_launcher_core::catalog::LAUNCHER_ID;
// What the job was doing when it ended says what a cancellation called off.
let last = if launcher { self.launcher_job.take() } else { self.entry(&app).and_then(|e| e.job) };
if let Some(e) = self.entry_mut(&app) {
e.job = None;
}
@ -287,6 +287,12 @@ impl LauncherUi {
Ok(msg) => self.toast(ctx, msg, false),
// The paused card says it all.
Err(e) if e == PAUSED => {}
Err(e) if e == CANCELLED && launcher => self.toast(ctx, "Update cancelled", false),
// An install's own cancellations end as INSTALL_CANCELLED, so past the
// download this is a removal called off while it waited for the installer.
Err(e) if e == CANCELLED && matches!(last, Some(JobPhase::Waiting | JobPhase::Removing)) => {
self.toast(ctx, "Uninstall cancelled", false);
}
Err(e) if e == CANCELLED => self.toast(ctx, "Download cancelled", false),
Err(e) if e == INSTALL_CANCELLED && kept => {
self.toast(ctx, "Install cancelled. The download is kept for the next try.", false);

View File

@ -116,8 +116,17 @@ impl LauncherUi {
),
JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. } => (None, "Downloading…".to_string()),
JobPhase::Verifying => (None, "Verifying…".to_string()),
JobPhase::Waiting => (None, "Waiting for other installs…".to_string()),
JobPhase::Installing | JobPhase::Removing => (None, "Installing, then restarting…".to_string()),
};
// Another app's install can hang on a password prompt: the wait can be called off.
if phase == JobPhase::Waiting
&& widgets::glyph_button(ui, widgets::Glyph::Close, "Cancel update")
.on_hover_text("Stop waiting; the update can be started again later")
.clicked()
{
self.send(Command::Cancel { app: artcraft_launcher_core::catalog::LAUNCHER_ID.to_string() });
}
widgets::progress(ui, fraction, &caption, t.accent, 220.0);
return;
}
@ -630,18 +639,22 @@ impl LauncherUi {
(f, if short { "Reconnecting…".into() } else { format!("Reconnecting… {}", amount(done, total)) }, t.warn)
}
JobPhase::Verifying => (None, "Verifying checksum…".into(), rgb(entry.app.color)),
JobPhase::Waiting => (None, "Waiting for another app…".into(), rgb(entry.app.color)),
JobPhase::Installing => (None, "Installing…".into(), rgb(entry.app.color)),
JobPhase::Removing => (None, "Removing…".into(), rgb(entry.app.color)),
};
let downloading = matches!(phase, JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. });
// A job waiting for the installer hasn't started it yet, so it can still be called off.
let waiting = phase == JobPhase::Waiting;
// Waiting has one square button beside the bar, like the menu otherwise.
widgets::progress(ui, fraction, &caption, color, if downloading { bar_w } else { main_w });
if downloading {
if widgets::glyph_button(ui, widgets::Glyph::Pause, "Pause download").clicked() {
self.send(Command::Pause { app: id.clone() });
}
if widgets::glyph_button(ui, widgets::Glyph::Close, "Cancel download").clicked() {
self.send(Command::Cancel { app: id });
}
if downloading && widgets::glyph_button(ui, widgets::Glyph::Pause, "Pause download").clicked() {
self.send(Command::Pause { app: id.clone() });
}
if (downloading || waiting)
&& widgets::glyph_button(ui, widgets::Glyph::Close, if waiting { "Cancel" } else { "Cancel download" }).clicked()
{
self.send(Command::Cancel { app: id });
}
return;
}

View File

@ -305,6 +305,60 @@ fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again
assert!(h.query_by_label("Download again").is_none());
}
#[test]
fn an_install_waiting_for_another_says_so_and_can_be_cancelled() {
let mut events = events();
// Two updates reached the system's installer together: one runs, the other waits its turn.
events.push(Event::Progress { app: "photocraft".into(), phase: JobPhase::Installing });
events.push(Event::Progress { app: "effectcraft".into(), phase: JobPhase::Waiting });
let (mut h, sent) = harness_with(events, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
h.run_steps(6);
snapshot(&mut h, "grid-waiting-dark");
h.get_by_label("Installing…");
h.get_by_label("Waiting for another app…");
// Only the waiting one: the install that is running can't be called off.
h.get_by_label("Cancel").click();
h.run_steps(2);
let cancels: Vec<_> = sent.borrow().iter().filter(|c| matches!(c, Command::Cancel { .. })).cloned().collect();
assert_eq!(cancels, [Command::Cancel { app: "effectcraft".into() }]);
}
#[test]
fn the_launcher_update_waiting_for_other_installs_can_be_cancelled() {
let mut ev = events();
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
ev.push(Event::Progress { app: "artcraft-launcher".into(), phase: JobPhase::Waiting });
let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
h.run_steps(6);
snapshot(&mut h, "grid-launcher-waiting-dark");
h.get_by_label("Waiting for other installs…");
sent.borrow_mut().clear();
h.get_by_label("Cancel update").click();
h.run_steps(2);
assert_eq!(*sent.borrow(), [Command::Cancel { app: "artcraft-launcher".into() }]);
}
#[test]
fn each_cancellation_says_what_it_called_off() {
let finished = |app: &str, why: &str| Event::JobFinished { app: app.into(), result: Err(why.into()) };
let mut ev = events();
// An uninstall called off while it waited for the installer.
ev.push(Event::Progress { app: "lightcraft".into(), phase: JobPhase::Removing });
ev.push(Event::Progress { app: "lightcraft".into(), phase: JobPhase::Waiting });
ev.push(finished("lightcraft", CANCELLED));
// The launcher's own update, cancelled while it waited.
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
ev.push(Event::Progress { app: "artcraft-launcher".into(), phase: JobPhase::Waiting });
ev.push(finished("artcraft-launcher", CANCELLED));
// A download cancelled mid-way.
ev.push(finished("filmcraft", CANCELLED));
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
h.run_steps(6);
for toast in ["Uninstall cancelled", "Update cancelled", "Download cancelled"] {
assert_eq!(h.query_all_by_label(toast).count(), 1, "{toast}");
}
}
#[test]
fn table_view_and_light_theme() {
let (mut h, sent) = harness(ViewMode::Table, egui::Theme::Light, true);