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 1/3] 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" } + } +} From a82ecb375369e12a8cd56dc6597b2944ef91c430 Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 00:18:22 -0500 Subject: [PATCH 2/3] 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" } From 8c8467707950ce8fce3678cdcd7b7fd7744d370f Mon Sep 17 00:00:00 2001 From: Chad Elliott Date: Sun, 11 Oct 2026 00:46:21 -0500 Subject: [PATCH 3/3] docs(windows): #399 note the pre-0.6 install-folder upgrade fix Add a What's New entry for keeping the install folder when upgrading from a pre-0.6 Windows installer, and describe the new Windows MSI job in the Packaging lint workflow header. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/packaging-lint.yml | 6 ++++-- docs/whats-new.md | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 6ac5b12c..f39de00b 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -1,5 +1,7 @@ -# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, -# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. +# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging +# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a +# Windows job that builds and installs throwaway per-user MSI probes for the install-folder +# searches (a few minutes). name: Packaging lint on: diff --git a/docs/whats-new.md b/docs/whats-new.md index f3fb3a14..50c99f3f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-11 + +### Reliability +- Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed + in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade + installs to Program Files. + ## 2026-10-10 ### Activity stack