From cfbb5a59fa237d0b8414c434c69746e41c1877ce Mon Sep 17 00:00:00 2001 From: kitzy Date: Tue, 28 Jul 2026 23:56:46 -0400 Subject: [PATCH] Add GNU Privacy Guard as a Windows FMA (#50025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** #50020 # What this does Adds **GNU Privacy Guard** as a Windows Fleet-maintained app. One of the 11 apps split out of #48501 that failed the FMA validator; #50016 shipped the 6 that passed. ## Why it was failing The install itself worked — the validator logged `New application detected at: C:\Program Files\GnuPG`. The *script* never returned: ``` 20:18:36 INFO msg="Executing install script..." app="GNU Privacy Guard" 20:28:36 ERROR msg="Error executing install script: exit status 1" # exactly 10:00 later 20:28:36 INFO msg="New application detected at: C:\Program Files\GnuPG" ``` Ten minutes on the nose is the validator's `executeScript` timeout. The cause is a PowerShell detail rather than anything wrong with the installer: **`Start-Process -Wait` waits for the process *and all of its descendants***. GnuPG's installer starts `gpg-agent`, `dirmngr`, `keyboxd` and `scdaemon` and leaves them resident, so `-Wait` never returns. The same run left the installer `.exe` locked in the validator's temp dir, which is the other tell that a child process was still alive. The install script now follows the pattern already established by [`ollama_install.ps1`](ee/maintained-apps/inputs/winget/scripts/ollama_install.ps1): start with `-PassThru` (no `-Wait`), wait on the installer process alone with a 7-minute cap (below the caller's 10-minute script budget), poll for the Add/Remove Programs entry so a fast-returning installer can't be mistaken for a finished one, then stop the daemons. Stopping the daemons also fixes the uninstall, which would otherwise fail on files those processes hold open. The uninstall script stops them up front, uses NSIS's `_?=` switch so the uninstaller runs in place instead of relaunching itself detached from `%TEMP%`, and polls the ARP key to confirm removal. ## Notes - Clean ARP `DisplayName` (`GNU Privacy Guard`), so exact name matching — no `fuzzy_match_name` needed. Publisher `The GnuPG Project`. - Ships a new catalog icon and website asset. # 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 - [x] FMA CI validator (install → detect → uninstall) **passes** on the SYSTEM-context Windows runner — [run 30384069714](https://github.com/fleetdm/fleet/actions/runs/30384069714) (`All checks passed`) - [x] Generated output verified locally: manifest SHA matches the winget manifest, exists/patched queries reviewed for name + publisher correctness, `apps.json` is valid JSON with a description filled in. - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Added GNU Privacy Guard as a supported Windows application in the maintained apps catalog. * Added install/upgrade detection and uninstall support for Windows. * Added GNU Privacy Guard to the software catalog (Security category). * Added a dedicated GNU Privacy Guard icon to the software interface for proper name-based display. --- ee/maintained-apps/inputs/winget/gnupg.json | 13 +++ .../inputs/winget/scripts/gnupg_install.ps1 | 77 +++++++++++++++ .../inputs/winget/scripts/gnupg_uninstall.ps1 | 89 ++++++++++++++++++ ee/maintained-apps/outputs/apps.json | 7 ++ ee/maintained-apps/outputs/gnupg/windows.json | 22 +++++ .../SoftwarePage/components/icons/Gnupg.tsx | 14 +++ .../SoftwarePage/components/icons/index.ts | 2 + .../assets/images/app-icon-gnupg-60x60@2x.png | Bin 0 -> 1410 bytes 8 files changed, 224 insertions(+) create mode 100644 ee/maintained-apps/inputs/winget/gnupg.json create mode 100644 ee/maintained-apps/inputs/winget/scripts/gnupg_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/gnupg_uninstall.ps1 create mode 100644 ee/maintained-apps/outputs/gnupg/windows.json create mode 100644 frontend/pages/SoftwarePage/components/icons/Gnupg.tsx create mode 100644 website/assets/images/app-icon-gnupg-60x60@2x.png 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 0000000000000000000000000000000000000000..810a8981282876291312f0a27afd4a605096b783 GIT binary patch literal 1410 zcmV-|1%3L7P)<#hNMUDpSlB5+EUkh3t3Sbq^ly?3P@`ac$+W)gNw-mm%@iw*uGS^_~!GX zfQ!MkP9D_9BoEonQ^2r#*t^o0i$H$-X;|}fxD`Qf{F+v>CufGSjWMZ6%}|Dniov|m z0HFzj0&iJI18-ZIh78rwMTxWm8Q>|*QcU6M^poxsCnZ+GS3rTc7*#?N0y?4A#1exe zV@=PAzvV;{Czn4mK2kOH`BCP~brmHzd_ha#^ZW$5N&#yUmRJ@KytjICZmKOUnxd0qlo=JUq8r56=5tEU>ZSNdi>B!0 z7|&Qyfj-M9nf;44F(+N8{G?Egg&drvGm@wuL|%qquZ;GQ^wnCqS4 z*xjlCtl;gyk}&{DS76(l>wyMO?92$>9QPhHj;nK2yo`GQB=}{taZr5rb=Y_$i>Kkl z!`V74buy_#&`0R{=< z1@4GR^7RJDrIw0IG04Pp?s z6+ZGxpq_O-UUb|2lAv;}@=lym9FM|Lfw*MGbGRpr{j`pGk0wz|&AT!D9Ki9iw{u+H z%lFbR))8|#XLg)6?zph3B|G{gT(105*0JWY57vgO;9&q+4zgg3*{YRPbN7WpoTF=A zZCw|#0vSU3Twb_GAn3S1fWKZrt_Hy(tp=5uIL7o|DG(=%H-D)>+|0_cz7~g2@bgZV z^#4Pmo7YMDIgR_Q!c@jxQSn~NPXc$DS=kZ9B16^=b65oySbG)V0XhOGLGZ~2E9}J_ z@vxQ{(N|xAvV(rf35(VM48Nz(k{>{GL({n*G?ww`736`sp+Fq;mQK!e+62sC1sVLG zpNu0Nw34N1NZ~r9wH3hV0!om%Rl|ypu4Vk2Rm(TPgTJo;Ml}=k^(l^UvV#9}h0M&8 zn4eYv5;`cM^SyP`g6|WobSEm)s1x8p+pYkp93=o4J0iZy=YN#`Uhba&i6BLt1r&00 zX#o%NJOxTZ8B_#*XMGy*-^PW3D&XM1+<&vq1#m}J@|OBDm6e_i4B8 z$_~?B3XqrFjM&$urYq`At_zQa6p(_D3K