Skip to content

Automatically install and relaunch macOS updates - #6

Merged
trypsynth merged 2 commits into
trypsynth:masterfrom
johanntan:macos-automatic-updates
Oct 3, 2026
Merged

trypsynth merged 2 commits into
trypsynth:masterfrom
johanntan:macos-automatic-updates

Conversation

@johanntan

@johanntan johanntan commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem and behavior

On macOS, accepting an update currently opens the verified DMG and leaves the user to quit the app and drag its replacement into Applications. This change prepares and installs the update automatically for writable, Apple developer-signed app bundles, replacing the app at its existing path and relaunching it. Custom folders and renamed bundles are supported. Protected, mounted-image, Gatekeeper-translocated, unsigned and ad-hoc installations keep the manual flow; no administrator authorization is requested.

Implementation

  • Download into a private temporary directory and retain Minisign verification before installation.
  • Mount the DMG privately and read-only, require exactly one top-level app matching the running bundle identifier, and validate its executable and code signature before and after staging it beside the installed app. Native code requirements enforce an Apple-issued signing chain with the running app's developer Team ID. Preparation runs off the UI thread and checks cancellation between stages.
  • Start a detached helper with an explicit ready/commit handshake. After the host exits, recheck the installation identity and replace bundles with same-filesystem renames. Restore the old app if replacement fails; preserve its backup and report the recovery path if restoration or relaunch fails.
  • Sweep abandoned stages older than 24 hours on the next automatic update, limited to the same bundle and user with no live host/helper. Preserve recent stages, symlinks, uncertain ownership metadata and recovery backups. Delete successful/canceled helper logs and retain failure logs under ~/Library/Logs/ship-shape-update-*.log.
  • Add run_update_check_with_exit_handler so the host can save state and quit normally, and with_macos_relaunch_env to preserve explicit native environment values through Launch Services. The existing update-check entry point keeps its immediate-exit behavior.
  • Keep the progress gauge below native completion and defer completion until an active Update/Pulse call returns, avoiding dialog destruction and storage borrowing during nested native event processing.

The README documents integration, signer requirements, manual fallback, recovery, timeouts and testing. The repository demo creates disposable v1/v2 apps and a DMG; automatic installation requires an Apple-issued signing identity, while ad-hoc fixtures use manual fallback. Its shared-source harness is excluded from the published crate package and does not add a production local-file installation API.

Validation

  • 64 automated tests pass on macOS, covering replacement, rollback, failed restore/launch, cancellation, timeouts, competing destination changes, bundle identity, signature tampering, ad-hoc signer rejection, quoted paths, stale-stage safety, log deletion/retention and nested progress completion.
  • The additional optional signed-release test passes with an existing notarized Paperback app: its own developer team is accepted and a different team is rejected.
  • Standalone cargo clippy --all-targets -- -D warnings passes with this crate's declared lint settings and normal published wxDragon dependency. Formatting and whitespace checks pass.
  • cargo package --list confirms the demo is excluded and the installer helper assets are included.

The original replacement/relaunch mechanism was exercised end to end with a disposable Paperback copy and the ad-hoc v1/v2 demo. After tightening the signer policy, automatic demo installation requires a developer signing identity and has not been rerun here; the new signer check was verified separately against a notarized release.

Limits and translation

The helper allows 60 seconds for host shutdown. Launch Services success confirms that macOS accepted the request; it does not detect a later application crash. Failed launch preserves the old bundle rather than automatically rolling back an app that may already have started. Recovery backups are not automatically swept.

The new macOS user-facing strings use patois::t and will need catalog translations in consuming apps.

Companion integration: trypsynth/paperback#1004, a draft using the shutdown callback to save application state before replacement and announce the restart to VoiceOver. It is pinned to this PR's reviewed commit and will switch to a published crate dependency after this change is merged and released.

Stage the matching signed app from a verified DMG beside the running bundle,
then hand replacement and relaunch to a detached helper after host shutdown.
Keep the manual installation flow for protected and translocated apps. Use
same-filesystem renames, restore the old bundle on replacement failure, and
preserve backups and diagnostic logs when recovery or relaunch fails.

