Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ repository = "https://github.com/trypsynth/ship-shape"
readme = "README.md"
keywords = ["updater", "wxdragon", "auto-update", "desktop", "github-releases"]
categories = ["gui"]
exclude = ["examples/"]

[package.metadata.patois]
translatable = true
Expand All @@ -23,6 +24,9 @@ ureq = { version = "3.4.2", default-features = false, features = ["json", "rustl
# which parts of wxWidgets get built; nothing here needs any of them.
wxdragon = { version = "0.9.22", default-features = false }

[target.'cfg(target_os = "macos")'.dependencies]
tempfile = "3.27.0"

[lints.clippy]
all = { level = "warn", priority = -1 }
cargo = { level = "warn", priority = -1 }
Expand Down
57 changes: 55 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ Auto-updater library for wxDragon desktop apps, supporting Windows, macOS, and L
- Minisign signature verification before applying
- `ui` module: update/progress dialogs, plus platform-specific install flows:
- Windows: PowerShell install/extract scripts that relaunch the app afterward
- macOS: downloads and mounts a signed, notarized `.dmg` for the user to drag into
Applications; the app must be quit and relaunched manually
- macOS: privately mounts the verified `.dmg`, stages and validates its matching app bundle,
then replaces and relaunches writable installations; protected, mounted-image, or translocated
installations retain manual DMG instructions
- Linux: a shell script that extracts the tarball or replaces the running `.AppImage`, then
relaunches the app, the same self-updating flow as Windows
- Other platforms: downloads the file and tells the user where it is
Expand Down Expand Up @@ -62,6 +63,58 @@ ui::run_update_check(config, &frame, UpdateChannel::Stable, CheckTrigger::Manual
- `UpdateError` variants dropped their `Error` suffix (`HttpError` is now `Http(u16)`), gained
`Io`, and the enum is `#[non_exhaustive]`.

## macOS installation

Updates replace the running app at its existing location, including custom folders and renamed
bundles. The DMG must contain exactly one top-level `.app` matching the running bundle identifier,
with a valid code signature and executable. Both the running app and its replacement must carry
the same Apple developer Team ID, verified against an Apple-issued signing chain. Unsigned and
ad-hoc signed development builds use the manual flow. Mounted images and Gatekeeper-translocated
apps are not modified. No administrator privileges are requested.

Mounting, validation, copying, and the helper handshake run off the UI thread. Canceling before
shutdown drops the staged update. The detached helper waits up to 60 seconds for the app to exit,
then swaps bundles using same-filesystem renames. A failed replacement restores the old bundle;
a failed restore or launch preserves the backup and reports its location. Failure logs survive
cleanup under `~/Library/Logs/ship-shape-update-*.log`; successful and canceled updates remove
their logs. A successful Launch Services request does not detect subsequent application crashes.

Use `ui::run_update_check_with_exit_handler` to save state and terminate normally. The existing
`run_update_check` entry point keeps its immediate process-exit behavior. The handler runs on the
UI thread only after successful installer preparation. It must terminate the process within
60 seconds; simply hiding the main window is insufficient.

`UpdaterConfig::with_macos_relaunch_env(name, value)` preserves an explicit environment override
when launching through macOS `open`, such as an isolated settings directory. It is ignored on
other platforms; values are passed as arguments and never interpolated into shell code.

From a repository checkout, build `cargo build --example macos_update_demo`. Run the example with
`--setup /tmp/ship-shape-demo` using a new directory, then run the generated
`Installed Demo.app/Contents/MacOS/demo` executable with `--cancel` or `--apply` followed by the
fixture's `demo.dmg` path. For automatic installation, set `SHIP_SHAPE_DEMO_SIGNING_ID` to an
Apple-issued signing identity when running `--setup`. Applying must display version 2; canceling must retain version 1. Without
that identity, fixtures are ad-hoc signed and demonstrate the manual fallback instead. The demo
trusts only its generated local fixture and keeps the production signer checks. It is a repository
test harness, excluded from the published crate package.

The download gauge stays below 100 until verification and preparation finish. Native progress
updates can yield to the event loop, so completion waits for the active Update/Pulse call to
return before destroying the dialog or calling the shutdown handler. Regression tests cover this
ordering without requiring a graphical session.

Run `cargo test` on macOS for coverage of cancellation, helper timeouts, replacement and rollback,
failed restore and launch, competing destination changes, protected locations, bundle selection,
signature tampering, and paths with spaces and apostrophes. Tests substitute launch commands and
alerts and only modify temporary fixture apps. The demo above additionally exercises actual DMG
mounting and Launch Services; testing a signed, notarized release establishes the Gatekeeper path.
For a read-only signer check with an existing release, set `SHIP_SHAPE_TEST_SIGNED_APP` to its
app path and run `cargo test developer_signed_release_accepts_only_its_team -- --ignored`.

On the next automatic update, stages older than 24 hours are swept only when they belong to the
same bundle and user and neither the host nor helper process is alive. Unregistered stages,
symlinks, and stages containing `old.app` recovery backups are preserved. This avoids deleting
another app's staging area, an active update, or the only recoverable copy after a failed swap.

## License

MIT
129 changes: 129 additions & 0 deletions examples/macos_update_demo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Disposable macOS updater demo. It includes the actual installer implementation, without
//! adding a production API for applying arbitrary local files. No network or key bypass is
//! added to the updater. This demo trusts ONLY its own locally generated fixture image.
//! Set `SHIP_SHAPE_DEMO_SIGNING_ID` to an Apple-issued signing identity for automatic installation.
#![allow(dead_code, reason = "the shared platform module has APIs unused by the demo")]

#[cfg(target_os = "macos")]
#[path = "../src/platform/macos.rs"]
mod macos;

#[cfg(target_os = "macos")]
use std::{env, fs, path::Path, process::Command};

#[cfg(target_os = "macos")]
use ship_shape::{InstallKind, UpdateError, UpdaterConfig};

#[cfg(target_os = "macos")]
enum InstallOutcome {
Prepared(macos::PreparedUpdate),
ManualStep(String),
}

#[cfg(target_os = "macos")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
if args.get(1).is_some_and(|arg| arg == "--setup") {
let root = Path::new(args.get(2).ok_or("Pass a NEW destination directory after --setup")?);
// Refuse reuse or an existing destination. The demo only replaces copies it creates.
fs::create_dir(root)?;
let running = root.join("Installed Demo.app");
let image_root = root.join("image");
fs::create_dir(&image_root)?;
make_bundle(&running, "1")?;
make_bundle(&image_root.join("Update Demo.app"), "2")?;
let dmg = root.join("demo.dmg");
let status = Command::new("/usr/bin/hdiutil")
.args(["create", "-quiet", "-fs", "HFS+", "-srcfolder"])
.arg(&image_root)
.arg(&dmg)
.status()?;
if !status.success() {
return Err("Failed to create demo DMG".into());
}
println!("Created disposable demo at {}", root.display());
println!("Run the installed bundle's Contents/MacOS/demo executable with --apply and the demo.dmg path.");
return Ok(());
}
let exe = env::current_exe()?;
let bundle = exe.parent().and_then(Path::parent).and_then(Path::parent).ok_or("Not in a demo bundle")?;
if bundle.join("Contents/Resources/fixture-version").is_file() {
if args.get(1).is_some_and(|arg| arg == "--apply" || arg == "--cancel") {
let path = Path::new(args.get(2).ok_or("Pass demo.dmg after --apply or --cancel")?);
// Bind the test harness to the destination and DMG produced by --setup.
let root = bundle.parent().ok_or("Missing fixture directory")?;
if bundle.file_name().is_none_or(|name| name != "Installed Demo.app")
|| path.canonicalize()? != root.join("demo.dmg").canonicalize()?
{
return Err("Use only the fixture's Installed Demo.app and demo.dmg".into());
}
let config = UpdaterConfig::new("fixture/demo", "demo", "Updater Demo", "unused-fixture-key", "1");
match macos::install(&config, path)? {
InstallOutcome::Prepared(update) if args[1] == "--cancel" => {
drop(update);
println!("Cancelled prepared update; version 1 must remain installed.");
}
InstallOutcome::Prepared(update) => {
update.commit()?;
println!("Helper ready. Exiting so it can replace this disposable bundle.");
}
InstallOutcome::ManualStep(message) => println!("{message}"),
}
return Ok(());
}
let version = fs::read_to_string(bundle.join("Contents/Resources/fixture-version"))?;
if let Some(root) = bundle.parent()
&& root.join(".demo-headless").is_file()
{
fs::write(root.join("launched-version"), &version)?;
return Ok(());
}
let message = format!("Updater demo version {version} launched. This is a disposable test app.");
Command::new("/usr/bin/osascript")
.args([
"-e",
"on run argv",
"-e",
"display dialog (item 1 of argv) buttons {\"OK\"} default button 1",
"-e",
"end run",
&message,
])
.status()?;
return Ok(());
}
Err("Use --setup with a new disposable directory first".into())
}

#[cfg(target_os = "macos")]
fn make_bundle(bundle: &Path, version: &str) -> Result<(), Box<dyn std::error::Error>> {
let macos = bundle.join("Contents/MacOS");
fs::create_dir_all(&macos)?;
fs::create_dir_all(bundle.join("Contents/Resources"))?;
fs::copy(env::current_exe()?, macos.join("demo"))?;
fs::write(bundle.join("Contents/Resources/fixture-version"), version)?;
fs::write(
bundle.join("Contents/Info.plist"),
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleIdentifier</key><string>org.ship-shape.disposable-demo</string>
<key>CFBundleName</key><string>Updater Demo</string>
<key>CFBundleExecutable</key><string>demo</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleVersion</key><string>{version}</string>
</dict></plist>"#
),
)?;
let identity = env::var("SHIP_SHAPE_DEMO_SIGNING_ID").unwrap_or_else(|_| "-".to_owned());
if !Command::new("/usr/bin/codesign").args(["--force", "--sign", &identity]).arg(bundle).status()?.success() {
return Err("Failed to sign fixture".into());
}
Ok(())
}

#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("This disposable demo requires macOS.");
}
14 changes: 14 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::ffi::OsString;

