A desktop updater can download the right archive and still leave the user without a working application.
The dangerous boundary is replacement: the old app is gone, but the new app has not reached its final location. A failed move at that point turns an update failure into a recovery problem.
AIClaw 3.7.27 changes the macOS replacement script to stage the incoming application, keep the previous bundle, and attempt recovery when replacement or the system open command fails. This is released code in v3.7.27; the latest public release at this review is v3.7.28.
The gap between extraction and installation
The previous script waited for the running process to exit, extracted the ZIP, found an application bundle, removed the installed bundle, and moved the extracted one into place.
Extraction failure happened before removal, so that case left the installed app intact. The gap came later: once the installed bundle had been deleted, a failed move had no saved application to restore.
The new ordering is visible in updater.ts at v3.7.27, in replaceAndRelaunch. The version diff includes the replacement change and other recovery fixes.
Stage before moving the installed app
The replacement process still runs in a detached child shell and waits for the old process to exit. It derives the installed bundle from the running executable, rather than assuming an /Applications location.
Then it:
- Extracts the archive to a temporary directory.
- Checks that the incoming bundle has
Contents/Info.plistand the expected executable with its executable bit set. - Creates a staging directory beside the installed app and copies the incoming bundle there.
- Moves the installed app into staging as
previous.app. - Moves the staged replacement into the installed location and asks macOS to open it.
Copying beside the installed app moves the potentially expensive cross-filesystem copy before the old bundle is touched. The final moves use that nearby staging directory.
The structure checks are deliberately narrow. They check the expected files and executable permission; they do not establish code-signing validity, archive authenticity, or application health.
Recovery has its own failure path
An EXIT trap tracks whether the old bundle was backed up and whether the replacement was installed.
If a later command fails after backup, the script attempts to remove any installed replacement, move previous.app back, and reopen the restored app. If extraction, validation, or staging fails earlier, the original bundle remains at its installed location and the script attempts to reopen it.
Rollback can fail too. When the backup cannot be moved back, the script preserves the staging directory containing previous.app and records its location instead of deleting the remaining recovery copy.
The child process now appends stdout and stderr to update-install.log in Electron's application user-data directory. That matters because the original Electron process has already exited when these failures happen. Its normal UI cannot report the rest of the installation sequence.
Paths are also shell-quoted with embedded apostrophes escaped. A custom installation directory containing spaces or a single quote should remain a path, rather than changing how the generated shell command is parsed.
What was verified
For this article, I reran:
npm run test:updater
The result was 19 tests passed, zero failed, zero skipped. The local updater source and test file matched the corresponding public v3.7.27 files by SHA-256.
The macOS replacement group executes the actual generated Bash script against temporary fixture bundles. It removes the process-wait line for the test and substitutes controlled implementations of ditto, mv, xattr, and open.
Its eight scenarios cover success, extraction failure, missing executable validation, staging-copy failure, backup-move failure, replacement-move failure, launch-command failure, and rollback failure. Assertions check the installed or preserved old-version marker, staging cleanup, and the reopen target where recovery succeeds. The fixture paths include spaces and an apostrophe.
The test implementation also contains the existing download and Windows installer checks. The total of 19 includes the replacement group's parent test and eight subtests; it does not mean 19 real installations were performed.
Where the protection stops
No real application was replaced during these tests. They demonstrate the script's control flow under injected failures, not end-to-end installer acceptance on every Mac.
A successful open command means macOS accepted the request. It does not prove that the new application stays healthy afterward, and this change does not monitor a later crash or roll it back. The shell recovery path also cannot guarantee recovery from power loss or a forcibly killed updater.
This change concerns macOS bundle replacement. It does not introduce the same mechanism into Windows or Linux installation.
There is also a version boundary: the updater belongs to the application currently running. An older build does not gain the new replacement script merely because the archive it downloads contains 3.7.27 or later.
Keeping the previous bundle until the replacement has been moved and the open request has succeeded gives the updater a concrete recovery path. Preserving that backup when recovery itself fails gives the user something to recover from.
Top comments (0)