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>
This commit is contained in:
kitzy
2026-08-02 20:40:48 -05:00
committed by GitHub
co-authored by Allen Houchins
parent 2833401d12
commit da1947cd1f
8 changed files with 168 additions and 0 deletions
@@ -0,0 +1,12 @@
{
"name": "Paint.NET",
"slug": "paint-dot-net/windows",
"package_identifier": "dotPDN.PaintDotNet",
"unique_identifier": "Paint.NET",
"install_script_path": "ee/maintained-apps/inputs/winget/scripts/paint_dot_net_install.ps1",
"uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/paint_dot_net_uninstall.ps1",
"installer_arch": "x64",
"installer_type": "zip",
"installer_scope": "machine",
"default_categories": ["Productivity"]
}
@@ -0,0 +1,79 @@
# 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
}
@@ -0,0 +1,32 @@
# The ProductCode changes every release and differs between the .exe and .msi
# variants; the UpgradeCode is stable across both.
$upgradeCode = '{04A40F40-A207-4B48-AED7-6AA532E43275}'
$timeoutSeconds = 300
$successCodes = @(0, 3010, 1641)
try {
$inst = New-Object -ComObject "WindowsInstaller.Installer"
# An empty list means nothing to remove; a failed query means we don't know.
$productCodes = @()
try {
$productCodes = @($inst.RelatedProducts($upgradeCode))
} catch {
Write-Host "Could not query related products for upgrade code $upgradeCode. Error: $_"
Exit 1
}
if ($productCodes.Count -eq 0) { Write-Host "No installed product found for upgrade code $upgradeCode."; Exit 0 }
foreach ($productCode in $productCodes) {
$process = Start-Process msiexec -ArgumentList @("/quiet", "/x", $productCode, "/norestart") -PassThru
if (-not $process.WaitForExit($timeoutSeconds * 1000)) {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
Write-Host "Uninstall for $productCode timed out."
Exit 1603
}
Write-Host "Uninstall for $productCode exited $($process.ExitCode)"
if ($successCodes -notcontains $process.ExitCode) { Exit $process.ExitCode }
}
} catch { Write-Host "Error: $_"; Exit 1 }
Exit 0
+7
View File
@@ -6392,6 +6392,13 @@
"unique_identifier": "com.charlessoft.pacifist",
"description": "Pacifist is an extract files and folders from package files, disk images, and archives."
},
{
"name": "Paint.NET",
"slug": "paint-dot-net/windows",
"platform": "windows",
"unique_identifier": "Paint.NET",
"description": "Paint.NET is an image and photo editor with support for layers, effects, and a range of image formats."
},
{
"name": "Pale Moon",
"slug": "pale-moon/darwin",
@@ -0,0 +1,22 @@
{
"versions": [
{
"version": "5.1.12",
"queries": {
"exists": "SELECT 1 FROM programs WHERE name = 'Paint.NET' AND publisher = 'dotPDN LLC';",
"patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Paint.NET' AND publisher = 'dotPDN LLC' AND version_compare(version, '5.1.12') < 0);"
},
"installer_url": "https://github.com/paintdotnet/release/releases/download/v5.1.12/paint.net.5.1.12.install.x64.zip",
"install_script_ref": "1dd07f15",
"uninstall_script_ref": "a23d064d",
"sha256": "3cd861b5af3f85bd28666d4a9017d03de237c08ad0103ee9d92b2cd921f8c867",
"default_categories": [
"Productivity"
]
}
],
"refs": {
"1dd07f15": "# Paint.NET ships as a zip containing its installer .exe.\n\n$zipFilePath = \"${env:INSTALLER_PATH}\"\n\n$installTimeoutSeconds = 420\n$registrationTimeoutSeconds = 120\n\n$machineKey = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n# Same DisplayName and Publisher the catalog's exists query uses.\nfunction Get-PaintDotNetEntry {\n Get-ChildItem -Path @($machineKey, $machineKey32on64) -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |\n Where-Object { $_.DisplayName -eq \"Paint.NET\" -and $_.Publisher -eq \"dotPDN LLC\" } |\n Select-Object -First 1\n}\n\ntry {\n\n$extractPath = Join-Path $env:TEMP \"PaintDotNetInstall\"\nif (Test-Path $extractPath) { Remove-Item -Path $extractPath -Recurse -Force }\nExpand-Archive -Path $zipFilePath -DestinationPath $extractPath -Force\n\n$installer = Get-ChildItem -Path $extractPath -Filter \"*.exe\" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1\nif (-not $installer) {\n Write-Host \"Error: installer .exe not found under $extractPath\"\n Exit 1\n}\n\n# /auto is the vendor's silent switch. -Wait would also wait on descendants.\n$process = Start-Process -FilePath $installer.FullName -ArgumentList \"/auto\" -PassThru\n$null = $process.Handle # keeps .ExitCode readable after exit\n\n$killed = $false\nif (-not $process.WaitForExit($installTimeoutSeconds * 1000)) {\n Write-Host \"Installer process did not exit within ${installTimeoutSeconds}s, stopping it.\"\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n $null = $process.WaitForExit(30 * 1000)\n $killed = $true\n}\n\n$exitCode = $null\nif ($process.HasExited) {\n $exitCode = $process.ExitCode\n Write-Host \"Install exit code: $exitCode\"\n}\n\n# The installer can return before the ARP entry is written.\n$elapsed = 0\nwhile (-not (Get-PaintDotNetEntry) -and ($elapsed -lt $registrationTimeoutSeconds)) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n Write-Host \"Waiting for Paint.NET to register... ($elapsed seconds)\"\n}\n\nRemove-Item -Path $extractPath -Recurse -Force -ErrorAction SilentlyContinue\n\nStop-Process -Name \"paintdotnet\" -Force -ErrorAction SilentlyContinue\n\n$entry = Get-PaintDotNetEntry\nif (-not $entry) {\n Write-Host \"Paint.NET did not register in Add/Remove Programs.\"\n Exit 1\n}\nWrite-Host \"Registered '$($entry.DisplayName)' by '$($entry.Publisher)', version $($entry.DisplayVersion).\"\n\n# Registration is the success signal; a killed process's code means nothing.\nif ($killed -or $null -eq $exitCode) { Exit 0 }\n\n# 3010/1641 = reboot required/initiated.\nif ($exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }\n\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n",
"a23d064d": "# The ProductCode changes every release and differs between the .exe and .msi\n# variants; the UpgradeCode is stable across both.\n$upgradeCode = '{04A40F40-A207-4B48-AED7-6AA532E43275}'\n$timeoutSeconds = 300\n$successCodes = @(0, 3010, 1641)\n\ntry {\n $inst = New-Object -ComObject \"WindowsInstaller.Installer\"\n # An empty list means nothing to remove; a failed query means we don't know.\n $productCodes = @()\n try {\n $productCodes = @($inst.RelatedProducts($upgradeCode))\n } catch {\n Write-Host \"Could not query related products for upgrade code $upgradeCode. Error: $_\"\n Exit 1\n }\n\n if ($productCodes.Count -eq 0) { Write-Host \"No installed product found for upgrade code $upgradeCode.\"; Exit 0 }\n\n foreach ($productCode in $productCodes) {\n $process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $productCode, \"/norestart\") -PassThru\n if (-not $process.WaitForExit($timeoutSeconds * 1000)) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Write-Host \"Uninstall for $productCode timed out.\"\n Exit 1603\n }\n Write-Host \"Uninstall for $productCode exited $($process.ExitCode)\"\n if ($successCodes -notcontains $process.ExitCode) { Exit $process.ExitCode }\n }\n} catch { Write-Host \"Error: $_\"; Exit 1 }\n\nExit 0\n"
}
}
File diff suppressed because one or more lines are too long
@@ -752,6 +752,7 @@ import OrigamiStudio from "./OrigamiStudio";
import P4V from "./P4V";
import Pacifist from "./Pacifist";
import Package from "./Package";
import PaintDotNet from "./PaintDotNet";
import PaleMoon from "./PaleMoon";
import Paletro from "./Paletro";
import ParallelsDesktop from "./ParallelsDesktop";
@@ -1915,6 +1916,7 @@ export const SOFTWARE_NAME_TO_ICON_MAP = {
p4v: P4V,
pacifist: Pacifist,
package: Package,
"paint.net": PaintDotNet,
"pale moon": PaleMoon,
paletro: Paletro,
"parallels desktop": ParallelsDesktop,
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB