From a82ecb375369e12a8cd56dc6597b2944ef91c430 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 00:18:22 -0500 Subject: [PATCH] fix(windows): #399 ignore stale App Paths values in the install-folder fallback The App Paths directory search doesn't check that lightcraft.exe exists, and when the old installers' unquoted path is gone, Windows Installer falls back to the text before the first space: a deleted C:\Program Files\LightCraft\lightcraft.exe resolved to C:\, so an upgrade would install into the drive root. Gate the fallback on a second search of the same value with a FileSearch for lightcraft.exe. The install-folder test gains legacy-folder-deleted (failed before this fix) and legacy-exe-deleted cases, and removes its empty parent registry key. Co-Authored-By: Claude Opus 5.5 --- packaging/windows/lightcraft.wxs | 12 +++++++++- packaging/windows/test-install-folder.ps1 | 28 ++++++++++++++++------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packaging/windows/lightcraft.wxs b/packaging/windows/lightcraft.wxs index f1be9d04..a2025409 100644 --- a/packaging/windows/lightcraft.wxs +++ b/packaging/windows/lightcraft.wxs @@ -51,9 +51,19 @@ + + + + + + + Condition="LEGACYINSTALLFOLDER AND LEGACYINSTALLEXE AND NOT PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" /> diff --git a/packaging/windows/test-install-folder.ps1 b/packaging/windows/test-install-folder.ps1 index 56b41786..be7733cd 100644 --- a/packaging/windows/test-install-folder.ps1 +++ b/packaging/windows/test-install-folder.ps1 @@ -12,7 +12,8 @@ $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false $id = [Guid]::NewGuid().ToString('N') $root = Join-Path ([IO.Path]::GetTempPath()) "lightcraft-install-folder-$id" -$key = "Software\LightCraftInstallerTests\$id" +$testsKey = 'Software\LightCraftInstallerTests' +$key = "$testsKey\$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 } @@ -72,20 +73,25 @@ try { $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') + $emptied = Join-Path $root 'emptied app' + New-Item -ItemType Directory -Path $legacy, $modern, $explicit, $emptied | Out-Null + $legacyExe = Join-Path $legacy 'lightcraft.exe' + [IO.File]::WriteAllText($legacyExe, '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 = 'legacy-app-paths'; Legacy = $legacyExe; Expected = $legacy }, + # A stale unquoted path with a space used to resolve to the parent of the text before the space. + @{ Name = 'legacy-folder-deleted'; Legacy = (Join-Path $root 'deleted app\lightcraft.exe'); Expected = (Join-Path $programFiles 'LightCraft') }, + @{ Name = 'legacy-exe-deleted'; Legacy = (Join-Path $emptied 'lightcraft.exe'); Expected = (Join-Path $programFiles 'LightCraft') }, @{ 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 } + @{ Name = 'remembered-beats-legacy'; Modern = $true; Legacy = $legacyExe; Expected = $modern }, + @{ Name = 'explicit-beats-legacy'; Legacy = $legacyExe; Explicit = $true; Expected = $explicit }, + @{ Name = 'explicit-beats-both'; Modern = $true; Legacy = $legacyExe; 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.Legacy) { Set-TestValue 'Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe' '' $case.Legacy } if ($case.Modern) { Set-TestValue 'Software\LightCraft' 'InstallFolder' "$modern\" } $arguments = @('/i', "`"$msi`"") if ($case.Explicit) { $arguments += "INSTALLFOLDER=`"$explicit`"" } @@ -108,6 +114,12 @@ try { if ($installed) { Invoke-Msi @('/x', $product) (Join-Path $root 'cleanup.log') -AllowAbsent } } finally { $registry.DeleteSubKeyTree($key, $false) + $tests = $registry.OpenSubKey($testsKey) + if ($tests) { + $empty = $tests.SubKeyCount -eq 0 -and $tests.ValueCount -eq 0 + $tests.Dispose() + if ($empty) { $registry.DeleteSubKey($testsKey, $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" }