Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,14 @@ user's commands. It is the whole chain, end to end, with no game:
cargo test -- --ignored a_real_activation_drives_a_session
```

Verifying a change means running it as the user does: deploy the two
executables to the install folder, restart the logon task
(`schtasks /Run /TN "GameModeExecutor\Watcher"`), and read the log at `debug`
through a real game session. Restarting the watcher while a game is running
fires the stop commands; do not.
Verifying a change means running it as the user does: `gamemode-executor
stop`, copy the two release executables over the installed ones,
`gamemode-executor install-task`, and read the log at `debug` through a real
game session. A change to the package itself is verified by uninstalling
from *Programs and Features* and installing the new `dist\` package — the
product code is fixed per version, so the same version cannot install over
itself. Restarting the watcher while a game is running fires the stop
commands; do not.

## Pitfalls that have already cost time

Expand Down
58 changes: 35 additions & 23 deletions docs/design/08-distribution.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Lot 8 — Distribution

**Status: in progress since 2026-09-17.** Already there: `scripts/build.ps1`
runs the whole checklist and produces the zip archive in `dist/`,
`.vscode/tasks.json` drives it, and the repository is public with CI green
on a stock runner. The script was written first on purpose — a release that
cannot be made by hand is not one CI can make either. What is left, in the
order it is taken:
**Status: done 2026-09-18.** `v0.1.0` was published that night by the
workflow alone, from the tag's commit, with the installer, the zip and their
checksums — which is the "done when" below. The lot was taken 2026-09-17/18
in the order listed; `scripts/build.ps1` was written first on purpose, since
a release that cannot be made by hand is not one CI can make either. One
field check is still outstanding and is recorded when it happens: the first
install on a second machine.

- [x] Create the public GitHub repository and push — done 2026-09-17, with approval
- [x] The three measurements below, on a minimal package, before any table is written — done 2026-09-17
Expand All @@ -16,9 +17,9 @@ order it is taken:
the checklist, build the **MSI** and the **zip archive**, and publish a
GitHub Release carrying both with their SHA-256 — nothing built or uploaded
by hand. Written 2026-09-17 (`release.yml`, `scripts/release-notes.ps1`);
its first run is the first tag
first run 2026-09-18 on the `v0.1.0` tag, green, three assets published
- [x] The documentation: *Getting started* and the README point at the release rather than at `cargo build`, the reference gains `purge`, *How it works* gains removal — 2026-09-17
- [ ] Verified in the field: the MSI on two machines, one real upgrade, one purge round trip — the maintainer's machine done 2026-09-17, below
- [ ] Verified in the field: the MSI on two machines, one real upgrade, one purge round trip — the maintainer's machine done 2026-09-17/18, below: install, upgrade, uninstall, purge; the second machine's first install still to come

**Done when** a tag alone produces a release a stranger can install from, and
the two artefacts on it were built by the workflow from that tag's commit.
Expand Down Expand Up @@ -366,23 +367,34 @@ unelevated shell, `/passive`, with a verbose log each time:
| Upgrade to 0.2.0 | exit 0 in 6 s, no prompt; file replaced, the old product gone, one product left at 0.2.0 | `Nested installation UAC elevation tracks that of parent (is not elevated)` — `RemoveExistingProducts` at 1510 removed 0.1.0 first |
| Uninstall | exit 0 in 6 s, no prompt; folder gone, registration gone; the neighbouring folders, the watcher's install and its scheduled tasks untouched | `Removal completed successfully` |

Two things the documentation had not made plain. **The summary stream's
"elevated privileges not required" bit (WordCount bit 3) is the whole
mechanism**: with it set, Windows Installer treats the package as per-user
outright, redirects `ProgramFilesFolder` to `%LOCALAPPDATA%\Programs`, and
logs `MSIINSTALLPERUSER property is not valid for UAC compliant package.
Ignoring` — so `ALLUSERS=2` and `MSIINSTALLPERUSER=1`, the dual-purpose
recipe, are not needed for a program with no per-machine story, and the
package is simpler without them. And **no SDK tool is needed to build the
database**: the COM automation creates tables, inserts rows and embeds the
cabinet, which means the release script can produce the MSI on a stock
runner the same way it produces the zip. `MsiDb`, `MsiFiler` and `Orca`
remain what they are, tools to inspect one. What the probe did not do and
the real package must: carry versioned files with `VERSIONINFO`, and pass
ICE validation (`MsiVal2`, from the SDK).
Two things the documentation had not made plain. The first this page got
wrong on the probe and corrected on the real package the same day, so both
readings stay here. **The probe's reading:** the summary stream's "elevated
privileges not required" bit (WordCount bit 3) looked like the whole
mechanism — with it set, Windows Installer ran without a prompt, put the
file under `%LOCALAPPDATA%\Programs`, and logged `MSIINSTALLPERUSER property
is not valid for UAC compliant package. Ignoring`, which read as "the
dual-purpose properties are not needed". **The real package's reading,
measured 2026-09-17 on an administrator account running unelevated:** it
takes all three. The bit lets the install run without a prompt; `ALLUSERS=2`
with `MSIINSTALLPERUSER=1` is what resolves the install to per-user and
redirects `ProgramFilesFolder`. With the bit alone the folder stayed at
`C:\Program Files (x86)`; with `ALLUSERS=2` alone the install turned
per-machine for an administrator and failed unelevated, 1603. The log's
"Ignoring" line is misleading: the property still decides how `ALLUSERS=2`
resolves. The probe had passed because its one file was small enough to be
looked for in the wrong folder without anyone noticing. And **no SDK tool is
needed to build the database**: the COM automation creates tables, inserts
rows and embeds the cabinet, which means the release script can produce the
MSI on a stock runner the same way it produces the zip — it did, on
2026-09-18. `MsiDb`, `MsiFiler` and `Orca` remain what they are, tools to
inspect one. What the probe did not do and the real package does: carry
versioned files with `VERSIONINFO`, and pass ICE validation (`MsiVal2`, from
the SDK, 102 evaluators, none tolerated).

The choice is closed: **MSI, authored from PowerShell through Windows
Installer's automation, per-user by the summary bit.**
Installer's automation, per-user by the summary bit and the two
properties together.**

Both candidates can offer per-user *or* per-machine from one installer, but
**this program has no per-machine story**: the logon task, the configuration
Expand Down
184 changes: 142 additions & 42 deletions docs/design/13-updating.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Lot 13 — Updating

**Status: proposed.** Decided 2026-09-17 to be a lot of its own rather than
a tail of [Lot 8](08-distribution.md): updating touches the "no network"
non-goal, the tray menu and the running process, and each of those deserves
its own measurement. Nothing here is built. It needs Lot 8 first — there is
nothing to update to until a release exists.
**Status: proposed, measured against a real release on 2026-09-18.** Decided
2026-09-17 to be a lot of its own rather than a tail of
[Lot 8](08-distribution.md): updating touches the "no network" non-goal, the
tray menu and the running process, and each of those deserves its own
measurement. Nothing here is built. Lot 8 is done and `v0.1.0` exists, so
there is now something to update from, and what a release actually answers
is recorded below rather than assumed.

**Goal.** A user who wants the newer version gets it from the notification
icon, without a browser, without an administrator prompt, and without the
Expand All @@ -15,50 +17,148 @@ says what it found, installs it on request while no game is running, and
the watcher comes back on the new version — verified in the field across a
real release pair.

## What Lot 8 already settled
## What Lot 8 settled, and what it then did for this lot

**The installer is the updater.** An updater that swaps files under an
installer is the wrong shape, for reasons the distribution page keeps in
its table of Windows Installer's four moments. So the updater downloads the
new package, verifies it, and runs it silently: `msiexec /i new.msi
/passive`, per-user, no UAC. The **zip** is the opposite case — nothing else
owns the files, so there the updater replaces them itself.
`MsiEnumRelatedProducts` on the package's UpgradeCode tells the two
installations apart.
new package, verifies it, and runs it silently: `msiexec /i new.msi`,
per-user, no UAC. `MsiEnumRelatedProducts` on the package's UpgradeCode —
already in `purge` — tells an installed copy from an unpacked one.

## What is decided, ahead of building it
**The package now stops and restarts the watcher itself.** This page first
proposed that the watcher quit before launching the installer and hand its
own relaunch to a detached shell, because Windows Installer's Restart
Manager would otherwise put up a files-in-use dialog. Lot 8 met that dialog
on its first uninstall and answered it in the package: an immediate action
runs `stop` before `InstallValidate`, and `install-task` at the end starts
the watcher through its task. Measured on a real upgrade on 2026-09-18:
700 ms from *Stopped* to *starting*, no dialog, one product listed. So the
updater has less to do than planned — start the installer detached and let
the package close the process that started it; the new version comes back
by the package's own doing. What the updater still owns is the failure
path: if the install fails after the watcher was stopped, nothing restarts
it until the next logon, so something must wait for `msiexec` and run the
task again when it exits non-zero. The same idiom as `purge`'s after-exit
shell: hidden Windows PowerShell, `Wait-Process`, then `schtasks /Run`.

## What the release answers, measured 2026-09-18 against `v0.1.0`

| Request | Answer |
| --- | --- |
| `HEAD github.com/{owner}/{repo}/releases/latest` | `302`, `Location: …/releases/tag/v0.1.0` — the tag, and nothing to parse but a URL |
| `HEAD …/releases/latest/download/SHA256SUMS.txt` | `302` to `…/releases/download/v0.1.0/SHA256SUMS.txt`, then the file: one line per asset, `<sha256> <name>` |
| `…/releases/download/v0.1.0/GameModeExecutor-0.1.0.msi` | `302` to a signed `release-assets.githubusercontent.com` URL valid for about an hour, then `200`, `Content-Length: 1462272`, `application/octet-stream` |
| `GET api.github.com/repos/{owner}/{repo}/releases/latest` | JSON: `tag_name`, `name`, `draft`, `prerelease`, `published_at`, `body`, and per asset `name`, `size`, `browser_download_url` and `digest: sha256:…`; `X-RateLimit-Limit: 60` per IP unauthenticated, `Cache-Control: max-age=60`, an `ETag` |

Two witnesses to the same hash: the workflow's `SHA256SUMS.txt`, computed
on the runner from the files it built, and GitHub's own `digest` on each
asset, computed on upload. For the `.msi` they agreed,
`85d6178b…d0d5d1`. Neither defends against a compromised account, only
against a corrupted or truncated download, and the record says so.

**What the program already knows without connecting:** its own version,
`build_info::VERSION`; whether Windows Installer owns it and under which
product code, `purge::installed_product()`; the installed product's
version, `MsiGetProductInfoW` with `VersionString` — which is the number
*Programs and Features* shows and the one an upgrade must beat; whether a
game is running; whether the logon task exists.

## The shape, decided ahead of building it

- **Never a silent poll.** An automatic release check breaks the "no
network" non-goal. The compatible shape is a *Check for updates…* entry
that connects only when clicked, or an explicit opt-in in the
configuration; nothing else ever opens a connection.
- **Over WinHTTP**, a Microsoft library using the system certificate store.
No HTTP crate.
- **The download verified against a SHA-256 published with the release.**
That guards against corruption, not against a compromised account, and
the record says so.
- **GitHub provides for the check with no key.** Either the REST API —
`GET /repos/{owner}/{repo}/releases/latest`, 60 requests an hour per IP
unauthenticated, `User-Agent` mandatory — or no API at all:
`github.com/{owner}/{repo}/releases/latest` answers 302 with the tag in
`Location`, and `…/releases/latest/download/{asset}` serves the latest
asset through a redirect to `objects.githubusercontent.com`. The second
suffices: a `HEAD`, a `Location`, a tag compared with
`build_info::VERSION`, nothing to parse. A token would matter only for a
private repository, and embedding one in a public executable would be a
fault.
- **The running executable.** The watcher holds its own `.exe`; Windows
Installer's Restart Manager would show a files-in-use dialog even under
`/passive`. So: refuse to update while a game is on — the same rule as the
purge — then launch the installer *and quit*, with the relaunch handed to
a detached shell (`msiexec … & schtasks /Run Watcher`). No custom action.
network" non-goal. *Check for updates…* connects when clicked and at no
other time. An opt-in check at start is not offered in this lot; if it
ever is, it is a configuration key that defaults to off, at most once a
day, and the record says what it sends.
- **The check is one request and no API.** `HEAD …/releases/latest` with
redirects disabled, the tag read from `Location`, `x.y.z` parsed from it
and compared with the running version as three numbers. A tag that does
not parse as exactly `vX.Y.Z` is "a release this version does not
understand", shown as such, never guessed at. No rate limit to think
about, no JSON, no `User-Agent` contract. The API stays in reserve for
the release notes, should the menu ever show them.
- **Every later request names the tag, not `latest`.** The checksum file
and the package are fetched from `…/releases/download/<tag>/…`, so a
release published between the check and the download cannot mix one
version's hash with another's file.
- **Verified against `SHA256SUMS.txt`**, the line for the package's exact
file name, with the hash computed through BCrypt — a Microsoft library,
no crate. A mismatch deletes the file and says so; nothing is ever run
unverified.
- **Over WinHTTP**, a Microsoft library using the system certificate store
and the system proxy (`WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY`). No HTTP
crate, no relaxed certificate flag. It follows `https` → `https`
redirects by default, which the asset chain needs, and can be told not
to for the one request whose redirect *is* the answer.
- **Downloaded to `%LOCALAPPDATA%\GameModeExecutor\updates\`**, local and
disposable like the log; the watcher empties that folder when it starts,
so a package is kept only until the version it carries is running.
Windows Installer caches its own copy of every package it installs, so
deleting the download costs a later repair nothing.
- **Installed with `msiexec /i <file> /qn /l*v <updates>\install.log`**,
started detached — not a child that shares the watcher's fate — and the
package's own actions stop this process and start the new one. `/qn`
rather than `/passive`, provisionally: `/passive` shows Windows
Installer's progress window, and the program's rule is no windows; the
icon going and coming back is the visible part, as it is for the
installer run by hand, and the version in the tooltip afterwards is the
confirmation. To be measured on screen before it is settled.
- **Refused while a game is running**, for the purge's reason: the stop
commands would fire mid-game and the new watcher would fire the start
commands seconds later. The menu says so; the user quits the game and
clicks again.
- **The zip copy is told, not updated.** Nothing owns its files but the
user, and replacing two executables under a running logon task from a
hidden shell is exactly the file-swapping shape this lot exists to avoid.
A hand-installed copy gets the same check, and the menu entry then
opens the release page. The person who chose no installer keeps their
files in their hands.
- **The menu is the whole interface**, as everywhere else: the entry reads
*Check for updates…*, then *Up to date (0.1.0)* greyed, or *Update to
0.2.0…*, or *Could not check: offline* greyed; the tooltip mirrors it.
No balloon, no dialog. The log carries the same lines under `setup`,
with the URL, the size and the hash, so an update is as readable
afterwards as an install.
- **A downgrade is never offered.** The package refuses one anyway
(`NEWERVERSIONDETECTED`), and the comparison makes it unreachable.

## What it does not defend against, said plainly

- **A compromised release or account.** The hash proves the file is the one
the workflow published, not that the workflow was honest. Authenticode
was declined in Lot 8 for want of a certificate anyone else's Windows
trusts; that decision, not this lot, is where the line moves if it ever
does.
- **SmartScreen does not see it.** A file fetched through WinHTTP carries
no Mark of the Web — browsers and Explorer write it, libraries do not —
so `msiexec` runs the package without the warning a person downloading
the same file would meet. Convenient, and worth knowing: the program is
the one vouching for the file, through the hash and nothing else.
- **A stale mirror or a captive portal.** A `302` to somewhere that is not
GitHub, or a `200` that is an HTML page, must fail the parse and be
shown as "could not check", never as "up to date".

## To measure, when the lot is taken

1. The redirect chain of `releases/latest` and `releases/latest/download`
through WinHTTP, and what a rate-limited or offline answer looks like
from the menu.
2. A `/passive` upgrade launched by the watcher itself, with the watcher
gone by the time Windows Installer looks for files in use.
3. The zip path: replacing two executables under a running logon task, and
what happens when the task fires in the middle of it.
1. WinHTTP against the four requests above: reading `Location` with
redirects disabled, following the asset chain to the signed URL with
them enabled, a proxy, an offline machine and a DNS failure, each as
seen from the menu and the log.
2. The watcher launching its own upgrade: `msiexec /qn` detached,
`StopWatcher` closing the process that started it, `RegisterTask`
bringing the new version back — and the failure path, with a package
built to fail after `InstallValidate`, restarting the old one.
3. `/qn` against `/passive`, on screen, success and failure.
4. The updates folder emptied at start while Windows Installer's cache
still serves a repair.
5. The zip path: the check, the notice, the page opening, and nothing else
happening.

## Size

A module of a few hundred lines — the requests, the hash, the version
comparison, the launch — with the comparison and the `SHA256SUMS.txt` parse
under unit tests, one menu entry and one tooltip state in the tray, and a
`setup` line for each step. Verifying it needs a real release pair: it is
built against `v0.1.0` and proved by installing whatever `v0.1.1` becomes.
Loading