From 11c43818baa3917f0cced3ed9eb8d77ffb3e3dbc Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:46:19 +0200 Subject: [PATCH 01/10] Open Lot 13 with the shape agreed for the updater The page records what was decided on 2026-09-18 before any code: the installer is the updater and the package already stops and restarts the watcher; the zip copy updates itself through the after-exit shell; the session is handed over rather than closed, with stop --handover and a start that resumes an open session; the state machine the menu renders, its phases, entries and two expiries; the faults and their log lines; what it does not defend against; and what to measure. AGENTS.md gains the three rules the lot rests on: the tray renders state and holds no rule, the setup commands are a contract with three callers verified together, and the one network connection is the user's click. The non-goal and the README say the same. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 31 +++- README.md | 4 +- docs/design/13-updating.md | 341 +++++++++++++++++++++++++------------ docs/design/README.md | 8 +- 4 files changed, 259 insertions(+), 125 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e40209..3a7c292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,12 @@ These decide most questions before they are asked. concrete. - **Discreet.** No dialogs, no windows, no sounds. The icon, its tooltip and its menu are the whole user interface; the log is the rest. -- **No elevation, no network, no service, no telemetry.** Recorded as - non-goals in the design record with their reasons. Programs that need - administrator rights are reached through a scheduled task the user registers - once, never by elevating the watcher. +- **No elevation, no service, no telemetry, and no network the user did + not ask for.** Recorded as non-goals in the design record with their + reasons. The one connection the program ever opens is *Check for updates*, + on a click, and `docs/design/13-updating.md` says exactly what it sends. + Programs that need administrator rights are reached through a scheduled + task the user registers once, never by elevating the watcher. - **Microsoft libraries only.** The `windows` crate for Win32, the Windows SDK's `rc.exe` for resources. No third-party tray, icon, or installer crate. @@ -60,10 +62,23 @@ deleted. `ALL CAPS` categories, no `camelCase` in prose. Conversation with the maintainer is in French. - **Log lines follow the contract in `docs/reference.md`.** `info` is - reserved for detection, the watcher's own start and stop, and what the - setup commands did to the machine; everything else is `debug` unless it is - a degradation (`warn`) or needs the user (`error`). The message is the sentence, the fields are the technical annex, - and every call names a `target:` — a test fails the build otherwise. + reserved for detection, the watcher's own start and stop, what the setup + commands did to the machine, and each step of an update; everything else + is `debug` unless it is a degradation (`warn`) or needs the user + (`error`). The message is the sentence, the fields are the technical + annex, and every call names a `target:` — a test fails the build + otherwise. +- **The tray renders state and holds no rule.** What the icon, the tooltip + and the menu show comes from objects that own the rules — the engine's + session, `update`'s phase — and the tray asks them what to draw and which + action a click means. A rule written in the menu code is in the wrong + place and cannot be tested. +- **The setup commands are a contract with three callers.** `stop`, `init`, + `install-task` and `uninstall-task` are sequenced by the package + (`scripts/msi.ps1`), by the zip's after-exit shell in `update`, and by + `purge`, each in the order its own mechanism allows. A change to any one + of those commands, however small, is verified on all three paths before + it is called done. - Module-level doc comments carry the rules a module is shaped by (the tray's re-entrancy rule, the marker's location, the engine's callback). Read them before changing a module, and update them when the rule changes. diff --git a/README.md b/README.md index 37aa5ff..151ff29 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ or anything else with a command line. **There is no list of games to maintain.** Windows itself decides when a game is running — the watcher observes the Game Bar presence writer that Windows starts for one — and it does not poll while you play. It runs as you, with no -administrator rights, keeps no network connection, and shows nothing but a -small icon in the notification area. +administrator rights, connects to nothing unless you ask it to look for an +update, and shows nothing but a small icon in the notification area. ## Documentation diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md index 28e0d94..fbce169 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -1,46 +1,70 @@ # Lot 13 — Updating -**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. +**Status: in progress since 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. The shape below was agreed with the +maintainer on 2026-09-18, against `v0.1.0`, before a line was written. + +- [ ] The session handed from one watcher to the next: `stop --handover`, and a start that resumes an open session instead of closing it — two scenarios in `engine/tests.rs` +- [ ] `update`: the state machine, tested whole through a scripted feed; the WinHTTP feed and the BCrypt hash behind it +- [ ] The menu section, rendered from the machine and nothing else +- [ ] The package: `StopForUpgrade` hands over, `StopForRemoval` restores +- [ ] The zip copy updates itself the same way, through the after-exit shell +- [ ] The documentation: *Getting started*, *How it works*, the reference, the README's word on the network +- [ ] Measured: the four requests through WinHTTP, offline and behind a proxy; a self-launched upgrade with the session resumed; the failure path restarting the old watcher; `/qn` on screen +- [ ] Verified in the field across a real release pair **Goal.** A user who wants the newer version gets it from the notification icon, without a browser, without an administrator prompt, and without the program ever connecting on its own. -**Done when:** *Check for updates…* in the menu finds the latest release, -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. +**Done when:** *Check for updates* in the menu finds the latest release, +says what it found, installs it on request — mid-game included — and the +watcher comes back on the new version with the game session intact, +verified in the field across a real release pair. ## 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`, -per-user, no UAC. `MsiEnumRelatedProducts` on the package's UpgradeCode — -already in `purge` — tells an installed copy from an unpacked one. - -**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`. +its table of Windows Installer's four moments. So for an installed copy the +updater downloads the new package, verifies it, and runs it silently: +`msiexec /i new.msi /qn`, per-user, no UAC. `MsiEnumRelatedProducts` on the +package's UpgradeCode — already in `purge` — tells an installed copy from an +unpacked one. + +**The package stops and restarts the watcher itself.** Lot 8 met the Restart +Manager's 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 starts the installer detached and lets 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 a hidden shell waits for `msiexec` and runs the task +again when it exits non-zero — `purge`'s after-exit idiom. + +**The zip copy is not told, it is updated** — decided 2026-09-18 after this +page had, for a night, proposed a notice and a link instead. The shape to +avoid is swapping files under an installer that owns them; nothing owns an +unpacked copy's files but the user, which is what the distribution page +had said all along. The same hidden shell does the work in that mode: wait +for the watcher to exit, expand the archive over the folder — the zip ships +no `config.toml`, so a configuration beside the executables is untouched — +keep the previous executables as `.old` until the new version has started, +then `install-task`. Not transactional, unlike Windows Installer, and the +page says so; the old files stay until the new ones are in place. + +**The commands are the contract, and each caller sequences them.** The +package, the zip's shell and `purge` each call `stop`, `init`, +`install-task` and `uninstall-task` in the order their own mechanism +allows; a shared "finish" verb was considered on 2026-09-18 and declined, +because the package could not call it at the moments Windows Installer +dictates anyway and it would exist for symmetry alone. The cost of that +freedom is a rule, in `AGENTS.md`: a change to any of those commands is +verified on all three paths. ## What the release answers, measured 2026-09-18 against `v0.1.0` @@ -59,69 +83,168 @@ 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. *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//…`, 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 /qn /l*v \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. +product code, `purge::installed_product()`; whether a game is running; +whether the logon task exists. + +## The session is handed over, not closed + +Decided 2026-09-18, when the maintainer asked why an update should wait for +the game to end. The first draft of this page refused to install mid-game, +because `stop` is *Quit*: the stop commands fire, the marker goes, and the +new watcher then detects the running game and fires the start commands — +a two-second blip of the idle configuration in the middle of a session. +The refusal avoided the blip from the wrong end. The session marker +already says "a game session is open"; what was missing was a stop that +leaves it saying so. + +| | *Quit*, `stop` | `stop --handover` | +| --- | --- | --- | +| Who follows | nobody | a watcher, within the second | +| Stop commands | run now — the standing promise, "not left on a gaming configuration" | **do not run** | +| Marker | removed once the commands are confirmed | **left in place**: the session is open, and handed on | +| Log | `Stopping while a game is running, so the stop commands run now` | `Stopping for an update; the game session is handed to the next watcher` | + +And at start, `recover()` learns the distinction [Lot 9](09-robustness.md) +had already written down for the configuration-fault case — *look for the +writer before recovering*: + +- marker present **and the presence writer alive** → **resume**: the name + from the marker, the icon active, the wait on the writer's handle taken + up again. Nothing runs, neither stop nor start: the start already + happened. No refinement either; the name in the marker is the refined + one when there was one. +- marker present, writer gone → the recovery of today: the stop commands + run, because the game ended in the gap, or during a logoff. + +It covers more than the updater: a watcher that crashes mid-game and is +restarted by its task, the failure path restarting the old watcher, the +development loop of `stop` then `install-task` — all resume where they +used to blip. `StopSignal` carries a reason, `Restore` or `Handover`; the +session window takes an application message beside `WM_CLOSE` so a +`stop --handover` from another process can say which; the engine +branches on it. Two scenarios in `engine/tests.rs`: a handed-over session +is resumed by the next watcher without running anything, and a +handed-over session whose game ended in between runs the stop commands at +start. + +**In the package**, two stop actions where there was one: `StopForUpgrade`, +`stop --handover`, conditioned on `PREVIOUSVERSIONS` — a successor is +guaranteed by `RegisterTask` in the same sequence — and `StopForRemoval`, +plain `stop`, on an uninstall, where nobody follows and the machine must +be restored. **One degradation, accepted on 2026-09-18:** the first package +to carry `--handover` runs it on the installed `0.1.0`, which does not know +the flag; the action fails, continues, and the Restart Manager's dialog +comes back for that one upgrade on the two machines that have `0.1.0`. +Clicking through was measured clean on 2026-09-18 01:08. A legacy action +kept forever for two machines was not worth it. + +`purge` keeps refusing mid-game: nobody follows a purge. + +## The machine, and the menu that renders it + +**The UI reflects an object.** Every rule lives in `update`: which entries +exist in which phase, which actions are legal, when a verdict expires. The +tray asks `view()` for a list of items and calls `perform(action)` for the +one chosen; it holds no rule of its own. The object is driven by events, +so it is tested whole without a network, through the same seam the engine +uses for the OS: a `Feed` trait — the latest tag, a text file, a download — +with `WinHttp` as the one real implementation and a scripted one for the +tests. + +``` +Idle nothing to say +Checking one request in flight +UpToDate { version, at } a verdict about now +Available { release, at } tag, version, page, file name, hash +Downloading { release, size } +Installing { release } msiexec, or the zip's shell, is running +Failed { fault, at } a sentence, a code, the log has the rest +``` + +`apply(event)` with `CheckAsked`, `CheckDone(Ok(verdict) | Err(fault))`, +`InstallAsked`, `DownloadStarted(size)`, `DownloadDone(Ok | Err)`, +`Launched`, `LaunchFailed(fault)`; `view(now) -> Vec`, an `Item` being +a label, an optional `Action` — `Check`, `Install`, `OpenReleasePage` — and +whether it is enabled. Expiry is computed in `view` from the phase's `at`; +there is no timer. + +The section sits between *Documentation*'s separator and *Quit*: + +| Phase | Entries (⊘ disabled) | +| --- | --- | +| Idle | Check for updates | +| Checking | ⊘ Checking for updates… | +| UpToDate, within the hour | Check for updates · ⊘ 0.1.0 is the latest version | +| Available | Check for updates · **Download and install 0.2.0** · What changed in 0.2.0 | +| Downloading | ⊘ Check for updates · ⊘ Downloading 0.2.0 (1.4 MB)… · What changed in 0.2.0 | +| Installing | ⊘ Check for updates · ⊘ Installing 0.2.0… | +| Failed, within the hour | Check for updates · ⊘ Could not check: no connection (see log) | +| A failure found at start | Check for updates · ⊘ Update to 0.2.0 failed: Windows Installer 1603 (see log) | + +Three entries at most, never two disabled ones outside a download. *Check +for updates* is clickable again as soon as a result exists and clears the +rest; it is disabled while a download or an install is running, where a +new check would mean nothing. The tooltip and the icon do not change: the +updater says nothing through the icon. + +**Two expiries, not one.** `UpToDate` and `Failed` are claims about *now* +and expire after an hour. `Available` does not expire: a release does not +un-release, and someone who said "later" should find the offer where they +left it rather than click twice. Both were the maintainer's call on +2026-09-18, between five minutes and a day. + +**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. Every later request names +the tag, not `latest`, so a release published between the check and the +download cannot mix one version's hash with another's file. The API stays +in reserve for the release notes, should the menu ever show them; *What +changed* opens the release page in the browser. + +**Never a silent poll.** *Check for updates* connects when clicked and at +no other time. An opt-in check at start is not offered; 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. **A downgrade is never offered**; the package +refuses one anyway. + +**The same from the console.** `gamemode-executor update` drives the same +object — `--check` prints the verdict and stops, the default downloads, +verifies and installs — so a script, a diagnosis or the second machine's +maintainer can do what the menu does, and the network path can be +measured from a shell without a watcher. + +## Faults, and what the log says + +A network is an outside dependency the program did not have before, so +every step is written down, under a new category, `update` — +`RUST_LOG=update=debug` isolates everything that touches it: + +| Cause | Menu | Log | +| --- | --- | --- | +| DNS, connection refused, timeout | Could not check: no connection (see log) | `warn`, the WinHTTP code as a field | +| GitHub answered 4xx/5xx, or 429 | Could not check: GitHub answered 503 (see log) | `warn` | +| An unexpected answer — no `Location`, a tag that is not `vX.Y.Z`, HTML where the checksums should be, a captive portal | Could not check: unexpected answer (see log) | `warn`, the headers at `debug` | +| The hash does not match | Download failed: the file did not verify (see log) | `warn`, the file deleted | +| Disk full, folder not writable | Download failed: cannot write to …\updates (see log) | `warn`, the Win32 code | +| `msiexec` refuses before stopping the watcher — 1618 another install running, 1638 | Update failed: Windows Installer 1618 (see log) | `warn`; the watcher is still there to say so | +| A failure *after* the watcher stopped — 1603, the zip's extraction, `install-task` | seen at the next start: Update to 0.2.0 failed: … (see log) | the shell restarts the previous watcher and writes `updates\result.txt`; the watcher reads it at start, logs `warn`, shows it until the next check | + +At `info`, the story: `Checking for updates` · `0.1.0 is the latest` · +`Update available: 0.2.0` · `Downloading 0.2.0 (1.4 MB)` · `Downloaded and +verified 0.2.0` · `Installing 0.2.0; the watcher stops now and comes back +on the new version` — then, from the new watcher, `Updated to 0.2.0`. The +watcher writes `updates\pending.txt` with the version it is installing +before it launches anything; whichever watcher starts next compares that +file with its own version — equal, the update took; different, it did +not, and `result.txt` says why when the shell got as far as writing it. +Every menu line that ends in *(see log)* means it. + +Downloads go to `%LOCALAPPDATA%\GameModeExecutor\updates\`, local and +disposable like the log; the watcher empties it at start once the pending +file has been read. Windows Installer caches its own copy of every package +it installs, so deleting the download costs a later repair nothing. ## What it does not defend against, said plainly @@ -139,26 +262,20 @@ game is running; whether the logon task exists. 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 +## To measure, when the pieces exist 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. + them enabled, the system proxy, an offline machine and a DNS failure, + each as seen from the menu and the log. +2. A self-launched upgrade mid-game: `msiexec /qn` detached, + `StopForUpgrade` handing the session over, `RegisterTask` bringing the + new version back, the session resumed with nothing run — and the + failure path, with a package built to fail after `InstallValidate`, + restarting the old one, which resumes too. +3. `/qn` 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. +5. The zip path, on an unpacked copy: the files replaced under the logon + task, `.old` kept until the new version starts, and what happens when + the task fires in the middle of it. diff --git a/docs/design/README.md b/docs/design/README.md index 83d6a51..3424ffd 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -24,7 +24,7 @@ session rather than when the code compiles. Each has its own page. | 10 | [Configuration window](10-configuration-window.md) | proposed | | 11 | [Documentation for the people who use it](11-user-documentation.md) | done | | 12 | [Editing the configuration without breaking it](12-editing-on-a-copy.md) | proposed | -| 13 | [Updating](13-updating.md) | proposed | +| 13 | [Updating](13-updating.md) | in progress | **Dependency order:** 1 → 2 → 4 → 5 → 6 → 7, with 3 independent and 7 needing both 3 and 6. Logging sits before the icon deliberately — the icon logs too, @@ -54,8 +54,10 @@ Recorded so they stop coming back. applications. - **No allow-list of game executables, and no heuristics that guess at what a game is.** Detection is Windows' verdict, read from Windows. -- **No telemetry, no network access.** A future update check must be manual or - opt-in, or this stops being true — see [Lot 13](13-updating.md). +- **No telemetry, and no network access the user did not ask for.** The one + connection the program opens is *Check for updates*, on a click, and + [Lot 13](13-updating.md) says what it sends and to whom. Nothing is ever + polled, and an opt-in check at start, if it ever comes, defaults to off. - **No elevation.** The watcher runs as the user, on purpose. Programs that need administrator rights are reached through a scheduled task, never by elevating the watcher — see [Lot 1](01-console-watcher.md). From dc630a8cf6d6a4ef984f20f5b2609028d2fef7ab Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:50:43 +0200 Subject: [PATCH 02/10] Hand a game session from one watcher to the next A stop mid-game meant Quit: the stop commands ran, the marker went, and the watcher that followed detected the game again and ran the start commands -- the idle and then the gaming configuration two seconds apart, in the middle of a game. An update or an upgrade needs a stop that leaves the session open instead. StopSignal carries a reason. `stop --handover` posts WM_HANDOVER to the session window where WM_CLOSE means Quit; the engine then runs nothing and leaves the marker saying the session is open. At start, the engine looks for the presence writer before recovering: still running, the session is resumed from the marker -- name, icon, the wait on the writer -- with neither start nor stop commands run; gone, the recovery of before. Three scenarios in engine/tests.rs. The package's one stop action becomes two: StopForUpgrade hands over, since RegisterTask starts the new watcher seconds later, and StopForRemoval restores, since nobody follows an uninstall. The installed 0.1.0 does not know the flag, so the first upgrade from it gets the Restart Manager's dialog back once, accepted and recorded. Co-Authored-By: Claude Opus 5 --- docs/design/09-robustness.md | 10 +++ docs/getting-started.md | 4 +- docs/how-it-works.md | 9 +++ docs/reference.md | 4 +- scripts/msi.ps1 | 25 +++++--- src/cli.rs | 18 +++++- src/engine.rs | 78 +++++++++++++++++++---- src/engine/tests.rs | 120 +++++++++++++++++++++++++++++++++++ src/purge.rs | 2 +- src/service.rs | 35 ++++++---- src/win.rs | 103 +++++++++++++++++++++++++----- 11 files changed, 351 insertions(+), 57 deletions(-) diff --git a/docs/design/09-robustness.md b/docs/design/09-robustness.md index 919685d..0253076 100644 --- a/docs/design/09-robustness.md +++ b/docs/design/09-robustness.md @@ -38,6 +38,16 @@ depend on Windows' timing: - At start, before watching, a marker present means the last session never closed: one `info` line naming the game, the stop commands, the marker removed. Logoff, shutdown, crash and power cut are one case. +- **Since 2026-09-18, the writer is looked for first.** A marker present + with the presence writer still running means the game never ended: the + last watcher handed the session over — `stop --handover`, which an update + or an upgrade uses because a watcher follows within the second — or died + under it. Then nothing runs, neither stop nor start, and the session is + resumed from the marker: name, icon, the wait on the writer's handle. The + stop commands run at the end of the game as they always did. Without this + an update mid-game switched the configuration off and on again two + seconds apart. Three scenarios in `engine/tests.rs`; the design is in + [Lot 13](13-updating.md). **Where it lives, and why not in `logs\`.** A logs folder is disposable by nature and gets emptied without a second thought, which would take a pending diff --git a/docs/getting-started.md b/docs/getting-started.md index bfe66f8..966ec11 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -111,7 +111,9 @@ gamemode-executor install-task The first is **Quit** from the icon's menu, typed. The second starts it again — and, from the zip, registers the task that starts it at every logon, once. -No administrator rights, no password, no window. +No administrator rights, no password, no window. Doing this while a game is +running? `stop --handover` instead of `stop`: the game session is left to the +new watcher, which takes it up where it was without running anything. **That is the end of the setup.** Play. The commands fire by themselves. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index c78fadc..45c88b1 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -177,6 +177,15 @@ is off. `stop_actions_on_exit = false` opts out of both: quitting the watcher mid-game leaves your profile alone, and so does the next logon. +The same file lets one watcher hand a game to the next. When the watcher is +stopped for an update — by the installer, or by `stop --handover` — with a +game on, it runs nothing and leaves the file saying the session is open; the +watcher that starts a second later finds the game still running and takes +the session up where it was, icon and name included. Nothing runs twice, and +your gaming configuration is never switched off and on again in the middle +of a game. If the game ended in that second, the new watcher runs the stop +commands instead, as after a logoff. + `gamemode-executor status` shows whether that file is there, and where. ## Removing it diff --git a/docs/reference.md b/docs/reference.md index 00cdb2c..fd566bc 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -39,7 +39,7 @@ way. | `init [--force]` | Write the starter configuration file into `%APPDATA%\GameModeExecutor`. One that is already there is kept unless `--force`. The installer runs this. What happened is logged under `setup`. | | `install-task [--delay 15s] [--force]` | Register a per-user logon task that runs `gamemode-executorw.exe` with no window, then start it now. A task already registered is kept unless `--force`. The configuration path is stored absolute. The installer runs this too. Logged under `setup`. | | `uninstall-task` | Remove that task. No task is not an error. The installer runs this on an uninstall, not on an upgrade. Logged under `setup`. | -| `stop` | Stop the running watcher the way *Quit* in its menu does — mid-game, the stop commands run on the way out — and wait until it has gone. None running is not an error. The task is left alone; `install-task` starts it again. The installer runs this before removing or replacing the executables. Logged under `setup`. | +| `stop [--handover]` | Stop the running watcher the way *Quit* in its menu does — mid-game, the stop commands run on the way out — and wait until it has gone. With `--handover` an open game session is left to the watcher that follows: the stop commands do not run, and the next start resumes the session with nothing run twice — for an update or an upgrade, where one follows within seconds. None running is not an error. The task is left alone; `install-task` starts it again. The installer runs this before removing (plain) or replacing (`--handover`) the executables. Logged under `setup`. | | `purge [--yes]` | Remove every trace of the program: the logon task, the configuration, the log, the session marker, the executables. It lists what it will remove and asks; `--yes` is for scripts. Refuses while a game is running. See [Removing it](how-it-works.md#removing-it). | Global options: `--config `, `--log-level `, `--version`. @@ -209,7 +209,7 @@ syntax — `RUST_LOG=game=debug` for the detection lines alone. | --- | --- | --- | | Configuration | next to the executable, or `%APPDATA%\GameModeExecutor\config.toml` | yours; roams with the profile | | Log | `%LOCALAPPDATA%\GameModeExecutor\logs\` | disposable | -| Session marker | `%LOCALAPPDATA%\GameModeExecutor\pending-stop-actions` | present while a game session is open; left behind by a logoff, shutdown or crash, and honoured at the next start. `status` reports it. | +| Session marker | `%LOCALAPPDATA%\GameModeExecutor\pending-stop-actions` | present while a game session is open; left behind by a logoff, shutdown, crash or handover, and settled at the next start — the session resumed if the game is still on, closed if it is gone. `status` reports it. | | Logon task | `\GameModeExecutor\Watcher` in Task Scheduler | records the absolute path of the executable; removed with the package, kept through an upgrade | ## Building and releasing diff --git a/scripts/msi.ps1 b/scripts/msi.ps1 index adf2047..8d77044 100644 --- a/scripts/msi.ps1 +++ b/scripts/msi.ps1 @@ -6,7 +6,9 @@ # uninstall reaches them. Four custom actions, all the program's own # commands and all idempotent, run through the windowless executable: # `stop` before an uninstall or upgrade touches the files, so the Restart -# Manager never has to ask; `init`, which writes a starter configuration +# Manager never has to ask -- with `--handover` on an upgrade, so a game +# session in progress is resumed by the new watcher rather than closed and +# reopened; `init`, which writes a 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 -- to finish an install or upgrade; and @@ -282,16 +284,22 @@ try { # + 64 (carry on if it fails). Immediate, before InstallValidate, where # the Restart Manager would otherwise find the watcher holding the files # and put up its "close these applications" dialog (seen 2026-09-18 on - # the first uninstall). It runs the *installed* executable, which an - # upgrade has not replaced yet; one too old to know `stop` fails, and - # the dialog comes back -- a visible, harmless degradation. - Insert 'CustomAction' @('StopWatcher', 98, 'INSTALLDIR', '"[INSTALLDIR]gamemode-executorw.exe" stop', $null) + # the first uninstall). Both run the *installed* executable, which an + # upgrade has not replaced yet. An upgrade hands a game session over -- + # RegisterTask starts the new watcher seconds later and it resumes the + # session, nothing runs twice; a removal restores, since nobody follows. + # An installed version too old to know the verb or the flag fails the + # action, which continues, and the dialog comes back for that one + # upgrade: 0.1.0 does not know --handover, accepted 2026-09-18. + Insert 'CustomAction' @('StopForUpgrade', 98, 'INSTALLDIR', '"[INSTALLDIR]gamemode-executorw.exe" stop --handover', $null) + Insert 'CustomAction' @('StopForRemoval', 98, 'INSTALLDIR', '"[INSTALLDIR]gamemode-executorw.exe" stop', $null) $sequences = @{ InstallExecuteSequence = @( @('FindRelatedProducts', 25), @('LaunchConditions', 100), @('ValidateProductID', 700), @('CostInitialize', 800), @('FileCost', 900), @('CostFinalize', 1000), - @('StopWatcher', 1300), @('InstallValidate', 1400), @('InstallInitialize', 1500), + @('StopForUpgrade', 1300), @('StopForRemoval', 1310), + @('InstallValidate', 1400), @('InstallInitialize', 1500), @('RemoveExistingProducts', 1510), @('ProcessComponents', 1600), @('UnpublishFeatures', 1800), @('UnregisterTask', 3400), @('RemoveFiles', 3500), @('InstallFiles', 4000), @@ -319,7 +327,7 @@ try { } # The setup actions run on an install and on an upgrade -- a new product # code is not Installed -- and never on a repair or an uninstall. The - # watcher is stopped on an uninstall and on an upgrade, where its files + # watcher is stopped on an upgrade and on an uninstall, where its files # are about to go; a fresh install has none to stop. Not when this # product is the old one being removed by an upgrade: the new package # stopped the watcher before it got here, and the first upgrade @@ -327,7 +335,8 @@ try { $conditions = @{ InitConfig = 'NOT Installed' RegisterTask = 'NOT Installed' - StopWatcher = '(REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE) OR PREVIOUSVERSIONS' + StopForUpgrade = 'PREVIOUSVERSIONS' + StopForRemoval = 'REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE' UnregisterTask = 'REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE' } foreach ($t in $sequences.Keys) { diff --git a/src/cli.rs b/src/cli.rs index 21bc7ab..28a31af 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -101,7 +101,14 @@ pub enum Command { /// Stop the running watcher, the way Quit in its menu does: mid-game, /// the stop commands run on the way out. None running is not an error. /// The logon task is left as it is; `install-task` starts it again. - Stop, + Stop { + /// Leave an open game session to the next watcher instead of + /// closing it: the stop commands do not run, and the watcher that + /// starts next resumes the session. For an update or an upgrade, + /// where one follows within seconds. + #[arg(long)] + handover: bool, + }, /// Remove every trace of the program: the logon task, the configuration, /// the log, the session marker, and the executables themselves. Refuses /// while a game is running. Shows what it will remove and asks first. @@ -143,9 +150,14 @@ pub fn run(cli: Cli, console: bool) -> Result<()> { setup_logging(cli.config.as_deref(), cli.log_level.as_deref(), console)?; return task::uninstall(); } - Some(Command::Stop) => { + Some(Command::Stop { handover }) => { setup_logging(cli.config.as_deref(), cli.log_level.as_deref(), console)?; - service::stop()?; + let reason = if handover { + crate::win::StopReason::Handover + } else { + crate::win::StopReason::Restore + }; + service::stop(reason)?; return Ok(()); } Some(Command::Check { path, pid }) => return check(path.as_deref(), pid), diff --git a/src/engine.rs b/src/engine.rs index f3e443f..c59b1ca 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -26,7 +26,7 @@ use crate::detect::{self, GameSignal}; use crate::logging::{self, target}; use crate::marker::Marker; use crate::sensor::Sensor; -use crate::win::StopSignal; +use crate::win::{StopReason, StopSignal}; /// What the engine tells the outside world about the session. #[derive(Debug, Clone, PartialEq, Eq)] @@ -123,20 +123,47 @@ impl Engine { } } - /// Close the session the last process never got to. + /// Settle the session the last process left open: resume it when the + /// game is still on, close it when the game is gone. /// /// Measured on 2026-09-16: a command started at logoff, even one /// millisecond after Windows first asks, dies with STATUS_DLL_INIT_FAILED. /// The session-end handshake is not where the stop commands can run, so /// they run here, at the start that follows. A logoff, a shutdown, a crash /// and a power cut are then one case. - fn recover(&self) { - let Some(marker) = &self.marker else { - return; - }; - let Some(pending) = marker.pending() else { - return; - }; + /// + /// The other case, decided 2026-09-18: the writer is still running, so the + /// game never ended -- the last watcher handed the session over for an + /// update, or crashed under it. Then nothing runs, neither stop nor start, + /// and the session is taken up where it was. Looking for the writer + /// *before* recovering is what keeps a game still on from getting the + /// idle and then the gaming configuration seconds apart. Returns the + /// writer to park on and the name to show when resuming. + fn recover(&self) -> Option<(u32, Option)> { + let marker = self.marker.as_ref()?; + let pending = marker.pending()?; + if let Some(pid) = self.sensor.writer_pid() { + let signal = pending.game.clone().map(|name| GameSignal { + source: "resumed", + process_name: Some(name), + process_id: None, + process_path: None, + }); + match &pending.game { + Some(game) => tracing::info!( + target: target::GAME, + since = pending.since.as_deref(), + "The last watcher left a session open with {game} still running, so it resumes where it was" + ), + None => tracing::info!( + target: target::GAME, + since = pending.since.as_deref(), + "The last watcher left a session open with a game still running, so it resumes where it was" + ), + } + self.report(&Session::Playing(signal.clone())); + return Some((pid, signal)); + } match &pending.game { Some(game) => tracing::info!( target: target::GAME, @@ -162,6 +189,7 @@ impl Engine { &ActionContext::new("game_stop", signal.as_ref()), ); self.forget(); + None } pub fn run(&mut self, stop: &StopSignal) -> Result<()> { @@ -170,12 +198,23 @@ impl Engine { idle_poll = ?self.config.detection.poll_interval, "Watching for games" ); - self.recover(); + let mut resumed = self.recover(); - while let Some(mut pid) = self.await_writer(stop) { + loop { + // A resumed session already had its start: no commands, no + // marker to write, and no refinement -- the name in the marker + // is the refined one when there was one. + let (mut pid, mut signal, fresh) = match resumed.take() { + Some((pid, signal)) => (pid, signal, false), + None => match self.await_writer(stop) { + Some(pid) => (pid, self.identify(), true), + None => break, + }, + }; let session_start = Instant::now(); - let mut signal = self.identify(); - self.fire_start(signal.as_ref()); + if fresh { + self.fire_start(signal.as_ref()); + } // The satellites of a title -- launcher stubs, anti-cheat // services -- can match the known game list too, and the one that @@ -185,7 +224,7 @@ impl Engine { // found nothing to arbitrate. So: once, a little way into the // session, ask which candidate is actually rendering, and expect // "no better answer" more often than not. - let mut refine_due = !self.config.detection.identify_after.is_zero(); + let mut refine_due = fresh && !self.config.detection.identify_after.is_zero(); let stopped = loop { let timeout = refine_due.then_some(self.config.detection.identify_after); @@ -228,6 +267,17 @@ impl Engine { }; if stopped { + // A handover: the watcher that follows resumes this session, + // so nothing runs and the marker stays open. The commands + // would only have swapped the configuration twice in the + // middle of a game. + if stop.reason() == StopReason::Handover { + tracing::info!( + target: target::WATCHER, + "Stopping for an update; the game session is handed to the next watcher" + ); + return Ok(()); + } if self.config.general.stop_actions_on_exit { // Stays at info: without it the reader sees a session end // and has no way to tell the game stopped from the watcher diff --git a/src/engine/tests.rs b/src/engine/tests.rs index efe1aff..32b0444 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -36,6 +36,9 @@ struct Scripted { /// same instant `wait_for_writer_exit` reports the exit, as a logoff /// does when Windows kills the writer before the watcher is told. session_ends_with_writer: bool, + /// A stop reported by `wait_for_writer_exit` is a handover, as + /// `stop --handover` from an update makes it. + stops_by_handover: bool, stop: Arc, } @@ -50,10 +53,16 @@ impl Scripted { list_unreadable: false, counters_unreadable: false, session_ends_with_writer: false, + stops_by_handover: false, stop: Arc::clone(stop), } } + fn stops_by_handover(mut self) -> Self { + self.stops_by_handover = true; + self + } + fn list_unreadable(mut self) -> Self { self.list_unreadable = true; self @@ -122,6 +131,9 @@ impl Sensor for Scripted { if self.session_ends_with_writer && outcome == WaitOutcome::WriterExited { self.stop.signal(); } + if self.stops_by_handover && outcome == WaitOutcome::Stopped { + self.stop.signal_handover(); + } Ok(outcome) } @@ -601,6 +613,114 @@ fn opting_out_of_stop_on_exit_runs_nothing_and_closes_the_marker() { ); } +// ------------------------------------------------------------- handover -- + +#[test] +fn a_handover_mid_game_runs_nothing_and_leaves_the_session_open() { + // An update stops the watcher while a game is on. The stop commands must + // not run -- the next watcher resumes the session within the second -- + // and the marker must still say the session is open. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .writer(&[Some(7)]) + .waits(&[WaitOutcome::Stopped]) + .stops_by_handover() + .candidates(&[&[game(10, "game.exe")]]); + let dir = scratch(); + let ran = dir.join("stop-ran"); + let mut config = quick_config(); + config.general.stop_actions_on_exit = true; + config.on_game_stop = stop_event(vec![touch(&ran)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(config, sensor) + .reporting_to(sink) + .remembering(Marker::in_dir(&dir)); + engine.run(&stop).unwrap(); + + assert!(!ran.exists(), "the stop commands did not run"); + let pending = Marker::in_dir(&dir) + .pending() + .expect("the session stays open"); + assert_eq!(pending.game.as_deref(), Some("game.exe")); + assert_eq!( + seen(&log), + vec![Session::Playing(Some(game(10, "game.exe")))], + "the session was never reported as ended" + ); +} + +#[test] +fn a_session_handed_over_is_resumed_without_running_anything() { + // The next watcher starts with the marker open and the writer alive: it + // takes the session up -- name from the marker, icon active -- and runs + // neither the start commands, which already ran, nor the stop commands, + // which are for when the game ends. Then the game ends, and they run. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .writer(&[Some(7)]) + .waits(&[WaitOutcome::WriterExited]); + let dir = scratch(); + let marker = Marker::in_dir(&dir); + marker.open(Some("game.exe"), "earlier").unwrap(); + let started = dir.join("start-ran"); + let stopped = dir.join("stop-ran"); + let mut config = quick_config(); + config.on_game_start = stop_event(vec![touch(&started)]); + config.on_game_stop = stop_event(vec![touch(&stopped)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(config, sensor) + .reporting_to(sink) + .remembering(marker); + engine.run(&stop).unwrap(); + + assert!(!started.exists(), "the start commands did not run again"); + assert!( + stopped.exists(), + "the stop commands ran when the game ended" + ); + assert!( + Marker::in_dir(&dir).pending().is_none(), + "and the session closed as usual" + ); + let resumed = GameSignal { + source: "resumed", + process_name: Some("game.exe".to_owned()), + process_id: None, + process_path: None, + }; + assert_eq!( + seen(&log), + vec![Session::Playing(Some(resumed)), Session::Idle], + "resumed as playing, then ended" + ); +} + +#[test] +fn a_session_handed_over_whose_game_ended_meanwhile_is_closed_at_start() { + // Same marker, but the writer is gone by the time the next watcher + // starts: the ordinary recovery, the stop commands run before watching. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop).writer(&[None]); + let dir = scratch(); + let marker = Marker::in_dir(&dir); + marker.open(Some("game.exe"), "earlier").unwrap(); + let stopped = dir.join("stop-ran"); + let mut config = quick_config(); + config.on_game_stop = stop_event(vec![touch(&stopped)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(config, sensor) + .reporting_to(sink) + .remembering(marker); + engine.run(&stop).unwrap(); + + assert!(stopped.exists(), "the stop commands ran at start"); + assert!(Marker::in_dir(&dir).pending().is_none()); + assert!(seen(&log).is_empty(), "recovery is not a session"); +} + // ------------------------------------------------------------- recovery -- #[test] diff --git a/src/purge.rs b/src/purge.rs index 5330b2b..217809b 100644 --- a/src/purge.rs +++ b/src/purge.rs @@ -231,7 +231,7 @@ pub fn discover(config: Option<&config::Config>, config_path: &Path) -> Layout { /// the caller prints nothing after this returns. pub fn execute(plan: &Plan) -> Result<()> { if plan.stop_watcher { - service::stop()?; + service::stop(win::StopReason::Restore)?; println!("Watcher stopped."); } if plan.remove_task { diff --git a/src/service.rs b/src/service.rs index 9e19fff..918e1cb 100644 --- a/src/service.rs +++ b/src/service.rs @@ -17,7 +17,9 @@ //! `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. `purge` and the installer both use it, so the files -//! are never pulled from under a running watcher. +//! are never pulled from under a running watcher. With `StopReason::Handover` +//! the session window gets `WM_HANDOVER` instead, and a game session that +//! is open stays open for the watcher that follows. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -25,7 +27,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crate::config::{self, Config}; -use crate::win::{SessionWindow, SingleInstance, StopSignal}; +use crate::win::{SessionWindow, SingleInstance, StopReason, StopSignal}; use crate::{engine, logging, sensor, tray, win}; /// Ceiling on how long `WM_ENDSESSION` holds the shutdown while the stop @@ -163,14 +165,16 @@ pub enum Stopped { NotRunning, } -/// Ask the running watcher to quit the way its menu does and wait for it to -/// have gone. Mid-game that runs the stop commands, as *Quit* would. No -/// watcher is not an error: the caller wanted none running, and none is. +/// Ask the running watcher to stop and wait for it to have gone. With +/// `Restore` that is *Quit*: mid-game the stop commands run. With +/// `Handover` an open session is left in the marker for the watcher that +/// follows. No watcher is not an error: the caller wanted none running, and +/// none is. /// /// A watcher that is still starting holds the mutex before it has a window, /// so the close is retried until the mutex is free. Logged at `info` under /// `setup`, whether a person or the installer asked. -pub fn stop() -> Result { +pub fn stop(reason: StopReason) -> Result { if !SingleInstance::is_held(INSTANCE) { tracing::info!(target: logging::target::SETUP, "No watcher was running"); return Ok(Stopped::NotRunning); @@ -178,13 +182,20 @@ pub fn stop() -> Result { let asked = Instant::now(); loop { // May find no window yet, or none any more: the mutex is the verdict. - let _ = win::close_session_window(); + let _ = win::close_session_window(reason); if !SingleInstance::is_held(INSTANCE) { - tracing::info!( - target: logging::target::SETUP, - waited = ?asked.elapsed(), - "Watcher stopped, as asked" - ); + match reason { + StopReason::Restore => tracing::info!( + target: logging::target::SETUP, + waited = ?asked.elapsed(), + "Watcher stopped, as asked" + ), + StopReason::Handover => tracing::info!( + target: logging::target::SETUP, + waited = ?asked.elapsed(), + "Watcher stopped, as asked; a game session that was open waits for the next one" + ), + } return Ok(Stopped::Stopped); } anyhow::ensure!( diff --git a/src/win.rs b/src/win.rs index c0d79ed..e93c22c 100644 --- a/src/win.rs +++ b/src/win.rs @@ -1,6 +1,7 @@ //! Thin, safe wrappers around the few Win32 calls the program needs. use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::Duration; @@ -25,12 +26,28 @@ use windows::Win32::UI::WindowsAndMessaging::{ }; use windows::core::{BOOL, HSTRING, PCWSTR}; +/// Why the watcher is stopping, which decides what a stop mid-game does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StopReason { + /// Nobody follows: *Quit*, `stop`, Ctrl-C, a logoff. The stop commands + /// run, so the machine is not left on its gaming configuration. + Restore, + /// A watcher follows within seconds: an update, an upgrade, the + /// development loop. The session stays open in the marker and the next + /// watcher resumes it, so nothing runs twice. Decided 2026-09-18. + Handover, +} + /// A manual-reset event used to unblock every wait in the program at once. /// /// Waiting on a kernel event rather than checking a flag on a timer is what /// keeps the watcher at zero wake-ups while a game is running. pub struct StopSignal { event: HANDLE, + /// Set before the event when the stop is a handover. The first reason to + /// arrive wins: a *Quit* after a handover request still hands over, a + /// handover after a *Quit* has nothing left to hand. + handover: AtomicBool, } // SAFETY: a Win32 event handle is a kernel object; signalling and waiting on @@ -45,14 +62,34 @@ impl StopSignal { // is owned by `StopSignal` and closed on drop. let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) } .context("cannot create the stop event")?; - Ok(Self { event }) + Ok(Self { + event, + handover: AtomicBool::new(false), + }) } + /// Stop, and restore: the stop commands run if a game is on. pub fn signal(&self) { // SAFETY: the event is open for as long as `self` lives. let _ = unsafe { SetEvent(self.event) }; } + /// Stop, and hand an open session to the watcher that follows. + pub fn signal_handover(&self) { + if !self.is_set() { + self.handover.store(true, Ordering::SeqCst); + } + self.signal(); + } + + pub fn reason(&self) -> StopReason { + if self.handover.load(Ordering::SeqCst) { + StopReason::Handover + } else { + StopReason::Restore + } + } + pub fn is_set(&self) -> bool { // SAFETY: as for `signal`. unsafe { WaitForSingleObject(self.event, 0) == WAIT_OBJECT_0 } @@ -86,6 +123,10 @@ impl Drop for StopSignal { /// the main thread knows there is nothing left to wait for. const WM_WATCHER_FINISHED: u32 = WM_APP + 1; +/// Posted by another process of this program -- `stop --handover` -- where +/// `WM_CLOSE` would mean *Quit*. `WM_APP + 2` and `+ 3` belong to the tray. +const WM_HANDOVER: u32 = WM_APP + 4; + /// What the window procedure needs. There is exactly one watcher per process -- /// `SingleInstance` guarantees it -- so a process-wide slot is simpler and /// safer than threading a raw pointer through `CREATESTRUCT`. @@ -147,6 +188,22 @@ unsafe extern "system" fn window_proc( } LRESULT(0) } + // `WM_CLOSE` with a reason: the session is handed on, not closed. + // Destroying the window is what the default procedure does for + // `WM_CLOSE`, and it ends the message loop the same way. + WM_HANDOVER => { + if let Some(state) = SESSION.get() { + tracing::debug!( + target: crate::logging::target::WATCHER, + "Asked to hand the session over, so the watcher stops without closing it" + ); + state.stop.signal_handover(); + } + // SAFETY: `window` is this procedure's own window, destroyed on + // its own thread; `WM_DESTROY` follows and ends the loop. + unsafe { _ = DestroyWindow(window) }; + LRESULT(0) + } WM_WATCHER_FINISHED | WM_DESTROY => { // SAFETY: no arguments beyond the exit code; only affects the // calling thread's message queue. @@ -169,39 +226,53 @@ unsafe extern "system" fn window_proc( /// of this program finds it. const SESSION_CLASS: &str = "GameModeExecutorSession"; -/// Ask a running watcher to quit, the way its *Quit* menu entry does: `WM_CLOSE` -/// on its session window, which the default procedure turns into -/// `WM_DESTROY` and so into the end of the message loop. Nothing here waits; -/// the caller watches the single-instance mutex to know the process is gone. +/// Ask a running watcher to stop: `WM_CLOSE` on its session window, the way +/// its *Quit* menu entry does, or `WM_HANDOVER` to leave an open session to +/// the watcher that follows. Either ends the message loop through +/// `WM_DESTROY`. Nothing here waits; the caller watches the single-instance +/// mutex to know the process is gone. /// /// `FindWindowW` cannot see a class another process registered, so the /// top-level windows are enumerated and asked their class name instead. -pub fn close_session_window() -> Result<()> { - unsafe extern "system" fn visit(window: HWND, found: LPARAM) -> BOOL { +pub fn close_session_window(reason: StopReason) -> Result<()> { + /// The message and the found flag, handed to the callback as one + /// pointer. + struct Visit { + message: u32, + found: bool, + } + unsafe extern "system" fn visit(window: HWND, visit: LPARAM) -> BOOL { let mut name = [0u16; 64]; // SAFETY: `name` is a valid buffer and its length is what is passed; // GetClassNameW writes at most that many characters. let len = unsafe { GetClassNameW(window, &mut name) }; if len > 0 && String::from_utf16_lossy(&name[..len as usize]) == SESSION_CLASS { - // SAFETY: WM_CLOSE carries no pointers; the window handle came + // SAFETY: `visit` is the address of the caller's `Visit`, alive + // for the whole enumeration and written only here. + let visit = unsafe { &mut *(visit.0 as *mut Visit) }; + // SAFETY: the message carries no pointers; the window handle came // from the enumeration and may be gone by the time it is read, // which PostMessageW reports rather than dereferences. - if unsafe { PostMessageW(Some(window), WM_CLOSE, WPARAM(0), LPARAM(0)) }.is_ok() { - // SAFETY: `found` is the address of the caller's `bool`, - // alive for the whole enumeration. - unsafe { *(found.0 as *mut bool) = true }; + if unsafe { PostMessageW(Some(window), visit.message, WPARAM(0), LPARAM(0)) }.is_ok() { + visit.found = true; } return BOOL(0); } BOOL(1) } - let mut found = false; + let mut state = Visit { + message: match reason { + StopReason::Restore => WM_CLOSE, + StopReason::Handover => WM_HANDOVER, + }, + found: false, + }; // SAFETY: the callback reads only what it is given and writes only to - // `found`, whose address is passed and which outlives the call. An + // `state`, whose address is passed and which outlives the call. An // enumeration the callback stops is reported as an error by EnumWindows, // which is why its result is not the verdict. - let _ = unsafe { EnumWindows(Some(visit), LPARAM(&mut found as *mut bool as isize)) }; - anyhow::ensure!(found, "no running watcher was found"); + let _ = unsafe { EnumWindows(Some(visit), LPARAM(&mut state as *mut Visit as isize)) }; + anyhow::ensure!(state.found, "no running watcher was found"); Ok(()) } From dbc77f0a7b9d07b115cddc4a7bf30e219555a2f5 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:33:15 +0200 Subject: [PATCH 03/10] Add the updater: check, download, verify and install from the menu `update` is the object the menu renders and holds no rule of its own: phases, events, the items each phase shows and the two expiries live in `Machine`, tested whole through events. The network is behind `Feed` -- the latest release's redirect, a text file, a download -- with WinHTTP as the one real feed; the hash is CNG's SHA-256. What the release answers was measured by hand against v0.1.0 through the code itself, and what the tests exercise instead is a listener of their own on 127.0.0.1, written from the standard library, answering the record's table: the 302 with the tag, a redirect to a second listener for the asset host, a body larger than one read, a cut-off download, a 404, a 503, a page where a text file should be, a port nobody listens on. It talks plain http to a cfg(test) constructor the shipped program does not have; TLS is WinHTTP's, and a bad certificate was measured refused with 12175. No test calls an external host, and AGENTS.md says so. Two things the record could not know: closing a WinHTTP session cancels the requests under it, so the parent handles ride along with the response; and a header is asked for its length first, since a signed asset URL runs to a kilobyte. An installed copy runs the package quietly through a hidden shell and lets the package hand this watcher's session to the new one; an unpacked copy's shell waits for this process, expands the archive over the folder with the old executables kept as .old, and runs install-task. A copy counts as installed only when the upgrade code is registered and it runs from the folder the package installs to, so an unpacked copy beside a package updates itself rather than the package. pending.txt and result.txt tell the next watcher whether the update took. What Windows Installer knows about the program moves to `package`, which purge and update both ask. The tray draws the section from update::view() and hands a chosen entry back; `gamemode-executor update [--check]` drives the same object from a console. Every step is logged under `update`. The library's line coverage goes from 64.5 % to 70 % with the updater at 77 % to 95 % per file and the command line at 44 % from nothing. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 8 +- Cargo.toml | 4 + README.md | 8 +- docs/design/09-robustness.md | 11 + docs/design/13-updating.md | 93 ++++- docs/getting-started.md | 30 ++ docs/how-it-works.md | 26 +- docs/reference.md | 13 +- src/build_info.rs | 3 + src/cli.rs | 151 +++++++ src/lib.rs | 3 + src/logging.rs | 6 +- src/package.rs | 88 +++++ src/purge.rs | 90 +---- src/service.rs | 13 +- src/shell.rs | 59 +++ src/tray.rs | 47 +++ src/update/feed.rs | 307 +++++++++++++++ src/update/hash.rs | 145 +++++++ src/update/install.rs | 400 +++++++++++++++++++ src/update/mod.rs | 668 +++++++++++++++++++++++++++++++ src/update/tests.rs | 389 ++++++++++++++++++ src/update/winhttp.rs | 737 +++++++++++++++++++++++++++++++++++ 23 files changed, 3189 insertions(+), 110 deletions(-) create mode 100644 src/package.rs create mode 100644 src/shell.rs create mode 100644 src/update/feed.rs create mode 100644 src/update/hash.rs create mode 100644 src/update/install.rs create mode 100644 src/update/mod.rs create mode 100644 src/update/tests.rs create mode 100644 src/update/winhttp.rs diff --git a/AGENTS.md b/AGENTS.md index 3a7c292..d1dd208 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,13 @@ deleted. - Pure logic gets a unit test; Win32 behaviour gets verified by hand and the result written into the design record with its date. The engine reads the OS only through `sensor::Sensor`, and `engine/tests.rs` scripts one to run - whole sessions; a change to the loop gets a scenario there. + whole sessions; a change to the loop gets a scenario there. The updater + reads the network only through `update::feed::Feed`, scripted the same + way. +- **No test calls an external host**, ignored or not: the script runs the + ignored tests on every developer machine, and a test that needs GitHub + is a test that fails with the Wi-Fi. The network path is measured by hand + with `gamemode-executor update --check` and recorded with its date. - Commit messages: an imperative subject, a short body saying what changed and why, and a `Co-Authored-By` trailer for the agent that co-wrote it. The collaboration is not hidden. diff --git a/Cargo.toml b/Cargo.toml index ec191d5..51d2d40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ windows = { version = "0.62", features = [ # MsiEnumRelatedProducts: whether the installer owns the executables, which # decides how `purge` removes them. "Win32_System_ApplicationInstallationAndServicing", + # The updater: WinHTTP for the release, CNG for the SHA-256 of what it + # fetched. Microsoft's libraries, no HTTP or hashing crate. + "Win32_Networking_WinHttp", + "Win32_Security_Cryptography", # WNDCLASSEXW names HBRUSH, HICON and HCURSOR, so the window class needs Gdi # even though this program never draws anything. "Win32_Graphics_Gdi", diff --git a/README.md b/README.md index 151ff29..3f21ba8 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,11 @@ gamemode-executor init # write the starter config.toml gamemode-executor install-task # start the watcher now and at every logon ``` -To remove it, *Programs and Features* takes the executables and the logon -task away and leaves your configuration; `gamemode-executor purge` removes -every trace. Building +Later releases install themselves: right-click the icon, **Check for +updates**, and the menu offers the newer version — nothing is checked +unless you ask. To remove it, *Programs and Features* takes the executables +and the logon task away and leaves your configuration; `gamemode-executor +purge` removes every trace. Building from source is in the [reference](docs/reference.md#building-and-releasing). ## License diff --git a/docs/design/09-robustness.md b/docs/design/09-robustness.md index 0253076..4a6d05e 100644 --- a/docs/design/09-robustness.md +++ b/docs/design/09-robustness.md @@ -212,3 +212,14 @@ game", so the icon stayed grey through such a session. The sink now carries a `Session` enum — `Idle` or `Playing(Option)` — and the case has a test. The manual `trigger` command no longer builds an engine at all; it runs the commands, which is all it ever did. + +**Measured again on 2026-09-18, with Lot 13's updater in:** the library at +70 % line coverage, ignored tests included, and `winhttp` at 92 % through a +listener the tests run themselves. The updater was written to the +same cut — the network behind `Feed`, the machine driven by events — and +sits at 76 % to 95 % per file, the shell scripts it generates checked for +their shape and the `pending`/`result` files exercised on scratch folders. +The command line gained parse tests and a machine-bound diagnostics test, +44 % from nothing. What stays near zero is what it should be: `service`, +`win`, and the parts of `tray` that are Win32 calls, verified by hand with +the dates in this record. diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md index fbce169..148f4d7 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -6,12 +6,12 @@ the "no network" non-goal, the tray menu and the running process, and each of those deserves its own measurement. The shape below was agreed with the maintainer on 2026-09-18, against `v0.1.0`, before a line was written. -- [ ] The session handed from one watcher to the next: `stop --handover`, and a start that resumes an open session instead of closing it — two scenarios in `engine/tests.rs` -- [ ] `update`: the state machine, tested whole through a scripted feed; the WinHTTP feed and the BCrypt hash behind it -- [ ] The menu section, rendered from the machine and nothing else -- [ ] The package: `StopForUpgrade` hands over, `StopForRemoval` restores -- [ ] The zip copy updates itself the same way, through the after-exit shell -- [ ] The documentation: *Getting started*, *How it works*, the reference, the README's word on the network +- [x] The session handed from one watcher to the next: `stop --handover`, and a start that resumes an open session instead of closing it — three scenarios in `engine/tests.rs`, 2026-09-18 +- [x] `update`: the state machine, tested whole through a scripted feed; the WinHTTP feed and the CNG hash behind it — 2026-09-18, and no test ever calls GitHub: the network path is measured by hand, below +- [x] The menu section, rendered from the machine and nothing else — 2026-09-18, to be seen on screen +- [x] The package: `StopForUpgrade` hands over, `StopForRemoval` restores — 2026-09-18 +- [x] The zip copy updates itself the same way, through the after-exit shell — 2026-09-18, the script tested for its shape +- [x] The documentation: *Getting started*, *How it works*, the reference, the README's word on the network — 2026-09-18 - [ ] Measured: the four requests through WinHTTP, offline and behind a proxy; a self-launched upgrade with the session resumed; the failure path restarting the old watcher; `/qn` on screen - [ ] Verified in the field across a real release pair @@ -83,7 +83,7 @@ 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()`; whether a game is running; +product code, `package::installed_product()`; whether a game is running; whether the logon task exists. ## The session is handed over, not closed @@ -122,10 +122,10 @@ development loop of `stop` then `install-task` — all resume where they used to blip. `StopSignal` carries a reason, `Restore` or `Handover`; the session window takes an application message beside `WM_CLOSE` so a `stop --handover` from another process can say which; the engine -branches on it. Two scenarios in `engine/tests.rs`: a handed-over session -is resumed by the next watcher without running anything, and a -handed-over session whose game ended in between runs the stop commands at -start. +branches on it. Three scenarios in `engine/tests.rs`: a handover mid-game +runs nothing and leaves the session open, a handed-over session is +resumed by the next watcher without running anything, and a handed-over +session whose game ended in between runs the stop commands at start. **In the package**, two stop actions where there was one: `StopForUpgrade`, `stop --handover`, conditioned on `PREVIOUSVERSIONS` — a successor is @@ -155,18 +155,22 @@ tests. Idle nothing to say Checking one request in flight UpToDate { version, at } a verdict about now -Available { release, at } tag, version, page, file name, hash +Available { release } tag, version, page, file name, hash Downloading { release, size } Installing { release } msiexec, or the zip's shell, is running -Failed { fault, at } a sentence, a code, the log has the rest +Failed { fault, during, at } a sentence, a code, the log has the rest ``` -`apply(event)` with `CheckAsked`, `CheckDone(Ok(verdict) | Err(fault))`, -`InstallAsked`, `DownloadStarted(size)`, `DownloadDone(Ok | Err)`, -`Launched`, `LaunchFailed(fault)`; `view(now) -> Vec`, an `Item` being -a label, an optional `Action` — `Check`, `Install`, `OpenReleasePage` — and +`apply(event, now)` takes `CheckAsked`, `CheckDone(Ok(verdict) | Err(fault))`, +`InstallAsked`, `DownloadStarted { size }`, `DownloadDone(Ok | Err)`, +`InstallFailed(fault)` and `FoundAtStart(fault)`, and hands back the +`Effect` the worker must go and run — `Check`, `Download(release)`, +`Install(release)` — or nothing, for an answer nobody asked for or a click +the phase does not take. `view(now) -> Vec`, an `Item` being a label, +an optional `Action` — `Check`, `Install`, `OpenReleasePage(url)` — and whether it is enabled. Expiry is computed in `view` from the phase's `at`; -there is no timer. +there is no timer. `Failed` with no `at` is a failure found at start, kept +until the next check. The section sits between *Documentation*'s separator and *Quit*: @@ -215,6 +219,59 @@ verifies and installs — so a script, a diagnosis or the second machine's maintainer can do what the menu does, and the network path can be measured from a shell without a watcher. +**Measured through the code on 2026-09-18**, with `update --check` from a +console and a run of the feed by hand, against `v0.1.0`: the `HEAD` answers +`302` with the tag in 330 ms; the checksum file comes through its redirect; +the 1.4 MB installer comes through the signed-URL chain in 340 ms and +hashes to the release's line. One thing the record could not have known: +closing a WinHTTP session cancels every request under it, and the first +body read failed with `12017` until the session and connection handles +were kept alive with the response. No test calls GitHub — the script runs +the ignored tests on every developer machine — so this is a hand +measurement, repeated with the command whenever the feed changes. + +**What the tests do instead, decided 2026-09-18:** a listener of their +own on `127.0.0.1`, written from the standard library in the test module +of `winhttp.rs`, that answers the record's table — the `302` with the tag, +a redirect to a *second* listener for the asset host, a body larger than +one read, a cut-off download, a `404`, a `503`, a page where a text file +should be, a port nobody listens on. It talks plain `http` to a +`#[cfg(test)]` constructor of the feed that the shipped program does not +have; a TLS server without a crate would be SChannel by hand, and TLS is +WinHTTP's, not ours. The `12017` defect above is the kind this catches. +A local certificate was considered and declined: absent from the runner, +and a test that installs one touches the machine's trust store. So was +HTTP.sys, which IIS and .NET's `HttpListener` serve HTTPS through, with SNI +bindings since Windows 8: binding a certificate to a port is `netsh http +add sslcert`, administrator only, and a non-administrator cannot reserve a +URL prefix for a listener without `netsh http add urlacl` either — a test +can do neither unelevated, and one that could would be changing the +machine. Considered on the maintainer's remark, 2026-09-18. What TLS +does when the certificate is wrong was measured by hand instead, the same +day, against `expired`, `self-signed` and `wrong.host` at badssl.com: +`NoConnection { code: 12175 }`, `ERROR_WINHTTP_SECURE_FAILURE`, all three +— the program relaxes no flag. A comparison test against GitHub, to +measure drift, was declined for the same rule; drift shows up as +"unexpected answer" from `update --check`, with the headers at `debug`, +and the record and the listener are corrected together. + +**A stand-in for GitHub as a project of its own** — a Python or .NET +minimal API beside the repository, for end-to-end runs — was weighed on +2026-09-18 and declined. It would not buy TLS either: the blocker is the +client, which trusts only what the machine trusts, and a development +certificate is trusted through a consent prompt or the root store, neither +of which a runner has. It would buy fidelity the code does not read, +against a process to start and stop, a second toolchain, and a dependency +tree of its own to keep patched. Should an end-to-end run against the real +binary ever be worth having, the honest shape is not a fake GitHub but a +real one: a repository of test releases, with real HTTPS, real redirects +and real signed URLs, reached through a configuration key naming the +repository to update from — a key a fork would need anyway, and one that +opens nothing new, since whoever can edit the configuration can already +name any executable in it. Run by hand or on a `workflow_dispatch`, never +in `test`, because it calls an external host. Kept in reserve until a fork +asks for it. + ## Faults, and what the log says A network is an outside dependency the program did not have before, so diff --git a/docs/getting-started.md b/docs/getting-started.md index 966ec11..4a53612 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -147,11 +147,34 @@ arrow next to the clock, and drag it onto the taskbar to keep it there. | **Edit configuration** | opens your `config.toml` in whatever you use for text files — Notepad if `.toml` is not associated with anything | | **Open log** | opens the log the same way | | **Documentation** | opens this page for **the exact build you are running**, not for whatever the project looks like today | +| **Check for updates** | asks GitHub whether a newer release exists — the only time this program ever connects to anything, and only when you click. The answer appears in the menu the next time you open it: *0.1.0 is the latest version*, or **Download and install 0.2.0** beside a **What changed in 0.2.0** that opens the release page | | **Quit** | stops the watcher, running the stop commands on the way out so you are not left on a gaming profile | Quitting only stops it until the next time you log on. To stop it for good, see [Turning it off](#turning-it-off). +## Updating + +Right-click the icon, **Check for updates**, then open the menu again. If a +newer release exists, **Download and install** fetches it, checks it against +the checksums the release publishes, and installs it — in the middle of a +game if you like: the running watcher hands the game over to the new one, +which picks it up where it was without touching your commands. The icon +disappears for about a second and comes back; the menu then says what +happened, and so does the log, under `update`. + +The same from a terminal: + +```bash +gamemode-executor update --check +gamemode-executor update +``` + +Nothing is ever checked or downloaded unless you ask. If a check or an +install fails, the menu says so in one line ending in *(see log)*, and the +log has the reason — no connection, a refusal from GitHub, a file that did +not verify, or Windows Installer's own error code. + ## Checking that it is alive ```bash @@ -248,6 +271,13 @@ They calm down at your next logon. Windows does not let the stop commands run once the session is ending, so the watcher runs them the moment it starts again — see [How it works](how-it-works.md#logging-off-mid-game). +**The update failed, the menu says so.** +Read the log: the `update` lines carry the reason. A download that did not +verify is deleted, and a check that could not reach GitHub is just that — +try again later. If Windows Installer refused with a code, the log names it +and `%LOCALAPPDATA%\GameModeExecutor\updates\install.log` has its own +account. The version you had keeps running either way. + **The fans take ages to calm down after I quit.** That wait is Windows', not this program's. It releases its own "a game is running" signal when it decides to — sometimes in seconds, sometimes in minutes, diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 45c88b1..ecc102c 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -218,9 +218,33 @@ register — the ones a recipe had you create, say — stays, and so does the anything else stays too. The recipes carry their own way out for what they added. +## Updating itself + +*Check for updates* asks GitHub for the latest release — one request, no +API, no key — and compares the tag with the version running. If it is +newer, *Download and install* fetches the installer (or the zip, for a +copy unpacked by hand) by its tag, computes its SHA-256 with Windows' own +cryptography and compares it with the `SHA256SUMS.txt` the release +publishes; a file that does not match is deleted before anything can run +it. Then the installer is the updater: the package is run quietly, stops +the watcher with a handover, replaces the files and starts the new version, +which resumes the game session if there was one. An unpacked copy does the +same through a small hidden shell that waits for the watcher to exit, +expands the archive over the folder — keeping the previous executables as +`.old` until the new version has started — and runs `install-task`. + +The hash proves the file is the one the release published, not that the +release is honest; the program has no code signature, and the design record +says why. A file it downloads carries no mark of the web, so Windows' +SmartScreen never sees it: the program vouches for it, through the hash. + ## What it does not do -- **No network.** It never connects to anything, and there is no telemetry. +- **No network it did not ask you about.** It connects to exactly one + thing, GitHub, and only when you click *Check for updates* or run + `update`. There is no telemetry, nothing is polled, and the check itself + is one request: where does `releases/latest` redirect — the tag is the + answer. - **No administrator rights.** It runs as you, deliberately. That is why programs needing elevation go through a scheduled task instead. - **It does not touch your games.** It reads which processes exist and what the diff --git a/docs/reference.md b/docs/reference.md index fd566bc..5f7b5f9 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -39,6 +39,7 @@ way. | `init [--force]` | Write the starter configuration file into `%APPDATA%\GameModeExecutor`. One that is already there is kept unless `--force`. The installer runs this. What happened is logged under `setup`. | | `install-task [--delay 15s] [--force]` | Register a per-user logon task that runs `gamemode-executorw.exe` with no window, then start it now. A task already registered is kept unless `--force`. The configuration path is stored absolute. The installer runs this too. Logged under `setup`. | | `uninstall-task` | Remove that task. No task is not an error. The installer runs this on an uninstall, not on an upgrade. Logged under `setup`. | +| `update [--check]` | Look for a newer release on GitHub; with `--check`, say so and stop. Otherwise download it, verify it against the release's `SHA256SUMS.txt` and install it the way the package or the zip's shell does — a running watcher hands its game session to the new one. The one command that connects to anything. Logged under `update`. | | `stop [--handover]` | Stop the running watcher the way *Quit* in its menu does — mid-game, the stop commands run on the way out — and wait until it has gone. With `--handover` an open game session is left to the watcher that follows: the stop commands do not run, and the next start resumes the session with nothing run twice — for an update or an upgrade, where one follows within seconds. None running is not an error. The task is left alone; `install-task` starts it again. The installer runs this before removing (plain) or replacing (`--handover`) the executables. Logged under `setup`. | | `purge [--yes]` | Remove every trace of the program: the logon task, the configuration, the log, the session marker, the executables. It lists what it will remove and asks; `--yes` is for scripts. Refuses while a game is running. See [Removing it](how-it-works.md#removing-it). | @@ -168,7 +169,7 @@ what was done to this machine to set it up, which is the same story one chapter earlier. Nothing else competes with those lines. Each line is `time LEVEL category message`, with the category one of -`watcher`, `game`, `commands` or `setup`: +`watcher`, `game`, `commands`, `setup` or `update`: ``` 2026-09-18 00:51:36.740 INFO setup Starter configuration written @@ -190,6 +191,15 @@ Those commands open the log where the watcher would — the configuration's so a fresh install's first lines say what the installer did, and a machine that misbehaves can be read back to the day it was set up. +`update` is every step of looking for, fetching and installing a newer +release — the only thing in the program that touches a network, so each +request is written down: `Checking for updates`, `0.1.0 is the latest +version` or `Update available: 0.2.0`, `Downloading 0.2.0 (1.4 MB)`, +`Downloaded and verified 0.2.0`, `Installing 0.2.0; the watcher stops now +and comes back on the new version`, then from the new watcher `Updated to +0.2.0`. A failure is a `warn` with the WinHTTP or Windows Installer code as +a field; `RUST_LOG=update=debug` adds every request and its status. + `debug` does not give a different log. It gives the same one annotated — the technical detail rides along as fields rather than in lines of its own: @@ -211,6 +221,7 @@ syntax — `RUST_LOG=game=debug` for the detection lines alone. | Log | `%LOCALAPPDATA%\GameModeExecutor\logs\` | disposable | | Session marker | `%LOCALAPPDATA%\GameModeExecutor\pending-stop-actions` | present while a game session is open; left behind by a logoff, shutdown, crash or handover, and settled at the next start — the session resumed if the game is still on, closed if it is gone. `status` reports it. | | Logon task | `\GameModeExecutor\Watcher` in Task Scheduler | records the absolute path of the executable; removed with the package, kept through an upgrade | +| Updates | `%LOCALAPPDATA%\GameModeExecutor\updates\` | a downloaded release and the installer's log while an update runs; emptied when the next watcher starts, the log kept if the update failed | ## Building and releasing diff --git a/src/build_info.rs b/src/build_info.rs index 93ac0c5..a5799b1 100644 --- a/src/build_info.rs +++ b/src/build_info.rs @@ -19,6 +19,9 @@ pub const COMMIT_DISPLAY: &str = env!("GIT_COMMIT_DISPLAY"); pub const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); +/// The bare `x.y.z` from `Cargo.toml`, for comparing with a release tag. +pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); + /// One line: `0.1.0 (de538e3f-dirty)`. What `-V` prints. pub const VERSION: &str = concat!( env!("CARGO_PKG_VERSION"), diff --git a/src/cli.rs b/src/cli.rs index 28a31af..de51b2c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -109,6 +109,15 @@ pub enum Command { #[arg(long)] handover: bool, }, + /// Look for a newer release on GitHub and install it: downloaded, + /// verified against the release's checksums, then run the way the + /// installer would -- a running watcher hands its game session to the + /// new one. The one command that connects to anything. + Update { + /// Only say whether a newer release exists. + #[arg(long)] + check: bool, + }, /// Remove every trace of the program: the logon task, the configuration, /// the log, the session marker, and the executables themselves. Refuses /// while a game is running. Shows what it will remove and asks first. @@ -160,6 +169,10 @@ pub fn run(cli: Cli, console: bool) -> Result<()> { service::stop(reason)?; return Ok(()); } + Some(Command::Update { check }) => { + setup_logging(cli.config.as_deref(), cli.log_level.as_deref(), console)?; + return update_command(check); + } Some(Command::Check { path, pid }) => return check(path.as_deref(), pid), Some(Command::Purge { yes }) => return purge_command(cli.config, yes), _ => {} @@ -195,6 +208,51 @@ pub fn run(cli: Cli, console: bool) -> Result<()> { } } +/// `update`: the same object the menu drives, from a console. The log +/// lines say what happens; the printed lines say what to do next. +fn update_command(check_only: bool) -> Result<()> { + use crate::update::{Context, Launched, Verdict, Version, check_now, install_now, wait_for}; + + let context = Context::of_this_process(None)?; + let release = match check_now(&context) { + Ok(Verdict::UpToDate) => { + println!("{} is the latest version.", Version::running()); + return Ok(()); + } + Ok(Verdict::Available(release)) => release, + Err(fault) => anyhow::bail!("could not check for updates: {fault}"), + }; + println!( + "{} is available ({}); this is {}.", + release.version, + release.page, + Version::running() + ); + if check_only { + return Ok(()); + } + match install_now(&context, &release) { + Ok(Launched::Installer(child)) => match wait_for(&context, child) { + None => { + println!( + "Installed {}; a watcher that was running is back on it.", + release.version + ); + Ok(()) + } + Some(fault) => anyhow::bail!("the update to {} failed: {fault}", release.version), + }, + Ok(Launched::Shell) => { + println!( + "The update to {} continues once this command has exited; the log says how it went.", + release.version + ); + Ok(()) + } + Err(fault) => anyhow::bail!("the update to {} failed: {fault}", release.version), + } +} + /// The log for the setup commands: the configuration's level and folder /// when there is a usable configuration, the defaults otherwise -- `init` /// runs before any configuration exists, and a broken one is no reason to @@ -472,3 +530,96 @@ fn print_foreground(snapshot: &Snapshot, known: Option<&KnownGames>) { }; println!(" Windows calls it a game: {verdict}"); } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(line: &[&str]) -> Cli { + Cli::try_parse_from(std::iter::once("gamemode-executor").chain(line.iter().copied())) + .expect("parses") + } + + #[test] + fn the_default_command_is_run_and_hidden_is_still_accepted() { + assert!(parse(&[]).command.is_none()); + assert!(matches!( + parse(&["run", "--hidden"]).command, + Some(Command::Run { hidden: true }) + )); + assert!(matches!( + parse(&["--config", r"C:\x\config.toml"]).config, + Some(path) if path.ends_with("config.toml") + )); + } + + #[test] + fn the_setup_commands_take_their_flags() { + assert!(matches!( + parse(&["stop", "--handover"]).command, + Some(Command::Stop { handover: true }) + )); + assert!(matches!( + parse(&["stop"]).command, + Some(Command::Stop { handover: false }) + )); + assert!(matches!( + parse(&["init", "--force"]).command, + Some(Command::Init { force: true }) + )); + match parse(&["install-task", "--delay", "1m", "--force"]).command { + Some(Command::InstallTask { delay, force }) => { + assert_eq!(delay, "1m"); + assert!(force); + } + other => panic!("{other:?}"), + } + assert!(matches!( + parse(&["uninstall-task"]).command, + Some(Command::UninstallTask) + )); + assert!(matches!( + parse(&["update", "--check"]).command, + Some(Command::Update { check: true }) + )); + assert!(matches!( + parse(&["purge", "--yes"]).command, + Some(Command::Purge { yes: true }) + )); + } + + #[test] + fn check_takes_a_path_or_a_pid_but_not_both() { + assert!(matches!( + parse(&["check", "--pid", "42"]).command, + Some(Command::Check { + path: None, + pid: Some(42) + }) + )); + assert!( + Cli::try_parse_from(["gamemode-executor", "check", r"C:\g.exe", "--pid", "1"]).is_err() + ); + } + + /// The diagnostics against this machine: they read the Known Game List + /// and the Game Bar registration, which a GitHub-hosted runner does not + /// have, so they run where a Windows client is -- the script runs them + /// when `CI` is not set. + #[test] + #[ignore = "reads this machine's registry, which a stock runner lacks"] + fn status_and_check_answer_for_this_machine() { + status().expect("status reports what it sees"); + check(Some(r"C:\Windows\notepad.exe"), None).expect("an executable is checked"); + check(None, Some(std::process::id())).expect("this process is inspected"); + } + + #[test] + fn the_configuration_path_is_the_explicit_one_when_given() { + let explicit = PathBuf::from(r"C:\somewhere\config.toml"); + assert_eq!( + resolve_config_path(Some(explicit.clone())).unwrap(), + explicit + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 89daad1..eb2015b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,10 +16,13 @@ pub mod engine; pub mod exit; pub mod logging; pub mod marker; +pub mod package; pub mod purge; pub mod registry; pub mod sensor; pub mod service; +pub mod shell; pub mod task; pub mod tray; +pub mod update; pub mod win; diff --git a/src/logging.rs b/src/logging.rs index 1b7d8d6..6b0e6b5 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -50,9 +50,13 @@ pub mod target { /// the logon task. Written by the commands and by the installer alike, /// so the log says who did what to this machine and when. pub const SETUP: &str = "setup"; + /// Looking for, fetching and installing a newer release: the one thing + /// in the program that touches a network, so every step of it is + /// written down. + pub const UPDATE: &str = "update"; /// Every category, in the order they appear in a session. - pub const ALL: &[&str] = &[WATCHER, GAME, COMMANDS, SETUP]; + pub const ALL: &[&str] = &[WATCHER, GAME, COMMANDS, SETUP, UPDATE]; /// Width of the category column, so messages line up whatever the category. pub(super) const WIDTH: usize = 8; diff --git a/src/package.rs b/src/package.rs new file mode 100644 index 0000000..e580e5e --- /dev/null +++ b/src/package.rs @@ -0,0 +1,88 @@ +//! What Windows Installer knows about this program. +//! +//! The package `scripts/msi.ps1` builds carries one upgrade code for the +//! life of the product; every version is a new product code under it. +//! Asking Windows Installer for that upgrade code says whether the program +//! was installed from the package and which product code to remove. Both +//! `purge` and `update` need the answer, and neither is the natural home +//! of a Windows Installer query, so it lives here. + +use std::path::PathBuf; + +/// The same value `scripts/msi.ps1` writes into every package. Fixed for the +/// life of the product; a test checks the two copies agree. +pub const UPGRADE_CODE: &str = "{8C4E0B2D-3F6A-4E7B-9A1C-5D2E8F7B6A30}"; + +/// Where the package installs, `%LOCALAPPDATA%\Programs\GameModeExecutor`, +/// which `scripts/msi.ps1` fixes through `ProgramFilesFolder`. +pub fn install_dir() -> Option { + std::env::var_os("LOCALAPPDATA").map(|local| { + PathBuf::from(local) + .join("Programs") + .join("GameModeExecutor") + }) +} + +/// The product code Windows Installer registered for this upgrade code, if +/// the program was installed from the package. +pub fn installed_product() -> Option { + use windows::Win32::Foundation::ERROR_SUCCESS; + use windows::Win32::System::ApplicationInstallationAndServicing::MsiEnumRelatedProductsW; + use windows::core::{HSTRING, PWSTR}; + + let upgrade = HSTRING::from(UPGRADE_CODE); + // A product code is 38 characters plus the terminator. + let mut buffer = [0u16; 39]; + // SAFETY: `upgrade` outlives the call, and `buffer` is exactly the size + // the function documents for a product code, written in place. + let result = unsafe { MsiEnumRelatedProductsW(&upgrade, None, 0, PWSTR(buffer.as_mut_ptr())) }; + if result != ERROR_SUCCESS.0 { + return None; + } + let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len()); + Some(String::from_utf16_lossy(&buffer[..len])) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + #[test] + fn the_upgrade_code_matches_the_package_builder() { + let script = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("scripts") + .join("msi.ps1"), + ) + .expect("scripts/msi.ps1 is in the repository"); + assert!( + script.contains(&format!("$UpgradeCode = '{UPGRADE_CODE}'")), + "scripts/msi.ps1 does not carry {UPGRADE_CODE}" + ); + } + + #[test] + fn the_install_folder_is_the_one_the_package_builder_names() { + let script = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("scripts") + .join("msi.ps1"), + ) + .expect("scripts/msi.ps1 is in the repository"); + // ProgramFilesFolder redirects to %LOCALAPPDATA%\Programs for a + // per-user package, and the directory row names the last segment. + assert!(script.contains("'ProgramFilesFolder', 'TARGETDIR'")); + assert!( + script.contains("'INSTALLDIR', 'ProgramFilesFolder', 'GAMEMO~1|GameModeExecutor'"), + "the folder is named after the product" + ); + let dir = install_dir().expect("LOCALAPPDATA is set on Windows"); + assert!( + dir.ends_with(r"Programs\GameModeExecutor"), + "{}", + dir.display() + ); + } +} diff --git a/src/purge.rs b/src/purge.rs index 217809b..64961b5 100644 --- a/src/purge.rs +++ b/src/purge.rs @@ -21,21 +21,18 @@ //! from discovering the machine, so the tests can hand it a scratch layout. use std::path::{Path, PathBuf}; -use std::process::Command; use anyhow::{Context, Result}; use crate::config; use crate::logging; use crate::marker; +use crate::package; use crate::service; +use crate::shell::{after_exit, quoted}; use crate::task; use crate::win; -/// The same value `scripts/msi.ps1` writes into every package. Fixed for the -/// life of the product; a test checks the two copies agree. -pub const UPGRADE_CODE: &str = "{8C4E0B2D-3F6A-4E7B-9A1C-5D2E8F7B6A30}"; - /// What the executables' removal has to go through. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Program { @@ -223,7 +220,7 @@ pub fn discover(config: Option<&config::Config>, config_path: &Path) -> Layout { exe_dir: std::env::current_exe() .ok() .and_then(|exe| exe.parent().map(Path::to_path_buf)), - product_code: installed_product(), + product_code: package::installed_product(), } } @@ -295,75 +292,14 @@ pub fn execute(plan: &Plan) -> Result<()> { Ok(()) } -/// The product code Windows Installer registered for this upgrade code, if -/// the program was installed from the package. -pub fn installed_product() -> Option { - use windows::Win32::Foundation::ERROR_SUCCESS; - use windows::Win32::System::ApplicationInstallationAndServicing::MsiEnumRelatedProductsW; - use windows::core::{HSTRING, PWSTR}; - - let upgrade = HSTRING::from(UPGRADE_CODE); - // A product code is 38 characters plus the terminator. - let mut buffer = [0u16; 39]; - // SAFETY: `upgrade` outlives the call, and `buffer` is exactly the size - // the function documents for a product code, written in place. - let result = unsafe { MsiEnumRelatedProductsW(&upgrade, None, 0, PWSTR(buffer.as_mut_ptr())) }; - if result != ERROR_SUCCESS.0 { - return None; - } - let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len()); - Some(String::from_utf16_lossy(&buffer[..len])) -} - -/// A path as a PowerShell single-quoted literal, which only a quote can -/// end -- doubled inside, and nothing else expands. -fn quoted(path: &Path) -> String { - format!("'{}'", path.display().to_string().replace('\'', "''")) -} - -/// Run PowerShell statements once this process has exited, in a window -/// nobody sees. -/// -/// Windows PowerShell rather than `cmd.exe`, because it can wait for -/// exactly this process -- `Wait-Process` on our own id -- where a batch -/// line could only guess with a delay. Each step says for itself what it -/// does when its target is already gone. `CREATE_NO_WINDOW` gives it a hidden console of its own; -/// outliving this process needs no flag, Windows does not end children with -/// their parent. Only single quotes reach the command line, so std's -/// quoting for `CommandLineToArgvW` carries it through intact. -fn after_exit(steps: &[String]) -> Result<()> { - after_process(std::process::id(), steps) -} - -/// The same, once the process `pid` has exited -- which is how the tests -/// run the steps without exiting themselves. -fn after_process(pid: u32, steps: &[String]) -> Result<()> { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let mut script = vec![format!( - "Wait-Process -Id {pid} -ErrorAction SilentlyContinue" - )]; - script.extend(steps.iter().cloned()); - Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-WindowStyle", - "Hidden", - "-Command", - ]) - .arg(script.join("; ")) - .creation_flags(CREATE_NO_WINDOW) - .spawn() - .context("cannot start the shell that finishes the removal")?; - Ok(()) -} - #[cfg(test)] mod tests { use std::time::{Duration, Instant}; + use std::process::Command; + use super::*; + use crate::shell::after_process; fn scratch() -> PathBuf { static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); @@ -474,20 +410,6 @@ mod tests { /// The package builder and this module must agree on the upgrade code, /// or `purge` on an installed copy would fall back to deleting files /// under Windows Installer's feet. - #[test] - fn the_upgrade_code_matches_the_package_builder() { - let script = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("scripts") - .join("msi.ps1"), - ) - .expect("scripts/msi.ps1 is in the repository"); - assert!( - script.contains(&format!("$UpgradeCode = '{UPGRADE_CODE}'")), - "scripts/msi.ps1 does not carry {UPGRADE_CODE}" - ); - } - #[test] fn executing_an_unpacked_plan_removes_files_and_empty_folders() { let root = scratch(); diff --git a/src/service.rs b/src/service.rs index 918e1cb..670ead2 100644 --- a/src/service.rs +++ b/src/service.rs @@ -28,7 +28,7 @@ use anyhow::{Context, Result}; use crate::config::{self, Config}; use crate::win::{SessionWindow, SingleInstance, StopReason, StopSignal}; -use crate::{engine, logging, sensor, tray, win}; +use crate::{engine, logging, sensor, tray, update, win}; /// Ceiling on how long `WM_ENDSESSION` holds the shutdown while the stop /// actions run. `schtasks` returns in about 100 ms, so this is only here so a @@ -107,6 +107,17 @@ pub fn serve( ); } + // The updater: reads what the last update left behind and gives the + // menu its section. It never connects on its own. + match update::Context::of_this_process(Some(Arc::clone(&stop))) { + Ok(context) => update::start(context), + Err(error) => tracing::warn!( + target: logging::target::UPDATE, + error = %format!("{error:#}"), + "Updates are unavailable from the menu this session" + ), + } + // The commit rides along as a field, so it is there at debug level when // someone is working out which build wrote a log they were sent, and out of // the way otherwise. diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..390cce7 --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,59 @@ +//! Windows PowerShell in a window nobody sees, for the steps a process +//! cannot take itself: deleting its own executable, replacing it, waiting +//! for an installer that is about to stop it. +//! +//! Windows PowerShell rather than `cmd.exe`, because it can wait for exactly +//! one process -- `Wait-Process` on an id -- where a batch line could only +//! guess with a delay. `CREATE_NO_WINDOW` gives it a hidden console of its +//! own; outliving the process that started it needs no flag, Windows does +//! not end children with their parent. Only single quotes reach the command +//! line, so std's quoting for `CommandLineToArgvW` carries it through +//! intact. Seen the other way on 2026-09-17: a `cmd.exe` line with double +//! quotes, escaped as `\"` by std, deleted nothing. + +use std::path::Path; +use std::process::{Child, Command}; + +use anyhow::{Context, Result}; + +/// A path as a PowerShell single-quoted literal, which only a quote can +/// end -- doubled inside, and nothing else expands. +pub fn quoted(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "''")) +} + +/// Run `script` in a hidden Windows PowerShell and hand back the process, +/// for a caller that wants to know when it is done -- or that does not, +/// and drops it. +pub fn hidden(script: &str) -> Result { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + ]) + .arg(script) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .context("cannot start the hidden shell") +} + +/// Run `steps` once the process `pid` has exited. Each step says for itself +/// what it does when its target is already gone. +pub fn after_process(pid: u32, steps: &[String]) -> Result<()> { + let mut script = vec![format!( + "Wait-Process -Id {pid} -ErrorAction SilentlyContinue" + )]; + script.extend(steps.iter().cloned()); + hidden(&script.join("; "))?; + Ok(()) +} + +/// Run `steps` once this process has exited. +pub fn after_exit(steps: &[String]) -> Result<()> { + after_process(std::process::id(), steps) +} diff --git a/src/tray.rs b/src/tray.rs index 79f26be..a1e703a 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -298,6 +298,10 @@ const ID_CONFIG: usize = 1; const ID_LOG: usize = 2; const ID_DOCS: usize = 3; const ID_QUIT: usize = 4; +/// The update section's entries, one id per item in the order `update` +/// lists them. The section is whatever `update::view()` says: this module +/// draws it and holds no rule about it. +const ID_UPDATE_BASE: usize = 100; /// One icon per state and taskbar theme, compiled in. /// @@ -665,6 +669,12 @@ fn show_menu(window: HWND, at: POINT) { let log = wide("Open log"); let docs = wide("Documentation"); let quit = wide("Quit"); + // The update section, as the object renders it right now. Read once, + // before the menu is built, and used again after it closes to know what + // an id meant -- the phase may have moved meanwhile, and a stale click + // is one the object ignores. + let updates = crate::update::view(); + let update_labels: Vec> = updates.iter().map(|item| wide(&item.label)).collect(); // SAFETY: the strings outlive the block, `menu` was just created and is // destroyed at the end, and `window` is the tray's own window. Nothing @@ -683,6 +693,19 @@ fn show_menu(window: HWND, at: POINT) { let _ = AppendMenuW(menu, MF_STRING, ID_CONFIG, PCWSTR(config.as_ptr())); let _ = AppendMenuW(menu, MF_STRING, ID_LOG, PCWSTR(log.as_ptr())); let _ = AppendMenuW(menu, MF_STRING, ID_DOCS, PCWSTR(docs.as_ptr())); + if !updates.is_empty() { + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + for (i, (item, label)) in updates.iter().zip(&update_labels).enumerate() { + // A disabled entry carries a sentence and cannot be chosen; + // id 0 so a stray selection means nothing. + let (flags, id) = if item.enabled && item.action.is_some() { + (MF_STRING, ID_UPDATE_BASE + i) + } else { + (MF_STRING | MF_DISABLED | MF_GRAYED, 0) + }; + let _ = AppendMenuW(menu, flags, id, PCWSTR(label.as_ptr())); + } + } let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); let _ = AppendMenuW(menu, MF_STRING, ID_QUIT, PCWSTR(quit.as_ptr())); @@ -704,9 +727,33 @@ fn show_menu(window: HWND, at: POINT) { chosen.0 as usize }; + if let Some(action) = chosen + .checked_sub(ID_UPDATE_BASE) + .and_then(|i| updates.get(i)) + .and_then(|item| item.action.clone()) + { + run_update_action(action); + return; + } run_command(chosen); } +/// What an update entry asked for. The page opens here, because the shell +/// is this module's business; everything else is the object's. +fn run_update_action(action: crate::update::Action) { + match action { + crate::update::Action::OpenReleasePage(url) => { + tracing::debug!( + target: crate::logging::target::UPDATE, + url, + "Opening the release page" + ); + open(&url, None); + } + other => crate::update::perform(other), + } +} + fn run_command(id: usize) { match id { ID_CONFIG | ID_LOG => { diff --git a/src/update/feed.rs b/src/update/feed.rs new file mode 100644 index 0000000..725fa5e --- /dev/null +++ b/src/update/feed.rs @@ -0,0 +1,307 @@ +//! What the release answers, and what is read out of it. +//! +//! The only network the program ever touches, so it is behind a trait: +//! [`Feed`] answers three questions -- where does `releases/latest` redirect, +//! what does a small text file say, and put this asset in that file -- +//! and everything above it is pure. `WinHttp` is the one real feed; +//! the tests script one. Measured against `v0.1.0` on 2026-09-18: +//! `docs/design/13-updating.md` has the four answers. +//! +//! Every request after the check names the tag, never `latest`, so a +//! release published between the two cannot mix one version's checksum +//! with another's file. + +use std::fmt; +use std::path::Path; + +use super::Fault; + +/// A version as three numbers, which is all a tag may carry: `v0.2.0`. +/// Anything else is "a release this version does not understand", never +/// a guess. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version(pub u64, pub u64, pub u64); + +impl Version { + pub fn parse(text: &str) -> Option { + let mut parts = text.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some(Self(major, minor, patch)) + } + + /// The version this build carries, from `Cargo.toml` through + /// `build_info`. + pub fn running() -> Self { + Self::parse(crate::build_info::PACKAGE_VERSION) + .expect("Cargo.toml carries a three-part version") + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.0, self.1, self.2) + } +} + +/// A release found newer than the running version: enough to show, to +/// fetch and to verify. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Release { + pub version: Version, + /// `v0.2.0`, as GitHub spells it in every URL that names the release. + pub tag: String, + /// The release page, for *What changed*. + pub page: String, + /// The asset to fetch: the installer for an installed copy, the zip for + /// an unpacked one. + pub asset: String, + /// Its SHA-256, lower-case hex, from the release's `SHA256SUMS.txt`. + pub sha256: String, +} + +impl Release { + pub fn download_url(&self, repository: &str) -> String { + format!("{repository}/releases/download/{}/{}", self.tag, self.asset) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + UpToDate, + Available(Release), +} + +/// Which file a copy of the program updates itself with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Installer, + Zip, +} + +impl Kind { + pub fn asset_name(self, version: Version) -> String { + match self { + Kind::Installer => format!("GameModeExecutor-{version}.msi"), + Kind::Zip => format!("GameModeExecutor-{version}.zip"), + } + } +} + +/// The three requests, and nothing else the program ever asks a network. +pub trait Feed { + /// The `Location` a `HEAD` on `url` answers with, redirects *not* + /// followed: for `releases/latest` that is the tag, and nothing to + /// parse but a URL. + fn redirect_of(&self, url: &str) -> Result; + /// A small text file, redirects followed. + fn text(&self, url: &str) -> Result; + /// An asset written to `to`, redirects followed. `progress` is told the + /// size once the headers are in. Returns the bytes written. + fn download( + &self, + url: &str, + to: &Path, + progress: &mut dyn FnMut(Option), + ) -> Result; +} + +/// The tag at the end of `…/releases/tag/`, and the version in it. +/// A `Location` that points anywhere else -- a captive portal, a moved +/// repository -- is an unexpected answer, not a version. +pub fn parse_latest(location: &str, repository: &str) -> Result<(String, Version), Fault> { + let prefix = format!("{repository}/releases/tag/"); + let tag = location + .strip_prefix(&prefix) + .map(|rest| rest.trim_end_matches('/')) + .ok_or_else(|| Fault::Unexpected(format!("releases/latest redirected to {location}")))?; + let version = tag + .strip_prefix('v') + .and_then(Version::parse) + .ok_or_else(|| Fault::Unexpected(format!("the latest release is tagged {tag}")))?; + Ok((tag.to_owned(), version)) +} + +/// The hash for `asset` in a `SHA256SUMS.txt`: ` ` per line, as +/// `sha256sum` writes it and the release workflow does. +pub fn parse_sums(text: &str, asset: &str) -> Result { + for line in text.lines() { + let mut parts = line.split_whitespace(); + if let (Some(hash), Some(name)) = (parts.next(), parts.next()) + && name == asset + { + if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(hash.to_ascii_lowercase()); + } + return Err(Fault::Unexpected(format!( + "the checksum file carries no SHA-256 for {asset}" + ))); + } + } + Err(Fault::Unexpected(format!( + "the checksum file does not list {asset}" + ))) +} + +/// The check: one request when there is nothing newer, two when there is. +pub fn check( + feed: &dyn Feed, + repository: &str, + running: Version, + kind: Kind, +) -> Result { + let location = feed.redirect_of(&format!("{repository}/releases/latest"))?; + let (tag, version) = parse_latest(&location, repository)?; + if version <= running { + return Ok(Verdict::UpToDate); + } + let asset = kind.asset_name(version); + let sums = feed.text(&format!( + "{repository}/releases/download/{tag}/SHA256SUMS.txt" + ))?; + let sha256 = parse_sums(&sums, &asset)?; + Ok(Verdict::Available(Release { + version, + page: format!("{repository}/releases/tag/{tag}"), + tag, + asset, + sha256, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REPO: &str = "https://github.com/Geeooff/GameModeExecutor"; + + #[test] + fn versions_are_three_numbers_and_nothing_else() { + assert_eq!(Version::parse("0.1.0"), Some(Version(0, 1, 0))); + assert_eq!(Version::parse("10.2.33"), Some(Version(10, 2, 33))); + assert_eq!(Version::parse("0.2.0-rc1"), None); + assert_eq!(Version::parse("1.2"), None); + assert_eq!(Version::parse("1.2.3.4"), None); + assert!(Version(0, 2, 0) > Version(0, 1, 9)); + assert!(Version(1, 0, 0) > Version(0, 99, 99)); + assert_eq!(Version(0, 1, 0).to_string(), "0.1.0"); + } + + #[test] + fn the_tag_is_read_from_the_redirect_and_nowhere_else() { + let (tag, version) = parse_latest(&format!("{REPO}/releases/tag/v0.2.0"), REPO).unwrap(); + assert_eq!(tag, "v0.2.0"); + assert_eq!(version, Version(0, 2, 0)); + + let portal = parse_latest("https://login.example.net/?next=github", REPO); + assert!(matches!(portal, Err(Fault::Unexpected(_)))); + let odd = parse_latest(&format!("{REPO}/releases/tag/nightly"), REPO); + assert!(matches!(odd, Err(Fault::Unexpected(_)))); + } + + #[test] + fn the_checksum_file_is_read_by_asset_name() { + let sums = "85d6178b6133e056309cceef1187d403b222429c4be26158681aff5772d0d5d1 GameModeExecutor-0.1.0.msi\n\ + b5b1618bd1d1223a4c9da3ce9df3ed8f0d2a820d4f04e3544d225ea278ae316a GameModeExecutor-0.1.0.zip\n"; + assert_eq!( + parse_sums(sums, "GameModeExecutor-0.1.0.zip").unwrap(), + "b5b1618bd1d1223a4c9da3ce9df3ed8f0d2a820d4f04e3544d225ea278ae316a" + ); + assert!(matches!( + parse_sums(sums, "GameModeExecutor-0.1.0.exe"), + Err(Fault::Unexpected(_)) + )); + assert!(matches!( + parse_sums("captive portal", "GameModeExecutor-0.1.0.msi"), + Err(Fault::Unexpected(_)) + )); + assert!(matches!( + parse_sums( + "notahash GameModeExecutor-0.1.0.msi", + "GameModeExecutor-0.1.0.msi" + ), + Err(Fault::Unexpected(_)) + )); + } + + /// A feed that answers from a script. + pub(crate) struct Scripted { + pub latest: Result, + pub sums: Result, + } + + impl Feed for Scripted { + fn redirect_of(&self, _url: &str) -> Result { + self.latest.clone() + } + fn text(&self, _url: &str) -> Result { + self.sums.clone() + } + fn download( + &self, + _url: &str, + _to: &Path, + _progress: &mut dyn FnMut(Option), + ) -> Result { + unreachable!("the check never downloads") + } + } + + #[test] + fn an_older_or_equal_release_is_up_to_date_after_one_request() { + let feed = Scripted { + latest: Ok(format!("{REPO}/releases/tag/v0.1.0")), + sums: Err(Fault::Unexpected("should not be asked".into())), + }; + assert_eq!( + check(&feed, REPO, Version(0, 1, 0), Kind::Installer).unwrap(), + Verdict::UpToDate + ); + assert_eq!( + check(&feed, REPO, Version(0, 3, 0), Kind::Installer).unwrap(), + Verdict::UpToDate, + "a downgrade is never offered" + ); + } + + #[test] + fn a_newer_release_carries_its_asset_and_hash() { + let feed = Scripted { + latest: Ok(format!("{REPO}/releases/tag/v0.2.0")), + sums: Ok("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 GameModeExecutor-0.2.0.msi\n\ + 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef GameModeExecutor-0.2.0.zip\n".to_owned()), + }; + let verdict = check(&feed, REPO, Version(0, 1, 0), Kind::Zip).unwrap(); + let Verdict::Available(release) = verdict else { + panic!("expected a release"); + }; + assert_eq!(release.version, Version(0, 2, 0)); + assert_eq!(release.tag, "v0.2.0"); + assert_eq!(release.asset, "GameModeExecutor-0.2.0.zip"); + assert_eq!( + release.sha256, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + assert_eq!(release.page, format!("{REPO}/releases/tag/v0.2.0")); + assert_eq!( + release.download_url(REPO), + format!("{REPO}/releases/download/v0.2.0/GameModeExecutor-0.2.0.zip") + ); + } + + #[test] + fn a_network_fault_is_passed_through_unchanged() { + let feed = Scripted { + latest: Err(Fault::NoConnection { code: 12007 }), + sums: Ok(String::new()), + }; + assert_eq!( + check(&feed, REPO, Version(0, 1, 0), Kind::Installer), + Err(Fault::NoConnection { code: 12007 }) + ); + } +} diff --git a/src/update/hash.rs b/src/update/hash.rs new file mode 100644 index 0000000..14657e0 --- /dev/null +++ b/src/update/hash.rs @@ -0,0 +1,145 @@ +//! SHA-256 through CNG (`bcrypt.dll`), the system's own implementation. +//! +//! A hashing crate would be smaller to call, but the rule is Microsoft +//! libraries only, and the file being hashed is the one the program is +//! about to run: the fewer hands between the download and the verdict, the +//! better. One algorithm handle per call; the program hashes one file per +//! update and nothing else. + +use std::io::Read; +use std::path::Path; + +use anyhow::{Context, Result, anyhow}; +use windows::Win32::Security::Cryptography::{ + BCRYPT_ALG_HANDLE, BCRYPT_HASH_HANDLE, BCRYPT_OPEN_ALGORITHM_PROVIDER_FLAGS, + BCRYPT_SHA256_ALGORITHM, BCryptCloseAlgorithmProvider, BCryptCreateHash, BCryptDestroyHash, + BCryptFinishHash, BCryptHashData, BCryptOpenAlgorithmProvider, +}; +use windows::core::PCWSTR; + +/// The SHA-256 of `path`, lower-case hex, read in 64 KiB pieces. +pub fn sha256_of(path: &Path) -> Result { + let mut file = std::fs::File::open(path) + .with_context(|| format!("cannot open `{}` to verify it", path.display()))?; + let mut hasher = Sha256::new()?; + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("cannot read `{}` to verify it", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read])?; + } + hasher.finish() +} + +/// The SHA-256 of a byte string, for the tests and nothing else so far. +#[cfg(test)] +fn sha256_of_bytes(bytes: &[u8]) -> Result { + let mut hasher = Sha256::new()?; + hasher.update(bytes)?; + hasher.finish() +} + +/// An open algorithm and hash object, closed in order on drop. +struct Sha256 { + algorithm: BCRYPT_ALG_HANDLE, + hash: BCRYPT_HASH_HANDLE, +} + +impl Sha256 { + fn new() -> Result { + let mut algorithm = BCRYPT_ALG_HANDLE::default(); + // SAFETY: `algorithm` is a valid out-pointer for the call; the + // algorithm name is a static NUL-terminated string; no implementation + // is named, so the system's default provider answers. The handle is + // closed in `drop`. + let status = unsafe { + BCryptOpenAlgorithmProvider( + &mut algorithm, + BCRYPT_SHA256_ALGORITHM, + PCWSTR::null(), + BCRYPT_OPEN_ALGORITHM_PROVIDER_FLAGS(0), + ) + }; + if status.is_err() { + return Err(anyhow!("BCryptOpenAlgorithmProvider failed: {status:?}")); + } + let mut hash = BCRYPT_HASH_HANDLE::default(); + // SAFETY: `algorithm` was just opened; `hash` is a valid out-pointer; + // no caller-supplied hash object, so CNG allocates its own, freed by + // BCryptDestroyHash in `drop`; no secret, this is a plain hash. + let status = unsafe { BCryptCreateHash(algorithm, &mut hash, None, None, 0) }; + if status.is_err() { + // SAFETY: the algorithm handle is open and closed exactly once here. + unsafe { _ = BCryptCloseAlgorithmProvider(algorithm, 0) }; + return Err(anyhow!("BCryptCreateHash failed: {status:?}")); + } + Ok(Self { algorithm, hash }) + } + + fn update(&mut self, bytes: &[u8]) -> Result<()> { + // SAFETY: the hash handle is open; the slice is read for its length + // and not kept. + let status = unsafe { BCryptHashData(self.hash, bytes, 0) }; + if status.is_err() { + return Err(anyhow!("BCryptHashData failed: {status:?}")); + } + Ok(()) + } + + fn finish(self) -> Result { + let mut digest = [0u8; 32]; + // SAFETY: the hash handle is open and `digest` is exactly the SHA-256 + // output length, which is what the call writes. + let status = unsafe { BCryptFinishHash(self.hash, &mut digest, 0) }; + if status.is_err() { + return Err(anyhow!("BCryptFinishHash failed: {status:?}")); + } + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) + } +} + +impl Drop for Sha256 { + fn drop(&mut self) { + // SAFETY: both handles were opened in `new`, the hash before the + // algorithm is closed as CNG requires, each destroyed once. + unsafe { + _ = BCryptDestroyHash(self.hash); + _ = BCryptCloseAlgorithmProvider(self.algorithm, 0); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_system_hashes_the_way_the_release_workflow_does() { + // FIPS 180-4's own test vectors, which `sha256sum` and the workflow + // agree with. + assert_eq!( + sha256_of_bytes(b"abc").unwrap(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_of_bytes(b"").unwrap(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn a_file_is_hashed_in_pieces_to_the_same_result() { + let dir = std::env::temp_dir().join(format!("gme-hash-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.bin"); + // Larger than one read, so the loop runs more than once. + let bytes: Vec = (0..200_000u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(&path, &bytes).unwrap(); + assert_eq!(sha256_of(&path).unwrap(), sha256_of_bytes(&bytes).unwrap()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/update/install.rs b/src/update/install.rs new file mode 100644 index 0000000..211cb64 --- /dev/null +++ b/src/update/install.rs @@ -0,0 +1,400 @@ +//! Running the update once the file is verified, and reading afterwards +//! whether it took. +//! +//! An installed copy hands the package to `msiexec /qn` through a hidden +//! shell that waits for it: the package itself stops this watcher with a +//! handover and starts the new one, so on success nothing here is left to +//! do; on failure the shell writes why and runs the logon task, so the +//! previous watcher comes back and reports it. An unpacked copy's shell +//! waits for this process to exit, expands the archive over the folder +//! with the old executables kept as `.old`, and runs `install-task`. +//! +//! `pending.txt` is written before anything is launched and read by +//! whichever watcher starts next: the same version, the update took; +//! another, it did not, and `result.txt` says why when the shell got as +//! far as writing it. + +use std::path::Path; +use std::process::Child; + +use super::{Context, Fault, Kind, Release, Version}; +use crate::logging::target; +use crate::shell; + +const PENDING: &str = "pending.txt"; +const RESULT: &str = "result.txt"; +const INSTALL_LOG: &str = "install.log"; + +pub enum Launched { + /// The shell running `msiexec`; waiting on it says whether the + /// installer refused before stopping this process. + Installer(Child), + /// The shell waiting for this process to exit; nothing to wait on. + Shell, +} + +/// Read what the last update left behind, tidy the folder, and say what +/// the menu should carry. +pub fn settle(context: &Context) -> Option { + let dir = &context.updates_dir; + let pending = std::fs::read_to_string(dir.join(PENDING)).ok(); + let result = std::fs::read_to_string(dir.join(RESULT)).ok(); + let running = Version::running(); + let verdict = match pending.as_deref().map(str::trim).and_then(Version::parse) { + None => None, + Some(version) if version == running => { + tracing::info!(target: target::UPDATE, "Updated to {running}"); + None + } + Some(version) => { + let why = result + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| "the update did not take".to_owned()); + tracing::warn!( + target: target::UPDATE, + log = %dir.join(INSTALL_LOG).display(), + "The update to {version} failed and this is still {running}: {why}" + ); + Some(Fault::Setup(format!("Update to {version} failed: {why}"))) + } + }; + tidy(context, verdict.is_none()); + verdict +} + +/// Empty the updates folder of everything but the installer's log, and +/// drop the `.old` executables an unpacked copy keeps until its new +/// version has started -- which it has, if this runs. +fn tidy(context: &Context, all: bool) { + if let Ok(entries) = std::fs::read_dir(&context.updates_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let is_log = path.extension().is_some_and(|ext| ext == "log"); + if is_log && !all { + continue; + } + if path.is_dir() { + let _ = std::fs::remove_dir_all(&path); + } else { + let _ = std::fs::remove_file(&path); + } + } + } + if context.kind == Kind::Zip { + for name in ["gamemode-executor.exe.old", "gamemode-executorw.exe.old"] { + let _ = std::fs::remove_file(context.install_dir.join(name)); + } + } +} + +/// The shell line for an installed copy: `msiexec /qn` waited for, and on +/// failure a note and the logon task, so the previous watcher comes back. +fn installer_script(file: &Path, log: &Path, result: &Path) -> String { + [ + format!( + "$p = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', {}, '/qn', '/l*v', {}) -Wait -PassThru", + shell::quoted(file), + shell::quoted(log) + ), + format!( + "if ($p.ExitCode -ne 0) {{ Set-Content -LiteralPath {} -Value ('Windows Installer ' + $p.ExitCode); schtasks /Run /TN '{}' | Out-Null }}", + shell::quoted(result), + crate::task::TASK_NAME + ), + ] + .join("; ") +} + +/// The shell line for an unpacked copy: wait for the process `pid`, expand +/// the archive, keep the old executables as `.old`, copy the new files +/// over, `install-task`. On any failure: a note, the old executables back, +/// `install-task` all the same, so a watcher runs either way. +fn zip_script( + pid: u32, + file: &Path, + unpacked: &Path, + source: &Path, + install_dir: &Path, + result: &Path, +) -> String { + let twin = shell::quoted(&install_dir.join("gamemode-executorw.exe")); + let dir = shell::quoted(install_dir); + let names = "'gamemode-executor.exe', 'gamemode-executorw.exe'"; + [ + format!("Wait-Process -Id {pid} -ErrorAction SilentlyContinue"), + "try {".to_owned(), + format!( + "Expand-Archive -LiteralPath {} -DestinationPath {} -Force -ErrorAction Stop", + shell::quoted(file), + shell::quoted(unpacked) + ), + format!( + "foreach ($n in {names}) {{ Move-Item -LiteralPath (Join-Path {dir} $n) -Destination (Join-Path {dir} ($n + '.old')) -Force -ErrorAction Stop }}" + ), + format!( + "Copy-Item -Path (Join-Path {} '*') -Destination {dir} -Recurse -Force -ErrorAction Stop", + shell::quoted(source) + ), + format!("& {twin} install-task"), + "} catch {".to_owned(), + format!( + "Set-Content -LiteralPath {} -Value ('zip: ' + $_.Exception.Message)", + shell::quoted(result) + ), + format!( + "foreach ($n in {names}) {{ $old = Join-Path {dir} ($n + '.old'); if (Test-Path -LiteralPath $old) {{ Move-Item -LiteralPath $old -Destination (Join-Path {dir} $n) -Force }} }}" + ), + format!("& {twin} install-task"), + "}".to_owned(), + ] + .join("; ") +} + +/// Launch the update. The file has been verified by the caller. +pub fn launch(context: &Context, release: &Release, file: &Path) -> Result { + let dir = &context.updates_dir; + std::fs::write(dir.join(PENDING), release.version.to_string()) + .map_err(|error| Fault::write(&dir.join(PENDING), &error))?; + let _ = std::fs::remove_file(dir.join(RESULT)); + let result = dir.join(RESULT); + match context.kind { + Kind::Installer => { + let script = installer_script(file, &dir.join(INSTALL_LOG), &result); + tracing::info!( + target: target::UPDATE, + package = %file.display(), + "Installing {}; the watcher stops now and comes back on the new version", + release.version + ); + let child = shell::hidden(&script).map_err(|error| { + Fault::Unexpected(format!("cannot start the installer: {error:#}")) + })?; + Ok(Launched::Installer(child)) + } + Kind::Zip => { + let unpacked = dir.join("unpacked"); + let source = unpacked.join(format!("GameModeExecutor-{}", release.version)); + let script = zip_script( + std::process::id(), + file, + &unpacked, + &source, + &context.install_dir, + &result, + ); + tracing::info!( + target: target::UPDATE, + archive = %file.display(), + folder = %context.install_dir.display(), + "Installing {}; the watcher stops now and comes back on the new version", + release.version + ); + shell::hidden(&script) + .map_err(|error| Fault::Unexpected(format!("cannot start the shell: {error:#}")))?; + Ok(Launched::Shell) + } + } +} + +/// Wait for the installer's shell and read the note it leaves on failure. +/// `None` is no note: the installer returned success. From a watcher that +/// is still running afterwards, that is its own kind of failure -- the +/// package should have stopped it -- and the caller says so. +pub fn wait(context: &Context, mut child: Child) -> Option { + let _ = child.wait(); + let note = std::fs::read_to_string(context.updates_dir.join(RESULT)) + .ok() + .map(|text| text.trim().to_owned()) + .filter(|text| !text.is_empty())?; + let _ = std::fs::remove_file(context.updates_dir.join(PENDING)); + let _ = std::fs::remove_file(context.updates_dir.join(RESULT)); + Some( + match note + .strip_prefix("Windows Installer ") + .and_then(|code| code.trim().parse::().ok()) + { + Some(code) => Fault::Installer { code }, + None => Fault::Setup(note), + }, + ) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn scratch() -> PathBuf { + static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("gme-install-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn context(kind: Kind, dir: &Path) -> Context { + Context { + repository: "https://example.invalid".to_owned(), + kind, + updates_dir: dir.join("updates"), + install_dir: dir.join("program"), + stop: None, + } + } + + #[test] + fn nothing_pending_means_nothing_to_say_and_a_tidy_folder() { + let dir = scratch(); + let context = context(Kind::Installer, &dir); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::write(context.updates_dir.join("GameModeExecutor-0.2.0.msi"), b"x").unwrap(); + std::fs::create_dir_all(context.updates_dir.join("unpacked")).unwrap(); + assert_eq!(settle(&context), None); + assert!( + std::fs::read_dir(&context.updates_dir) + .unwrap() + .next() + .is_none() + ); + } + + #[test] + fn the_running_version_pending_means_the_update_took() { + let dir = scratch(); + let context = context(Kind::Zip, &dir); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::create_dir_all(&context.install_dir).unwrap(); + std::fs::write( + context.updates_dir.join(PENDING), + Version::running().to_string(), + ) + .unwrap(); + std::fs::write(context.updates_dir.join(INSTALL_LOG), b"log").unwrap(); + let old = context.install_dir.join("gamemode-executorw.exe.old"); + std::fs::write(&old, b"old").unwrap(); + assert_eq!(settle(&context), None); + assert!(!old.exists(), "the previous executable is dropped"); + assert!( + !context.updates_dir.join(INSTALL_LOG).exists(), + "everything goes on success" + ); + } + + #[test] + fn another_version_pending_means_it_did_not_take_and_the_note_says_why() { + let dir = scratch(); + let context = context(Kind::Installer, &dir); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::write(context.updates_dir.join(PENDING), "99.0.0").unwrap(); + std::fs::write(context.updates_dir.join(RESULT), "Windows Installer 1603\n").unwrap(); + std::fs::write(context.updates_dir.join(INSTALL_LOG), b"log").unwrap(); + assert_eq!( + settle(&context), + Some(Fault::Setup( + "Update to 99.0.0 failed: Windows Installer 1603".to_owned() + )) + ); + assert!( + context.updates_dir.join(INSTALL_LOG).exists(), + "the log stays for reading" + ); + assert!(!context.updates_dir.join(PENDING).exists(), "said once"); + assert_eq!(settle(&context), None, "and not again"); + } + + #[test] + fn a_pending_version_with_no_note_is_still_a_failure() { + let dir = scratch(); + let context = context(Kind::Installer, &dir); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::write(context.updates_dir.join(PENDING), "99.0.0").unwrap(); + assert_eq!( + settle(&context), + Some(Fault::Setup( + "Update to 99.0.0 failed: the update did not take".to_owned() + )) + ); + } + + #[test] + fn the_installer_script_runs_msiexec_quietly_and_notes_a_failure() { + let script = installer_script( + Path::new(r"C:\u\it's\GameModeExecutor-0.2.0.msi"), + Path::new(r"C:\u\install.log"), + Path::new(r"C:\u\result.txt"), + ); + assert!(script.contains("'msiexec.exe'")); + assert!(script.contains("'/qn'"), "no window"); + assert!( + script.contains(r"'C:\u\it''s\GameModeExecutor-0.2.0.msi'"), + "quotes doubled: {script}" + ); + assert!(script.contains("-Wait -PassThru")); + assert!(script.contains("'Windows Installer ' + $p.ExitCode")); + assert!(script.contains(r"schtasks /Run /TN 'GameModeExecutor\Watcher'")); + } + + #[test] + fn the_zip_script_waits_keeps_the_old_files_and_restarts_either_way() { + let script = zip_script( + 4242, + Path::new(r"C:\u\GameModeExecutor-0.2.0.zip"), + Path::new(r"C:\u\unpacked"), + Path::new(r"C:\u\unpacked\GameModeExecutor-0.2.0"), + Path::new(r"C:\Tools\GME"), + Path::new(r"C:\u\result.txt"), + ); + assert!(script.starts_with("Wait-Process -Id 4242")); + assert!(script.contains(r"Expand-Archive -LiteralPath 'C:\u\GameModeExecutor-0.2.0.zip'")); + assert!( + script.contains("($n + '.old')"), + "the old executables are kept" + ); + assert!( + script.contains( + r"Copy-Item -Path (Join-Path 'C:\u\unpacked\GameModeExecutor-0.2.0' '*')" + ) + ); + assert_eq!( + script.matches("install-task").count(), + 2, + "restarted on success and on failure" + ); + assert!(script.contains("} catch {")); + assert!(script.contains("'zip: ' + $_.Exception.Message")); + } + + #[test] + fn waiting_reads_the_note_and_forgets_it() { + let dir = scratch(); + let context = context(Kind::Installer, &dir); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + let done = || { + std::process::Command::new("cmd.exe") + .args(["/c", "exit 0"]) + .spawn() + .unwrap() + }; + + std::fs::write(context.updates_dir.join(PENDING), "0.2.0").unwrap(); + std::fs::write(context.updates_dir.join(RESULT), "Windows Installer 1618").unwrap(); + assert_eq!( + wait(&context, done()), + Some(Fault::Installer { code: 1618 }) + ); + assert!(!context.updates_dir.join(RESULT).exists()); + assert!(!context.updates_dir.join(PENDING).exists()); + + std::fs::write(context.updates_dir.join(RESULT), "zip: no such folder").unwrap(); + assert_eq!( + wait(&context, done()), + Some(Fault::Setup("zip: no such folder".to_owned())) + ); + + assert_eq!(wait(&context, done()), None, "no note, no fault"); + } +} diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 0000000..5429154 --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,668 @@ +//! Updating from the latest GitHub release, on the user's click and at no +//! other time. +//! +//! **The UI reflects an object.** Every rule lives here: which menu entries +//! exist in which phase, which actions are legal, when a verdict expires. +//! The tray asks [`view`] for a list of items and calls [`perform`] for the +//! one chosen; it holds no rule of its own. The object is driven by +//! events, so it is tested whole without a network -- the network is +//! behind [`feed::Feed`], the same seam the engine has for the OS. +//! +//! **The installer is the updater.** An installed copy downloads the next +//! package, verifies it against the release's checksum file and runs +//! `msiexec /qn` detached; the package stops this watcher with a handover +//! and starts the new one, which resumes the game session if there is +//! one. An unpacked copy does the same through a hidden shell that waits +//! for this process to exit, expands the archive over the folder and runs +//! `install-task`. `docs/design/13-updating.md` has the whole shape and +//! the measurements behind it. +//! +//! **Never a silent poll.** `perform(Action::Check)` is the only thing that +//! opens a connection, and only the menu and the `update` command call it. + +pub mod feed; +pub mod hash; +mod install; +pub mod winhttp; + +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Result; + +pub use feed::{Kind, Release, Verdict, Version}; + +use crate::logging::target; +use crate::win::StopSignal; + +/// How long a verdict about *now* -- up to date, or a failure -- stays in +/// the menu. A release found does not expire: it does not un-release. +pub const VERDICT_TTL: Duration = Duration::from_secs(60 * 60); + +/// What went wrong, in one line for the menu and a code for the log. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Fault { + /// WinHTTP could not reach the server: DNS, connection, timeout, TLS. + NoConnection { code: u32 }, + /// The server answered, with a status that is not the one expected. + Http { status: u32 }, + /// An answer that is not what a release looks like: a captive portal, + /// a moved repository, a tag that is not a version. + Unexpected(String), + /// The downloaded file does not hash to what the release says. + Verification, + /// The download could not be written where it goes. + Write { path: PathBuf, detail: String }, + /// Windows Installer refused before it stopped the watcher. + Installer { code: i32 }, + /// The previous update did not take; the text is what the shell wrote. + Setup(String), +} + +impl Fault { + /// A file that could not be written, said by its folder: the folder is + /// what the user can do something about. + fn write(path: &Path, error: &std::io::Error) -> Self { + Fault::Write { + path: path.parent().unwrap_or(path).to_path_buf(), + detail: error.to_string(), + } + } + + fn write_dir(dir: &Path, error: &std::io::Error) -> Self { + Fault::Write { + path: dir.to_path_buf(), + detail: error.to_string(), + } + } + + /// The disabled menu line, ending in a pointer to the log because the + /// line is all the menu can carry. + pub fn menu_line(&self, during: &str) -> String { + match self { + Fault::NoConnection { .. } => format!("Could not {during}: no connection (see log)"), + Fault::Http { status } => { + format!("Could not {during}: GitHub answered {status} (see log)") + } + Fault::Unexpected(_) => format!("Could not {during}: unexpected answer (see log)"), + Fault::Verification => "Download failed: the file did not verify (see log)".to_owned(), + Fault::Write { path, .. } => { + format!( + "Download failed: cannot write to {} (see log)", + path.display() + ) + } + Fault::Installer { code } => { + format!("Update failed: Windows Installer {code} (see log)") + } + Fault::Setup(text) => format!("{text} (see log)"), + } + } +} + +impl fmt::Display for Fault { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Fault::NoConnection { code } => write!(f, "no connection (WinHTTP error {code})"), + Fault::Http { status } => write!(f, "GitHub answered {status}"), + Fault::Unexpected(text) => write!(f, "unexpected answer: {text}"), + Fault::Verification => write!(f, "the file did not verify"), + Fault::Write { path, detail } => { + write!(f, "cannot write to {}: {detail}", path.display()) + } + Fault::Installer { code } => write!(f, "Windows Installer exited with {code}"), + Fault::Setup(text) => write!(f, "{text}"), + } + } +} + +/// What the updater is doing, and what it has to say. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Phase { + Idle, + Checking, + UpToDate { + version: Version, + at: Instant, + }, + Available { + release: Release, + }, + Downloading { + release: Release, + size: Option, + }, + Installing { + release: Release, + }, + /// `at` is `None` for a failure found at start, which stays until the + /// next check rather than expiring. + Failed { + fault: Fault, + during: &'static str, + at: Option, + }, +} + +/// What happened, from the menu or from the worker. +#[derive(Clone, Debug)] +pub enum Event { + CheckAsked, + CheckDone(Result), + InstallAsked, + DownloadStarted { + size: Option, + }, + DownloadDone(Result<(), Fault>), + InstallFailed(Fault), + /// The previous update did not take; read at start. + FoundAtStart(Fault), +} + +/// What the worker has to go and do once an event was applied. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Effect { + Check, + Download(Release), + Install(Release), +} + +/// What a menu entry does when chosen. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + Check, + Install, + OpenReleasePage(String), +} + +/// One menu entry, as the tray draws it: a label, what choosing it means, +/// and whether it can be chosen at all -- a disabled entry is how the menu +/// carries a sentence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub label: String, + pub action: Option, + pub enabled: bool, +} + +impl Item { + fn says(label: impl Into) -> Self { + Self { + label: label.into(), + action: None, + enabled: false, + } + } + + fn offers(label: impl Into, action: Action) -> Self { + Self { + label: label.into(), + action: Some(action), + enabled: true, + } + } + + fn withheld(label: impl Into, action: Action) -> Self { + Self { + label: label.into(), + action: Some(action), + enabled: false, + } + } +} + +const CHECK: &str = "Check for updates"; + +/// The state machine. Pure: it applies events and renders items, and +/// tells the caller what to go and do. +#[derive(Debug)] +pub struct Machine { + phase: Phase, + running: Version, +} + +impl Machine { + pub fn new(running: Version) -> Self { + Self { + phase: Phase::Idle, + running, + } + } + + pub fn phase(&self) -> &Phase { + &self.phase + } + + /// Whether the phase is busy: a check, a download or an install in + /// flight, during which no new check makes sense. + fn busy(&self) -> bool { + matches!( + self.phase, + Phase::Checking | Phase::Downloading { .. } | Phase::Installing { .. } + ) + } + + pub fn apply(&mut self, event: Event, now: Instant) -> Option { + match (event, &self.phase) { + (Event::CheckAsked, _) if !self.busy() => { + self.phase = Phase::Checking; + Some(Effect::Check) + } + (Event::CheckDone(result), Phase::Checking) => { + self.phase = match result { + Ok(Verdict::UpToDate) => Phase::UpToDate { + version: self.running, + at: now, + }, + Ok(Verdict::Available(release)) => Phase::Available { release }, + Err(fault) => Phase::Failed { + fault, + during: "check", + at: Some(now), + }, + }; + None + } + (Event::InstallAsked, Phase::Available { release }) => { + let release = release.clone(); + self.phase = Phase::Downloading { + release: release.clone(), + size: None, + }; + Some(Effect::Download(release)) + } + (Event::DownloadStarted { size }, Phase::Downloading { release, .. }) => { + self.phase = Phase::Downloading { + release: release.clone(), + size, + }; + None + } + (Event::DownloadDone(Ok(())), Phase::Downloading { release, .. }) => { + let release = release.clone(); + self.phase = Phase::Installing { + release: release.clone(), + }; + Some(Effect::Install(release)) + } + (Event::DownloadDone(Err(fault)), Phase::Downloading { .. }) => { + self.phase = Phase::Failed { + fault, + during: "download", + at: Some(now), + }; + None + } + (Event::InstallFailed(fault), Phase::Installing { .. }) => { + self.phase = Phase::Failed { + fault, + during: "install", + at: Some(now), + }; + None + } + (Event::FoundAtStart(fault), Phase::Idle) => { + self.phase = Phase::Failed { + fault, + during: "install", + at: None, + }; + None + } + // A stale answer, or a click the phase does not take: nothing. + _ => None, + } + } + + /// The menu section, top to bottom. Expiry is decided here, from `now`, + /// so there is no timer anywhere. + pub fn view(&self, now: Instant) -> Vec { + let expired = |at: Instant| now.duration_since(at) >= VERDICT_TTL; + match &self.phase { + Phase::Idle => vec![Item::offers(CHECK, Action::Check)], + Phase::Checking => vec![Item::says("Checking for updates\u{2026}")], + Phase::UpToDate { at, .. } if expired(*at) => vec![Item::offers(CHECK, Action::Check)], + Phase::UpToDate { version, .. } => vec![ + Item::offers(CHECK, Action::Check), + Item::says(format!("{version} is the latest version")), + ], + Phase::Available { release } => vec![ + Item::offers(CHECK, Action::Check), + Item::offers( + format!("Download and install {}", release.version), + Action::Install, + ), + Item::offers( + format!("What changed in {}", release.version), + Action::OpenReleasePage(release.page.clone()), + ), + ], + Phase::Downloading { release, size } => vec![ + Item::withheld(CHECK, Action::Check), + Item::says(match size { + Some(size) => format!( + "Downloading {} ({})\u{2026}", + release.version, + megabytes(*size) + ), + None => format!("Downloading {}\u{2026}", release.version), + }), + Item::offers( + format!("What changed in {}", release.version), + Action::OpenReleasePage(release.page.clone()), + ), + ], + Phase::Installing { release } => vec![ + Item::withheld(CHECK, Action::Check), + Item::says(format!("Installing {}\u{2026}", release.version)), + ], + Phase::Failed { at: Some(at), .. } if expired(*at) => { + vec![Item::offers(CHECK, Action::Check)] + } + Phase::Failed { fault, during, .. } => vec![ + Item::offers(CHECK, Action::Check), + Item::says(fault.menu_line(during)), + ], + } + } +} + +fn megabytes(bytes: u64) -> String { + format!("{:.1} MB", bytes as f64 / 1_000_000.0) +} + +// ------------------------------------------------------------ the process -- + +/// What the worker needs to know about this copy of the program. +pub struct Context { + /// `https://github.com/{owner}/{repo}`, from the build. + pub repository: String, + pub kind: Kind, + /// Where downloads go: `%LOCALAPPDATA%\GameModeExecutor\updates`. + pub updates_dir: PathBuf, + /// Where the executables live, for the zip path. + pub install_dir: PathBuf, + /// The running watcher's stop, when there is one: the zip path stops + /// this process itself, with a handover, once its shell is started. + pub stop: Option>, +} + +impl Context { + /// The context of this process: installed or unpacked. + /// + /// Installed means two things at once: Windows Installer knows the + /// upgrade code, *and* this executable runs from the folder the package + /// installs to. A copy unpacked somewhere else on a machine that also + /// has the package must update its own files, not the package's -- + /// otherwise its `msiexec` would upgrade the other copy and leave + /// itself as it was. + pub fn of_this_process(stop: Option>) -> Result { + let exe = std::env::current_exe()?; + let install_dir = exe + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let kind = kind_of( + &install_dir, + crate::package::installed_product().is_some(), + crate::package::install_dir().as_deref(), + ); + let updates_dir = crate::config::local_dir() + .ok_or_else(|| anyhow::anyhow!("no local profile folder"))? + .join("updates"); + Ok(Self { + repository: crate::build_info::REPOSITORY.to_owned(), + kind, + updates_dir, + install_dir, + stop, + }) + } +} + +/// The decision alone, so it can be tested without a package on the +/// machine. Paths are compared as spelled, case-insensitively, which is +/// what Windows does with them. +fn kind_of(install_dir: &Path, product_installed: bool, package_dir: Option<&Path>) -> Kind { + let same = |a: &Path, b: &Path| { + a.to_string_lossy() + .trim_end_matches(['\\', '/']) + .eq_ignore_ascii_case(b.to_string_lossy().trim_end_matches(['\\', '/'])) + }; + match package_dir { + Some(package_dir) if product_installed && same(install_dir, package_dir) => Kind::Installer, + _ => Kind::Zip, + } +} + +struct State { + machine: Machine, + context: Arc, +} + +static STATE: Mutex> = Mutex::new(None); + +fn with_state(f: impl FnOnce(&mut State) -> T) -> Option { + let mut held = STATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + held.as_mut().map(f) +} + +/// Start the updater for this process: read what the previous update left +/// behind -- a version that took, or one that did not -- say so, and give +/// the menu its section. Nothing connects until someone clicks. +pub fn start(context: Context) { + let mut machine = Machine::new(Version::running()); + if let Some(fault) = install::settle(&context) { + machine.apply(Event::FoundAtStart(fault), Instant::now()); + } + let mut held = STATE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *held = Some(State { + machine, + context: Arc::new(context), + }); +} + +/// The menu section as it should read right now. Empty when the updater +/// was never set up, which is what the tests of everything else see. +pub fn view() -> Vec { + with_state(|state| state.machine.view(Instant::now())).unwrap_or_default() +} + +/// Apply an event and run what it asks for, on a worker thread. +fn apply(event: Event) { + let effect = with_state(|state| { + let effect = state.machine.apply(event, Instant::now()); + effect.map(|effect| (effect, Arc::clone(&state.context))) + }) + .flatten(); + if let Some((effect, context)) = effect { + std::thread::spawn(move || run(effect, &context)); + } +} + +/// Act on a menu entry. Anything the phase does not take is ignored, which +/// is what a click on a stale menu deserves. `OpenReleasePage` carries its +/// URL for the caller to open -- the shell is the tray's business -- and +/// changes nothing here. +pub fn perform(action: Action) { + match action { + Action::Check => apply(Event::CheckAsked), + Action::Install => apply(Event::InstallAsked), + Action::OpenReleasePage(_) => {} + } +} + +/// The check, over the real network, said in the log: what the menu's +/// *Check for updates* and the console's `update --check` both run. +pub fn check_now(context: &Context) -> Result { + tracing::info!(target: target::UPDATE, "Checking for updates"); + let feed = winhttp::WinHttp::new(); + let verdict = feed::check(&feed, &context.repository, Version::running(), context.kind); + match &verdict { + Ok(Verdict::UpToDate) => tracing::info!( + target: target::UPDATE, + "{} is the latest version", + Version::running() + ), + Ok(Verdict::Available(release)) => tracing::info!( + target: target::UPDATE, + tag = release.tag, + asset = release.asset, + sha256 = release.sha256, + "Update available: {}", + release.version + ), + Err(fault) => tracing::warn!( + target: target::UPDATE, + error = %fault, + "Could not check for updates" + ), + } + verdict +} + +/// Fetch, verify and launch `release`, said in the log. What comes back +/// is what the caller has to wait for, if anything. +pub fn install_now(context: &Context, release: &Release) -> Result { + let feed = winhttp::WinHttp::new(); + download(&feed, context, release).map_err(|fault| { + tracing::warn!( + target: target::UPDATE, + error = %fault, + "Could not download {}", + release.version + ); + fault + })?; + let file = context.updates_dir.join(&release.asset); + install::launch(context, release, &file).map_err(|fault| { + tracing::warn!( + target: target::UPDATE, + error = %fault, + "The update to {} could not be started", + release.version + ); + fault + }) +} + +/// What was launched, and so what is left to wait for. +pub use install::Launched; + +/// Wait for a launched installer and read its note; `None` is success. +pub fn wait_for(context: &Context, child: std::process::Child) -> Option { + install::wait(context, child) +} + +/// The worker: the network, the disk and the launch, never on the window's +/// thread. +fn run(effect: Effect, context: &Context) { + let feed = winhttp::WinHttp::new(); + match effect { + Effect::Check => apply(Event::CheckDone(check_now(context))), + Effect::Download(release) => { + let outcome = download(&feed, context, &release); + if let Err(fault) = &outcome { + tracing::warn!( + target: target::UPDATE, + error = %fault, + "Could not download {}", + release.version + ); + } + apply(Event::DownloadDone(outcome)); + } + Effect::Install(release) => { + let file = context.updates_dir.join(&release.asset); + match install::launch(context, &release, &file) { + Ok(install::Launched::Installer(child)) => { + // The package stops this process on its way. Still + // being here when the shell returns means the install + // failed -- before the stop, with the shell's note + // saying how, or in a way that left this watcher + // running, which is a failure of its own. + let fault = install::wait(context, child).unwrap_or_else(|| { + Fault::Setup("the installer ended without replacing the program".to_owned()) + }); + tracing::warn!( + target: target::UPDATE, + error = %fault, + "The update to {} failed", + release.version + ); + apply(Event::InstallFailed(fault)); + } + Ok(install::Launched::Shell) => { + // The shell waits for this process; leave, handing the + // session over. + if let Some(stop) = &context.stop { + stop.signal_handover(); + } + } + Err(fault) => { + tracing::warn!( + target: target::UPDATE, + error = %fault, + "The update to {} could not be started", + release.version + ); + apply(Event::InstallFailed(fault)); + } + } + } + } +} + +/// Fetch the asset by tag and verify it against the release's hash. A file +/// that does not verify is deleted before anything can run it. +fn download(feed: &dyn feed::Feed, context: &Context, release: &Release) -> Result<(), Fault> { + std::fs::create_dir_all(&context.updates_dir) + .map_err(|error| Fault::write_dir(&context.updates_dir, &error))?; + let file = context.updates_dir.join(&release.asset); + let url = release.download_url(&context.repository); + let mut announced = None; + let written = feed.download(&url, &file, &mut |size| { + announced = size; + if let Some(size) = size { + tracing::info!( + target: target::UPDATE, + url, + "Downloading {} ({})", + release.version, + megabytes(size) + ); + } else { + tracing::info!(target: target::UPDATE, url, "Downloading {}", release.version); + } + apply(Event::DownloadStarted { size }); + })?; + let actual = hash::sha256_of(&file).map_err(|error| Fault::Unexpected(error.to_string()))?; + if actual != release.sha256 { + tracing::warn!( + target: target::UPDATE, + expected = release.sha256, + actual, + bytes = written, + "The downloaded file does not hash to what the release says; deleted" + ); + let _ = std::fs::remove_file(&file); + return Err(Fault::Verification); + } + tracing::info!( + target: target::UPDATE, + path = %file.display(), + bytes = written, + "Downloaded and verified {}", + release.version + ); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/update/tests.rs b/src/update/tests.rs new file mode 100644 index 0000000..73cdd76 --- /dev/null +++ b/src/update/tests.rs @@ -0,0 +1,389 @@ +//! The machine, driven by events and read through `view`: every rule the +//! menu shows, with no network and no clock but the one handed in. + +use std::time::Duration; + +use super::*; + +const REPO: &str = "https://github.com/Geeooff/GameModeExecutor"; + +fn release(version: Version) -> Release { + Release { + version, + tag: format!("v{version}"), + page: format!("{REPO}/releases/tag/v{version}"), + asset: format!("GameModeExecutor-{version}.msi"), + sha256: "0".repeat(64), + } +} + +fn labels(items: &[Item]) -> Vec<(&str, bool)> { + items + .iter() + .map(|item| (item.label.as_str(), item.enabled)) + .collect() +} + +fn machine() -> (Machine, Instant) { + (Machine::new(Version(0, 1, 0)), Instant::now()) +} + +#[test] +fn idle_offers_one_thing_and_a_check_disables_it_while_it_runs() { + let (mut m, now) = machine(); + assert_eq!(labels(&m.view(now)), vec![("Check for updates", true)]); + assert_eq!(m.view(now)[0].action, Some(Action::Check)); + + assert_eq!(m.apply(Event::CheckAsked, now), Some(Effect::Check)); + assert_eq!( + labels(&m.view(now)), + vec![("Checking for updates\u{2026}", false)] + ); + assert_eq!(m.apply(Event::CheckAsked, now), None, "one check at a time"); +} + +#[test] +fn up_to_date_is_said_for_an_hour_and_then_not() { + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + assert_eq!(m.apply(Event::CheckDone(Ok(Verdict::UpToDate)), now), None); + assert_eq!( + labels(&m.view(now)), + vec![ + ("Check for updates", true), + ("0.1.0 is the latest version", false) + ] + ); + let later = now + VERDICT_TTL - Duration::from_secs(1); + assert_eq!(m.view(later).len(), 2, "still within the hour"); + let expired = now + VERDICT_TTL; + assert_eq!(labels(&m.view(expired)), vec![("Check for updates", true)]); +} + +#[test] +fn a_release_found_is_offered_until_acted_on() { + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + let found = release(Version(0, 2, 0)); + m.apply(Event::CheckDone(Ok(Verdict::Available(found.clone()))), now); + let items = m.view(now); + assert_eq!( + labels(&items), + vec![ + ("Check for updates", true), + ("Download and install 0.2.0", true), + ("What changed in 0.2.0", true), + ] + ); + assert_eq!(items[1].action, Some(Action::Install)); + assert_eq!( + items[2].action, + Some(Action::OpenReleasePage(found.page.clone())) + ); + let days_later = now + Duration::from_secs(2 * 24 * 3600); + assert_eq!(m.view(days_later).len(), 3, "a release does not un-release"); +} + +#[test] +fn installing_downloads_then_installs_and_says_where_it_is() { + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + let found = release(Version(0, 2, 0)); + m.apply(Event::CheckDone(Ok(Verdict::Available(found.clone()))), now); + + assert_eq!( + m.apply(Event::InstallAsked, now), + Some(Effect::Download(found.clone())) + ); + assert_eq!( + labels(&m.view(now)), + vec![ + ("Check for updates", false), + ("Downloading 0.2.0\u{2026}", false), + ("What changed in 0.2.0", true), + ] + ); + m.apply( + Event::DownloadStarted { + size: Some(1_462_272), + }, + now, + ); + assert_eq!(m.view(now)[1].label, "Downloading 0.2.0 (1.5 MB)\u{2026}"); + assert_eq!( + m.apply(Event::CheckAsked, now), + None, + "no check mid-download" + ); + + assert_eq!( + m.apply(Event::DownloadDone(Ok(())), now), + Some(Effect::Install(found)) + ); + assert_eq!( + labels(&m.view(now)), + vec![ + ("Check for updates", false), + ("Installing 0.2.0\u{2026}", false) + ] + ); +} + +#[test] +fn a_failed_download_is_said_and_expires() { + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + m.apply( + Event::CheckDone(Ok(Verdict::Available(release(Version(0, 2, 0))))), + now, + ); + m.apply(Event::InstallAsked, now); + assert_eq!( + m.apply(Event::DownloadDone(Err(Fault::Verification)), now), + None + ); + assert_eq!( + labels(&m.view(now)), + vec![ + ("Check for updates", true), + ("Download failed: the file did not verify (see log)", false), + ] + ); + assert_eq!( + labels(&m.view(now + VERDICT_TTL)), + vec![("Check for updates", true)] + ); +} + +#[test] +fn a_check_failure_names_its_cause() { + for (fault, line) in [ + ( + Fault::NoConnection { code: 12007 }, + "Could not check: no connection (see log)", + ), + ( + Fault::Http { status: 503 }, + "Could not check: GitHub answered 503 (see log)", + ), + ( + Fault::Unexpected("a portal".into()), + "Could not check: unexpected answer (see log)", + ), + ] { + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + m.apply(Event::CheckDone(Err(fault)), now); + assert_eq!(m.view(now)[1].label, line); + } +} + +#[test] +fn an_installer_refusal_is_said_with_its_code() { + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + m.apply( + Event::CheckDone(Ok(Verdict::Available(release(Version(0, 2, 0))))), + now, + ); + m.apply(Event::InstallAsked, now); + m.apply(Event::DownloadDone(Ok(())), now); + m.apply(Event::InstallFailed(Fault::Installer { code: 1618 }), now); + assert_eq!( + m.view(now)[1].label, + "Update failed: Windows Installer 1618 (see log)" + ); + assert_eq!( + m.apply(Event::CheckAsked, now), + Some(Effect::Check), + "and a new check clears it" + ); +} + +#[test] +fn a_failure_found_at_start_stays_until_the_next_check() { + let (mut m, now) = machine(); + m.apply( + Event::FoundAtStart(Fault::Setup( + "Update to 0.2.0 failed: Windows Installer 1603".into(), + )), + now, + ); + let line = "Update to 0.2.0 failed: Windows Installer 1603 (see log)"; + assert_eq!(m.view(now)[1].label, line); + let days_later = now + Duration::from_secs(2 * 24 * 3600); + assert_eq!(m.view(days_later)[1].label, line, "no expiry"); + assert_eq!(m.apply(Event::CheckAsked, days_later), Some(Effect::Check)); + assert_eq!(m.view(days_later).len(), 1); +} + +#[test] +fn a_new_check_clears_an_offer_and_stale_answers_are_ignored() { + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + m.apply( + Event::CheckDone(Ok(Verdict::Available(release(Version(0, 2, 0))))), + now, + ); + assert_eq!(m.apply(Event::CheckAsked, now), Some(Effect::Check)); + assert_eq!( + labels(&m.view(now)), + vec![("Checking for updates\u{2026}", false)] + ); + + let (mut m, now) = machine(); + assert_eq!(m.apply(Event::CheckDone(Ok(Verdict::UpToDate)), now), None); + assert_eq!( + m.phase(), + &Phase::Idle, + "an answer nobody asked for changes nothing" + ); + assert_eq!(m.apply(Event::InstallAsked, now), None); + assert_eq!(m.apply(Event::DownloadDone(Ok(())), now), None); + assert_eq!(m.phase(), &Phase::Idle); +} + +#[test] +fn every_fault_has_a_menu_line_and_a_log_sentence() { + let faults = [ + Fault::NoConnection { code: 12002 }, + Fault::Http { status: 429 }, + Fault::Unexpected("html".into()), + Fault::Verification, + Fault::Write { + path: std::path::PathBuf::from(r"C:\u\updates"), + detail: "disk full".into(), + }, + Fault::Installer { code: 1603 }, + Fault::Setup("Update to 0.2.0 failed: zip: no such folder".into()), + ]; + for fault in &faults { + let line = fault.menu_line("download"); + assert!(line.ends_with("(see log)"), "{line}"); + assert!(!fault.to_string().is_empty()); + } + assert_eq!( + faults[4].menu_line("download"), + r"Download failed: cannot write to C:\u\updates (see log)" + ); + assert_eq!(faults[0].to_string(), "no connection (WinHTTP error 12002)"); + assert_eq!(megabytes(1_462_272), "1.5 MB"); +} + +#[test] +fn the_process_wide_updater_renders_its_section_once_set_up() { + // Before `start`, nothing: the tests of everything else see no + // section. After it, the idle entry, and an action the phase does not + // take is ignored without a thread being spawned. + let context = scratch_context(); + start(context); + assert_eq!(labels(&view()), vec![("Check for updates", true)]); + perform(Action::Install); + assert_eq!(labels(&view()), vec![("Check for updates", true)]); + perform(Action::OpenReleasePage("https://example.invalid".into())); + assert_eq!(labels(&view()), vec![("Check for updates", true)]); +} + +#[test] +fn a_failure_left_behind_is_shown_when_the_updater_is_set_up() { + let context = scratch_context(); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::write(context.updates_dir.join("pending.txt"), "99.0.0").unwrap(); + std::fs::write( + context.updates_dir.join("result.txt"), + "Windows Installer 1603", + ) + .unwrap(); + let mut machine = Machine::new(Version::running()); + if let Some(fault) = install::settle(&context) { + machine.apply(Event::FoundAtStart(fault), Instant::now()); + } + assert_eq!( + machine.view(Instant::now())[1].label, + "Update to 99.0.0 failed: Windows Installer 1603 (see log)" + ); +} + +#[test] +fn a_copy_is_installed_only_when_the_package_owns_its_folder() { + let package = std::path::Path::new(r"C:\Users\me\AppData\Local\Programs\GameModeExecutor"); + assert_eq!(kind_of(package, true, Some(package)), Kind::Installer); + assert_eq!( + kind_of( + std::path::Path::new(r"c:\users\me\appdata\local\programs\gamemodeexecutor\"), + true, + Some(package) + ), + Kind::Installer, + "case and a trailing separator do not matter" + ); + assert_eq!( + kind_of(std::path::Path::new(r"C:\Tools\GME"), true, Some(package)), + Kind::Zip, + "an unpacked copy beside a package updates itself, not the package" + ); + assert_eq!(kind_of(package, false, Some(package)), Kind::Zip); + assert_eq!(kind_of(package, true, None), Kind::Zip); +} + +#[test] +fn the_context_of_this_process_names_a_repository_and_a_folder() { + let context = Context::of_this_process(None).unwrap(); + assert!(context.repository.starts_with("https://github.com/")); + assert!(context.updates_dir.ends_with("updates")); + assert!(context.install_dir.is_dir()); +} + +/// A feed that serves bytes from memory, for the download path. +struct Bytes(Vec); + +impl feed::Feed for Bytes { + fn redirect_of(&self, _url: &str) -> Result { + unreachable!() + } + fn text(&self, _url: &str) -> Result { + unreachable!() + } + fn download( + &self, + _url: &str, + to: &std::path::Path, + progress: &mut dyn FnMut(Option), + ) -> Result { + progress(Some(self.0.len() as u64)); + std::fs::write(to, &self.0).unwrap(); + Ok(self.0.len() as u64) + } +} + +fn scratch_context() -> Context { + static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("gme-download-{}-{n}", std::process::id())); + Context { + repository: REPO.to_owned(), + kind: Kind::Installer, + updates_dir: dir.join("updates"), + install_dir: dir.join("program"), + stop: None, + } +} + +#[test] +fn a_download_is_kept_only_when_it_hashes_to_what_the_release_says() { + let context = scratch_context(); + let mut found = release(Version(0, 2, 0)); + // "abc", whose SHA-256 the standard publishes. + found.sha256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_owned(); + assert_eq!(download(&Bytes(b"abc".to_vec()), &context, &found), Ok(())); + assert!(context.updates_dir.join(&found.asset).exists()); + + assert_eq!( + download(&Bytes(b"abd".to_vec()), &context, &found), + Err(Fault::Verification) + ); + assert!( + !context.updates_dir.join(&found.asset).exists(), + "a file that does not verify is deleted before anything can run it" + ); +} diff --git a/src/update/winhttp.rs b/src/update/winhttp.rs new file mode 100644 index 0000000..c6362a7 --- /dev/null +++ b/src/update/winhttp.rs @@ -0,0 +1,737 @@ +//! The one real [`Feed`]: WinHTTP, the system's HTTP client for programs +//! with no user in front of them. +//! +//! Microsoft's library, the system certificate store, the system proxy +//! (`WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY`), no certificate check relaxed. +//! It follows `https` to `https` redirects on its own, which the asset chain +//! needs -- `github.com` to `release-assets.githubusercontent.com` -- and is +//! told not to for the one request whose redirect *is* the answer. +//! +//! Synchronous, on purpose: every call here runs on the updater's worker +//! thread, never on the thread that owns the window. + +use std::ffi::c_void; +use std::io::Write; +use std::path::Path; + +use windows::Win32::Networking::WinHttp::{ + INTERNET_DEFAULT_HTTP_PORT, INTERNET_DEFAULT_HTTPS_PORT, WINHTTP_ACCESS_TYPE, + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_FLAG_SECURE, WINHTTP_OPEN_REQUEST_FLAGS, + WINHTTP_OPTION_REDIRECT_POLICY, WINHTTP_OPTION_REDIRECT_POLICY_NEVER, + WINHTTP_QUERY_CONTENT_LENGTH, WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_QUERY_LOCATION, + WINHTTP_QUERY_STATUS_CODE, WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, + WinHttpQueryHeaders, WinHttpReadData, WinHttpReceiveResponse, WinHttpSendRequest, + WinHttpSetOption, WinHttpSetTimeouts, +}; +use windows::core::{HSTRING, PCWSTR}; + +use super::Fault; +use super::feed::Feed; + +/// Resolve, connect, send, receive: generous for a person waiting on a +/// click, short enough that "no connection" is said within the minute. +const TIMEOUTS_MS: (i32, i32, i32, i32) = (10_000, 10_000, 15_000, 30_000); + +pub struct WinHttp { + agent: String, + /// `https` only, which is the shipped program. The tests build one that + /// also speaks plain `http` to a listener of their own on `127.0.0.1`: + /// a TLS server without a crate would be SChannel by hand, and TLS is + /// WinHTTP's to get right -- a bad certificate was measured refused + /// with 12175 on 2026-09-18. + secure_only: bool, + proxy: WINHTTP_ACCESS_TYPE, + timeouts: (i32, i32, i32, i32), +} + +impl WinHttp { + pub fn new() -> Self { + Self { + agent: format!("GameModeExecutor/{}", crate::build_info::VERSION), + secure_only: true, + proxy: WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + timeouts: TIMEOUTS_MS, + } + } + + /// For the tests alone: plain `http`, no proxy, and short timeouts so + /// a listener that never answers is a fault in seconds. + #[cfg(test)] + fn plain() -> Self { + Self { + agent: "GameModeExecutor/test".to_owned(), + secure_only: false, + proxy: windows::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NO_PROXY, + timeouts: (2_000, 2_000, 2_000, 2_000), + } + } +} + +impl Default for WinHttp { + fn default() -> Self { + Self::new() + } +} + +/// A WinHTTP handle closed on drop. Session, connection and request are all +/// the same kind of handle to the library. +struct Handle(*mut c_void); + +impl Drop for Handle { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the handle was returned by WinHTTP and is closed once. + unsafe { _ = WinHttpCloseHandle(self.0) }; + } + } +} + +/// A URL split the way WinHTTP wants it. +#[derive(Debug, PartialEq, Eq)] +struct Target { + secure: bool, + host: String, + port: u16, + path: String, +} + +/// `https://host[:port]/path`, and `http://` only when the caller allows +/// it: a plain `http` release URL in the shipped program would be a +/// configuration mistake worth refusing. +fn split(url: &str, allow_plain: bool) -> Result { + let (secure, rest) = if let Some(rest) = url.strip_prefix("https://") { + (true, rest) + } else if let Some(rest) = url.strip_prefix("http://").filter(|_| allow_plain) { + (false, rest) + } else { + return Err(Fault::Unexpected(format!("not an https URL: {url}"))); + }; + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) => ( + host, + port.parse::() + .map_err(|_| Fault::Unexpected(format!("no port in {url}")))?, + ), + None => ( + authority, + if secure { + INTERNET_DEFAULT_HTTPS_PORT + } else { + INTERNET_DEFAULT_HTTP_PORT + }, + ), + }; + if host.is_empty() { + return Err(Fault::Unexpected(format!("no host in {url}"))); + } + Ok(Target { + secure, + host: host.to_owned(), + port, + path: format!("/{path}"), + }) +} + +/// A WinHTTP error as a fault. The library's own codes are 12000 to 12175; +/// anything else is not a network problem and is said as it is. +fn fault(error: windows::core::Error, what: &str) -> Fault { + let code = error.code().0 as u32 & 0xFFFF; + tracing::debug!( + target: crate::logging::target::UPDATE, + code, + error = %error, + "{what} failed" + ); + if (12000..=12200).contains(&code) { + Fault::NoConnection { code } + } else { + Fault::Unexpected(format!("{what}: {error}")) + } +} + +/// A received response: its status, and the request handle to read from. +/// +/// The session and the connection ride along, declared after the request +/// so they are closed after it: closing a parent handle cancels every +/// request under it, and a body read after that fails with 12017, +/// `ERROR_WINHTTP_OPERATION_CANCELLED` -- measured on the first real +/// download, 2026-09-18. +struct Response { + request: Handle, + status: u32, + _connection: Handle, + _session: Handle, +} + +impl WinHttp { + fn send(&self, verb: &str, url: &str, follow_redirects: bool) -> Result { + let target = split(url, !self.secure_only)?; + let agent = HSTRING::from(self.agent.as_str()); + // SAFETY: the agent string outlives the call; no proxy strings, the + // system settings apply; the handle is owned by `Handle`. + let session = Handle(unsafe { + WinHttpOpen( + PCWSTR(agent.as_ptr()), + self.proxy, + PCWSTR::null(), + PCWSTR::null(), + 0, + ) + }); + if session.0.is_null() { + return Err(fault(windows::core::Error::from_thread(), "WinHttpOpen")); + } + // SAFETY: the session handle is open; the four values are + // milliseconds. + unsafe { + WinHttpSetTimeouts( + session.0, + self.timeouts.0, + self.timeouts.1, + self.timeouts.2, + self.timeouts.3, + ) + } + .map_err(|error| fault(error, "WinHttpSetTimeouts"))?; + + let host = HSTRING::from(target.host.as_str()); + // SAFETY: the session is open and the host string outlives the call. + let connection = + Handle(unsafe { WinHttpConnect(session.0, PCWSTR(host.as_ptr()), target.port, 0) }); + if connection.0.is_null() { + return Err(fault(windows::core::Error::from_thread(), "WinHttpConnect")); + } + + let verb_w = HSTRING::from(verb); + let path = HSTRING::from(target.path.as_str()); + let flags = if target.secure { + WINHTTP_FLAG_SECURE + } else { + WINHTTP_OPEN_REQUEST_FLAGS(0) + }; + // SAFETY: the connection is open; the strings outlive the call; no + // accept types (null-terminated list absent) and HTTP/1.1 by + // default. + let request = Handle(unsafe { + WinHttpOpenRequest( + connection.0, + PCWSTR(verb_w.as_ptr()), + PCWSTR(path.as_ptr()), + PCWSTR::null(), + PCWSTR::null(), + std::ptr::null(), + flags, + ) + }); + if request.0.is_null() { + return Err(fault( + windows::core::Error::from_thread(), + "WinHttpOpenRequest", + )); + } + if !follow_redirects { + let policy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER.to_ne_bytes(); + // SAFETY: the request is open and the option value is a DWORD + // passed as its bytes, as the option requires. + unsafe { + WinHttpSetOption( + Some(request.0), + WINHTTP_OPTION_REDIRECT_POLICY, + Some(&policy), + ) + } + .map_err(|error| fault(error, "WinHttpSetOption"))?; + } + // SAFETY: the request is open; no extra headers, no body. + unsafe { WinHttpSendRequest(request.0, None, None, 0, 0, 0) } + .map_err(|error| fault(error, "WinHttpSendRequest"))?; + // SAFETY: the request was sent; the reserved argument is null. + unsafe { WinHttpReceiveResponse(request.0, std::ptr::null_mut()) } + .map_err(|error| fault(error, "WinHttpReceiveResponse"))?; + + let status = query_number(&request, WINHTTP_QUERY_STATUS_CODE) + .ok_or_else(|| Fault::Unexpected("no status code in the answer".to_owned()))?; + tracing::debug!( + target: crate::logging::target::UPDATE, + verb, + url, + status, + "Request answered" + ); + Ok(Response { + request, + status, + _connection: connection, + _session: session, + }) + } +} + +fn query_number(request: &Handle, header: u32) -> Option { + let mut value = 0u32; + let mut length = std::mem::size_of::() as u32; + let mut index = 0u32; + // SAFETY: the request has received its response; `value` is the DWORD + // the flag asks for, `length` its size, `index` a valid slot. + unsafe { + WinHttpQueryHeaders( + request.0, + header | WINHTTP_QUERY_FLAG_NUMBER, + PCWSTR::null(), + Some(&mut value as *mut u32 as *mut c_void), + &mut length, + &mut index, + ) + } + .ok() + .map(|()| value) +} + +/// A text header of any length: asked for its size first, as the API +/// does it -- a signed asset URL runs to a kilobyte, and a fixed buffer +/// would have truncated a longer one in silence. +fn query_text(request: &Handle, header: u32) -> Option { + let mut length = 0u32; + let mut index = 0u32; + // SAFETY: no buffer, so the call only writes the byte length needed + // into `length` and fails with ERROR_INSUFFICIENT_BUFFER -- or with + // HEADER_NOT_FOUND, leaving `length` at zero. + let _ = unsafe { + WinHttpQueryHeaders( + request.0, + header, + PCWSTR::null(), + None, + &mut length, + &mut index, + ) + }; + if length == 0 { + return None; + } + let mut buffer = vec![0u16; length as usize / 2 + 1]; + let mut index = 0u32; + // SAFETY: the request has received its response; the buffer and its + // byte length are what the call is given, and it writes at most that + // much, NUL-terminated. + unsafe { + WinHttpQueryHeaders( + request.0, + header, + PCWSTR::null(), + Some(buffer.as_mut_ptr() as *mut c_void), + &mut length, + &mut index, + ) + } + .ok()?; + let chars = (length as usize / 2).min(buffer.len()); + Some(String::from_utf16_lossy(&buffer[..chars])) +} + +/// Read the whole body through `sink`, in 64 KiB pieces. +fn read_body( + request: &Handle, + mut sink: impl FnMut(&[u8]) -> Result<(), Fault>, +) -> Result { + let mut buffer = vec![0u8; 64 * 1024]; + let mut total = 0u64; + loop { + let mut read = 0u32; + // SAFETY: the request has received its response; the buffer and its + // length are what the call may write, and `read` says how much it did. + unsafe { + WinHttpReadData( + request.0, + buffer.as_mut_ptr() as *mut c_void, + buffer.len() as u32, + &mut read, + ) + } + .map_err(|error| fault(error, "WinHttpReadData"))?; + if read == 0 { + return Ok(total); + } + sink(&buffer[..read as usize])?; + total += u64::from(read); + } +} + +impl Feed for WinHttp { + fn redirect_of(&self, url: &str) -> Result { + let response = self.send("HEAD", url, false)?; + match response.status { + 301..=308 => query_text(&response.request, WINHTTP_QUERY_LOCATION) + .ok_or_else(|| Fault::Unexpected(format!("{url} redirected without a Location"))), + 200 => Err(Fault::Unexpected(format!( + "{url} answered a page instead of a redirect" + ))), + status => Err(Fault::Http { status }), + } + } + + fn text(&self, url: &str) -> Result { + let response = self.send("GET", url, true)?; + if response.status != 200 { + return Err(Fault::Http { + status: response.status, + }); + } + let mut bytes = Vec::new(); + read_body(&response.request, |piece| { + // A checksum file is a few hundred bytes; a megabyte here is a + // page, not the file. + if bytes.len() + piece.len() > 1024 * 1024 { + return Err(Fault::Unexpected(format!( + "{url} answered more than a text file" + ))); + } + bytes.extend_from_slice(piece); + Ok(()) + })?; + String::from_utf8(bytes) + .map_err(|_| Fault::Unexpected(format!("{url} did not answer text"))) + } + + fn download( + &self, + url: &str, + to: &Path, + progress: &mut dyn FnMut(Option), + ) -> Result { + let response = self.send("GET", url, true)?; + if response.status != 200 { + return Err(Fault::Http { + status: response.status, + }); + } + let size = query_number(&response.request, WINHTTP_QUERY_CONTENT_LENGTH).map(u64::from); + progress(size); + let mut file = std::fs::File::create(to).map_err(|error| Fault::write(to, &error))?; + let written = read_body(&response.request, |piece| { + file.write_all(piece) + .map_err(|error| Fault::write(to, &error)) + })?; + file.flush().map_err(|error| Fault::write(to, &error))?; + if let Some(size) = size + && size != written + { + return Err(Fault::Unexpected(format!( + "{url} announced {size} bytes and sent {written}" + ))); + } + Ok(written) + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + + use super::*; + + #[test] + fn only_https_urls_are_split_unless_the_tests_say_otherwise() { + assert_eq!( + split( + "https://github.com/Geeooff/GameModeExecutor/releases/latest", + false + ) + .unwrap(), + Target { + secure: true, + host: "github.com".to_owned(), + port: 443, + path: "/Geeooff/GameModeExecutor/releases/latest".to_owned() + } + ); + assert_eq!(split("https://github.com", false).unwrap().path, "/"); + assert!(matches!( + split("http://github.com/x", false), + Err(Fault::Unexpected(_)) + )); + assert!(matches!( + split("https:///x", false), + Err(Fault::Unexpected(_)) + )); + assert_eq!( + split("http://127.0.0.1:8080/a", true).unwrap(), + Target { + secure: false, + host: "127.0.0.1".to_owned(), + port: 8080, + path: "/a".to_owned() + } + ); + assert!(matches!( + split("http://127.0.0.1:x/a", true), + Err(Fault::Unexpected(_)) + )); + } + + // ------------------------------------------------ a server of our own -- + + /// What the listener answers to one `METHOD /path`: a status, headers, + /// and a body that is sent unless the request was a `HEAD`. + struct Answer { + status: &'static str, + headers: Vec, + body: Vec, + /// Send only this many bytes of the body, then close: a cut-off + /// download. + truncate_to: Option, + } + + fn redirect(to: String) -> Answer { + Answer { + status: "302 Found", + headers: vec![format!("Location: {to}")], + body: Vec::new(), + truncate_to: None, + } + } + + fn ok(body: &[u8]) -> Answer { + Answer { + status: "200 OK", + headers: Vec::new(), + body: body.to_vec(), + truncate_to: None, + } + } + + fn status(status: &'static str) -> Answer { + Answer { + status, + headers: Vec::new(), + body: Vec::new(), + truncate_to: None, + } + } + + /// An HTTP/1.1 listener on `127.0.0.1`, written by hand from the + /// standard library: it reads one request's line and headers, answers + /// from `routes` by `METHOD /path`, closes, and does that `count` + /// times. What GitHub answers is in the design record's table; this is + /// that table made to talk. + fn serve(routes: Vec<(&'static str, Answer)>, count: usize) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for _ in 0..count { + let Ok((stream, _)) = listener.accept() else { + return; + }; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + continue; + } + let mut header = String::new(); + loop { + header.clear(); + if reader.read_line(&mut header).is_err() || header.trim().is_empty() { + break; + } + } + let mut parts = line.split_whitespace(); + let (method, path) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); + let key = format!("{method} {path}"); + let mut stream = reader.into_inner(); + let answer = routes + .iter() + .find(|(route, _)| *route == key) + .map(|(_, answer)| answer); + let Some(answer) = answer else { + let _ = stream.write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + continue; + }; + let mut head = format!( + "HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n", + answer.status, + answer.body.len() + ); + for h in &answer.headers { + head.push_str(h); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + let _ = stream.write_all(head.as_bytes()); + if method != "HEAD" { + let sent = answer.truncate_to.unwrap_or(answer.body.len()); + let _ = stream.write_all(&answer.body[..sent]); + } + let _ = stream.flush(); + } + }); + port + } + + fn scratch_file(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("gme-winhttp-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + #[test] + fn the_check_reads_the_redirect_and_does_not_follow_it() { + let port = serve( + vec![( + "HEAD /releases/latest", + redirect("http://127.0.0.1:1/releases/tag/v0.2.0".to_owned()), + )], + 1, + ); + let feed = WinHttp::plain(); + assert_eq!( + feed.redirect_of(&format!("http://127.0.0.1:{port}/releases/latest")), + Ok("http://127.0.0.1:1/releases/tag/v0.2.0".to_owned()), + "port 1 answers nothing, so following it would have failed" + ); + } + + #[test] + fn a_page_where_a_redirect_was_expected_is_an_unexpected_answer() { + let port = serve(vec![("HEAD /releases/latest", ok(b""))], 1); + let feed = WinHttp::plain(); + assert!(matches!( + feed.redirect_of(&format!("http://127.0.0.1:{port}/releases/latest")), + Err(Fault::Unexpected(_)) + )); + } + + #[test] + fn a_text_file_is_followed_to_another_host_and_read_whole() { + // GitHub answers the checksum file through a redirect to another + // host; here the other host is a second listener. + let sums = b"0123abcd GameModeExecutor-0.2.0.msi\n"; + let asset_host = serve(vec![("GET /blob/SHA256SUMS.txt", ok(sums))], 1); + let port = serve( + vec![( + "GET /releases/download/v0.2.0/SHA256SUMS.txt", + redirect(format!("http://127.0.0.1:{asset_host}/blob/SHA256SUMS.txt")), + )], + 1, + ); + let feed = WinHttp::plain(); + assert_eq!( + feed.text(&format!( + "http://127.0.0.1:{port}/releases/download/v0.2.0/SHA256SUMS.txt" + )), + Ok(String::from_utf8_lossy(sums).into_owned()) + ); + } + + #[test] + fn a_download_is_written_whole_and_its_size_announced() { + // Larger than one read, so the body arrives in pieces. + let body: Vec = (0..300_000u32).map(|i| (i % 253) as u8).collect(); + let port = serve(vec![("GET /asset.msi", ok(&body))], 1); + let feed = WinHttp::plain(); + let file = scratch_file("asset.msi"); + let mut announced = None; + let written = feed + .download( + &format!("http://127.0.0.1:{port}/asset.msi"), + &file, + &mut |size| announced = size, + ) + .unwrap(); + assert_eq!(written, body.len() as u64); + assert_eq!(announced, Some(body.len() as u64)); + assert_eq!(std::fs::read(&file).unwrap(), body); + } + + #[test] + fn a_download_cut_short_is_an_unexpected_answer() { + let body = vec![7u8; 10_000]; + let port = serve( + vec![( + "GET /asset.msi", + Answer { + truncate_to: Some(4_000), + ..ok(&body) + }, + )], + 1, + ); + let feed = WinHttp::plain(); + let file = scratch_file("short.msi"); + let outcome = feed.download( + &format!("http://127.0.0.1:{port}/asset.msi"), + &file, + &mut |_| {}, + ); + assert!( + matches!(outcome, Err(Fault::Unexpected(ref text)) if text.contains("announced 10000 bytes and sent 4000")), + "{outcome:?}" + ); + } + + #[test] + fn statuses_other_than_the_expected_one_are_http_faults() { + let port = serve( + vec![ + ("GET /gone", status("404 Not Found")), + ("HEAD /down", status("503 Service Unavailable")), + ], + 2, + ); + let feed = WinHttp::plain(); + assert_eq!( + feed.text(&format!("http://127.0.0.1:{port}/gone")), + Err(Fault::Http { status: 404 }) + ); + assert_eq!( + feed.redirect_of(&format!("http://127.0.0.1:{port}/down")), + Err(Fault::Http { status: 503 }) + ); + } + + #[test] + fn a_body_the_size_of_a_page_is_not_a_text_file() { + let body = vec![b'x'; 1024 * 1024 + 1]; + let port = serve(vec![("GET /SHA256SUMS.txt", ok(&body))], 1); + let feed = WinHttp::plain(); + assert!(matches!( + feed.text(&format!("http://127.0.0.1:{port}/SHA256SUMS.txt")), + Err(Fault::Unexpected(_)) + )); + } + + #[test] + fn a_port_nobody_listens_on_is_no_connection() { + let port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + // The listener is gone; the port is free again. + let feed = WinHttp::plain(); + // ERROR_WINHTTP_CANNOT_CONNECT. + assert_eq!( + feed.redirect_of(&format!("http://127.0.0.1:{port}/releases/latest")), + Err(Fault::NoConnection { code: 12029 }) + ); + } + + #[test] + fn winhttp_errors_are_no_connection_and_others_are_said_as_they_are() { + use windows::core::{Error, HRESULT}; + // ERROR_WINHTTP_NAME_NOT_RESOLVED, 12007, as an HRESULT. + let dns = Error::from_hresult(HRESULT(0x80072EE7u32 as i32)); + assert_eq!(fault(dns, "send"), Fault::NoConnection { code: 12007 }); + // ERROR_WINHTTP_TIMEOUT, 12002. + let timeout = Error::from_hresult(HRESULT(0x80072EE2u32 as i32)); + assert_eq!(fault(timeout, "send"), Fault::NoConnection { code: 12002 }); + // E_ACCESSDENIED is nobody's network. + let denied = Error::from_hresult(HRESULT(0x80070005u32 as i32)); + assert!( + matches!(fault(denied, "open"), Fault::Unexpected(text) if text.starts_with("open: ")) + ); + } +} From cbff497265517b28879c500f6be8869c782af27d Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:43:15 +0200 Subject: [PATCH 04/10] Answer a click with a notification, read the installer's verdict, ship the same four files What the first field run of the updater taught, both paths against the real v0.1.0. The menu closes on the click, as every Windows menu does, and the verdict waited in a menu nobody had reopened: the outcome of what the user clicked now reaches them as a silent notification from the icon, held back during quiet hours, and stays in the menu too. The object owns it -- Machine leaves a Notice on each outcome and the tray draws it -- and it only ever answers a click. A 0.0.9 build installed after a 0.1.0 that did not know pending.txt reported the update failed when it had not: settle now reads Windows Installer's own verdict from the log it wrote, UTF-16 with a byte-order mark, and says installed, or names the installer's error code, before falling back to "did not take". The resume line carried a run of spaces where a line continuation had been meant; fixed. The package carried the license and two executables, the zip a readme and the whole documentation tree: both now carry the same four files, LICENSE.txt and README.txt beside the executables, one readme for both ways in that links the documentation and the recipes for this exact commit. Lot 14, release notes people can read, is proposed on the maintainer's remark that the release page says nothing to the person who clicked What changed. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 5 +- docs/design/08-distribution.md | 11 ++++ docs/design/13-updating.md | 56 ++++++++++++++++- docs/design/14-release-notes.md | 50 +++++++++++++++ docs/design/README.md | 1 + docs/getting-started.md | 9 +-- docs/how-it-works.md | 5 ++ scripts/build.ps1 | 69 +++++++++++--------- scripts/msi.ps1 | 8 ++- src/cli.rs | 2 +- src/engine.rs | 4 +- src/purge.rs | 3 +- src/service.rs | 5 +- src/tray.rs | 67 +++++++++++++++++++- src/update/install.rs | 104 +++++++++++++++++++++++++++++- src/update/mod.rs | 108 +++++++++++++++++++++++++++----- src/update/tests.rs | 51 ++++++++++++++- 17 files changed, 493 insertions(+), 65 deletions(-) create mode 100644 docs/design/14-release-notes.md diff --git a/AGENTS.md b/AGENTS.md index d1dd208..cb71233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,10 @@ These decide most questions before they are asked. option first and argue for a fallback only if it protects something concrete. - **Discreet.** No dialogs, no windows, no sounds. The icon, its tooltip and - its menu are the whole user interface; the log is the rest. + its menu are the whole user interface, plus a silent notification to + answer something the user clicked -- a menu closes on a click, as every + Windows menu does, and the answer has to reach them somewhere; the log is + the rest. - **No elevation, no service, no telemetry, and no network the user did not ask for.** Recorded as non-goals in the design record with their reasons. The one connection the program ever opens is *Check for updates*, diff --git a/docs/design/08-distribution.md b/docs/design/08-distribution.md index a63ab92..37fcd58 100644 --- a/docs/design/08-distribution.md +++ b/docs/design/08-distribution.md @@ -223,6 +223,17 @@ worked, and four remarks came back, all taken the same evening: later logged *Logon task registered* with the user, the program, the configuration path and the 15 s delay — the first time that line, rather than *kept*, had been seen from the package. +- **The same four files in both, decided 2026-09-18** on the maintainer's + remark that the package carried the license and two executables while + the zip carried a readme and the whole documentation tree besides. Now + both carry `gamemode-executor.exe`, `gamemode-executorw.exe`, + `LICENSE.txt` and `README.txt` — `.txt` both, because the people who open + them are not on GitHub; the repository keeps `LICENSE` without an + extension, as GitHub expects, and the staging step renames it. The readme + is one text for both ways in, and links the documentation and the recipes + *for this exact commit* instead of shipping a copy that could describe a + version no longer running. `purge` still knows the old zip's `LICENSE` + and its `docs\` tree. - **The package's own metadata.** Explorer's Details tab showed *Title: Installation Database* — the phrase the SDK suggests, which tells a tool what the file is and a person nothing. The summary now names the product diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md index 148f4d7..68011fe 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -12,7 +12,8 @@ maintainer on 2026-09-18, against `v0.1.0`, before a line was written. - [x] The package: `StopForUpgrade` hands over, `StopForRemoval` restores — 2026-09-18 - [x] The zip copy updates itself the same way, through the after-exit shell — 2026-09-18, the script tested for its shape - [x] The documentation: *Getting started*, *How it works*, the reference, the README's word on the network — 2026-09-18 -- [ ] Measured: the four requests through WinHTTP, offline and behind a proxy; a self-launched upgrade with the session resumed; the failure path restarting the old watcher; `/qn` on screen +- [x] Measured on the maintainer's machine, 2026-09-18 13:03–13:33, both paths against the real `v0.1.0`: the handover mid-game and the resume, the check, the download and its verification, the install from the zip and from the package, the watcher back on the new version — below +- [ ] Measured: offline and behind a proxy, as seen from the menu; the failure path restarting the old watcher; `/qn` on screen - [ ] Verified in the field across a real release pair **Goal.** A user who wants the newer version gets it from the notification @@ -191,6 +192,25 @@ rest; it is disabled while a download or an install is running, where a new check would mean nothing. The tooltip and the icon do not change: the updater says nothing through the icon. +**The menu closes on the click, and the answer is a notification.** Seen +on the first field run: choosing *Check for updates* closes the menu, as +choosing anything in a Windows menu does, and the verdict then waits in a +menu nobody has reopened. Keeping a popup menu open through a click has +no supported path — `TrackPopupMenuEx` returns when the choice is made — +and closing one after a delay would be a menu doing what no other menu +does. So the outcome of what the user clicked reaches them the way +Windows reports finished background work: a notification from the icon +(`NIF_INFO`), silent (`NIIF_NOSOUND`), held back during quiet hours, +shown as a toast and kept in the notification centre. *Up to date*, +*Update available — right-click the icon to download and install it*, +*Installing 0.2.0*, or the fault and *See the log*. Only ever to answer a +click, never for anything the program did on its own; and the same +answer stays in the menu. The object owns it: `Machine` leaves a `Notice` +on each outcome, the worker wakes the window's thread through a callback +the tray handed in, and the tray takes the notice and draws it — no rule +in the tray. The earlier line of this page, *no balloon*, was written +before a person had clicked; corrected 2026-09-18. + **Two expiries, not one.** `UpToDate` and `Failed` are claims about *now* and expire after an hour. `Available` does not expire: a release does not un-release, and someone who said "later" should find the offer where they @@ -272,6 +292,40 @@ name any executable in it. Run by hand or on a `workflow_dispatch`, never in `test`, because it calls an external host. Kept in reserve until a fork asks for it. +## What the first field run taught, 2026-09-18 + +Both paths, the same afternoon, against the real `v0.1.0`, from a `0.0.9` +build of the branch. The zip first, with the package uninstalled so the +task pointed at the unpacked copy; then the package. + +- **The handover works in the field, mid-game, on both.** Starfield on, + `stop --handover`, `install-task`: *Stopping for an update; the game + session is handed to the next watcher*, then twenty seconds later *The + last watcher left a session open with Starfield.exe still running, so it + resumes where it was* — no command ran, the fans stayed where they were, + the icon came back green with the name. +- **The zip path**: *Update available: 0.1.0* with the zip's hash, 1.7 MB + downloaded and verified, *Installing*, the watcher stopped itself, and + one second later *GameModeExecutor 0.1.0 starting* — from the same folder, + through the task kept as it was. The archive was expanded over the folder + with the executables kept as `.old`. +- **The package path**: the same lines with the installer's hash and + 1.5 MB, then the package's own actions: *Watcher stopped, as asked* — + plain, since `0.1.0`'s package does not know `--handover` — configuration + kept, task kept, the new watcher up 0.9 s after the download. +- **A false failure, and a better verdict.** The `0.1.0` installed does not + know `pending.txt`, so it never consumed it; the `0.0.9` package installed + next read *0.1.0 pending* and reported the update failed. It had not: the + installer's own log ended with *Installation success or error status: + 0*. `settle` now reads that verdict — UTF-16 with a byte-order mark, as + `msiexec /l*v` writes it — and says *installed, and this is 0.0.9 by other + means* at `info`, or names the installer's error code when there is one, + before falling back to *did not take*. +- **The menu closed on the click** — above. +- **A word swallowed in the resume line**: the source carried a run of + spaces where a line continuation had been meant, and the log showed it. + Fixed; the pitfall was the editing tool, not the code. + ## Faults, and what the log says A network is an outside dependency the program did not have before, so diff --git a/docs/design/14-release-notes.md b/docs/design/14-release-notes.md new file mode 100644 index 0000000..2c27362 --- /dev/null +++ b/docs/design/14-release-notes.md @@ -0,0 +1,50 @@ +# Lot 14 — Release notes people can read + +**Status: proposed 2026-09-18**, on the maintainer's remark after the first +update ran through the menu: *What changed in 0.1.0* opened the release +page, and the page said *First public release* over a list of commits. +That is a changelog for the people who wrote the commits, not for the +person who clicked. + +**Goal.** Every release page reads, in a few lines, what changed for the +person running the program — and the same lines are what the updater's +*What changed* opens. + +**Done when:** a release published by the workflow carries notes a lay +user can read without a link, the commits stay below for the curious, and +the notes of every release already published have been rewritten the same +way. + +## What is decided, ahead of building it + +- **The notes are written by hand, in the release commit.** The workflow + cannot summarise; a person can, once per release, in the commit that + bumps the version — which is already the one commit a release comes + from. A `CHANGELOG.md` at the root, one section per version, newest + first, in the shape [Keep a Changelog](https://keepachangelog.com/) made + familiar: *Added*, *Changed*, *Fixed*, *Removed*, each line a sentence + about what the user sees, not about the code. +- **The workflow refuses a release without its section.** The tag names + the version; `release-notes.ps1` takes the section for that version out + of `CHANGELOG.md` and fails the run when there is none — the same way it + already fails when the tag and `Cargo.toml` disagree. A release cannot + be published with nothing to say. +- **The commits stay, below.** The list the script already writes goes + under a *For the curious* heading, after the notes, unchanged: it is + the honest record and costs nothing. +- **Links, sparingly.** A line may link the lot page in `docs/design/` + that carries the reasoning, when there is one; commits are not linked + from the notes, they are listed below. No link is needed for a line to + make sense. +- **The install lines and the checksums stay where they are**, first and + last, as the release notes script writes them today. +- **Published releases are rewritten once**, by hand, with the new shape, + when this lot lands — `v0.1.0` and whatever follows it before then. + +## To settle when it is taken + +- Whether the updater should show the notes itself one day, through the + API's `body`, rather than open the page. Not before the notes are worth + showing. +- Where the standing rule goes: *a release commit updates `CHANGELOG.md`* + belongs in `AGENTS.md` beside the versioning rule. diff --git a/docs/design/README.md b/docs/design/README.md index 3424ffd..838fd80 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -25,6 +25,7 @@ session rather than when the code compiles. Each has its own page. | 11 | [Documentation for the people who use it](11-user-documentation.md) | done | | 12 | [Editing the configuration without breaking it](12-editing-on-a-copy.md) | proposed | | 13 | [Updating](13-updating.md) | in progress | +| 14 | [Release notes people can read](14-release-notes.md) | proposed | **Dependency order:** 1 → 2 → 4 → 5 → 6 → 7, with 3 independent and 7 needing both 3 and 6. Logging sits before the icon deliberately — the icon logs too, diff --git a/docs/getting-started.md b/docs/getting-started.md index 4a53612..f943802 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -147,7 +147,7 @@ arrow next to the clock, and drag it onto the taskbar to keep it there. | **Edit configuration** | opens your `config.toml` in whatever you use for text files — Notepad if `.toml` is not associated with anything | | **Open log** | opens the log the same way | | **Documentation** | opens this page for **the exact build you are running**, not for whatever the project looks like today | -| **Check for updates** | asks GitHub whether a newer release exists — the only time this program ever connects to anything, and only when you click. The answer appears in the menu the next time you open it: *0.1.0 is the latest version*, or **Download and install 0.2.0** beside a **What changed in 0.2.0** that opens the release page | +| **Check for updates** | asks GitHub whether a newer release exists — the only time this program ever connects to anything, and only when you click. The menu closes, as menus do; the answer arrives as a silent notification a second later, and waits in the menu too: *0.1.0 is the latest version*, or **Download and install 0.2.0** beside a **What changed in 0.2.0** that opens the release page | | **Quit** | stops the watcher, running the stop commands on the way out so you are not left on a gaming profile | Quitting only stops it until the next time you log on. To stop it for good, see @@ -155,13 +155,14 @@ Quitting only stops it until the next time you log on. To stop it for good, see ## Updating -Right-click the icon, **Check for updates**, then open the menu again. If a +Right-click the icon, **Check for updates**. The menu closes and a +notification answers a second later; the answer waits in the menu too. If a newer release exists, **Download and install** fetches it, checks it against the checksums the release publishes, and installs it — in the middle of a game if you like: the running watcher hands the game over to the new one, which picks it up where it was without touching your commands. The icon -disappears for about a second and comes back; the menu then says what -happened, and so does the log, under `update`. +disappears for about a second and comes back; a notification and the menu +say what happened, and so does the log, under `update`. The same from a terminal: diff --git a/docs/how-it-works.md b/docs/how-it-works.md index ecc102c..3dcc357 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -238,6 +238,11 @@ release is honest; the program has no code signature, and the design record says why. A file it downloads carries no mark of the web, so Windows' SmartScreen never sees it: the program vouches for it, through the hash. +The notification that answers *Check for updates* is the one time the +program shows anything beyond its icon: a menu closes when you click in it, +so the answer has to reach you somewhere. It is silent, it respects your +quiet hours, and it only ever answers something you clicked. + ## What it does not do - **No network it did not ask you about.** It connects to exactly one diff --git a/scripts/build.ps1 b/scripts/build.ps1 index abe119f..b66c1dd 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -274,43 +274,48 @@ function Invoke-Release { Copy-Item (Join-Path $root 'target\release\gamemode-executor.exe') $stage Copy-Item (Join-Path $root 'target\release\gamemode-executorw.exe') $stage - Copy-Item (Join-Path $root 'LICENSE') $stage - # No configuration in the zip: `init` writes the starter one where the - # installer would, so both ways in leave the same machine behind. - # The docs ship as they are, rather than being rewritten for the bundle. - # One copy means the bundle cannot describe a version that no longer exists. - Copy-Item (Join-Path $root 'docs') $stage -Recurse + # The same four files in the zip and in the package, decided 2026-09-18: + # the two executables, the license and a readme, both as .txt because + # the people who open them are not on GitHub. No configuration -- `init` + # writes the starter one where the installer would -- and no copy of the + # documentation: the readme links the pages for this exact commit, which + # cannot describe a version that no longer exists. + Copy-Item (Join-Path $root 'LICENSE') (Join-Path $stage 'LICENSE.txt') # Asked of the binary rather than of git, so the readme cannot claim a # commit different from the one actually compiled in. $stamp = & (Join-Path $stage 'gamemode-executor.exe') --version + $docLink = ($stamp | Select-String -Pattern '^documentation:\s+(\S+)').Matches[0].Groups[1].Value + $recipesLink = $docLink -replace '/blob/([^/]+)/docs/getting-started\.md$', '/tree/$1/docs/recipes' Set-Content -Path (Join-Path $stage 'README.txt') -Encoding UTF8 -Value @" -GameModeExecutor $version - zip archive +GameModeExecutor $version $($stamp -join "`r`n") -The documentation link above names the exact commit these executables were -built from, so it describes this build and not whatever the project looks like -by the time you follow it. - - Runs the programs you configure when a game starts, and others when it stops. There is no list of games to maintain: detection is Windows' own. -Nothing to install. Keep this folder where you put it -- the scheduled task -will remember this path. Then, from a terminal in this folder: +The documentation link above names the exact commit these executables were +built from, so it describes this build and not whatever the project looks like +by the time you follow it. Start there. - gamemode-executor init writes a starter configuration - gamemode-executor install-task starts the watcher now and at every logon +INSTALLED FROM THE .MSI + Nothing to do. The installer wrote a starter configuration if you had + none, registered the logon task and started the watcher: the icon beside + the clock is the confirmation. Right-click it, Edit configuration. -The starter configuration runs nothing; the icon that appears shows the -watcher is working. What to run is yours to write -- docs\recipes\ has -worked examples, one folder each. +UNPACKED FROM THE .ZIP + Keep this folder where you put it -- the logon task remembers the path. + Then, from a terminal in this folder: -START HERE - docs\getting-started.md, next to this file -- or the documentation link - above, which is the same page at the exact commit this was built from. + gamemode-executor init writes a starter configuration + gamemode-executor install-task starts the watcher now and at every logon + +WHAT TO RUN + The starter configuration runs nothing; the icon that appears shows the + watcher is working. Worked examples, one folder each, for this build: + $recipesLink THE TWO EXECUTABLES gamemode-executor.exe the one you talk to. Every command. It answers, @@ -318,12 +323,15 @@ THE TWO EXECUTABLES gamemode-executorw.exe the one that works. No window, ever. It starts itself at logon. You never launch it yourself. +UPDATING + Right-click the icon, Check for updates. Nothing is checked unless you + ask. From a terminal: gamemode-executor update + QUICK CHECK - .\gamemode-executor.exe validate - .\gamemode-executor.exe status - .\gamemode-executor.exe install-task (start it at every logon) + gamemode-executor validate + gamemode-executor status -MIT licensed. Full documentation and source: +MIT licensed, see LICENSE.txt. Source and full documentation: https://github.com/Geeooff/GameModeExecutor "@ @@ -358,9 +366,12 @@ https://github.com/Geeooff/GameModeExecutor # A personal path baked into a public artefact is the kind of thing nobody # looks for until it is already published. Step "Nothing local leaked" + # The account as a path or as a logon name, not the bare word: the license + # carries the author's name, and an account named after its owner matched + # it the first time the license shipped as a .txt (2026-09-18). $text = Get-ChildItem $stage -Recurse -File -Include *.toml, *.xml, *.ps1, *.txt, *.md - $leaks = $text | Select-String -Pattern ([regex]::Escape($env:USERNAME)), - ([regex]::Escape($env:USERDOMAIN)) -List + $leaks = $text | Select-String -Pattern ([regex]::Escape("\Users\$env:USERNAME")), + ([regex]::Escape("$env:USERDOMAIN\$env:USERNAME")) -List if ($leaks) { $leaks | ForEach-Object { Write-Host " $($_.Filename): $($_.Line.Trim())" -ForegroundColor Red } Fail "the bundle names this machine's account" @@ -369,7 +380,7 @@ https://github.com/Geeooff/GameModeExecutor # The opposite mistake, and the likelier one: a template whose placeholders # were filled in on the way past, so it carries one machine's paths to # every other. - foreach ($template in Get-ChildItem (Join-Path $stage 'docs') -Recurse -Filter 'FanControl-*.xml') { + foreach ($template in Get-ChildItem (Join-Path $root 'docs') -Recurse -Filter 'FanControl-*.xml') { $content = [System.IO.File]::ReadAllText($template.FullName, [System.Text.Encoding]::Unicode) foreach ($placeholder in '__FANCONTROL_DIR__', '__DOMAIN__\__USERNAME__', '__CONFIGURATION__') { if ($content -notlike "*$placeholder*") { diff --git a/scripts/msi.ps1 b/scripts/msi.ps1 index 8d77044..f8df93b 100644 --- a/scripts/msi.ps1 +++ b/scripts/msi.ps1 @@ -1,6 +1,7 @@ # Builds the Windows Installer package from a staged release folder. # -# Per-user, no elevation, no UI: the two executables and the license go to +# Per-user, no elevation, no UI: the two executables, the license and the +# readme -- the same four files the zip carries -- go to # %LOCALAPPDATA%\Programs\GameModeExecutor. The user's configuration, log, # marker and scheduled tasks are not components, so no repair, upgrade or # uninstall reaches them. Four custom actions, all the program's own @@ -23,7 +24,7 @@ # the package code from the version and the commit, each component from its # file name. Two builds of the same commit give the same package. param( - [Parameter(Mandatory)] [string] $Stage, # holds the executables and LICENSE + [Parameter(Mandatory)] [string] $Stage, # holds the executables, LICENSE.txt and README.txt [Parameter(Mandatory)] [string] $Version, # x.y.z, from Cargo.toml [Parameter(Mandatory)] [string] $Out, # the .msi to write [string] $Commit = 'unknown', @@ -76,7 +77,8 @@ $PackageCode = New-NameGuid "package/$scope$Version/$Commit" $files = @( @{ Key = 'gamemode_executor.exe'; Name = 'gamemode-executor.exe'; Short = 'GAMEMO~1.EXE' }, @{ Key = 'gamemode_executorw.exe'; Name = 'gamemode-executorw.exe'; Short = 'GAMEMO~2.EXE' }, - @{ Key = 'LICENSE'; Name = 'LICENSE'; Short = 'LICENSE' } + @{ Key = 'LICENSE.txt'; Name = 'LICENSE.txt'; Short = 'LICENSE.TXT' }, + @{ Key = 'README.txt'; Name = 'README.txt'; Short = 'README.TXT' } ) foreach ($f in $files) { $f.Path = Join-Path $Stage $f.Name diff --git a/src/cli.rs b/src/cli.rs index de51b2c..40d9a38 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -213,7 +213,7 @@ pub fn run(cli: Cli, console: bool) -> Result<()> { fn update_command(check_only: bool) -> Result<()> { use crate::update::{Context, Launched, Verdict, Version, check_now, install_now, wait_for}; - let context = Context::of_this_process(None)?; + let context = Context::of_this_process(None, None)?; let release = match check_now(&context) { Ok(Verdict::UpToDate) => { println!("{} is the latest version.", Version::running()); diff --git a/src/engine.rs b/src/engine.rs index c59b1ca..1a833e1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -153,12 +153,12 @@ impl Engine { Some(game) => tracing::info!( target: target::GAME, since = pending.since.as_deref(), - "The last watcher left a session open with {game} still running, so it resumes where it was" + "The last watcher left a session open with {game} still running, so it resumes where it was" ), None => tracing::info!( target: target::GAME, since = pending.since.as_deref(), - "The last watcher left a session open with a game still running, so it resumes where it was" + "The last watcher left a session open with a game still running, so it resumes where it was" ), } self.report(&Session::Playing(signal.clone())); diff --git a/src/purge.rs b/src/purge.rs index 64961b5..35ef8cf 100644 --- a/src/purge.rs +++ b/src/purge.rs @@ -75,7 +75,8 @@ pub struct Plan { /// The files a hand-installed copy is made of, beyond the executables: /// what the zip unpacks next to them. -const BUNDLE_FILES: [&str; 2] = ["LICENSE", "README.txt"]; +/// `LICENSE` without an extension is what zips before 2026-09-18 carried. +const BUNDLE_FILES: [&str; 3] = ["LICENSE", "LICENSE.txt", "README.txt"]; const EXECUTABLES: [&str; 2] = ["gamemode-executor.exe", "gamemode-executorw.exe"]; impl Plan { diff --git a/src/service.rs b/src/service.rs index 670ead2..b09f1c8 100644 --- a/src/service.rs +++ b/src/service.rs @@ -109,7 +109,10 @@ pub fn serve( // The updater: reads what the last update left behind and gives the // menu its section. It never connects on its own. - match update::Context::of_this_process(Some(Arc::clone(&stop))) { + match update::Context::of_this_process( + Some(Arc::clone(&stop)), + Some(tray::update_sink(window_id)), + ) { Ok(context) => update::start(context), Err(error) => tracing::warn!( target: logging::target::UPDATE, diff --git a/src/tray.rs b/src/tray.rs index a1e703a..0847af7 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -24,9 +24,10 @@ use anyhow::{Context, Result}; use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, WPARAM}; use windows::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi}; use windows::Win32::UI::Shell::{ - NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, - NOTIFY_ICON_DATA_FLAGS, NOTIFYICON_VERSION_4, NOTIFYICONDATAW, Shell_NotifyIconW, - ShellExecuteW, + NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIIF_INFO, NIIF_NOSOUND, + NIIF_RESPECT_QUIET_TIME, NIM_ADD, NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, + NOTIFY_ICON_DATA_FLAGS, NOTIFY_ICON_INFOTIP_FLAGS, NOTIFYICON_VERSION_4, NOTIFYICONDATAW, + Shell_NotifyIconW, ShellExecuteW, }; use windows::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CreateIconFromResourceEx, CreatePopupMenu, DestroyIcon, DestroyMenu, @@ -45,6 +46,10 @@ const WM_TRAY: u32 = WM_APP + 2; /// Posted by the watcher thread when the session changed. const WM_SESSION: u32 = WM_APP + 3; +/// Posted by the updater's worker when an outcome left a notice to show. +/// `WM_APP + 4` is `win`'s handover message. +const WM_UPDATE: u32 = WM_APP + 5; + /// One wording for a game Windows flags but does not name, shared by the /// tooltip and the menu and agreeing with what the log already says. Three /// surfaces disagreeing about the same fact is worse than any of them being @@ -109,6 +114,23 @@ pub fn session_sink(window: isize) -> crate::engine::SessionSink { }) } +/// Hand this to the updater so it wakes the window's thread when an +/// outcome left a notice; the thread reads the notice itself. +pub fn update_sink(window: isize) -> Arc { + Arc::new(move || { + // SAFETY: posting carries no pointers, and a window that is gone makes + // the call fail, which is ignored. + unsafe { + let _ = PostMessageW( + Some(HWND(window as *mut std::ffi::c_void)), + WM_UPDATE, + WPARAM(0), + LPARAM(0), + ); + } + }) +} + /// Dark context menus, through the only door Windows offers. /// /// A menu built with `TrackPopupMenuEx` renders light whatever the taskbar is @@ -420,6 +442,8 @@ enum Plan { Reload, /// Explorer restarted and took the icon with it. ReAdd, + /// The updater has something to say; the notice is read with no borrow. + Notify, } /// Add the icon. Call once, from the thread owning `window`. @@ -500,6 +524,12 @@ pub fn dispatch(message: u32, wparam: WPARAM, lparam: LPARAM) -> Option re_add(); Some(LRESULT(0)) } + Plan::Notify => { + if let Some(notice) = crate::update::take_notice() { + notify(¬ice.title, ¬ice.text); + } + Some(LRESULT(0)) + } } } @@ -526,6 +556,7 @@ impl Tray { WM_DPICHANGED => Plan::Reload, // The engine says a game started, was renamed, or ended. WM_SESSION => Plan::Reload, + WM_UPDATE => Plan::Notify, _ => Plan::Ignore, } } @@ -552,6 +583,36 @@ impl Tray { // Everything below runs with no borrow held. // --------------------------------------------------------------------------- +/// A notification from the icon: the answer to something the user clicked, +/// since the menu they clicked in closed under them as every menu does. +/// Silent, and held back during quiet hours; Windows shows it as a toast +/// and keeps it in the notification centre. Never for anything the user +/// did not ask for. +fn notify(title: &str, text: &str) { + let Some(mut data) = TRAY.with(|cell| cell.borrow().as_ref().map(Tray::data)) else { + return; + }; + data.uFlags = NOTIFY_ICON_DATA_FLAGS(data.uFlags.0 | NIF_INFO.0); + data.dwInfoFlags = + NOTIFY_ICON_INFOTIP_FLAGS(NIIF_INFO.0 | NIIF_NOSOUND.0 | NIIF_RESPECT_QUIET_TIME.0); + let title_w = wide(title); + let len = title_w.len().min(data.szInfoTitle.len() - 1); + data.szInfoTitle[..len].copy_from_slice(&title_w[..len]); + let text_w = wide(text); + let len = text_w.len().min(data.szInfo.len() - 1); + data.szInfo[..len].copy_from_slice(&text_w[..len]); + // SAFETY: `data` is the fully initialised struct the icon was added with, + // its strings NUL-terminated within their buffers; nothing in the tray is + // borrowed while the shell handles it. + if unsafe { Shell_NotifyIconW(NIM_MODIFY, &data) }.as_bool() { + tracing::debug!( + target: crate::logging::target::UPDATE, + title, + "Notification shown" + ); + } +} + fn add(data: &NOTIFYICONDATAW) -> Result<()> { // SAFETY: `data` is fully initialised, `cbSize` included, and the handles // it carries are live. diff --git a/src/update/install.rs b/src/update/install.rs index 211cb64..75cde41 100644 --- a/src/update/install.rs +++ b/src/update/install.rs @@ -47,12 +47,29 @@ pub fn settle(context: &Context) -> Option { None } Some(version) => { - let why = result + let note = result .as_deref() .map(str::trim) .filter(|text| !text.is_empty()) - .map(str::to_owned) - .unwrap_or_else(|| "the update did not take".to_owned()); + .map(str::to_owned); + // The shell's note first; Windows Installer's own verdict next, + // read from the log it wrote -- which can say the update took + // even though this is another version: measured on 2026-09-18, + // when the version installed was one that did not know this + // file, and a build reinstalled by hand read it afterwards. + let why = match (note, installer_status(&dir.join(INSTALL_LOG))) { + (Some(note), _) => note, + (None, Some(0)) => { + tracing::info!( + target: target::UPDATE, + "The update to {version} was installed, and this is {running} by other means" + ); + tidy(context, true); + return None; + } + (None, Some(code)) => format!("Windows Installer {code}"), + (None, None) => "the update did not take".to_owned(), + }; tracing::warn!( target: target::UPDATE, log = %dir.join(INSTALL_LOG).display(), @@ -65,6 +82,31 @@ pub fn settle(context: &Context) -> Option { verdict } +/// Windows Installer's own verdict on the log it wrote: the number after +/// `Installation success or error status:` on its last line. The log is +/// UTF-16 with a byte-order mark, as `msiexec /l*v` writes it. +fn installer_status(log: &Path) -> Option { + let bytes = std::fs::read(log).ok()?; + let text = if bytes.starts_with(&[0xFF, 0xFE]) { + let units: Vec = bytes[2..] + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + String::from_utf16_lossy(&units) + } else { + String::from_utf8_lossy(&bytes).into_owned() + }; + const MARK: &str = "Installation success or error status: "; + let after = &text[text.rfind(MARK)? + MARK.len()..]; + after + .split(|c: char| !c.is_ascii_digit()) + .next()? + .parse() + .ok() +} + /// Empty the updates folder of everything but the installer's log, and /// drop the `.old` executables an unpacked copy keeps until its new /// version has started -- which it has, if this runs. @@ -243,6 +285,7 @@ mod tests { updates_dir: dir.join("updates"), install_dir: dir.join("program"), stop: None, + wake: None, } } @@ -306,6 +349,61 @@ mod tests { assert_eq!(settle(&context), None, "and not again"); } + /// A log the way `msiexec /l*v` writes one: UTF-16, a byte-order mark, + /// and the verdict on the last line. + fn installer_log(dir: &Path, status: i32) { + let text = format!( + "MSI (s) (64:B8) [13:33:31:127]: Product: GameModeExecutor -- Installation completed.\r\n\ + MSI (s) (64:B8) [13:33:31:127]: Windows Installer installed the product. Product Version: 0.1.0. \ + Installation success or error status: {status}.\r\n" + ); + let mut bytes = vec![0xFF, 0xFE]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + std::fs::write(dir.join(INSTALL_LOG), bytes).unwrap(); + } + + #[test] + fn the_installer_log_is_read_for_its_verdict() { + let dir = scratch(); + installer_log(&dir, 0); + assert_eq!(installer_status(&dir.join(INSTALL_LOG)), Some(0)); + installer_log(&dir, 1603); + assert_eq!(installer_status(&dir.join(INSTALL_LOG)), Some(1603)); + std::fs::write(dir.join(INSTALL_LOG), "no verdict here").unwrap(); + assert_eq!(installer_status(&dir.join(INSTALL_LOG)), None); + assert_eq!(installer_status(&dir.join("absent.log")), None); + } + + #[test] + fn a_pending_version_the_installer_reports_installed_is_not_a_failure() { + // The field case of 2026-09-18: the version installed did not know + // pending.txt, and a build reinstalled by hand read it afterwards. + let dir = scratch(); + let context = context(Kind::Installer, &dir); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::write(context.updates_dir.join(PENDING), "99.0.0").unwrap(); + installer_log(&context.updates_dir, 0); + assert_eq!(settle(&context), None); + assert!(!context.updates_dir.join(PENDING).exists()); + } + + #[test] + fn a_pending_version_with_an_installer_error_names_its_code() { + let dir = scratch(); + let context = context(Kind::Installer, &dir); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::write(context.updates_dir.join(PENDING), "99.0.0").unwrap(); + installer_log(&context.updates_dir, 1603); + assert_eq!( + settle(&context), + Some(Fault::Setup( + "Update to 99.0.0 failed: Windows Installer 1603".to_owned() + )) + ); + } + #[test] fn a_pending_version_with_no_note_is_still_a_failure() { let dir = scratch(); diff --git a/src/update/mod.rs b/src/update/mod.rs index 5429154..19fa163 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -215,12 +215,24 @@ impl Item { const CHECK: &str = "Check for updates"; +/// What to tell the user once, when the outcome of something they asked +/// for arrives: a title and a sentence for a notification. The menu closes +/// on a click, as every Windows menu does, so the answer has to reach them +/// somewhere else -- and it stays in the menu too. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Notice { + pub title: String, + pub text: String, +} + /// The state machine. Pure: it applies events and renders items, and /// tells the caller what to go and do. #[derive(Debug)] pub struct Machine { phase: Phase, running: Version, + /// Set by an outcome, taken by whoever shows it. + notice: Option, } impl Machine { @@ -228,6 +240,7 @@ impl Machine { Self { phase: Phase::Idle, running, + notice: None, } } @@ -235,6 +248,18 @@ impl Machine { &self.phase } + /// The notice the last outcome left, once. + pub fn take_notice(&mut self) -> Option { + self.notice.take() + } + + fn say(&mut self, title: impl Into, text: impl Into) { + self.notice = Some(Notice { + title: title.into(), + text: text.into(), + }); + } + /// Whether the phase is busy: a check, a download or an install in /// flight, during which no new check makes sense. fn busy(&self) -> bool { @@ -252,16 +277,37 @@ impl Machine { } (Event::CheckDone(result), Phase::Checking) => { self.phase = match result { - Ok(Verdict::UpToDate) => Phase::UpToDate { - version: self.running, - at: now, - }, - Ok(Verdict::Available(release)) => Phase::Available { release }, - Err(fault) => Phase::Failed { - fault, - during: "check", - at: Some(now), - }, + Ok(Verdict::UpToDate) => { + self.say( + "Up to date", + format!("{} is the latest version.", self.running), + ); + Phase::UpToDate { + version: self.running, + at: now, + } + } + Ok(Verdict::Available(release)) => { + self.say( + "Update available", + format!( + "{} is available. Right-click the icon to download and install it.", + release.version + ), + ); + Phase::Available { release } + } + Err(fault) => { + self.say( + "Could not check for updates", + format!("{fault}. See the log."), + ); + Phase::Failed { + fault, + during: "check", + at: Some(now), + } + } }; None } @@ -282,12 +328,17 @@ impl Machine { } (Event::DownloadDone(Ok(())), Phase::Downloading { release, .. }) => { let release = release.clone(); + self.say( + format!("Installing {}", release.version), + "The icon disappears for a moment and comes back on the new version.", + ); self.phase = Phase::Installing { release: release.clone(), }; Some(Effect::Install(release)) } (Event::DownloadDone(Err(fault)), Phase::Downloading { .. }) => { + self.say("Update failed", format!("{fault}. See the log.")); self.phase = Phase::Failed { fault, during: "download", @@ -296,6 +347,7 @@ impl Machine { None } (Event::InstallFailed(fault), Phase::Installing { .. }) => { + self.say("Update failed", format!("{fault}. See the log.")); self.phase = Phase::Failed { fault, during: "install", @@ -304,6 +356,7 @@ impl Machine { None } (Event::FoundAtStart(fault), Phase::Idle) => { + self.say("The last update failed", format!("{fault}. See the log.")); self.phase = Phase::Failed { fault, during: "install", @@ -387,6 +440,11 @@ pub struct Context { /// The running watcher's stop, when there is one: the zip path stops /// this process itself, with a handover, once its shell is started. pub stop: Option>, + /// Called after an outcome changed the phase, from the worker thread: + /// the tray's way to learn there is a notice to show. A callback rather + /// than a window handle, for the reason the engine reports sessions + /// through one -- this module has no business knowing what is drawn. + pub wake: Option>, } impl Context { @@ -398,7 +456,10 @@ impl Context { /// has the package must update its own files, not the package's -- /// otherwise its `msiexec` would upgrade the other copy and leave /// itself as it was. - pub fn of_this_process(stop: Option>) -> Result { + pub fn of_this_process( + stop: Option>, + wake: Option>, + ) -> Result { let exe = std::env::current_exe()?; let install_dir = exe .parent() @@ -418,6 +479,7 @@ impl Context { updates_dir, install_dir, stop, + wake, }) } } @@ -474,13 +536,29 @@ pub fn view() -> Vec { with_state(|state| state.machine.view(Instant::now())).unwrap_or_default() } -/// Apply an event and run what it asks for, on a worker thread. +/// The notice the last outcome left, once; the tray shows it. +pub fn take_notice() -> Option { + with_state(|state| state.machine.take_notice()).flatten() +} + +/// Apply an event, run what it asks for on a worker thread, and wake the +/// tray if the outcome left a notice. fn apply(event: Event) { - let effect = with_state(|state| { + let (effect, wake) = with_state(|state| { let effect = state.machine.apply(event, Instant::now()); - effect.map(|effect| (effect, Arc::clone(&state.context))) + let context = Arc::clone(&state.context); + let wake = state + .machine + .notice + .is_some() + .then(|| context.wake.clone()) + .flatten(); + (effect.map(|effect| (effect, context)), wake) }) - .flatten(); + .unwrap_or((None, None)); + if let Some(wake) = wake { + wake(); + } if let Some((effect, context)) = effect { std::thread::spawn(move || run(effect, &context)); } diff --git a/src/update/tests.rs b/src/update/tests.rs index 73cdd76..adb0e91 100644 --- a/src/update/tests.rs +++ b/src/update/tests.rs @@ -328,7 +328,7 @@ fn a_copy_is_installed_only_when_the_package_owns_its_folder() { #[test] fn the_context_of_this_process_names_a_repository_and_a_folder() { - let context = Context::of_this_process(None).unwrap(); + let context = Context::of_this_process(None, None).unwrap(); assert!(context.repository.starts_with("https://github.com/")); assert!(context.updates_dir.ends_with("updates")); assert!(context.install_dir.is_dir()); @@ -366,9 +366,58 @@ fn scratch_context() -> Context { updates_dir: dir.join("updates"), install_dir: dir.join("program"), stop: None, + wake: None, } } +#[test] +fn every_outcome_leaves_one_notice_and_a_click_leaves_none() { + let (mut m, now) = machine(); + assert_eq!(m.take_notice(), None); + m.apply(Event::CheckAsked, now); + assert_eq!(m.take_notice(), None, "asking is not an outcome"); + + m.apply(Event::CheckDone(Ok(Verdict::UpToDate)), now); + let notice = m.take_notice().expect("a verdict is told"); + assert_eq!(notice.title, "Up to date"); + assert_eq!(notice.text, "0.1.0 is the latest version."); + assert_eq!(m.take_notice(), None, "told once"); + + m.apply(Event::CheckAsked, now); + m.apply( + Event::CheckDone(Ok(Verdict::Available(release(Version(0, 2, 0))))), + now, + ); + let notice = m.take_notice().unwrap(); + assert_eq!(notice.title, "Update available"); + assert!(notice.text.starts_with("0.2.0 is available.")); + + m.apply(Event::InstallAsked, now); + assert_eq!(m.take_notice(), None); + m.apply(Event::DownloadDone(Ok(())), now); + assert_eq!(m.take_notice().unwrap().title, "Installing 0.2.0"); + m.apply(Event::InstallFailed(Fault::Installer { code: 1618 }), now); + let notice = m.take_notice().unwrap(); + assert_eq!(notice.title, "Update failed"); + assert_eq!( + notice.text, + "Windows Installer exited with 1618. See the log." + ); + + let (mut m, now) = machine(); + m.apply(Event::CheckAsked, now); + m.apply( + Event::CheckDone(Err(Fault::NoConnection { code: 12007 })), + now, + ); + let notice = m.take_notice().unwrap(); + assert_eq!(notice.title, "Could not check for updates"); + assert_eq!( + notice.text, + "no connection (WinHTTP error 12007). See the log." + ); +} + #[test] fn a_download_is_kept_only_when_it_hashes_to_what_the_release_says() { let context = scratch_context(); From 8d99c42833f75535ffa6968c3e2286e304ccc2af Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:55:35 +0200 Subject: [PATCH 05/10] Note the maintainer's questions on who writes the release notes The agent, from the commits and the design record, on request; the workflow publishes a placeholder it cannot fill; the rules of the summary go in AGENTS.md before anyone follows them twice. Left open in Lot 14 until it is taken. Co-Authored-By: Claude Opus 5 --- docs/design/14-release-notes.md | 60 +++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/design/14-release-notes.md b/docs/design/14-release-notes.md index 2c27362..0920f7b 100644 --- a/docs/design/14-release-notes.md +++ b/docs/design/14-release-notes.md @@ -17,18 +17,19 @@ way. ## What is decided, ahead of building it -- **The notes are written by hand, in the release commit.** The workflow - cannot summarise; a person can, once per release, in the commit that - bumps the version — which is already the one commit a release comes - from. A `CHANGELOG.md` at the root, one section per version, newest - first, in the shape [Keep a Changelog](https://keepachangelog.com/) made - familiar: *Added*, *Changed*, *Fixed*, *Removed*, each line a sentence - about what the user sees, not about the code. -- **The workflow refuses a release without its section.** The tag names - the version; `release-notes.ps1` takes the section for that version out - of `CHANGELOG.md` and fails the run when there is none — the same way it - already fails when the tag and `Cargo.toml` disagree. A release cannot - be published with nothing to say. +- **The notes are written before the release, not by it.** The workflow + cannot summarise. The first draft of this page said *by hand, in the + release commit*; the maintainer's questions below move the author to + the coding agent and the moment to *on request*. The shape holds either + way: one section per version, newest first, in the form + [Keep a Changelog](https://keepachangelog.com/) made familiar — *Added*, + *Changed*, *Fixed*, *Removed* — each line a sentence about what the user + sees, not about the code. +- **A release is never published with nothing to say — or it says so.** + Either the workflow refuses a tag whose version has no section, the way + it refuses a tag that disagrees with `Cargo.toml`, or it publishes a + placeholder that says the notes are pending. Which one follows from the + questions below. - **The commits stay, below.** The list the script already writes goes under a *For the curious* heading, after the notes, unchanged: it is the honest record and costs nothing. @@ -41,10 +42,41 @@ way. - **Published releases are rewritten once**, by hand, with the new shape, when this lot lands — `v0.1.0` and whatever follows it before then. +## Who writes it — the maintainer's questions, 2026-09-18 + +The page above says *by hand, in the release commit*. The maintainer's +reading, the same day, is that this is a task for the coding agent, not +for a person: a person forgets, and an agent can establish everything a +changelog needs from the commits between two tags and the design pages +they touch, and turn it into something coherent that a lay user can read. +Two constraints shape how, and both are noted here to be settled when the +lot is taken: + +- **The release workflow cannot call the agent.** A GitHub runner has + Copilot, not Claude, and the agent's memory of this project is local to + the maintainer's machine. So the notes cannot be written *by* the + release. What the workflow can do is publish a **placeholder** — the + install lines, the checksums, the commits — and mark the notes as + pending; the agent then rewrites them on the maintainer's request, from + the commits and the design record, and the maintainer publishes the + result. Whether that rewrite goes through `gh release edit` by the agent + on approval, or through a `CHANGELOG.md` the next release picks up, is + the choice to make. +- **The rules of the summary belong in `AGENTS.md`.** What a changelog + line is made of — what the user sees, in sentences, never the code; + which commits are one line and which are none; when a lot page is + linked and when nothing is; how the *For the curious* list relates to + the lines above it — has to be written down before an agent is asked to + follow it twice the same way. A framework there, the way the log + contract and the commit-message shape already are. + +Left open on purpose until then. What the page decided above stands where +it does not depend on the author: the notes sit first, the commits stay +below, published releases are rewritten once. + ## To settle when it is taken +- The two questions above. - Whether the updater should show the notes itself one day, through the API's `body`, rather than open the page. Not before the notes are worth showing. -- Where the standing rule goes: *a release commit updates `CHANGELOG.md`* - belongs in `AGENTS.md` beside the versioning rule. From 81b8d39e79536e36cfef3c77c219403239ca7491 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:52:23 +0200 Subject: [PATCH 06/10] Say when the new version first runs, and decide installed or unpacked when asked The install goes by in a second on a fast machine, too quick to see the version change: the version that comes out of it now says so with one more notification at its first start, from the pending file the previous watcher wrote. A watcher the package itself starts decided at start whether it was an installed or an unpacked copy -- and RegisterTask runs before RegisterProduct, so at that instant Windows Installer knows no product. On the second field run of the package path it took itself for an unpacked copy, fetched the zip and expanded it over the package's own folder. The kind is now decided when the question is asked, from what Windows Installer says at that moment; the tests pin it. Co-Authored-By: Claude Opus 5 --- docs/design/13-updating.md | 21 ++++++++-- docs/getting-started.md | 5 ++- src/update/install.rs | 44 ++++++++++++++------- src/update/mod.rs | 81 +++++++++++++++++++++++++++++--------- src/update/tests.rs | 57 ++++++++++++++++++++++++++- 5 files changed, 168 insertions(+), 40 deletions(-) diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md index 68011fe..27d1191 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -203,9 +203,12 @@ Windows reports finished background work: a notification from the icon (`NIF_INFO`), silent (`NIIF_NOSOUND`), held back during quiet hours, shown as a toast and kept in the notification centre. *Up to date*, *Update available — right-click the icon to download and install it*, -*Installing 0.2.0*, or the fault and *See the log*. Only ever to answer a -click, never for anything the program did on its own; and the same -answer stays in the menu. The object owns it: `Machine` leaves a `Notice` +*Installing 0.2.0*, or the fault and *See the log* — and, from the new +version at its first start, *Updated to 0.2.0*: on the maintainer's +machine the install went by in a second, too quick to see the version +change, so the version that came out of it says so (2026-09-18). Only +ever to answer a click, never for anything the program did on its own; +and the same answer stays in the menu. The object owns it: `Machine` leaves a `Notice` on each outcome, the worker wakes the window's thread through a callback the tray handed in, and the tray takes the notice and draws it — no rule in the tray. The earlier line of this page, *no balloon*, was written @@ -322,6 +325,18 @@ task pointed at the unpacked copy; then the package. means* at `info`, or names the installer's error code when there is one, before falling back to *did not take*. - **The menu closed on the click** — above. +- **A watcher started by the package took itself for an unpacked copy**, + 17:44 the same day, on the second run of the package path: it checked, + found the *zip*, expanded it over the package's own folder, and left a + product registered as 0.0.9 with 0.1.0 files, `docs\` and `.old` + executables beside them. The kind of copy was decided once, at start — + and the package starts the watcher from `RegisterTask`, sequenced + *before* `RegisterProduct`: at that instant Windows Installer knows no + product, and the folder rule alone says unpacked. The first run of the + path had passed by luck, on a watcher restarted by hand after the + install. The kind is now decided when the question is asked, from what + Windows Installer says at that moment; the tests pin it. Cleaned by + uninstalling the package and deleting what it did not own. - **A word swallowed in the resume line**: the source carried a run of spaces where a line continuation had been meant, and the log showed it. Fixed; the pitfall was the editing tool, not the code. diff --git a/docs/getting-started.md b/docs/getting-started.md index f943802..c07ecb5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -161,8 +161,9 @@ newer release exists, **Download and install** fetches it, checks it against the checksums the release publishes, and installs it — in the middle of a game if you like: the running watcher hands the game over to the new one, which picks it up where it was without touching your commands. The icon -disappears for about a second and comes back; a notification and the menu -say what happened, and so does the log, under `update`. +disappears for about a second and comes back — and the new version says so +with one more notification, since the install itself is too quick to watch. +The menu and the log, under `update`, say the same. The same from a terminal: diff --git a/src/update/install.rs b/src/update/install.rs index 75cde41..176e2f2 100644 --- a/src/update/install.rs +++ b/src/update/install.rs @@ -33,9 +33,22 @@ pub enum Launched { Shell, } +/// What the last update left behind, read at start. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Settled { + /// No update was pending. + Nothing, + /// The update took: this is the version it installed, running for the + /// first time -- the moment to say so, since the install itself went by + /// in a second on a fast machine (the maintainer's remark, 2026-09-18). + Updated(Version), + /// The update did not take, and this is why. + Failed(Fault), +} + /// Read what the last update left behind, tidy the folder, and say what -/// the menu should carry. -pub fn settle(context: &Context) -> Option { +/// the menu and a notification should carry. +pub fn settle(context: &Context) -> Settled { let dir = &context.updates_dir; let pending = std::fs::read_to_string(dir.join(PENDING)).ok(); let result = std::fs::read_to_string(dir.join(RESULT)).ok(); @@ -44,7 +57,8 @@ pub fn settle(context: &Context) -> Option { None => None, Some(version) if version == running => { tracing::info!(target: target::UPDATE, "Updated to {running}"); - None + tidy(context, true); + return Settled::Updated(running); } Some(version) => { let note = result @@ -65,7 +79,7 @@ pub fn settle(context: &Context) -> Option { "The update to {version} was installed, and this is {running} by other means" ); tidy(context, true); - return None; + return Settled::Nothing; } (None, Some(code)) => format!("Windows Installer {code}"), (None, None) => "the update did not take".to_owned(), @@ -79,7 +93,7 @@ pub fn settle(context: &Context) -> Option { } }; tidy(context, verdict.is_none()); - verdict + verdict.map_or(Settled::Nothing, Settled::Failed) } /// Windows Installer's own verdict on the log it wrote: the number after @@ -125,7 +139,7 @@ fn tidy(context: &Context, all: bool) { } } } - if context.kind == Kind::Zip { + if context.kind() == Kind::Zip { for name in ["gamemode-executor.exe.old", "gamemode-executorw.exe.old"] { let _ = std::fs::remove_file(context.install_dir.join(name)); } @@ -202,7 +216,7 @@ pub fn launch(context: &Context, release: &Release, file: &Path) -> Result { let script = installer_script(file, &dir.join(INSTALL_LOG), &result); tracing::info!( @@ -281,7 +295,7 @@ mod tests { fn context(kind: Kind, dir: &Path) -> Context { Context { repository: "https://example.invalid".to_owned(), - kind, + kind: Some(kind), updates_dir: dir.join("updates"), install_dir: dir.join("program"), stop: None, @@ -296,7 +310,7 @@ mod tests { std::fs::create_dir_all(&context.updates_dir).unwrap(); std::fs::write(context.updates_dir.join("GameModeExecutor-0.2.0.msi"), b"x").unwrap(); std::fs::create_dir_all(context.updates_dir.join("unpacked")).unwrap(); - assert_eq!(settle(&context), None); + assert_eq!(settle(&context), Settled::Nothing); assert!( std::fs::read_dir(&context.updates_dir) .unwrap() @@ -319,7 +333,7 @@ mod tests { std::fs::write(context.updates_dir.join(INSTALL_LOG), b"log").unwrap(); let old = context.install_dir.join("gamemode-executorw.exe.old"); std::fs::write(&old, b"old").unwrap(); - assert_eq!(settle(&context), None); + assert_eq!(settle(&context), Settled::Updated(Version::running())); assert!(!old.exists(), "the previous executable is dropped"); assert!( !context.updates_dir.join(INSTALL_LOG).exists(), @@ -337,7 +351,7 @@ mod tests { std::fs::write(context.updates_dir.join(INSTALL_LOG), b"log").unwrap(); assert_eq!( settle(&context), - Some(Fault::Setup( + Settled::Failed(Fault::Setup( "Update to 99.0.0 failed: Windows Installer 1603".to_owned() )) ); @@ -346,7 +360,7 @@ mod tests { "the log stays for reading" ); assert!(!context.updates_dir.join(PENDING).exists(), "said once"); - assert_eq!(settle(&context), None, "and not again"); + assert_eq!(settle(&context), Settled::Nothing, "and not again"); } /// A log the way `msiexec /l*v` writes one: UTF-16, a byte-order mark, @@ -385,7 +399,7 @@ mod tests { std::fs::create_dir_all(&context.updates_dir).unwrap(); std::fs::write(context.updates_dir.join(PENDING), "99.0.0").unwrap(); installer_log(&context.updates_dir, 0); - assert_eq!(settle(&context), None); + assert_eq!(settle(&context), Settled::Nothing); assert!(!context.updates_dir.join(PENDING).exists()); } @@ -398,7 +412,7 @@ mod tests { installer_log(&context.updates_dir, 1603); assert_eq!( settle(&context), - Some(Fault::Setup( + Settled::Failed(Fault::Setup( "Update to 99.0.0 failed: Windows Installer 1603".to_owned() )) ); @@ -412,7 +426,7 @@ mod tests { std::fs::write(context.updates_dir.join(PENDING), "99.0.0").unwrap(); assert_eq!( settle(&context), - Some(Fault::Setup( + Settled::Failed(Fault::Setup( "Update to 99.0.0 failed: the update did not take".to_owned() )) ); diff --git a/src/update/mod.rs b/src/update/mod.rs index 19fa163..5568c90 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -159,6 +159,9 @@ pub enum Event { InstallFailed(Fault), /// The previous update did not take; read at start. FoundAtStart(Fault), + /// The previous update took, and this is its version running for the + /// first time; read at start. + UpdatedAtStart(Version), } /// What the worker has to go and do once an event was applied. @@ -364,6 +367,15 @@ impl Machine { }; None } + // The install went by in a second; this is the moment the new + // version can be seen. Nothing to offer, so the phase stays. + (Event::UpdatedAtStart(version), Phase::Idle) => { + self.say( + format!("Updated to {version}"), + "GameModeExecutor is running the new version.", + ); + None + } // A stale answer, or a click the phase does not take: nothing. _ => None, } @@ -432,7 +444,14 @@ fn megabytes(bytes: u64) -> String { pub struct Context { /// `https://github.com/{owner}/{repo}`, from the build. pub repository: String, - pub kind: Kind, + /// The kind of copy this is, when pinned -- the tests pin it. `None` + /// means it is decided when asked, from what Windows Installer says at + /// that moment, never at start: the package starts the watcher from + /// `RegisterTask`, which runs *before* `RegisterProduct`, so a watcher + /// that decided at start saw no product and took itself for an + /// unpacked copy. Seen on 2026-09-18 17:44 -- it expanded the zip over + /// the package's folder. + pub kind: Option, /// Where downloads go: `%LOCALAPPDATA%\GameModeExecutor\updates`. pub updates_dir: PathBuf, /// Where the executables live, for the zip path. @@ -448,14 +467,8 @@ pub struct Context { } impl Context { - /// The context of this process: installed or unpacked. - /// - /// Installed means two things at once: Windows Installer knows the - /// upgrade code, *and* this executable runs from the folder the package - /// installs to. A copy unpacked somewhere else on a machine that also - /// has the package must update its own files, not the package's -- - /// otherwise its `msiexec` would upgrade the other copy and leave - /// itself as it was. + /// The context of this process. Which kind of copy it is waits for the + /// question -- see `kind`. pub fn of_this_process( stop: Option>, wake: Option>, @@ -465,23 +478,36 @@ impl Context { .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); - let kind = kind_of( - &install_dir, - crate::package::installed_product().is_some(), - crate::package::install_dir().as_deref(), - ); let updates_dir = crate::config::local_dir() .ok_or_else(|| anyhow::anyhow!("no local profile folder"))? .join("updates"); Ok(Self { repository: crate::build_info::REPOSITORY.to_owned(), - kind, + kind: None, updates_dir, install_dir, stop, wake, }) } + + /// Installed or unpacked, decided now. + /// + /// Installed means two things at once: Windows Installer knows the + /// upgrade code, *and* this executable runs from the folder the package + /// installs to. A copy unpacked somewhere else on a machine that also + /// has the package must update its own files, not the package's -- + /// otherwise its `msiexec` would upgrade the other copy and leave + /// itself as it was. + pub fn kind(&self) -> Kind { + self.kind.unwrap_or_else(|| { + kind_of( + &self.install_dir, + crate::package::installed_product().is_some(), + crate::package::install_dir().as_deref(), + ) + }) + } } /// The decision alone, so it can be tested without a package on the @@ -518,9 +544,22 @@ fn with_state(f: impl FnOnce(&mut State) -> T) -> Option { /// the menu its section. Nothing connects until someone clicks. pub fn start(context: Context) { let mut machine = Machine::new(Version::running()); - if let Some(fault) = install::settle(&context) { - machine.apply(Event::FoundAtStart(fault), Instant::now()); + match install::settle(&context) { + install::Settled::Nothing => {} + install::Settled::Updated(version) => { + machine.apply(Event::UpdatedAtStart(version), Instant::now()); + } + install::Settled::Failed(fault) => { + machine.apply(Event::FoundAtStart(fault), Instant::now()); + } } + // An outcome found at start is told the way any other is: the tray + // reads the notice once its message loop runs. + let wake = machine + .notice + .is_some() + .then(|| context.wake.clone()) + .flatten(); let mut held = STATE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -528,6 +567,10 @@ pub fn start(context: Context) { machine, context: Arc::new(context), }); + drop(held); + if let Some(wake) = wake { + wake(); + } } /// The menu section as it should read right now. Empty when the updater @@ -581,7 +624,9 @@ pub fn perform(action: Action) { pub fn check_now(context: &Context) -> Result { tracing::info!(target: target::UPDATE, "Checking for updates"); let feed = winhttp::WinHttp::new(); - let verdict = feed::check(&feed, &context.repository, Version::running(), context.kind); + let kind = context.kind(); + tracing::debug!(target: target::UPDATE, kind = ?kind, "This copy updates as"); + let verdict = feed::check(&feed, &context.repository, Version::running(), kind); match &verdict { Ok(Verdict::UpToDate) => tracing::info!( target: target::UPDATE, diff --git a/src/update/tests.rs b/src/update/tests.rs index adb0e91..83d7ae6 100644 --- a/src/update/tests.rs +++ b/src/update/tests.rs @@ -270,11 +270,18 @@ fn every_fault_has_a_menu_line_and_a_log_sentence() { assert_eq!(megabytes(1_462_272), "1.5 MB"); } +/// The two tests that go through the process-wide state take turns: the +/// state is one per process, and the test harness runs tests in parallel. +static PROCESS_WIDE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn the_process_wide_updater_renders_its_section_once_set_up() { // Before `start`, nothing: the tests of everything else see no // section. After it, the idle entry, and an action the phase does not // take is ignored without a thread being spawned. + let _turn = PROCESS_WIDE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let context = scratch_context(); start(context); assert_eq!(labels(&view()), vec![("Check for updates", true)]); @@ -295,13 +302,56 @@ fn a_failure_left_behind_is_shown_when_the_updater_is_set_up() { ) .unwrap(); let mut machine = Machine::new(Version::running()); - if let Some(fault) = install::settle(&context) { + if let install::Settled::Failed(fault) = install::settle(&context) { machine.apply(Event::FoundAtStart(fault), Instant::now()); } assert_eq!( machine.view(Instant::now())[1].label, "Update to 99.0.0 failed: Windows Installer 1603 (see log)" ); + assert_eq!( + machine.take_notice().unwrap().title, + "The last update failed" + ); +} + +#[test] +fn a_version_running_for_the_first_time_after_an_update_says_so() { + // The install itself goes by in a second, so this is the moment the + // new version is seen: a notice, the menu unchanged, and the tray woken + // for it once the updater starts. + let _turn = PROCESS_WIDE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (mut m, now) = machine(); + assert_eq!(m.apply(Event::UpdatedAtStart(Version(0, 2, 0)), now), None); + let notice = m.take_notice().expect("told once"); + assert_eq!(notice.title, "Updated to 0.2.0"); + assert_eq!(notice.text, "GameModeExecutor is running the new version."); + assert_eq!(labels(&m.view(now)), vec![("Check for updates", true)]); + + let woken = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut context = scratch_context(); + let flag = Arc::clone(&woken); + context.wake = Some(Arc::new(move || { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + })); + std::fs::create_dir_all(&context.updates_dir).unwrap(); + std::fs::write( + context.updates_dir.join("pending.txt"), + Version::running().to_string(), + ) + .unwrap(); + start(context); + assert!( + woken.load(std::sync::atomic::Ordering::SeqCst), + "the tray was woken" + ); + assert_eq!( + take_notice().unwrap().title, + format!("Updated to {}", Version::running()) + ); + assert_eq!(take_notice(), None); } #[test] @@ -332,6 +382,9 @@ fn the_context_of_this_process_names_a_repository_and_a_folder() { assert!(context.repository.starts_with("https://github.com/")); assert!(context.updates_dir.ends_with("updates")); assert!(context.install_dir.is_dir()); + // Decided when asked, not at start: the test binary is no package. + assert_eq!(context.kind, None); + assert_eq!(context.kind(), Kind::Zip); } /// A feed that serves bytes from memory, for the download path. @@ -362,7 +415,7 @@ fn scratch_context() -> Context { let dir = std::env::temp_dir().join(format!("gme-download-{}-{n}", std::process::id())); Context { repository: REPO.to_owned(), - kind: Kind::Installer, + kind: Some(Kind::Installer), updates_dir: dir.join("updates"), install_dir: dir.join("program"), stop: None, From df1cad205d2a45b130adb9e40950df73b56254a9 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:59:07 +0200 Subject: [PATCH 07/10] Drop the installing notice; the new version announces itself The version that comes out of an update says it runs at its first start, from the pending file the previous one wrote. A notice for the install step itself, shown for an afternoon, was one too many: one notification for the outcome, not two for the steps. The published 0.1.0 predates the pending file, so the field runs ended on no notification; the first real release pair will show the new one. Co-Authored-By: Claude Opus 5 --- docs/design/13-updating.md | 27 ++++++++++++++++++++------- docs/getting-started.md | 6 +++--- src/update/mod.rs | 8 ++++---- src/update/tests.rs | 6 +++++- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md index 27d1191..d17ecce 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -203,12 +203,14 @@ Windows reports finished background work: a notification from the icon (`NIF_INFO`), silent (`NIIF_NOSOUND`), held back during quiet hours, shown as a toast and kept in the notification centre. *Up to date*, *Update available — right-click the icon to download and install it*, -*Installing 0.2.0*, or the fault and *See the log* — and, from the new -version at its first start, *Updated to 0.2.0*: on the maintainer's -machine the install went by in a second, too quick to see the version -change, so the version that came out of it says so (2026-09-18). Only -ever to answer a click, never for anything the program did on its own; -and the same answer stays in the menu. The object owns it: `Machine` leaves a `Notice` +the fault and *See the log* — and, from the new version at its first +start, *Updated to 0.2.0*: on the maintainer's machine the install went +by in a second, too quick to see the version change, so the version that +comes out of it says so (2026-09-18). Nothing for the install itself: an +*Installing* notice was shown for an afternoon and dropped the same day +as one too many — one notification for the outcome, not two for the +steps. Only ever to answer a click, never for anything the program did +on its own; and the same answer stays in the menu. The object owns it: `Machine` leaves a `Notice` on each outcome, the worker wakes the window's thread through a callback the tray handed in, and the tray takes the notice and draws it — no rule in the tray. The earlier line of this page, *no balloon*, was written @@ -336,7 +338,18 @@ task pointed at the unpacked copy; then the package. path had passed by luck, on a watcher restarted by hand after the install. The kind is now decided when the question is asked, from what Windows Installer says at that moment; the tests pin it. Cleaned by - uninstalling the package and deleting what it did not own. + uninstalling the package and deleting what it did not own, and the + case replayed at 17:56 on the fixed build: *This copy updates as + kind=Installer* from the watcher the package had started, the package + fetched, 0.1.0 running 1.0 s after the download. +- **The new version's own notification cannot be seen yet.** *Updated to + 0.1.0* is said by the version that comes out of the update, from the + pending file the previous one wrote; the published `0.1.0` predates the + file, so after these runs no notification followed the click. From the + first release that carries this code, every update ends with the new + version saying it runs — to be seen on the first real release pair. An + *Installing* notice had filled the gap that afternoon; the maintainer + found it one too many, and it went. - **A word swallowed in the resume line**: the source carried a run of spaces where a line continuation had been meant, and the log showed it. Fixed; the pitfall was the editing tool, not the code. diff --git a/docs/getting-started.md b/docs/getting-started.md index c07ecb5..206f46b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -161,9 +161,9 @@ newer release exists, **Download and install** fetches it, checks it against the checksums the release publishes, and installs it — in the middle of a game if you like: the running watcher hands the game over to the new one, which picks it up where it was without touching your commands. The icon -disappears for about a second and comes back — and the new version says so -with one more notification, since the install itself is too quick to watch. -The menu and the log, under `update`, say the same. +disappears for about a second and comes back, and the new version says so +with a notification, since the install itself is too quick to watch. The +menu and the log, under `update`, say the same. The same from a terminal: diff --git a/src/update/mod.rs b/src/update/mod.rs index 5568c90..53456ac 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -329,12 +329,12 @@ impl Machine { }; None } + // No notice here: the install goes by in a second, and the new + // version says it runs at its first start. One notification + // for the outcome, not two for the steps -- the maintainer's + // call, 2026-09-18. (Event::DownloadDone(Ok(())), Phase::Downloading { release, .. }) => { let release = release.clone(); - self.say( - format!("Installing {}", release.version), - "The icon disappears for a moment and comes back on the new version.", - ); self.phase = Phase::Installing { release: release.clone(), }; diff --git a/src/update/tests.rs b/src/update/tests.rs index 83d7ae6..374808d 100644 --- a/src/update/tests.rs +++ b/src/update/tests.rs @@ -448,7 +448,11 @@ fn every_outcome_leaves_one_notice_and_a_click_leaves_none() { m.apply(Event::InstallAsked, now); assert_eq!(m.take_notice(), None); m.apply(Event::DownloadDone(Ok(())), now); - assert_eq!(m.take_notice().unwrap().title, "Installing 0.2.0"); + assert_eq!( + m.take_notice(), + None, + "installing is not an outcome: the new version says it runs" + ); m.apply(Event::InstallFailed(Fault::Installer { code: 1618 }), now); let notice = m.take_notice().unwrap(); assert_eq!(notice.title, "Update failed"); From 5f453c06d8e00e8e8e4c17a0aeec33c6ea544bf1 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:08:37 +0200 Subject: [PATCH 08/10] Record the first-start notification seen with a planted pending file Co-Authored-By: Claude Opus 5 --- docs/design/13-updating.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md index d17ecce..302bbc8 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -347,9 +347,12 @@ task pointed at the unpacked copy; then the package. pending file the previous one wrote; the published `0.1.0` predates the file, so after these runs no notification followed the click. From the first release that carries this code, every update ends with the new - version saying it runs — to be seen on the first real release pair. An - *Installing* notice had filled the gap that afternoon; the maintainer - found it one too many, and it went. + version saying it runs. The notification itself was seen at 18:05, on + a `0.0.9` build started with a pending file planted by hand: *Updated + to 0.0.9* in the log and on screen. The chain — one version writes, the + next reads — waits for the first real release pair. An *Installing* + notice had filled the gap that afternoon; the maintainer found it one + too many, and it went. - **A word swallowed in the resume line**: the source carried a run of spaces where a line continuation had been meant, and the log showed it. Fixed; the pitfall was the editing tool, not the code. From d37a447e50ad0dab4450875b40b1484fe95397fd Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:12:55 +0200 Subject: [PATCH 09/10] Tidy what a last pass over the branch found The package header counts five actions, not four; the updater's module doc names check_now as the one connection rather than the menu's action; the user agent carries the bare version; how-it-works lists the updates folder among the places; the lot page says it ships in 0.2.0 and closes on the first update from it. Co-Authored-By: Claude Opus 5 --- docs/design/13-updating.md | 13 ++++++++----- docs/how-it-works.md | 1 + scripts/msi.ps1 | 14 +++++++------- src/update/mod.rs | 5 +++-- src/update/winhttp.rs | 2 +- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md index 302bbc8..9e2abe7 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -1,10 +1,13 @@ # Lot 13 — Updating -**Status: in progress since 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. The shape below was agreed with the -maintainer on 2026-09-18, against `v0.1.0`, before a line was written. +**Status: in progress since 2026-09-18; ships in 0.2.0.** 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. The shape below +was agreed with the maintainer on 2026-09-18, against `v0.1.0`, before a +line was written. The lot closes on the first update *from* 0.2.0, which +is the first version that carries it: 0.1.0 to 0.2.0 is done by hand, and +meets the Restart Manager's dialog once, as recorded below. - [x] The session handed from one watcher to the next: `stop --handover`, and a start that resumes an open session instead of closing it — three scenarios in `engine/tests.rs`, 2026-09-18 - [x] `update`: the state machine, tested whole through a scripted feed; the WinHTTP feed and the CNG hash behind it — 2026-09-18, and no test ever calls GitHub: the network path is measured by hand, below diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3dcc357..241caee 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -266,6 +266,7 @@ quiet hours, and it only ever answers something you clicked. | Configuration | `config.toml` next to the executable if there is one, otherwise `%APPDATA%\GameModeExecutor\config.toml` | | Log | `%LOCALAPPDATA%\GameModeExecutor\logs\gamemode-executor.log`, one file, local timestamps | | Scheduled tasks | a `GameModeExecutor` folder in Task Scheduler, holding `Watcher` and anything a recipe added | +| A release being installed | `%LOCALAPPDATA%\GameModeExecutor\updates\`, emptied once the new version has started | **Roaming for the configuration, Local for the log**, and the split is deliberate. Windows carries `%APPDATA%` between machines on a roaming profile diff --git a/scripts/msi.ps1 b/scripts/msi.ps1 index f8df93b..15cf60c 100644 --- a/scripts/msi.ps1 +++ b/scripts/msi.ps1 @@ -4,14 +4,14 @@ # readme -- the same four files the zip carries -- go to # %LOCALAPPDATA%\Programs\GameModeExecutor. The user's configuration, log, # marker and scheduled tasks are not components, so no repair, upgrade or -# uninstall reaches them. Four custom actions, all the program's own +# uninstall reaches them. Five custom actions, all the program's own # commands and all idempotent, run through the windowless executable: -# `stop` before an uninstall or upgrade touches the files, so the Restart -# Manager never has to ask -- with `--handover` on an upgrade, so a game -# session in progress is resumed by the new watcher rather than closed and -# reopened; `init`, which writes a 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 +# `stop --handover` before an upgrade touches the files and plain `stop` +# before an uninstall does, so the Restart Manager never has to ask and a +# game session in progress is resumed by the new watcher rather than +# closed and reopened; `init`, which writes a 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 -- to finish an install or upgrade; and # `uninstall-task` on an uninstall, since the task is the package's to take # down. diff --git a/src/update/mod.rs b/src/update/mod.rs index 53456ac..dcfe396 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -17,8 +17,9 @@ //! `install-task`. `docs/design/13-updating.md` has the whole shape and //! the measurements behind it. //! -//! **Never a silent poll.** `perform(Action::Check)` is the only thing that -//! opens a connection, and only the menu and the `update` command call it. +//! **Never a silent poll.** [`check_now`] is the only thing that opens a +//! connection, and it runs for a click on the menu or for the `update` +//! command in a console -- never on the program's own initiative. pub mod feed; pub mod hash; diff --git a/src/update/winhttp.rs b/src/update/winhttp.rs index c6362a7..a8292d8 100644 --- a/src/update/winhttp.rs +++ b/src/update/winhttp.rs @@ -47,7 +47,7 @@ pub struct WinHttp { impl WinHttp { pub fn new() -> Self { Self { - agent: format!("GameModeExecutor/{}", crate::build_info::VERSION), + agent: format!("GameModeExecutor/{}", crate::build_info::PACKAGE_VERSION), secure_only: true, proxy: WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, timeouts: TIMEOUTS_MS, From d7d9aa452fe8a3d210a2f37659a3a4175ee9be59 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:17:31 +0200 Subject: [PATCH 10/10] Say in the release notes where the program was measured The notes claimed Windows 10 and 11. Windows 11 is where every measurement in the record was taken; Windows 10 ships the same Xbox Game Bar component since 1903 and should work, and the notes now say exactly that rather than more. Co-Authored-By: Claude Opus 5 --- scripts/release-notes.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release-notes.ps1 b/scripts/release-notes.ps1 index 905d8c5..d58d064 100644 --- a/scripts/release-notes.ps1 +++ b/scripts/release-notes.ps1 @@ -42,7 +42,7 @@ $changes = if ($previous) { } $lines = @( - 'Runs the executables you configure when a game starts and when it stops. Windows 10 and 11.', + 'Runs the executables you configure when a game starts and when it stops. Measured on Windows 11; it relies on the Xbox Game Bar component Windows ships by default, which Windows 10 carries too since version 1903, but nobody has run it there yet.', '', '## Install', '',