#143: Windows MSI desktop shortcut checkbox, Start Menu fix and install-complete dialog (#183)

* #143: Windows MSI desktop shortcut checkbox and install-complete dialog

Add native installer dialogs (installer-ui.wxs): a welcome screen with a
"Create a desktop shortcut" checkbox (ticked by default), repair/remove,
progress, and "Setup completed successfully" / failed / cancelled screens
with Finish. Unattended /qn and /qb installs never wait on a dialog and can
skip the desktop shortcut with INSTALLDESKTOPSHORTCUT=0.

Start Menu and desktop shortcuts are now plain links to pdfcraft.exe in
their own components (HKLM key paths, removed on uninstall) instead of
advertised MSI shortcuts.

test-msi.ps1 checks the compiled MSI's shortcut, checkbox and dialog tables
during packaging; the ARM64 install smoke test checks both shortcut
targets, their removal on uninstall, and the INSTALLDESKTOPSHORTCUT=0
opt-out.

Co-authored-by: ChatGPT <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JqiBTPWGWbhQDYNFcxFte6

* #143: run test-msi out of process before signing; add files-in-use dialog

test-msi.ps1 held the MSI open through a Windows Installer COM handle in
package.ps1's own process, so signtool could fail on signed release builds.
Run it in a child process and release the COM objects.

The hand-authored UI had no MsiRMFilesInUse dialog, so an upgrade or repair
while PdfCraft is running could not offer to close it. Add one (OK closes the
programs with MSIRMSHUTDOWN=2, Cancel stops setup) and check for it in
test-msi.ps1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JqiBTPWGWbhQDYNFcxFte6

---------

