Skip to content

Lot 8: distribution — installer, purge, release workflow - #2

Merged
geeooff merged 20 commits into
mainfrom
lot-8-distribution
Sep 18, 2026
Merged

geeooff merged 20 commits into
mainfrom
lot-8-distribution

Conversation

@geeooff

@geeooff geeooff commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Summary

Lot 8, distribution: a per-user Windows Installer package, a purge command, a tag-driven release workflow, and the field fixes from installing it for real. Design record in docs/design/08-distribution.md.

  • Version resources: each executable carries a VERSIONINFO block with the version, the commit and the copyright line from LICENSE; a dirty tree is flagged as a private build.
  • The package, scripts/msi.ps1, authored from Windows Installer's own automation and makecab, no third-party tooling: per user, no elevation, into %LOCALAPPDATA%\Programs\GameModeExecutor, deterministic GUIDs, downgrades refused, older versions removed first. build.ps1 release builds it and runs the SDK's 102 ICEs over it, failing on any finding.
  • What the package does beyond copying files, each one the program's own command run through the windowless twin: stop before the Restart Manager would ask to close the watcher, init (keeps an existing configuration), install-task (keeps an existing task, starts the watcher so the icon is the confirmation), uninstall-task on removal only.
  • gamemode-executor purge: lists and removes every trace — task, configuration, log, marker, executables — through Windows Installer or a detached shell as appropriate, refuses mid-game, leaves anything it does not recognise.
  • One command line for both executables (src/cli.rs): the twin parses the same arguments and prints nothing. The setup commands log what they did under a new setup category, so a machine's log says how it was set up.
  • Release workflow: a v* tag builds on a runner, validates, and publishes the installer, the zip, checksums and notes generated from the commits.
  • Measurements and corrections recorded in place: the console flash the probe could not see, the two-writer log, the immediate action's session, the first real upgrade.

Field verification

On the maintainer's machine, 2026-09-17/18, read from the log and watched on screen: install, reinstall, upgrade 0.1.0 → 0.1.1 → back, uninstall with no dialog and no orphaned task, purge. The first install on a second machine is still to come and does not block this merge.

Test plan

  • .\scripts\build.ps1 release — 112 + 3 tests, 102 ICEs clean, release OK
  • Install, upgrade, uninstall, reinstall from dist\, log read after each
  • CI on the runner: the MSI built and the 102 ICEs ran on windows-latest (run 35289134694)
  • CI artifact holds both the installer and the zip

🤖 Generated with Claude Code

geeooff and others added 20 commits September 17, 2026 18:21
The distribution page lists what is left in the order it is taken, with
the three MSI measurements first, and links to a new Lot 13 page that
carries the updater's decisions: never a silent poll, WinHTTP, SHA-256,
GitHub's keyless release endpoints, and the relaunch. What the updater
demands of the package stays with the package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ompt

A minimal package built from PowerShell through Windows Installer's COM
automation, one text file and no UI, went through all three from an
unelevated shell with exit 0 and no prompt; the logs are quoted. The
summary stream's "no elevation required" bit turns out to be the whole
mechanism, and MSIINSTALLPERUSER is ignored in its presence. The choice
is closed: an MSI authored that way, per-user by that bit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build.rs now writes one resource script per binary: the icon and a
VERSIONINFO whose FileVersion carries the commit, whose description and
OriginalFilename name the file, whose copyright line is read from
LICENSE and whose company is the package author. A tree with
uncommitted changes is flagged as a private build. Windows Installer's
repair and upgrade rules need versioned files, and the Properties
dialog now says what a file is. The checklist reads the blocks back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scripts\msi.ps1 writes the package with nothing but Windows Installer's
COM automation and makecab: the two executables and the license,
per-user without a prompt, older versions removed first, newer ones
refused, deterministic identifiers so the same commit gives the same
package. The release step builds it next to the zip and runs every ICE
the SDK ships over it, unpacking MsiVal2 without elevation and
registering its evaluator per user under the CLSID it actually serves.

Measured along the way, in the package's own logs: the summary bit
alone leaves ProgramFilesFolder at C:\Program Files (x86); ALLUSERS=2
alone turns per-machine for an administrator; the documented pair with
MSIINSTALLPERUSER=1 is what redirects to %LOCALAPPDATA%\Programs.
FindRelatedProducts has to precede LaunchConditions or a downgrade
slips through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`gamemode-executor purge` lists what it will remove and asks: the logon
task, the configuration wherever it was found, the log wherever it was
written, the session marker, the two profile folders once empty, and
last the executables -- through msiexec when Windows Installer owns
them, through a detached shell after this process has exited when they
were unpacked by hand. It refuses while a game is running and stops the
watcher first, with WM_CLOSE on its session window. It removes only what
it recognises as its own: a task it did not register and a folder
holding anything else stay.

The plan is computed apart from discovering the machine, so tests drive
it on scratch folders, including the detached shell. A test also holds
the upgrade code to the one scripts\msi.ps1 writes. The reference and
How it works describe the command and what uninstalling leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
release.yml runs on a vX.Y.Z tag: it checks the tag names the version
in Cargo.toml, runs the same checklist as a developer, and publishes the
installer, the zip and their SHA-256 checksums with gh. The notes are
written by scripts\release-notes.ps1 from what the build produced -- the
documentation link the binary carries, the checksums, the commits since
the previous tag -- so they can be tried locally and need no API.

