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:
commit
82f42713a8
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = "1"" />
|
||||
<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 = "1"" />
|
||||
|
||||
<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 &desktop shortcut" />
|
||||
<Control Id="Install" Type="PushButton" X="165" Y="118" Width="65" Height="20"
|
||||
Default="yes" ElevationShield="yes" Text="&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 = "1"">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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.'
|
||||
Loading…
Reference in New Issue