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" }
+ }
+}