ArtCraft Launcher updates itself and restarts

When a newer launcher is released, the banner offers "Update and
restart": the new build is downloaded and verified like any Craft App,
put in place the way this copy was installed, and started by a small
helper once this process has exited (it holds the single-instance lock,
and Windows locks a running exe).

- macOS .app: new bundle copied from the DMG over this one, reopened
- Windows MSI (per-user): helper runs the new MSI after exit
- Windows portable: helper unzips over the folder after exit; settings stay
- Linux AppImage: new AppImage renamed over this one
- Linux .deb / .rpm: system package manager, then restart
- Flatpak, FreeBSD and dev builds keep the download link

ARTCRAFT_LAUNCHER_PRETEND_VERSION makes an installed copy act old, to try
the update against the current release. Tested: the helper waits for the
old process to exit; a real DMG replaces a bundle in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 23:49:38 -04:00
parent 37a1ef72ad
commit b642df291d
11 changed files with 542 additions and 11 deletions
+4
View File
@@ -95,6 +95,10 @@ installed by hand (and the launcher finds apps you installed yourself):
| Linux | `/usr/bin`, `/usr/share/applications` | The release `.deb` (apt) or `.rpm` (dnf, zypper), with your system's password prompt. Without those, a Flatpak, or an AppImage in `~/Applications` |
| FreeBSD | `/usr/local` | The release tree, with a password prompt (polkit); `~/.local` when polkit isn't installed |
ArtCraft Launcher keeps itself current too: when a new version is out, **Update and restart**
downloads it, verifies it, puts it where this copy is installed (the `.app`, the Windows Installer
package or portable folder, the AppImage, or the `.deb`/`.rpm`) and reopens it.
Updates use the same format an app was installed with. Every download is checked against the
release's published SHA-256 before it is installed, and a running app is never replaced.
+1 -1
View File
@@ -182,7 +182,7 @@ impl Backend for WebBackend {
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: false, reveal_label: "" }
Capabilities { manage_apps: false, reveal_label: "", self_update: false }
}
fn platform(&self) -> Option<Platform> {
+13
View File
@@ -170,6 +170,19 @@ pub const SOCIAL: [(&str, &str); 4] = [
/// The ArtCraft community on Discord.
pub const DISCORD_URL: &str = "https://discord.gg/artcraft";
/// ArtCraft Launcher itself, described like a Craft App so it can update itself with the same
/// machinery (asset names `artcraft-launcher-<ver>-…`, bundle id `ai.storyteller.artcraft-launcher`,
/// `ArtCraft Launcher.app`). Not part of [`SUITE`].
pub const LAUNCHER: CraftApp = app(
"artcraft-launcher",
"ArtCraft Launcher",
"Installs and updates the Craft App Suite",
"",
AppGroup::New,
[0x0b, 0x0b, 0x0c],
"storytold/craft-launcher",
);
/// The launcher's own repo, checked so it can say when a newer launcher is out.
pub const LAUNCHER_REPO: &str = "storytold/craft-launcher";
/// The launcher's own asset stem.
+7
View File
@@ -41,6 +41,8 @@ pub enum Command {
Cancel {
app: String,
},
/// Download the newer ArtCraft Launcher, put it in place and restart into it.
UpdateLauncher,
/// Change how the backend behaves.
Configure(BackendOptions),
}
@@ -125,6 +127,8 @@ pub enum Event {
},
/// The latest ArtCraft Launcher release, when it's newer than this one.
LauncherUpdate(Release),
/// The new launcher is in place (or will be, once this one exits): quit now so it can start.
LauncherRestarting,
Detected {
app: String,
installed: Option<Installed>,
@@ -161,6 +165,9 @@ pub struct Capabilities {
pub manage_apps: bool,
/// What "show in file manager" is called here (`Show in Finder`).
pub reveal_label: &'static str,
/// The launcher can update itself in place and restart (an installed build, not a dev build or
/// a Flatpak). Without it the UI offers the download instead.
pub self_update: bool,
}
/// The UI's view of a backend.
+52 -3
View File
@@ -38,6 +38,8 @@ struct Shared {
/// A detection pass is running; another request waits for the next one.
detecting: AtomicBool,
last_check: Mutex<Option<i64>>,
/// The newer ArtCraft Launcher release, once a check has found one.
launcher_release: Mutex<Option<Release>>,
}
impl Shared {
@@ -53,6 +55,8 @@ pub struct NativeBackend {
http: Option<Http>,
source: Option<Arc<dyn ReleaseSource>>,
platform: Option<Platform>,
/// How this launcher is installed, when it can replace itself.
self_install: Option<crate::self_update::Install>,
}
impl NativeBackend {
@@ -82,9 +86,10 @@ impl NativeBackend {
options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false),
detecting: AtomicBool::new(false),
launcher_release: Mutex::new(None),
last_check: Mutex::new(None),
});
let backend = Self { shared, paths, http, source, platform: native_platform() };
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
backend.replay_cache();
backend.detect_all();
backend
@@ -176,9 +181,10 @@ impl NativeBackend {
});
// Is there a newer ArtCraft Launcher?
if let LatestRelease::Found(r) = source.latest(LAUNCHER_REPO, false).latest
&& let (Some(latest), Some(current)) = (r.version.as_ref(), version::parse(env!("CARGO_PKG_VERSION")))
&& let (Some(latest), Some(current)) = (r.version.as_ref(), crate::self_update::current_version())
&& version::is_newer(latest, &current)
{
*shared.launcher_release.lock().unwrap_or_else(PoisonError::into_inner) = Some(r.clone());
shared.emit(Event::LauncherUpdate(r));
}
source.save();
@@ -254,6 +260,48 @@ impl NativeBackend {
});
}
/// Downloads the newer launcher, puts it in place and asks the UI to quit so it can start.
fn update_launcher(&self) {
let id = LAUNCHER_ID.to_string();
let (Some(install), Some(platform)) = (self.self_install.clone(), self.platform) else {
self.shared.emit(Event::JobFinished {
app: id,
result: Err("this copy of ArtCraft Launcher can't update itself; download the new version instead".into()),
});
return;
};
let Some(release) = self.shared.launcher_release.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return };
let stop = Arc::new(Stop::default());
{
let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner);
if jobs.contains_key(&id) {
return;
}
jobs.insert(id.clone(), stop.clone());
}
let (shared, paths, http) = (self.shared.clone(), self.paths.clone(), self.http.clone());
std::thread::spawn(move || {
let mut last_emit = Instant::now() - Duration::from_secs(1);
let mut phase = |p: JobPhase| {
if matches!(p, JobPhase::Downloading { done, total } if done != total && done != 0) && last_emit.elapsed() < Duration::from_millis(50)
{
return;
}
last_emit = Instant::now();
shared.emit(Event::Progress { app: LAUNCHER_ID.to_string(), phase: p });
};
let result = match http.as_ref() {
Some(http) => crate::self_update::update(&install, &release, platform, http, &paths, &stop, &mut phase),
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) }),
}
});
}
fn installed(&self, app: &str) -> Option<Installed> {
self.shared.installed.lock().unwrap_or_else(PoisonError::into_inner).get(app).cloned()
}
@@ -407,6 +455,7 @@ impl Backend for NativeBackend {
}
}
}
Command::UpdateLauncher => self.update_launcher(),
Command::Configure(options) => {
let changed_pre = {
let mut o = self.shared.options.lock().unwrap_or_else(PoisonError::into_inner);
@@ -431,7 +480,7 @@ impl Backend for NativeBackend {
Some(Os::Windows) => "Show in Explorer",
_ => "Show in Files",
};
Capabilities { manage_apps: true, reveal_label }
Capabilities { manage_apps: true, reveal_label, self_update: self.self_install.is_some() }
}
fn platform(&self) -> Option<Platform> {
+2 -2
View File
@@ -130,7 +130,7 @@ pub fn available_kinds() -> Vec<PackageKind> {
/// Fetches `asset` into the download folder, checked against its published SHA-256: GitHub's
/// per-file digest, else the release's `SHA256SUMS.txt`. A release with neither still installs,
/// checked by size only, and says so.
fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<(PathBuf, Verified), String> {
pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<(PathBuf, Verified), String> {
if !release::valid_file_name(&asset.name) {
return Err("the release has an unexpected file name".into());
}
@@ -311,7 +311,7 @@ fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {
}
#[cfg(target_os = "linux")]
mod linux {
pub(crate) mod linux {
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt;
+1
View File
@@ -16,6 +16,7 @@ pub mod manifest;
pub mod packages;
pub mod paths;
pub mod process;
pub mod self_update;
pub mod single_instance;
pub mod source;
+344
View File
@@ -0,0 +1,344 @@
//! ArtCraft Launcher updating itself.
//!
//! The new version is downloaded and verified like any Craft App, then swapped in the way this
//! copy was installed. A small helper process waits for this launcher to exit, finishes anything
//! that can't happen while it runs (Windows locks a running exe), and starts the new one. The
//! launcher has to exit first anyway: it holds the single-instance lock, and a new copy started
//! while it runs would only ask it to show its window.
//!
//! | Installed as | Swap |
//! |---|---|
//! | macOS `.app` | copy the new bundle from the DMG over this one (fine while running), reopen it |
//! | Windows MSI (per-user) | after exit, run the new MSI, then start the exe |
//! | Windows portable zip | after exit, unzip over this folder (settings in `ArtCraftLauncherData` stay) |
//! | Linux AppImage | rename the new AppImage over this one (fine while running), start it |
//! | Linux `.deb` / `.rpm` | the system package manager, with the password prompt; then restart |
//!
//! Anything else (a Flatpak, FreeBSD, a development build) gets the download link instead.
use std::path::{Path, PathBuf};
use artcraft_launcher_core::catalog::LAUNCHER;
use artcraft_launcher_core::platform::{Platform, pick_asset};
use artcraft_launcher_core::protocol::JobPhase;
use artcraft_launcher_core::release::Release;
use artcraft_launcher_core::version::{self, Version};
use artcraft_launcher_core::PackageKind;
#[cfg(target_os = "macos")]
use artcraft_launcher_core::{InstallSource, Installed};
use crate::http::{Http, Stop};
use crate::install::{self, Job};
use crate::paths::Paths;
use crate::process;
/// How this running launcher was installed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Install {
/// The `ArtCraft Launcher.app` bundle.
MacApp(PathBuf),
/// The per-user Windows Installer package; the exe.
Msi(PathBuf),
/// The Windows portable zip; the exe (with `portable.txt` beside it).
Portable(PathBuf),
/// The AppImage file.
AppImage(PathBuf),
/// A Debian or RPM package; the binary.
Package(PackageKind, PathBuf),
}
impl Install {
fn kind(&self) -> PackageKind {
match self {
Self::MacApp(_) => PackageKind::Dmg,
Self::Msi(_) => PackageKind::Msi,
Self::Portable(_) => PackageKind::PortableZip,
Self::AppImage(_) => PackageKind::AppImage,
Self::Package(kind, _) => *kind,
}
}
}
/// The version this launcher counts as. `ARTCRAFT_LAUNCHER_PRETEND_VERSION=0.0.1` makes an
/// installed copy believe it's old, to try the self-update against the current release.
pub fn current_version() -> Option<Version> {
std::env::var("ARTCRAFT_LAUNCHER_PRETEND_VERSION").ok().and_then(|v| version::parse(&v)).or_else(|| version::parse(env!("CARGO_PKG_VERSION")))
}
/// How this copy is installed, if it's one the launcher knows how to replace.
pub fn current() -> Option<Install> {
let exe = std::env::current_exe().ok()?;
let exe = exe.canonicalize().unwrap_or(exe);
detect(&exe)
}
#[cfg(target_os = "macos")]
fn detect(exe: &Path) -> Option<Install> {
let bundle = exe.ancestors().find(|p| p.extension().is_some_and(|e| e == "app"))?;
let plist = plist::Value::from_file(bundle.join("Contents/Info.plist")).ok()?;
let id = plist.as_dictionary()?.get("CFBundleIdentifier")?.as_string()?;
(id == LAUNCHER.bundle_id()).then(|| Install::MacApp(bundle.to_path_buf()))
}
#[cfg(windows)]
fn detect(exe: &Path) -> Option<Install> {
let dir = exe.parent()?;
if dir.join("portable.txt").is_file() {
return Some(Install::Portable(exe.to_path_buf()));
}
// The MSI installs per user into %LOCALAPPDATA%\Programs\ArtCraft Launcher.
let programs = dirs::data_local_dir()?.join("Programs").join(LAUNCHER.name);
let programs = programs.canonicalize().unwrap_or(programs);
(dir == programs).then(|| Install::Msi(exe.to_path_buf()))
}
#[cfg(target_os = "linux")]
fn detect(exe: &Path) -> Option<Install> {
if std::env::var_os("FLATPAK_ID").is_some() {
return None;
}
if let Some(appimage) = std::env::var_os("APPIMAGE") {
return Some(Install::AppImage(PathBuf::from(appimage)));
}
if exe != Path::new("/usr/bin").join(LAUNCHER.id) {
return None;
}
let t = std::time::Duration::from_secs(10);
if process::capture("dpkg-query", &["-W", "-f", "${db:Status-Status}", LAUNCHER.id], t).is_some_and(|s| s.trim() == "installed") {
return Some(Install::Package(PackageKind::Deb, exe.to_path_buf()));
}
if process::capture("rpm", &["-q", LAUNCHER.id], t).is_some() {
return Some(Install::Package(PackageKind::Rpm, exe.to_path_buf()));
}
None
}
#[cfg(not(any(target_os = "macos", windows, target_os = "linux")))]
fn detect(_exe: &Path) -> Option<Install> {
None
}
/// 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.
pub fn update(
install: &Install,
release: &Release,
platform: Platform,
http: &Http,
paths: &Paths,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
) -> 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)?;
phase(JobPhase::Installing);
let pid = std::process::id();
match install {
#[cfg(target_os = "macos")]
Install::MacApp(bundle) => {
let existing = Installed { version: None, source: InstallSource::MacApp, location: bundle.display().to_string(), package: None };
let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), paths);
let _ = std::fs::remove_file(&file);
let placed = placed?;
relaunch_after_exit(pid, &[Path::new("/usr/bin/open").as_os_str(), placed.as_os_str()])
}
#[cfg(target_os = "linux")]
Install::AppImage(path) => {
replace_appimage(&file, path)?;
let _ = std::fs::remove_file(&file);
relaunch_after_exit(pid, &[path.as_os_str()])
}
#[cfg(target_os = "linux")]
Install::Package(kind, exe) => {
let result = install::linux::install_package(*kind, &file);
let _ = std::fs::remove_file(&file);
result?;
relaunch_after_exit(pid, &[exe.as_os_str()])
}
#[cfg(windows)]
Install::Msi(exe) => windows_helper(
pid,
&format!(
"Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\\msiexec.exe') -ArgumentList '/i',{},'/passive','/norestart' -Wait; Remove-Item -LiteralPath {} -ErrorAction SilentlyContinue",
ps_quote_arg(&file),
ps_quote(&file)
),
exe,
),
#[cfg(windows)]
Install::Portable(exe) => {
let dir = exe.parent().ok_or("the launcher isn't in a folder")?;
let tmp = paths.cache.join("launcher-update");
windows_helper(
pid,
&format!(
"$t = {tmp}; Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction SilentlyContinue; Expand-Archive -LiteralPath {zip} -DestinationPath $t -Force; \
$top = Get-ChildItem -LiteralPath $t | Select-Object -First 1; Copy-Item -Path (Join-Path $top.FullName '*') -Destination {dir} -Recurse -Force; \
Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath {zip} -ErrorAction SilentlyContinue",
tmp = ps_quote(&tmp),
zip = ps_quote(&file),
dir = ps_quote(dir),
),
exe,
)
}
#[allow(unreachable_patterns)]
_ => {
let _ = (pid, &file);
Err("this copy of ArtCraft Launcher can't update itself; download the new version instead".into())
}
}
}
/// Copies the new AppImage beside the running one and renames it over it. The running copy is
/// mounted from its own (now unlinked) file, so it keeps working until it exits.
#[cfg(target_os = "linux")]
fn replace_appimage(new: &Path, path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let dir = path.parent().ok_or("the AppImage isn't in a folder")?;
let staged = dir.join(format!(".{}.update", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()));
std::fs::copy(new, &staged).map_err(|e| format!("couldn't write next to {} ({e})", path.display()))?;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
std::fs::rename(&staged, path).map_err(|e| {
let _ = std::fs::remove_file(&staged);
format!("couldn't replace {} ({e})", path.display())
})
}
/// Starts `sh`, which waits for `pid` to exit and then runs `command`. Detached, so it outlives us.
#[cfg(unix)]
fn relaunch_after_exit(pid: u32, command: &[&std::ffi::OsStr]) -> Result<(), String> {
let mut cmd = process::command("/bin/sh");
cmd.arg("-c")
.arg(r#"pid=$1; shift; i=0; while kill -0 "$pid" 2>/dev/null && [ $i -lt 600 ]; do sleep 0.1; i=$((i+1)); done; exec "$@""#)
.arg("sh")
.arg(pid.to_string())
.args(command);
process::spawn_detached(cmd).map_err(|e| format!("couldn't schedule the restart ({e})"))
}
/// Starts a hidden PowerShell that waits for `pid` to exit, runs `step`, then starts `exe`.
#[cfg(windows)]
fn windows_helper(pid: u32, step: &str, exe: &Path) -> Result<(), String> {
let script = format!("Wait-Process -Id {pid} -Timeout 60 -ErrorAction SilentlyContinue; {step}; Start-Process -FilePath {}", ps_quote(exe));
let mut cmd = process::command("powershell.exe");
cmd.args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", &script]);
process::spawn_detached(cmd).map_err(|e| format!("couldn't schedule the update ({e})"))
}
/// A PowerShell single-quoted literal.
#[cfg_attr(not(windows), allow(dead_code))]
fn ps_quote(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "''"))
}
/// A PowerShell literal for an msiexec argument: the path in double quotes, as msiexec expects.
#[cfg_attr(not(windows), allow(dead_code))]
fn ps_quote_arg(path: &Path) -> String {
format!("'\"{}\"'", path.display().to_string().replace('\'', "''"))
}
#[cfg(test)]
mod tests {
use super::*;
/// The helper waits for the old launcher to exit before it starts the new one.
#[cfg(unix)]
#[test]
fn the_restart_waits_for_the_old_process_to_exit() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("started");
let mut old = std::process::Command::new("/bin/sleep").arg("1").spawn().unwrap();
relaunch_after_exit(old.id(), &[std::ffi::OsStr::new("/usr/bin/touch"), marker.as_os_str()]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(!marker.exists(), "started while the old launcher was still running");
old.wait().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !marker.exists() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(marker.exists(), "never started after the old launcher exited");
}
/// A real disk image: the new `ArtCraft Launcher.app` replaces the old one in place.
#[cfg(target_os = "macos")]
#[test]
fn the_new_bundle_replaces_the_running_one() {
fn bundle(at: &Path, version: &str) {
let contents = at.join("ArtCraft Launcher.app/Contents");
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
std::fs::write(
contents.join("Info.plist"),
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"><dict>\
<key>CFBundleIdentifier</key><string>ai.storyteller.artcraft-launcher</string>\
<key>CFBundleShortVersionString</key><string>{version}</string></dict></plist>"
),
)
.unwrap();
std::fs::write(contents.join("MacOS/ArtCraft Launcher"), version).unwrap();
}
let dir = tempfile::tempdir().unwrap();
let apps = dir.path().join("Applications");
bundle(&apps, "0.1.0");
let src = dir.path().join("src");
bundle(&src, "0.2.0");
let dmg = dir.path().join("artcraft-launcher-0.2.0-macos-universal.dmg");
let ok = std::process::Command::new("hdiutil")
.args(["create", "-quiet", "-format", "UDZO", "-volname", "ArtCraft Launcher 0.2.0", "-srcfolder"])
.arg(&src)
.arg(&dmg)
.status()
.unwrap()
.success();
assert!(ok, "hdiutil create failed");
let current = apps.join("ArtCraft Launcher.app");
let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };
let placed = install::macos::install_dmg(&LAUNCHER, &dmg, Some(&existing), &Paths::under(dir.path())).unwrap();
assert_eq!(placed, current);
assert_eq!(std::fs::read_to_string(current.join("Contents/MacOS/ArtCraft Launcher")).unwrap(), "0.2.0");
assert!(!dir.path().join("Applications/.ArtCraft Launcher.app.partial").exists());
}
#[test]
fn powershell_quoting_survives_apostrophes() {
let p = Path::new(r"C:\Users\O'Brien\AppData\Local\ArtCraft Launcher\x.msi");
assert_eq!(ps_quote(p), r"'C:\Users\O''Brien\AppData\Local\ArtCraft Launcher\x.msi'");
assert_eq!(ps_quote_arg(p), r#"'"C:\Users\O''Brien\AppData\Local\ArtCraft Launcher\x.msi"'"#);
}
#[test]
fn a_development_build_does_not_update_itself() {
// `cargo test` runs from target/, which no installer puts anywhere.
assert_eq!(current(), None);
}
#[test]
fn the_launcher_picks_its_own_release_files() {
let names = [
"artcraft-launcher-0.2.0-macos-universal.dmg",
"artcraft-launcher-0.2.0-windows-x64.msi",
"artcraft-launcher-0.2.0-windows-x64-portable.zip",
"artcraft-launcher-0.2.0-linux-x86_64.AppImage",
"artcraft-launcher-0.2.0-linux-x86_64.deb",
"artcraft-launcher-web-0.2.0.zip",
];
let release = Release {
assets: names
.iter()
.map(|n| artcraft_launcher_core::release::Asset { name: (*n).into(), size: 1, url: String::new(), sha256: None })
.collect(),
..Release::default()
};
use artcraft_launcher_core::{Arch, Os};
let pick = |os, kind| pick_asset(&release, &[LAUNCHER.id], Platform { os, arch: Arch::X64 }, &[kind]).map(|(a, _)| a.name.clone());
assert_eq!(pick(Os::MacOs, Install::MacApp(PathBuf::new()).kind()).as_deref(), Some(names[0]));
assert_eq!(pick(Os::Windows, Install::Msi(PathBuf::new()).kind()).as_deref(), Some(names[1]));
assert_eq!(pick(Os::Windows, Install::Portable(PathBuf::new()).kind()).as_deref(), Some(names[2]));
assert_eq!(pick(Os::Linux, Install::AppImage(PathBuf::new()).kind()).as_deref(), Some(names[3]));
assert_eq!(pick(Os::Linux, Install::Package(PackageKind::Deb, PathBuf::new()).kind()).as_deref(), Some(names[4]));
}
}
+21 -1
View File
@@ -53,6 +53,10 @@ pub struct LauncherUi {
pub(crate) last_source: Option<Source>,
pub(crate) rate_limited_until: Option<i64>,
pub(crate) launcher_update: Option<Release>,
/// The launcher's own update, while it downloads and installs.
pub(crate) launcher_job: Option<artcraft_launcher_core::protocol::JobPhase>,
/// The new launcher is in place: quit so it can start.
restart_requested: bool,
pub(crate) search: String,
pub(crate) toasts: Vec<Toast>,
pub(crate) dialog: Option<Dialog>,
@@ -90,6 +94,8 @@ impl LauncherUi {
last_source: None,
rate_limited_until: None,
launcher_update: None,
launcher_job: None,
restart_requested: false,
search: String::new(),
toasts: Vec::new(),
dialog: None,
@@ -170,6 +176,11 @@ impl LauncherUi {
if changed {
self.announce_updates(host);
}
if self.restart_requested {
// The helper is waiting for this process to exit before it starts the new launcher.
self.restart_requested = false;
host.quit();
}
// Wake up now and then for scheduled checks, even in the tray.
ctx.request_repaint_after(Duration::from_secs(60));
}
@@ -194,11 +205,17 @@ impl LauncherUi {
true
}
Event::Progress { app, phase } => {
if let Some(e) = self.entry_mut(&app) {
if app == artcraft_launcher_core::catalog::LAUNCHER_ID {
self.launcher_job = Some(phase);
} else if let Some(e) = self.entry_mut(&app) {
e.job = Some(phase);
}
false
}
Event::LauncherRestarting => {
self.restart_requested = true;
false
}
Event::Paused { app, download } => {
if let Some(e) = self.entry_mut(&app) {
e.paused = download;
@@ -206,6 +223,9 @@ impl LauncherUi {
false
}
Event::JobFinished { app, result } => {
if app == artcraft_launcher_core::catalog::LAUNCHER_ID {
self.launcher_job = None;
}
if let Some(e) = self.entry_mut(&app) {
e.job = None;
}
+25 -3
View File
@@ -93,12 +93,34 @@ impl LauncherUi {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("ArtCraft Launcher {} is available.", r.version_label())).family(theme::medium()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(phase) = self.launcher_job {
let (fraction, caption) = match phase {
JobPhase::Downloading { done, total } if total > 0 => (
Some(done as f32 / total as f32),
format!("Downloading {} of {}", widgets::human_bytes(done), widgets::human_bytes(total)),
),
JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. } => (None, "Downloading…".to_string()),
JobPhase::Verifying => (None, "Verifying…".to_string()),
JobPhase::Installing | JobPhase::Removing => (None, "Installing, then restarting…".to_string()),
};
widgets::progress(ui, fraction, &caption, t.accent, 220.0);
return;
}
if widgets::icon_button(ui, "×", "Dismiss").clicked() {
self.launcher_update = None;
}
let url = self.platform.and_then(|p| launcher_download(&r, p)).unwrap_or_else(|| r.html_url.clone());
if widgets::primary(ui, "Download", t.accent, 0.0).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
if self.caps.self_update {
if widgets::primary(ui, "Update and restart", t.accent, 0.0)
.on_hover_text("Downloads the new version, replaces this one and opens it again")
.clicked()
{
self.send(Command::UpdateLauncher);
}
} else {
let url = self.platform.and_then(|p| launcher_download(&r, p)).unwrap_or_else(|| r.html_url.clone());
if widgets::primary(ui, "Download", t.accent, 0.0).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
if ui.link("What's new").clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(r.html_url.clone()));
+72 -1
View File
@@ -30,7 +30,7 @@ impl Backend for Fake {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder" }
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
@@ -375,6 +375,77 @@ fn looks_again_at_installed_apps_every_few_minutes_not_every_frame() {
assert_eq!(detects(&sent), 1, "once at five minutes, not once per frame");
}
#[test]
fn the_launcher_updates_itself_and_quits_to_restart() {
struct QuitHost(Rc<Cell<bool>>);
impl Host for QuitHost {
fn quit(&mut self) {
self.0.set(true);
}
}
let sent = Rc::new(RefCell::new(Vec::new()));
let mut events = events();
events.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
let shared_events = Rc::new(RefCell::new(Vec::new()));
struct Scripted {
inner: Fake,
later: Rc<RefCell<Vec<Event>>>,
}
impl Backend for Scripted {
fn send(&mut self, c: Command) {
self.inner.send(c);
}
fn poll(&mut self) -> Vec<Event> {
let mut out = self.inner.poll();
out.extend(self.later.borrow_mut().drain(..));
out
}
fn capabilities(&self) -> Capabilities {
self.inner.capabilities()
}
fn platform(&self) -> Option<Platform> {
self.inner.platform()
}
fn now(&self) -> i64 {
self.inner.now()
}
}
let quit = Rc::new(Cell::new(false));
let mut host = QuitHost(quit.clone());
let mut backend =
Some(Scripted { inner: Fake { events, sent: sent.clone(), manage: true, clock: Rc::new(Cell::new(NOW)) }, later: shared_events.clone() });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() }));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut host);
l.ui(ui, &mut host);
}
},
None,
);
h.run_steps(6);
h.get_by_label_contains("ArtCraft Launcher 0.2.0 is available");
h.get_by_label("Update and restart").click();
h.run_steps(2);
assert!(sent.borrow().contains(&Command::UpdateLauncher));
// Downloading: the banner shows progress instead of the button.
shared_events
.borrow_mut()
.push(Event::Progress { app: "artcraft-launcher".into(), phase: JobPhase::Downloading { done: 5 << 20, total: 10 << 20 } });
h.run_steps(3);
assert!(h.query_by_label("Update and restart").is_none());
assert!(!quit.get());
// In place: the launcher quits so the helper can start the new one.
shared_events.borrow_mut().push(Event::LauncherRestarting);
h.run_steps(3);
assert!(quit.get());
}
#[test]
fn web_build_offers_downloads() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);