Skip to content

Fix cross-volume update failure that deletes the app without replacing it#294

Open
augiekim wants to merge 1 commit into
Squirrel:mainfrom
augiekim:fix/cross-volume-update
Open

Fix cross-volume update failure that deletes the app without replacing it#294
augiekim wants to merge 1 commit into
Squirrel:mainfrom
augiekim:fix/cross-volume-update

Conversation

@augiekim

@augiekim augiekim commented May 2, 2026

Copy link
Copy Markdown

When the target app lives on a different volume than where ShipIt stages downloads (~/Library/Caches/ on the system volume), POSIX rename() fails with EXDEV. The old fallback called removeItemAtURL: on the target first, then NSFileManager moveItemAtURL:toURL:. If that move fails for any reason, the target slot is empty and the app is gone with no way to recover.

Fix: when EXDEV fires, copy the source to a UUID-named staging path on the same volume as the target, then rename() atomically from staging to target. Both paths are on the same volume so rename() is guaranteed to succeed or leave the target untouched. The source is only deleted after a successful rename. On copy failure, the source is still intact and the existing item at the target is never touched.

This correctly handles the backup step in acquireTargetBundleURLForRequest: (moving the current app off an external volume to system temp) and the rollback path in abortInstall (restoring from system temp back to the external volume).

New tests:

  • Install an update from one external volume to another (double EXDEV)
  • Restore the app to an external volume after too many failed install attempts

Fixes: #201

@augiekim

augiekim commented May 2, 2026

Copy link
Copy Markdown
Author

I know this project hasn’t been actively maintained for some time, but it’s still used by Electron for updates, and this bug is causing real pain for Mac users who install Electron apps on external volumes. Recently, more Mac users—especially Mac Mini users—have been relying on external SSDs for large app installations. Apps from the Apple App Store don’t have issues installing on external volumes, but Electron apps run into problems because of this bug. Hopefully, this can be fixed and adopted by Electron so that things become easier for everyone.

@MarshallOfSound

Copy link
Copy Markdown
Collaborator

@augiekim Electron floats a substantial quantity of patches on top of Squirrel.Mac in-tree for both testing & stability reasons

https://github.com/electron/electron/tree/main/patches/squirrel.mac

Might take some time this weekend to bring this repo into this decade but this fixing an Electron issue in this repo today is unlikely to land in electron for a while given how Electrons build system works.

@augiekim
augiekim force-pushed the fix/cross-volume-update branch 2 times, most recently from e237b51 to bd0af2d Compare May 3, 2026 04:01
@augiekim

augiekim commented May 3, 2026

Copy link
Copy Markdown
Author

@augiekim Electron floats a substantial quantity of patches on top of Squirrel.Mac in-tree for both testing & stability reasons

https://github.com/electron/electron/tree/main/patches/squirrel.mac

Might take some time this weekend to bring this repo into this decade but this fixing an Electron issue in this repo today is unlikely to land in electron for a while given how Electrons build system works.

Thank you for the quick response and for pulling quite a few patches! Now I remember those patches in the Electron repo. The last time I looked into Electron was over five years ago, and I haven’t had a chance to revisit it since my current work isn’t related to Electron.

…g it

When the target app lives on a different volume than where ShipIt stages
downloads (~/Library/Caches/ on the system volume), POSIX rename() fails
with EXDEV. The old fallback called removeItemAtURL: on the target first,
then NSFileManager moveItemAtURL:toURL:. If that move fails for any reason,
the target slot is empty and the app is gone with no way to recover.

Fix: when EXDEV fires, copy the source to a UUID-named staging path on the
same volume as the target, then rename() atomically from staging to target.
Both paths are on the same volume so rename() is guaranteed to succeed or
leave the target untouched. The source is only deleted after a successful
rename. On copy failure, the source is still intact and the existing item
at the target is never touched.

This correctly handles the backup step in acquireTargetBundleURLForRequest:
(moving the current app off an external volume to system temp) and the
rollback path in abortInstall (restoring from system temp back to the
external volume).

New tests:
- Install an update from one external volume to another (double EXDEV)
- Restore the app to an external volume after too many failed install attempts

Fixes: Squirrel#201

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@augiekim
augiekim force-pushed the fix/cross-volume-update branch from bd0af2d to d7440b4 Compare May 4, 2026 04:42
@augiekim

augiekim commented May 4, 2026

Copy link
Copy Markdown
Author

I just realized that this PR doesn’t correctly address the cross-volume update failure issue. The real root cause is that Squirrel.framework/Versions/A/Resources/ShipIt doesn’t have permission to access the volume.

The best option is probably to update Electron to check the installation location and show a popup guiding users to grant permission to ShipIt, followed by opening the Settings window. I’ll also look into whether there are better ways to address this.

Here is the analysis and a suggestion:

Electron autoUpdater: Post-Mortem Update Failure Detection

Problem

When a Squirrel.Mac update fails after the app has already quit (e.g. due to
TCC permission denial on a non-/Applications volume), there is no way for
the app to know what went wrong on next launch. The user just finds their app
gone with no explanation.

Root cause in the log:

NSPOSIXErrorDomain Code=1 "Operation not permitted"
NSLocalizedDescription: %APPLICATION_NAME% couldn't be copied because
you don't have permission to access "Applications".

Why Squirrel Cannot Currently Report This

  • ShipIt communicates with Squirrel via a one-way file-based channel
    (ShipItState.plist). There is no return channel.
  • ShipIt runs after the parent app has quit, so no Squirrel process is
    alive to receive errors at the time of failure.
  • Squirrel's relaunchToInstallUpdate error signal only covers failures
    before ShipIt is launched — post-launch failures are invisible to it.
  • ShipIt writes its errors only to ShipIt_stderr.log in the app's ShipIt
    cache directory.

