How we built fleet-wide software uninstall (registry vs winget, running uninstallers as SYSTEM)
One of the more satisfying features we shipped recently: search your entire fleet’s installed software, pick an app, and queue an uninstall across every machine that has it - no SCCM, no logon scripts, no RDP. From a browser tab.
The concept sounds straightforward. The implementation has enough edge cases that it’s worth writing up.
The UX: search, select, uninstall
The RFF inventory page aggregates software across all enrolled devices. Type “Acrobat” and see which version is on which machines. Click Uninstall, confirm, and one job per machine gets queued. Each job shows up in the job list with live output.
On the per-device view, there’s an Uninstall button next to each software entry - same mechanism, single machine.
The important design choice: we pass only the software name to the agent. We don’t ship the uninstall command over the network. The agent resolves the right command on the box. This matters for a few reasons we’ll get to.
What the agent does with a package name
When a job with Action = Uninstall arrives, the agent skips the normal file-staging path and goes straight to GetUninstallInfo(packageName). This reads the Add/Remove Programs registry keys - the same two paths Windows itself uses:
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall
HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall
Every installed application (MSI or custom installer) has a subkey here with DisplayName, UninstallString, and often QuietUninstallString.
The lookup logic has two levels of matching:
var nd = Norm(displayName);
bool isExact = nd == normPkg;
if (!isExact && !TokenMatch(nd, normPkg)) continue;
if (isExact) return parsed; // exact match - stop here
fallback ??= parsed; // first loose match - keep as fallback
Exact match wins immediately. Loose match (all tokens from the shorter name appear in the longer one) is kept as a fallback and returned only if nothing exact is found.
Why exact match matters: “Microsoft Edge” and “Microsoft Edge WebView2 Runtime” both live in the registry. A token match on “Microsoft Edge” would hit both. Exact match means “Microsoft Edge” → exactly that, not the WebView2 runtime.
Normalization strips parentheses and brackets ((x64), [version]) and lowercases before comparing, so “Mozilla Firefox (x64)” matches “Mozilla Firefox” as an exact match after normalization.
QuietUninstallString: the hang you didn’t see coming
Here’s the edge case that bit us first.
Most uninstallers are interactive. Run UninstallString without arguments and you get a GUI: “Are you sure you want to uninstall?” - with buttons. The agent is a Windows service running as LocalSystem in session 0. Session 0 has no interactive desktop. The GUI launches, renders to nothing, and waits forever for a button click that never comes.
The registry has a second field, QuietUninstallString, specifically for this:
QuietUninstallString: "C:\Program Files\App\unins000.exe" /SILENT
We always prefer QuietUninstallString over UninstallString when it exists:
var uninstallString =
sub.GetValue("QuietUninstallString") as string
?? sub.GetValue("UninstallString") as string;
For MSI installs, we extract the product GUID and build msiexec /x {GUID} /qn /norestart - /qn is MSI’s silent flag. For custom installers with no QuietUninstallString, we run the bare UninstallString and rely on it being non-interactive (many modern installers are). If it hangs, the job times out at 600 seconds and kills the process tree.
Figuring out the right silent switch for a given installer is its own little rabbit hole, separate from fleet uninstall - some want /S, some /silent, some /qn, some a vendor one-off nobody documents. We split that part into a free standalone tool: SwitchHunt reads an installer’s silent switches right in the browser, nothing uploaded. If you just need the quiet flag for one installer, start there.
Why we run uninstallers as processes, not PowerShell
Our first draft drove the uninstall via PowerShell. It looked clean and it broke immediately.
The problem: UninstallString is a Windows command string, not a PowerShell expression. It looks like:
"C:\Program Files (x86)\App\uninst.exe" /SILENT /LOG="C:\Temp\uninstall.log"
That’s fine in a CMD shell or CreateProcess. In PowerShell, the quoting is different, & vs . vs Start-Process all behave differently with embedded quotes, and Invoke-Expression is fragile with arbitrary strings. We hit a class of silent failures where the uninstaller ran without its flags (because the quotes were eaten) and opened an interactive dialog.
The fix was to run uninstallers as direct processes via ProcessStartInfo:
var psi = new ProcessStartInfo(exe, args)
{
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
UseShellExecute = false lets us redirect stdout/stderr for live log streaming to the job output. CreateNoWindow + WindowStyle.Hidden suppress any UI. We parse the UninstallString into exe and arguments ourselves using a simple quoted-path split - no shell involved, no quoting ambiguity.
The grandchild hang
There’s a second process execution trap, specific to WaitForExit().
Some uninstallers spawn child processes and exit immediately. msiexec does this - it launches a subprocess and returns from the parent. If those child processes inherit the redirected stdout handle (and they do, unless you explicitly close it), WaitForExit() waits for all processes holding the stdout handle to exit, not just the one you started. A grandchild that outlives the parent can hold the agent in WaitForExit() indefinitely.
Our fix is a bounded wait:
bool exited = process.WaitForExit(timeoutSeconds * 1000);
if (!exited)
{
process.Kill(entireProcessTree: true);
process.WaitForExit(5000); // give cleanup handlers a moment
throw new TimeoutException(...);
}
process.WaitForExit(5000); // drain any buffered output
The second WaitForExit(5000) after the process exits is the key line. It gives grandchild processes up to 5 more seconds to flush any buffered output to the redirected pipes and exit naturally. After 5 seconds we move on regardless. The job ends within timeoutSeconds + 5 no matter what the uninstaller’s process tree does.
The winget path
For software that was deployed via the RFF catalog (winget packages), we use winget uninstall instead of the registry:
winget uninstall --id Publisher.Product --exact --silent --accept-source-agreements
Winget has a well-known SYSTEM problem. It’s an MSIX package, and when a user runs winget, Windows activates the package and adds the framework DLLs (VCLibs, UI.Xaml) to the process’s DLL search path automatically. When SYSTEM runs the raw winget.exe by full path - bypassing package activation - those DLLs aren’t found. Exit code is 0xC0000135 (STATUS_DLL_NOT_FOUND), which manifests as the process disappearing immediately with no output.
The fix is to find the framework packages in C:\Program Files\WindowsApps and prepend them to the child process’s PATH before launching winget:
var dirs = new List<string>();
var root = Path.Combine(Environment.GetFolderPath(
Environment.SpecialFolder.ProgramFiles), "WindowsApps");
foreach (var pattern in new[]
{
"Microsoft.VCLibs.140.00.UWPDesktop_*_x64__8wekyb3d8bbwe",
"Microsoft.UI.Xaml.*_x64__8wekyb3d8bbwe",
})
{
var matches = Directory.GetDirectories(root, pattern);
Array.Sort(matches, StringComparer.OrdinalIgnoreCase);
dirs.Add(matches[^1]); // latest version
}
If winget uninstall fails anyway, we fall back to the registry path. Most winget-installed apps still have ARP registry entries, so the fallback usually works.
Why we don’t ship the command over the network
We touched on this earlier, but it’s worth being explicit. The job payload contains only packageName - the display name as shown in the UI. No uninstall command, no path, no flags.
The alternative would be to capture the full UninstallString at inventory time and ship it as part of the uninstall job. We do capture it (it’s in the inventory JSON), and the fleet-wide view uses it to decide whether to show the Uninstall button at all (no UninstallString → no button). But we don’t ship it as the command.
The reason: uninstall commands are machine-specific paths. "C:\Program Files (x86)\App v2.1.3\unins000.exe" /SILENT is correct on the machine where inventory was collected. On a machine where the user reinstalled the app to a different path, or where a different version is installed, the path in inventory is stale. Resolving on the box at execution time always uses the current registry state.
There’s also a subtler point: shipping the exact command adds a command injection surface at the API layer. Passing a display name and resolving it locally keeps the API boundary narrow - the agent decides how to uninstall based on what it finds on the box, and the server isn’t in the business of constructing shell commands.
What the output looks like
The uninstall job streams live output from the uninstaller process. For an MSI uninstall it looks like:
[12:34:01] Running: msiexec /x {A1B2C3D4-...} /qn /norestart
[12:34:01] Exit code: 0 (Success)
For a custom installer:
[12:34:01] Running: "C:\Program Files\App\unins000.exe" /SILENT
[12:34:01] Uninstalling...
[12:34:04] Exit code: 0
Exit code 3010 (reboot required) is treated as success. The job result says “Success (reboot required).”
If nothing is found in the registry for the given name, the job fails immediately with a clear message:
No uninstall entry found for 'SomeApp'. The software may have already been removed,
or the display name may not match the registry entry exactly.
Bulk uninstall across the fleet
The fleet inventory page fires one job per machine in parallel:
const calls = hostnames.map(h =>
createJob({
action: 'Uninstall',
packageName: app.name,
targetHostnames: [h],
execContext: 'System',
timeoutSeconds: 600,
})
)
await Promise.all(calls)
Each job runs independently. If it fails on two machines (maybe those have a different app version with a different registry entry), the others still succeed. The job list shows each one individually so you can see exactly which machines worked and which didn’t.
(Getting the agent onto a fresh box in the first place - the agentless bootstrap that makes all of this possible - is its own performance story: we cut it from 183s to 26s by holding one SMB session instead of shelling out to sc.exe six times.)
The things that still bite occasionally
Side-by-side installs: “Python 3.11.4 (64-bit)” and “Python 3.12.1 (64-bit)” are separate registry entries. If you search for “Python” and click Uninstall, the exact-match logic will miss both (neither is exactly “Python”) and fall back to the first token match. Better to be specific.
App names that don’t match display names: Some software uses different names in the product installer than in winget. “Microsoft Office” in winget might be “Microsoft 365 Apps for enterprise” in the registry. These need a two-step: check inventory first to confirm the display name, then uninstall by that name.
Restart-required uninstallers: Some installers refuse to run if a previous operation left a pending restart flag (HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations). Nothing we can do about that one from outside the machine; the job fails and tells you to reboot first.
RFF is a Windows fleet management tool - patch, deploy, audit, remote desktop, all from a browser tab, no VPN. getrff.com