Files
Allen Houchins 89acb97395 Add NVDA as a Windows Fleet-maintained app (#50450)
**Related issue:** Resolves #50125

Adds NVDA as a Windows Fleet-maintained app, from winget `NVAccess.NVDA`
(2026.1.1, NSIS/nullsoft, x86 launcher).

## Identity — read out of the shipped installer, not the manifest

I downloaded the 60 MB installer, extracted the NSIS payload, and read
the identity fields from `_buildVersion.pyc` and the PE headers. The
winget manifest is misleading in two ways:

| Field | winget says | Actually is | Source |
|---|---|---|---|
| Architecture | `x86` | **x64** app behind a 32-bit NSIS launcher stub
| `nvda_noUIAccess.exe` / `nvda_slave.exe` PE headers |
| Registry DisplayName | PackageName `NVDA` | **`NVDA 2026.1.1`** |
`source/installer.py` `getUninstallerRegInfo()`: `DisplayName=f"{name}
{version}"` |
| Publisher | `NV Access` | `NV Access` (matches) | `_buildVersion.pyc`:
`publisher = "NV Access"` |

Two consequences:

- Because NVDA itself is a **64-bit** process, it registers under the
native registry view, **not** `Wow6432Node` (the launcher's 32-bit-ness
is irrelevant). Both scripts check both views anyway, for legacy 32-bit
copies.
- DisplayName carries the version, so this needs `fuzzy_match_name:
true` → `name LIKE 'NVDA %'`. Publisher matches the locale manifest, so
no `program_publisher` override.

`installer_arch` stays `x86` because that's what the manifest declares
and the ingester matches on it.

## Version reconciles without a validator exception

DisplayVersion is the 4-part `2026.1.1.55980` (`version_detailed`)
against winget's `2026.1.1`:

- **Validator:** passes via the existing
`strings.HasPrefix(result.Version, appVersion+".")` branch in
`cmd/maintained-apps/validate/windows.go`. No new skip added —
deliberately, since existence-only skips make patch policies always
report "patched".
- **Patch policy:** `version_compare('2026.1.1.55980', '2026.1.1')` is
`> 0`, so an installed copy reads as newer, not outdated. No perpetual
false "update available".

## The install script can't trust the exit code

`source/gui/installerGui.py` `doInstall()` pops `winUser.MessageBox` /
`gui.messageBox` on **every** install failure path with **no `if silent`
guard**, and then falls through and exits **0**. Under SYSTEM in session
0 that means:

1. a failure **hangs forever** — nobody can click Retry/Cancel; and
2. if it were dismissed, a failed install would report **success**.

So `nvda_install.ps1` uses a watchdog plus an Add/Remove Programs
registration poll as the real success signal — the same shape as the
existing `azure_data_studio_install.ps1`. Timeouts are 420 + 120 + 30 =
570s, under the caller's 10-minute cap.

On timeout it kills only the launcher's `%TEMP%` children
(`nvda_noUIAccess` / `nvda_uiAccess`), **deliberately not `nvda.exe`** —
an installed NVDA runs as `nvda.exe`, and force-killing it would cut off
a signed-in user's screen reader with no warning.

## Uninstall

Vendor-documented `/S` (NVDA user guide, "Uninstalling NVDA"), plus
`_?=` last so the NSIS uninstaller runs in place instead of relaunching
from `%TEMP%` and returning immediately. NVDA writes **no**
`QuietUninstallString`, and its `UninstallString` is an **unquoted path
containing spaces** (`C:\Program Files\NVDA\uninstall.exe`), so the
parser handles that form. The directory comes from NVDA's `InstallDir`
value (not `InstallLocation`). Absence of the ARP entry is the success
signal, since NVDA removes it via `nvda_slave.exe unregisterInstall`.

## Reviewer notes

- **`installer_scope` is `""`, not `"machine"`.** NVDA genuinely
installs machine-wide (`%ProgramFiles%\NVDA` + HKLM), but the winget
manifest declares no `Scope`, so the ingester derives `""` and
`"machine"` panics with "failed to find installer". The one-line
ingester fix for this is designed in #48248 but isn't in `main`; I chose
not to change shared installer-selection code for a single-app addition.
Happy to land that fix here instead if preferred.
- **Upgrade caveat:** if NVDA is running for a signed-in user,
`--install-silent` refuses to overwrite its own running files by design
(`installer.py` `install()`). The script fails with an actionable
message rather than force-killing the screen reader.
- Installer URL is version-pinned
(`download.nvaccess.org/releases/2026.1.1/...`), not a "latest"
redirect. SHA verified against my own download of the file.

# 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

- [ ] QA'd all new/changed functionality manually

`go test ./ee/maintained-apps/...` passes; prettier and `tsc --noEmit`
are clean. I have no Windows host or `pwsh`, so **the install/uninstall
scripts are unexercised** until FMA validation CI runs them on a Windows
runner. No changes file — consistent with other FMA additions (#50415,
#50348, #50352).
2026-08-03 12:40:44 -05:00

87 lines
2.9 KiB
PowerShell

# Learn more about .exe install scripts:
# http://fleetdm.com/learn-more-about/exe-install-scripts
$exeFilePath = "${env:INSTALLER_PATH}"
$installTimeoutSeconds = 420
$registrationTimeoutSeconds = 120
$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
# NVDA writes DisplayName as "NVDA <version>".
function Test-NvdaRegistered {
$null -ne (Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |
ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
Where-Object {
$_.DisplayName -and
($_.DisplayName -eq 'NVDA' -or $_.DisplayName -like 'NVDA *') -and
$_.Publisher -like '*NV Access*'
} |
Select-Object -First 1)
}
try {
if (-not (Test-Path $exeFilePath)) {
Write-Host "Error: Installer file not found at: $exeFilePath"
Exit 1
}
$process = Start-Process -FilePath "$exeFilePath" `
-ArgumentList "--install-silent" `
-PassThru
# Keeps .ExitCode readable after the process ends.
$null = $process.Handle
# NVDA shows a modal "File in Use" box on failure even when silent, which would
# hang forever as SYSTEM.
$killed = $false
if (-not $process.WaitForExit($installTimeoutSeconds * 1000)) {
Write-Host "Installer did not exit within ${installTimeoutSeconds}s, stopping it."
Write-Host "NVDA is likely running in another session and the installer is blocked on a dialog."
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
# Only the copies the launcher runs from %TEMP%. An installed NVDA runs as
# nvda.exe; killing that would cut off a signed-in user's screen reader.
foreach ($name in @('nvda_noUIAccess', 'nvda_uiAccess')) {
Stop-Process -Name $name -Force -ErrorAction SilentlyContinue
}
# Reading .ExitCode while the process is alive would throw.
$null = $process.WaitForExit(30 * 1000)
$killed = $true
}
$exitCode = $null
if ($process.HasExited) {
$exitCode = $process.ExitCode
Write-Host "Install exit code: $exitCode"
} else {
Write-Host "Installer could not be stopped; falling back to the registration check."
}
$elapsed = 0
while (-not (Test-NvdaRegistered) -and ($elapsed -lt $registrationTimeoutSeconds)) {
Start-Sleep -Seconds 5
$elapsed += 5
Write-Host "Waiting for NVDA to register... ($elapsed seconds)"
}
# NVDA exits 0 even when the install failed, so registration is the real signal.
if (-not (Test-NvdaRegistered)) {
Write-Host "NVDA did not register in Add/Remove Programs."
Write-Host "If NVDA was already running for a signed-in user, exit it and retry."
Exit 1
}
if ($killed -or $null -eq $exitCode) { Exit 0 }
# 3010 (reboot required) and 1641 (reboot initiated) are successful installs.
if ($exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }
Exit $exitCode
} catch {
Write-Host "Error: $_"
Exit 1
}