Files
kitzyandAllen Houchins da1947cd1f Add Paint.NET as a Windows Fleet-maintained app (#50340)
**Related issue:** Resolves #50330

Adds Paint.NET as a Windows Fleet-maintained app, from winget
`dotPDN.PaintDotNet` (5.1.12, machine scope, x64). Found in a customer's
ManageEngine ServiceDesk Plus Windows deployment catalog with no Fleet
equivalent.

## Identity: winget's metadata is wrong here

The winget locale manifest gives `PackageName: paint.net` (lowercase).
The actual registry `DisplayName` is **`Paint.NET`**, read straight out
of the MSI Property table:

```
ProductName    Paint.NET
Manufacturer   dotPDN LLC
UpgradeCode    {04A40F40-A207-4B48-AED7-6AA532E43275}
ALLUSERS       2
```

There is no `ARPDISPLAYNAME` override and no `ARPSYSTEMCOMPONENT`, so
`ProductName` is what lands in Add/Remove Programs. Taking the winget
name at face value would have produced an exists query that silently
never matches. `ALLUSERS=2` confirms it installs per-machine when run
elevated, which is how Fleet runs it.

## This is a zip-wrapped installer

Paint.NET publishes **only** `.zip` assets — there is no bare `.exe` or
`.msi` on the vendor's GitHub releases. So this uses `installer_type:
zip` with custom scripts, following the existing precedent of
`agent-ransack`, `adobe-acrobat-pro`, `vnc-server`, and `vnc-viewer`.
The install script extracts the archive and runs the nested installer
with `/auto`, the vendor's silent switch per the manifest's
`InstallerSwitches`.

**Uninstall resolves the product from the UpgradeCode, not the
ProductCode.** Paint.NET's ProductCode changes with every release, and
the `.exe` and `.msi` variants register *different* ProductCodes. The
UpgradeCode is stable — I verified it is identical across 5.1.10 and
5.1.12 — so `RelatedProducts` on it removes whichever variant is
present.

## One thing reviewers may want to change

The manifest offers six installers; three are x64/machine/zip and differ
only by `NestedInstallerType` (`exe`, `wix`, `portable`). The ingester's
selection loop takes the **first** match and breaks, so it picks the
`.install.x64.exe` bootstrapper. The `.winmsi.x64.zip` variant is
arguably the better FMA target — a plain MSI with predictable ARP
behaviour — but there is no way to express "prefer this nested type" in
the input today. Selecting it would need an ingester change, so I did
not do it here. Worth a follow-up if we hit trouble with the
bootstrapper.

## Verification

- Zip SHA confirmed against a local download (`3cd861b5…c867`); archive
contains exactly one file, `paint.net.5.1.12.install.x64.exe`.
- Icon extracted from that installer's own 256px resource, not sourced
from the web.
- Icon map key is `"paint.net"`, the lowercased catalog name. The icon
generator derives its key from the slug and produced `"paint dot net"`,
which would never have matched at runtime — corrected by hand.

# Checklist for submitter

- [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 Paint.NET to the Windows software catalog.
* Added support for installing, upgrading, detecting, and uninstalling
Paint.NET.
  * Added Paint.NET branding and an icon to the software interface.
* Included Paint.NET version 5.1.12 with verified download metadata and
Productivity categorization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Allen Houchins <allenhouchins@mac.com>
2026-08-02 20:40:48 -05:00

80 lines
2.7 KiB
PowerShell

# Paint.NET ships as a zip containing its installer .exe.
$zipFilePath = "${env:INSTALLER_PATH}"
$installTimeoutSeconds = 420
$registrationTimeoutSeconds = 120
$machineKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
$machineKey32on64 = 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
# Same DisplayName and Publisher the catalog's exists query uses.
function Get-PaintDotNetEntry {
Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |
ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
Where-Object { $_.DisplayName -eq "Paint.NET" -and $_.Publisher -eq "dotPDN LLC" } |
Select-Object -First 1
}
try {
$extractPath = Join-Path $env:TEMP "PaintDotNetInstall"
if (Test-Path $extractPath) { Remove-Item -Path $extractPath -Recurse -Force }
Expand-Archive -Path $zipFilePath -DestinationPath $extractPath -Force
$installer = Get-ChildItem -Path $extractPath -Filter "*.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $installer) {
Write-Host "Error: installer .exe not found under $extractPath"
Exit 1
}
# /auto is the vendor's silent switch. -Wait would also wait on descendants.
$process = Start-Process -FilePath $installer.FullName -ArgumentList "/auto" -PassThru
$null = $process.Handle # keeps .ExitCode readable after exit
$killed = $false
if (-not $process.WaitForExit($installTimeoutSeconds * 1000)) {
Write-Host "Installer process did not exit within ${installTimeoutSeconds}s, stopping it."
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
$null = $process.WaitForExit(30 * 1000)
$killed = $true
}
$exitCode = $null
if ($process.HasExited) {
$exitCode = $process.ExitCode
Write-Host "Install exit code: $exitCode"
}
# The installer can return before the ARP entry is written.
$elapsed = 0
while (-not (Get-PaintDotNetEntry) -and ($elapsed -lt $registrationTimeoutSeconds)) {
Start-Sleep -Seconds 5
$elapsed += 5
Write-Host "Waiting for Paint.NET to register... ($elapsed seconds)"
}
Remove-Item -Path $extractPath -Recurse -Force -ErrorAction SilentlyContinue
Stop-Process -Name "paintdotnet" -Force -ErrorAction SilentlyContinue
$entry = Get-PaintDotNetEntry
if (-not $entry) {
Write-Host "Paint.NET did not register in Add/Remove Programs."
Exit 1
}
Write-Host "Registered '$($entry.DisplayName)' by '$($entry.Publisher)', version $($entry.DisplayVersion)."
# Registration is the success signal; a killed process's code means nothing.
if ($killed -or $null -eq $exitCode) { Exit 0 }
# 3010/1641 = reboot required/initiated.
if ($exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }
Exit $exitCode
} catch {
Write-Host "Error: $_"
Exit 1
}