Repository navigation
Disable package managers whose tool is missing on first run - #5573
Conversation
PackageManager.IsEnabled() returns !DisabledManagers[Name], so a manager is enabled unless an entry says otherwise. On a fresh machine the dictionary is empty, every manager counts as enabled, and each tool that is not installed ends up enabled with Status.Found == false. That shows an Error "Not found" badge in the settings pages and the TUI Managers page, so a new user gets a warning for every tool they never installed. Initialize() now records a default enabled state right after looking for the executable, but only when DisabledManagers has no entry for the manager. An explicit user choice always wins. A missing manager is recorded as disabled and gets the normal "is disabled" status; a found manager is recorded as enabled, so a later uninstall still shows "Not found" instead of silently disabling it. Because the default is written per manager, managers added in later versions are covered too. PEInterface initializes managers in parallel and SetDictionaryItem is a read-modify-write of the whole dictionary file, so the check-and-write runs under a static lock; without it, concurrent first-run writes lose each other's entries. The --fake-data sandbox seeds an explicit enabled entry for its fake not-found manager (Cargo) when none exists, so the fake data set and the TUI E2E tests keep the not-found state they exist to show. InitializeEnabledManagerWithoutExecutableStaysNotReady now sets an explicit enabled entry so it still tests an enabled manager whose executable is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Cold-cache reads can overwrite newly saved defaults during parallel initialization, and the concurrency test prewarms the cache.
1 open issue
What changed in this PR
This PR records default enabled states from executable detection, reducing first-run warnings for unavailable package managers while preserving explicit user choices.
Changes:
- Persist per-manager defaults and serialize default writes.
- Add regression tests for missing tools, remembered states, and concurrent initialization.
- Preserve the fake-data sandbox’s intentional not-found manager state.
| File | Description |
|---|---|
| src/UniGetUI.Tui/FakeData/FakeDataEnvironment.cs | Seeds enabled defaults for intentionally missing fake managers. |
| src/UniGetUI.PackageEngine.Tests/PackageManagerTests.cs | Tests default selection and preserves explicit-enable coverage. |
| src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/PackageManager.cs | Records detection-based defaults and stops initialization for auto-disabled managers. |
Review effort: Balanced
Settings._getDictionary read and deserialized the file on a cache miss, then published the result with a plain assignment. A SetDictionary that landed in between was replaced in the cache by the older snapshot, and the next SetDictionaryItem wrote that snapshot back to disk without the other writer's entry. With managers initializing in parallel on first run, one manager could load an empty DisabledManagers while another saved its default under the PackageManager lock, losing that default. A cold load now publishes through _publishLoadedDictionary, which uses GetOrAdd. If its own dictionary is the one stored, it returns its value as before; if a write or another load published first, it reads again through the cache-hit path and returns the newer value. The fix sits in the settings engine rather than in a cache warm-up before parallel initialization, so every cold reader (UI, IPC) is covered. SetDictionary still assigns directly, since a write always replaces the cache. The publish step is its own internal method so the race can be tested deterministically: ADictionaryLoadPublishedAfterAWriteKeepsTheWrite writes first and publishes a stale load second, the exact interleaving of the bug, with no threads or timing. It fails with the old plain assignment and passes with GetOrAdd. ADictionaryLoadIntoAColdCacheIsPublished covers the normal cold load. UniGetUI.Core.Settings gets the same InternalsVisibleTo.cs file the other core projects use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The changes preserve explicit settings, resolve the reported cache-publication race, and include focused regression coverage with no unresolved blocking findings.
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": "2a24d8a0-ce1c-11f1-9956-39fe7aec0a56",
"headSha": "8063f1ac069e990960e80f18f793276395b257ca",
"reviewer": "copilot-pull-request-reviewer[bot]"
}
Bug report
The maintainer reported this directly; there is no GitHub issue. The first time UniGetUI opens on a machine, every package manager is enabled by default, including the ones whose tool is not installed. So every new user starts with warnings. Managers should only be enabled when their prerequisite is present.
Root cause
PackageManager.IsEnabled()returns!Settings.GetDictionaryItem(K.DisabledManagers, Name), so a manager is enabled unless an entry says otherwise. On a fresh machineDisabledManagersis empty, so every manager counts as enabled.Initialize()then looks for each executable, and any tool that is not installed (Scoop, Chocolatey, Pip, Npm, Cargo, Vcpkg, ...) ends up enabled withStatus.Found == false. That logs "enabled but was not found" and shows an Error "Not found" badge in the Avalonia settings pages and the TUI Managers page. Nothing ever recorded a default enabled state from what was actually detected.Fix
PackageManager.Initialize()calls a new_recordDefaultEnabledState(found)right after_loadManagerExecutableFile. IfDisabledManagersalready has an entry for the manager, nothing changes: an explicit user choice (true or false) always wins. If there is no entry, it writesDisabledManagers[Name] = !foundonce.PEInterfaceinitializes all managers in parallel, andSettings.SetDictionaryItemis a read-modify-write of the whole dictionary file, so without the lock concurrent first-run writes lose each other's entries. With the lock removed, the concurrency test failed in 3 of 3 runs.FakeDataEnvironment.Activate: the--fake-datasandbox now seeds an explicit enabled entry for its fake not-found manager (Cargo,Found: false), but only when no entry exists, so a persisted--fake-data-dirkeeps the user's toggles. Without the seed, that manager would be auto-disabled and the fake data set (andManagers_DisableAManager_HidesItsPackages, which waits for "Cargo was not found") would lose the not-found state they exist to show.InitializeEnabledManagerWithoutExecutableStaysNotReadynow sets an explicit enabled entry, so it still tests what its name says instead of the first-run default.Re-enabling a manager after installing its tool still goes through the existing toggles (ManagersHomepage, PackageManagerPage, TUI ManagersPage, IPC), which write the entry explicitly. No new user-facing strings.
Verification
UniGetUI.PackageEngine.TestsandUniGetUI.Tui.Tests(with dependencies),-p:Platform=x64: 0 errors. The warnings were already there, in files this PR does not touch.dotnet format whitespace src --folder --verify-no-changesanddotnet format style src/UniGetUI.Windows.slnx --verify-no-changesboth exit 0.UniGetUI.PackageEngine.Tests2493/2493 passed.UniGetUI.Tui.Tests225 passed, 0 failed, 1 not executed (DumpPages, a diagnostic that is always skipped).PackageManager.csandFakeDataEnvironment.csstashed, thePackageManagerTestsfilter gave 18 total, 15 passed, 3 failed, and the 3 failures were exactly the new tests. With onlyFakeDataEnvironment.csstashed,Managers_DisableAManager_HidesItsPackagesfailed. With both restored, everything passes.Regression tests
UniGetUI.PackageEngine.Tests.PackageManagerTests.FirstInitializeDisablesManagerWhoseExecutableIsMissingUniGetUI.PackageEngine.Tests.PackageManagerTests.FirstInitializeKeepsFoundManagerEnabledAfterItsExecutableDisappearsUniGetUI.PackageEngine.Tests.PackageManagerTests.FirstInitializeDisablesOnlyMissingManagersWhenInitializedConcurrentlyAll three fail without the fix and pass with it.
Review notes (non-blocking)
PackageManagerPage.axaml.cs,PackageManagerViewModel.cs:143): on a first-run machine Scoop is now recorded as disabled. After installing it and restarting, the user also has to flip the enable toggle on the same page. Before this change, Scoop worked right after the restart. This is a small UX follow-up (the install command could clear the entry), not a regression of the reported case.SetDictionaryItem's read-modify-write can still race with a UI or IPC toggle at the same moment. That race already existed and this change does not widen it.MissingDependencyDialogonly looks atIsReady()managers. Telemetry andInstalledPackagesLoaderfilter onIsEnabled() && Found.BaseNuGet.Initializecallsbase.Initialize, so NuGet managers go through the new path. Other tests that initialize real managers use an isolated data directory override.🤖 Generated with Claude Code