This commit is contained in:
Brandon Thomas 2026-10-09 20:36:41 -04:00
commit dea37269ba
6 changed files with 182 additions and 5 deletions

View File

@ -51,6 +51,11 @@ jobs:
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
done
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"

View File

@ -147,6 +147,7 @@ jobs:
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
# package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2

View File

@ -445,7 +445,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by
default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an
option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`.
### macOS

View File

@ -145,6 +145,10 @@
)
### Reliability
- The Windows installer asks where to install LightCraft (Program Files by default; upgrades keep the folder you
chose) and ends on a page saying it was installed, with a "Launch LightCraft" box. It used to finish without a word,
so a successful install looked like nothing had happened (issues #18, #399). Silent installs (`/qn`, `/passive`)
show no dialogs and take `INSTALLFOLDER=...`.
- If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a
message box that names the log file, instead of quitting without a trace (issue #260).
- On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,

View File

@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
LightCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
LightCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`, plus the UI and Util extensions:
`wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`). Built by package.ps1:
wix build lightcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
wix build lightcraft.wxs -arch x64 -ext WixToolset.UI.wixext/5.0.2 -ext WixToolset.Util.wixext/5.0.2
-d Version=1.2.3 -d BinDir=... -d IconPath=... -d UiDialogBmp=... -d UiBannerBmp=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by LightCraft,
Per-machine install into Program Files (or the folder chosen in the installer, or
`INSTALLFOLDER=...` on the msiexec command line; upgrades keep it), a Start Menu shortcut, `.pcraft` owned by LightCraft,
and LightCraft offered under "Open with" for PSD/PSB and the image formats it reads (without
taking over the user's default app for them).
-->
@ -33,13 +36,133 @@
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="LightCraft" />
</StandardDirectory>
<!-- The folder of the installed version (written by LightcraftApp), so an upgrade installs
where the user chose instead of moving back to Program Files (issue #399). Searched into
its own property: AppSearch would otherwise overwrite INSTALLFOLDER=... given to msiexec. -->
<Property Id="PREVIOUSINSTALLFOLDER">
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Key="Software\LightCraft" Name="InstallFolder" Type="raw" />
</Property>
<SetProperty Id="INSTALLFOLDER" Value="[PREVIOUSINSTALLFOLDER]" After="AppSearch" Sequence="both"
Condition="PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" />
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="LightCraft" Level="1">
<ComponentGroupRef Id="LightcraftFiles" />
</Feature>
<UIRef Id="LightcraftUI" />
</Package>
<!--
Interactive install UI: Welcome -> Destination Folder -> Ready to install -> progress ->
"Completed" (Finish), with a "Launch LightCraft" checkbox (checked by default). Destination
Folder (issue #399) shows Program Files\LightCraft, or the installed version's folder on an
upgrade; Change... browses, and a folder that can't be used (InvalidDirDlg) is refused before
anything is installed. Failure and cancellation end on WiX's standard FatalError / UserExit
dialogs instead of the completion page. Maintenance (running the MSI again, Repair/Remove from
Apps) uses the standard maintenance dialogs.
This is WixUI_InstallDir without its licence-acceptance page: LightCraft is MIT OR Apache-2.0,
which ask nothing of people who just install and run it, so a click-through EULA would only
add a step. Silent (/qn) and basic-UI (/passive, /qb) installs never show these dialogs, so
they are unaffected, and nothing is launched.
The dialogs, strings and error/cancel pages come from WixToolset.UI.wixext; the side and
banner bitmaps are plain panels generated by package.ps1 (no third-party artwork).
-->
<Fragment>
<WixVariable Id="WixUIDialogBmp" Value="$(var.UiDialogBmp)" />
<WixVariable Id="WixUIBannerBmp" Value="$(var.UiBannerBmp)" />
<Property Id="WIXUI_EXITDIALOGOPTIONALTEXT"
Value="LightCraft has been installed. You can start it at any time from the Start menu." />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch LightCraft" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
<!-- "Launch LightCraft" on Finish. WixUnelevatedShellExec (Util extension) asks the desktop
shell (Explorer) to start the program, so it runs as the signed-in user at normal
integrity even when the installer UI itself is elevated (e.g. msiexec started from an
admin prompt); it never runs from the elevated/SYSTEM server side. It is a UI-only control
event, so silent and passive installs never run it. Return="ignore": a failed launch must
not turn a successful install into an error. -->
<Property Id="WixUnelevatedShellExecTarget" Value="[#LightcraftExe]" />
<CustomAction Id="LaunchLightcraft" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)"
DllEntry="WixUnelevatedShellExec" Execute="immediate" Impersonate="yes" Return="ignore" />
<UI Id="LightcraftUI">
<TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
<TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
<TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />
<Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
<DialogRef Id="ErrorDlg" />
<DialogRef Id="FatalError" />
<DialogRef Id="FilesInUse" />
<DialogRef Id="MsiRMFilesInUse" />
<DialogRef Id="PrepareDlg" />
<DialogRef Id="ProgressDlg" />
<DialogRef Id="ResumeDlg" />
<DialogRef Id="UserExit" />
<DialogRef Id="WelcomeDlg" />
<DialogRef Id="InstallDirDlg" />
<DialogRef Id="BrowseDlg" />
<DialogRef Id="InvalidDirDlg" />
<DialogRef Id="DiskCostDlg" />
<DialogRef Id="VerifyReadyDlg" />
<DialogRef Id="MaintenanceWelcomeDlg" />
<DialogRef Id="MaintenanceTypeDlg" />
<!-- The folder InstallDirDlg edits. -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
<!-- First install / upgrade: Welcome -> Destination Folder -> Ready to install. A patch skips
the folder (it can't move an installed product). -->
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Condition="NOT Installed" />
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Condition="Installed AND PATCH" />
<!-- Destination Folder, as in WixUI_InstallDir: Next sets the folder, validates it (WiX's
WixUIValidatePath) and refuses one that can't be used; Change... opens the folder browser,
whose OK validates too. -->
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" />
<Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" />
<Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="2"
Condition="NOT WIXUI_DONTVALIDATEPATH" />
<Publish Dialog="InstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"
Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID&lt;&gt;&quot;1&quot;" />
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4"
Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID=&quot;1&quot;" />
<Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />
<Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" />
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="3"
Condition="NOT WIXUI_DONTVALIDATEPATH" />
<Publish Dialog="BrowseDlg" Control="OK" Event="SpawnDialog" Value="InvalidDirDlg" Order="4"
Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID&lt;&gt;&quot;1&quot;" />
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg" Order="1"
Condition="NOT Installed" />
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="3"
Condition="Installed AND PATCH" />
<!-- Maintenance: Welcome -> Repair / Remove -> Ready. -->
<Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg" />
<Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg" />
<Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg" />
<Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg" />
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2"
Condition="Installed AND NOT PATCH" />
<!-- Completion page: optionally launch, then close. -->
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchLightcraft" Order="1"
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
<Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999" />
<!-- One feature, nothing to change: no "Change" in Apps & features. -->
<Property Id="ARPNOMODIFY" Value="1" />
</UI>
<UIRef Id="WixUI_Common" />
</Fragment>
<Fragment>
<ComponentGroup Id="LightcraftFiles" Directory="INSTALLFOLDER">
<Component Id="LightcraftApp" Guid="8A1D3A39-F7C1-47B1-97AE-40C80BF82F4A">
@ -50,6 +173,7 @@
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\lightcraft.exe" Value="[#LightcraftExe]" Type="string" />
<RegistryValue Root="HKLM" Key="Software\LightCraft" Name="InstallFolder" Value="[INSTALLFOLDER]" Type="string" />
</Component>
<Component Id="LightcraftCli">

View File

@ -13,6 +13,8 @@
Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe),
and WiX v5: dotnet tool install --global wix --version 5.0.2
The WiX UI and Util extensions (installer dialogs, "Launch LightCraft" on Finish) are added to
the global WiX extension cache by this script (wix extension add -g ..., needs network once).
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
@ -32,6 +34,29 @@ function Invoke-Native([string] $What, [scriptblock] $Block) {
if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" }
}
# A 24-bit BMP of two flat colours: $SplitX pixels of $Left, then $Right (colours as R,G,B).
# Used for the installer's side/banner bitmaps so the MSI ships no third-party artwork.
function New-PanelBitmap([string] $Path, [int] $Width, [int] $Height, [int] $SplitX, [byte[]] $Left, [byte[]] $Right) {
$stride = [int][Math]::Ceiling($Width * 3 / 4) * 4
$row = New-Object byte[] $stride
for ($x = 0; $x -lt $Width; $x++) {
$c = if ($x -lt $SplitX) { $Left } else { $Right }
$row[$x * 3] = $c[2]; $row[$x * 3 + 1] = $c[1]; $row[$x * 3 + 2] = $c[0] # BMP pixels are B,G,R
}
$pixels = $stride * $Height
$ms = [IO.MemoryStream]::new()
$w = [IO.BinaryWriter]::new($ms)
$w.Write([byte]0x42); $w.Write([byte]0x4D) # 'BM'
$w.Write([uint32](54 + $pixels)); $w.Write([uint32]0); $w.Write([uint32]54)
$w.Write([uint32]40); $w.Write([int32]$Width); $w.Write([int32]$Height) # BITMAPINFOHEADER
$w.Write([uint16]1); $w.Write([uint16]24); $w.Write([uint32]0); $w.Write([uint32]$pixels)
$w.Write([int32]2835); $w.Write([int32]2835); $w.Write([uint32]0); $w.Write([uint32]0)
for ($y = 0; $y -lt $Height; $y++) { $w.Write($row) }
$w.Flush()
[IO.File]::WriteAllBytes($Path, $ms.ToArray())
$w.Dispose()
}
# The version lives in one place: [workspace.package] version in the root Cargo.toml.
$Version = $env:LIGHTCRAFT_VERSION
if (-not $Version) {
@ -91,10 +116,26 @@ Copy-Item (Join-Path $Bin 'lightcraft.exe'), (Join-Path $Bin 'lightcraft-cli.exe
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'lightcraft.exe') (Join-Path $Stage 'lightcraft-cli.exe')
# ---- MSI ---------------------------------------------------------------------------------------
# Extensions must match the WiX tool version (5.0.2, see release.yml). Re-adding is harmless.
$WixVersion = '5.0.2'
$WixExtensions = 'WixToolset.UI.wixext', 'WixToolset.Util.wixext'
foreach ($ext in $WixExtensions) {
Invoke-Native "wix extension add $ext/$WixVersion" { wix extension add -g "$ext/$WixVersion" }
}
$ExtArgs = foreach ($ext in $WixExtensions) { '-ext'; "$ext/$WixVersion" }
# Installer dialog bitmaps (WixUI sizes): a brand-blue side panel on the welcome/finish pages and
# a plain white banner on the inner pages. Generated here, never committed.
$UiDialogBmp = Join-Path $Stage 'ui-dialog.bmp'
$UiBannerBmp = Join-Path $Stage 'ui-banner.bmp'
New-PanelBitmap $UiDialogBmp 493 312 164 ([byte[]](0x4E, 0x7B, 0xFB)) ([byte[]](0xFF, 0xFF, 0xFF))
New-PanelBitmap $UiBannerBmp 493 58 0 ([byte[]](0xFF, 0xFF, 0xFF)) ([byte[]](0xFF, 0xFF, 0xFF))
$Msi = Join-Path $Dist "lightcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'lightcraft.wxs') -arch $Arch `
wix build (Join-Path $PSScriptRoot 'lightcraft.wxs') -arch $Arch -culture en-us @ExtArgs `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\lightcraft.ico')" `
-d "UiDialogBmp=$UiDialogBmp" -d "UiBannerBmp=$UiBannerBmp" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.