diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a074e6dbe..eee6564ea 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -28,8 +28,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: effectcraft app-dir: EffectCraft + install-relative-path: Programs/ArtCraft/EffectCraft + install-scope: perUser diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index dd2895643..00b683d70 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 9320cd084..7ed031649 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 876aaa519..17c7efd0b 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -66,7 +66,10 @@ - + + + + @@ -78,7 +81,7 @@ - + @@ -86,7 +89,19 @@ - + + + + + + + + + + + diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 21199ca7f..d0b9a73bf 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -12,6 +12,7 @@ $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 +& (Join-Path $PSScriptRoot 'test-legacy-fixture.ps1') -Work (Join-Path $output 'legacy-source') Add-Type -AssemblyName System.Drawing foreach ($arch in $Architectures) { $stage = Join-Path $output $arch diff --git a/packaging/windows/test-legacy-fixture.ps1 b/packaging/windows/test-legacy-fixture.ps1 new file mode 100644 index 000000000..44336dca9 --- /dev/null +++ b/packaging/windows/test-legacy-fixture.ps1 @@ -0,0 +1,21 @@ +<# Exercise the legacy source transformation without building or installing. #> +param([Parameter(Mandatory = $true)] [string] $Work) +$ErrorActionPreference = 'Stop' +[void](New-Item -ItemType Directory -Force -Path $Work) +$source = Get-Content -Raw (Join-Path $PSScriptRoot 'test-msi.ps1') +$start = $source.IndexOf('$Wxs =') +$end = $source.IndexOf('$Msi = @{}', $start) +if ($start -lt 0 -or $end -le $start) { throw 'Could not locate the legacy source transformation.' } +# Run the test harness's exact source-producing block, with only scratch outputs. +$block = $source.Substring($start, $end - $start).Replace('$PSScriptRoot', ("'" + $PSScriptRoot.Replace("'", "''") + "'")) +Invoke-Expression $block +[xml]$legacy = Get-Content -Raw $LegacyWxs +$ns = New-Object Xml.XmlNamespaceManager($legacy.NameTable) +$ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') +if ($legacy.SelectNodes('//w:RegistryValue[@Name="Path" or @Name="InstallDir" or @Name="InstallFolder"]', $ns).Count) { + throw 'Legacy fixture must omit every modern folder record so discovery uses the executable fallback.' +} +if (-not $legacy.SelectNodes('//w:RegistryValue[contains(@Value,"EffectcraftExe")]', $ns).Count) { + throw 'Legacy fixture must retain its executable registration.' +} +Write-Output 'PASS legacy fixture exposes only the executable-path fallback.' diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index f4a88eec0..8f8396395 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -72,10 +72,10 @@ Copy-Item (Join-Path $env:SystemRoot 'System32\cmd.exe') (Join-Path $Bin 'effect [IO.File]::WriteAllText((Join-Path $Bin 'NOTICE'), 'Test fixture notice') # 0.0.1 stands in for releases before this installer change: the same package without the App -# Paths "Path" value. +# Paths "Path" value or modern InstallDir/InstallFolder records. $Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs' $LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs' -$pathValue = '(?m)^.*]*Name="Path"[^>]*/>\r?\n' +$pathValue = '(?m)^.*]*Name="(?:Path|InstallDir|InstallFolder)"[^>]*/>\r?\n' $text = Get-Content -Raw $Wxs if ($text -notmatch $pathValue) { throw "no App Paths Path value in $Wxs to leave out" } [IO.File]::WriteAllText($LegacyWxs, ($text -replace $pathValue, ''))