Files
fleet/ee/maintained-apps/inputs/winget/scripts/logitech_unifying_software_uninstall.ps1
kitzy c61632305a Add Logitech Unifying Software as a Windows FMA (#50024)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** #50020

# What this does

Adds **Logitech Unifying Software** as a Windows Fleet-maintained app.
One of the 11 apps split out of #48501 that failed the FMA validator;
#50016 shipped the 6 that passed.

## Why it was failing

Install and detection were already fine on the SYSTEM-context Windows
runner — osquery found `Logitech Unifying Software 2.52` at `C:\Program
Files\Common Files\LogiShrd\Unifying`. **Uninstall** was the failure:

```
20:40:55  INFO  msg="Executing uninstall script for app..."
20:40:57  INFO  msg="Found app: 'Logitech Unifying Software 2.52' ... Version: 2.52.33"
20:40:57  ERROR msg="App still present after uninstall (expected no match for version '2.52.33' in programs)"
```

Two seconds start to finish — the uninstaller hadn't actually done
anything yet. This is standard NSIS behavior: the uninstaller copies
itself to `%TEMP%` and relaunches, so the process the script starts
exits almost immediately while the real work happens in a detached
child.

The fix passes NSIS's `_?=<dir>` switch, which runs the uninstaller in
place instead of relaunching, making it synchronous. It has to be the
last argument and unquoted, so the script builds a single argument
string rather than an array (PowerShell would quote an element
containing spaces). A bounded poll on the ARP key follows as a backstop,
and the script fails explicitly if the entry is still there.

## Notes

- **Versioned ARP name.** The registry `DisplayName` is `Logitech
Unifying Software 2.52`, so the input uses `fuzzy_match_name` and the
exists query is `name LIKE 'Logitech Unifying Software %'`. The
uninstall script matches the same prefix rather than an exact string.
- Publisher `Logitech` confirmed against the winget locale manifest.
- Installs under `C:\Program Files\Common Files`, so the validator's "no
changes detected in `C:\Program Files`" line is an expected warning, not
a failure.
- Ships a new catalog icon and website asset.

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] FMA CI validator (install → detect → uninstall) **passes** on the
SYSTEM-context Windows runner — [run
30384010810](https://github.com/fleetdm/fleet/actions/runs/30384010810)
(`All checks passed`)
- [x] Generated output verified locally: manifest SHA matches the winget
manifest, exists/patched queries reviewed for name + publisher
correctness, `apps.json` is valid JSON with a description filled in.
- [x] QA'd all new/changed functionality manually




<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Logitech Unifying Software to the Windows software catalog,
including the version 2.52.33 download, checksum, and install-detection
metadata.
* Implemented silent installation and a robust, registry-aware uninstall
flow (with process lock handling and timeout behavior).
* Added a dedicated Logitech Unifying Software icon to the software page
UI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-28 22:59:09 -05:00

99 lines
4.0 KiB
PowerShell

# The ARP DisplayName carries a version suffix ("Logitech Unifying Software 2.52"),
# so match on a prefix, plus the publisher to avoid other products sharing it.
$softwareName = "Logitech Unifying Software"
$softwareNameLike = "$softwareName*"
$softwarePublisher = "Logitech"
$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
$exitCode = 0
$timeoutSeconds = 300
# Logs matching entries and their publishers to diagnose a name/publisher miss.
function Write-UnifyingCandidates {
Write-Host "Registry entries matching '$softwareNameLike':"
$found = Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |
ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
Where-Object { $_.DisplayName -like $softwareNameLike }
if (-not $found) { Write-Host " (none)" ; return }
foreach ($f in $found) {
Write-Host " DisplayName='$($f.DisplayName)' Publisher='$($f.Publisher)' Version='$($f.DisplayVersion)'"
}
}
function Get-UnifyingUninstallKey {
Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |
ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
Where-Object { $_.DisplayName -like $softwareNameLike -and $_.Publisher -eq $softwarePublisher } |
Select-Object -First 1
}
# Stop leftovers: they hold file locks, and -Wait would block on them.
foreach ($name in @("LogiUnify", "Unifying", "UnifyingUnInstaller", "DJCUHost")) {
Stop-Process -Name $name -Force -ErrorAction SilentlyContinue
}
try {
$key = Get-UnifyingUninstallKey
if (-not $key) {
Write-UnifyingCandidates
Write-Host "Uninstall entry not found for '$softwareName' with publisher '$softwarePublisher'."
Exit 0
}
$uninstallString = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
Write-Host "Uninstall string: $uninstallString"
# Handles quoted paths, unquoted paths with spaces, and bare tokens.
$uninstallCommand = $uninstallString
$existingArgs = ""
if ($uninstallCommand -match '^\s*"([^"]+)"\s*(.*)$') {
$uninstallCommand = $Matches[1]; $existingArgs = $Matches[2]
} elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)\s*(.*)$') {
$uninstallCommand = $Matches[1]; $existingArgs = $Matches[2]
} elseif ($uninstallCommand -match '^\s*(\S+)\s*(.*)$') {
$uninstallCommand = $Matches[1]; $existingArgs = $Matches[2]
}
# This vendor NSIS uninstaller rejects the in-place "_?=<dir>" switch with
# exit code 10, so use plain /S and poll for removal at the end instead.
# Keep any registry arguments rather than dropping them.
$uninstallArgs = ("$existingArgs /S").Trim()
Write-Host "Uninstall command: $uninstallCommand"
Write-Host "Uninstall args: $uninstallArgs"
# No -NoNewWindow: a leftover child would hold this script's pipes open.
$process = Start-Process -FilePath $uninstallCommand -ArgumentList $uninstallArgs -PassThru
# Keeps .ExitCode readable after the process ends.
$null = $process.Handle
if (-not $process.WaitForExit($timeoutSeconds * 1000)) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
Write-Host "Uninstall timed out after $timeoutSeconds seconds"
Exit 1603
}
$exitCode = $process.ExitCode
Write-Host "Uninstall exit code: $exitCode"
} catch {
Write-Host "Error: $_"
Exit 1
}
# The uninstaller hands off, so wait for the ARP entry to disappear.
$elapsed = 0
while ((Get-UnifyingUninstallKey) -and ($elapsed -lt 240)) {
Start-Sleep -Seconds 5
$elapsed += 5
Write-Host "Waiting for the uninstall to finish... ($elapsed seconds)"
}
if (Get-UnifyingUninstallKey) {
Write-UnifyingCandidates
Write-Host "'$softwareName' is still registered after the uninstall."
Exit 1
}
Exit $exitCode