Skip to content

Stop the macOS updater swapping a translocated copy and failing silently - #5578

Merged
Gabriel Dufresne (GabrielDuf) merged 6 commits into
mainfrom
fix/macos-updater-translocation-no-replace
Oct 23, 2026
Merged

Gabriel Dufresne (GabrielDuf) merged 6 commits into
mainfrom
fix/macos-updater-translocation-no-replace

Conversation

@GabrielDuf

@GabrielDuf Gabriel Dufresne (GabrielDuf) commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor
  • I have read the contributing guidelines, and I agree with the Code of Conduct.
  • Have you checked that there aren't other open pull requests for the same changes?
  • Have you tested that the committed code can be executed without errors?
  • Have you confirmed that this issue is caused by UniGetUI itself, and not by the package manager or the package involved?
    If the same issue can be reproduced outside UniGetUI with the relevant package manager or with the package itself, please report it there first. UniGetUI should only be used to track issues that are specific to UniGetUI's behavior or integration.

Bug report

There is no GitHub issue; this comes from a report by the maintainer. On macOS the auto-update closes the app but reopens it at the same version with nothing changed. It does not happen for everyone, but it is common. A colleague who hit it found that the app was quarantined and the running path was an AppTranslocation path instead of /Applications/UniGetUI.app. His log shows Replacing /private/var/folders/.../T/AppTranslocation/<GUID>/d/UniGetUI.app with the freshly-extracted bundle and relaunching. repeating on every attempt, and his /Applications/UniGetUI.app is root:wheel 755 with /Applications as root:admin 775, with a non-root admin user.

The report suggests detecting each condition and explaining it: translocation (resolve the original path, or tell the user to move the app to Applications), no write permission (check before downloading and say it needs admin rights), signs of a managed install (warn, let the user proceed), and an explicit managed preference from a configuration profile (honour it). The first two are fixed here; the last two are not (see below). For the second, the updater asks for an administrator password rather than only reporting the problem.

Root cause

The updater chose what to replace with ResolveRunningMacAppBundle(), which only walked up from Environment.ProcessPath to the nearest .app. When Gatekeeper translocates a quarantined app, the process runs from a read-only nullfs mount at /private/var/folders/.../T/AppTranslocation/<GUID>/d/UniGetUI.app, and the updater picked that mount as the swap target.

The detached /bin/sh swap helper then ran mv "$target" "$target.old". That fails on the read-only mount, and the script ignored the failure. It then ran /usr/bin/open -na "$target" unconditionally, relaunching the same untouched translocated copy. The original /Applications/UniGetUI.app was never touched and stayed quarantined, so every launch was translocated again. No Attempt finished marker was written, nothing told the user why, and the next attempt downloaded, extracted and "swapped" all over again, which is the loop in the log.

The same silent failure happens when the user cannot write to the bundle or its parent folder: nothing checked this before downloading, and the swap swallowed the mv error.

