Skip to content

fix: give each Uninstaller row the uninstall entry that is its own - #3230

Merged
laurentiu021 merged 1 commit into
mainfrom
fix/uninstaller-exact-entry
Nov 10, 2026
Merged

laurentiu021 merged 1 commit into
mainfrom
fix/uninstaller-exact-entry

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Three places in the Uninstaller worked out which uninstall entry, or which command, belongs to an app from less than the entry itself.

What changes

  • Each row takes its own entry. EnrichFromRegistry matched the rows winget lists to Windows' uninstall entries by name alone, with TryAdd, so the first row of a name took the first entry of that name and a second row of the name took nothing. Of two programs with one name, a 32-bit and a 64-bit build, or a copy for one user beside one for everyone, one row ran the other copy's uninstaller, IsStillRegistered then checked that command and reported the row removed, and the other row had no command. The entries are now read once, and a row with an ARP\Machine\X64\<key>, ARP\Machine\X86\<key> or ARP\User\…\<key> id takes the entry the id names. winget gives that id to every app it found only in the registry, which are the rows removed with the entry's own command. Any other row is matched by name, entries Windows shows first, then by version, and when the entries left still run different commands or name different folders the row takes neither from them. Size, publisher and icon are display only and keep coming from the first entry of the name that has one.
  • The MSI removal switch follows the program that runs. /I was turned into /X inside ParseUninstallCommand, in the branch for a command that starts with MsiExec, so "C:\Windows\System32\MsiExec.exe" /I{…} and C:\Windows\System32\msiexec.exe /I{…} kept /I and were refused as not a plain removal while the bare spelling of the same command ran. The rewrite now happens in UninstallLocalAsync once the program is known to be MsiExec, before the arguments are validated, so all three spellings run the same removal.
  • The leftover search finds where another app lives without the launch's refusals. ParseUninstallCommand is now the launch rules (RefuseChaining) over SplitUninstallCommand, which only splits. LeftoverFinder.UninstallerFolder uses the split, so a command the Uninstaller would not run, an unquoted path with & in it say, still says where that app's uninstaller is; before, the refusal read as "lives nowhere" and a folder around that uninstaller could be offered as the removed app's install folder, ticked. SharedWith also refuses a folder that another installed app's uninstall command names anywhere in its text, followed by a separator, a quote or the end, which covers the DLL a rundll32 command loads and a command no rule splits into a program.

How it was checked

Each new test failed with its rule taken out:

  • With the ARP\ id ignored, ARowWithAnArpId_TakesTheEntryItNames_AndAnotherRowOfTheNameTakesItsOwn, AUsersArpId_IsLookedUpInTheUsersList and AnArpIdNamingNoEntry_TakesNothingFromAnotherEntryOfItsName failed. Taking commands from entries that differ failed TwoProgramsOfOneName_GiveARowWithoutAnArpId_NeitherCommandNorFolder; ignoring the version failed AmongEntriesOfOneName_TheRowsVersionPicksItsOwn. TheListsAreReadInWindowsOrder_SixtyFourBitThenThirtyTwoBitThenTheUsers now gives its three entries one command, since entries of one name that run different commands are exactly what no longer hands one over.
  • Without the rewrite at launch, all three rows of UninstallLocalAsync_AnMsiInstallSwitch_IsARemoval_HoweverMsiExecIsWritten failed. ParseUninstallCommand_MsiExec_SplitsOffItsArgumentsAsWritten replaces the test that pinned the rewrite in the parse.
  • With the split refusing & the way the launch does, AnApp_StillLivesAroundItsUninstaller_WhenSysManagerWouldNotRunItsCommand and both rows of SplitUninstallCommand_FindsTheProgram_OfACommandTheLaunchRefuses failed. Without the search through the command's text, both rows of AnApp_LivesInAFolderItsUninstallCommandNames_ThoughNotAsTheProgramItRuns failed, and without the boundary after the name, ACommandNamingAFolderWhoseNameStartsTheSame_DoesNotClaimThisOne did.
  • Unit suite twice, the format check, and all four projects built with no warnings.

The Uninstaller matched the rows winget lists to Windows' uninstall
entries by name alone, the first row of a name taking the first entry
of that name. Of two programs with one name, a 32-bit and a 64-bit
build say, one row ran the other copy's uninstaller and the other row
had no command. A row with an ARP id now takes the entry the id names,
by its list and key; winget gives that id to every app it found only
in the registry, the rows removed with the entry's own command. Any
other row is matched by name and, among several entries of the name,
by version, and takes no command and no folder when they still differ.

The /I to /X rewrite of a Windows Installer command was made in the
parse, for the bare MsiExec spelling only, so the same command quoted
or as a full path was refused as no plain removal. It is made at
launch now, for the program that runs.

The leftover search took the folder of another installed app's
uninstaller as one more place that app lives, through the parse that
refuses a command at launch, so a command it would not run read as
"lives nowhere" and a folder around that uninstaller could be offered
as the removed app's install folder, ticked. The search now splits the
command without the launch's refusals, and also refuses a folder that
another app's uninstall command names anywhere in its text, which
covers the DLL a rundll32 command loads and a command no rule splits.
@laurentiu021
laurentiu021 merged commit e1f4c15 into main Nov 10, 2026
7 checks passed
@laurentiu021
laurentiu021 deleted the fix/uninstaller-exact-entry branch November 10, 2026 08:51
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.

1 participant