Proposed Solution

Add post-mortem detection: on next app launch, read the ShipIt stderr log,
parse for known failure patterns, and surface them as a structured event that
Electron app developers can consume.


Contribution Path

Squirrel/Squirrel.Mac  →  electron/electron  →  Apps (VS code, Claude, ...)
     (detection)              (event API)           (UI / dialog)

Each step is an independent PR and can be pursued in parallel.


Step 1 — Squirrel.Mac

Repo: https://github.com/Squirrel/Squirrel.Mac

Add a method to SQRLUpdater.m that runs on app startup:

  1. Locate the ShipIt stderr log via SQRLDirectoryManager (path is already
    known to Squirrel)
  2. Parse the last Installation error: entry
  3. Detect known failure patterns:
    • NSPOSIXErrorDomain Code=1 → TCC permission denied (EPERM)
    • SQRLCodeSignatureErrorDomain Code=-1 → code signature validation failed
    • SQRLInstallerErrorDomain Code=-7 → cross-volume move failed
  4. Expose a new signal lastInstallError on SQRLUpdater returning a
    structured NSError with the reason

Key point: This is read-only on startup — no changes to install logic,
low risk.


Step 2 — Electron

Repo: https://github.com/electron/electron

Apply a squirrel.patch to the Squirrel.Mac dependency (bypassing the
archived fork) and wire the new lastInstallError signal into Electron's
autoUpdater module as a new event:

autoUpdater.on('update-failed-last-session', (event, details) => {
  // details.reason: 'permission-denied' | 'code-signature' | 'cross-volume'
  // details.message: human-readable description
})

Squirrel.patch approach sidesteps the archived fork risk entirely —
Electron applies the patch at build time against the upstream Squirrel.Mac
source, no fork maintenance required.


Step 3 — Electron Apps

Listen for the new update-failed-last-session event and show a dialog:

autoUpdater.on('update-failed-last-session', (event, details) => {
  if (details.reason === 'permission-denied') {
    dialog.showMessageBox({
      type: 'warning',
      title: 'Update Failed',
      message: 'The app could not update itself.',
      detail:
        'The updater (ShipIt) does not have permission to write to your ' +
        'Applications folder. Please grant Full Disk Access to ShipIt in ' +
        'System Settings to allow future updates.',
      buttons: ['Open System Settings', 'Dismiss'],
    }).then(result => {
      if (result.response === 0) {
        shell.openExternal(
          'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'
        );
      }
    });
  }
});

Why This Is Low Risk

  • No changes to install logic — detection only
  • ShipIt log path is deterministic, already managed by SQRLDirectoryManager
  • Error patterns are specific and unlikely to produce false positives
  • squirrel.patch approach avoids dependency on the archived Electron fork
  • Each PR is independently mergeable and testable

Failure Patterns to Detect

Error Domain Code Reason
TCC permission denied NSPOSIXErrorDomain 1 ShipIt needs Full Disk Access
Code signature invalid SQRLCodeSignatureErrorDomain -1 Downloaded update is corrupt or tampered
Cross-volume move failed SQRLInstallerErrorDomain -7 App on external volume, copy failed

References

  • ShipIt log location: ~/Library/Caches/<bundle-id>.ShipIt/ShipIt_stderr.log
  • Full Disk Access System Settings URL: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles
  • Squirrel.Mac source: https://github.com/Squirrel/Squirrel.Mac
  • Electron autoUpdater docs: https://www.electronjs.org/docs/latest/api/auto-updater

@Forger7

Forger7 commented Jul 19, 2026

Copy link
Copy Markdown

Real-world confirmation of the TCC-permission theory above, from a MacMini + external SSD setup (same scenario @augiekim described).

Setup: Element.app installed via Homebrew Cask to an external APFS volume (/dev/disk7s1, mounted with nodev, nosuid, noowners).

Exact error chain, from ~/Library/Caches/im.riot.app.ShipIt/ShipIt_stderr.log:

Error Domain=SQRLInstallerErrorDomain Code=-7 "Couldn't move bundle contents
file:///var/folders/.../T/im.riot.app.ShipIt.XXXX/Element.app across volumes
to file:///Volumes/<external volume>/Element.app/"
  └─ NSCocoaErrorDomain Code=513 "Element.app" couldn't be moved because
     you don't have permission to access "<external volume>"."
     └─ NSCocoaErrorDomain Code=513 ""Element.app" couldn't be copied
        because you don't have permission to access "<external volume>"."
        └─ NSPOSIXErrorDomain Code=1 "Operation not permitted"

This matches both the SQRLInstallerErrorDomain Code=-7 this PR targets and the NSPOSIXErrorDomain Code=1 @augiekim flagged as the TCC signature.

However: I granted Full Disk Access to both Element.app and Terminal in System Settings and the failure persisted identically. That's worth noting because the actual bundle-move is performed by ShipIt running from a temp staging path (/var/folders/.../T/im.riot.app.ShipIt.XXXX/...), not from Element.app or Terminal's own binary — granting FDA to the outer app may simply never reach the process actually performing the cross-volume move, since it's a separately-signed helper launched via launchd from a non-stable path. If TCC's grant is scoped to that specific (ephemeral) binary identity, there may be no way to grant it access in advance through the normal Full Disk Access UI at all.

Happy to test any specific diagnostic steps if useful — this reproduces reliably on this machine.

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.

Can't update application which is located outside of Applications folder

3 participants