/// How the running copy of the app was installed. Selects which release asset to download and how
/// to apply it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
Expand Down Expand Up @@ -56,6 +58,10 @@ pub struct UpdaterConfig {
/// Command-line arguments passed to a downloaded installer on Windows. Defaults to
/// `["/silent"]` (Inno Setup); NSIS installers need `["/S"]`.
pub installer_args: Vec<String>,
/// Environment overrides passed to macOS `open` when relaunching the app. Empty by default.
/// Use this for an explicit configuration directory that must survive a Launch Services launch.
/// Ignored on other platforms. Values are passed as arguments, never as shell source.
pub macos_relaunch_env: Vec<(String, OsString)>,
}

impl UpdaterConfig {
Expand All @@ -81,6 +87,7 @@ impl UpdaterConfig {
asset_suffix: String::new(),
installer_asset_suffix: None,
installer_args: vec!["/silent".into()],
macos_relaunch_env: Vec::new(),
}
}

Expand Down Expand Up @@ -133,6 +140,13 @@ impl UpdaterConfig {
self
}

/// Preserve an environment override when macOS relaunches the updated app.
#[must_use]
pub fn with_macos_relaunch_env(mut self, name: impl Into<String>, value: impl Into<OsString>) -> Self {
self.macos_relaunch_env.push((name.into(), value.into()));
self
}

