From 32df17722c40e5adc263bd8571098914ff326d10 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:43 +0000 Subject: [PATCH 1/8] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 +++ packaging/windows/check-install-policy.ps1 | 195 ++++++++++++ packaging/windows/install-policy.json | 329 +++++++++++++++++++++ packaging/windows/install-policy.wxi | 54 ++++ packaging/windows/installer-ui.wxs | 26 +- packaging/windows/package.ps1 | 2 + packaging/windows/pdfcraft.wxs | 180 +++++------ packaging/windows/test-install-policy.ps1 | 54 ++++ packaging/windows/test-msi.ps1 | 68 ++--- 10 files changed, 798 insertions(+), 164 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..7e847137f1 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,22 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 0000000000..982a5b4202 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 0000000000..dd28956435 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 0000000000..374bd4e5fe --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,329 @@ +{ + "id": "pdfcraft", + "name": "PdfCraft", + "upgradeCode": "EA00C367-7270-4C50-9D8B-A7A5C88AD27F", + "sources": [ + "pdfcraft.wxs", + "installer-ui.wxs" + ], + "icon": "assets/app-icon/pdfcraft.ico", + "localization": [], + "extensions": [], + "payloads": [ + "LICENSE-APACHE", + "LICENSE-MIT", + "LICENSE-document.txt", + "NOTICE", + "pdfcraft-cli.exe", + "pdfcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pdfcraft.exe", + "", + "[#PdfcraftExe]" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "", + "PDF Document" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\PdfCraft.Document", + "TypeOverlay", + "" + ], + [ + "-1", + "Software\\Classes\\.pdf\\OpenWithProgids", + "PdfCraft.Document", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe", + "FriendlyAppName", + "PdfCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\DefaultIcon", + "", + "\"[#PdfcraftExe]\",-2" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\SupportedTypes", + ".pdf", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\pdfcraft.exe\\shell\\open\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationName", + "PdfCraft" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationDescription", + "Open-source PDF workbench" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities", + "ApplicationIcon", + "[#PdfcraftExe],0" + ], + [ + "-1", + "Software\\PdfCraft\\Capabilities\\FileAssociations", + ".pdf", + "PdfCraft.Document" + ], + [ + "-1", + "Software\\RegisteredApplications", + "PdfCraft", + "Software\\PdfCraft\\Capabilities" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "StartMenu", + "#1" + ], + [ + "-1", + "Software\\PdfCraft\\Shortcuts", + "Desktop", + "#1" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "", + "Edit with PdfCraft" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "Icon", + "\"[#PdfcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit", + "MultiSelectModel", + "Document" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.pdf\\shell\\PdfCraft.Edit\\command", + "", + "\"[#PdfcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.png\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpeg\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.tiff\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.gif\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.bmp\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jp2\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.j2k\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "", + "Create PDF with PdfCraft\u2026" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf", + "MultiSelectModel", + "Single" + ], + [ + "-1", + "Software\\Classes\\SystemFileAssociations\\.jpx\\shell\\PdfCraft.CreatePdf\\command", + "", + "\"[#PdfcraftExe]\" --create-images \"%1\"" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 0000000000..e1a51f80f3 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index a437c2a8c7..cb7b486d9e 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -1,6 +1,4 @@ - @@ -10,12 +8,9 @@ - - - + + + @@ -26,8 +21,7 @@ - + @@ -66,8 +60,7 @@ - + @@ -75,8 +68,7 @@ - + @@ -84,8 +76,7 @@ - + @@ -96,8 +87,7 @@ continues; Cancel ends setup. --> - + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index dccc21f559..e9b9e4432b 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -154,6 +154,8 @@ Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $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 diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 65e76f254b..c5881401db 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -1,37 +1,16 @@ - - - + + + - - + - + - - + + - - - - - - - - - - - + + + + + + + + + + + + - - + + - - - + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 0000000000..21199ca7f4 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 24a4c6d75a..59b1c13696 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -38,41 +38,11 @@ function Assert-NoRow([string] $Sql, [string] $What) { } finally { [void] $view.Close() } } -# Test the compiled condition with Windows Installer's evaluator, in a restricted session that -# cannot change machine state. Normal installs/repairs work, per-user overrides fail, and removal -# of an older incorrectly scoped installation remains possible (#305). -$scopeMessage = 'PdfCraft must be installed for all users. Run setup with administrator privileges and ALLUSERS=1; per-user installation is not supported.' -$scopeCondition = Read-Row ('SELECT `Condition` FROM `LaunchCondition` WHERE `Description` = ''' + $scopeMessage + '''') 1 +# The shared compiled contract covers both scopes, duplicate guards and safe maintenance. +# Keep it in a separate process because Windows Installer permits one session per process. +& (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Path +if ($LASTEXITCODE -ne 0) { throw 'Dual-scope installer contract failed.' } $Installer.UILevel = 2 -$session = $null -try { - $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) - foreach ($case in @( - @('machine install', '1', '', '', '', 1), - @('machine repair', '1', '', '1', '', 1), - @('forced per-user install', '2', '1', '', '', 0), - @('empty ALLUSERS', '', '', '', '', 0), - @('resolved per-user install', '', '1', '', '', 0), - @('forced per-user repair', '2', '1', '1', '', 0), - @('machine uninstall', '1', '', '1', 'ALL', 1), - @('legacy per-user uninstall', '', '1', '1', 'ALL', 1))) { - $session.Property('ALLUSERS') = $case[1] - $session.Property('MSIINSTALLPERUSER') = $case[2] - $session.Property('Installed') = $case[3] - $session.Property('REMOVE') = $case[4] - Assert-Equal ($session.EvaluateCondition($scopeCondition[0])) $case[5] $case[0] - } -} finally { - if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } -} -foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { - $launch = Read-Row ('SELECT `Condition`, `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''LaunchConditions''') 2 - Assert-Equal $launch[0] '' "$sequence launch conditions are unconditional" - $cost = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''CostInitialize''') 1 - if ([int] $launch[1] -le 0 -or [int] $launch[1] -ge [int] $cost[0]) { - throw "$sequence must reject per-user overrides before costing" - } -} $manufacturer = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''Manufacturer''' 1 Assert-Equal $manufacturer[0] 'Learning Machines LLC' 'MSI manufacturer' @@ -173,7 +143,7 @@ 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' +Assert-Equal $scope[0] '2' 'Dual-scope shortcut package' # 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 @@ -181,12 +151,22 @@ Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' $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" + foreach ($context in @(@('M', '2'), @('U', '1'))) { + $cache = '' + $loaders = $Database.OpenView('SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''' + $Found + '''') + try { + [void]$loaders.Execute() + while ($row = $loaders.Fetch()) { + if ($row.StringData(1) -match ('^CraftLoadOption[0-9]+' + $context[0] + '$')) { $cache = $row.StringData(2).Trim('[]') } + } + } finally { [void]$loaders.Close() } + if (-not $cache) { throw "$What has no context-specific loader." } + $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $cache + '''') 4 + Assert-Equal $search[0] $context[1] "$What context $($context[0]) search root" + Assert-Equal $search[1] $Key "$What search key" + Assert-Equal $search[2] '' "$What search reads the default value" + 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" @@ -213,7 +193,7 @@ foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2 $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] 'PdfcraftImageContextMenu' "$ext context menu component" - Assert-Equal $menu[2] '2' "$ext context menu HKLM root" + Assert-Equal $menu[2] '-1' "$ext context menu context-sensitive root" $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" Assert-Equal $command[1] 'PdfcraftImageContextMenu' "$ext context menu command component" @@ -246,7 +226,7 @@ $pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' $pdfMenu = Read-Row ('SELECT `Registry`, `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` IS NULL') 4 Assert-Equal $pdfMenu[1] 'Edit with PdfCraft' 'PDF context menu label' Assert-Equal $pdfMenu[2] 'PdfcraftPdfContextMenu' 'PDF context menu component' -Assert-Equal $pdfMenu[3] '2' 'PDF context menu HKLM root' +Assert-Equal $pdfMenu[3] '-1' 'PDF context menu context-sensitive root' foreach ($value in @(@('Icon', '"[#PdfcraftExe]",0'), @('MultiSelectModel', 'Document'))) { $row = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $pdfKey + ''' AND `Name` = ''' + $value[0] + '''') 2 Assert-Equal $row[0] $value[1] "PDF context menu $($value[0])" @@ -299,4 +279,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 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' +Write-Output 'ok MSI: dual-scope guards, 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' From c39a0f36586a9a04e0996cda47732afea6e822ce Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 10:49:45 +0000 Subject: [PATCH 2/8] fix(installer): address scope and ARM64 review feedback --- .github/workflows/windows-arm64.yml | 6 +- packaging/windows/check-install-policy.ps1 | 66 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 6 +- packaging/windows/installer-ui.wxs | 21 +++++-- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d470419d26..85238e99d7 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 with: app-name: pdfcraft app-dir: PdfCraft + install-relative-path: Programs/ArtCraft/PdfCraft + install-scope: perUser diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index dd28956435..00b683d708 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -131,9 +131,12 @@ try { } $ui = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } - if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { - throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } @@ -144,7 +147,18 @@ try { } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) - foreach ($choice in @('user','machine','user')) { + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { Set-SessionProperty $session 'CRAFT_SCOPE' $choice foreach ($event in $events) { if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } @@ -176,6 +190,52 @@ try { if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } } } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index e1a51f80f3..79e46664cb 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -50,5 +50,9 @@ - + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index cb7b486d9e..7bea8562f8 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -8,10 +8,14 @@ - + + - + + + + @@ -22,12 +26,21 @@ - + - + + + + + + + + + + From c1670d0aa535226e6567f461a62d81eebfe2d4c5 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:46:22 +0000 Subject: [PATCH 3/8] fix(installer): keep the desktop shortcut on a machine-wide update; harden install-policy checks --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/pdfcraft.wxs | 15 ++++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 7e847137f1..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -35,8 +37,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d708..451295a2e4 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index c5881401db..44f6012ba0 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -27,12 +27,21 @@ updates with /passive and no properties, so deleting the shortcut is the only way its users can say no. A value given on the command line wins. --> - - + + + - + + + + + + + + + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From bf353cb0d9a38084ff461ebeca33546ee064f151 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:13:37 +0000 Subject: [PATCH 7/8] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc648..cc9416e9c9 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From dfb74c7990d14d96f63199f1f782495931915e6a Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:52:54 +0000 Subject: [PATCH 8/8] ci: use storytold/craft-common-actions windows-arm64 workflow --- .github/workflows/windows-arm64.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 85238e99d7..838af4524c 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,7 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: pdfcraft app-dir: PdfCraft