diff --git a/README.md b/README.md index 86f7d7643..c439879f0 100644 --- a/README.md +++ b/README.md @@ -452,17 +452,40 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no `%APPDATA%`; delete that file to use the normal per-user folders. The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft" -to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"), -without changing which app opens PDFs by default. For unattended deployment without a desktop -shortcut, run from an elevated terminal: +to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of +images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under +"Show more options"), without changing which app opens them by default. For unattended deployment +without a desktop shortcut, run from an elevated terminal: ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 ``` -Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too, -since each install applies its own choices. Use the MSI for your architecture. Per-user -installation overrides are not supported. +| Property | `=0` leaves out | +|---|---| +| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut | +| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | +| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | + +All three are on for a first install. An upgrade keeps what the installed version has: a shortcut +or menu entry that is there stays, and one that is missing (left out at install, or a desktop +shortcut someone deleted) stays missing, so you don't need to pass them again. A value on the +command line always wins: `=1` puts one back and `=0` removes it. Upgrading from a release that +didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier for the image menu) +adds it, as a first install would. Use the MSI for your architecture. Per-user installation +overrides are not supported. + +**Pin the checksum, not just the version.** Release candidates of a version share its MSI version +(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an +MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't +be told apart by version, so if your deployment tool checks the file, pin its SHA-256 from +`SHA256SUMS.txt` (`Get-FileHash -Algorithm SHA256`). + +**Signing identities.** The Windows MSIs and both executables are signed through Azure Trusted +Signing with a certificate issued to `Learning Machines Inc` (Atlanta, Georgia, US). The macOS app +and command-line tool are signed with the Developer ID `Learning Machines LLC (DJ6XS33FX8)` and +notarized. The MSI's manufacturer, shown as the publisher in Installed apps, is +`Learning Machines LLC`. ### macOS diff --git a/docs/releasing.md b/docs/releasing.md index 5d6a850ab..ec2e5853b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -121,10 +121,14 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. The welcome dialog has a "Create a desktop shortcut" checkbox and an "Edit with PdfCraft" right-click menu checkbox, both ticked by default. `/qn` and `/qb` stay unattended and create - both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed. An update (the - launcher runs `msiexec /passive` with no properties) keeps the desktop shortcut only if - `PdfCraft.lnk` is still on the all-users desktop, so deleting it once keeps it away (#1058); a - value given on the command line wins. + both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; + `INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs + `msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still + on the all-users desktop, so deleting it once keeps it away (#1058), and keeps each menu only if + its key path is still in HKLM (#305). Both are AppSearch results read before the old version is + removed. An update from a release that didn't have a menu yet (before 0.7.1 for the PDF menu, + before 0.4.0 for the image menu, found by `OnlyDetect` Upgrade rows) adds it like a first install. + A value given on the command line wins. - The PDF right-click entry (#682) is a classic verb under `HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit`, so it shows whichever app is the default for `.pdf` and never changes that default. Several selected PDFs (up to 15, the @@ -132,8 +136,12 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI doesn't have. +- The image right-click entry (#190) is the same kind of verb, `PdfCraft.CreatePdf`, for ten image + types, in its own component (`PdfcraftImageContextMenu`) since it became optional. Its rows moved + out of `PdfcraftApp`; that's safe because `MajorUpgrade` removes the old version before installing + the new one (`afterInstallValidate`), which `test-msi.ps1` checks. - `test-msi.ps1 ` checks the compiled shortcut, context-menu and dialog tables, and - evaluates the desktop shortcut's first-install and update conditions. Packaging + evaluates the first-install and update conditions of the desktop shortcut and both menus. Packaging runs it in a child process before signing, so the MSI isn't held open when signtool runs. The ARM64 install smoke test checks that both all-users shortcuts point at the installed `pdfcraft.exe` and are removed on uninstall, and that `INSTALLDESKTOPSHORTCUT=0` skips the desktop one. diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 3f291721a..7fae1329f 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -6,9 +6,10 @@ Per-machine install into Program Files, a Start Menu shortcut, a desktop shortcut (on by default, optional, and an update keeps it only if it is still there), an App Paths entry, PdfCraft registered as a PDF handler (offered under "Open with" - and in Default apps, never forced as the default), and "Edit with PdfCraft" on PDF files' - right-click menu (on by default, optional). Files opened with PdfCraft arrive on its command - line. + and in Default apps, never forced as the default), "Edit with PdfCraft" on PDF files' + right-click menu and "Create PDF with PdfCraft…" on image files' right-click menu (both on by + default, optional, and an update keeps the installed version's choice). Files opened with + PdfCraft arrive on its command line. --> - - + + + + + + + + + + + + + + + @@ -98,38 +121,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 3af5bd037..37ab1d7ce 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -88,33 +88,50 @@ foreach ($entry in @(@('StartMenuShortcut', 'ProgramMenuFolder', 'PdfcraftStartM Assert-Equal $component[0] $entry[1] "$($entry[2]) directory" Assert-Equal $component[1] $entry[3] "$($entry[2]) condition" } -# No static default (#1058): a SetProperty after AppSearch turns it on for a first install, and for an -# update only while the old shortcut is still on the desktop; a command-line value is left alone. -Assert-NoRow 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLDESKTOPSHORTCUT''' 'desktop shortcut static default' -$setDesktop = Read-Row 'SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''INSTALLDESKTOPSHORTCUT''' 2 -Assert-Equal $setDesktop[1] '1' 'Desktop shortcut default value' -foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { - $search = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''AppSearch''') 1 - $set = Read-Row ('SELECT `Sequence`, `Condition` FROM `' + $sequence + '` WHERE `Action` = ''' + $setDesktop[0] + '''') 2 - if ([int] $set[0] -le [int] $search[0]) { throw "$sequence must set the desktop shortcut default after AppSearch" } -} -$session = $null -try { - $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) - foreach ($case in @( - @('first install', '', '', '', 1), - @('first install, INSTALLDESKTOPSHORTCUT=0', '0', '', '', 0), - @('update, shortcut still there', '', '{00000000-0000-0000-0000-000000000000}', 'C:\Users\Public\Desktop\PdfCraft.lnk', 1), - @('update, shortcut deleted', '', '{00000000-0000-0000-0000-000000000000}', '', 0), - @('update, INSTALLDESKTOPSHORTCUT=0', '0', '{00000000-0000-0000-0000-000000000000}', 'C:\Users\Public\Desktop\PdfCraft.lnk', 0))) { - $session.Property('INSTALLDESKTOPSHORTCUT') = $case[1] - $session.Property('WIX_UPGRADE_DETECTED') = $case[2] - $session.Property('EXISTINGDESKTOPSHORTCUT') = $case[3] - Assert-Equal ($session.EvaluateCondition($set[1])) $case[4] "Desktop shortcut default: $($case[0])" +# Defaults an update keeps (#1058, #305). No static default: a SetProperty after AppSearch turns it on +# for a first install and, for an update from a release that offered it ($Release set), only while what +# it installed is still there ($Found, from AppSearch). A command-line value is left alone. Windows +# Installer's evaluator runs the SetProperty condition and then $Component's own condition. +function Assert-KeptOnUpdate([string] $Property, [string] $Release, [string] $Found, [string] $FoundValue, [string] $Component, [string] $What) { + Assert-NoRow ('SELECT `Value` FROM `Property` WHERE `Property` = ''' + $Property + '''') "$What static default" + $action = Read-Row ('SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''' + $Property + '''') 2 + Assert-Equal $action[1] '1' "$What default value" + $condition = $null + foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { + $search = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''AppSearch''') 1 + $set = Read-Row ('SELECT `Sequence`, `Condition` FROM `' + $sequence + '` WHERE `Action` = ''' + $action[0] + '''') 2 + if ([int] $set[0] -le [int] $search[0]) { throw "$sequence must set the $What default after AppSearch" } + $condition = $set[1] + } + $installed = Read-Row ('SELECT `Condition` FROM `Component` WHERE `Component` = ''' + $Component + '''') 1 + Assert-Equal $installed[0] "$Property = 1" "$Component condition" + $old = '{00000000-0000-0000-0000-000000000000}' + # Name, command-line value, WIX_UPGRADE_DETECTED, $Release, $Found, installed (1) or not (0). + $cases = @( + @('first install', '', '', '', '', 1), + @("first install, $Property=0", '0', '', '', '', 0), + @('update, still there', '', $old, $old, $FoundValue, 1), + @('update, removed', '', $old, $old, '', 0), + @("update, $Property=0", '0', $old, $old, $FoundValue, 0), + @("update, removed, $Property=1", '1', $old, $old, '', 1)) + # An update from a release from before it existed (WIX_UPGRADE_DETECTED alone) is a first install. + if ($Release -ne 'WIX_UPGRADE_DETECTED') { $cases += , @('update from a release without it', '', $old, '', '', 1) } + $session = $null + try { + $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) + foreach ($case in $cases) { + $session.Property($Property) = $case[1] + $session.Property('WIX_UPGRADE_DETECTED') = $case[2] + $session.Property($Release) = $case[3] + $session.Property($Found) = $case[4] + if ($session.EvaluateCondition($condition) -eq 1) { $session.Property($Property) = $action[1] } + Assert-Equal ($session.EvaluateCondition($installed[0])) $case[5] "$What`: $($case[0])" + } + } finally { + if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } } -} finally { - if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } } +Assert-KeptOnUpdate 'INSTALLDESKTOPSHORTCUT' 'WIX_UPGRADE_DETECTED' 'EXISTINGDESKTOPSHORTCUT' 'C:\Users\Public\Desktop\PdfCraft.lnk' 'PdfcraftDesktopShortcut' 'Desktop shortcut' $secure = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''SecureCustomProperties''' 1 if (($secure[0] -split ';') -notcontains 'INSTALLDESKTOPSHORTCUT') { throw "INSTALLDESKTOPSHORTCUT is not secure: '$($secure[0])'" } $checkbox = Read-Row 'SELECT `Type`, `Property`, `Text` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''DesktopShortcut''' 3 @@ -128,23 +145,71 @@ Assert-Equal $app[0] 'PdfcraftExe' 'Shortcut executable key path' $scope = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''ALLUSERS''' 1 Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' -# Image context menu opens the DPI chooser; the app component owns every registry row so -# uninstall removes it. No image default association is changed. +# Each File Explorer menu remembers whether it was installed by finding its key path in HKLM, in the +# package's own registry view (64-bit for x64 and arm64). Only a release that offered the menu can +# leave it out, so the OnlyDetect Upgrade rows start at the first release with that menu. +$upgradeCode = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''UpgradeCode''' 1 +$view64 = $Database.SummaryInformation(0).Property(7) -notmatch '^Intel;' +function Assert-MenuSearch([string] $Found, [string] $Key, [string] $Release, [string] $Since, [string] $What) { + $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $Found + '''') 4 + Assert-Equal $search[0] '2' "$What search HKLM root" + Assert-Equal $search[1] $Key "$What search key" + Assert-Equal $search[2] '' "$What search reads the default value" + # msidbLocatorTypeRawValue (2), plus msidbLocatorType64bit (16) in a 64-bit package. + Assert-Equal ([int] $search[3]) $(if ($view64) { 18 } else { 2 }) "$What search type" + $upgrade = Read-Row ('SELECT `UpgradeCode`, `VersionMin`, `VersionMax`, `Attributes` FROM `Upgrade` WHERE `ActionProperty` = ''' + $Release + '''') 4 + Assert-Equal $upgrade[0] $upgradeCode[0] "$What release detection upgrade code" + Assert-Equal $upgrade[1] $Since "$What release detection minimum" + Assert-Equal $upgrade[2] '' "$What release detection maximum" + # msidbUpgradeAttributesOnlyDetect (2) + VersionMinInclusive (256): never removes anything itself. + Assert-Equal ([int] $upgrade[3]) 258 "$What release detection attributes" + if (($secure[0] -split ';') -notcontains $Release) { throw "$Release is not secure: '$($secure[0])'" } +} +# The old version is removed before the new one is installed, so rows that moved between components +# (the image menu left PdfcraftApp after 0.7.1) are removed with it rather than orphaned. +$validate = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''InstallValidate''' 1 +$remove = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''RemoveExistingProducts''' 1 +$initialize = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''InstallInitialize''' 1 +if ([int] $remove[0] -le [int] $validate[0] -or [int] $remove[0] -ge [int] $initialize[0]) { + throw "RemoveExistingProducts ($($remove[0])) must run between InstallValidate and InstallInitialize" +} + +# Image context menu (#190) opens the DPI chooser. Its own component, gated by INSTALLIMAGECONTEXTMENU +# (on for a first install, kept on update only while present, secure), with the .png label as key +# path so uninstall removes every row. No image default association is changed. +$imageKey = 'Software\Classes\SystemFileAssociations\.png\shell\PdfCraft.CreatePdf' foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2k', 'jpx')) { $key = 'Software\Classes\SystemFileAssociations\.' + $ext + '\shell\PdfCraft.CreatePdf' $menu = Read-Row ('SELECT `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` IS NULL') 3 Assert-Equal $menu[0] 'Create PDF with PdfCraft…' "$ext context menu label" - Assert-Equal $menu[1] 'PdfcraftApp' "$ext context menu component" + Assert-Equal $menu[1] 'PdfcraftImageContextMenu' "$ext context menu component" Assert-Equal $menu[2] '2' "$ext context menu HKLM root" - $command = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 1 + $command = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 2 Assert-Equal $command[0] '"[#PdfcraftExe]" --create-images "%1"' "$ext context menu command" - $selection = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` = ''MultiSelectModel''') 1 + Assert-Equal $command[1] 'PdfcraftImageContextMenu' "$ext context menu command component" + $selection = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` = ''MultiSelectModel''') 2 Assert-Equal $selection[0] 'Single' "$ext context menu selection" + Assert-Equal $selection[1] 'PdfcraftImageContextMenu' "$ext context menu selection component" } +$view = $Database.OpenView('SELECT `Key` FROM `Registry` WHERE `Component_` = ''PdfcraftApp''') +try { + [void] $view.Execute() + while ($record = $view.Fetch()) { + if ($record.StringData(1) -match 'PdfCraft\.CreatePdf') { throw "image menu row left in PdfcraftApp: $($record.StringData(1))" } + } +} finally { [void] $view.Close() } +$imageKeyPath = Read-Row ('SELECT `Registry` FROM `Registry` WHERE `Key` = ''' + $imageKey + ''' AND `Name` IS NULL') 1 +$imageComponent = Read-Row 'SELECT `KeyPath`, `Attributes` FROM `Component` WHERE `Component` = ''PdfcraftImageContextMenu''' 2 +Assert-Equal $imageComponent[0] $imageKeyPath[0] 'Image context menu key path' +# msidbComponentAttributesRegistryKeyPath (4). +if (([int] $imageComponent[1] -band 4) -eq 0) { throw "Image context menu key path is not a registry value: attributes $($imageComponent[1])" } +Assert-KeptOnUpdate 'INSTALLIMAGECONTEXTMENU' 'IMAGECONTEXTMENURELEASE' 'EXISTINGIMAGECONTEXTMENU' 'Create PDF with PdfCraft…' 'PdfcraftImageContextMenu' 'Image context menu' +Assert-MenuSearch 'EXISTINGIMAGECONTEXTMENU' $imageKey 'IMAGECONTEXTMENURELEASE' '0.4.0' 'Image context menu' +if (($secure[0] -split ';') -notcontains 'INSTALLIMAGECONTEXTMENU') { throw "INSTALLIMAGECONTEXTMENU is not secure: '$($secure[0])'" } # PDF context menu (#682): "Edit with PdfCraft" opens the file in PdfCraft. Its own component, gated -# by INSTALLPDFCONTEXTMENU (default 1, secure, welcome checkbox), with its menu value as key path so -# uninstall removes it. Several selected PDFs each start pdfcraft.exe "%1" (Document), which the +# by INSTALLPDFCONTEXTMENU (on for a first install, kept on update only while present, secure, welcome +# checkbox), with its menu value as key path so uninstall removes it. Several selected PDFs each start pdfcraft.exe "%1" (Document), which the # single-instance hand-off opens as tabs. The .pdf default is never set, so the user's default app # stays. $pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' @@ -163,8 +228,8 @@ Assert-Equal $pdfCommand[1] 'PdfcraftPdfContextMenu' 'PDF context menu command c $pdfComponent = Read-Row 'SELECT `Condition`, `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftPdfContextMenu''' 2 Assert-Equal $pdfComponent[0] 'INSTALLPDFCONTEXTMENU = 1' 'PDF context menu condition' Assert-Equal $pdfComponent[1] $pdfMenu[0] 'PDF context menu key path' -$pdfDefault = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLPDFCONTEXTMENU''' 1 -Assert-Equal $pdfDefault[0] '1' 'PDF context menu default' +Assert-KeptOnUpdate 'INSTALLPDFCONTEXTMENU' 'PDFCONTEXTMENURELEASE' 'EXISTINGPDFCONTEXTMENU' 'Edit with PdfCraft' 'PdfcraftPdfContextMenu' 'PDF context menu' +Assert-MenuSearch 'EXISTINGPDFCONTEXTMENU' $pdfKey 'PDFCONTEXTMENURELEASE' '0.7.1' 'PDF context menu' if (($secure[0] -split ';') -notcontains 'INSTALLPDFCONTEXTMENU') { throw "INSTALLPDFCONTEXTMENU is not secure: '$($secure[0])'" } $pdfCheckbox = Read-Row 'SELECT `Type`, `Property`, `Text` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''PdfContextMenu''' 3 Assert-Equal $pdfCheckbox[0] 'CheckBox' 'Welcome PDF context-menu control' @@ -204,4 +269,4 @@ $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Installer) -Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut (on for a first install, kept on update only while present, checkbox), optional PDF context menu (default on, checkbox), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' +Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad291ef2e..3282143e0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -10085,7 +10085,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut and an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682), and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." +notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut, an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682) and an optional (default on, INSTALLIMAGECONTEXTMENU) \"Create PDF with PdfCraft…\" entry for images; an update keeps whichever of the three the installed version has (#1058, #305). It confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." [[feature]] id = "misc.web-deploy"