mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-02 18:07:04 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ¤t)
|
||||
{
|
||||
*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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user