mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-10-14 14:26:48 -04:00
Self-update: stop restart loops and unsigned-looking macOS copies
Fixes found trying "Update and restart" on a real install: - The restart helper no longer passes ARTCRAFT_LAUNCHER_PRETEND_VERSION on to the new launcher. It inherited the old launcher's environment, so the restarted copy still believed it was old and offered the same update again, forever. A pretend version now lasts one run. - Loop guard: before restarting, the launcher records the release it updated to (self-update.txt in its data folder) and, for a day, won't offer that release again. Protects against a release whose binary reports a different version from its tag. - macOS installs from a DMG (the launcher's own update and every Craft App) copy with `ditto --norsrc --noextattr --noacl`. The disk image adds Finder metadata to its files; copying it made `codesign --verify --strict` reject the bundle as carrying detritus, although Gatekeeper still accepted it. Signatures live in the bundle's files, not in extended attributes. Tests: the helper command removes the pretend variable; a just-installed release isn't offered again until the guard lapses. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b642df291d
commit
f4f1d7767e
3 files changed
+67
-9
No files matched your search
@@ -159,6 +159,7 @@ impl NativeBackend {
|
||||
return;
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
let paths = self.paths.clone();
|
||||
let pre = self.options().prereleases;
|
||||
std::thread::spawn(move || {
|
||||
shared.emit(Event::CheckStarted);
|
||||
@@ -183,6 +184,7 @@ impl NativeBackend {
|
||||
if let LatestRelease::Found(r) = source.latest(LAUNCHER_REPO, false).latest
|
||||
&& let (Some(latest), Some(current)) = (r.version.as_ref(), crate::self_update::current_version())
|
||||
&& version::is_newer(latest, ¤t)
|
||||
&& !crate::self_update::just_updated_to(&paths, &r)
|
||||
{
|
||||
*shared.launcher_release.lock().unwrap_or_else(PoisonError::into_inner) = Some(r.clone());
|
||||
shared.emit(Event::LauncherUpdate(r));
|
||||
|
||||
@@ -474,8 +474,12 @@ pub mod macos {
|
||||
let staged = dir.join(format!(".{bundle}.partial"));
|
||||
let _ = std::fs::remove_dir_all(&staged);
|
||||
let (Some(src_s), Some(staged_s)) = (src.to_str(), staged.to_str()) else { return Err("unusual characters in a path".into()) };
|
||||
// ditto keeps the code signature, extended attributes and symlinks intact.
|
||||
process::capture("ditto", &[src_s, staged_s], Duration::from_secs(600)).ok_or("couldn't copy the app")?;
|
||||
// ditto keeps the code signature and symlinks intact. The disk image's volume adds Finder
|
||||
// metadata (com.apple.FinderInfo) to the files; leave it and the extended attributes
|
||||
// behind, or `codesign --verify --strict` rejects the bundle as carrying "detritus". A
|
||||
// bundle's signature lives in its files, not in extended attributes.
|
||||
process::capture("ditto", &["--norsrc", "--noextattr", "--noacl", src_s, staged_s], Duration::from_secs(600))
|
||||
.ok_or("couldn't copy the app")?;
|
||||
if crate::detect::macos::read_bundle(app, &staged).is_none() {
|
||||
let _ = std::fs::remove_dir_all(&staged);
|
||||
return Err("the copied app is incomplete".into());
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use artcraft_launcher_core::PackageKind;
|
||||
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};
|
||||
|
||||
@@ -59,10 +59,30 @@ impl Install {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Makes an installed copy believe it's this version, to try the self-update against the current
|
||||
/// release. The restart helper drops it, so it lasts one run.
|
||||
const PRETEND_VAR: &str = "ARTCRAFT_LAUNCHER_PRETEND_VERSION";
|
||||
|
||||
/// The version this launcher counts as (see [`PRETEND_VAR`]).
|
||||
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")))
|
||||
std::env::var(PRETEND_VAR).ok().and_then(|v| version::parse(&v)).or_else(|| version::parse(env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
|
||||
/// How long a just-installed version stays exempt from being offered again.
|
||||
const JUST_UPDATED_FOR: i64 = 24 * 3600;
|
||||
|
||||
/// Whether `release` is the version this launcher just updated itself to. Offering it again would
|
||||
/// loop: update, restart, "update available", … That happens if a release's binary reports a
|
||||
/// different version from its tag, or while a pretend version is set.
|
||||
pub fn just_updated_to(paths: &Paths, release: &Release) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(marker(paths)) else { return false };
|
||||
let mut lines = text.lines();
|
||||
let (Some(tag), Some(at)) = (lines.next(), lines.next().and_then(|a| a.parse::<i64>().ok())) else { return false };
|
||||
tag == release.tag && crate::now() - at < JUST_UPDATED_FOR
|
||||
}
|
||||
|
||||
fn marker(paths: &Paths) -> PathBuf {
|
||||
paths.data.join("self-update.txt")
|
||||
}
|
||||
|
||||
/// How this copy is installed, if it's one the launcher knows how to replace.
|
||||
@@ -135,6 +155,9 @@ pub fn update(
|
||||
let mut job = Job { http, paths, stop, phase };
|
||||
let (file, _) = install::download(release, &asset, &mut job)?;
|
||||
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}");
|
||||
}
|
||||
let pid = std::process::id();
|
||||
match install {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -211,13 +234,20 @@ fn replace_appimage(new: &Path, path: &Path) -> Result<(), String> {
|
||||
/// 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> {
|
||||
process::spawn_detached(helper_command(pid, command)).map_err(|e| format!("couldn't schedule the restart ({e})"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn helper_command(pid: u32, command: &[&std::ffi::OsStr]) -> std::process::Command {
|
||||
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})"))
|
||||
.args(command)
|
||||
// The new launcher reports its real version; a pretend one was for this run only.
|
||||
.env_remove(PRETEND_VAR);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Starts a hidden PowerShell that waits for `pid` to exit, runs `step`, then starts `exe`.
|
||||
@@ -225,7 +255,7 @@ fn relaunch_after_exit(pid: u32, command: &[&std::ffi::OsStr]) -> Result<(), Str
|
||||
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]);
|
||||
cmd.args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", &script]).env_remove(PRETEND_VAR);
|
||||
process::spawn_detached(cmd).map_err(|e| format!("couldn't schedule the update ({e})"))
|
||||
}
|
||||
|
||||
@@ -245,6 +275,28 @@ fn ps_quote_arg(path: &Path) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_version_just_installed_is_not_offered_again() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = Paths::under(dir.path());
|
||||
let r = Release { tag: "v0.2.0".into(), ..Release::default() };
|
||||
assert!(!just_updated_to(&paths, &r));
|
||||
crate::paths::write_atomic(&marker(&paths), format!("v0.2.0\n{}\n", crate::now()).as_bytes()).unwrap();
|
||||
assert!(just_updated_to(&paths, &r));
|
||||
assert!(!just_updated_to(&paths, &Release { tag: "v0.3.0".into(), ..Release::default() }));
|
||||
// A day later the guard lapses.
|
||||
crate::paths::write_atomic(&marker(&paths), format!("v0.2.0\n{}\n", crate::now() - JUST_UPDATED_FOR - 1).as_bytes()).unwrap();
|
||||
assert!(!just_updated_to(&paths, &r));
|
||||
}
|
||||
|
||||
/// The restart helper doesn't pass a pretend version on to the new launcher.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_restarted_launcher_does_not_inherit_a_pretend_version() {
|
||||
let cmd = helper_command(1, &[std::ffi::OsStr::new("/usr/bin/true")]);
|
||||
assert!(cmd.get_envs().any(|(k, v)| k == PRETEND_VAR && v.is_none()), "the helper must remove {PRETEND_VAR}");
|
||||
}
|
||||
|
||||
/// The helper waits for the old launcher to exit before it starts the new one.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user