The README and Getting started now send people to the release rather
than to a source build, and say what uninstalling leaves and what purge
removes; the reference and AGENTS.md carry the release procedure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first field run opened a console: a detached cmd.exe has no console
of its own, so ping created a visible one. CREATE_NO_WINDOW alone gives
the shell a hidden console its children inherit, and a child outlives
its parent on Windows without any flag. The same run left the program's
folder behind because the PowerShell it was typed into sat inside it;
purge now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shell that deletes a hand-installed copy after purge exits was a
cmd.exe pausing on the batch idiom ping -n 3, a guessed delay from a
program that promises to connect to nothing. It is now a hidden Windows
PowerShell that waits for exactly this process with Wait-Process and
then removes what it was given; the installed case starts msiexec the
same way. Only single quotes reach the command line, so std's quoting
carries it intact, and the test hands the shell the id of a process
that has already exited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four remarks from the first install from the package, all taken. The
package ends with two custom actions, the program's own commands run as
the user: init, which writes the starter configuration only where there
is none, and install-task, which registers the logon task only where
there is none and then starts the watcher -- the icon appearing is the
confirmation an install without a UI otherwise lacks. Both keep what
exists unless --force, which the package never passes; init used to fail
on an existing file, which would have failed every upgrade.

The starter configuration names no program any more: two beeps,
commented out, and the recipes for the rest. That makes a configuration
with no commands valid, which it was not -- the watcher then detects and
logs and runs nothing. The zip stops shipping a config.toml, so both
ways in leave the same machine. A test-only family lets the package be
installed beside the real one, which is how the actions were measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… does

The installer's actions flashed a console twice at the end of every
install: Windows Installer does not hide an executable action's console,
and the probe that had said otherwise could not see a console host's
window. The actions now run through gamemode-executorw.exe, which has
none, and the maintainer saw no window on the next install.

Rather than give the twin two hidden verbs of its own, both binaries now
parse the same `Cli` from a new `cli` module in the library and differ in
one boolean, whether there is a console to print to. `init`,
`install-task` and `uninstall-task` open the log -- the configuration's
`log_dir` when one can be read, the default location otherwise -- and
write what they did at `info` under a new `setup` category: configuration
written, kept or replaced; task registered, kept, replaced or removed;
watcher started. A person at the console sees the same lines; the
installer's run leaves them in the log, where until now nothing said how
a machine had been set up. `schtasks` runs with CREATE_NO_WINDOW too.

The distribution record corrects its "no window" note in place, the
windowless-watcher page records why the twin took the whole command line,
and the reference, how-it-works and getting-started pages describe the
shared command line and the setup lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es it

The first uninstall from Programs and Features put up the Restart Manager
dialog asking to close "GameModeExecutor watcher": the package could
start the watcher but not stop it, so InstallValidate found it holding
the files about to go.

`stop` is Quit from outside -- WM_CLOSE on the session window, then a
wait on the single-instance mutex, which the watcher releases only after
its last log line. It lives in `service`, `purge` uses it instead of its
own copy, and the package runs it as an immediate action before
InstallValidate on an uninstall and on an upgrade, from the executable
already installed, carrying on if that fails so a version too old to
know the verb degrades to the dialog rather than to an error. The mutex
is now read with OpenMutexW rather than taken, so a watcher starting at
that instant cannot mistake the probe for a running instance.

Two processes write the log while a watcher is being stopped. The file
is opened for appending only, so the file system places each write at
the end and a line is one write; 80 processes writing at once left 80
whole lines. Recorded where the file is opened and in the reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The immediate action ran in the interactive session: the watcher logged
Stopped, the action logged the stop 252 ms later, and the Restart
Manager never asked, since no session-end line was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first real upgrade stopped the watcher from the new package, then
ran the old product's own stop action during RemoveExistingProducts,
which found nothing and logged a second line for it. The condition now
excludes a product being removed by an upgrade, and the upgrade is
recorded: 700 ms from stop to start, one product listed, no dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ummary

Uninstalling left the logon task registered and armed: at the next logon
it would have started an executable that was no longer there and failed,
every time. The task is the package's infrastructure, not the user's
data, so an UnregisterTask action now runs `uninstall-task` on an
uninstall, deferred, before RemoveFiles takes the executable it runs.
Not on the removal an upgrade performs, so a delay or a configuration
path chosen with `install-task` survives an upgrade as before.

The summary information read "Installation Database" in Explorer's
Details tab, the SDK's customary title, which says what the file is to
a tool and nothing to a person. It now names the product and version,
says what the program does, carries the commit and the documentation
link, and sets the creation time Explorer otherwise takes from the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI kept only the zip, and inside GitHub's own zip at that. Both
artefacts now go into one artifact named after the version, at
compression level 0: GitHub serves every artifact as a zip of its own
whatever it holds, so the level keeps it a plain container around two
files that are already compressed. Raw files come from the release
workflow, which attaches them to the release itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@geeooff
geeooff merged commit 27a34f8 into main Sep 18, 2026
1 check passed
@geeooff
geeooff deleted the lot-8-distribution branch September 18, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant