macOS: open disk images reliably, and say why when they won't open; 0.2.1 (#135)

* macOS: open disk images reliably, and say why when they won't open

Installing or updating an app on macOS could fail with only "couldn't open
the disk image". hdiutil's own reason was thrown away, and every install of
an app mounted its image at the same folder, which an attach that was
interrupted or timed out could still be holding.

- Each install mounts its image at a new, empty folder.
- The attach skips hdiutil's checksum pass (-noverify): the download's
  SHA-256 is already checked against the release, and on a large image that
  pass alone could run past the time limit, now 5 minutes.
- When the image still won't open, or the app won't copy, the message says
  why in hdiutil's or ditto's words ("couldn't open the disk image:
  Resource temporarily unavailable").
- hdiutil, ditto and codesign are run by full path: a launcher started from
  Finder or at login gets a minimal PATH.

process::run is capture's sibling that returns the helper's error message;
capture is now built on it. The launcher's own macOS self-update installs
through the same code, so it gets all of this too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Release 0.2.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-11 19:51:54 +07:00 committed by GitHub
parent c5b073f5f7
commit c1a9b36f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 98 additions and 32 deletions

12
Cargo.lock generated
View File

@ -276,7 +276,7 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "artcraft-launcher"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"artcraft-launcher-core",
"artcraft-launcher-native",
@ -298,7 +298,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-core"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"semver",
"serde",
@ -307,7 +307,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-native"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"artcraft-launcher-core",
"dirs 6.0.0",
@ -325,7 +325,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-ui-egui"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"artcraft-launcher-core",
"egui",
@ -341,7 +341,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-web"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"artcraft-launcher-core",
"artcraft-launcher-ui-egui",
@ -5799,7 +5799,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.2.1"
[[package]]
name = "yoke"

View File

@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"

View File

@ -736,26 +736,39 @@ pub mod macos {
/// `hdiutil create` too), since it turns concurrent requests away.
pub(crate) static DISK_IMAGES: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Apple's tools, by full path: a launcher started from Finder or at login gets a minimal
/// `PATH`, and nothing earlier on it should stand in for these.
const HDIUTIL: &str = "/usr/bin/hdiutil";
const DITTO: &str = "/usr/bin/ditto";
const CODESIGN: &str = "/usr/bin/codesign";
pub fn install_dmg(app: &CraftApp, dmg: &Path, existing: Option<&Installed>, paths: &Paths, expect: &Expect<'_>) -> Result<PathBuf, String> {
let mount = paths.cache.join(format!("mount-{}", app.id));
let _ = detach(&mount);
std::fs::create_dir_all(&mount).map_err(|e| e.to_string())?;
let (Some(dmg_s), Some(mount_s)) = (dmg.to_str(), mount.to_str()) else { return Err("unusual characters in the cache folder's path".into()) };
// A new, empty mount point for every install. A fixed one can still hold an image from an
// attach that was interrupted or timed out, and hdiutil won't mount another over it.
let attempt = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let mount = paths.cache.join(format!("mount-{}-{}-{attempt}", app.id, std::process::id()));
std::fs::create_dir_all(&mount).map_err(|e| format!("couldn't create a folder to open the disk image in ({e})"))?;
let (Some(dmg_s), Some(mount_s)) = (dmg.to_str(), mount.to_str()) else {
let _ = std::fs::remove_dir(&mount);
return Err("unusual characters in the cache folder's path".into());
};
// diskimages-helper refuses an attach while it is busy with another image ("Resource
// temporarily unavailable"): attach one image at a time (several apps may be updating at
// once), and wait a little and try again when something outside the launcher holds it.
// The download's SHA-256 is already checked, so hdiutil's own checksum pass (-noverify)
// would only add minutes on a large image.
let attached = {
let _one_at_a_time = DISK_IMAGES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let attach = || {
process::capture(
"hdiutil",
&["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountpoint", mount_s, dmg_s],
Duration::from_secs(120),
process::run(
HDIUTIL,
&["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", mount_s, dmg_s],
Duration::from_secs(300),
)
};
let mut attached = attach();
for wait in [1, 2, 4, 8] {
if attached.is_some() {
if attached.is_ok() {
break;
}
let _ = detach(&mount);
@ -764,7 +777,10 @@ pub mod macos {
}
attached
};
attached.ok_or("couldn't open the disk image")?;
if let Err(reason) = attached {
let _ = std::fs::remove_dir(&mount);
return Err(format!("couldn't open the disk image: {reason}"));
}
let result = copy_app(app, &mount, existing, expect);
let _ = detach(&mount);
let _ = std::fs::remove_dir(&mount);
@ -784,8 +800,8 @@ pub mod macos {
// 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")?;
process::run(DITTO, &["--norsrc", "--noextattr", "--noacl", src_s, staged_s], Duration::from_secs(600))
.map_err(|reason| format!("couldn't copy the app: {reason}"))?;
let checked = crate::detect::macos::read_bundle(app, &staged)
.ok_or_else(|| "the copied app is incomplete".to_string())
.and_then(|found| check(&staged, &found, expect));
@ -821,7 +837,7 @@ pub mod macos {
return false;
}
let requirement = format!("=anchor apple generic and certificate leaf[subject.OU] = \"{team}\"");
process::capture("codesign", &["--verify", "--deep", "-R", &requirement, path], Duration::from_secs(120)).is_some()
process::capture(CODESIGN, &["--verify", "--deep", "-R", &requirement, path], Duration::from_secs(120)).is_some()
}
/// Whether a bundle's version is the release's. A bundle that reports only the numbers
@ -835,8 +851,8 @@ pub mod macos {
if !mount.join(".").exists() {
return None;
}
process::capture("hdiutil", &["detach", m, "-quiet"], Duration::from_secs(60))
.or_else(|| process::capture("hdiutil", &["detach", m, "-force", "-quiet"], Duration::from_secs(60)))
process::capture(HDIUTIL, &["detach", m, "-quiet"], Duration::from_secs(60))
.or_else(|| process::capture(HDIUTIL, &["detach", m, "-force", "-quiet"], Duration::from_secs(60)))
}
/// Moves an app to the user's Trash (so it can be put back).

View File

@ -45,13 +45,21 @@ pub fn clean_appimage_env(cmd: &mut Command) {
/// Runs a helper and returns its standard output when it succeeds within `timeout`.
pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String> {
let mut child = command(program).args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let mut stdout = child.stdout.take()?;
let reader = std::thread::spawn(move || {
let mut out = Vec::new();
let _ = std::io::Read::read_to_end(&mut std::io::Read::take(&mut stdout, 8 << 20), &mut out);
out
});
run(program, args, timeout).ok()
}
/// Runs a helper and returns its standard output when it succeeds within `timeout`; otherwise
/// the reason, in the helper's own words (its standard error) where it gave one.
pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
let mut child = command(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("couldn't start {program} ({e})"))?;
// Read both pipes while it runs, so a chatty helper can't block on a full one.
let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take()));
let start = Instant::now();
let status = loop {
match child.try_wait() {
@ -60,12 +68,28 @@ pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
return Err(format!("{program} didn't finish within {} seconds", timeout.as_secs()));
}
}
};
let out = reader.join().ok()?;
status.success().then(|| String::from_utf8_lossy(&out).into_owned())
let text = |pipe: std::thread::JoinHandle<Vec<u8>>| String::from_utf8_lossy(&pipe.join().unwrap_or_default()).into_owned();
let (out, err) = (text(stdout), text(stderr));
if status.success() {
return Ok(out);
}
let reason = err.trim();
Err(if reason.is_empty() { format!("{program} failed ({status})") } else { reason.to_string() })
}
/// Reads a child's pipe to the end (at most 8 MB) on its own thread.
fn read_all<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut out = Vec::new();
if let Some(pipe) = pipe {
let _ = std::io::Read::read_to_end(&mut std::io::Read::take(pipe, 8 << 20), &mut out);
}
out
})
}
/// Starts `cmd` without waiting for it, and reaps it when it exits.
@ -282,6 +306,32 @@ mod tests {
}
}
#[cfg(test)]
mod run_tests {
use super::*;
/// A shell command line, for the platform's shell.
fn shell(script: &str) -> (&'static str, [&str; 2]) {
if cfg!(windows) { ("cmd", ["/C", script]) } else { ("/bin/sh", ["-c", script]) }
}
#[test]
fn a_failing_helper_reports_its_own_error() {
let (program, args) = shell(if cfg!(windows) { "echo no such image 1>&2 & exit /b 3" } else { "echo no such image >&2; exit 3" });
assert_eq!(run(program, &args, Duration::from_secs(30)), Err("no such image".into()));
assert_eq!(capture(program, &args, Duration::from_secs(30)), None);
}
#[test]
fn a_silent_failure_and_a_success() {
let (program, args) = shell("exit 4");
let err = run(program, &args, Duration::from_secs(30)).unwrap_err();
assert!(err.starts_with(&format!("{program} failed")), "{err}");
let (program, args) = shell("echo hello");
assert_eq!(run(program, &args, Duration::from_secs(30)).map(|out| out.trim().to_string()), Ok("hello".into()));
}
}
#[cfg(test)]
mod explorer_tests {
use super::*;