Add a shutdown callback and explicit macOS relaunch environment overrides
so applications can persist state and retain custom settings directories.
Prepare updates off the UI thread and defer completion until native progress
calls return, avoiding dialog destruction and RefCell reentrancy panics.

Cover helper success and recovery paths, cancellation, timeouts, competing
installers, bundle identity and signatures, and progress-dialog lifetime.
Include a disposable signed fixture demo and document integration and limits.

Co-Authored-By: Codex <noreply@openai.com>
johanntan added a commit to johanntan/paperback that referenced this pull request Oct 2, 2026
Use ship-shape's installer-ready callback to force-close the main window
through Paperback's normal persistence and cleanup handler, then leave the
application event loop. This saves settings, document positions and window
geometry, stops audio and timers, and bypasses macOS's hide-on-close veto
so the update helper can replace and relaunch the app.

Pass the resolved settings directory through Launch Services when the new
app is launched, preserving custom and isolated configurations.

Pin the companion ship-shape PR commit so this draft builds without a
local checkout. Replace the temporary Git patch with the published crate
version after trypsynth/ship-shape#6 is merged and released.

Co-Authored-By: Codex <noreply@openai.com>

@trypsynth trypsynth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, it's careful work. I read all of it, ran the 62 tests on macOS (all pass), and ran the demo end to end: v1 was replaced by v2, relaunched, and nothing was left next to the bundle. A few things before it can go in.

Needs fixing

  1. The signature check accepts an app signed by anyone. codesign --verify --deep --strict only checks that the replacement is validly signed, not by whom. An ad-hoc signature passes: the demo's installed app ends up Signature=adhoc, TeamIdentifier=not set. Minisign on the DMG is still the real guard, but please require the replacement to carry the running app's Team ID, or to satisfy the running bundle's designated requirement (codesign -d -r-, then codesign --verify -R=...). If the running app has no Team ID, as with an unsigned dev build, fall back to the manual flow.
  2. cargo clippy --all-targets -- -D warnings fails on two lints in the new code: missing backticks in a doc comment at src/ui/progress.rs:71, and a missing ; at src/ui.rs:244. Master is clean. The lints that matter are the ones in this crate's own Cargo.toml, not Paperback's workspace.

Should fix

  1. Stale staging directories. .ship-shape-* is created next to the app, so usually in /Applications, and holds a full copy of the app. If the helper dies during its window (logout, power loss), nothing ever removes it. Please sweep stale .ship-shape-* directories next to the bundle on the next update, or on launch.
  2. Logs accumulate. Every update leaves a ~/Library/Logs/ship-shape-update-*.log, even a successful one. Remove it on success, or keep only the last few.

Minor

  • The demo includes src/platform/macos.rs through #[path] and redefines InstallOutcome to make it compile. That's brittle, and it gets published with the crate unless examples/ is excluded from the package.
  • This adds about 20 translatable strings, which is fine, just flagging it for translators.

Done well

  • The helper only takes paths as arguments, never as shell source, and passes alert text to osascript as argv.
  • It uses atomic renames, restores the old bundle on failure, and only proceeds after an explicit commit.
  • It rechecks the bundle's inode before replacing it.
  • It skips translocated, symlinked and unwritable bundles.
  • The DMG is mounted private and read-only, must contain exactly one app with the same bundle ID, and the executable must sit inside that bundle.
  • Cancelling at any stage is clean.
  • The progress lifecycle fix and its tests deal with a real re-entrancy problem.
  • Windows and Linux keep their existing behaviour.

Require the running and replacement bundles to satisfy an Apple signing
chain requirement for the same developer Team ID. Keep unsigned and ad-hoc
hosts on the manual path, and validate the replacement before and after
staging. Test rejection of valid ad-hoc signatures and provide an optional
read-only check for an existing developer-signed release.

