From 8b8a57780f5b669d66ff68841547824042c30081 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 13:51:44 -0400 Subject: [PATCH 01/11] Packaging: Windows upgrades and repairs install into the existing folder (#466) The MSI records its folder as the App Paths "Path" value of effectcraft.exe and reads it back with a registry search, so an upgrade, a repair or a silent reinstall goes where the installed copy already is. A folder passed on the command line (INSTALLFOLDER=...) still wins, and INSTALLFOLDER is Secure so a folder chosen in the UI reaches the elevated install. v0.6.0 and earlier have no Path value and upgrade in Program Files, where they are. --- packaging/windows/effectcraft.wxs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index 5a70002b..ae1fd194 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -4,7 +4,7 @@ wix build effectcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi - Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by EffectCraft, + Per-machine install into Program Files (or a folder the user picks), a Start Menu shortcut, `.pcraft` owned by EffectCraft, and EffectCraft offered under "Open with" for PSD/PSB and the image formats it reads (without taking over the user's default app for them). --> @@ -30,6 +30,16 @@ + + + + + + + @@ -50,6 +60,7 @@ + From b32965f209770d9f85d2144b5703001c9175e0a1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 13:58:35 -0400 Subject: [PATCH 02/11] Packaging: the Windows installer asks where to install EffectCraft (#466) A fresh install opens on a destination page (Program Files by default; Change... browses, with Up and New folder), then a Ready page that shows the folder before anything is written. An upgrade goes straight to the Ready page with the installed copy's folder and can't move it; opening the MSI again offers Repair or Remove. Cancel, finish, files-in-use and error dialogs are authored too. Silent installs (/qn) show none of it. The dialogs (installer-ui.wxs) are original, built from Windows Installer's own controls and events, not the WixUI library, whose MS-RL licence is not on the allowed list. package.ps1 builds both .wxs files. --- packaging/windows/effectcraft.wxs | 4 +- packaging/windows/installer-ui.wxs | 198 +++++++++++++++++++++++++++++ packaging/windows/package.ps1 | 2 +- 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 packaging/windows/installer-ui.wxs diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index ae1fd194..7e5cf556 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -2,7 +2,7 @@ + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs new file mode 100644 index 00000000..9fd1d689 --- /dev/null +++ b/packaging/windows/installer-ui.wxs @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 18551225..7f1320c8 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -93,7 +93,7 @@ Copy-Item (Join-Path $Bin 'effectcraft.exe'), (Join-Path $Bin 'effectcraft-cli.e # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "effectcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'effectcraft.wxs') -arch $Arch ` + wix build (Join-Path $PSScriptRoot 'effectcraft.wxs') (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\effectcraft.ico')" ` -o $Msi } From 9ceda6a8f52d6892af15795b99ea12598e5b2722 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 13:59:46 -0400 Subject: [PATCH 03/11] CI: packaging lint checks every Windows .wxs file (#466) --- .github/workflows/packaging-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ea726d5d..81b150bc 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -46,7 +46,7 @@ jobs: } - name: XML, desktop entry, AppStream, Flatpak manifest run: | - xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* + xmllint --noout packaging/windows/*.wxs packaging/macos/*.plist* packaging/linux/*.xml* # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. if grep -oE ' Date: Fri, 9 Oct 2026 14:00:09 -0400 Subject: [PATCH 04/11] CI: the MSI is install-tested with a custom folder, upgrade, repair and uninstall (#466) test-msi.ps1 builds the MSI around stub binaries, validates it (ICE) and, silently: installs into a folder with spaces and a non-ASCII name, upgrades without naming a folder (it must stay there, one entry in Apps), repairs a deleted file, uninstalls (files and App Paths gone), and installs to the Program Files default. Run by a windows-latest job in packaging-lint.yml. --- .github/workflows/packaging-lint.yml | 16 ++++- packaging/windows/test-msi.ps1 | 91 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packaging/windows/test-msi.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 81b150bc..91764b06 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -1,5 +1,6 @@ # Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, -# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. Plus a +# Windows MSI install test with stub binaries (install folder, upgrade, repair, uninstall). name: Packaging lint on: @@ -66,3 +67,16 @@ jobs: EOF - name: FreeBSD package layout (stub binaries) run: bash packaging/freebsd/package.sh --dry-run + + windows-msi: + name: Windows MSI install folder (stub binaries) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Install, upgrade, repair and uninstall + shell: pwsh + run: ./packaging/windows/test-msi.ps1 diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 new file mode 100644 index 00000000..076a8bab --- /dev/null +++ b/packaging/windows/test-msi.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + 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: + 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. + 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. + +.EXAMPLE + pwsh packaging/windows/test-msi.ps1 +#> +param([string] $Work = (Join-Path ([IO.Path]::GetTempPath()) 'effectcraft-msi-test')) +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$AppPaths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe' + +function Invoke-Native([string] $What, [scriptblock] $Block) { + Write-Output "==> $What" + & $Block + if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" } +} +function Invoke-Msiexec([string] $What, [string[]] $Arguments) { + $log = Join-Path $Work "$($What -replace '\W', '-').log" + $p = Start-Process msiexec.exe -ArgumentList ($Arguments + '/qn', '/l*v', "`"$log`"") -Wait -PassThru + 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) { + 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'" } + Write-Output "ok installed in $Dir" +} + +Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue +$Bin = Join-Path $Work 'bin' +New-Item -ItemType Directory -Force -Path $Bin | Out-Null +# Any GUI and console executables will do: the MSI only copies them. +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') + +$Msi = @{} +foreach ($v in '0.0.1', '0.0.2') { + $Msi[$v] = Join-Path $Work "effectcraft-$v.msi" + Invoke-Native "wix build $v" { + wix build (Join-Path $PSScriptRoot 'effectcraft.wxs') (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] } +} + +# Spaces and a non-ASCII letter (written as a char so the script's own encoding doesn't matter). +$Custom = Join-Path $env:SystemDrive "EffectCraft Test $([char]0x00DC)nicode\My Apps" +$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 +if (Test-Path -LiteralPath $Default) { throw "a custom install also wrote $Default" } + +Invoke-Msiexec 'upgrade without a folder' @('/i', "`"$($Msi['0.0.2'])`"") +Assert-InstalledIn $Custom +$arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | + Get-ItemProperty | Where-Object { $_.DisplayName -eq 'EffectCraft' }) +if ($arp.Count -ne 1 -or $arp[0].DisplayVersion -ne '0.0.2') { throw "expected one EffectCraft 0.0.2 in Apps, found: $($arp.DisplayVersion -join ', ')" } + +Remove-Item -LiteralPath (Join-Path $Custom 'effectcraft-cli.exe') +Invoke-Msiexec 'repair' @('/fa', "`"$($Msi['0.0.2'])`"") +Assert-InstalledIn $Custom + +Invoke-Msiexec 'uninstall the custom install' @('/x', "`"$($Msi['0.0.2'])`"") +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'])`"") +Assert-InstalledIn $Default +Invoke-Msiexec 'uninstall the default install' @('/x', "`"$($Msi['0.0.2'])`"") +if (Test-Path -LiteralPath (Join-Path $Default 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } +Write-Output 'MSI folder handling ok' From 7dc2b7a38185909ac73f1009686374c8d931a2e5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:00:29 -0400 Subject: [PATCH 05/11] README Downloads: the Windows installer lets you pick the folder (#466) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a96364df..8dfe53b4 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,9 @@ send us right now. | arm64 (Snapdragon and other ARM PCs) | `effectcraft--windows-arm64.msi` | `effectcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `effectcraft--windows-x86.msi` | `effectcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. +Installers and executables are code-signed. The installer asks where to install EffectCraft +(Program Files by default), and later versions install into the same folder. To install silently +into a folder of your choice: `msiexec /i effectcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`. ### macOS From 5d58fd4029172664064a16523533d8346c92e5e1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:00:46 -0400 Subject: [PATCH 06/11] Docs: releasing.md describes the MSI install folder and its test (#466) --- docs/releasing.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/releasing.md b/docs/releasing.md index 746bc2ec..dfba8926 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -66,6 +66,13 @@ jobs are refused by the environment's branch rule, and no draft release is made. The ARM64 MSI is installed and run on ARM64 hardware by [`windows-arm64.yml`](../.github/workflows/windows-arm64.yml). +The MSI's setup wizard ([`installer-ui.wxs`](../packaging/windows/installer-ui.wxs)) lets people +choose the install folder; upgrades and repairs reuse it, and `INSTALLFOLDER="..."` picks it for a +silent (`/qn`) install. [`packaging-lint.yml`](../.github/workflows/packaging-lint.yml) install-tests +that with stub binaries ([`test-msi.ps1`](../packaging/windows/test-msi.ps1)): a custom folder, +an upgrade that keeps it, a repair, uninstall, and the Program Files default. The wizard pages +themselves are checked by hand. + Every binary reports its version: `effectcraft --version`, `effectcraft-cli --version` and *Help › About EffectCraft*. From 6c58eea643d297e1ec68cf31b21f454f4736f57f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:05:18 -0400 Subject: [PATCH 07/11] Contributors: credit tylerjenningsw as Tyler Jennings --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 441b42d7..3e3a84a4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -36,3 +36,8 @@ display_name = "ShirokoWaWu" [people.sanyamsingh001] display_name = "sanyamsingh001" sync_github_name = false + +[people.tylerjenningsw] +real_name = "Tyler Jennings" +display_name = "Tyler Jennings" +sync_github_name = false From 2412173b7e710118e22ebcf056914a4da519e071 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:05:49 -0400 Subject: [PATCH 08/11] Contributors: tylerjenningsw's display name is tylerjenningsw --- contributors/people.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index 3e3a84a4..ee210059 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -39,5 +39,5 @@ sync_github_name = false [people.tylerjenningsw] real_name = "Tyler Jennings" -display_name = "Tyler Jennings" +display_name = "tylerjenningsw" sync_github_name = false From 184df3487536ae3be21380411f18f591e449feea Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:11:53 -0400 Subject: [PATCH 09/11] CI: the MSI test reads Apps entries one value at a time (#466) Get-ItemProperty throws on a malformed Uninstall entry, which aborted the upgrade check on a real Windows 10 machine. Failures now print their message. --- packaging/windows/test-msi.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 076a8bab..2a0a8e91 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -19,6 +19,7 @@ #> param([string] $Work = (Join-Path ([IO.Path]::GetTempPath()) 'effectcraft-msi-test')) $ErrorActionPreference = 'Stop' +trap { Write-Output "FAILED: $_"; exit 1 } $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $AppPaths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe' @@ -72,9 +73,10 @@ if (Test-Path -LiteralPath $Default) { throw "a custom install also wrote $Defau Invoke-Msiexec 'upgrade without a folder' @('/i', "`"$($Msi['0.0.2'])`"") Assert-InstalledIn $Custom +# Read single values: Get-ItemProperty throws on some machines' malformed Uninstall entries. $arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | - Get-ItemProperty | Where-Object { $_.DisplayName -eq 'EffectCraft' }) -if ($arp.Count -ne 1 -or $arp[0].DisplayVersion -ne '0.0.2') { throw "expected one EffectCraft 0.0.2 in Apps, found: $($arp.DisplayVersion -join ', ')" } + 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 ', ')" } Remove-Item -LiteralPath (Join-Path $Custom 'effectcraft-cli.exe') Invoke-Msiexec 'repair' @('/fa', "`"$($Msi['0.0.2'])`"") From 462913a48395428ebe96e0c8fa8e4383447817a1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:45:47 -0400 Subject: [PATCH 10/11] 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. --- packaging/windows/effectcraft.wxs | 11 ++++++- packaging/windows/test-msi.ps1 | 55 +++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index 7e5cf556..294a019c 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -33,14 +33,23 @@ + "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. --> + + + + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 2a0a8e91..e9d4b4b6 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -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)^.*]*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' From 4aea350f2a0fac9cb4ff463f3cff41a229aef4e1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:49:19 -0400 Subject: [PATCH 11/11] Packaging: the installer's Repair puts back a damaged effectcraft-cli.exe (#466) Cause: Repair used REINSTALLMODE=ecmus. effectcraft-cli.exe has no version resource, so Windows Installer treats it as unversioned: a changed copy with a later write time counts as a user edit and is kept, and "c" only checks files that carry an MSI checksum, which these don't. Fix: Repair uses "amus", which puts back every file whatever its version or date. Tests: test-msi.ps1's stand-in CLI is unversioned like the real one; the test damages it in place and repairs with the REINSTALLMODE read from installer-ui.wxs (no longer msiexec /fa after deleting it), then compares contents. It fails with ecmus (the damaged file stays) and passes now. --- packaging/windows/installer-ui.wxs | 5 ++++- packaging/windows/test-msi.ps1 | 22 +++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 9fd1d689..14989932 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -90,7 +90,10 @@ - + + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index e9d4b4b6..0bed8c43 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -9,7 +9,7 @@ 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, + 4. repairs a damaged (unversioned) CLI there with the wizard's Repair mode, 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 @@ -54,9 +54,11 @@ function Assert-InstalledIn([string] $Dir, [switch] $NoPathValue) { Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue $Bin = Join-Path $Work 'bin' New-Item -ItemType Directory -Force -Path $Bin | Out-Null -# Any GUI and console executables will do: the MSI only copies them. +# The MSI only copies these. The app is versioned (it embeds VERSIONINFO); the real CLI has no +# version resource, so its stand-in is an unversioned file too: Windows Installer keeps a changed +# unversioned file on repair unless the repair mode forces it. 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') +[IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in') # 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' @@ -97,9 +99,19 @@ $arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstal ForEach-Object { if ($_.GetValue('DisplayName') -eq 'EffectCraft') { $_.GetValue('DisplayVersion') } }) 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.3'])`"") +# Repair the way the wizard's Repair button does: damage the CLI in place (a later write time, as a +# user edit or corruption would have), repair with the wizard's REINSTALLMODE, compare contents. +$mode = [regex]::Match((Get-Content -Raw (Join-Path $PSScriptRoot 'installer-ui.wxs')), 'Event="ReinstallMode" Value="([^"]+)"').Groups[1].Value +if (-not $mode) { throw 'no ReinstallMode in installer-ui.wxs' } +$cli = Join-Path $Custom 'effectcraft-cli.exe' +Start-Sleep -Seconds 2 +[IO.File]::WriteAllText($cli, 'damaged') +Invoke-Msiexec "repair ($mode)" @('/i', "`"$($Msi['0.0.3'])`"", 'REINSTALL=ALL', "REINSTALLMODE=$mode") Assert-InstalledIn $Custom +if ((Get-FileHash -LiteralPath $cli).Hash -ne (Get-FileHash -LiteralPath (Join-Path $Bin 'effectcraft-cli.exe')).Hash) { + throw "repair ($mode) left the damaged effectcraft-cli.exe in place" +} +Write-Output "ok repair ($mode) restored effectcraft-cli.exe" 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' }