diff --git a/AGENTS.md b/AGENTS.md index 2e40209..cb71233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,11 +30,16 @@ 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. -- **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. + 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*, + 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 +65,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. @@ -76,7 +94,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 37aa5ff..3f21ba8 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 @@ -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/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/09-robustness.md b/docs/design/09-robustness.md index 919685d..4a6d05e 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 @@ -202,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 28e0d94..9e2abe7 100644 --- a/docs/design/13-updating.md +++ b/docs/design/13-updating.md @@ -1,46 +1,74 @@ # 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; 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 +- [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 +- [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 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 +87,309 @@ 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, `package::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. 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 +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 } tag, version, page, file name, hash +Downloading { release, size } +Installing { release } msiexec, or the zip's shell, is running +Failed { fault, during, at } a sentence, a code, the log has the rest +``` + +`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. `Failed` with no `at` is a failure found at start, kept +until the next check. + +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. + +**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*, +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 +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 +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. + +**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. + +## 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 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, 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. 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. + +## 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 +407,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/14-release-notes.md b/docs/design/14-release-notes.md new file mode 100644 index 0000000..0920f7b --- /dev/null +++ b/docs/design/14-release-notes.md @@ -0,0 +1,82 @@ +# 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 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. +- **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. + +## 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. diff --git a/docs/design/README.md b/docs/design/README.md index 83d6a51..838fd80 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -24,7 +24,8 @@ 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 | +| 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, @@ -54,8 +55,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). diff --git a/docs/getting-started.md b/docs/getting-started.md index bfe66f8..206f46b 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. @@ -145,11 +147,36 @@ 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 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 [Turning it off](#turning-it-off). +## Updating + +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, 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: + +```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 @@ -246,6 +273,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 c78fadc..241caee 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 @@ -209,9 +218,38 @@ 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. + +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 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 @@ -228,6 +266,7 @@ added. | 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/docs/reference.md b/docs/reference.md index 00cdb2c..5f7b5f9 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -39,7 +39,8 @@ 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`. | +| `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). | Global options: `--config `, `--log-level `, `--version`. @@ -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: @@ -209,8 +219,9 @@ 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 | +| 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/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 adf2047..15cf60c 100644 --- a/scripts/msi.ps1 +++ b/scripts/msi.ps1 @@ -1,14 +1,17 @@ # 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 +# 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; `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. @@ -21,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', @@ -74,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 @@ -282,16 +286,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 +329,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 +337,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/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', '', 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 21bc7ab..40d9a38 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -101,7 +101,23 @@ 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, + }, + /// 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. @@ -143,11 +159,20 @@ 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::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), _ => {} @@ -183,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, 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 @@ -460,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/engine.rs b/src/engine.rs index f3e443f..1a833e1 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/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 5330b2b..35ef8cf 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 { @@ -78,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 { @@ -223,7 +221,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(), } } @@ -231,7 +229,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 { @@ -295,75 +293,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 +411,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 9e19fff..b09f1c8 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,8 +27,8 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crate::config::{self, Config}; -use crate::win::{SessionWindow, SingleInstance, StopSignal}; -use crate::{engine, logging, sensor, tray, win}; +use crate::win::{SessionWindow, SingleInstance, StopReason, StopSignal}; +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 @@ -105,6 +107,20 @@ 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)), + Some(tray::update_sink(window_id)), + ) { + 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. @@ -163,14 +179,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 +196,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/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..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 @@ -298,6 +320,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. /// @@ -416,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`. @@ -496,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)) + } } } @@ -522,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, } } @@ -548,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. @@ -665,6 +730,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 +754,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 +788,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..176e2f2 --- /dev/null +++ b/src/update/install.rs @@ -0,0 +1,512 @@ +//! 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, +} + +/// 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 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(); + 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}"); + tidy(context, true); + return Settled::Updated(running); + } + Some(version) => { + let note = result + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .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 Settled::Nothing; + } + (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(), + "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.map_or(Settled::Nothing, Settled::Failed) +} + +/// 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. +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: Some(kind), + updates_dir: dir.join("updates"), + install_dir: dir.join("program"), + stop: None, + wake: 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), Settled::Nothing); + 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), Settled::Updated(Version::running())); + 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), + Settled::Failed(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), Settled::Nothing, "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), Settled::Nothing); + 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), + Settled::Failed(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(); + 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), + Settled::Failed(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..dcfe396 --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,792 @@ +//! 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.** [`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; +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), + /// 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. +#[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"; + +/// 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 { + pub fn new(running: Version) -> Self { + Self { + phase: Phase::Idle, + running, + notice: None, + } + } + + pub fn phase(&self) -> &Phase { + &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 { + 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) => { + 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 + } + (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 + } + // 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.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", + at: Some(now), + }; + None + } + (Event::InstallFailed(fault), Phase::Installing { .. }) => { + self.say("Update failed", format!("{fault}. See the log.")); + self.phase = Phase::Failed { + fault, + during: "install", + at: Some(now), + }; + None + } + (Event::FoundAtStart(fault), Phase::Idle) => { + self.say("The last update failed", format!("{fault}. See the log.")); + self.phase = Phase::Failed { + fault, + during: "install", + at: None, + }; + 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, + } + } + + /// 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, + /// 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. + 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>, + /// 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 { + /// 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>, + ) -> Result { + let exe = std::env::current_exe()?; + let install_dir = exe + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + 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: 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 +/// 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()); + 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); + *held = Some(State { + 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 +/// 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() +} + +/// 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, wake) = with_state(|state| { + let effect = state.machine.apply(event, Instant::now()); + let context = Arc::clone(&state.context); + let wake = state + .machine + .notice + .is_some() + .then(|| context.wake.clone()) + .flatten(); + (effect.map(|effect| (effect, context)), wake) + }) + .unwrap_or((None, None)); + if let Some(wake) = wake { + wake(); + } + 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 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, + "{} 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..374808d --- /dev/null +++ b/src/update/tests.rs @@ -0,0 +1,495 @@ +//! 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"); +} + +/// 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)]); + 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 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] +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, None).unwrap(); + 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. +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: Some(Kind::Installer), + 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(), + 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"); + 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(); + 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..a8292d8 --- /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::PACKAGE_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: ")) + ); + } +} 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(()) }