Register stage ownership and host/helper PIDs. On the next automatic update,
sweep stages older than 24 hours only for the same bundle and user, with no
live host/helper and no recovery backup. Preserve uncertain metadata,
symlinks and other apps' stages. Remove successful/canceled helper logs and
retain failure diagnostics. Cover these cleanup rules in the helper tests.

Correct the standalone clippy findings, keep native relaunch environment
values intact, and exclude the repository demo from published packages.
Allow the demo to use an Apple-issued signing identity and document its
manual fallback when an ad-hoc signature is used.

Co-Authored-By: Codex <noreply@openai.com>
@johanntan

Copy link
Copy Markdown
Contributor Author

Pushed bbbdc57 with the review fixes:

  1. Signer identity: both the running app and the replacement must satisfy a native codesign -R requirement for an Apple-issued signing chain and the same developer Team ID. The replacement is checked before and after staging. Hosts with no Team ID, including ad-hoc development builds, use the manual DMG flow.
  2. Clippy: fixed the doc-comment backticks and missing semicolon, and checked the crate standalone using its own lint settings and normal published wxDragon dependency. cargo clippy --all-targets -- -D warnings now passes.
  3. Abandoned stages: stage metadata records the bundle path and host/helper PIDs. The next automatic update sweeps stages older than 24 hours only for the same bundle and user, with neither process alive. Recent stages, symlinks, uncertain metadata, other apps' stages, and anything containing an old.app recovery backup are preserved. The backup exception is intentional so cleanup cannot remove the only recoverable app after an interrupted swap.
  4. Logs: successful installation and canceled preparation remove their helper log; failure logs remain for diagnosis. Tests assert both behaviors.

The repository demo now accepts SHIP_SHAPE_DEMO_SIGNING_ID for an Apple-issued signing identity; without one, its ad-hoc fixtures exercise manual fallback. I kept the shared-source harness to avoid exposing a production API for installing arbitrary local files, but excluded examples/ from the published package and verified the package file list. It remains covered by the all-targets clippy check.

Validation: 64 automated tests pass, plus the optional read-only signed-release test, which accepts an existing notarized Paperback release for its own team and rejects a different team. Formatting and whitespace checks pass. I have not rerun automatic v1-to-v2 demo installation with a developer signing identity; the earlier ad-hoc end-to-end demo no longer qualifies for automatic installation under the new policy.

Translation note: the new macOS user-facing messages use patois::t, including the signer rejection message, and will need catalog translations in consuming apps.

@trypsynth
trypsynth merged commit 77a3b2d into trypsynth:master Oct 3, 2026
trypsynth added a commit to trypsynth/paperback that referenced this pull request Oct 3, 2026
* Save state before macOS update installation

Use ship-shape's installer-ready callback to force-close the main window
through Paperback's normal persistence and cleanup handler, then leave the
application event loop. This saves settings, document positions and window
geometry, stops audio and timers, and bypasses macOS's hide-on-close veto
so the update helper can replace and relaunch the app.

Pass the resolved settings directory through Launch Services when the new
app is launched, preserving custom and isolated configurations.

Pin the companion ship-shape PR commit so this draft builds without a
local checkout. Replace the temporary Git patch with the published crate
version after trypsynth/ship-shape#6 is merged and released.

Co-Authored-By: Codex <noreply@openai.com>

* Announce update restarts and preserve explicit overrides

Forward PAPERBACK_CONFIG_DIR only when it is present in this process's
environment. Preserve its native value so relaunch does not synthesize an
override for the default settings directory or lose non-UTF-8 path bytes.

Post a translated high-priority live-region announcement before updater
shutdown. Disable the frame and keep the UI event loop running for a brief
grace period so VoiceOver can receive the asynchronous notification, then
run the existing forced-close persistence and cleanup path.

Update the pinned ship-shape revision and lockfile to the reviewed signer,
stale-stage cleanup and log-retention fixes. Keep the integration draft
pending the upstream crate release.

Co-Authored-By: Codex <noreply@openai.com>

* Use ship-shape 0.4.0 from crates.io instead of the pinned PR commit

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Quin Gillespie <trypsynth@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants