Repository navigation
Stop the macOS updater swapping a translocated copy and failing silently - #5578
Gabriel Dufresne (GabrielDuf) merged 6 commits into
Conversation
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>
There was a problem hiding this comment.
🔵 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>
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>
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>
There was a problem hiding this comment.
🔵 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
There was a problem hiding this comment.
🤖 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>
There was a problem hiding this comment.
🤖 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]"
}

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 showsReplacing /private/var/folders/.../T/AppTranslocation/<GUID>/d/UniGetUI.app with the freshly-extracted bundle and relaunching.repeating on every attempt, and his/Applications/UniGetUI.appisroot:wheel 755with/Applicationsasroot: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 fromEnvironment.ProcessPathto 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/shswap helper then ranmv "$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.appwas never touched and stayed quarantined, so every launch was translocated again. NoAttempt finishedmarker 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
mverror.Fix
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./AppTranslocation/paths and asks a resolver for the original location. If that fails it falls back to/Applications/UniGetUI.appwhen it exists, otherwise the result isTranslocatedUnresolved.ReadOnlyVolumewhen the target is on a read-only volume (accessfails withEROFS), such as the mounted DMG, since not even root can replace it there.NotWritable.src/UniGetUI.Avalonia/Infrastructure/MacBundleLocation.cs),LibraryImportonly so it stays NativeAOT-safe:SecTranslocateCreateOriginalPathForURLwith the CFURL helpers to resolve the original path, andaccess(2)withW_OKfor writability and, through itsEROFSerror, read-only volumes. Failures are logged and treated as unresolved or not writable. The file is added to the explicitCompilelist inUniGetUI.Avalonia.csproj.AvaloniaAutoUpdater.cs:TranslocatedUnresolvedwarns that macOS is running UniGetUI from a temporary copy and it must be moved to Applications, andReadOnlyVolumesays the same for a mounted disk image. Both write the finished marker and returns before anything is downloaded.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.LaunchMacInstallerAsync, which now swaps the original location instead of the translocated mount.mvfails it appends=== Swap failed: <target>to the log, then still relaunches whatever is left so the user keeps a working app.CheckForOrphanedUpdateAttemptlooks 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.ResolveRunningMacAppBundleis removed; its logic is nowFindEnclosingAppBundle.src/Languages/lang_en.json, all throughCoreTools.Translate.Verification
dotnet build -p:Platform=x64 -p:UseSharedCompilation=falsesucceeded with 0 errors forUniGetUI.Core.Tools.TestsandUniGetUI.Avalonia(built on macOS as net10.0). The changed files raise no warnings.dotnet format whitespaceanddotnet format stylewith--verify-no-changeson the changed files: exit 0.--filter "FullyQualifiedName~MacUpdateTargetTests": 14 executed, 14 passed. Run with-p:Platform=arm64because this arm64 Mac has no x64 dotnet host.openreplaced byecho: with a read-only parent,mvfailed, 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 andaccessresolve on macOS 26 from the exact library paths used.access(/Applications, W_OK)returns 0 andaccess(/System, W_OK)returns -1.dotnet publishwith theosx-arm64-NativeAotprofile succeeds. Its 16 trim/AOT warnings all come from Avalonia DataGrid, Vanara and Iciclecreek; none are in the files this PR touches.readOnlyVolume=True,/Systemreports not writable but not read-only, and the home folder reports writable. A real AppTranslocation mount could not be created from a test process (SecTranslocateCreateSecureDirectoryForURLreturns 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.Regression test
MacUpdateTargetTestsinUniGetUI.Core.Tools.Tests, notablyResolve_UpdatesTheOriginalBundleWhenRunningFromATranslocatedCopy,Resolve_RefusesToUpdateATranslocatedCopyWithNoOriginalToReplace,Resolve_ReportsARootOwnedBundleAsNotWritableandResolve_ReportsAnUnwritableParentFolderAsNotWritable. TheElevatedSwapScript_*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 intoResolve, 6 of 14 failed, with the translocation cases returning the reporter's AppTranslocation path instead of/Applications/UniGetUI.appand the permission cases returningReadyinstead ofNotWritable. With the fix, 14 of 14 pass. A plaingit stashwas not usable because the resolver class is new.Not covered
osascriptunprivileged 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./Applicationsfallback is a judgement call: if the translocated copy came from elsewhere and the original cannot be resolved, the/Applicationscopy is the one updated.mv-failure shape and is unchanged.BundleNotFoundis unchanged: it still downloads first and then shows the existing notice.Review notes (non-blocking)
AvaloniaAutoUpdater.cs:215: the swap-failure banner only appears ifCheckForOrphanedUpdateAttemptreadslast-update-attempt.logbeforeUpdateCheckLoopAsyncresets 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 unforeseenmvfailures.LibraryImportwith blittable signatures.Relates to: none, there is no issue for this report.
🤖 Generated with Claude Code