diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 5c94894e..e020e2d4 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: @@ -46,7 +47,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 '-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 diff --git a/contributors/people.toml b/contributors/people.toml index 089fc24a..748f8fc7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -39,3 +39,8 @@ sync_github_name = false [people.Aavirash] display_name = "Asquareportal" + +[people.tylerjenningsw] +real_name = "Tyler Jennings" +display_name = "tylerjenningsw" +sync_github_name = false 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*. diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index 5a70002b..294a019c 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -2,9 +2,9 @@ @@ -23,6 +23,8 @@ + + @@ -30,6 +32,25 @@ + + + + + + + + + + + @@ -50,6 +71,7 @@ + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs new file mode 100644 index 00000000..14989932 --- /dev/null +++ b/packaging/windows/installer-ui.wxs @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 } diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 new file mode 100644 index 00000000..0bed8c43 --- /dev/null +++ b/packaging/windows/test-msi.ps1 @@ -0,0 +1,124 @@ +<# +.SYNOPSIS + Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build). + +.DESCRIPTION + 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 (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 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 + 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' +trap { Write-Output "FAILED: $_"; exit 1 } +$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, [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 ($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" +} + +Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue +$Bin = Join-Path $Work 'bin' +New-Item -ItemType Directory -Force -Path $Bin | Out-Null +# 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') +[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' +$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', '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 $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] } +} + +# 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 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 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.3') { throw "expected one EffectCraft 0.0.3 in Apps, found: $($arp -join ', ')" } + +# 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' } +if (Test-Path -LiteralPath $AppPaths) { throw 'uninstall left the App Paths registration behind' } + +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.3'])`"") +if (Test-Path -LiteralPath (Join-Path $Default 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } +Write-Output 'MSI folder handling ok'