Packaging: upgrading a custom-folder install from 0.6.0 or earlier keeps its folder (#466)

Cause: the folder search read only the App Paths "Path" value, which this
branch introduced. 0.6.0 and earlier could already be installed elsewhere
with INSTALLFOLDER=..., but have no Path value, so an upgrade went to
Program Files while the wizard said it would stay in the same folder.

Fix: when there is no Path value, a second search reads the App Paths
default value (the full path of effectcraft.exe, written by every
release). A "file" registry search with no FileSearch inside yields the
folder of that file.

Tests: test-msi.ps1 installs a legacy package (the same .wxs without the
Path value) into a custom folder, then upgrades it without naming a
folder; it fails on the previous .wxs (the upgrade lands in Program
Files) and passes now. A further upgrade checks the Path value route.
This commit is contained in:
tylerjenningsw 2026-10-09 14:45:47 -04:00
parent 184df34875
commit 462913a483
2 changed files with 47 additions and 19 deletions

View File

@ -33,14 +33,23 @@
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
<!-- An upgrade, repair or silent reinstall goes where the installed copy already is: the App Paths
"Path" value written below. A folder passed on the command line (INSTALLFOLDER=...) wins. -->
"Path" value written below, or for releases before it (0.6.0 and earlier, which could be put
elsewhere with INSTALLFOLDER=...) the folder of the effectcraft.exe that App Paths names. A
"file" search with no FileSearch inside yields that file's folder. A folder passed on the
command line (INSTALLFOLDER=...) wins. -->
<Property Id="INSTALLFOLDER" Secure="yes" />
<Property Id="PREVIOUSINSTALLFOLDER" Secure="yes">
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Type="directory"
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Name="Path" />
</Property>
<Property Id="PREVIOUSEXEFOLDER" Secure="yes">
<RegistrySearch Id="PreviousExeFolder" Root="HKLM" Type="file"
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" />
</Property>
<SetProperty Id="INSTALLFOLDER" Value="[PREVIOUSINSTALLFOLDER]" After="AppSearch" Sequence="both"
Condition="PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" />
<SetProperty Id="INSTALLFOLDER" Action="SetINSTALLFOLDERFromExe" Value="[PREVIOUSEXEFOLDER]"
After="SetINSTALLFOLDER" Sequence="both" Condition="PREVIOUSEXEFOLDER AND NOT INSTALLFOLDER" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="EffectCraft" />

View File

@ -3,13 +3,15 @@
Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build).
.DESCRIPTION
Builds the MSI twice (versions 0.0.1 and 0.0.2) around two stand-in executables, validates both
(ICE), then, silently and per-machine:
Builds the MSI around two stand-in executables as 0.0.1 the way 0.6.0 and earlier were (no App
Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine:
1. installs 0.0.1 into a folder with spaces and a non-ASCII name (INSTALLFOLDER=...),
2. upgrades to 0.0.2 without naming a folder: it must stay in that folder,
3. repairs a deleted file there,
4. uninstalls: files and the App Paths registration go,
5. installs 0.0.2 with no folder: it must land in Program Files, then uninstalls.
2. upgrades to 0.0.2 without naming a folder: it must stay in that folder (found from the
installed effectcraft.exe, as there is no Path value yet),
3. upgrades to 0.0.3 the same way (found from the Path value 0.0.2 wrote),
4. repairs a deleted file there,
5. uninstalls: files and the App Paths registration go,
6. installs 0.0.3 with no folder: it must land in Program Files, then uninstalls.
The wizard itself needs a person (or the snapshot steps in the PR); this covers what msiexec /qn
and upgrades do with the folder. Needs an elevated shell and WiX v5 (`wix`) on PATH.
Run by .github/workflows/packaging-lint.yml.
@ -34,14 +36,18 @@ function Invoke-Msiexec([string] $What, [string[]] $Arguments) {
if ($p.ExitCode -ne 0) { Get-Content $log -Tail 60; throw "msiexec ($What) exited $($p.ExitCode)" }
Write-Output "ok $What"
}
function Assert-InstalledIn([string] $Dir) {
function Assert-InstalledIn([string] $Dir, [switch] $NoPathValue) {
foreach ($exe in 'effectcraft.exe', 'effectcraft-cli.exe') {
if (-not (Test-Path -LiteralPath (Join-Path $Dir $exe))) { throw "$exe is not in $Dir" }
}
$key = Get-ItemProperty -LiteralPath $AppPaths
$want = Join-Path $Dir 'effectcraft.exe'
if ($key.'(default)' -ne $want) { throw "App Paths points at '$($key.'(default)')', expected '$want'" }
if ($key.Path.TrimEnd('\') -ne $Dir.TrimEnd('\')) { throw "App Paths Path is '$($key.Path)', expected '$Dir'" }
if ($NoPathValue) {
if ($null -ne $key.Path) { throw "the legacy package wrote an App Paths Path value" }
} elseif ($key.Path.TrimEnd('\') -ne $Dir.TrimEnd('\')) {
throw "App Paths Path is '$($key.Path)', expected '$Dir'"
}
Write-Output "ok installed in $Dir"
}
@ -52,11 +58,20 @@ New-Item -ItemType Directory -Force -Path $Bin | Out-Null
Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe')
Copy-Item (Join-Path $env:SystemRoot 'System32\whoami.exe') (Join-Path $Bin 'effectcraft-cli.exe')
# 0.0.1 stands in for 0.6.0 and earlier: the same package without the App Paths "Path" value.
$Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs'
$LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs'
$pathValue = '(?m)^.*<RegistryValue [^>]*Name="Path"[^>]*/>\r?\n'
$text = Get-Content -Raw $Wxs
if ($text -notmatch $pathValue) { throw "no App Paths Path value in $Wxs to leave out" }
[IO.File]::WriteAllText($LegacyWxs, ($text -replace $pathValue, ''))
$Msi = @{}
foreach ($v in '0.0.1', '0.0.2') {
foreach ($v in '0.0.1', '0.0.2', '0.0.3') {
$Msi[$v] = Join-Path $Work "effectcraft-$v.msi"
$src = if ($v -eq '0.0.1') { $LegacyWxs } else { $Wxs }
Invoke-Native "wix build $v" {
wix build (Join-Path $PSScriptRoot 'effectcraft.wxs') (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch x64 `
wix build $src (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch x64 `
-d "Version=$v" -d "BinDir=$Bin" -d "IconPath=$(Join-Path $Root 'assets\app-icon\effectcraft.ico')" -o $Msi[$v]
}
Invoke-Native "wix msi validate $v" { wix msi validate $Msi[$v] }
@ -67,27 +82,31 @@ $Custom = Join-Path $env:SystemDrive "EffectCraft Test $([char]0x00DC)nicode\My
$Default = Join-Path $env:ProgramFiles 'EffectCraft'
if (Test-Path -LiteralPath $AppPaths) { throw 'EffectCraft is already installed here; run this on a clean machine' }
Invoke-Msiexec 'install to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"")
Assert-InstalledIn $Custom
Invoke-Msiexec 'install the legacy package to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"")
Assert-InstalledIn $Custom -NoPathValue
if (Test-Path -LiteralPath $Default) { throw "a custom install also wrote $Default" }
Invoke-Msiexec 'upgrade without a folder' @('/i', "`"$($Msi['0.0.2'])`"")
Invoke-Msiexec 'upgrade the legacy install without a folder' @('/i', "`"$($Msi['0.0.2'])`"")
Assert-InstalledIn $Custom
if (Test-Path -LiteralPath $Default) { throw "the upgrade moved the install to $Default" }
Invoke-Msiexec 'upgrade again without a folder' @('/i', "`"$($Msi['0.0.3'])`"")
Assert-InstalledIn $Custom
# Read single values: Get-ItemProperty throws on some machines' malformed Uninstall entries.
$arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' |
ForEach-Object { if ($_.GetValue('DisplayName') -eq 'EffectCraft') { $_.GetValue('DisplayVersion') } })
if ($arp.Count -ne 1 -or $arp[0] -ne '0.0.2') { throw "expected one EffectCraft 0.0.2 in Apps, found: $($arp -join ', ')" }
if ($arp.Count -ne 1 -or $arp[0] -ne '0.0.3') { throw "expected one EffectCraft 0.0.3 in Apps, found: $($arp -join ', ')" }
Remove-Item -LiteralPath (Join-Path $Custom 'effectcraft-cli.exe')
Invoke-Msiexec 'repair' @('/fa', "`"$($Msi['0.0.2'])`"")
Invoke-Msiexec 'repair' @('/fa', "`"$($Msi['0.0.3'])`"")
Assert-InstalledIn $Custom
Invoke-Msiexec 'uninstall the custom install' @('/x', "`"$($Msi['0.0.2'])`"")
Invoke-Msiexec 'uninstall the custom install' @('/x', "`"$($Msi['0.0.3'])`"")
if (Test-Path -LiteralPath (Join-Path $Custom 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' }
if (Test-Path -LiteralPath $AppPaths) { throw 'uninstall left the App Paths registration behind' }
Invoke-Msiexec 'install to the default folder' @('/i', "`"$($Msi['0.0.2'])`"")
Invoke-Msiexec 'install to the default folder' @('/i', "`"$($Msi['0.0.3'])`"")
Assert-InstalledIn $Default
Invoke-Msiexec 'uninstall the default install' @('/x', "`"$($Msi['0.0.2'])`"")
Invoke-Msiexec 'uninstall the default install' @('/x', "`"$($Msi['0.0.3'])`"")
if (Test-Path -LiteralPath (Join-Path $Default 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' }
Write-Output 'MSI folder handling ok'