diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 46ce3c90..6ac5b12c 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -71,3 +71,17 @@ jobs: EOF - name: FreeBSD tarball layout (stub binaries) run: bash packaging/freebsd/package.sh --dry-run + + windows-install-folder: + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Install-folder fallback and precedence (real MSI AppSearch) + shell: pwsh + run: | + ./packaging/windows/test-install-folder.ps1 -Arch x64 + ./packaging/windows/test-install-folder.ps1 -Arch x86 diff --git a/packaging/windows/lightcraft.wxs b/packaging/windows/lightcraft.wxs index ce98a714..f1be9d04 100644 --- a/packaging/windows/lightcraft.wxs +++ b/packaging/windows/lightcraft.wxs @@ -44,6 +44,16 @@ + + + + + diff --git a/packaging/windows/test-install-folder.ps1 b/packaging/windows/test-install-folder.ps1 new file mode 100644 index 00000000..56b41786 --- /dev/null +++ b/packaging/windows/test-install-folder.ps1 @@ -0,0 +1,115 @@ +<# + Exercise the install-folder searches and actions from lightcraft.wxs with real MSI AppSearch. + Requires Windows and WiX 5.0.2. No application binaries, administrator rights, or real + LightCraft installation are needed: a per-user probe writes only to a unique test registry key. + Run: pwsh packaging/windows/test-install-folder.ps1 [-Arch x86|x64|arm64] [-Wix path/to/wix.exe] +#> +param( + [ValidateSet('x86', 'x64', 'arm64')][string] $Arch = 'x64', + [string] $Wix = 'wix' +) +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +$id = [Guid]::NewGuid().ToString('N') +$root = Join-Path ([IO.Path]::GetTempPath()) "lightcraft-install-folder-$id" +$key = "Software\LightCraftInstallerTests\$id" +$product = [Guid]::NewGuid().ToString('B') +$upgrade = [Guid]::NewGuid().ToString('B') +$view = if ($Arch -eq 'x86') { [Microsoft.Win32.RegistryView]::Registry32 } else { [Microsoft.Win32.RegistryView]::Registry64 } +$registry = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::CurrentUser, $view) +$installed = $false +$passed = $false +New-Item -ItemType Directory -Path $root | Out-Null + +function Invoke-Msi([string[]] $Arguments, [string] $Log, [switch] $AllowAbsent) { + $p = Start-Process msiexec.exe -ArgumentList ($Arguments + @('/qn', '/norestart', '/l*v', "`"$Log`"")) -Wait -PassThru + if ($p.ExitCode -ne 0 -and -not ($AllowAbsent -and $p.ExitCode -eq 1605)) { + Get-Content $Log -Tail 40 + throw "msiexec exited $($p.ExitCode)" + } +} + +function Set-TestValue([string] $Subkey, [string] $Name, [string] $Value) { + $k = $registry.CreateSubKey("$key\$Subkey") + try { $k.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) } finally { $k.Dispose() } +} + +try { + [xml] $source = Get-Content (Join-Path $PSScriptRoot 'lightcraft.wxs') -Raw + $ns = [Xml.XmlNamespaceManager]::new($source.NameTable) + $ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') + # Copy the real search/action authoring, changing only registry roots/keys for isolation. + # Do not reimplement folder resolution here: removing the fallback must fail the legacy case. + $nodes = $source.SelectNodes('/w:Wix/w:Package/w:Property[w:RegistrySearch] | /w:Wix/w:Package/w:SetProperty[@Id="INSTALLFOLDER"]', $ns) + $authoring = foreach ($node in $nodes) { + $copy = $node.CloneNode($true) + foreach ($search in $copy.SelectNodes('.//w:RegistrySearch', $ns)) { + $search.SetAttribute('Root', 'HKCU') + $search.SetAttribute('Key', "$key\$($search.GetAttribute('Key'))") + } + $copy.OuterXml + } + $directory = $source.SelectSingleNode('/w:Wix/w:Package/w:StandardDirectory[w:Directory[@Id="INSTALLFOLDER"]]', $ns).OuterXml + $probe = @" + + + $($authoring -join "`n") + $directory + + + + + + +"@ + $wxs = Join-Path $root 'probe.wxs' + $msi = Join-Path $root 'probe.msi' + $probe | Set-Content $wxs -Encoding utf8 + & $Wix build $wxs -arch $Arch -o $msi + if ($LASTEXITCODE -ne 0) { throw 'Could not build the folder-search probe' } + + $legacy = Join-Path $root 'legacy app café' + $modern = Join-Path $root 'remembered app' + $explicit = Join-Path $root 'explicit app' + New-Item -ItemType Directory -Path $legacy, $modern, $explicit | Out-Null + [IO.File]::WriteAllText((Join-Path $legacy 'lightcraft.exe'), 'test placeholder; never executed') + $programFiles = if ($Arch -eq 'x86' -and ${env:ProgramFiles(x86)}) { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + $cases = @( + @{ Name = 'fresh'; Expected = (Join-Path $programFiles 'LightCraft') }, + @{ Name = 'legacy-app-paths'; Legacy = $true; Expected = $legacy }, + @{ Name = 'remembered-folder'; Modern = $true; Expected = $modern }, + @{ Name = 'remembered-beats-legacy'; Modern = $true; Legacy = $true; Expected = $modern }, + @{ Name = 'explicit-beats-legacy'; Legacy = $true; Explicit = $true; Expected = $explicit }, + @{ Name = 'explicit-beats-both'; Modern = $true; Legacy = $true; Explicit = $true; Expected = $explicit } + ) + foreach ($case in $cases) { + $registry.DeleteSubKeyTree($key, $false) + if ($case.Legacy) { Set-TestValue 'Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe' '' (Join-Path $legacy 'lightcraft.exe') } + if ($case.Modern) { Set-TestValue 'Software\LightCraft' 'InstallFolder' "$modern\" } + $arguments = @('/i', "`"$msi`"") + if ($case.Explicit) { $arguments += "INSTALLFOLDER=`"$explicit`"" } + # Also attempt cleanup if an installation only partially completes. + $installed = $true + Invoke-Msi $arguments (Join-Path $root "$($case.Name)-install.log") + $result = $registry.OpenSubKey("$key\Result") + if (-not $result) { throw "$($case.Name): no result recorded" } + try { $actual = [string] $result.GetValue('Folder') } finally { $result.Dispose() } + if ($actual.TrimEnd('\') -ine $case.Expected.TrimEnd('\')) { + throw "$($case.Name): expected '$($case.Expected)', got '$actual'" + } + Invoke-Msi @('/x', $product) (Join-Path $root "$($case.Name)-uninstall.log") + $installed = $false + Write-Output "ok $Arch $($case.Name)" + } + $passed = $true +} finally { + try { + if ($installed) { Invoke-Msi @('/x', $product) (Join-Path $root 'cleanup.log') -AllowAbsent } + } finally { + $registry.DeleteSubKeyTree($key, $false) + $registry.Dispose() + # Keep logs on failure; they explain MSI failures that otherwise only report an exit code. + if ($passed) { Remove-Item -Recurse -Force $root } else { Write-Output "Test artifacts retained: $root" } + } +}