Files
kitzy 43bff98998 Add Gpg4win as a Windows FMA (#50026)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** #50020

# What this does

Adds **Gpg4win** 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

Same root cause as GNU Privacy Guard (#50025) — Gpg4win bundles GnuPG.
The install worked; the *script* never returned:

```
20:30:53  INFO  msg="Executing install script..." app=Gpg4win
20:40:53  ERROR msg="Error executing install script: exit status 1"   # exactly 10:00 later
20:40:53  INFO  msg="New application detected at: C:\Program Files\Gpg4win"
```

Ten minutes on the nose is the validator's `executeScript` timeout.
**`Start-Process -Wait` waits for the process *and all of its
descendants***, and Gpg4win leaves `gpg-agent`, `dirmngr`, `keyboxd` and
`scdaemon` resident (plus Kleopatra), so `-Wait` never returns. The same
run left `gpg4win-5.0.2.exe` locked in the validator's temp dir,
confirming a live child process.

The install script now follows the pattern already established by
[`ollama_install.ps1`](ee/maintained-apps/inputs/winget/scripts/ollama_install.ps1):
start with `-PassThru` (no `-Wait`), wait on the installer process alone
with a 7-minute cap (below the caller's 10-minute script budget), poll
for the Add/Remove Programs entry, then stop the leftovers.

The uninstall script stops those processes up front (they hold file
locks that make the uninstall fail), uses NSIS's `_?=<dir>` switch so
the uninstaller runs in place rather than relaunching itself detached
from `%TEMP%`, and polls the ARP key to confirm removal.

## Notes

- **Versioned ARP name.** The registry `DisplayName` is `Gpg4win
(5.0.2)`, so the input uses `fuzzy_match_name` and the exists query is
`name LIKE 'Gpg4win %'`. The uninstall script matches the same prefix.
- x86-only installer. Publisher `The Gpg4win Project`.
- 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
30384125610](https://github.com/fleetdm/fleet/actions/runs/30384125610)
(`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 Gpg4win as a supported Windows application.
  - Added Gpg4win version 5.0.2 with Security categorization.
  - Added a Gpg4win icon to the software interface.
- Introduced silent install and uninstall support with process cleanup,
timeouts, and registry-based verification to confirm install/removal
outcomes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 09:57:32 -05:00

102 lines
3.8 KiB
PowerShell

# Learn more about .exe install scripts:
# http://fleetdm.com/learn-more-about/exe-install-scripts
$exeFilePath = "${env:INSTALLER_PATH}"
# The installer stalls on a modal dialog with no interactive desktop and never
# exits. Closing the window lets it run through to the section that writes the
# Add/Remove Programs entry; killing it instead would leave a partial install.
# The dialog belongs to a child process, so search the whole tree.
$leftovers = @("gpg-agent", "dirmngr", "keyboxd", "scdaemon", "gpg-connect-agent", "gpgconf", "kleopatra", "gpgme-w32spawn")
$installTimeoutSeconds = 420
$pollSeconds = 10
$graceSeconds = 30
$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
# Uninstall info is written with SHCTX, so it can land per-user.
$userKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
# The installer process plus its descendants.
function Get-InstallerTree([int]$rootId) {
$all = @{}
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
ForEach-Object { $all[[int]$_.ProcessId] = [int]$_.ParentProcessId }
$ids = New-Object System.Collections.Generic.HashSet[int]
$null = $ids.Add($rootId)
for ($depth = 0; $depth -lt 5; $depth++) {
foreach ($procId in @($all.Keys)) {
if ($ids.Contains($all[$procId])) { $null = $ids.Add($procId) }
}
}
Get-Process -ErrorAction SilentlyContinue | Where-Object { $ids.Contains($_.Id) }
}
function Test-Gpg4winRegistered {
$null -ne (Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey) -ErrorAction SilentlyContinue |
ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
Where-Object { $_.DisplayName -like "Gpg4win*" } |
Select-Object -First 1)
}
try {
$process = Start-Process -FilePath "$exeFilePath" -ArgumentList "/S" -PassThru
# Keeps .ExitCode readable after the process ends.
$null = $process.Handle
$elapsed = 0
while (-not $process.HasExited -and ($elapsed -lt $installTimeoutSeconds)) {
Start-Sleep -Seconds $pollSeconds
$elapsed += $pollSeconds
$process.Refresh()
if ($process.HasExited) { break }
$tree = Get-InstallerTree $process.Id
$names = @($tree | Select-Object -ExpandProperty ProcessName -Unique)
$windowTitle = ""
try { $windowTitle = $process.MainWindowTitle } catch { }
$childWindows = @($tree | Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } |
ForEach-Object { "$($_.ProcessName): '$($_.MainWindowTitle)'" })
Write-Host "Installing... ($elapsed seconds, registered: $(Test-Gpg4winRegistered), window: '$windowTitle', tree: $($names -join ', '), child windows: $($childWindows -join ' | '))"
if ($elapsed -ge $graceSeconds) {
foreach ($p in $tree) {
if ($p.MainWindowHandle -ne [IntPtr]::Zero) {
Write-Host "Closing window owned by $($p.ProcessName) ('$($p.MainWindowTitle)') so the install can continue."
$null = $p.CloseMainWindow()
}
}
}
}
if (-not $process.HasExited) {
Write-Host "Installer still running after ${installTimeoutSeconds}s; stopping it."
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
} else {
Write-Host "Install exit code: $($process.ExitCode)"
}
# Stop resident processes; they hold file locks the uninstall needs released.
foreach ($name in $leftovers) {
Stop-Process -Name $name -Force -ErrorAction SilentlyContinue
}
# Registration is the success signal: a killed installer's exit code says nothing.
if (-not (Test-Gpg4winRegistered)) {
Write-Host "Gpg4win did not register in Add/Remove Programs."
Exit 1
}
Write-Host "Gpg4win is registered in Add/Remove Programs."
Exit 0
} catch {
Write-Host "Error: $_"
Exit 1
}