Co-authored-by: ChatGPT <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-07 21:38:02 -04:00
1 parent d7b93b56b5
commit 9e0fcc1380
9 files changed
+272 -16

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Original, text-only native MSI dialogs. No third-party UI artwork or custom-action DLLs.
Full UI gets welcome (with the desktop-shortcut checkbox)/progress/outcome screens; /qn and /qb stay unattended. -->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Fragment>
<UI Id="PdfcraftInstallerUI">
<TextStyle Id="InstallerNormal" FaceName="Segoe UI" Size="9" />
<TextStyle Id="InstallerTitle" FaceName="Segoe UI" Size="12" Bold="yes" />
<Property Id="DefaultUIFont" Value="InstallerNormal" />
<Dialog Id="InstallWelcome" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\InstallerTitle}Install [ProductName]" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="40"
Text="Setup will install [ProductName] for all users and add it to the Start menu. Click Install to continue." />
<Control Id="DesktopShortcut" Type="CheckBox" X="20" Y="105" Width="330" Height="17"
Property="INSTALLDESKTOPSHORTCUT" CheckBoxValue="1" Text="Create a &amp;desktop shortcut" />
<Control Id="Install" Type="PushButton" X="195" Y="145" Width="75" Height="23" Default="yes" ElevationShield="yes" Text="&amp;Install">
<Publish Event="EndDialog" Value="Return" />
</Control>
<Control Id="Cancel" Type="PushButton" X="280" Y="145" Width="70" Height="23" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit" />
</Control>
</Dialog>
<Dialog Id="InstallMaintenance" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\InstallerTitle}Maintain [ProductName]" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] is already installed. Choose Repair to restore missing files and shortcuts, or Remove to uninstall it." />
<Control Id="Repair" Type="PushButton" X="110" Y="145" Width="75" Height="23" Default="yes" ElevationShield="yes" Text="&amp;Repair">
<Publish Property="REINSTALL" Value="ALL" Order="1" />
<Publish Property="REINSTALLMODE" Value="amus" Order="2" />
<Publish Event="EndDialog" Value="Return" Order="3" />
</Control>
<Control Id="Remove" Type="PushButton" X="195" Y="145" Width="75" Height="23" ElevationShield="yes" Text="R&amp;emove">
<Publish Property="REMOVE" Value="ALL" Order="1" />
<Publish Event="EndDialog" Value="Return" Order="2" />
</Control>
<Control Id="Cancel" Type="PushButton" X="280" Y="145" Width="70" Height="23" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit" />
</Control>
</Dialog>
<Dialog Id="InstallProgress" Width="370" Height="180" Title="[ProductName] Setup" Modeless="yes">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\InstallerTitle}Setting up [ProductName]" />
<Control Id="Status" Type="Text" X="20" Y="60" Width="330" Height="40" Text="Please wait while setup completes.">
<Subscribe Event="ActionText" Attribute="Text" />
</Control>
<Control Id="Progress" Type="ProgressBar" X="20" Y="110" Width="330" Height="15" ProgressBlocks="yes">
<Subscribe Event="SetProgress" Attribute="Progress" />
</Control>
<Control Id="Cancel" Type="PushButton" X="280" Y="145" Width="70" Height="23" Cancel="yes" Text="Cancel">
<Publish Event="SpawnDialog" Value="ConfirmCancel" />
</Control>
</Dialog>
<Dialog Id="ConfirmCancel" Width="300" Height="110" Title="Cancel setup">
<Control Id="Description" Type="Text" X="20" Y="20" Width="260" Height="35" Text="Cancel [ProductName] setup?" />
<Control Id="Yes" Type="PushButton" X="130" Y="70" Width="70" Height="23" Text="&amp;Yes">
<Publish Event="EndDialog" Value="Exit" />
</Control>
<Control Id="No" Type="PushButton" X="210" Y="70" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;No">
<Publish Event="EndDialog" Value="Return" />
</Control>
</Dialog>
<Dialog Id="InstallComplete" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\InstallerTitle}Setup completed successfully" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] setup has completed successfully. Click Finish to close this window." />
<Control Id="Finish" Type="PushButton" X="280" Y="145" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">
<Publish Event="EndDialog" Value="Return" />
</Control>
</Dialog>
<Dialog Id="InstallFailed" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\InstallerTitle}Setup failed" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] setup could not be completed. Resolve the reported error and run setup again." />
<Control Id="Finish" Type="PushButton" X="280" Y="145" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">
<Publish Event="EndDialog" Value="Return" />
</Control>
</Dialog>
<Dialog Id="InstallCancelled" Width="370" Height="180" Title="[ProductName] Setup">
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\InstallerTitle}Setup cancelled" />
<Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"
Text="[ProductName] setup was cancelled. Run setup again when you are ready." />
<Control Id="Finish" Type="PushButton" X="280" Y="145" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">
<Publish Event="EndDialog" Value="Return" />
</Control>
</Dialog>
<!-- Shown by Windows Installer (Restart Manager) when an upgrade or repair must replace files
that a running PdfCraft holds open. OK closes those programs (MSIRMSHUTDOWN=2) and
continues; Cancel ends setup. -->
<Dialog Id="MsiRMFilesInUse" Width="370" Height="200" Title="[ProductName] Setup" KeepModeless="yes">
<Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="25" Text="{\InstallerTitle}Files in use" />
<Control Id="Description" Type="Text" X="20" Y="45" Width="330" Height="30"
Text="These programs are using files that setup needs to update. Click OK to close them and continue, or Cancel to stop setup." />
<Control Id="List" Type="ListBox" X="20" Y="80" Width="330" Height="55" Property="FileInUseProcess" Sunken="yes" TabSkip="yes" />
<Control Id="OK" Type="PushButton" X="195" Y="165" Width="75" Height="23" Default="yes" Text="OK">
<Publish Property="MSIRMSHUTDOWN" Value="2" Order="1" />
<Publish Event="EndDialog" Value="Return" Order="2" />
</Control>
<Control Id="Cancel" Type="PushButton" X="280" Y="165" Width="70" Height="23" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit" />
</Control>
</Dialog>
<InstallUISequence>
<Show Dialog="InstallWelcome" Before="InstallMaintenance" Condition="UILevel = 5 AND NOT Installed" />
<Show Dialog="InstallMaintenance" Before="InstallProgress" Condition="UILevel = 5 AND Installed AND NOT REMOVE AND NOT REINSTALL" />
<Show Dialog="InstallProgress" Before="ExecuteAction" Condition="UILevel = 5" />
<Show Dialog="InstallComplete" OnExit="success" Condition="UILevel = 5" />
<Show Dialog="InstallFailed" OnExit="error" Condition="UILevel = 5" />
<Show Dialog="InstallCancelled" OnExit="cancel" Condition="UILevel = 5" />
</InstallUISequence>
</UI>
</Fragment>
</Wix>
+4
View File
@@ -94,9 +94,13 @@ Copy-Item (Join-Path $Bin 'pdfcraft.exe'), (Join-Path $Bin 'pdfcraft-cli.exe') $
$Msi = Join-Path $Dist "pdfcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'pdfcraft.wxs') -arch $Arch `
(Join-Path $PSScriptRoot 'installer-ui.wxs') `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\pdfcraft.ico')" `
-o $Msi
}
# Inspect the built MSI, not just the XML, before signing/publishing it. In a child process, so
# its Windows Installer database handle is gone before signtool opens the MSI.
Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi }
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
+30 -9
View File
@@ -2,11 +2,12 @@
<!--
PdfCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
wix build pdfcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
wix build pdfcraft.wxs installer-ui.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, an App Paths entry, and PdfCraft
registered as a PDF handler (offered under "Open with" and in Default apps, never forced as the
default). Files opened with PdfCraft arrive on its command line.
Per-machine install into Program Files, a Start Menu shortcut, a desktop shortcut (on by default,
optional), an App Paths entry, and PdfCraft registered as a PDF handler (offered under "Open with"
and in Default apps, never forced as the default). Files opened with PdfCraft arrive on its
command line.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
@@ -31,11 +32,17 @@
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/pdfcraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/pdfcraft" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
<!-- Desktop shortcut (#143): on by default, a checkbox in the full-UI welcome dialog, and
INSTALLDESKTOPSHORTCUT=0 on the msiexec command line for unattended installs. Secure, so the
choice made in the (unelevated) UI reaches the elevated install. -->
<Property Id="INSTALLDESKTOPSHORTCUT" Value="1" Secure="yes" />
<UIRef Id="PdfcraftInstallerUI" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="PdfCraft" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<StandardDirectory Id="DesktopFolder" />
<Feature Id="Main" Title="PdfCraft" Level="1">
<ComponentGroupRef Id="PdfcraftFiles" />
@@ -45,11 +52,7 @@
<Fragment>
<ComponentGroup Id="PdfcraftFiles" Directory="INSTALLFOLDER">
<Component Id="PdfcraftApp" Guid="E724C1C6-091F-4E21-A685-80D089FDBE21">
<File Id="PdfcraftExe" Source="$(var.BinDir)\pdfcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="PdfCraft"
Description="PdfCraft PDF workbench" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="PdfcraftIcon.ico" IconIndex="0" />
</File>
<File Id="PdfcraftExe" Source="$(var.BinDir)\pdfcraft.exe" KeyPath="yes" />
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\pdfcraft.exe" Value="[#PdfcraftExe]" Type="string" />
@@ -73,6 +76,24 @@
<RegistryValue Root="HKLM" Key="Software\RegisteredApplications" Name="PdfCraft" Value="Software\PdfCraft\Capabilities" Type="string" />
</Component>
<!-- Shortcuts (#143) are plain links to pdfcraft.exe, not advertised MSI shortcuts: advertised
ones point at a Windows Installer descriptor rather than the program, which Windows can't
use as a file handler (#107), and 0.2.1's advertised one went missing from the Start menu
(#143). Each has its own component with an HKLM key path, so Windows Installer removes it
on uninstall; in this per-machine package both folders are the all-users ones. -->
<Component Id="PdfcraftStartMenuShortcut" Directory="ProgramMenuFolder" Guid="DC028192-C233-4F99-9A77-ED458C60DF26">
<Shortcut Id="StartMenuShortcut" Name="PdfCraft" Description="PdfCraft PDF workbench"
Target="[#PdfcraftExe]" WorkingDirectory="INSTALLFOLDER" Icon="PdfcraftIcon.ico" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\PdfCraft\Shortcuts" Name="StartMenu" Type="integer" Value="1" KeyPath="yes" />
</Component>
<Component Id="PdfcraftDesktopShortcut" Directory="DesktopFolder" Guid="F6948627-32AB-4D78-8865-0FDF417CE442"
Condition="INSTALLDESKTOPSHORTCUT = 1">
<Shortcut Id="DesktopShortcut" Name="PdfCraft" Description="PdfCraft PDF workbench"
Target="[#PdfcraftExe]" WorkingDirectory="INSTALLFOLDER" Icon="PdfcraftIcon.ico" IconIndex="0" />
<RegistryValue Root="HKLM" Key="Software\PdfCraft\Shortcuts" Name="Desktop" Type="integer" Value="1" KeyPath="yes" />
</Component>
<Component Id="PdfcraftCli">
<File Id="PdfcraftCliExe" Source="$(var.BinDir)\pdfcraft-cli.exe" KeyPath="yes" />
</Component>
+75
View File
@@ -0,0 +1,75 @@
<#
.SYNOPSIS
Check the compiled MSI's shortcuts, desktop-shortcut checkbox and full-UI outcome wiring (#143), without installing it.
.EXAMPLE
pwsh packaging/windows/test-msi.ps1 dist/release/pdfcraft-0.2.1-windows-x64.msi
#>
param([Parameter(Mandatory)] [string] $Path)
$ErrorActionPreference = 'Stop'
$Installer = New-Object -ComObject WindowsInstaller.Installer
$Database = $Installer.OpenDatabase((Resolve-Path -LiteralPath $Path).Path, 0)
function Read-Row([string] $Sql, [int] $Columns) {
$view = $Database.OpenView($Sql)
try {
[void] $view.Execute()
$record = $view.Fetch()
if (-not $record) { throw "MSI row missing: $Sql" }
$values = @(for ($i = 1; $i -le $Columns; $i++) { $record.StringData($i) })
return ,$values
} finally { [void] $view.Close() }
}
function Assert-Equal($Actual, $Expected, [string] $What) {
if ($Actual -cne $Expected) { throw "$What`: expected '$Expected', got '$Actual'" }
}
# Plain (non-advertised) shortcuts to pdfcraft.exe, each in its own component; the desktop one is
# gated by INSTALLDESKTOPSHORTCUT, which defaults to 1 and is secure so the UI choice reaches the
# elevated install.
foreach ($entry in @(@('StartMenuShortcut', 'ProgramMenuFolder', 'PdfcraftStartMenuShortcut', ''),
@('DesktopShortcut', 'DesktopFolder', 'PdfcraftDesktopShortcut', 'INSTALLDESKTOPSHORTCUT = 1'))) {
$row = Read-Row ('SELECT `Directory_`, `Target`, `Icon_`, `WkDir`, `Component_` FROM `Shortcut` WHERE `Shortcut` = ''' + $entry[0] + '''') 5
Assert-Equal $row[0] $entry[1] "$($entry[0]) directory"
Assert-Equal $row[1] '[#PdfcraftExe]' "$($entry[0]) target"
Assert-Equal $row[2] 'PdfcraftIcon.ico' "$($entry[0]) icon"
Assert-Equal $row[3] 'INSTALLFOLDER' "$($entry[0]) working directory"
Assert-Equal $row[4] $entry[2] "$($entry[0]) component"
$component = Read-Row ('SELECT `Directory_`, `Condition` FROM `Component` WHERE `Component` = ''' + $entry[2] + '''') 2
Assert-Equal $component[0] $entry[1] "$($entry[2]) directory"
Assert-Equal $component[1] $entry[3] "$($entry[2]) condition"
}
$default = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLDESKTOPSHORTCUT''' 1
Assert-Equal $default[0] '1' 'Desktop shortcut default'
$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
Assert-Equal $checkbox[0] 'CheckBox' 'Welcome desktop-shortcut control'
Assert-Equal $checkbox[1] 'INSTALLDESKTOPSHORTCUT' 'Welcome checkbox property'
if ($checkbox[2] -notmatch 'desktop shortcut') { throw "Welcome checkbox label: '$($checkbox[2])'" }
$checked = Read-Row 'SELECT `Value` FROM `CheckBox` WHERE `Property` = ''INSTALLDESKTOPSHORTCUT''' 1
Assert-Equal $checked[0] '1' 'Welcome checkbox value'
$app = Read-Row 'SELECT `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftApp''' 1
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'
# Negative sequences are Windows Installer's success/user-exit/failure paths. Only full UI
# shows these dialogs: an unattended /qn or /qb install must never wait for a Finish click.
foreach ($exit in @(@('InstallComplete', '-1'), @('InstallCancelled', '-2'), @('InstallFailed', '-3'))) {
$row = Read-Row ('SELECT `Condition`, `Sequence` FROM `InstallUISequence` WHERE `Action` = ''' + $exit[0] + '''') 2
Assert-Equal $row[0] 'UILevel = 5' "$($exit[0]) UI level"
Assert-Equal $row[1] $exit[1] "$($exit[0]) exit path"
$finish = Read-Row ('SELECT `Type`, `Text` FROM `Control` WHERE `Dialog_` = ''' + $exit[0] + ''' AND `Control` = ''Finish''') 2
Assert-Equal $finish[0] 'PushButton' "$($exit[0]) Finish control"
Assert-Equal $finish[1] '&Finish' "$($exit[0]) Finish label"
$endDialog = Read-Row ('SELECT `Argument` FROM `ControlEvent` WHERE `Dialog_` = ''' + $exit[0] + ''' AND `Control_` = ''Finish'' AND `Event` = ''EndDialog''') 1
Assert-Equal $endDialog[0] 'Return' "$($exit[0]) Finish event"
}
$title = Read-Row 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallComplete'' AND `Control` = ''Title''' 1
if ($title[0] -notmatch 'completed successfully') { throw 'Success dialog does not confirm completion' }
$rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse''' 1
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: Start Menu shortcut, optional desktop shortcut (default on, checkbox), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog'