Files
PaddleStroke 216388bab9 Windows MSI: use the installed app icon for Start shortcuts (#9366) (#9397)
* claim: #9366

* Windows MSI: install before removing the old version, so Start menu pins survive updates (#9366)

The MSI major upgrade removed the old version (solidcraft.exe and the Start
menu shortcut) before installing the new one, and Windows drops a Start menu
or taskbar pin whose target disappears. Schedule RemoveExistingProducts
afterInstallExecute so both stay at their path through an update; set
REINSTALLMODE=amus so a pre-release or nightly of the same X.Y.Z (same file
version) still replaces solidcraft.exe; skip the folder page on an update so
components keep their folder. Packaging lint checks both settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Windows MSI: a non-advertised Start menu shortcut, so its app ID and pin survive updates (#9366)

An advertised shortcut targets C:\Windows\Installer\{ProductCode}, so its
Start menu app ID changes with every build and Windows drops the pin.
Diagnosis by SamD98.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Windows MSI: name the Start menu shortcut SolidCraft CAD, so removing an older release keeps it (#9366)

Releases before this change advertised SolidCraft.lnk, and an advertised
shortcut is removed with its feature by file name. With the old product
removed after the new one is installed, that removal deleted the new
non-advertised SolidCraft.lnk, leaving no Start menu entry. A different
file name leaves the old removal only its own file. Packaging lint checks
the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Windows MSI: use the installed app icon for Start shortcuts

Keep the shortcut icon outside the per-ProductCode Windows Installer cache. Add an executable-icon authoring guard and regression coverage. Packaging-only WiX 5.0.2 and ICE50 checks pass; Start-pin persistence awaits an upgrade test.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 09:56:43 +00:00

177 lines
10 KiB
PowerShell

<#
.SYNOPSIS
Build and package SolidCraft for Windows.
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
solidcraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
solidcraft-<version>-windows-<arch>-portable.zip solidcraft.exe + solidcraft-cli.exe + licences
(+ the solvecraft-cli.exe compatibility alias)
SolidCraft-<version>-windows-<arch>.exe the app alone (x64 only: a plain download)
The binaries link the C runtime statically (+crt-static), so nothing else needs installing.
The icon comes from $env:SOLIDCRAFT_ICONS (solidcraft.ico, made by `solidcraft-cli icon`), or
is drawn by the CLI just built when this machine can run it.
Signing happens outside this script (the release workflow sends files to SignPath), so the work
can be split in two phases with signing in between:
-Phase stage build, check, and copy the three .exe files to the staging folder, then stop
-Phase package make the MSI, portable zip and plain .exe from the staging folder as it is
now (holding the signed .exe files), without building again
-Phase all (the default) does both, unsigned, as before.
Needs: Rust (MSVC toolchain + the target) and WiX v5:
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
pwsh packaging/windows/package.ps1 -Arch x64 -Phase stage # then sign target/windows-package/x64/*.exe
pwsh packaging/windows/package.ps1 -Arch x64 -Phase package
#>
param(
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
[switch] $SkipBuild,
[ValidateSet('all', 'stage', 'package')] [string] $Phase = 'all'
)
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Invoke-Native([string] $What, [scriptblock] $Block) {
Write-Output "==> $What"
& $Block
if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" }
}
# The version lives in one place: [workspace.package] version in the root Cargo.toml.
$Version = $env:SOLIDCRAFT_VERSION
if (-not $Version) {
$inPkg = $false
foreach ($line in Get-Content (Join-Path $Root 'Cargo.toml')) {
if ($line -match '^\s*\[') { $inPkg = ($line.Trim() -eq '[workspace.package]'); continue }
if ($inPkg -and $line -match '^\s*version\s*=\s*"([^"]+)"') { $Version = $Matches[1]; break }
}
}
if (-not $Version) { throw 'could not read [workspace.package] version from Cargo.toml' }
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
$MsiVersion = ($Version -split '-')[0]
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
Write-Output "SolidCraft $Version for Windows $Arch ($Target)"
if (-not $SkipBuild -and $Phase -ne 'package') {
# Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and
# proc-macros are unaffected.
$flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS'
[Environment]::SetEnvironmentVariable($flagVar, '-C target-feature=+crt-static')
# Two invocations: cargo unifies features per invocation, and the app's eframe turns on wgpu's
# GL backend, which would link opengl32.dll into the CLI too (see the import check below).
Invoke-Native "cargo build app ($Target)" { cargo build --release --locked -p solidcraft --target $Target }
Invoke-Native "cargo build CLI ($Target)" { cargo build --release --locked -p solidcraft-cli --target $Target }
}
$Bin = Join-Path $TargetDir "$Target\release"
$Stage = Join-Path $TargetDir "windows-package\$Arch"
# -Phase package checks the staged (by then signed) copies; the other phases check the build.
$CheckDir = if ($Phase -eq 'package') { $Stage } else { $Bin }
# Check both binaries' PE headers: Machine must match -Arch; Subsystem 2 = GUI (the app opens no
# console window), 3 = console (the CLI's output reaches the terminal).
function Get-PeHeader([string] $Path) {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
}
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
# solvecraft-cli.exe: the old CLI name, forwarding to solidcraft-cli.exe (compatibility shim,
# docs/compatibility.md). Built by `-p solidcraft-cli` with the CLI.
foreach ($check in @(@('solidcraft.exe', 2), @('solidcraft-cli.exe', 3), @('solvecraft-cli.exe', 3))) {
$h = Get-PeHeader (Join-Path $CheckDir $check[0])
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
}
& (Join-Path $PSScriptRoot 'check-icon.ps1') -Path (Join-Path $CheckDir 'solidcraft.exe')
# The CLI must not import opengl32.dll: Windows Server Core has none, and the loader then refuses
# to start the CLI at all (0xC0000135, no output; #14971). The app keeps GL and may import it.
function Get-DllImports([string] $Path) {
$objdump = Get-Command llvm-objdump -ErrorAction SilentlyContinue
if ($objdump) {
return & $objdump.Source -p $Path | Select-String '^\s*DLL Name:\s*(\S+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }
}
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$vs = if (Test-Path $vswhere) { & $vswhere -latest -products * -property installationPath }
$dumpbin = if ($vs) { Get-ChildItem (Join-Path $vs 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe') -ErrorAction SilentlyContinue | Select-Object -Last 1 }
if (-not $dumpbin) { throw 'cannot check the CLI imports: neither llvm-objdump nor dumpbin was found' }
return & $dumpbin.FullName /nologo /dependents $Path | Select-String '^\s+(\S+\.dll)\s*$' | ForEach-Object { $_.Matches[0].Groups[1].Value }
}
foreach ($exe in 'solidcraft-cli.exe', 'solvecraft-cli.exe') {
$imports = @(Get-DllImports (Join-Path $CheckDir $exe))
if ($imports.Count -eq 0) { throw "could not read the DLL imports of $exe" }
if ($imports -contains 'opengl32.dll') {
throw "$exe imports opengl32.dll, so it will not start on Windows Server Core (0xC0000135). wgpu's GL backend (feature `"gles`") reached the CLI: build it in its own cargo invocation, without the app."
}
Write-Output "ok ${exe}: no opengl32.dll import ($($imports.Count) DLLs)"
}
& (Join-Path $PSScriptRoot 'check-shortcuts.ps1') -Path (Join-Path $Root 'assets/platforms/windows/solidcraft.wxs')
if ($Phase -ne 'package') {
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
Copy-Item (Join-Path $Bin 'solidcraft.exe'), (Join-Path $Bin 'solidcraft-cli.exe'), (Join-Path $Bin 'solvecraft-cli.exe') $Stage
foreach ($f in 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'THIRD-PARTY-LICENSES.txt') { Copy-Item (Join-Path $Root $f) $Stage }
}
if ($Phase -eq 'stage') {
Write-Output "staged for signing: $Stage"
exit 0
}
$Icon = if ($env:SOLIDCRAFT_ICONS) { Join-Path $env:SOLIDCRAFT_ICONS 'solidcraft.ico' } else { Join-Path $Root 'assets/icons/app/windows/exports/solidcraft.ico' }
if (-not (Test-Path $Icon)) { throw "Missing package icon: $Icon" }
# ---- MSI ---------------------------------------------------------------------------------------
$Msi = Join-Path $Dist "solidcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $Root 'assets/platforms/windows/solidcraft.wxs') -arch $Arch `
-ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
-culture en-US -loc (Join-Path $Root 'assets/platforms/windows/solidcraft.en-us.wxl') `
-d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$Icon" `
-o $Msi
}
Invoke-Native 'MSI shortcut icon validation (ICE50)' {
wix msi validate $Msi -ice ICE50 -intermediateFolder (Join-Path $Stage 'msi-validation')
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
# ---- portable zip ------------------------------------------------------------------------------
$Portable = Join-Path $TargetDir "windows-package\solidcraft-$Version-windows-$Arch-portable"
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
foreach ($f in 'README.md', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'THIRD-PARTY-LICENSES.txt') { Copy-Item (Join-Path $Root $f) $Portable }
$Zip = Join-Path $Dist "solidcraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# ---- the app as a plain download (x64) ---------------------------------------------------------
$Outputs = @($Msi, $Zip)
if ($Arch -eq 'x64') {
$Exe = Join-Path $Dist "SolidCraft-$Version-windows-$Arch.exe"
Copy-Item (Join-Path $Stage 'solidcraft.exe') $Exe
$Outputs += $Exe
}
if ($CanRun) {
Invoke-Native 'solidcraft-cli --version' { & (Join-Path $Stage 'solidcraft-cli.exe') --version }
} else {
Write-Output "skipping solidcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Outputs | Format-Table Name, Length