diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index e9927c5..056cb05 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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 diff --git a/README.md b/README.md index aa24ed8..13fa0d6 100644 --- a/README.md +++ b/README.md @@ -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--windows-arm64.msi` | `gridcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `gridcraft--windows-x86.msi` | `gridcraft--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 diff --git a/docs/releasing.md b/docs/releasing.md index 9f4e954..7f7ecce 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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. diff --git a/packaging/windows/gridcraft.wxs b/packaging/windows/gridcraft.wxs index d82acc1..4fd4fe4 100644 --- a/packaging/windows/gridcraft.wxs +++ b/packaging/windows/gridcraft.wxs @@ -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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -83,6 +198,21 @@ + + + + + + + + + + diff --git a/packaging/windows/preview-installer-dialogs.ps1 b/packaging/windows/preview-installer-dialogs.ps1 new file mode 100644 index 0000000..b96ad3f --- /dev/null +++ b/packaging/windows/preview-installer-dialogs.ps1 @@ -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) } +} diff --git a/packaging/windows/test-desktop-shortcut.ps1 b/packaging/windows/test-desktop-shortcut.ps1 new file mode 100644 index 0000000..9a5b4cb --- /dev/null +++ b/packaging/windows/test-desktop-shortcut.ps1 @@ -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.'