Merge pull request #108 from harrisonoconnorhover/codex/desktop-shortcut

Offer an optional desktop shortcut in the Windows installer

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas 2026-10-10 14:32:51 -04:00
commit 82f42713a8
6 changed files with 541 additions and 4 deletions

View File

@ -1,5 +1,5 @@
# 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 release-pipeline checks without a Rust build: workflow and packaging-script syntax,
# Linux desktop metadata, and the Windows desktop-shortcut installer lifecycle.
name: Packaging lint
on:
@ -64,3 +64,33 @@ jobs:
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
desktop-shortcut:
name: Windows desktop shortcut smoke
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build fixture MSIs and test shortcut lifecycle
shell: pwsh
run: |
dotnet tool install --tool-path "$env:RUNNER_TEMP\wix" wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'Could not install WiX' }
$env:PATH = "$env:RUNNER_TEMP\wix;$env:PATH"
./packaging/windows/test-desktop-shortcut.ps1
- name: Capture native MSI dialog previews
shell: pwsh
run: |
pwsh -NoProfile -STA -File ./packaging/windows/preview-installer-dialogs.ps1 `
-Msi "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\gridcraft-90.0.0.msi" `
-OutputDir "$env:RUNNER_TEMP\gridcraft-shortcut-smoke\dialogs"
if ($LASTEXITCODE -ne 0) { throw 'Native MSI dialog preview failed' }
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: desktop-shortcut-msi-evidence
path: |
${{ runner.temp }}/gridcraft-shortcut-smoke/*.log
${{ runner.temp }}/gridcraft-shortcut-smoke/dialogs/*
if-no-files-found: ignore
retention-days: 7

View File

@ -211,7 +211,8 @@ numbers and estimates are in [ROADMAP.md](ROADMAP.md); the ranked work list is [
| arm64 (Snapdragon and other ARM PCs) | `gridcraft-<ver>-windows-arm64.msi` | `gridcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `gridcraft-<ver>-windows-x86.msi` | `gridcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
Installers and executables are code-signed. The MSI installer offers an optional desktop shortcut
(unchecked on a new installation).
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's

View File

@ -128,6 +128,28 @@ the MSI, and zips a portable build.
in `Capabilities` + `RegisteredApplications`, so GridCraft appears in *Open with* and
*Settings › Default apps* but never silently becomes the default. The MSI version is the
numeric `X.Y.Z`; same-version upgrades let release candidates replace each other.
- **Desktop shortcut:** full installer UI offers an unchecked checkbox on a new installation.
Unattended installs can use `msiexec /i gridcraft.msi /qn DESKTOPSHORTCUT=1` to opt in
(or `DESKTOPSHORTCUT=0` to opt out). The choice is stored per machine and reused on upgrades;
repair retains the installed component choice, and uninstall removes the managed shortcut.
The Start Menu shortcut is always installed. Run
the Packaging lint workflow’s Windows job to build small fixture installers and check
installation, repair, upgrade, opt-out and cleanup, including removal by SYSTEM after
installation by the runner account. The shortcut marker uses `HKMU`, which resolves to
`HKLM` for this per-machine package; it leaves no installing-user registry marker. Unlike
a literal `HKLM` key path, MSI registry root -1 also meets ICE43's shortcut rule without
suppressing validation ([WiX registry roots](https://docs.firegiant.com/wix/schema/wxs/registryvalue/),
[ICE43](https://learn.microsoft.com/windows/win32/msi/ice43)). Its
`packaging/windows/test-desktop-shortcut.ps1` script requires a GitHub-hosted Windows runner.
- **Installer review images:** the same Windows job renders the compiled MSI's dialogs through
Windows Installer's native preview API and uploads PNGs plus install logs in
`desktop-shortcut-msi-evidence`, on success or failure. These are native dialog previews;
the separate silent lifecycle test verifies installation behavior. Normal full UI presents
the shortcut choice and then completion; cancel/failure and files-in-use dialogs are support
paths. To review on a maintainer branch, run **Packaging lint** with that branch selected
(or `gh workflow run packaging-lint.yml --repo storytold/gridcraft --ref BRANCH`). No release
environment, signing credentials or Rust build is required. Interactive checkbox behavior
still needs a Windows desktop check; previews alone do not establish that behavior.
- **Signing:** `packaging/windows/sign.ps1` uses `signtool` with SHA-256 and an RFC 3161
timestamp, from a `.pfx` (`WINDOWS_CERTIFICATE*`) if present, otherwise **Azure Trusted
Signing** (`AZURE_*`, the storytold setup). With neither it warns and leaves files unsigned.

View File

@ -4,7 +4,8 @@
wix build gridcraft.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, App Paths (Win+R `gridcraft`),
Per-machine install into Program Files, a Start Menu shortcut, an optional desktop shortcut,
App Paths (Win+R `gridcraft`),
and GridCraft offered under "Open with" (and in Settings > Default apps) for .xlsx, .xlsm,
.csv and .tsv, without taking over the user's default app for them.
@ -33,10 +34,124 @@
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/gridcraft" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
<!-- An explicit command-line value wins. Otherwise retain the previous installation's
choice, defaulting to off for a fresh install. Run only in the first sequence so the
execute sequence cannot reload the old choice after the user unchecks the box. -->
<Property Id="DESKTOPSHORTCUT" Secure="yes" />
<Property Id="SAVEDDESKTOPSHORTCUT" Secure="yes">
<RegistrySearch Id="FindDesktopShortcutPreference" Root="HKLM"
Key="Software\GridCraft\Installer" Name="DesktopShortcut" Type="raw" />
</Property>
<SetProperty Id="DESKTOPSHORTCUT" Action="LoadDesktopShortcutPreference" Value="1"
After="AppSearch" Sequence="first"
Condition="NOT DESKTOPSHORTCUT AND SAVEDDESKTOPSHORTCUT = &quot;1&quot;" />
<SetProperty Id="DESKTOPSHORTCUT" Action="DefaultDesktopShortcut" Value="0"
After="LoadDesktopShortcutPreference" Sequence="first" Condition="NOT DESKTOPSHORTCUT" />
<SetProperty Id="DesktopShortcutChoice" Value="1" After="DefaultDesktopShortcut"
Sequence="ui" Condition="DESKTOPSHORTCUT = &quot;1&quot;" />
<Property Id="DefaultUIFont" Value="GridcraftUIFont" />
<Property Id="ErrorDialog" Value="GridcraftErrorDlg" />
<EnsureTable Id="ListBox" />
<UI>
<TextStyle Id="GridcraftUIFont" FaceName="Segoe UI" Size="9" />
<Dialog Id="DesktopShortcutDlg" Width="320" Height="150" Title="GridCraft Setup" NoMinimize="yes">
<Control Id="Heading" Type="Text" X="15" Y="15" Width="290" Height="20" Text="Install GridCraft" />
<Control Id="Description" Type="Text" X="15" Y="40" Width="290" Height="28"
Text="Install GridCraft for everyone who uses this computer." />
<Control Id="DesktopShortcutCheckbox" Type="CheckBox" X="15" Y="76" Width="290" Height="18"
Property="DesktopShortcutChoice" CheckBoxValue="1" Text="Create a &amp;desktop shortcut" />
<Control Id="Install" Type="PushButton" X="165" Y="118" Width="65" Height="20"
Default="yes" ElevationShield="yes" Text="&amp;Install">
<!-- An unchecked checkbox clears its property. Publish an explicit zero so the
elevated execute sequence receives an unambiguous opt-out. -->
<Publish Property="DESKTOPSHORTCUT" Value="1" Condition="DesktopShortcutChoice" />
<Publish Property="DESKTOPSHORTCUT" Value="0" Condition="NOT DesktopShortcutChoice" />
<Publish Event="EndDialog" Value="Return" />
</Control>
<Control Id="Cancel" Type="PushButton" X="240" Y="118" Width="65" Height="20" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit" />
</Control>
</Dialog>
<!-- Windows Installer requires these support dialogs for authored full UI (ICE20).
They contain only installer messages and standard response controls. -->
<Dialog Id="GridcraftErrorDlg" Width="320" Height="120" Title="GridCraft Setup" ErrorDialog="yes" NoMinimize="yes">
<Control Id="ErrorText" Type="Text" X="15" Y="15" Width="290" Height="65" TabSkip="no" />
<Control Id="A" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Abort" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorAbort" />
</Control>
<Control Id="C" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Cancel" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorCancel" />
</Control>
<Control Id="I" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Ignore" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorIgnore" />
</Control>
<Control Id="N" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="No" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorNo" />
</Control>
<Control Id="O" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="OK" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorOk" />
</Control>
<Control Id="R" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Retry" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorRetry" />
</Control>
<Control Id="Y" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Yes" TabSkip="yes">
<Publish Event="EndDialog" Value="ErrorYes" />
</Control>
</Dialog>
<Dialog Id="FilesInUse" Width="320" Height="190" Title="GridCraft Setup" NoMinimize="yes">
<Control Id="Description" Type="Text" X="15" Y="15" Width="290" Height="35"
Text="Close the applications listed below, then choose Retry. Ignore may require a restart." />
<Control Id="Files" Type="ListBox" X="15" Y="55" Width="290" Height="85" Property="FileInUseProcess" />
<Control Id="Retry" Type="PushButton" X="95" Y="155" Width="65" Height="20" Default="yes" Text="Retry">
<Publish Event="EndDialog" Value="Retry" />
</Control>
<Control Id="Ignore" Type="PushButton" X="167" Y="155" Width="65" Height="20" Text="Ignore">
<Publish Event="EndDialog" Value="Ignore" />
</Control>
<Control Id="Cancel" Type="PushButton" X="240" Y="155" Width="65" Height="20" Cancel="yes" Text="Cancel">
<Publish Event="EndDialog" Value="Exit" />
</Control>
</Dialog>
<Dialog Id="SetupCompleteDlg" Width="320" Height="110" Title="GridCraft Setup" NoMinimize="yes">
<Control Id="Message" Type="Text" X="15" Y="20" Width="290" Height="35" Text="GridCraft setup completed." />
<Control Id="Close" Type="PushButton" X="240" Y="75" Width="65" Height="20" Default="yes" Cancel="yes" Text="Close">
<Publish Event="EndDialog" Value="Return" />
</Control>
</Dialog>
<Dialog Id="SetupCanceledDlg" Width="320" Height="110" Title="GridCraft Setup" NoMinimize="yes">
<Control Id="Message" Type="Text" X="15" Y="20" Width="290" Height="35" Text="GridCraft setup was canceled." />
<Control Id="Close" Type="PushButton" X="240" Y="75" Width="65" Height="20" Default="yes" Cancel="yes" Text="Close">
<Publish Event="EndDialog" Value="Return" />
</Control>
</Dialog>
<Dialog Id="SetupFailedDlg" Width="320" Height="110" Title="GridCraft Setup" NoMinimize="yes">
<Control Id="Message" Type="Text" X="15" Y="20" Width="290" Height="35" Text="GridCraft setup could not finish. See the installer message or log for details." />
<Control Id="Close" Type="PushButton" X="240" Y="75" Width="65" Height="20" Default="yes" Cancel="yes" Text="Close">
<Publish Event="EndDialog" Value="Return" />
</Control>
</Dialog>
</UI>
<InstallUISequence>
<!-- Component conditions must see the choice before costing. Do not show the choice
during basic/silent installs, repair or uninstall. -->
<Show Dialog="DesktopShortcutDlg" Before="CostInitialize" Condition="NOT Installed AND NOT REMOVE AND UILevel = 5" />
<Show Dialog="SetupCompleteDlg" OnExit="success" Condition="UILevel = 5" />
<Show Dialog="SetupCanceledDlg" OnExit="cancel" Condition="UILevel = 5" />
<Show Dialog="SetupFailedDlg" OnExit="error" Condition="UILevel = 5" />
</InstallUISequence>
<AdminUISequence>
<Show Dialog="SetupCompleteDlg" OnExit="success" Condition="UILevel = 5" />
<Show Dialog="SetupCanceledDlg" OnExit="cancel" Condition="UILevel = 5" />
<Show Dialog="SetupFailedDlg" OnExit="error" Condition="UILevel = 5" />
</AdminUISequence>
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="GridCraft" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<StandardDirectory Id="DesktopFolder" />
<Feature Id="Main" Title="GridCraft" Level="1">
<ComponentGroupRef Id="GridcraftFiles" />
@ -83,6 +198,21 @@
<Component Id="GridcraftCli">
<File Id="GridcraftCliExe" Source="$(var.BinDir)\gridcraft-cli.exe" KeyPath="yes" />
</Component>
<!-- Persist both on and off, independently of the optional shortcut. AppSearch reads
this before MajorUpgrade removes the previous product. -->
<Component Id="DesktopShortcutPreference">
<RegistryValue Root="HKLM" Key="Software\GridCraft\Installer" Name="DesktopShortcut"
Value="[DESKTOPSHORTCUT]" Type="string" KeyPath="yes" />
</Component>
<Component Id="DesktopShortcutComponent" Directory="DesktopFolder" Condition="DESKTOPSHORTCUT = &quot;1&quot;">
<Shortcut Id="DesktopShortcut" Name="GridCraft" Description="GridCraft spreadsheet"
Target="[#GridcraftExe]" WorkingDirectory="INSTALLFOLDER" Icon="GridcraftIcon.ico" IconIndex="0" />
<!-- HKMU resolves to HKLM for this per-machine package, so another account can
uninstall the marker too. MSI root -1 also satisfies ICE43 for this shortcut. -->
<RegistryValue Root="HKMU" Key="Software\GridCraft\Installer" Name="DesktopShortcutComponent"
Value="1" Type="integer" KeyPath="yes" />
</Component>
</ComponentGroup>
</Fragment>
</Wix>

View File

@ -0,0 +1,120 @@
<#
.SYNOPSIS
Capture native MSI dialog previews without installing the package.
.DESCRIPTION
Opens the MSI read-only and uses Windows Installer's inactive UI preview API.
These show authored dialog layout, not an end-to-end installation or runtime messages.
https://learn.microsoft.com/windows/win32/msi/previewing-the-user-interface
#>
param(
[Parameter(Mandatory = $true)] [string] $Msi,
[Parameter(Mandatory = $true)] [string] $OutputDir
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
throw 'Native MSI dialog previews require Windows.'
}
$Msi = (Resolve-Path -LiteralPath $Msi).Path
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$OutputDir = (Resolve-Path -LiteralPath $OutputDir).Path
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class GridcraftMsiPreview {
[DllImport("msi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
public static extern uint MsiOpenDatabaseW(string path, IntPtr persist, out uint database);
[DllImport("msi.dll")]
public static extern uint MsiEnableUIPreview(uint database, out uint preview);
[DllImport("msi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
public static extern uint MsiPreviewDialogW(uint preview, string dialog);
[DllImport("msi.dll")]
public static extern uint MsiCloseHandle(uint handle);
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
private delegate bool WindowCallback(IntPtr window, IntPtr state);
[DllImport("user32.dll")]
private static extern bool EnumWindows(WindowCallback callback, IntPtr state);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr window, out uint process);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")]
public static extern bool PrintWindow(IntPtr window, IntPtr dc, uint flags);
public static IntPtr FindDialog(uint process) {
IntPtr found = IntPtr.Zero;
EnumWindows(delegate(IntPtr window, IntPtr state) {
uint owner;
GetWindowThreadProcessId(window, out owner);
if (owner != process) return true;
var title = new StringBuilder(128);
GetWindowText(window, title, title.Capacity);
if (title.ToString() != "GridCraft Setup") return true;
found = window;
return false;
}, IntPtr.Zero);
return found;
}
}
'@
function Assert-MsiResult([uint32] $Result, [string] $Operation) {
if ($Result -ne 0) { throw "$Operation failed with MSI error $Result" }
}
[uint32] $database = 0
[uint32] $preview = 0
try {
Assert-MsiResult ([GridcraftMsiPreview]::MsiOpenDatabaseW($Msi, [IntPtr]::Zero, [ref] $database)) 'Open read-only MSI'
$dialogs = 'DesktopShortcutDlg', 'SetupCompleteDlg', 'SetupCanceledDlg', 'SetupFailedDlg', 'GridcraftErrorDlg', 'FilesInUse'
foreach ($dialog in $dialogs) {
# Give each dialog its own preview session; closing its handle releases the UI.
Assert-MsiResult ([GridcraftMsiPreview]::MsiEnableUIPreview($database, [ref] $preview)) "Enable preview for $dialog"
Assert-MsiResult ([GridcraftMsiPreview]::MsiPreviewDialogW($preview, $dialog)) "Preview $dialog"
$window = [IntPtr]::Zero
$wait = [Diagnostics.Stopwatch]::StartNew()
while ($window -eq [IntPtr]::Zero -and $wait.Elapsed.TotalSeconds -lt 5) {
[Windows.Forms.Application]::DoEvents()
$window = [GridcraftMsiPreview]::FindDialog([uint32] $PID)
Start-Sleep -Milliseconds 50
}
if ($window -eq [IntPtr]::Zero) { throw "No native window found for $dialog" }
[Windows.Forms.Application]::DoEvents()
$rect = New-Object GridcraftMsiPreview+Rect
if (-not [GridcraftMsiPreview]::GetWindowRect($window, [ref] $rect)) { throw "Cannot measure $dialog" }
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -lt 1 -or $height -lt 1 -or $width -gt 4096 -or $height -gt 4096) { throw "Invalid preview dimensions for $dialog" }
$bitmap = New-Object Drawing.Bitmap($width, $height)
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try {
$dc = $graphics.GetHdc()
try {
if (-not [GridcraftMsiPreview]::PrintWindow($window, $dc, 0)) { throw "Capture failed for $dialog" }
} finally { $graphics.ReleaseHdc($dc) }
$colors = New-Object 'Collections.Generic.HashSet[int]'
for ($y = 0; $y -lt $height; $y += [Math]::Max(1, [int] ($height / 32))) {
for ($x = 0; $x -lt $width; $x += [Math]::Max(1, [int] ($width / 32))) {
$null = $colors.Add($bitmap.GetPixel($x, $y).ToArgb())
}
}
if ($colors.Count -lt 2) { throw "Empty capture for $dialog" }
$bitmap.Save((Join-Path $OutputDir "$dialog.png"), [Drawing.Imaging.ImageFormat]::Png)
} finally {
$graphics.Dispose()
$bitmap.Dispose()
}
Assert-MsiResult ([GridcraftMsiPreview]::MsiCloseHandle($preview)) "Close preview for $dialog"
$preview = 0
Write-Host "Saved native MSI dialog preview: $dialog"
}
'Native MSI dialog previews only. No installation was run. Support dialogs have no live error or process data.' |
Set-Content -LiteralPath (Join-Path $OutputDir 'README.txt') -Encoding utf8
} finally {
if ($preview -ne 0) {
$null = [GridcraftMsiPreview]::MsiCloseHandle($preview)
}
if ($database -ne 0) { $null = [GridcraftMsiPreview]::MsiCloseHandle($database) }
}

View File

@ -0,0 +1,234 @@
<#
.SYNOPSIS
Exercise the actual MSI's desktop-shortcut choices on a disposable GitHub Windows runner.
.DESCRIPTION
Compiles original no-op GUI/CLI fixtures instead of Rust, uses GridCraft's existing icon,
and builds adjacent MSI versions. Installs the real package (including its registrations),
checks default/explicit choices, repair and major upgrades, then uninstalls in finally.
Run through the Packaging lint workflow, never on a developer's Windows installation.
#>
param([string] $WorkDir)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') {
throw 'This install test is restricted to disposable GitHub-hosted Windows runners.'
}
if (-not $WorkDir) { $WorkDir = Join-Path $env:RUNNER_TEMP 'gridcraft-shortcut-smoke' }
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$InstallDir = Join-Path $env:ProgramFiles 'GridCraft'
$DesktopLink = Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'GridCraft.lnk'
$StartLink = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) 'GridCraft.lnk'
$ChoiceKey = 'HKLM:\Software\GridCraft\Installer'
$ComponentKey = 'HKLM:\Software\GridCraft\Installer'
$UninstallRoot = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall'
$Icon = Join-Path $Root 'assets\app-icon\gridcraft.ico'
if ((Test-Path $InstallDir) -or (Test-Path $DesktopLink) -or (Test-Path $StartLink) -or
(Test-Path 'HKLM:\Software\GridCraft') -or (Test-Path 'HKCU:\Software\GridCraft')) {
throw 'Refusing to run against an existing GridCraft installation or its settings.'
}
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$Bin = Join-Path $WorkDir 'bin'
New-Item -ItemType Directory -Force -Path $Bin | Out-Null
function Assert-That([bool] $Condition, [string] $Message) {
if (-not $Condition) { throw $Message }
}
function Get-OptionalRegistryValue([string] $Path, [string] $Name) {
if (-not (Test-Path -LiteralPath $Path)) { return $null }
$key = Get-Item -LiteralPath $Path
try { return $key.GetValue($Name, $null) }
finally { $key.Close() }
}
function Get-ProductCode([string] $Msi) {
$installer = New-Object -ComObject WindowsInstaller.Installer
$database = $null
$view = $null
$record = $null
try {
$database = $installer.OpenDatabase($Msi, 0)
$view = $database.OpenView('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode''')
$view.Execute()
$record = $view.Fetch()
if (-not $record) { throw "No ProductCode in $Msi" }
return $record.StringData(1)
} finally {
if ($view) { $view.Close() }
foreach ($item in $record, $view, $database, $installer) {
if ($item) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($item) | Out-Null }
}
}
}
function Invoke-Msi([string] $Name, [string[]] $Arguments) {
$log = Join-Path $WorkDir "$Name.log"
# /norestart is unconditional. Each operation and the workflow have time limits.
$process = Start-Process msiexec.exe -ArgumentList ($Arguments + @('/qn', '/norestart', '/l*v', "`"$log`"")) -PassThru
try {
if (-not $process.WaitForExit(120000)) {
$process.Kill()
throw "$Name exceeded two minutes; see $log"
}
if ($process.ExitCode -ne 0) {
if (Test-Path $log) { Get-Content $log -Tail 60 | Write-Host }
throw "$Name exited $($process.ExitCode); see $log"
}
Write-Host "ok $Name"
} finally {
$process.Dispose()
}
}
function Assert-Package($Package) {
$key = Join-Path $UninstallRoot $Package.Code
$registered = Get-ItemProperty -Path $key -ErrorAction Stop
Assert-That ($registered.DisplayVersion -eq $Package.Version) "Wrong installed product version: $($Package.Version)"
foreach ($exe in 'gridcraft.exe', 'gridcraft-cli.exe') {
Assert-That (Test-Path (Join-Path $InstallDir $exe)) "$exe missing after installation"
}
Assert-That (Test-Path $StartLink) 'The existing Start Menu shortcut is missing'
}
function Assert-Desktop([bool] $Enabled) {
Assert-That ((Test-Path $DesktopLink) -eq $Enabled) "Desktop shortcut presence should be $Enabled"
$marker = Get-OptionalRegistryValue $ComponentKey 'DesktopShortcutComponent'
Assert-That (($null -ne $marker) -eq $Enabled) 'Shortcut component marker must match the machine-wide shortcut'
Assert-That ($null -eq (Get-OptionalRegistryValue 'HKCU:\Software\GridCraft\Installer' 'DesktopShortcutComponent')) 'Shortcut component must not leave a per-user marker'
$choice = Get-ItemPropertyValue -Path $ChoiceKey -Name DesktopShortcut
Assert-That ($choice -eq $(if ($Enabled) { '1' } else { '0' })) 'Installer did not persist the selected desktop choice'
if (-not $Enabled) { return }
$shell = New-Object -ComObject WScript.Shell
$shortcut = $null
try {
$shortcut = $shell.CreateShortcut($DesktopLink)
Assert-That ($shortcut.TargetPath -ieq (Join-Path $InstallDir 'gridcraft.exe')) 'Desktop shortcut targets the wrong executable'
Assert-That ($shortcut.WorkingDirectory.TrimEnd('\') -ieq $InstallDir.TrimEnd('\')) 'Desktop shortcut has the wrong working directory'
Assert-That ($shortcut.IconLocation -match '^(.*),\s*0$') 'Desktop shortcut must use icon index zero'
$iconPath = [Environment]::ExpandEnvironmentVariables($Matches[1].Trim('"'))
Assert-That (Test-Path $iconPath) 'Desktop shortcut icon is missing'
Assert-That ((Get-FileHash $iconPath).Hash -eq (Get-FileHash $Icon).Hash) 'Desktop shortcut does not use the packaged GridCraft icon'
} finally {
foreach ($item in $shortcut, $shell) {
if ($item) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($item) | Out-Null }
}
}
}
function Assert-Removed($Package) {
Assert-That (-not (Test-Path (Join-Path $UninstallRoot $Package.Code))) 'MSI still registered after removal'
Assert-That (-not (Test-Path (Join-Path $InstallDir 'gridcraft.exe'))) 'App executable remains after uninstall'
Assert-That (-not (Test-Path (Join-Path $InstallDir 'gridcraft-cli.exe'))) 'CLI executable remains after uninstall'
Assert-That (-not (Test-Path $DesktopLink)) 'Desktop shortcut remains after uninstall'
Assert-That (-not (Test-Path $StartLink)) 'Start Menu shortcut remains after uninstall'
Assert-That ($null -eq (Get-OptionalRegistryValue $ChoiceKey 'DesktopShortcut')) 'Saved choice remains after uninstall'
Assert-That ($null -eq (Get-OptionalRegistryValue $ComponentKey 'DesktopShortcutComponent')) 'Shortcut component registration remains after uninstall'
}
function Uninstall-AsSystem($Package) {
# Exercise removal under a different account without creating a user or credentials.
# This function is covered by the disposable hosted-runner guard at the top of the script.
$taskName = 'GridCraft-shortcut-' + [Guid]::NewGuid().ToString('N')
$log = Join-Path $WorkDir 'uninstall-as-system.log'
$action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\msiexec.exe') `
-Argument "/x $($Package.Code) /qn /norestart /l*v `"$log`""
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2)
try {
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings | Out-Null
$started = Get-Date
Start-ScheduledTask -TaskName $taskName
$deadline = $started.AddMinutes(2)
do {
Start-Sleep -Milliseconds 250
$task = Get-ScheduledTask -TaskName $taskName
$info = Get-ScheduledTaskInfo -TaskName $taskName
if ($info.LastRunTime -ge $started.AddSeconds(-1) -and $task.State -ne 'Running' -and $info.LastTaskResult -ne 267009) {
Assert-That ($info.LastTaskResult -eq 0) "Cross-account uninstall failed with $($info.LastTaskResult); see $log"
Write-Host 'ok uninstall-as-system'
return
}
} while ((Get-Date) -lt $deadline)
throw "Cross-account uninstall exceeded two minutes; see $log"
} finally {
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
}
}
# Original fixture source, compiled by the .NET Framework compiler already on the runner.
# https://learn.microsoft.com/dotnet/csharp/language-reference/compiler-options/
$source = Join-Path $WorkDir 'Stub.cs'
'public static class Program { public static void Main() { } }' | Set-Content -Path $source -Encoding utf8
$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
foreach ($fixture in @(@('gridcraft.exe', 'winexe'), @('gridcraft-cli.exe', 'exe'))) {
& $csc /nologo /platform:x64 "/target:$($fixture[1])" "/out:$(Join-Path $Bin $fixture[0])" $source
if ($LASTEXITCODE -ne 0) { throw 'Could not compile installer fixture' }
}
$packages = @()
foreach ($version in '90.0.0', '90.0.1', '90.0.2') {
$msi = Join-Path $WorkDir "gridcraft-$version.msi"
& wix build (Join-Path $PSScriptRoot 'gridcraft.wxs') -arch x64 `
-d "Version=$version" -d "BinDir=$Bin" -d "IconPath=$Icon" -o $msi | Write-Host
if ($LASTEXITCODE -ne 0) { throw "WiX failed to build $version" }
$packages += [pscustomobject]@{ Path = $msi; Code = (Get-ProductCode $msi); Version = $version }
}
$first, $second, $third = $packages
$failure = $null
try {
Invoke-Msi 'default-off' @('/i', "`"$($first.Path)`"")
Assert-Package $first
Assert-Desktop $false
# Repair uses existing choices; opt-in/out is only a fresh-install or major-upgrade option.
# https://learn.microsoft.com/windows-server/administration/windows-commands/msiexec
Invoke-Msi 'repair-off' @('/famus', $first.Code)
Assert-Desktop $false
Invoke-Msi 'upgrade-off' @('/i', "`"$($second.Path)`"")
Assert-Package $second
Assert-That (-not (Test-Path (Join-Path $UninstallRoot $first.Code))) 'Upgrade left the previous MSI installed'
Assert-Desktop $false
Invoke-Msi 'uninstall-off' @('/x', $second.Code)
Assert-Removed $second
Invoke-Msi 'explicit-on' @('/i', "`"$($first.Path)`"", 'DESKTOPSHORTCUT=1')
Assert-Package $first
Assert-Desktop $true
Remove-Item -LiteralPath $DesktopLink
Invoke-Msi 'repair-on' @('/famus', $first.Code)
Assert-Desktop $true
Invoke-Msi 'upgrade-on' @('/i', "`"$($second.Path)`"")
Assert-Package $second
Assert-That (-not (Test-Path (Join-Path $UninstallRoot $first.Code))) 'Upgrade left the previous MSI installed'
Assert-Desktop $true
Invoke-Msi 'upgrade-explicit-off' @('/i', "`"$($third.Path)`"", 'DESKTOPSHORTCUT=0')
Assert-Package $third
Assert-That (-not (Test-Path (Join-Path $UninstallRoot $second.Code))) 'Opt-out upgrade left the previous MSI installed'
Assert-Desktop $false
Invoke-Msi 'repair-after-opt-out' @('/famus', $third.Code)
Assert-Desktop $false
Invoke-Msi 'uninstall-after-opt-out' @('/x', $third.Code)
Assert-Removed $third
Invoke-Msi 'cross-account-install' @('/i', "`"$($first.Path)`"", 'DESKTOPSHORTCUT=1')
Assert-Desktop $true
Uninstall-AsSystem $first
Assert-Removed $first
} catch {
$failure = $_
} finally {
# Only uninstall the exact fixture products built above, including after a failed assertion.
foreach ($package in $packages) {
if (Test-Path (Join-Path $UninstallRoot $package.Code)) {
try { Invoke-Msi "cleanup-$($package.Version)" @('/x', $package.Code) }
catch {
Write-Warning $_
if (-not $failure) { $failure = $_ }
}
}
}
}
if ($failure) { throw $failure }
Write-Host 'Desktop shortcut smoke passed: default off, explicit on/off, repair, upgrade persistence and cross-account uninstall.'