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 <noreply@openai.com>
This commit is contained in:
THEtheChad 2026-10-11 03:42:56 +00:00
parent 0ba118de92
commit 32a358180c
3 changed files with 139 additions and 0 deletions

View File

@ -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

View File

@ -44,6 +44,16 @@
</Property>
<SetProperty Id="INSTALLFOLDER" Value="[PREVIOUSINSTALLFOLDER]" After="AppSearch" Sequence="both"
Condition="PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" />
<!-- Older MSIs (including 0.5.0) only recorded the executable in App Paths. Type="file"
without a FileSearch returns its containing directory. Keep the searches separate so
the newer InstallFolder value and an explicit INSTALLFOLDER always take precedence. -->
<Property Id="LEGACYINSTALLFOLDER">
<RegistrySearch Id="LegacyInstallFolder" Root="HKLM"
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe" Type="file" />
</Property>
<SetProperty Id="INSTALLFOLDER" Action="SetInstallFolderFromAppPaths" Value="[LEGACYINSTALLFOLDER]"
After="SetINSTALLFOLDER" Sequence="both"
Condition="LEGACYINSTALLFOLDER AND NOT PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" />
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="LightCraft" Level="1">

View File

@ -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 = @"
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="LightCraft folder search test $id" Manufacturer="LightCraft tests"
Version="1.0.0" ProductCode="$product" UpgradeCode="$upgrade" Scope="perUser">
$($authoring -join "`n")
$directory
<Feature Id="Main"><ComponentRef Id="Result" /></Feature>
<Component Id="Result" Directory="INSTALLFOLDER" Guid="*">
<RegistryValue Root="HKCU" Key="$key\Result" Name="Folder" Value="[INSTALLFOLDER]" Type="string" KeyPath="yes" />
</Component>
</Package>
</Wix>
"@
$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" }
}
}