pub(crate) fn effective_asset_suffix(&self) -> &str {
match self.install_kind {
InstallKind::Installer => self.installer_asset_suffix.as_deref().unwrap_or(&self.asset_suffix),
Expand Down
12 changes: 11 additions & 1 deletion src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,15 @@ pub fn download_update_file(
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
let file_name = url.rsplit('/').next().unwrap_or("update.bin");
let final_path = platform::download_dir(config)?.join(file_name);
let download_dir = platform::download_dir(config)?;
#[cfg(target_os = "macos")]
let private_dir = tempfile::Builder::new()
.prefix("ship-shape-download-")
.tempdir_in(&download_dir)
.map_err(|e| UpdateError::Io(format!("Failed to create private download directory: {e}")))?;
#[cfg(target_os = "macos")]
let download_dir = private_dir.path();
let final_path = download_dir.join(file_name);
let temp = TempDownload(final_path.with_file_name(format!("{file_name}.tmp")));
let mut file = File::create(&temp.0).map_err(|e| UpdateError::Io(format!("Failed to create file: {e}")))?;
let mut downloaded: u64 = 0;
Expand All @@ -93,6 +101,8 @@ pub fn download_update_file(
.verify(&data, &signature, true)
.map_err(|e| UpdateError::Verification(format!("Signature verification failed: {e}")))?;
fs::rename(&temp.0, &final_path).map_err(|e| UpdateError::Io(format!("Failed to rename verified file: {e}")))?;
#[cfg(target_os = "macos")]
let _ = private_dir.keep();
Ok(final_path)
}

Expand Down
6 changes: 5 additions & 1 deletion src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ mod windows;
#[cfg(target_os = "linux")]
pub use linux::{asset_name_parts, download_dir, install};
#[cfg(target_os = "macos")]
pub use macos::{asset_name_parts, download_dir, install};
#[allow(unused_imports, reason = "macOS UI uses cancellable preparation")]
pub use macos::{asset_name_parts, download_dir, install, install_with_cancel, remove_private_download};
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub use other::{asset_name_parts, download_dir, install};
#[cfg(target_os = "windows")]
Expand All @@ -22,6 +23,9 @@ pub use windows::{asset_name_parts, download_dir, install};
/// What the caller must do after a successful [`install`].
#[allow(dead_code, reason = "each platform constructs only some variants")]
pub enum InstallOutcome {
/// macOS preparation succeeded; commit on the UI thread before requesting shutdown.
#[cfg(target_os = "macos")]
Prepared(macos::PreparedUpdate),
/// The installer is waiting for this process to exit.
Exit,
/// The user must finish the install by hand. Holds the instructions to show.
Expand Down
Loading