diff --git a/ee/maintained-apps/inputs/winget/gnupg.json b/ee/maintained-apps/inputs/winget/gnupg.json
new file mode 100644
index 0000000000..6a3d4edc7c
--- /dev/null
+++ b/ee/maintained-apps/inputs/winget/gnupg.json
@@ -0,0 +1,13 @@
+{
+ "name": "GNU Privacy Guard",
+ "slug": "gnupg/windows",
+ "package_identifier": "GnuPG.GnuPG",
+ "unique_identifier": "GNU Privacy Guard",
+ "program_publisher": "The GnuPG Project",
+ "install_script_path": "ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1",
+ "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1",
+ "installer_arch": "x64",
+ "installer_type": "exe",
+ "installer_scope": "",
+ "default_categories": ["Security"]
+}
diff --git a/ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1
new file mode 100644
index 0000000000..f036f34d79
--- /dev/null
+++ b/ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1
@@ -0,0 +1,77 @@
+# 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 its window lets it run through to the section that writes the
+# Add/Remove Programs entry; killing it instead would leave a partial install.
+$daemons = @("gpg-agent", "dirmngr", "keyboxd", "scdaemon", "gpg-connect-agent", "gpgconf", "gpa", "launch-gpa")
+$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\*'
+
+function Test-GnuPGRegistered {
+ $null -ne (Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey) -ErrorAction SilentlyContinue |
+ ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
+ Where-Object { $_.DisplayName -like "GNU Privacy Guard*" } |
+ 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 }
+
+ $children = @(Get-Process -Name $daemons -ErrorAction SilentlyContinue |
+ Select-Object -ExpandProperty Name -Unique)
+ $windowTitle = ""
+ try { $windowTitle = $process.MainWindowTitle } catch { }
+
+ Write-Host "Installing... ($elapsed seconds, registered: $(Test-GnuPGRegistered), window: '$windowTitle', children: $($children -join ', '))"
+
+ if ($elapsed -ge $graceSeconds -and $process.MainWindowHandle -ne [IntPtr]::Zero) {
+ Write-Host "Installer is showing a window ('$windowTitle'); closing it so the install can continue."
+ $null = $process.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 the resident daemons; they hold file locks the uninstall needs released.
+foreach ($name in $daemons) {
+ Stop-Process -Name $name -Force -ErrorAction SilentlyContinue
+}
+
+# Registration is the success signal: a killed installer's exit code says nothing.
+if (-not (Test-GnuPGRegistered)) {
+ Write-Host "GnuPG did not register in Add/Remove Programs."
+ Exit 1
+}
+
+Write-Host "GnuPG is registered in Add/Remove Programs."
+Exit 0
+
+} catch {
+ Write-Host "Error: $_"
+ Exit 1
+}
diff --git a/ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1 b/ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1
new file mode 100644
index 0000000000..dfb1afe13b
--- /dev/null
+++ b/ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1
@@ -0,0 +1,89 @@
+$softwareName = "GNU Privacy Guard"
+$softwarePublisher = "The GnuPG Project"
+
+# The daemons hold file locks and would block -Wait, so stop them first and wait
+# only on the uninstaller process.
+$daemons = @("gpg-agent", "dirmngr", "keyboxd", "scdaemon", "gpg-connect-agent", "gpgconf")
+$timeoutSeconds = 300
+
+$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\*'
+$userKey32on64 = 'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
+$exitCode = 0
+
+function Get-GnuPGUninstallKey {
+ Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey, $userKey32on64) -ErrorAction SilentlyContinue |
+ ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |
+ Where-Object { $_.DisplayName -like "$softwareName*" -and $_.Publisher -eq $softwarePublisher } |
+ Select-Object -First 1
+}
+
+foreach ($daemon in $daemons) {
+ Stop-Process -Name $daemon -Force -ErrorAction SilentlyContinue
+}
+
+try {
+ $key = Get-GnuPGUninstallKey
+ if (-not $key) {
+ Write-Host "Uninstall entry not found for '$softwareName'."
+ Exit 0
+ }
+
+ $uninstallString = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+ Write-Host "Uninstall string: $uninstallString"
+
+ # Handles quoted paths, unquoted paths with spaces, and bare tokens.
+ $uninstallCommand = $uninstallString
+ if ($uninstallCommand -match '^\s*"([^"]+)"\s*(.*)$') {
+ $uninstallCommand = $Matches[1]
+ } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)\s*(.*)$') {
+ $uninstallCommand = $Matches[1]
+ } elseif ($uninstallCommand -match '^\s*(\S+)\s*(.*)$') {
+ $uninstallCommand = $Matches[1]
+ }
+
+ # NSIS uninstallers relaunch from %TEMP% and detach by default; "_?=
"
+ # runs in place so this stays synchronous. Must be the last argument.
+ $installDir = Split-Path -Parent $uninstallCommand
+ $uninstallArgs = "/S _?=$installDir"
+
+ Write-Host "Uninstall command: $uninstallCommand"
+ Write-Host "Uninstall args: $uninstallArgs"
+
+ $process = Start-Process -FilePath $uninstallCommand -ArgumentList $uninstallArgs -PassThru
+ # Keeps .ExitCode readable after the process ends.
+ $null = $process.Handle
+
+ if (-not $process.WaitForExit($timeoutSeconds * 1000)) {
+ Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+ Write-Host "Uninstall timed out after $timeoutSeconds seconds"
+ Exit 1603
+ }
+
+ $exitCode = $process.ExitCode
+ Write-Host "Uninstall exit code: $exitCode"
+} catch {
+ Write-Host "Error: $_"
+ Exit 1
+}
+
+# Stop anything restarted, then wait for the ARP entry to clear.
+foreach ($daemon in $daemons) {
+ Stop-Process -Name $daemon -Force -ErrorAction SilentlyContinue
+}
+
+$elapsed = 0
+while ((Get-GnuPGUninstallKey) -and ($elapsed -lt 120)) {
+ Start-Sleep -Seconds 5
+ $elapsed += 5
+ Write-Host "Waiting for the uninstall to finish... ($elapsed seconds)"
+}
+
+if (Get-GnuPGUninstallKey) {
+ Write-Host "'$softwareName' is still registered after the uninstall."
+ Exit 1
+}
+
+Exit $exitCode
diff --git a/ee/maintained-apps/outputs/apps.json b/ee/maintained-apps/outputs/apps.json
index bd2065b21d..64e6992784 100644
--- a/ee/maintained-apps/outputs/apps.json
+++ b/ee/maintained-apps/outputs/apps.json
@@ -3753,6 +3753,13 @@
"unique_identifier": "com.GeorgSeifert.Glyphs3",
"description": "Glyphs is a font editor."
},
+ {
+ "name": "GNU Privacy Guard",
+ "slug": "gnupg/windows",
+ "platform": "windows",
+ "unique_identifier": "GNU Privacy Guard",
+ "description": "GNU Privacy Guard is an implementation of the OpenPGP standard for encrypting and signing data and communications."
+ },
{
"name": "Go",
"slug": "go/windows",
diff --git a/ee/maintained-apps/outputs/gnupg/windows.json b/ee/maintained-apps/outputs/gnupg/windows.json
new file mode 100644
index 0000000000..df50bb0792
--- /dev/null
+++ b/ee/maintained-apps/outputs/gnupg/windows.json
@@ -0,0 +1,22 @@
+{
+ "versions": [
+ {
+ "version": "2.5.21",
+ "queries": {
+ "exists": "SELECT 1 FROM programs WHERE name = 'GNU Privacy Guard' AND publisher = 'The GnuPG Project';",
+ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'GNU Privacy Guard' AND publisher = 'The GnuPG Project' AND version_compare(version, '2.5.21') < 0);"
+ },
+ "installer_url": "https://gnupg.org/ftp/gcrypt/binary/gnupg-w32-2.5.21_20260702.exe",
+ "install_script_ref": "8c2ff75e",
+ "uninstall_script_ref": "d1a2230f",
+ "sha256": "6246c925a73167253444afc24a0deb83a3f43b7d636af84d6aaf48a98a62f024",
+ "default_categories": [
+ "Security"
+ ]
+ }
+ ],
+ "refs": {
+ "8c2ff75e": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n# The installer stalls on a modal dialog with no interactive desktop and never\n# exits. Closing its window lets it run through to the section that writes the\n# Add/Remove Programs entry; killing it instead would leave a partial install.\n$daemons = @(\"gpg-agent\", \"dirmngr\", \"keyboxd\", \"scdaemon\", \"gpg-connect-agent\", \"gpgconf\", \"gpa\", \"launch-gpa\")\n$installTimeoutSeconds = 420\n$pollSeconds = 10\n$graceSeconds = 30\n\n$machineKey = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n# Uninstall info is written with SHCTX, so it can land per-user.\n$userKey = 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\nfunction Test-GnuPGRegistered {\n $null -ne (Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey) -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |\n Where-Object { $_.DisplayName -like \"GNU Privacy Guard*\" } |\n Select-Object -First 1)\n}\n\ntry {\n\n$process = Start-Process -FilePath \"$exeFilePath\" -ArgumentList \"/S\" -PassThru\n# Keeps .ExitCode readable after the process ends.\n$null = $process.Handle\n\n$elapsed = 0\nwhile (-not $process.HasExited -and ($elapsed -lt $installTimeoutSeconds)) {\n Start-Sleep -Seconds $pollSeconds\n $elapsed += $pollSeconds\n $process.Refresh()\n if ($process.HasExited) { break }\n\n $children = @(Get-Process -Name $daemons -ErrorAction SilentlyContinue |\n Select-Object -ExpandProperty Name -Unique)\n $windowTitle = \"\"\n try { $windowTitle = $process.MainWindowTitle } catch { }\n\n Write-Host \"Installing... ($elapsed seconds, registered: $(Test-GnuPGRegistered), window: '$windowTitle', children: $($children -join ', '))\"\n\n if ($elapsed -ge $graceSeconds -and $process.MainWindowHandle -ne [IntPtr]::Zero) {\n Write-Host \"Installer is showing a window ('$windowTitle'); closing it so the install can continue.\"\n $null = $process.CloseMainWindow()\n }\n}\n\nif (-not $process.HasExited) {\n Write-Host \"Installer still running after ${installTimeoutSeconds}s; stopping it.\"\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Start-Sleep -Seconds 2\n} else {\n Write-Host \"Install exit code: $($process.ExitCode)\"\n}\n\n# Stop the resident daemons; they hold file locks the uninstall needs released.\nforeach ($name in $daemons) {\n Stop-Process -Name $name -Force -ErrorAction SilentlyContinue\n}\n\n# Registration is the success signal: a killed installer's exit code says nothing.\nif (-not (Test-GnuPGRegistered)) {\n Write-Host \"GnuPG did not register in Add/Remove Programs.\"\n Exit 1\n}\n\nWrite-Host \"GnuPG is registered in Add/Remove Programs.\"\nExit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n",
+ "d1a2230f": "$softwareName = \"GNU Privacy Guard\"\n$softwarePublisher = \"The GnuPG Project\"\n\n# The daemons hold file locks and would block -Wait, so stop them first and wait\n# only on the uninstaller process.\n$daemons = @(\"gpg-agent\", \"dirmngr\", \"keyboxd\", \"scdaemon\", \"gpg-connect-agent\", \"gpgconf\")\n$timeoutSeconds = 300\n\n$machineKey = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n# Uninstall info is written with SHCTX, so it can land per-user.\n$userKey = 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$userKey32on64 = 'HKCU:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$exitCode = 0\n\nfunction Get-GnuPGUninstallKey {\n Get-ChildItem -Path @($machineKey, $machineKey32on64, $userKey, $userKey32on64) -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |\n Where-Object { $_.DisplayName -like \"$softwareName*\" -and $_.Publisher -eq $softwarePublisher } |\n Select-Object -First 1\n}\n\nforeach ($daemon in $daemons) {\n Stop-Process -Name $daemon -Force -ErrorAction SilentlyContinue\n}\n\ntry {\n $key = Get-GnuPGUninstallKey\n if (-not $key) {\n Write-Host \"Uninstall entry not found for '$softwareName'.\"\n Exit 0\n }\n\n $uninstallString = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }\n Write-Host \"Uninstall string: $uninstallString\"\n\n # Handles quoted paths, unquoted paths with spaces, and bare tokens.\n $uninstallCommand = $uninstallString\n if ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $uninstallCommand = $Matches[1]\n } elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $uninstallCommand = $Matches[1]\n } elseif ($uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$') {\n $uninstallCommand = $Matches[1]\n }\n\n # NSIS uninstallers relaunch from %TEMP% and detach by default; \"_?=\"\n # runs in place so this stays synchronous. Must be the last argument.\n $installDir = Split-Path -Parent $uninstallCommand\n $uninstallArgs = \"/S _?=$installDir\"\n\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $process = Start-Process -FilePath $uninstallCommand -ArgumentList $uninstallArgs -PassThru\n # Keeps .ExitCode readable after the process ends.\n $null = $process.Handle\n\n if (-not $process.WaitForExit($timeoutSeconds * 1000)) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Write-Host \"Uninstall timed out after $timeoutSeconds seconds\"\n Exit 1603\n }\n\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n\n# Stop anything restarted, then wait for the ARP entry to clear.\nforeach ($daemon in $daemons) {\n Stop-Process -Name $daemon -Force -ErrorAction SilentlyContinue\n}\n\n$elapsed = 0\nwhile ((Get-GnuPGUninstallKey) -and ($elapsed -lt 120)) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n Write-Host \"Waiting for the uninstall to finish... ($elapsed seconds)\"\n}\n\nif (Get-GnuPGUninstallKey) {\n Write-Host \"'$softwareName' is still registered after the uninstall.\"\n Exit 1\n}\n\nExit $exitCode\n"
+ }
+}
diff --git a/frontend/pages/SoftwarePage/components/icons/Gnupg.tsx b/frontend/pages/SoftwarePage/components/icons/Gnupg.tsx
new file mode 100644
index 0000000000..4f2ae6ae7a
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/icons/Gnupg.tsx
@@ -0,0 +1,14 @@
+import * as React from "react";
+
+import type { SVGProps } from "react";
+
+const Gnupg = (props: SVGProps) => (
+
+);
+export default Gnupg;
diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts
index a4628d4b4c..3a7b4ce0e4 100644
--- a/frontend/pages/SoftwarePage/components/icons/index.ts
+++ b/frontend/pages/SoftwarePage/components/icons/index.ts
@@ -440,6 +440,7 @@ import Gitify from "./Gitify";
import GitKraken from "./GitKraken";
import GitupApp from "./GitupApp";
import Glyphs from "./Glyphs";
+import Gnupg from "./Gnupg";
import Go from "./Go";
import Go2Shell from "./Go2Shell";
import GoanywhereOpenpgpStudio from "./GoanywhereOpenpgpStudio";
@@ -1585,6 +1586,7 @@ export const SOFTWARE_NAME_TO_ICON_MAP = {
gitkraken: GitKraken,
gitup: GitupApp,
glyphs: Glyphs,
+ "gnu privacy guard": Gnupg,
go: Go,
go2shell: Go2Shell,
"goanywhere openpgp studio": GoanywhereOpenpgpStudio,
diff --git a/website/assets/images/app-icon-gnupg-60x60@2x.png b/website/assets/images/app-icon-gnupg-60x60@2x.png
new file mode 100644
index 0000000000..810a898128
Binary files /dev/null and b/website/assets/images/app-icon-gnupg-60x60@2x.png differ