**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).
87 lines
2.9 KiB
PowerShell
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
|
|
}
|