Fix

  • New pure resolver MacUpdateTargetResolver (src/UniGetUI.Core.Tools/MacUpdateTarget.cs), portable and string-based with / separators so its tests also run on Windows CI. Filesystem and native probes are injected.
    • Finds the enclosing bundle (the old helper's behaviour).
    • Detects /AppTranslocation/ paths and asks a resolver for the original location. If that fails it falls back to /Applications/UniGetUI.app when it exists, otherwise the result is TranslocatedUnresolved.
    • Reports ReadOnlyVolume when the target is on a read-only volume (access fails with EROFS), such as the mounted DMG, since not even root can replace it there.
    • Requires both the target bundle and its parent folder to be writable, otherwise NotWritable.
    • Parses a swap-failure marker out of the update log.
  • New macOS interop (src/UniGetUI.Avalonia/Infrastructure/MacBundleLocation.cs), LibraryImport only so it stays NativeAOT-safe: SecTranslocateCreateOriginalPathForURL with the CFURL helpers to resolve the original path, and access(2) with W_OK for writability and, through its EROFS error, read-only volumes. Failures are logged and treated as unresolved or not writable. The file is added to the explicit Compile list in UniGetUI.Avalonia.csproj.
  • AvaloniaAutoUpdater.cs:
    • Pre-check before download: right after an update is found, TranslocatedUnresolved warns that macOS is running UniGetUI from a temporary copy and it must be moved to Applications, and ReadOnlyVolume says the same for a mounted disk image. Both write the finished marker and returns before anything is downloaded.
    • Unwritable bundle (NotWritable): the update is downloaded and verified as usual, then the swap runs as root through the macOS administrator prompt (MacAdminFileInstaller.RunAsync, split out of the existing catalog-save helper). The root script (MacUpdateTargetResolver.ElevatedSwapScript) gives the new bundle the old bundle's owner, clears quarantine, swaps with rollback, and deletes the old bundle in the background once the app has exited. An unprivileged helper then relaunches the app. If the prompt is cancelled, the banner says the update needs administrator rights, with no download retried in a loop.
    • The same check runs again at install time in LaunchMacInstallerAsync, which now swaps the original location instead of the translocated mount.
    • The swap script now takes the update log path and a marker. If either mv fails it appends === Swap failed: <target> to the log, then still relaunches whatever is left so the user keeps a working app.
    • CheckForOrphanedUpdateAttempt looks for that marker first and shows an error banner ("UniGetUI could not replace {0}, so it is still running the previous version", with View log), then writes the finished marker so it does not repeat.
    • ResolveRunningMacAppBundle is removed; its logic is now FindEnclosingAppBundle.
  • Nine new English strings in src/Languages/lang_en.json, all through CoreTools.Translate.

Verification

  • dotnet build -p:Platform=x64 -p:UseSharedCompilation=false succeeded with 0 errors for UniGetUI.Core.Tools.Tests and UniGetUI.Avalonia (built on macOS as net10.0). The changed files raise no warnings.
  • dotnet format whitespace and dotnet format style with --verify-no-changes on the changed files: exit 0.
  • --filter "FullyQualifiedName~MacUpdateTargetTests": 14 executed, 14 passed. Run with -p:Platform=arm64 because this arm64 Mac has no x64 dotnet host.
  • Swap script run by hand in a scratch sandbox with open replaced by echo: with a read-only parent, mv failed, the === Swap failed: line was appended and the relaunch still ran; with a writable parent the swap succeeded and nothing was appended.
  • SecTranslocateCreateOriginalPathForURL, the CFURL functions and access resolve on macOS 26 from the exact library paths used. access(/Applications, W_OK) returns 0 and access(/System, W_OK) returns -1.
  • NativeAOT: dotnet publish with the osx-arm64-NativeAot profile succeeds. Its 16 trim/AOT warnings all come from Avalonia DataGrid, Vanara and Iciclecreek; none are in the files this PR touches.
  • The three macOS probes, compiled unchanged into a NativeAOT harness: a mounted read-only DMG reports readOnlyVolume=True, /System reports not writable but not read-only, and the home folder reports writable. A real AppTranslocation mount could not be created from a test process (SecTranslocateCreateSecureDirectoryForURL returns EPERM outside LaunchServices), so the translocated-path lookup is untested on a live mount. If it returns nothing, the resolver falls back to /Applications/UniGetUI.app.
  • Not tested: a real translocated launch, the real administrator prompt and an end-to-end self-update. The full test suite is left to CI.

Regression test

MacUpdateTargetTests in UniGetUI.Core.Tools.Tests, notably Resolve_UpdatesTheOriginalBundleWhenRunningFromATranslocatedCopy, Resolve_RefusesToUpdateATranslocatedCopyWithNoOriginalToReplace, Resolve_ReportsARootOwnedBundleAsNotWritable and Resolve_ReportsAnUnwritableParentFolderAsNotWritable. The ElevatedSwapScript_* tests run the root swap script unprivileged on macOS: it replaces the bundle, keeps the old one while the app runs and deletes it once it has exited, and leaves the bundle untouched when the new one is missing. With the pre-fix resolution logic swapped into Resolve, 6 of 14 failed, with the translocation cases returning the reporter's AppTranslocation path instead of /Applications/UniGetUI.app and the permission cases returning Ready instead of NotWritable. With the fix, 14 of 14 pass. A plain git stash was not usable because the resolver class is new.

Not covered

  • Managed-install notice and configuration-profile block are not implemented. They are new policy features, not the reported bug, and the loop is fixed without them. The profile block needs an admin-facing contract (preference domain and key, read through CFPreferences) that the maintainer should choose; the managed-install notice needs agreed heuristics (pkg receipt id, Jamf paths) and a UI change.
  • The administrator prompt itself was not exercised (it needs a person at the password dialog). The exact command was run through osascript unprivileged against a scratch bundle whose path has a space and an apostrophe: it swapped, removed the staging copy and returned without waiting on the background cleanup.
  • A bundle installed by IT (pkg or Jamf) and updated here may be overwritten by the next managed deployment.
  • The /Applications fallback is a judgement call: if the translocated copy came from elsewhere and the original cannot be resolved, the /Applications copy is the one updated.
  • The Linux swap helper has the same ignore-mv-failure shape and is unchanged.
  • BundleNotFound is unchanged: it still downloads first and then shows the existing notice.

Review notes (non-blocking)

  • AvaloniaAutoUpdater.cs:215: the swap-failure banner only appears if CheckForOrphanedUpdateAttempt reads last-update-attempt.log before UpdateCheckLoopAsync resets it. The same race already existed for the "Target version" orphan check, and the connectivity wait usually decides it, but nothing guarantees the order.
  • AvaloniaAutoUpdater.cs:1083: an unforeseen swap failure leaves no backoff, so the relaunched copy shows the banner and then auto-updates again. The reported causes are caught before download, so this only affects unforeseen mv failures.
  • Not verified: a real Gatekeeper-translocated launch, an end-to-end self-update, and the NativeAOT publish. All interop uses LibraryImport with blittable signatures.

Relates to: none, there is no issue for this report.

🤖 Generated with Claude Code

The updater picked the nearest enclosing .app of the running process as
the swap target. When Gatekeeper translocates a quarantined app, that is
a read-only nullfs mount under AppTranslocation, so the swap helper's mv
failed unnoticed and it relaunched the untouched copy, looping forever
while the real /Applications bundle stayed quarantined.

Resolve the original bundle path through SecTranslocate (falling back to
/Applications/UniGetUI.app), and require the bundle and its parent to be
writable before downloading anything. Unresolvable translocation and
missing write access now show an explanatory warning up front. If a swap
still fails, the helper records a marker in the update log and the
relaunched copy shows an error banner instead of a generic warning.

The resolver is string-based with injected probes so its decisions are
unit-tested on every platform; the native calls stay in a small
LibraryImport-only macOS file to remain NativeAOT-safe.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Real translocated self-update and NativeAOT validation remain outstanding, and swap-failure logging still needs a privacy fix.

1 open issue
What changed in this PR

This PR addresses silent macOS self-update failures by resolving translocated app bundles to their original locations and checking write access before downloading.

Changes:

  • Adds a portable target resolver, macOS native probes, and resolver regression tests.
  • Checks update eligibility before download and installation, and reports failed bundle swaps on relaunch.
  • Adds English messages for blocked and failed updates.
File Description
src/​UniGetUI.Core.Tools/​MacUpdateTarget.cs Resolves update targets and parses swap-failure markers.
src/​UniGetUI.Core.Tools.Tests/​MacUpdateTargetTests.cs Tests target resolution, permissions, and marker parsing.
src/​UniGetUI.Avalonia/​UniGetUI.Avalonia.csproj Includes the new macOS interop file.
src/​UniGetUI.Avalonia/​Infrastructure/​MacBundleLocation.cs Adds native original-path and write-access probes.
src/​UniGetUI.Avalonia/​Infrastructure/​AvaloniaAutoUpdater.cs Integrates prechecks, target selection, and failure reporting.
src/​Languages/​lang_en.json Adds user-facing update diagnostics.

Review effort: Balanced

The swap helper wrote the raw target path into the update log when a swap
failed, bypassing the Logger.Redact call that AppendToUpdateLog applies to
every other message. An install under /Users/<name>/ therefore exposed the
username even with username redaction enabled.

The helper now receives Logger.Redact(target) as a sixth argument and both
failure markers print it. mv, rm, xattr and open still use the real target.
The marker path is display-only: CheckForOrphanedUpdateAttempt uses it only
in Logger.Warn and the error banner, never in a filesystem call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A root-owned UniGetUI.app (or one in a folder the user cannot write to) used
to block the update with a message telling the user to contact IT. The
updater now downloads it as usual and, at install time, shows the macOS
administrator prompt and swaps the bundle as root, keeping the original
owner on the new bundle. The old bundle is deleted once the app has exited.

If the prompt is cancelled, the banner only says administrator rights are
needed. MacAdminFileInstaller gains a general RunAsync for this.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The elevated installer trusts mutable staging contents, and rollback can leave the application broken after a partial cross-filesystem move.

2 open issues
1 resolved since last review

Review effort: Balanced

The administrator swap installed the bundle the user had extracted into
their own writable staging folder, so another process running as that user
could replace it while the password prompt was open. The root script now
copies the downloaded archive into a private root-owned folder, checks its
SHA-256 against the productinfo hash, and extracts and installs only that
copy.

Its rollback also now removes a partial destination before restoring the
old bundle, as the unprivileged helper already did, so a failed move cannot
leave the old app nested inside a broken one.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The shutdown timeout can terminate the updater while administrator authorization is pending, losing relaunch and error handling.

1 open issue
2 resolved since last review

Review effort: Balanced

A Quit with a ready update waits for the installer handoff for two minutes,
then exits. The administrator prompt is a separate osascript process that
can stay open longer: entering the password after the deadline replaced the
app without relaunching it, and a cancel or failure went unhandled.

InstallerHandoffTracker gains BeginInteraction. While one is open,
WaitForAllAsync keeps waiting past its timeout, and the admin swap holds one
around the prompt.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Privileged bundle replacement still needs human validation of a real translocated self-update, administrator authorization, and the macOS NativeAOT publish path.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

randy-but-a-ro[bot]
randy-but-a-ro Bot previously approved these changes Oct 22, 2026

@randy-but-a-ro randy-but-a-ro Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Pull request was approved automatically: the AI review is complete and all its review threads are resolved. 🎉

Integration Details
{
	"deliveryId": "f6a5c3c0-ce45-11f1-8e08-d8c3ed29456b",
	"headSha": "d67ab31eec28c38cc8fd4ba9ac66c48e2cfb593b",
	"reviewer": "copilot-pull-request-reviewer[bot]"
}

Running UniGetUI straight from the mounted DMG reported the bundle as not
writable, so the updater would ask for an administrator password and then
fail, since not even root can modify a read-only disk image.

The resolver now reports ReadOnlyVolume when access() fails with EROFS, and
the updater tells the user to move UniGetUI to Applications before anything
is downloaded, as it does for an unresolved translocated copy.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The privileged self-update flow still needs human macOS validation with a real translocated launch, administrator prompt, and end-to-end installation.

No open issues 🎉

Review effort: Balanced

@randy-but-a-ro randy-but-a-ro Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Pull request was approved automatically: the AI review is complete and all its review threads are resolved. 🎉

Integration Details
{
	"deliveryId": "7020d9f0-ce51-11f1-8ce0-bdc74e739cdb",
	"headSha": "8e4e8b5655d15362db0920af5931b55b233a091d",
	"reviewer": "copilot-pull-request-reviewer[bot]"
}

@GabrielDuf
Gabriel Dufresne (GabrielDuf) merged commit c37e273 into main Oct 23, 2026
7 of 8 checks passed
@GabrielDuf
Gabriel Dufresne (GabrielDuf) deleted the fix/macos-updater-translocation-no-replace branch October 23, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants