From 32a358180cf512b01ab60f60a89d99edff4d697c Mon Sep 17 00:00:00 2001 From: THEtheChad <880190+THEtheChad@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:42:56 +0000 Subject: [PATCH] fix(windows): #399 preserve legacy MSI install folders Fall back to the parent directory of the App Paths executable when an older installer did not write InstallFolder. Keep explicit destinations and the newer stored folder ahead of the legacy fallback. Exercise the real MSI searches and actions with isolated per-user probes on x64 and x86, including legacy paths with spaces and Unicode. Co-Authored-By: GPT-6 Astra --- .github/workflows/packaging-lint.yml | 14 +++ packaging/windows/lightcraft.wxs | 10 ++ packaging/windows/test-install-folder.ps1 | 115 ++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 packaging/windows/test-install-folder.ps1 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" } + } +}