diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e425a2..0351aff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,14 +23,31 @@ jobs: # The same checklist a developer runs: fmt, clippy -D warnings, tests, # every shipped config.toml validated, every documentation link - # resolved, release build, PE subsystem check, commit stamp check, and - # the zip archive. A release nobody can make by hand is not one CI can - # make either, so there is one script and CI calls it. + # resolved, release build, PE subsystem check, commit stamp check, the + # zip and the installer with the SDK's ICEs run over it. A release + # nobody can make by hand is not one CI can make either, so there is + # one script and CI calls it. - name: Test, build and package shell: pwsh run: .\scripts\build.ps1 release + - name: Read the version + id: version + shell: pwsh + run: | + $version = (Select-String -Path Cargo.toml -Pattern '^version = "(.+)"').Matches[0].Groups[1].Value + "version=$version" >> $env:GITHUB_OUTPUT + + # Both artefacts, in one download. GitHub serves every artifact as a + # zip of its own whatever it holds -- there is no raw download from + # the Actions page -- so the level is 0 to keep it a plain container + # around two files that are already compressed. Raw files come from + # the release workflow, which attaches them to the release itself. - uses: actions/upload-artifact@v7 with: - name: GameModeExecutor-zip - path: dist/*.zip + name: GameModeExecutor-${{ steps.version.outputs.version }} + path: | + dist/*.msi + dist/*.zip + compression-level: 0 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4d8e99f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release + +# A tag alone produces the release: push vX.Y.Z and this runs the same +# checklist a developer runs, builds the installer and the zip from that +# exact commit, and publishes them with their checksums. Nothing is built +# or uploaded by hand. docs/design/08-distribution.md has the rules. + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + with: + # The notes list the commits since the previous tag. + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + # The version is bumped in the release commit, so the tag and Cargo.toml + # must agree; a release named after one and built from the other would + # carry the wrong version in every file. + - name: The tag names the version in Cargo.toml + shell: pwsh + run: | + $version = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"').Matches[0].Groups[1].Value + if ($env:GITHUB_REF_NAME -ne "v$version") { + throw "tag $env:GITHUB_REF_NAME does not match Cargo.toml version $version" + } + "GME_VERSION=$version" | Add-Content $env:GITHUB_ENV + + - name: Test, build and package + shell: pwsh + run: .\scripts\build.ps1 release + + - name: Checksums and notes + shell: pwsh + run: .\scripts\release-notes.ps1 -Tag $env:GITHUB_REF_NAME + + - name: Publish + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create $env:GITHUB_REF_NAME ` + "dist\GameModeExecutor-$env:GME_VERSION.msi" ` + "dist\GameModeExecutor-$env:GME_VERSION.zip" ` + dist\SHA256SUMS.txt ` + --title "GameModeExecutor $env:GME_VERSION" ` + --notes-file dist\notes.md ` + --verify-tag diff --git a/.gitignore b/.gitignore index 1a10dda..cf4b4f3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ # Personal, per-machine instructions for Claude Code /CLAUDE.local.md + +# makecab writes its report next to whoever calls it +setup.inf +setup.rpt diff --git a/AGENTS.md b/AGENTS.md index a3677f0..101bd0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,9 @@ 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 and the watcher's own start and stop; 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, + 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. - 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 @@ -88,7 +88,7 @@ deleted. ```powershell .\scripts\build.ps1 test # fmt, clippy -D warnings, tests, every shipped config.toml validated, every doc link resolved .\scripts\build.ps1 build # + release build, PE subsystem check -.\scripts\build.ps1 release # + refuses a dirty tree, checks the stamped commit, zips into dist\ +.\scripts\build.ps1 release # + refuses a dirty tree, checks the stamped commit, zips into dist\, builds and validates the MSI ``` Run `test` before every commit and read its result — a `FAILED` scrolling @@ -97,6 +97,13 @@ tree because the binaries carry the commit they were built from, and `--version` links to that commit's `docs/getting-started.md`: build a release from the commit that carries the final documentation, never before it. +**Publishing a release** is a tag, and the tag needs explicit approval like +any push: bump `version` in `Cargo.toml` in the release commit, merge it, +tag that commit `vX.Y.Z`, push the tag. The release workflow runs the same +script on a runner and publishes the installer, the zip and their checksums. +Versions follow `docs/design/08-distribution.md`: the number moves only in +a release commit, and 1.0.0 waits for the criteria written there. + Three tests read this machine's registry — the Known Game List, the Game Bar registration, the real sensor — which a GitHub-hosted Windows Server runner does not have. They are `#[ignore]`d with that reason and the script runs diff --git a/Cargo.toml b/Cargo.toml index 27c9d49..ec191d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "game-mode-executor" version = "0.1.0" +authors = ["Geoffrey Vancoetsem"] edition = "2024" # Let chains, stable since 1.88. rust-version = "1.88" @@ -37,6 +38,9 @@ windows = { version = "0.62", features = [ # CreateEventW and CreateMutexW take SECURITY_ATTRIBUTES, even as None. "Win32_Security", "Win32_Storage_Packaging_Appx", + # MsiEnumRelatedProducts: whether the installer owns the executables, which + # decides how `purge` removes them. + "Win32_System_ApplicationInstallationAndServicing", # 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 542de4f..37aa5ff 100644 --- a/README.md +++ b/README.md @@ -31,32 +31,38 @@ small icon in the notification area. ## Status -Version 0.1.0, in daily use on its author's machine; no release has been -published yet. Developed and measured on Windows 11 25H2. It relies on the Game -Bar component Windows ships by default, so a machine where Game Bar has been +In daily use on its author's machine, and released from +[the releases page](https://github.com/Geeooff/GameModeExecutor/releases). +Developed and measured on Windows 11 25H2. It relies on the Game Bar +component Windows ships by default, so a machine where Game Bar has been removed will not detect anything. ## Install Two self-contained executables, no runtime dependencies. `gamemode-executor.exe` -is the one you type commands into; `gamemode-executorw.exe` is the same watcher +is the one you type commands into; `gamemode-executorw.exe` is the same program with no console, started at logon by a task it registers for you. -Until a release exists, build from source with the stable Rust toolchain: +From the [latest release](https://github.com/Geeooff/GameModeExecutor/releases/latest), +take the **`.msi`** and run it: per user, no administrator prompt, into +`%LOCALAPPDATA%\Programs\GameModeExecutor`. It writes a starter +configuration, registers the logon task and starts the watcher — the icon +appearing beside the clock is the confirmation. Right-click it, *Edit +configuration*, and say what to run. -```powershell -.\scripts\build.ps1 release -``` - -That runs the checks, builds, and leaves a zip archive in `dist\`. Unzip it -in `%LOCALAPPDATA%\Programs\GameModeExecutor`, then: +The **`.zip`** holds the same executables for anyone who would rather unpack +them by hand; then: ```powershell -gamemode-executor init # write a starter config.toml -gamemode-executor validate # check it -gamemode-executor install-task # start the watcher at every logon +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 +from source is in the [reference](docs/reference.md#building-and-releasing). + ## License MIT. See [LICENSE](LICENSE). diff --git a/build.rs b/build.rs index 1f4be50..3079461 100644 --- a/build.rs +++ b/build.rs @@ -45,66 +45,159 @@ fn main() { println!("cargo:rustc-env=GIT_DOC_REF={doc_ref}"); println!("cargo:rustc-env=GIT_COMMIT_DISPLAY={display}"); - embed_icon(); + embed_resources(&short, !dirty.is_empty()); } -/// The icon Explorer, the task bar and Alt-Tab show for the executables. +/// The icon Explorer, the task bar and Alt-Tab show for the executables, and +/// the version block the Properties dialog and Windows Installer read. /// -/// Done with `rc.exe` from the Windows SDK and nothing else. An icon has to be -/// a PE resource -- there is no way to set it from code -- and the SDK's -/// resource compiler is the Microsoft tool for producing one. Anyone who can +/// Done with `rc.exe` from the Windows SDK and nothing else. Both have to be +/// PE resources -- there is no way to set them from code -- and the SDK's +/// resource compiler is the Microsoft tool for producing them. Anyone who can /// build this already has it: it ships with the Build Tools that provide the /// MSVC linker. /// +/// One resource file per binary, because the version block names the file it +/// is in: `OriginalFilename` and `FileDescription` differ between the console +/// executable, the windowless one and the probe. `FileVersion` carries the +/// commit, `ProductVersion` the plain version, and a tree with uncommitted +/// changes is flagged as a private build, which is what Windows calls one. +/// /// Like the commit stamp above, a miss is a warning rather than an error. An /// executable with no icon works perfectly; a build that refuses to run does /// not. -fn embed_icon() { - let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("cargo sets this"); +fn embed_resources(commit_short: &str, dirty: bool) { + let manifest = + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("cargo sets this")); // The "active" artwork, in its light-background variant: an executable icon // cannot follow the theme, and the darker green keeps its definition on the // white Explorer background Windows ships with. - let icon = std::path::Path::new(&manifest).join("assets/icons/gamemode-active-light.ico"); + let icon = manifest.join("assets/icons/gamemode-active-light.ico"); + let license = manifest.join("LICENSE"); println!("cargo:rerun-if-changed={}", icon.display()); + println!("cargo:rerun-if-changed={}", license.display()); if !icon.exists() { println!( - "cargo:warning=no icon at {}, building without one", + "cargo:warning=no icon at {}, building without resources", icon.display() ); return; } - let Some(rc) = find_resource_compiler() else { - println!("cargo:warning=rc.exe not found, building without an icon"); + println!("cargo:warning=rc.exe not found, building without resources"); return; }; - let out = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("cargo sets this")); - let script = out.join("icon.rc"); - let compiled = out.join("icon.res"); - - // Resource id 1: Windows shows the lowest-numbered icon group as the - // application icon, and 1 is the convention for it. - let contents = format!( - "1 ICON \"{}\"\n", - icon.display().to_string().replace('\\', "\\\\") + let version = std::env::var("CARGO_PKG_VERSION").expect("cargo sets this"); + let numeric: Vec<&str> = version.split('.').collect(); + let (major, minor, patch) = ( + numeric.first().copied().unwrap_or("0"), + numeric.get(1).copied().unwrap_or("0"), + numeric.get(2).copied().unwrap_or("0"), ); - if std::fs::write(&script, contents).is_err() { - println!("cargo:warning=cannot write the resource script, building without an icon"); - return; - } + let author = std::env::var("CARGO_PKG_AUTHORS").unwrap_or_default(); + let repository = std::env::var("CARGO_PKG_REPOSITORY").unwrap_or_default(); + // The LICENSE file is the one place the copyright line is written; the + // resource repeats it rather than keeping a second copy. + let copyright = std::fs::read_to_string(&license) + .ok() + .and_then(|text| { + text.lines() + .find(|line| line.starts_with("Copyright")) + .map(str::to_owned) + }) + .unwrap_or_default(); + let flags = if dirty { "0x8" } else { "0x0" }; // VS_FF_PRIVATEBUILD + let private = if dirty { + " VALUE \"PrivateBuild\", \"Built from a tree with uncommitted changes\\0\"\n" + } else { + "" + }; - let status = Command::new(&rc) - .args(["/nologo", "/fo"]) - .arg(&compiled) - .arg(&script) - .status(); - match status { - Ok(status) if status.success() => { - // Bins only: the library has no resources to carry. - println!("cargo:rustc-link-arg-bins={}", compiled.display()); + let out = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("cargo sets this")); + let binaries = [ + ("gamemode-executor", "GameModeExecutor command line"), + ("gamemode-executorw", "GameModeExecutor watcher"), + ( + "presence-probe", + "GameModeExecutor presence writer probe (development tool)", + ), + ]; + for (name, description) in binaries { + let script = out.join(format!("{name}.rc")); + let compiled = out.join(format!("{name}.res")); + // Resource id 1: Windows shows the lowest-numbered icon group as the + // application icon, and 1 is the convention for it. The numeric + // constants are rc.exe's own, spelled out so no header is needed: + // FILEOS 0x40004 is VOS_NT_WINDOWS32, FILETYPE 1 is VFT_APP, and the + // string block is en-US in Unicode. + let contents = format!( + concat!( + "1 ICON \"{icon}\"\n", + "1 VERSIONINFO\n", + "FILEVERSION {major},{minor},{patch},0\n", + "PRODUCTVERSION {major},{minor},{patch},0\n", + "FILEFLAGSMASK 0x3f\n", + "FILEFLAGS {flags}\n", + "FILEOS 0x40004\n", + "FILETYPE 0x1\n", + "FILESUBTYPE 0x0\n", + "BEGIN\n", + " BLOCK \"StringFileInfo\"\n", + " BEGIN\n", + " BLOCK \"040904b0\"\n", + " BEGIN\n", + " VALUE \"CompanyName\", \"{author}\\0\"\n", + " VALUE \"FileDescription\", \"{description}\\0\"\n", + " VALUE \"FileVersion\", \"{version} ({commit})\\0\"\n", + " VALUE \"InternalName\", \"{name}\\0\"\n", + " VALUE \"LegalCopyright\", \"{copyright}. MIT License.\\0\"\n", + " VALUE \"OriginalFilename\", \"{name}.exe\\0\"\n", + " VALUE \"ProductName\", \"GameModeExecutor\\0\"\n", + " VALUE \"ProductVersion\", \"{version}\\0\"\n", + " VALUE \"Comments\", \"{repository}\\0\"\n", + "{private}", + " END\n", + " END\n", + " BLOCK \"VarFileInfo\"\n", + " BEGIN\n", + " VALUE \"Translation\", 0x409, 1200\n", + " END\n", + "END\n", + ), + icon = icon.display().to_string().replace('\\', "\\\\"), + major = major, + minor = minor, + patch = patch, + flags = flags, + author = author, + description = description, + version = version, + commit = commit_short, + name = name, + copyright = copyright, + repository = repository, + private = private, + ); + if std::fs::write(&script, contents).is_err() { + println!( + "cargo:warning=cannot write the resource script for {name}, building it without resources" + ); + continue; + } + let status = Command::new(&rc) + .args(["/nologo", "/fo"]) + .arg(&compiled) + .arg(&script) + .status(); + match status { + Ok(status) if status.success() => { + // This binary only: the library has no resources to carry and + // each executable describes itself. + println!("cargo:rustc-link-arg-bin={name}={}", compiled.display()); + } + _ => println!("cargo:warning=rc.exe failed for {name}, building it without resources"), } - _ => println!("cargo:warning=rc.exe failed, building without an icon"), } } diff --git a/config.example.toml b/config.example.toml index 58f9a2c..83cd2ab 100644 --- a/config.example.toml +++ b/config.example.toml @@ -116,31 +116,20 @@ gpu_sample = "1s" [on_game_start] mode = "series" -# FanControl requires administrator elevation, so it cannot be started -# directly by this unelevated watcher (error 740). Go through a scheduled task -# registered once with "run with highest privileges" - see "Programs that -# require administrator rights" in docs/reference.md. -[[on_game_start.actions]] -name = "FanControl - Game" -program = "schtasks.exe" -args = ["/Run", "/TN", 'GameModeExecutor\FanControl Game'] -wait = true -timeout = "15s" - -# A program that needs no elevation can be run directly: +# Nothing runs until you say so: as written, this file makes the watcher +# detect and log sessions and do nothing else, which is a fine way to see it +# work first. To *hear* it work, uncomment the two commands below -- a rising +# beep when a game starts, a falling one when it ends -- then look at +# docs/recipes/ for real ones: fan profiles, a power plan, or your own. #[[on_game_start.actions]] -#name = "power plan - high performance" -#program = "powercfg.exe" -#args = ["/setactive", "SCHEME_MIN"] -#wait = true -#timeout = "10s" +#name = "beep" +#program = "powershell.exe" +#args = ["-NoProfile", "-Command", "[console]::Beep(880,120); [console]::Beep(1320,180)"] [on_game_stop] mode = "series" -[[on_game_stop.actions]] -name = "FanControl - Idle" -program = "schtasks.exe" -args = ["/Run", "/TN", 'GameModeExecutor\FanControl Idle'] -wait = true -timeout = "15s" +#[[on_game_stop.actions]] +#name = "beep" +#program = "powershell.exe" +#args = ["-NoProfile", "-Command", "[console]::Beep(1320,120); [console]::Beep(880,180)"] diff --git a/docs/design/05-windowless-watcher.md b/docs/design/05-windowless-watcher.md index b704122..881cddb 100644 --- a/docs/design/05-windowless-watcher.md +++ b/docs/design/05-windowless-watcher.md @@ -22,10 +22,11 @@ by this mechanism — measured on 2026-09-16, below, and taken up in | Binary | Subsystem | For | | --- | --- | --- | | `gamemode-executor.exe` | `WINDOWS_CUI` | everything typed: `status`, `validate`, `check`, `trigger`, `init`, `install-task`, and `run` | -| `gamemode-executorw.exe` | `WINDOWS_GUI` | watching, and nothing else. What the logon task runs. | +| `gamemode-executorw.exe` | `WINDOWS_GUI` | the same command line, printing nothing. What the logon task runs, and since 2026-09-18 what the installer runs. | -Both are a few lines over the same library; `src/service.rs` holds the one -implementation of "run the watcher" they share. Verified by reading the +Both are a few lines over the same library; `src/cli.rs` holds the one +command line they share and `src/service.rs` the one implementation of "run +the watcher". Verified by reading the subsystem field out of each PE header rather than by trusting build settings. Three ways to keep the CLI were weighed: @@ -45,6 +46,21 @@ Three ways to keep the CLI were weighed: The first was taken. +**Amended 2026-09-18.** The twin took the whole command line. Until then it +accepted `--config` and `--log-level` and watched, and everything typed +belonged to the console binary alone — a rule that held until the installer +needed to run `init` and `install-task` with no console to flash (see +[Distribution](08-distribution.md#what-the-first-install-taught)). Two +hidden verbs on the twin would have done it, and would have been a second, +undocumented command line to keep in step with the first. Instead the two +binaries parse the same `Cli` from the library and differ in one boolean, +whether there is a console to print to. What people are told does not +change: type the console one, the twin says nothing. What the setup commands +do is written in the log under `setup`, so the twin running them loses no +information — until then their confirmations were printed and recorded +nowhere else, and a machine that misbehaved could not be read back to the +day it was set up. + ## The window, and why it is here rather than with the icon A Windows-subsystem process with no window hears nothing from the shell and diff --git a/docs/design/08-distribution.md b/docs/design/08-distribution.md index 2bed951..eef72e3 100644 --- a/docs/design/08-distribution.md +++ b/docs/design/08-distribution.md @@ -1,19 +1,24 @@ # Lot 8 — Distribution -**Status: proposed.** Partly done: `scripts/build.ps1` runs the whole checklist -and produces the zip archive in `dist/`, and `.vscode/tasks.json` drives -it. The script was written first on purpose — a release that cannot be made by -hand is not one CI can make either. What is left needs a public repository: - -- Create the public GitHub repository and push — **with explicit approval** -- The whole delivery chain, unattended: pushing a `vX.Y.Z` tag makes CI run +**Status: in progress since 2026-09-17.** Already there: `scripts/build.ps1` +runs the whole checklist and produces the zip archive in `dist/`, +`.vscode/tasks.json` drives it, and the repository is public with CI green +on a stock runner. The script was written first on purpose — a release that +cannot be made by hand is not one CI can make either. What is left, in the +order it is taken: + +- [x] Create the public GitHub repository and push — done 2026-09-17, with approval +- [x] The three measurements below, on a minimal package, before any table is written — done 2026-09-17 +- [x] `VERSIONINFO` metadata in the executables, through the same `rc.exe` step that embeds the icon — done 2026-09-17, checked by the checklist +- [x] An MSI, per-user, into `%LOCALAPPDATA%\Programs\GameModeExecutor`, with the user's files outside its components — built 2026-09-17, ICE clean, the round trip measured below +- [x] `gamemode-executor purge`, the same command in every mode — built 2026-09-17, below +- [x] The whole delivery chain, unattended: pushing a `vX.Y.Z` tag makes CI run the checklist, build the **MSI** and the **zip archive**, and publish a GitHub Release carrying both with their SHA-256 — nothing built or uploaded - by hand -- An installer, per-user, into `%LOCALAPPDATA%\Programs\GameModeExecutor` -- `gamemode-executor purge`, the same command in every mode — below -- `VERSIONINFO` metadata in the executables, through the same `rc.exe` step that embeds the icon -- The install section of the documentation pointing at a release rather than at `cargo build` + by hand. Written 2026-09-17 (`release.yml`, `scripts/release-notes.ps1`); + its first run is the first tag +- [x] The documentation: *Getting started* and the README point at the release rather than at `cargo build`, the reference gains `purge`, *How it works* gains removal — 2026-09-17 +- [ ] Verified in the field: the MSI on two machines, one real upgrade, one purge round trip — the maintainer's machine done 2026-09-17, below **Done when** a tag alone produces a release a stranger can install from, and the two artefacts on it were built by the workflow from that tag's commit. @@ -53,11 +58,12 @@ tasks, all of it — and nothing removes any of it without being asked. - **Upgrades never purge.** An upgrade replaces the executables and nothing else. For the MSI that is a constraint on the package, not a courtesy: `config.toml`, the log and the marker are user data, not components, so no - repair, upgrade or uninstall can touch them. The installer writes no - configuration; `init` does, when asked. + repair, upgrade or uninstall can touch them. The installer runs `init` at + the end, and `init` writes only where there is no file. - **Uninstalling does not purge either.** The MSI's uninstall removes what - the MSI installed — the executables — which is what Windows applications - ordinarily do. The zip has no uninstaller; the user deletes the folder. + the MSI installed — the executables and, since 2026-09-18, the logon task + it registered — which is what Windows applications ordinarily do. The zip + has no uninstaller; the user deletes the folder and runs `uninstall-task`. - **The purge is a command, `gamemode-executor purge`, not a script.** The program already knows every location — where the configuration was found, where the log is written, the local folder, the task names — and a script @@ -88,10 +94,142 @@ tasks, all of it — and nothing removes any of it without being asked. alternative, no dialog and the documented command, and decides with the number in hand. -**To measure, with the installer:** the self-removal of a hand-installed -folder from a detached shell, and the cost of the uninstall-time prompt, in -tables. The watcher's own task is registered with `LeastPrivilege`, so the -purge needs no elevation to remove it. +**Built 2026-09-17, `src/purge.rs`.** The plan is computed from a `Layout` +that says what exists, separately from discovering the machine, so seven +tests drive it on scratch folders — including the hand-installed case, +where the executables are handed to a hidden Windows PowerShell that +`Wait-Process`es on this process's id and then removes them; the test +hands it the id of a process that has already exited. That replaced a +first version built on `cmd.exe` with the batch idiom `ping -n 3 +127.0.0.1` as its pause, which the maintainer rightly found curious: a +guessed delay, a shell whose quoting `std::process::Command` gets wrong +(it escapes quotes as `\"` for `CommandLineToArgvW`, which `cmd.exe` does +not read), and a program that promises to connect to nothing pinging +anything at all. Waiting for the exact process is what was wanted. The +installed case is told apart by `MsiEnumRelatedProducts` on the upgrade +code, and a test checks +the Rust constant against the one `scripts/msi.ps1` writes. The watcher is +stopped with `WM_CLOSE` on its session window, found by `EnumWindows` +because `FindWindow` cannot see a class another process registered, and +the single-instance mutex says when it has gone. Still to measure: the +cost of an uninstall-time prompt, in tables — not built, and not missed +so far. + +**Run for real on 2026-09-17**, on the maintainer's hand-installed copy, +after the recipe's `uninstall-tasks.ps1` and `uninstall-task`: it listed +ten things and did them. Two lessons, both fixed the same evening: the +shell that finishes the removal opened a console window — a process +started with `DETACHED_PROCESS` has no console, so its first console child +made a visible one; `CREATE_NO_WINDOW` alone gives it a hidden one to pass +down — and the program's folder stayed because the PowerShell the command +was typed into sat inside it, which the command now says. And one thing left +behind by the rule: a log dated 2026-09-09 in `%APPDATA%\GameModeExecutor\logs`, +from a layout no release ever shipped. The purge does not learn layouts +nobody else has; the file was deleted by hand. + +## What the first install taught + +The maintainer purged the hand-installed copy, ran the package and followed +*Getting started* as a stranger would, on 2026-09-17. It installed and +worked, and four remarks came back, all taken the same evening: + +- **Nothing said it had worked.** A per-user package with no UI ends in + silence. Rather than a dialog — the design record says why the program + has none — the install now ends by starting the watcher, and the icon + appearing beside the clock is the confirmation. +- **The package should write a configuration, only where there is none.** + Two custom actions, both the program's own commands: `init`, then + `install-task`, each keeping what exists unless `--force`, which the + package never passes. Type 1042 — an executable from the File table, + deferred, impersonated, as a per-user package must — sequenced after + `InstallFiles` and conditioned on `NOT Installed`, so they run on an + install and on an upgrade and never on a repair or removal. Measured + with a package of a separate test family beside the real one: both ran, + both kept what was there, the watcher was untouched. `init` used to fail + when the file existed, which would have failed every upgrade. A window + after all: a probe watching the actions' processes on 2026-09-18 saw a + console host with no title and no window and this record said so; the + maintainer, watching the screen, saw a console flash twice at the end of + the install. The probe was blind — a console host's window belongs to the + host, not to the process it serves — and the eye was right. Windows + Installer does not hide an executable action's console. Hiding the + `schtasks` child with `CREATE_NO_WINDOW` was not enough, because the + flash was the action's own console. The actions now run through + `gamemode-executorw.exe`, which has none; the maintainer saw no window on + the next install. To carry two commands, the twin took the whole command + line rather than two hidden verbs of its own, and the setup commands + write what they did to the log — recorded in + [the windowless watcher](05-windowless-watcher.md#two-binaries-the-w-convention). +- **The starter configuration named FanControl.** It now names nothing: + two commands that beep, commented out, and a pointer to the recipes. That + needed a configuration with no commands to be valid, which it was not; + the watcher then detects, names and logs sessions and runs nothing, which + is the right first hour. The zip no longer ships a `config.toml` either; + `init` writes the same file the installer does, so both ways in leave + the same machine. +- **After `install-task`, nothing said how to start it.** It starts the + task now, and says the icon is coming. +- **The purge, first run:** it did what it listed; the two fixes are above. +- **The first uninstall asked to close "GameModeExecutor watcher".** The + Restart Manager, at `InstallValidate`, lists every process holding a file + the install is about to remove, by its window title, and puts up its + dialog. Clicking through was clean — the handshake the session window + keeps for a *Quit* answered `WM_QUERYENDSESSION` and the log read + *Stopped* — but a dialog is a dialog. The package now stops the watcher + itself: `stop`, a command that is *Quit* from outside (`WM_CLOSE` on the + session window, then a wait on the single-instance mutex), run as an + immediate action before `InstallValidate` on an uninstall and on an + upgrade. It runs the executable already installed, since an upgrade has + not replaced it yet, and carries on if that fails — a version too old to + know `stop` gets the dialog back, visibly and harmlessly. `purge` uses + the same command. Two processes write the log at that moment, the + watcher's *Stopped* and the command's *Watcher stopped, as asked*; the + file is opened for appending only, so each write lands at the end by the + file system's doing, and 80 processes writing at once on 2026-09-18 left + 80 whole lines. Whether an immediate action of a per-user package runs + in the interactive session, where `EnumWindows` can see the window, was + inferred from the deferred ones — which had — and measured on the first + uninstall of a package that carries it, 2026-09-18 01:27: *Stopped* from + the watcher, *Watcher stopped, as asked* 252 ms later from the action, + no *Windows asked to end the session* line — the Restart Manager never + had to ask — and, from the maintainer's own eyes this time, no dialog. + The reinstall fifty seconds later found the configuration and the task + where they were and started the watcher. +- **The first upgrade of the real package,** 01:31 the same night, with a + 0.1.1 built from the same binaries: *Stopped*, *Watcher stopped, as + asked*, then the old product's own stop action reporting *No watcher + was running* as `RemoveExistingProducts` ran its removal, then the + configuration and the task kept and the watcher started — 700 ms from + stop to start, the icon gone and back too fast to be seen, one product + listed afterwards. No dialog. The second stop was a wasted run and a + confusing line, so the action is now skipped in a product being removed + by an upgrade (`UPGRADINGPRODUCTCODE`); a package with that condition + has yet to be upgraded from, which the next release will do. +- **The uninstall left the logon task behind, armed.** The rule above had + put the task with the user's data, and the maintainer's remark on the + reinstalled machine corrected it: a task that starts a missing executable + at every logon is not data kept for a reinstall, it is infrastructure + the package set up and must take down, and it fails visibly in Task + Scheduler until someone does. `UnregisterTask` now runs `uninstall-task` + on an uninstall, deferred, before `RemoveFiles` takes the executable it + runs; not on the removal an upgrade performs, so a delay or a + configuration path chosen with `install-task` survives the upgrade as + before. `purge` still removes the task itself, first, and the package's + action then finds none to remove. Measured the same night at 01:54: the + uninstall logged *Watcher stopped, as asked* then *Logon task removed* + 133 ms later, the task was gone from Task Scheduler with the recipe's + tasks left beside where it had been, no dialog; the reinstall a minute + 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 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 + and its version in the title, says what it does in the subject, carries + the commit and the documentation link in the comments, and sets the + creation time, which Explorer otherwise takes from the file — and NTFS + tunnels a creation time across a delete-and-recreate seconds apart, so + it read as the build from the day before. ## Versioning @@ -217,18 +355,34 @@ record of what it was weighed against. How the MSI is authored — the SDK tools, given what is said of WiX below — is confirmed when the lot starts and the tables are actually written, not before. -**To measure first, before a single table is written.** Everything above -about per-user MSI comes from Microsoft's documentation, not from this -project; a minimal package settles it in an afternoon: - -1. A per-user MSI installs from an unelevated account **with no prompt**. -2. A second MSI with a higher version, run `/passive` by an unelevated - process, replaces the files **with no prompt**, and *Programs and - Features* shows the new version. -3. Uninstalling asks nothing and leaves the user's scheduled task alone. +**Measured 2026-09-17, three yeses.** A minimal package — one text file, +no UI, built in PowerShell with nothing but Windows Installer's own COM +automation and `makecab` — was installed, upgraded and removed from an +unelevated shell, `/passive`, with a verbose log each time: -Three yeses close the choice. One no says exactly what to weigh against -Inno Setup. +| | Result | The log's word for it | +| --- | --- | --- | +| Install 0.1.0 | exit 0 in 7 s, no prompt; the file in `%LOCALAPPDATA%\Programs\\`, the product registered per-user (`AssignmentType=0`) and listed in *Programs and Features* | `MSI_LUA: Package is marked as LUA installation capable with no elevation required` | +| Upgrade to 0.2.0 | exit 0 in 6 s, no prompt; file replaced, the old product gone, one product left at 0.2.0 | `Nested installation UAC elevation tracks that of parent (is not elevated)` — `RemoveExistingProducts` at 1510 removed 0.1.0 first | +| Uninstall | exit 0 in 6 s, no prompt; folder gone, registration gone; the neighbouring folders, the watcher's install and its scheduled tasks untouched | `Removal completed successfully` | + +Two things the documentation had not made plain. **The summary stream's +"elevated privileges not required" bit (WordCount bit 3) is the whole +mechanism**: with it set, Windows Installer treats the package as per-user +outright, redirects `ProgramFilesFolder` to `%LOCALAPPDATA%\Programs`, and +logs `MSIINSTALLPERUSER property is not valid for UAC compliant package. +Ignoring` — so `ALLUSERS=2` and `MSIINSTALLPERUSER=1`, the dual-purpose +recipe, are not needed for a program with no per-machine story, and the +package is simpler without them. And **no SDK tool is needed to build the +database**: the COM automation creates tables, inserts rows and embeds the +cabinet, which means the release script can produce the MSI on a stock +runner the same way it produces the zip. `MsiDb`, `MsiFiler` and `Orca` +remain what they are, tools to inspect one. What the probe did not do and +the real package must: carry versioned files with `VERSIONINFO`, and pass +ICE validation (`MsiVal2`, from the SDK). + +The choice is closed: **MSI, authored from PowerShell through Windows +Installer's automation, per-user by the summary bit.** Both candidates can offer per-user *or* per-machine from one installer, but **this program has no per-machine story**: the logon task, the configuration @@ -283,29 +437,9 @@ The **zip** is the opposite case: nothing else owns the files, so there the updater swaps them itself. `MsiEnumRelatedProducts` on the package's UpgradeCode tells the two apart. -Two things this settles for the updater, whenever it comes: - -- *The running executable.* The watcher holds its own `.exe`; MSI's Restart - Manager would show a files-in-use dialog even under `/passive`. So: refuse to - update while a game is on, then launch the installer *and quit*, with - `msiexec … & schtasks /Run Watcher` in a detached `cmd` to relaunch — no - custom action. -- *The "no network" non-goal.* An automatic release check breaks it. The - compatible shape is a **Check for updates…** entry that connects only when - clicked, or an explicit opt-in — never a silent poll. Over **WinHTTP**, a - Microsoft library using the system certificate store. The download verified - against a SHA-256 published with the release, which guards against - corruption and not against a compromised account. - -**GitHub allows and provides for the check, with no key.** Either the REST -API — `GET /repos/{owner}/{repo}/releases/latest`, 60 requests an hour per IP -unauthenticated, `User-Agent` mandatory — or no API at all: -`github.com/{owner}/{repo}/releases/latest` answers 302 with the tag in -`Location`, and `…/releases/latest/download/{asset}` serves the latest asset, -via a redirect that changes host to `objects.githubusercontent.com`. The second -suffices: a `HEAD`, a `Location`, a tag compared with `build_info::VERSION`, -nothing to parse. A token would only matter for a private repository, and -embedding one in a public executable would be a fault. +The updater itself — the menu entry, the check against GitHub, the +download, the relaunch — is [Lot 13](13-updating.md), decided on 2026-09-17 +to be a lot of its own. What stays here is what it demands of the package. **Does it decide Inno against MSI?** No. Both need the same updater. It adds two cheap requirements to MSI — `VERSIONINFO`, wanted anyway, and the early diff --git a/docs/design/13-updating.md b/docs/design/13-updating.md new file mode 100644 index 0000000..e8242a9 --- /dev/null +++ b/docs/design/13-updating.md @@ -0,0 +1,64 @@ +# Lot 13 — Updating + +**Status: proposed.** Decided 2026-09-17 to be a lot of its own rather than +a tail of [Lot 8](08-distribution.md): updating touches the "no network" +non-goal, the tray menu and the running process, and each of those deserves +its own measurement. Nothing here is built. It needs Lot 8 first — there is +nothing to update to until a release exists. + +**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. + +## What Lot 8 already settled + +**The installer is the updater.** An updater that swaps files under an +installer is the wrong shape, for reasons the distribution page keeps in +its table of Windows Installer's four moments. So the updater downloads the +new package, verifies it, and runs it silently: `msiexec /i new.msi +/passive`, per-user, no UAC. The **zip** is the opposite case — nothing else +owns the files, so there the updater replaces them itself. +`MsiEnumRelatedProducts` on the package's UpgradeCode tells the two +installations apart. + +## What is decided, ahead of building it + +- **Never a silent poll.** An automatic release check breaks the "no + network" non-goal. The compatible shape is a *Check for updates…* entry + that connects only when clicked, or an explicit opt-in in the + configuration; nothing else ever opens a connection. +- **Over WinHTTP**, a Microsoft library using the system certificate store. + No HTTP crate. +- **The download verified against a SHA-256 published with the release.** + That guards against corruption, not against a compromised account, and + the record says so. +- **GitHub provides for the check with no key.** Either the REST API — + `GET /repos/{owner}/{repo}/releases/latest`, 60 requests an hour per IP + unauthenticated, `User-Agent` mandatory — or no API at all: + `github.com/{owner}/{repo}/releases/latest` answers 302 with the tag in + `Location`, and `…/releases/latest/download/{asset}` serves the latest + asset through a redirect to `objects.githubusercontent.com`. The second + suffices: a `HEAD`, a `Location`, a tag compared with + `build_info::VERSION`, nothing to parse. A token would matter only for a + private repository, and embedding one in a public executable would be a + fault. +- **The running executable.** The watcher holds its own `.exe`; Windows + Installer's Restart Manager would show a files-in-use dialog even under + `/passive`. So: refuse to update while a game is on — the same rule as the + purge — then launch the installer *and quit*, with the relaunch handed to + a detached shell (`msiexec … & schtasks /Run Watcher`). No custom action. + +## To measure, when the lot is taken + +1. The redirect chain of `releases/latest` and `releases/latest/download` + through WinHTTP, and what a rate-limited or offline answer looks like + from the menu. +2. A `/passive` upgrade launched by the watcher itself, with the watcher + gone by the time Windows Installer looks for files in use. +3. The zip path: replacing two executables under a running logon task, and + what happens when the task fires in the middle of it. diff --git a/docs/design/README.md b/docs/design/README.md index 07d09b6..406a3f5 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -19,11 +19,12 @@ session rather than when the code compiles. Each has its own page. | 5 | [A Windows program with no window](05-windowless-watcher.md) | done | | 6 | [Notification area icon](06-notification-icon.md) | done | | 7 | [Icon, tooltip and menu as one state](07-tray-state.md) | done | -| 8 | [Distribution](08-distribution.md) | proposed | +| 8 | [Distribution](08-distribution.md) | in progress | | 9 | [Robustness](09-robustness.md) | partly done | | 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 | **Dependency order:** 1 → 2 → 4 → 5 → 6 → 7, with 3 independent and 7 needing both 3 and 6. Logging sits before the icon deliberately — the icon logs too, diff --git a/docs/getting-started.md b/docs/getting-started.md index 5767743..2d835c4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -10,22 +10,33 @@ console window, the other has none. | Executable | Role | | --- | --- | -| **`gamemode-executorw.exe`** | The one that works. It starts by itself when you log on and never shows anything — no window, no icon. You never launch it yourself. | +| **`gamemode-executorw.exe`** | The one that works. It starts by itself when you log on and never shows anything — no window, no output. You never launch it yourself; the logon task and the installer do. | | **`gamemode-executor.exe`** | The one you talk to. Open it in a terminal to set things up, test, or check. It answers, then it is done. It does not keep watching. | The `w` just means *windowless*, the same convention as `python.exe` and `pythonw.exe`. -## Where to put the folder +## Installing -There is nothing to install: unzip it and keep it somewhere. Two things make -the choice worth a moment's thought. +The release page offers two files that hold the same two executables. -**It has to stay put.** The logon task records the full path to the executable, -so moving the folder afterwards means running `install-task` again. +**The installer, `GameModeExecutor-.msi`.** Run it. It asks for no +administrator rights and installs for you alone, into +`%LOCALAPPDATA%\Programs\GameModeExecutor`. It also writes a starter +configuration if you have none, registers the logon task, and starts the +watcher: the confirmation that it worked is the **grey controller icon** +that appears in the notification area, beside the clock. No window to click +through, and *Programs and Features* lists it afterwards. A newer release +installs over it the same way: it stops the running watcher, replaces the +files, leaves your configuration and your task where they are, and starts +the watcher again. Skip to [Say what to run](#say-what-to-run). + +**The zip, `GameModeExecutor-.zip`,** for anyone who would rather +not run an installer: unzip it and keep it somewhere. Two things make the +choice of somewhere worth a moment's thought. -**It needs to be a folder you can write to**, because your `config.toml` sits -next to the executable. +**It has to stay put.** The logon task records the full path to the executable, +so moving the folder afterwards means running `install-task --force` again. The tidiest place, and the Windows convention for a program installed for one user, is: @@ -42,16 +53,15 @@ avoid: | `C:\Program Files` | Needs administrator rights to write, and then your own configuration sits in a folder you cannot edit. This program is built to never ask for those rights. | | A OneDrive or Dropbox folder | Synced folders move files, lock them mid-sync, and can turn them into online-only placeholders. For something that starts at logon, that is a bad bet. | -## Three steps +## Say what to run -### 1. Write down what you want to run +The starter configuration runs nothing: as installed, the watcher detects +games, names them and logs them, and that is all — which is a fine way to see +it work before deciding what it should do. Right-click the icon and choose +**Edit configuration**, or open `%APPDATA%\GameModeExecutor\config.toml` +yourself. (From the zip, `gamemode-executor init` writes that file first.) -```bash -gamemode-executor init -``` - -That writes a starter `config.toml` into `%APPDATA%\GameModeExecutor` and tells -you where. Open it in any text editor. The part that matters looks like this: +The part that matters looks like this: ```toml [[on_game_start.actions]] @@ -65,7 +75,9 @@ program = "powercfg.exe" args = ["/setactive", "SCHEME_BALANCED"] ``` -One block per command. Add as many as you like to either event. +One block per command. Add as many as you like to either event. The starter +file carries two commands that beep, commented out: uncomment them to *hear* +a game being detected before you write anything real. Write paths between **single quotes** — that way Windows backslashes need no doubling: @@ -74,7 +86,7 @@ doubling: program = 'C:\Program Files\Something\tool.exe' ``` -### 2. Check it +## Check it ```bash gamemode-executor validate @@ -90,14 +102,16 @@ gamemode-executor trigger start gamemode-executor trigger stop ``` -### 3. Turn it on +The watcher reads the file when it starts, so after editing it, restart it: ```bash +gamemode-executor stop gamemode-executor install-task ``` -This registers a task that starts the watcher every time you log on. No -administrator rights, no password, no window. +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. **That is the end of the setup.** Play. The commands fire by themselves. @@ -154,6 +168,11 @@ The log keeps the history of every session. It lives in 2026-09-10 17:58:41.833 INFO game Game no longer detected: bf6.exe ``` +It starts earlier than the first session: the lines marked `setup` say when +the configuration was written and the logon task registered, whether you +typed the command or the installer did it. `init`, `install-task` and +`uninstall-task` print those same lines as they run. + ## Using the game's name in your command If you want the command to know which game started, these get substituted: @@ -236,10 +255,12 @@ where the time went if you set `log_level = "debug"`. ## Turning it off ```bash +gamemode-executor stop gamemode-executor uninstall-task ``` -Removes the logon task. Nothing else is left running. +The first stops the one running now; the second removes the logon task, so +nothing starts at the next logon. ## Everything else diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 012f7ca..062b3f0 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -97,9 +97,15 @@ It is not a setting you flip afterwards. So a program that must both sit silently in the background *and* answer you when you type commands cannot be one file. -Hence the pair. `gamemode-executorw.exe` watches and says nothing; -`gamemode-executor.exe` is everything you type. Python solves the same problem -the same way, with `python.exe` and `pythonw.exe`. +Hence the pair. Both understand the same commands. `gamemode-executor.exe` +is the one you type them into, because it answers where you can read it and +a shell waits for it to finish. `gamemode-executorw.exe` prints nothing and +nobody waits for it, which is exactly right for the two things that run it: +the logon task, which runs the watcher, and the installer, which writes your +starter configuration and registers the task through it — a console program +would flash a black window in the middle of the install. What it did is +written in the log instead. Python solves the same problem the same way, with +`python.exe` and `pythonw.exe`. The alternative — one windowless program that borrows the terminal it was launched from — was tried on paper and rejected: a shell does not wait for a @@ -107,7 +113,8 @@ windowless program, so the prompt comes back before the output, and commands like `validate` would stop reporting success or failure to any script using them. Silently, which is the worst way for that to break. -Both files are built from the same code, so the watcher they run is identical. +Both files are built from the same code, so the watcher they run is identical, +and so is every command. ## Only one watcher at a time @@ -117,7 +124,13 @@ rather than doubling up your commands. ## What starts it, and why not a service -A **per-user scheduled task**, triggered at logon, fifteen seconds in. +A **per-user scheduled task**, triggered at logon, fifteen seconds in. The +installer registers it and starts it once the files are in place, which is +why the icon appears as the install ends; from the zip, `install-task` does +the same by hand. Before an upgrade or an uninstall replaces or removes the +executables, the installer runs `stop` — *Quit*, typed — so Windows never +finds the watcher holding a file it is about to touch and never has to ask +you to close it. A Windows service was considered and dropped. Services run before anyone logs on, in a separate session, where they cannot see the desktop the game is on — @@ -166,6 +179,36 @@ leaves your profile alone, and so does the next logon. `gamemode-executor status` shows whether that file is there, and where. +## Removing it + +Uninstalling from *Programs and Features* removes what the installer put +there — the executables and the logon task it registered, which would +otherwise try to start a missing program at every logon — and nothing else, +as Windows applications ordinarily do: your configuration, the log and the +session marker stay, so that installing again finds everything as you left +it. Upgrading touches none of them, the task included. + +When you want every trace gone, ask for it: + +``` +gamemode-executor purge +``` + +It lists what it is about to remove and waits for a `yes`: the logon task, +the configuration wherever it found it, the log wherever it was written, the +session marker, its two profile folders once they are empty, and last the +executables — through Windows Installer when they were installed from the +package, or by deleting them once the command has exited when they were +unpacked from the zip. It refuses while a game is running, because a purge +then would leave your gaming configuration on with nothing left to restore +it; and it stops the running watcher first, the way *Quit* does. + +It removes only what it recognises as its own. A scheduled task it did not +register — the ones a recipe had you create, say — stays, and so does the +`\GameModeExecutor` folder in Task Scheduler around it; a folder that holds +anything else stays too. The recipes carry their own way out for what they +added. + ## What it does not do - **No network.** It never connects to anything, and there is no telemetry. diff --git a/docs/reference.md b/docs/reference.md index ab7f9f6..00cdb2c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -12,17 +12,22 @@ Self-contained, no runtime dependencies. | Executable | What it is for | | --- | --- | | `gamemode-executor.exe` | Everything you type. A console program, so a shell waits for it, pipes work and exit codes come back. | -| `gamemode-executorw.exe` | Watching, and nothing else. No console at all — this is what the logon task runs. | +| `gamemode-executorw.exe` | The same commands with no console at all: it prints nothing, and a shell does not wait for it. What the logon task runs, and what the installer runs. | | `presence-probe.exe` | Diagnostics, not shipped in the bundle. See [Detection](design/00-detection.md#the-instrument). | The `w` suffix is the same convention as `python.exe` and `pythonw.exe`, for the same reason: a program cannot be both a console and a windowless one in a -single file. Both share one library, so the watcher they run is the same code. +single file. Both share one library, so the commands they run are the same code. ## Commands -All belong to `gamemode-executor.exe`. `gamemode-executorw.exe` takes only -`--config` and `--log-level`, and watches. +Both executables take the same command line. Type it into +`gamemode-executor.exe`: it answers where you can read it and a shell waits +for it. `gamemode-executorw.exe` accepts the same line but prints nothing and +nobody waits for it, which suits its two callers — the logon task, which +runs `run`, and the installer, which runs `stop`, `init`, `install-task` +and `uninstall-task`. What those commands do is written in the log either +way. | Command | What it does | | --- | --- | @@ -31,9 +36,11 @@ All belong to `gamemode-executor.exe`. `gamemode-executorw.exe` takes only | `check ` | Ask whether Windows knows a given executable as a game. | | `trigger start\|stop` | Run one set of actions immediately, ignoring detection. Handy to test your commands. | | `validate` | Parse and check the configuration. The command to script against: it returns 3 or 4 without starting anything. | -| `init [--force]` | Write a starter configuration file. | -| `install-task [--delay HHHH:MM]` | Register a per-user logon task that runs `gamemode-executorw.exe`, with no window. The configuration path is stored absolute. | -| `uninstall-task` | Remove that task. | +| `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`. | +| `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`. @@ -156,18 +163,33 @@ One log serves two readers, and `log_level` is the dial between them: | `trace` | technician | Raw measurements. | `info` is reserved for what the program is for: a game detected, named or -gone, the watcher starting or stopping, a session recovered at start. Nothing -else competes with those lines. +gone, the watcher starting or stopping, a session recovered at start — and +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` or `commands`: +`watcher`, `game`, `commands` or `setup`: ``` +2026-09-18 00:51:36.740 INFO setup Starter configuration written +2026-09-18 00:51:37.102 INFO setup Logon task registered: it starts the watcher at every logon, with no execution time limit 2026-09-10 17:51:02.433 INFO watcher GameModeExecutor 0.1.0 starting 2026-09-10 17:53:14.080 INFO game Game detected: bf6.exe 2026-09-10 17:58:41.833 INFO game Game no longer detected: bf6.exe ``` +`setup` is written by `init`, `install-task`, `uninstall-task` and `stop`, +whether a person typed them or the installer ran them: a configuration +written, kept or replaced; a task registered, kept, replaced or removed; the +watcher started or stopped. Two processes then write the one file — `stop` +and the watcher it stops — and their lines interleave whole: the file is +opened for appending only, so Windows itself places each write at the end, +and a line is one write. +Those commands open the log where the watcher would — the configuration's +`log_dir` when a configuration can be read, the default location otherwise — +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. + `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: @@ -188,17 +210,19 @@ 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. | -| Logon task | `\GameModeExecutor\Watcher` in Task Scheduler | records the absolute path of the executable | +| Logon task | `\GameModeExecutor\Watcher` in Task Scheduler | records the absolute path of the executable; removed with the package, kept through an upgrade | ## Building and releasing Requires the Rust toolchain, stable, edition 2024. The Windows SDK's `rc.exe` -embeds the icon; without it the build warns and continues. +embeds the icon and the version block each executable carries — the one the +Properties dialog shows, with the commit in *File version*; without it the +build warns and continues. ```powershell .\scripts\build.ps1 # test .\scripts\build.ps1 build # test, then a release build -.\scripts\build.ps1 release # test, build, and the zip archive in dist\ +.\scripts\build.ps1 release # test, build, the zip archive and the installer in dist\ ``` Each mode runs everything the one before it does. `test` is more than @@ -216,10 +240,22 @@ Each mode runs everything the one before it does. `test` is more than header**: a console program and a windowless one cannot be the same file, and getting that backwards is invisible until someone sees a black window at logon. +A release proper is a tag. The version is bumped in `Cargo.toml` in the +release commit, that commit is tagged `vX.Y.Z`, and pushing the tag makes +the release workflow run this same script on a GitHub runner, then publish +the installer, the zip and their SHA-256 checksums as a GitHub release, with +notes listing the commits since the previous tag. Nothing is built or +uploaded by hand. + `release` refuses a dirty tree, checks the commit stamped into the binaries is -the commit being built, stages the bundle, zips it into `dist\`, and refuses to -finish if the archive names the building account or if a task template has -lost the placeholders that make it reusable. +the commit being built, checks the version block each executable carries, +stages the bundle, zips it into `dist\`, builds the Windows Installer package +next to it and runs the SDK's every ICE over that package — a warning fails +the build — and refuses to finish if the archive names the building account +or if a task template has lost the placeholders that make it reusable. The +package is written by `scripts\msi.ps1` from Windows Installer's own +automation; nothing but the SDK is needed, and the validation tools are +unpacked from the SDK on first use. **From VS Code:** `Ctrl+Shift+B` builds, and *Terminal → Run Task* offers the same three plus two for driving an installed watcher — restart it, or follow diff --git a/scripts/build.ps1 b/scripts/build.ps1 index a49a4e5..abe119f 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -70,6 +70,52 @@ function Get-Version { $line.Matches[0].Groups[1].Value } +# Runs the Windows SDK's Internal Consistency Evaluators over a package. +# +# MsiVal2 ships in the SDK as an installer of its own; an administrative +# install (msiexec /a) unpacks it into target\ without elevation. Its ICE +# evaluator, evalcom2.dll, is a COM server the tool creates by ProgID, and +# the SDK's own package registers it under the CLSID of the *old* evalcom.dll, +# which the new one refuses -- measured 2026-09-17; Orca's package has the +# right one. So the CLSID is registered here, for this user only, under +# HKCU\Software\Classes, which needs no elevation. Returns the findings +# (errors and warnings) and how many evaluators ran. +function Invoke-Ice([string] $Package) { + $kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $source = Get-ChildItem (Join-Path $kits '10.0.*\x86\MsiVal2-x86_en-us.msi') -ErrorAction SilentlyContinue | + Sort-Object FullName | Select-Object -Last 1 + if (-not $source) { Fail "the Windows SDK's MsiVal2 package is not under $kits" } + + $val2 = Join-Path $root 'target\msival2' + $tool = Join-Path $val2 'MsiVal2\MsiVal2.exe' + if (-not (Test-Path $tool)) { + $extract = Start-Process msiexec.exe -Wait -PassThru ` + -ArgumentList @('/a', "`"$($source.FullName)`"", '/qn', "TARGETDIR=`"$val2`"") + if ($extract.ExitCode -ne 0 -or -not (Test-Path $tool)) { Fail "cannot unpack MsiVal2 (msiexec /a exited $($extract.ExitCode))" } + } + + $dll = Join-Path $val2 'MsiVal2\evalcom2.dll' + $clsid = '{6E5E1910-8053-4660-B795-6B612E29BC58}' + foreach ($classes in 'HKCU:\Software\Classes', 'HKCU:\Software\Classes\WOW6432Node') { + $server = "$classes\CLSID\$clsid\InProcServer32" + if ((Get-ItemProperty $server -ErrorAction SilentlyContinue).'(default)' -ne $dll) { + New-Item -Path $server -Force | Out-Null + New-Item -Path "$classes\CLSID\$clsid\ProgID" -Force | Out-Null + New-Item -Path "$classes\MSI.EVALCOM2.1\CLSID" -Force | Out-Null + Set-ItemProperty -Path $server -Name '(default)' -Value $dll + Set-ItemProperty -Path $server -Name 'ThreadingModel' -Value 'Apartment' + Set-ItemProperty -Path "$classes\CLSID\$clsid\ProgID" -Name '(default)' -Value 'MSI.EVALCOM2.1' + Set-ItemProperty -Path "$classes\MSI.EVALCOM2.1\CLSID" -Name '(default)' -Value $clsid + } + } + + $output = & $tool $Package (Join-Path $val2 'MsiVal2\darice.cub') 2>&1 | ForEach-Object { "$_" } + if ($output -match 'Fatal Error') { Fail "MsiVal2 could not run: $($output -join ' ')" } + $ran = @($output | ForEach-Object { if ($_ -match '^(ICE\d+)\s') { $Matches[1] } } | Sort-Object -Unique).Count + $findings = @($output | Where-Object { $_ -match '^ICE\d+\s+(ERROR|WARNING)' } | ForEach-Object { $_.Trim() }) + [pscustomobject] @{ Ran = $ran; Findings = $findings } +} + # --- the checks ------------------------------------------------------------- function Invoke-Tests { @@ -186,6 +232,22 @@ function Invoke-Build { Write-Host (" {0,-24} {1}" -f $name, $label[$subsystem]) } + # build.rs writes a version block into each executable: the Properties + # dialog reads it, and Windows Installer's repair and upgrade rules need + # versioned files. A miss is only a warning at build time, so it is + # checked here instead. + Step "Version resources" + $version = Get-Version + foreach ($name in 'gamemode-executor.exe', 'gamemode-executorw.exe') { + $info = (Get-Item (Join-Path $root "target\release\$name")).VersionInfo + if ($info.ProductVersion -ne $version) { Fail "$name carries product version '$($info.ProductVersion)', expected $version" } + if ($info.FileVersionRaw -ne [version] "$version.0") { Fail "$name carries file version $($info.FileVersionRaw), expected $version.0" } + if ($info.OriginalFilename -ne $name) { Fail "$name says its original name is '$($info.OriginalFilename)'" } + if (-not $info.FileDescription) { Fail "$name has no file description" } + $flag = if ($info.IsPrivateBuild) { ' (private build: uncommitted changes)' } else { '' } + Write-Host (" {0,-24} {1}{2}" -f $name, $info.FileVersion, $flag) + } + # The release profile -- opt-level z, LTO, strip, panic = abort -- keeps # each binary near 1.1 MB. Losing it is silent: everything still builds and # runs, only twice as large and unwinding on panic, which the FATAL hook was @@ -213,10 +275,8 @@ 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 - # The FanControl recipe's configuration is the shipped default: it is the - # case this program was built for, and it is inert until its tasks exist. - Copy-Item (Join-Path $root 'docs\recipes\fancontrol-fan-profiles\config.toml') ` - (Join-Path $stage 'config.toml') + # 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 @@ -239,23 +299,19 @@ 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. +will remember this path. Then, from a terminal in this folder: + + gamemode-executor init writes a starter configuration + gamemode-executor install-task starts the watcher now and at every logon + +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. 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. -THE RECIPE THIS BUNDLE IS SET UP FOR - docs\recipes\fancontrol-fan-profiles\ - Quiet fans outside games, a game profile while playing, with FanControl. - config.toml here is already that recipe. It does nothing until you - register its two scheduled tasks -- that folder has a script for it. - - You need two FanControl configurations saved on this machine -- one for - everyday use, one for games, named however you like. The script asks - which is which. They are not shipped and cannot be: a fan curve depends - on the machine's own hardware. - THE TWO EXECUTABLES gamemode-executor.exe the one you talk to. Every command. It answers, then it is done. @@ -275,6 +331,30 @@ https://github.com/Geeooff/GameModeExecutor if (Test-Path $zip) { Remove-Item $zip } Compress-Archive -Path $stage -DestinationPath $zip -CompressionLevel Optimal + # The installer: the same two executables and the license, per-user, no + # elevation, built by scripts\msi.ps1 from Windows Installer's own + # automation. The commit and the documentation link come from the + # binary, as the readme's do. + Step "Windows Installer package" + $msi = Join-Path $root "dist\GameModeExecutor-$version.msi" + $docLink = ($stamp | Select-String -Pattern '^documentation:\s+(\S+)').Matches[0].Groups[1].Value + $commit = ($stamp | Select-String -Pattern '^commit:\s+([0-9a-f]{8})').Matches[0].Groups[1].Value + $package = & (Join-Path $root 'scripts\msi.ps1') -Stage $stage -Version $version -Out $msi ` + -Commit $commit -DocumentationUrl $docLink ` + -Icon (Join-Path $root 'assets\icons\gamemode-active-light.ico') + Write-Host " product $($package.ProductCode)" + Write-Host " package $($package.PackageCode)" + + # Every ICE the SDK ships, and nothing tolerated: a warning here is a + # package that behaves oddly on someone else's machine. + Step "Package validation" + $ice = Invoke-Ice -Package $msi + if ($ice.Findings) { + $ice.Findings | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + Fail "the package has ICE findings" + } + Write-Host " $($ice.Ran) evaluators ran, no errors, no warnings" + # 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" @@ -301,6 +381,7 @@ https://github.com/Geeooff/GameModeExecutor Write-Host "" Write-Host "dist\GameModeExecutor-$version.zip ($([math]::Round((Get-Item $zip).Length / 1KB)) KB)" -ForegroundColor Green + Write-Host "dist\GameModeExecutor-$version.msi ($([math]::Round((Get-Item $msi).Length / 1KB)) KB)" -ForegroundColor Green } # --- go --------------------------------------------------------------------- diff --git a/scripts/msi.ps1 b/scripts/msi.ps1 new file mode 100644 index 0000000..1a61dff --- /dev/null +++ b/scripts/msi.ps1 @@ -0,0 +1,483 @@ +# Builds the Windows Installer package from a staged release folder. +# +# Per-user, no elevation, no UI: the two executables and the license 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. Two custom actions, both the program's own +# commands and both idempotent, finish an install or upgrade: `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. +# +# Written with nothing but Windows Installer's own COM automation and +# makecab, so a stock runner can build it -- docs/design/08-distribution.md +# records the measurements behind every choice here. +# +# Identifiers are deterministic: the product code derives from the version, +# 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] $Version, # x.y.z, from Cargo.toml + [Parameter(Mandatory)] [string] $Out, # the .msi to write + [string] $Commit = 'unknown', + [string] $DocumentationUrl = 'https://github.com/Geeooff/GameModeExecutor', + [string] $Icon = '', # .ico for Programs and Features + # Tests only: a package that is not the real product. It gets its own + # upgrade and product codes and a name that says so, and can be installed + # beside the real one without either seeing the other. + [string] $Family = '' +) +$ErrorActionPreference = 'Stop' + +# Fixed for the life of the product: every version shares it, which is how +# a newer package finds the older installation it replaces. +$UpgradeCode = '{8C4E0B2D-3F6A-4E7B-9A1C-5D2E8F7B6A30}' +$ProductName = 'GameModeExecutor' +$Namespace = [guid] '{2B7D6F1E-9C4A-4D3B-8E5F-1A6C9D0B7E42}' +$Author = 'Geoffrey Vancoetsem' +$Repository = 'https://github.com/Geeooff/GameModeExecutor' + +# A name-based GUID (the SHA-1 construction of RFC 4122 section 4.3), +# braced and uppercase as Windows Installer wants it. +function New-NameGuid([string] $Name) { + $sha = [System.Security.Cryptography.SHA1]::Create() + $ns = $Namespace.ToByteArray() + # RFC 4122 hashes the namespace in network order; .NET stores the first + # three fields little-endian, so swap them first. + [Array]::Reverse($ns, 0, 4); [Array]::Reverse($ns, 4, 2); [Array]::Reverse($ns, 6, 2) + $hash = $sha.ComputeHash($ns + [System.Text.Encoding]::UTF8.GetBytes($Name)) + $bytes = $hash[0..15] + $bytes[6] = ($bytes[6] -band 0x0F) -bor 0x50 # version 5 + $bytes[8] = ($bytes[8] -band 0x3F) -bor 0x80 # RFC 4122 variant + [Array]::Reverse($bytes, 0, 4); [Array]::Reverse($bytes, 4, 2); [Array]::Reverse($bytes, 6, 2) + return '{' + ([guid] [byte[]] $bytes).ToString().ToUpperInvariant() + '}' +} + +if ($Family) { + $UpgradeCode = New-NameGuid "upgrade/$Family" + $ProductName = "GameModeExecutor ($Family)" +} +# The real product's names stay exactly what they were before families +# existed, so its codes do not move. +$scope = if ($Family) { "$Family/" } else { '' } +$ProductCode = New-NameGuid "product/$scope$Version" +$PackageCode = New-NameGuid "package/$scope$Version/$Commit" + +# --- the files --------------------------------------------------------------- +# Keys are Windows Installer identifiers (no hyphens), and the cabinet entries +# carry the same names. Short names must be unique 8.3 names. +$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' } +) +foreach ($f in $files) { + $f.Path = Join-Path $Stage $f.Name + if (-not (Test-Path $f.Path)) { throw "$($f.Name) is not in $Stage" } + $item = Get-Item $f.Path + $f.Size = [int] $item.Length + $raw = $item.VersionInfo.FileVersionRaw + $f.Version = if ($raw -and $raw -ne [version] '0.0.0.0') { $raw.ToString() } else { '' } + $f.Component = 'C_' + ($f.Key -replace '\.', '_') + $f.Guid = New-NameGuid "component/$($f.Name)" +} + +$work = Join-Path ([System.IO.Path]::GetTempPath()) "gamemode-executor-msi-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory $work | Out-Null +try { + # --- the cabinet --------------------------------------------------------- + $ddf = @( + '.OPTION EXPLICIT', + '.Set CabinetNameTemplate=files.cab', + ".Set DiskDirectoryTemplate=$work", + '.Set CompressionType=LZX', + '.Set Cabinet=on', + '.Set Compress=on', + '.Set InfFileName=nul', + '.Set RptFileName=nul' + ) + $sequence = 0 + foreach ($f in $files) { + $sequence++ + $f.Sequence = $sequence + $ddf += "`"$($f.Path)`" $($f.Key)" + } + $ddfPath = Join-Path $work 'files.ddf' + Set-Content -Path $ddfPath -Value $ddf -Encoding ASCII + $null = & makecab.exe /F $ddfPath + $cab = Join-Path $work 'files.cab' + if (-not (Test-Path $cab)) { throw 'makecab produced no cabinet' } + + # --- the database -------------------------------------------------------- + $installer = New-Object -ComObject WindowsInstaller.Installer + function Invoke-Com($object, $method, $arguments) { + return $object.GetType().InvokeMember($method, 'InvokeMethod', $null, $object, [object[]] $arguments) + } + function Get-ComProperty($object, $name, $arguments) { + return $object.GetType().InvokeMember($name, 'GetProperty', $null, $object, [object[]] $arguments) + } + function Set-ComProperty($object, $name, $arguments) { + $object.GetType().InvokeMember($name, 'SetProperty', $null, $object, [object[]] $arguments) | Out-Null + } + if (Test-Path $Out) { Remove-Item $Out -Force } + $db = Invoke-Com $installer 'OpenDatabase' @($Out, 3) # msiOpenDatabaseModeCreate + + function Exec-Sql([string] $sql) { + try { $view = Invoke-Com $db 'OpenView' @($sql) } catch { throw "bad SQL: $sql" } + Invoke-Com $view 'Execute' @() | Out-Null + Invoke-Com $view 'Close' @() | Out-Null + } + # MSI SQL insists on the column list in an INSERT. + $columns = @{ + Property = 'Property, Value' + Directory = 'Directory, Directory_Parent, DefaultDir' + Component = 'Component, ComponentId, Directory_, Attributes, Condition, KeyPath' + File = 'File, Component_, FileName, FileSize, Version, Language, Attributes, Sequence' + MsiFileHash = 'File_, Options, HashPart1, HashPart2, HashPart3, HashPart4' + Feature = 'Feature, Feature_Parent, Title, Description, Display, Level, Directory_, Attributes' + FeatureComponents = 'Feature_, Component_' + Media = 'DiskId, LastSequence, DiskPrompt, Cabinet, VolumeLabel, Source' + InstallExecuteSequence = 'Action, Condition, Sequence' + InstallUISequence = 'Action, Condition, Sequence' + AdminExecuteSequence = 'Action, Condition, Sequence' + AdminUISequence = 'Action, Condition, Sequence' + AdvtExecuteSequence = 'Action, Condition, Sequence' + Upgrade = 'UpgradeCode, VersionMin, VersionMax, Language, Attributes, Remove, ActionProperty' + LaunchCondition = 'Condition, Description' + CustomAction = 'Action, Type, Source, Target, ExtendedType' + Icon = 'Name, Data' + _Validation = 'Table, Column, Nullable, MinValue, MaxValue, KeyTable, KeyColumn, Category, Set, Description' + _Streams = 'Name, Data' + } + function Insert([string] $table, [object[]] $values) { + $marks = ($values | ForEach-Object { '?' }) -join ', ' + $cols = ($columns[$table] -split ', ' | ForEach-Object { "``$_``" }) -join ', ' + try { $view = Invoke-Com $db 'OpenView' @("INSERT INTO ``$table`` ($cols) VALUES ($marks)") } catch { throw "bad INSERT into $table" } + $record = Invoke-Com $installer 'CreateRecord' @($values.Count) + for ($i = 0; $i -lt $values.Count; $i++) { + $v = $values[$i] + if ($v -is [int]) { Set-ComProperty $record 'IntegerData' @(($i + 1), $v) } + elseif ($v -is [string] -and $v.StartsWith('stream:')) { Invoke-Com $record 'SetStream' @(($i + 1), $v.Substring(7)) | Out-Null } + elseif ($null -eq $v -or $v -eq '') { } # stays null + else { Set-ComProperty $record 'StringData' @(($i + 1), [string] $v) } + } + Invoke-Com $view 'Execute' @($record) | Out-Null + Invoke-Com $view 'Close' @() | Out-Null + # Released at once: a record holding a stream keeps the package open, + # and the caller may well hand the package to msiexec next. + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($record) | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($view) | Out-Null + } + + Exec-Sql "CREATE TABLE ``Property`` (``Property`` CHAR(72) NOT NULL, ``Value`` LONGCHAR NOT NULL LOCALIZABLE PRIMARY KEY ``Property``)" + Exec-Sql "CREATE TABLE ``Directory`` (``Directory`` CHAR(72) NOT NULL, ``Directory_Parent`` CHAR(72), ``DefaultDir`` CHAR(255) NOT NULL LOCALIZABLE PRIMARY KEY ``Directory``)" + Exec-Sql "CREATE TABLE ``Component`` (``Component`` CHAR(72) NOT NULL, ``ComponentId`` CHAR(38), ``Directory_`` CHAR(72) NOT NULL, ``Attributes`` SHORT NOT NULL, ``Condition`` CHAR(255), ``KeyPath`` CHAR(72) PRIMARY KEY ``Component``)" + Exec-Sql "CREATE TABLE ``File`` (``File`` CHAR(72) NOT NULL, ``Component_`` CHAR(72) NOT NULL, ``FileName`` CHAR(255) NOT NULL LOCALIZABLE, ``FileSize`` LONG NOT NULL, ``Version`` CHAR(72), ``Language`` CHAR(20), ``Attributes`` SHORT, ``Sequence`` LONG NOT NULL PRIMARY KEY ``File``)" + Exec-Sql "CREATE TABLE ``MsiFileHash`` (``File_`` CHAR(72) NOT NULL, ``Options`` SHORT NOT NULL, ``HashPart1`` LONG NOT NULL, ``HashPart2`` LONG NOT NULL, ``HashPart3`` LONG NOT NULL, ``HashPart4`` LONG NOT NULL PRIMARY KEY ``File_``)" + Exec-Sql "CREATE TABLE ``Feature`` (``Feature`` CHAR(38) NOT NULL, ``Feature_Parent`` CHAR(38), ``Title`` CHAR(64) LOCALIZABLE, ``Description`` CHAR(255) LOCALIZABLE, ``Display`` SHORT, ``Level`` SHORT NOT NULL, ``Directory_`` CHAR(72), ``Attributes`` SHORT NOT NULL PRIMARY KEY ``Feature``)" + Exec-Sql "CREATE TABLE ``FeatureComponents`` (``Feature_`` CHAR(38) NOT NULL, ``Component_`` CHAR(72) NOT NULL PRIMARY KEY ``Feature_``, ``Component_``)" + Exec-Sql "CREATE TABLE ``Media`` (``DiskId`` SHORT NOT NULL, ``LastSequence`` LONG NOT NULL, ``DiskPrompt`` CHAR(64) LOCALIZABLE, ``Cabinet`` CHAR(255), ``VolumeLabel`` CHAR(32), ``Source`` CHAR(72) PRIMARY KEY ``DiskId``)" + foreach ($t in 'InstallExecuteSequence', 'InstallUISequence', 'AdminExecuteSequence', 'AdminUISequence', 'AdvtExecuteSequence') { + Exec-Sql "CREATE TABLE ``$t`` (``Action`` CHAR(72) NOT NULL, ``Condition`` CHAR(255), ``Sequence`` SHORT PRIMARY KEY ``Action``)" + } + Exec-Sql "CREATE TABLE ``Upgrade`` (``UpgradeCode`` CHAR(38) NOT NULL, ``VersionMin`` CHAR(20), ``VersionMax`` CHAR(20), ``Language`` CHAR(255), ``Attributes`` LONG NOT NULL, ``Remove`` CHAR(255), ``ActionProperty`` CHAR(72) NOT NULL PRIMARY KEY ``UpgradeCode``, ``VersionMin``, ``VersionMax``, ``Language``, ``Attributes``)" + Exec-Sql "CREATE TABLE ``LaunchCondition`` (``Condition`` CHAR(255) NOT NULL, ``Description`` CHAR(255) NOT NULL LOCALIZABLE PRIMARY KEY ``Condition``)" + Exec-Sql "CREATE TABLE ``CustomAction`` (``Action`` CHAR(72) NOT NULL, ``Type`` SHORT NOT NULL, ``Source`` CHAR(72), ``Target`` CHAR(255), ``ExtendedType`` LONG PRIMARY KEY ``Action``)" + Exec-Sql "CREATE TABLE ``Icon`` (``Name`` CHAR(72) NOT NULL, ``Data`` OBJECT NOT NULL PRIMARY KEY ``Name``)" + Exec-Sql "CREATE TABLE ``_Validation`` (``Table`` CHAR(32) NOT NULL, ``Column`` CHAR(32) NOT NULL, ``Nullable`` CHAR(4) NOT NULL, ``MinValue`` LONG, ``MaxValue`` LONG, ``KeyTable`` CHAR(255), ``KeyColumn`` SHORT, ``Category`` CHAR(32), ``Set`` CHAR(255), ``Description`` CHAR(255) PRIMARY KEY ``Table``, ``Column``)" + + # Per-user takes all three, measured 2026-09-17 on an administrator + # account running unelevated: the summary stream's "no elevation + # required" bit (below) lets the install run without a prompt; + # ALLUSERS=2 with MSIINSTALLPERUSER=1 -- Single Package Authoring, as + # documented -- resolves to a per-user install and redirects + # ProgramFilesFolder to %LOCALAPPDATA%\Programs. With the bit alone the + # folder stays at C:\Program Files (x86); with ALLUSERS=2 alone the + # install turns per-machine for an administrator and fails unelevated. + # The log's "MSIINSTALLPERUSER ... Ignoring" line is misleading: the + # property still decides how ALLUSERS=2 resolves. The ARP entries are + # what Programs and Features shows. + $properties = [ordered] @{ + ProductCode = $ProductCode + UpgradeCode = $UpgradeCode + ProductName = $ProductName + ProductVersion = $Version + ProductLanguage = '1033' + Manufacturer = $Author + ALLUSERS = '2' + MSIINSTALLPERUSER = '1' + ARPCOMMENTS = 'Runs the executables you configure when a game starts and when it stops.' + ARPURLINFOABOUT = $Repository + ARPHELPLINK = $DocumentationUrl + ARPNOMODIFY = '1' + SecureCustomProperties = 'PREVIOUSVERSIONS;NEWERVERSIONDETECTED' + } + if ($Icon -and (Test-Path $Icon)) { + Insert 'Icon' @('GameModeExecutor.ico', "stream:$Icon") + $properties.ARPPRODUCTICON = 'GameModeExecutor.ico' + } + foreach ($k in $properties.Keys) { Insert 'Property' @($k, $properties[$k]) } + + # ProgramFilesFolder, which Windows Installer redirects to + # %LOCALAPPDATA%\Programs for a per-user package -- the documented + # mechanism, and measured 2026-09-17. Not ProgramFiles64Folder: that one + # stays at C:\Program Files, where an unelevated install cannot write. And + # not the profile folder spelled out: ICE38, ICE64 and ICE91 then demand + # registry key paths and RemoveFile rows meant for packages that might be + # installed per machine, which this one never is. + Insert 'Directory' @('TARGETDIR', $null, 'SourceDir') + Insert 'Directory' @('ProgramFilesFolder', 'TARGETDIR', 'PFiles') + Insert 'Directory' @('INSTALLDIR', 'ProgramFilesFolder', 'GAMEMO~1|GameModeExecutor') + + Insert 'Feature' @('Main', $null, 'GameModeExecutor', 'The watcher and its command line.', 1, 1, 'INSTALLDIR', 0) + foreach ($f in $files) { + # Attributes 0, a 32-bit component although the executables are + # 64-bit: the bit only governs registry reflection, which nothing + # here uses, and ICE80 refuses 64-bit components in ProgramFilesFolder. + Insert 'Component' @($f.Component, $f.Guid, 'INSTALLDIR', 0, $null, $f.Key) + $name = if ($f.Short -eq $f.Name) { $f.Name } else { "$($f.Short)|$($f.Name)" } + $language = if ($f.Version) { '1033' } else { $null } + # 512: vital -- the install fails rather than continues without it. + Insert 'File' @($f.Key, $f.Component, $name, $f.Size, $f.Version, $language, 512, $f.Sequence) + Insert 'FeatureComponents' @('Main', $f.Component) + if (-not $f.Version) { + # Unversioned files are compared by hash on repair and upgrade, + # not by date, when the package carries one. + $hash = $installer.FileHash([string] $f.Path, [int] 0) + $parts = 1..4 | ForEach-Object { [int] $hash.IntegerData([int] $_) } + Insert 'MsiFileHash' @($f.Key, 0, $parts[0], $parts[1], $parts[2], $parts[3]) + } + } + Insert 'Media' @(1, $sequence, $null, '#files.cab', $null, $null) + Insert '_Streams' @('files.cab', "stream:$cab") + + # Older versions are removed first -- RemoveExistingProducts right after + # InstallInitialize -- so the new files never land beside old ones. A + # newer version already installed stops the install before it starts. + Insert 'Upgrade' @($UpgradeCode, '0.0.0', $Version, $null, 256, $null, 'PREVIOUSVERSIONS') # 256: min inclusive, max exclusive + Insert 'Upgrade' @($UpgradeCode, $Version, $null, $null, 2, $null, 'NEWERVERSIONDETECTED') # 2: detect only; min exclusive + Insert 'LaunchCondition' @('NOT NEWERVERSIONDETECTED', 'A newer version of GameModeExecutor is already installed.') + + # 1042 = 18 (run an executable from the File table) + 1024 (deferred, + # in the install script, after the files are on disk). Impersonated, as + # deferred actions are by default, so they act as the user -- which is + # the only way a per-user package may run anything. The windowless twin, + # because Windows Installer does not hide an action's console and the + # console binary would flash one twice (seen 2026-09-18). + Insert 'CustomAction' @('InitConfig', 1042, 'gamemode_executorw.exe', 'init', $null) + Insert 'CustomAction' @('RegisterTask', 1042, 'gamemode_executorw.exe', 'install-task', $null) + # The task is the package's infrastructure, not the user's data: left + # behind, it would start a missing executable at every logon and fail + # (the maintainer's remark on 2026-09-18). Removed on an uninstall, from + # the executable it runs before RemoveFiles takes it; kept through an + # upgrade, which re-registers nothing and so keeps a delay or a path the + # user chose. + Insert 'CustomAction' @('UnregisterTask', 1042, 'gamemode_executorw.exe', 'uninstall-task', $null) + # 98 = 34 (run a command line in a directory from the Directory table) + # + 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) + + $sequences = @{ + InstallExecuteSequence = @( + @('FindRelatedProducts', 25), @('LaunchConditions', 100), @('ValidateProductID', 700), + @('CostInitialize', 800), @('FileCost', 900), @('CostFinalize', 1000), + @('StopWatcher', 1300), @('InstallValidate', 1400), @('InstallInitialize', 1500), + @('RemoveExistingProducts', 1510), + @('ProcessComponents', 1600), @('UnpublishFeatures', 1800), + @('UnregisterTask', 3400), @('RemoveFiles', 3500), @('InstallFiles', 4000), + @('InitConfig', 4100), @('RegisterTask', 4200), + @('RegisterUser', 6000), @('RegisterProduct', 6100), + @('PublishFeatures', 6300), @('PublishProduct', 6400), + @('InstallFinalize', 6600) + ) + InstallUISequence = @( + @('FindRelatedProducts', 25), @('LaunchConditions', 100), + @('CostInitialize', 800), @('FileCost', 900), @('CostFinalize', 1000), @('ExecuteAction', 1300) + ) + AdminExecuteSequence = @( + @('CostInitialize', 800), @('FileCost', 900), @('CostFinalize', 1000), + @('InstallValidate', 1400), @('InstallInitialize', 1500), + @('InstallAdminPackage', 3900), @('InstallFiles', 4000), @('InstallFinalize', 6600) + ) + AdminUISequence = @( + @('CostInitialize', 800), @('FileCost', 900), @('CostFinalize', 1000), @('ExecuteAction', 1300) + ) + AdvtExecuteSequence = @( + @('CostInitialize', 800), @('CostFinalize', 1000), @('InstallValidate', 1400), @('InstallInitialize', 1500), + @('PublishFeatures', 6300), @('PublishProduct', 6400), @('InstallFinalize', 6600) + ) + } + # 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 + # 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 + # (2026-09-18) logged a second, empty stop for nothing. + $conditions = @{ + InitConfig = 'NOT Installed' + RegisterTask = 'NOT Installed' + StopWatcher = '(REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE) OR PREVIOUSVERSIONS' + UnregisterTask = 'REMOVE="ALL" AND NOT UPGRADINGPRODUCTCODE' + } + foreach ($t in $sequences.Keys) { + foreach ($row in $sequences[$t]) { + $condition = if ($conditions.ContainsKey($row[0])) { $conditions[$row[0]] } else { $null } + Insert $t @($row[0], $condition, [int] $row[1]) + } + } + + # The column specifications ICE03 checks against, for the tables above + # only. Copied from the SDK's schema database (orca.dat) on 2026-09-17. + $validation = @' +_Validation | Table | N | | | | | Identifier | | Name of table +_Validation | Column | N | | | | | Identifier | | Name of column +_Validation | Description | Y | | | | | Text | | Description of column +_Validation | Set | Y | | | | | Text | | Set of values that are permitted +_Validation | Category | Y | | | | | | Text;Formatted;Template;Condition;Guid;Path;Version;Language;Identifier;Binary;UpperCase;LowerCase;Filename;Paths;AnyPath;WildCardFilename;RegPath;KeyFormatted;CustomSource;Property;Cabinet;Shortcut;URL | String category +_Validation | KeyColumn | Y | 1 | 32 | | | | | Column to which foreign key connects +_Validation | KeyTable | Y | | | | | Identifier | | For foreign key, Name of table to which data must link +_Validation | MaxValue | Y | -2147483647 | 2147483647 | | | | | Maximum value allowed +_Validation | MinValue | Y | -2147483647 | 2147483647 | | | | | Minimum value allowed +_Validation | Nullable | N | | | | | | Y;N;@ | Whether the column is nullable +AdminExecuteSequence | Action | N | | | | | Identifier | | Name of action to invoke, either built-in or custom. +AdminExecuteSequence | Condition | Y | | | | | Condition | | Optional expression which skips the action if evaluates to expFalse.If the expression syntax is invalid, the engine will terminate, returning iesBadActionData. +AdminExecuteSequence | Sequence | Y | -4 | 32767 | | | | | Number that determines the sort order in which the actions are to be executed. Leave blank to suppress action. +AdminUISequence | Action | N | | | | | Identifier | | Name of action to invoke, either built-in or custom. +AdminUISequence | Condition | Y | | | | | Condition | | Optional expression which skips the action if evaluates to expFalse.If the expression syntax is invalid, the engine will terminate, returning iesBadActionData. +AdminUISequence | Sequence | Y | -4 | 32767 | | | | | Number that determines the sort order in which the actions are to be executed. Leave blank to suppress action. +AdvtExecuteSequence | Action | N | | | | | Identifier | | Name of action to invoke, either built-in or custom. +AdvtExecuteSequence | Condition | Y | | | | | Condition | | Optional expression which skips the action if evaluates to expFalse.If the expression syntax is invalid, the engine will terminate, returning iesBadActionData. +AdvtExecuteSequence | Sequence | Y | -4 | 32767 | | | | | Number that determines the sort order in which the actions are to be executed. Leave blank to suppress action. +Component | Attributes | N | | | | | | | Remote execution option, one of irsEnum +CustomAction | Action | N | | | | | Identifier | | Primary key, name of action, normally appears in sequence table unless private use. +CustomAction | ExtendedType | Y | 0 | 2147483647 | | | | | The numeric custom action type info flags. +CustomAction | Source | Y | | | | | CustomSource | | The table reference of the source of the code. +CustomAction | Target | Y | | | | | Formatted | | Excecution parameter, depends on the type of custom action +CustomAction | Type | N | 1 | 32767 | | | | | The numeric custom action type, consisting of source location, code type, entry, option flags. +Component | Component | N | | | | | Identifier | | Primary key used to identify a particular component record. +Component | ComponentId | Y | | | | | Guid | | A string GUID unique to this component, version, and language. +Component | Condition | Y | | | | | Condition | | A conditional statement that will disable this component if the specified condition evaluates to the 'True' state. If a component is disabled, it will not be installed, regardless of the 'Action' state associated with the component. +Component | Directory_ | N | | | Directory | 1 | Identifier | | Required key of a Directory table record. This is actually a property name whose value contains the actual path, set either by the AppSearch action or with the default setting obtained from the Directory table. +Component | KeyPath | Y | | | File;Registry;ODBCDataSource | 1 | Identifier | | Either the primary key into the File table, Registry table, or ODBCDataSource table. This extract path is stored when the component is installed, and is used to detect the presence of the component and to return the path to it. +Directory | DefaultDir | N | | | | | DefaultDir | | The default sub-path under parent's path. +Directory | Directory | N | | | | | Identifier | | Unique identifier for directory entry, primary key. If a property by this name is defined, it contains the full path to the directory. +Directory | Directory_Parent | Y | | | Directory | 1 | Identifier | | Reference to the entry in this table specifying the default parent directory. A record parented to itself or with a Null parent represents a root of the install tree. +Feature | Attributes | N | | | | | | 0;1;2;4;5;6;8;9;10;16;17;18;20;21;22;24;25;26;32;33;34;36;37;38;48;49;50;52;53;54 | Feature attributes +Feature | Description | Y | | | | | Text | | Longer descriptive text describing a visible feature item. +Feature | Directory_ | Y | | | Directory | 1 | UpperCase | | The name of the Directory that can be configured by the UI. A non-null value will enable the browse button. +Feature | Display | Y | 0 | 32767 | | | | | Numeric sort order, used to force a specific display ordering. +Feature | Feature | N | | | | | Identifier | | Primary key used to identify a particular feature record. +Feature | Feature_Parent | Y | | | Feature | 1 | Identifier | | Optional key of a parent record in the same table. If the parent is not selected, then the record will not be installed. Null indicates a root item. +Feature | Level | N | 0 | 32767 | | | | | The install level at which record will be initially selected. An install level of 0 will disable an item and prevent its display. +Feature | Title | Y | | | | | Text | | Short text identifying a visible feature item. +FeatureComponents | Component_ | N | | | Component | 1 | Identifier | | Foreign key into Component table. +FeatureComponents | Feature_ | N | | | Feature | 1 | Identifier | | Foreign key into Feature table. +File | Attributes | Y | 0 | 32767 | | | | | Integer containing bit flags representing file attributes (with the decimal value of each bit position in parentheses) +File | Component_ | N | | | Component | 1 | Identifier | | Foreign key referencing Component that controls the file. +File | File | N | | | | | Identifier | | Primary key, non-localized token, must match identifier in cabinet. For uncompressed files, this field is ignored. +File | FileName | N | | | | | Filename | | File name used for installation, may be localized. This may contain a "short name|long name" pair. +File | FileSize | N | 0 | 2147483647 | | | | | Size of file in bytes (long integer). +File | Language | Y | | | | | Language | | List of decimal language Ids, comma-separated if more than one. +File | Sequence | N | 1 | 32767 | | | | | Sequence with respect to the media images; order must track cabinet order. +File | Version | Y | | | File | 1 | Version | | Version string for versioned files; Blank for unversioned files. +Icon | Data | N | | | | | Binary | | Binary stream. The binary icon data in PE (.DLL or .EXE) or icon (.ICO) format. +Icon | Name | N | | | | | Identifier | | Primary key. Name of the icon file. +InstallExecuteSequence | Action | N | | | | | Identifier | | Name of action to invoke, either built-in or custom. +InstallExecuteSequence | Condition | Y | | | | | Condition | | Optional expression which skips the action if evaluates to expFalse.If the expression syntax is invalid, the engine will terminate, returning iesBadActionData. +InstallExecuteSequence | Sequence | Y | -4 | 32767 | | | | | Number that determines the sort order in which the actions are to be executed. Leave blank to suppress action. +InstallUISequence | Action | N | | | | | Identifier | | Name of action to invoke, either built-in or custom. +InstallUISequence | Condition | Y | | | | | Condition | | Optional expression which skips the action if evaluates to expFalse.If the expression syntax is invalid, the engine will terminate, returning iesBadActionData. +InstallUISequence | Sequence | Y | -4 | 32767 | | | | | Number that determines the sort order in which the actions are to be executed. Leave blank to suppress action. +LaunchCondition | Condition | N | | | | | Condition | | Expression which must evaluate to TRUE in order for install to commence. +LaunchCondition | Description | N | | | | | Formatted | | Localizable text to display when condition fails and install must abort. +Media | Cabinet | Y | | | | | Cabinet | | If some or all of the files stored on the media are compressed in a cabinet, the name of that cabinet. +Media | DiskId | N | 1 | 32767 | | | | | Primary key, integer to determine sort order for table. +Media | DiskPrompt | Y | | | | | Text | | Disk name: the visible text actually printed on the disk. This will be used to prompt the user when this disk needs to be inserted. +Media | LastSequence | N | 0 | 32767 | | | | | File sequence number for the last file for this media. +Media | Source | Y | | | | | Property | | The property defining the location of the cabinet file. +Media | VolumeLabel | Y | | | | | Text | | The label attributed to the volume. +MsiFileHash | File_ | N | | | File | 1 | Identifier | | Primary key, foreign key into File table referencing file with this hash +MsiFileHash | HashPart1 | N | | | | | | | Size of file in bytes (long integer). +MsiFileHash | HashPart2 | N | | | | | | | Size of file in bytes (long integer). +MsiFileHash | HashPart3 | N | | | | | | | Size of file in bytes (long integer). +MsiFileHash | HashPart4 | N | | | | | | | Size of file in bytes (long integer). +MsiFileHash | Options | N | 0 | 32767 | | | | | Various options and attributes for this hash. +Property | Property | N | | | | | Identifier | | Name of property, uppercase if settable by launcher or loader. +Property | Value | N | | | | | Text | | String value for property. Never null or empty. +Upgrade | ActionProperty | N | | | | | UpperCase | | The property to set when a product in this set is found. +Upgrade | Attributes | N | 0 | 2147483647 | | | | | The attributes of this product set. +Upgrade | Language | Y | | | | | Language | | A comma-separated list of languages for either products in this set or products not in this set. +Upgrade | Remove | Y | | | | | Formatted | | The list of features to remove when uninstalling a product from this set. The default is "ALL". +Upgrade | UpgradeCode | N | | | | | Guid | | The UpgradeCode GUID belonging to the products in this set. +Upgrade | VersionMax | Y | | | | | Text | | The maximum ProductVersion of the products in this set. The set may or may not include products with this particular version. +Upgrade | VersionMin | Y | | | | | Text | | The minimum ProductVersion of the products in this set. The set may or may not include products with this particular version. +'@ + foreach ($line in $validation -split "`n") { + $line = $line.Trim() + if (-not $line) { continue } + $c = @($line -split ' \| ' | ForEach-Object { $_.Trim() }) + if ($c.Count -ne 10) { throw "_Validation row has $($c.Count) fields: $line" } + # Built by index: a subexpression yielding nothing would vanish from an + # array literal and shift every column after it. + $row = [object[]]::new(10) + foreach ($i in 0, 1, 2, 5, 7, 8, 9) { $row[$i] = $c[$i] } + foreach ($i in 3, 4, 6) { if ($c[$i]) { $row[$i] = [int] $c[$i] } } + try { Insert '_Validation' $row } catch { throw "_Validation row rejected: $line`n$($_.Exception.Message)" } + } + + # Summary information. WordCount: 2 = compressed sources, 8 = elevated + # privileges not required -- the bit that makes the package per-user. + # Template names the platform, PageCount the schema, Revision is the + # package code. The rest is what Explorer's Details tab shows a person + # who right-clicks the file: the SDK's customary title, "Installation + # Database", says what the file is to a tool and nothing to them, so + # the title names the product and the subject says what it does. The + # times are set because Explorer otherwise shows the file's creation + # time, which NTFS tunnels from the build before when a file of the + # same name was there seconds earlier. + $si = Get-ComProperty $db 'SummaryInformation' @(20) + $now = (Get-Date).ToUniversalTime() + $summary = [ordered] @{ + 2 = "$ProductName $Version installer" + 3 = 'Runs the executables you configure when a game starts and when it stops.' + 4 = $Author + 5 = "Installer; $ProductName; Windows; games" + 6 = "Built from commit $Commit. Documentation: $DocumentationUrl" + 7 = 'x64;1033' + 9 = $PackageCode + 12 = $now + 13 = $now + 14 = 500 + 15 = 10 + 18 = 'GameModeExecutor scripts\msi.ps1, over Windows Installer automation' + 19 = 0 + } + # Enumerated, not indexed: an integer index into an ordered dictionary is + # a position, not a key. + foreach ($e in $summary.GetEnumerator()) { Set-ComProperty $si 'Property' @([int] $e.Key, $e.Value) } + Invoke-Com $si 'Persist' @() | Out-Null + Invoke-Com $db 'Commit' @() | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($si) | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($db) | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($installer) | Out-Null +} finally { + # Whatever the runtime still holds on the package goes now, not at some + # later collection: the file must be free when this script returns. + [GC]::Collect(); [GC]::WaitForPendingFinalizers() + Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue +} + +[pscustomobject] @{ + Path = $Out + ProductCode = $ProductCode + UpgradeCode = $UpgradeCode + PackageCode = $PackageCode + Size = (Get-Item $Out).Length +} diff --git a/scripts/release-notes.ps1 b/scripts/release-notes.ps1 new file mode 100644 index 0000000..1b0f14d --- /dev/null +++ b/scripts/release-notes.ps1 @@ -0,0 +1,63 @@ +# Writes the release notes for a tag, from what the release build produced: +# the documentation link the binary carries, the checksums of the two +# artefacts, and the commits since the previous tag. Run by the release +# workflow after scripts\build.ps1 release; nothing in it needs GitHub. +# +# .\scripts\release-notes.ps1 -Tag v0.1.0 writes dist\notes.md and dist\SHA256SUMS.txt +param( + [Parameter(Mandatory)] [string] $Tag +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +$dist = Join-Path $root 'dist' + +if ($Tag -notmatch '^v(\d+\.\d+\.\d+)$') { throw "tag `"$Tag`" is not vX.Y.Z" } +$version = $Matches[1] +$msi = Join-Path $dist "GameModeExecutor-$version.msi" +$zip = Join-Path $dist "GameModeExecutor-$version.zip" +foreach ($artefact in $msi, $zip) { + if (-not (Test-Path $artefact)) { throw "$artefact is missing; run scripts\build.ps1 release first" } +} + +# Checksums, in the shape sha256sum reads back. +$sums = foreach ($artefact in $msi, $zip) { + '{0} {1}' -f (Get-FileHash $artefact -Algorithm SHA256).Hash.ToLower(), (Split-Path $artefact -Leaf) +} +Set-Content -Path (Join-Path $dist 'SHA256SUMS.txt') -Value $sums -Encoding ascii + +# The link names the commit the binaries were built from; asked of the +# binary so the notes cannot disagree with it. +$stamp = & (Join-Path $dist "GameModeExecutor-$version\gamemode-executor.exe") --version +$docs = ($stamp | Select-String -Pattern '^documentation:\s+(\S+)').Matches[0].Groups[1].Value + +# The previous tag by version order, not by date. Indexed rather than piped +# into Select-Object -First, which stops the upstream command. +$tags = @(& git -C $root tag --sort=-v:refname | Where-Object { $_ -ne $Tag -and $_ -match '^v\d+\.\d+\.\d+$' }) +$previous = if ($tags.Count) { $tags[0] } else { $null } +$changes = if ($previous) { + $log = @(& git -C $root log --format='- %s' "$previous..$Tag") + "## Changes since $previous`n`n" + ($log -join "`n") +} else { + "## Changes`n`nFirst public release." +} + +$lines = @( + 'Runs the executables you configure when a game starts and when it stops. Windows 10 and 11.', + '', + '## Install', + '', + "- **GameModeExecutor-$version.msi** -- the installer. Per user, no administrator prompt, into ``%LOCALAPPDATA%\Programs\GameModeExecutor``. Then ``gamemode-executor init``, edit the file it wrote, ``gamemode-executor install-task``.", + "- **GameModeExecutor-$version.zip** -- the same executables, to unpack wherever you like.", + '', + "[Documentation for this exact build]($docs).", + '', + '## SHA-256', + '', + '```' +) + $sums + @( + '```', + '', + $changes +) +Set-Content -Path (Join-Path $dist 'notes.md') -Value $lines -Encoding utf8 +Get-Content (Join-Path $dist 'notes.md') diff --git a/src/bin/gamemode-executorw.rs b/src/bin/gamemode-executorw.rs index 0b165b4..c49bb99 100644 --- a/src/bin/gamemode-executorw.rs +++ b/src/bin/gamemode-executorw.rs @@ -1,55 +1,34 @@ #![windows_subsystem = "windows"] -//! The watcher with no console, for the logon task. +//! The same program with no console, for the logon task and the installer. //! -//! The twin of `gamemode-executor.exe`, running the same watcher from the same +//! The twin of `gamemode-executor.exe`: the same commands from the same //! library, differing only in subsystem. The two subsystems cannot live in one //! file, which is why Python ships `python.exe` and `pythonw.exe`, and why this //! exists rather than a flag. //! -//! The split keeps a promise. A shell does not wait for a Windows-subsystem -//! process, so had the whole program moved here, `validate`'s exit code would -//! have stopped reaching scripts -- silently, which is the worst way for a -//! contract to break. Every command other than watching therefore stays in the -//! console binary, where a shell still waits, pipes still work and exit codes -//! still arrive. +//! What the subsystem changes is who waits and who reads. A shell does not +//! wait for a Windows-subsystem process, so a script that ran `validate` +//! through this binary would get its exit code before the verdict, and +//! nothing this binary prints goes anywhere. That is why people type the +//! console one and why the documentation only ever names it. This one is for +//! the two callers that have no console to give: the logon task, which runs +//! the watcher, and the installer, which runs `init` and `install-task` -- +//! Windows Installer starts an executable action without hiding its console, +//! and the console binary flashed a window twice at the end of every install, +//! seen on 2026-09-18. The commands log what they did, so nothing is lost by +//! not printing it; the exit code is what the caller records. -use std::path::PathBuf; - -use anyhow::Result; use clap::Parser; -use game_mode_executor::config::{self, Config}; -use game_mode_executor::{exit, service}; - -#[derive(Parser, Debug)] -#[command( - name = "gamemode-executorw", - version = game_mode_executor::build_info::VERSION, - long_version = game_mode_executor::build_info::LONG_VERSION, - about = "Watches for games with no console. Use gamemode-executor.exe for every other command." -)] -struct Cli { - /// Path to the configuration file. Defaults to config.toml next to the - /// executable, then %APPDATA%\GameModeExecutor\config.toml. - #[arg(short, long, value_name = "PATH")] - config: Option, - - /// Override general.log_level. - #[arg(long, value_name = "LEVEL")] - log_level: Option, - - /// Accepted and ignored, so a task registered against the console binary - /// keeps working if it is repointed here by hand. - #[arg(long, hide = true)] - hidden: bool, -} +use game_mode_executor::{cli, exit}; fn main() -> std::process::ExitCode { - match run() { + match cli::run(cli::Cli::parse(), false) { Ok(()) => std::process::ExitCode::SUCCESS, Err(error) => { - // There is no console to print to. The log file has the detail, and - // the exit code is what Task Scheduler records and shows. + // There is no console to print to. The log file has the detail + // when the command got as far as opening one, and the exit code + // is what Task Scheduler and Windows Installer record. tracing::error!( target: game_mode_executor::logging::target::WATCHER, error = %format!("{error:#}"), @@ -59,16 +38,3 @@ fn main() -> std::process::ExitCode { } } } - -fn run() -> Result<()> { - let cli = Cli::parse(); - let path = match cli.config { - Some(path) => path, - None => config::default_path()?, - }; - let config = Config::load(&path)?; - let level = cli - .log_level - .unwrap_or_else(|| config.general.log_level.clone()); - service::serve(config, &path, &level, false) -} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..21bc7ab --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,462 @@ +//! The command line, shared by both executables. +//! +//! `gamemode-executor.exe` and `gamemode-executorw.exe` parse the same +//! arguments and run the same commands; they differ in subsystem alone. The +//! console one is the one people type, because a shell waits for it and its +//! output and exit code arrive where a person or a script can see them. The +//! windowless one is what the logon task and the installer run: it prints +//! nothing, there is nowhere to print, and its exit code is the verdict. +//! Windows Installer starts an executable action without hiding its +//! console, so the console binary would flash a window twice at the end of +//! every install -- seen on 2026-09-18 -- which is why the installer runs +//! `init` and `install-task` through the twin. +//! +//! `init`, `install-task`, `uninstall-task` and `stop` write what they did to +//! the log, under `setup`, at `info`: the log then says who did what to this +//! machine and when, whether a person typed it or the installer ran it. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand, ValueEnum}; + +use crate::config::{self, Config}; +use crate::detect::known_games::KnownGames; +use crate::detect::presence_writer; +use crate::detect::process::Snapshot; +use crate::{actions, build_info, detect, logging, marker, purge, service, task}; + +#[derive(Parser, Debug)] +#[command( + name = "gamemode-executor", + version = build_info::VERSION, + long_version = build_info::LONG_VERSION, + about, + long_about = None +)] +pub struct Cli { + /// Path to the configuration file. Defaults to config.toml next to the + /// executable, then %APPDATA%\GameModeExecutor\config.toml. + #[arg(short, long, global = true, value_name = "PATH")] + pub config: Option, + + /// Override general.log_level. + #[arg(long, global = true, value_name = "LEVEL")] + pub log_level: Option, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Watch for games and run the configured actions (default). + /// + /// For an unattended instance use `gamemode-executorw.exe`, which is the + /// same watcher with no console at all. `install-task` registers that one. + Run { + /// Accepted and ignored. A task registered before this binary had a + /// windowless twin still passes it, and refusing it would stop that + /// task dead at the next logon. + #[arg(long, hide = true)] + hidden: bool, + }, + /// Print what the detectors currently see, then exit. + Status, + /// Run one set of actions immediately, without any detection. + Trigger { + #[arg(value_enum)] + event: TriggerEvent, + }, + /// Ask whether Windows knows a given executable as a game. + Check { + /// Full path of an executable. Omit when using --pid. + path: Option, + /// Inspect a running process instead, by process id. Shows what the + /// naming code actually reads, which is the only way to tell a missing + /// entry from an unreadable process. + #[arg(long, conflicts_with = "path")] + pid: Option, + }, + /// Check that the configuration file is valid. + Validate, + /// Write the starter configuration file. One already there is kept. + Init { + /// Overwrite an existing file. + #[arg(long)] + force: bool, + }, + /// Register a per-user logon task that starts the watcher hidden, and + /// start it now. A task already registered is kept. + InstallTask { + /// Delay after logon, e.g. `15s` or `1m`. + #[arg(long, default_value = "15s")] + delay: String, + /// Replace a task that is already registered. + #[arg(long)] + force: bool, + }, + /// Remove the logon task. + UninstallTask, + /// 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, + /// 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. + Purge { + /// Do not ask; for scripts. + #[arg(long)] + yes: bool, + }, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum TriggerEvent { + Start, + Stop, +} + +/// Run what the command line asks. `console` says whether this process has +/// one to write to; the log file is written either way. +pub fn run(cli: Cli, console: bool) -> Result<()> { + match cli.command { + Some(Command::Init { force }) => { + setup_logging(cli.config.as_deref(), cli.log_level.as_deref(), console)?; + let path = match cli.config { + Some(path) => path, + None => config::starter_path()?, + }; + config::write_starter(&path, force)?; + return Ok(()); + } + Some(Command::InstallTask { delay, force }) => { + setup_logging(cli.config.as_deref(), cli.log_level.as_deref(), console)?; + let delay = humantime::parse_duration(&delay) + .with_context(|| format!("cannot read `{delay}` as a delay"))?; + let path = resolve_config_path(cli.config)?; + task::install(&path, delay, force)?; + return Ok(()); + } + Some(Command::UninstallTask) => { + setup_logging(cli.config.as_deref(), cli.log_level.as_deref(), console)?; + return task::uninstall(); + } + Some(Command::Stop) => { + setup_logging(cli.config.as_deref(), cli.log_level.as_deref(), console)?; + service::stop()?; + return Ok(()); + } + Some(Command::Check { path, pid }) => return check(path.as_deref(), pid), + Some(Command::Purge { yes }) => return purge_command(cli.config, yes), + _ => {} + } + + let path = resolve_config_path(cli.config)?; + let config = Config::load(&path)?; + let level = cli + .log_level + .unwrap_or_else(|| config.general.log_level.clone()); + + match cli.command { + Some(Command::Validate) => { + println!("Configuration `{}` is valid.", path.display()); + Ok(()) + } + Some(Command::Status) => { + logging::init(&level, None, console)?; + status() + } + Some(Command::Trigger { event }) => { + logging::init(&level, None, console)?; + // The commands and nothing else: no detection, no session, no + // marker. A trigger is for testing what the commands do. + let (label, actions) = match event { + TriggerEvent::Start => ("game_start", &config.on_game_start), + TriggerEvent::Stop => ("game_stop", &config.on_game_stop), + }; + actions::run_all(actions, &actions::ActionContext::new(label, None)); + Ok(()) + } + _ => service::serve(config, &path, &level, console), + } +} + +/// 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 +/// lose the line that says what was done. +fn setup_logging(explicit: Option<&Path>, level: Option<&str>, console: bool) -> Result<()> { + let config = resolve_config_path(explicit.map(Path::to_path_buf)) + .ok() + .and_then(|path| Config::load(&path).ok()); + let level = level + .map(str::to_owned) + .or_else(|| { + config + .as_ref() + .map(|config| config.general.log_level.clone()) + }) + .unwrap_or_else(|| "info".to_owned()); + let dir = config + .as_ref() + .and_then(|config| config.general.log_dir.clone()) + .or_else(|| config::local_dir().map(|dir| dir.join("logs"))); + logging::init(&level, dir.as_deref(), console) +} + +fn resolve_config_path(explicit: Option) -> Result { + match explicit { + Some(path) => Ok(path), + None => config::default_path(), + } +} + +/// Everything the program left on this machine, removed on request. +/// +/// The configuration is read for the log's location and nothing else, so a +/// broken one does not stop the purge -- a broken configuration is a fine +/// reason to want one. +fn purge_command(explicit_config: Option, yes: bool) -> Result<()> { + let path = resolve_config_path(explicit_config)?; + let config = Config::load(&path).ok(); + + // The one refusal: a purge mid-game would leave the gaming configuration + // on with nothing left to restore it. + if let Ok(exe) = presence_writer::registered_exe() + && presence_writer::running_pid(&exe).is_some() + { + anyhow::bail!("a game is running; quit it first, so its stop commands can run"); + } + + let plan = purge::Plan::compute(&purge::discover(config.as_ref(), &path)); + if plan.is_empty() { + println!("Nothing of GameModeExecutor was found on this machine."); + return Ok(()); + } + println!("This will:"); + for line in plan.describe() { + println!(" - {line}"); + } + if !yes { + print!("Type yes to continue: "); + std::io::Write::flush(&mut std::io::stdout())?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if answer.trim() != "yes" { + println!("Nothing was changed."); + return Ok(()); + } + } + purge::execute(&plan) +} + +fn print_pending(pending: &marker::Pending, marker: &marker::Marker) { + println!( + " game : {}", + pending.game.as_deref().unwrap_or("not named") + ); + println!( + " since : {}", + pending.since.as_deref().unwrap_or("unknown") + ); + println!(" file : {}", marker.path().display()); +} + +fn status() -> Result<()> { + // First, because when someone is diagnosing a machine that is not theirs, + // knowing which build they are looking at comes before anything it reports. + println!("Build : {}", build_info::VERSION); + println!(" commit : {}", build_info::COMMIT_DISPLAY); + println!(" documentation : {}", build_info::DOCS_URL); + + let snapshot = Snapshot::take()?; + + // The detector itself. + let mut game_running = false; + match presence_writer::registered_exe() { + Ok(exe) => { + println!("Presence writer : {}", exe.display()); + println!( + " Microsoft default : {}", + if presence_writer::is_microsoft_default(&exe) { + "yes" + } else { + "NO - something else owns the registration" + } + ); + match presence_writer::running_pid(&exe) { + Some(pid) => { + game_running = true; + println!(" running : YES (pid {pid}) - a game is running"); + } + None => println!(" running : no - no game running"), + } + } + Err(error) => println!("Presence writer : unavailable ({error:#})"), + } + + // The marker means one of two things, and the writer tells them apart: a + // session open right now, which is the watcher doing its job, or one that + // never closed, which the watcher settles at its next start. Read with a + // game on, the first wording used to claim the second, and was wrong. + match marker::Marker::in_local_dir() { + Some(marker) => match (marker.pending(), game_running) { + (Some(pending), true) => { + println!("Session marker : present - a game session is open, as expected"); + print_pending(&pending, &marker); + } + (Some(pending), false) => { + println!( + "Session marker : PRESENT with no game running - the last session \ + never closed; the stop commands run when the watcher next starts" + ); + print_pending(&pending, &marker); + } + (None, true) => println!( + "Session marker : NONE while a game is running - the watcher has not \ + recorded this session; is it running? ({})", + marker.path().display() + ), + (None, false) => { + println!("Session marker : none ({})", marker.path().display()) + } + }, + None => println!("Session marker : unavailable, no local profile"), + } + + // Naming only, never detection. + let known = KnownGames::load(); + match &known { + Ok(known) => { + let counts = known.counts(); + println!(r"Known Game List (HKCU\System\GameConfigStore\Children)"); + println!(" entries seen : {}", counts.entries); + println!(" executable paths : {}", counts.exe_paths); + println!( + " parent directories : {} paths, {} names", + counts.parent_paths, counts.parent_names + ); + println!(" packaged titles : {}", counts.packages); + if !known.skipped_generic.is_empty() { + println!( + " names too generic : {}", + known.skipped_generic.join(", ") + ); + } + // Every match, with what the GPU says about it. A title brings + // several: seeing them ranked is the only way to tell whether the + // right one would be picked. + let candidates = known.candidates(&snapshot); + if candidates.is_empty() { + println!(" matching processes : none"); + } else { + let load = detect::gpu::rendering_load(std::time::Duration::from_millis(500)) + .unwrap_or_default(); + println!(" matching processes : {}", candidates.len()); + for candidate in &candidates { + let share = candidate + .process_id + .and_then(|pid| load.get(&pid)) + .copied() + .unwrap_or(0.0); + println!(" {share:>6.1}% rendering {}", candidate.describe()); + } + match detect::most_active(candidates, &load) { + Some(best) => println!(" would be named : {}", best.describe()), + None => println!(" would be named : none"), + } + } + } + Err(error) => println!("Known Game List : unavailable ({error:#})"), + } + + print_foreground(&snapshot, known.as_ref().ok()); + + println!("Processes visible : {}", snapshot.processes.len()); + Ok(()) +} + +fn check(path: Option<&str>, pid: Option) -> Result<()> { + let known = KnownGames::load()?; + + if let Some(pid) = pid { + return check_pid(&known, pid); + } + + let path = path.expect("clap requires a path when --pid is absent"); + match known.match_exe(path) { + Some(kind) => println!("{path}\n -> game, matched by {}", kind.label()), + None => println!("{path}\n -> not a known game"), + } + Ok(()) +} + +/// Show what the naming code actually reads for one process. Without this, +/// a process that cannot be opened is indistinguishable from one Windows +/// simply does not list as a game. +fn check_pid(known: &KnownGames, pid: u32) -> Result<()> { + let snapshot = Snapshot::take()?; + let name = snapshot + .by_pid(pid) + .map(|process| process.name.clone()) + .unwrap_or_else(|| "(not running)".to_owned()); + println!("pid {pid}: {name}"); + + let identity = detect::process::identity(pid); + let image = identity.path; + match &image { + Some(image) => println!(" image path : {image}"), + None => println!(" image path : UNREADABLE (cannot open the process)"), + } + + let family = identity.package_family; + match &family { + Some(family) => println!(" package family : {family}"), + None => println!(" package family : none (not packaged, or unreadable)"), + } + + let verdict = image + .as_deref() + .and_then(|image| known.match_exe(image)) + .map(|kind| kind.label().to_owned()) + .or_else(|| { + family + .as_deref() + .filter(|family| known.match_package(family)) + .map(|_| "package family".to_owned()) + }); + match verdict { + Some(kind) => println!(" -> game, matched by {kind}"), + None => println!(" -> not a known game"), + } + Ok(()) +} + +/// The foreground process is what Game Mode itself applies to, so it is the +/// interesting one to check against the Known Game List. +fn print_foreground(snapshot: &Snapshot, known: Option<&KnownGames>) { + let Some(pid) = detect::process::foreground_pid() else { + println!("Foreground : none"); + return; + }; + let name = snapshot + .by_pid(pid) + .map(|process| process.name.clone()) + .unwrap_or_else(|| "?".to_owned()); + let path = detect::process::full_path(pid); + println!("Foreground : {name} (pid {pid})"); + match &path { + Some(path) => println!(" path : {path}"), + None => println!(" path : (not readable)"), + } + let verdict = match (known, &path) { + (Some(known), Some(path)) => match known.match_exe(path) { + Some(kind) => format!("yes, via {}", kind.label()), + None => "no".to_owned(), + }, + _ => "unknown".to_owned(), + }; + println!(" Windows calls it a game: {verdict}"); +} diff --git a/src/config.rs b/src/config.rs index d4a4081..7df16cf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::Duration; -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; pub const CONFIG_FILE_NAME: &str = "config.toml"; @@ -191,11 +191,11 @@ impl Config { if self.detection.poll_interval.is_zero() { anyhow::bail!("detection.poll_interval must be greater than zero"); } - if self.on_game_start.actions.is_empty() && self.on_game_stop.actions.is_empty() { - anyhow::bail!( - "no actions configured: add [[on_game_start.actions]] or [[on_game_stop.actions]]" - ); - } + // No commands at all is a valid configuration -- the one `init` + // writes. The watcher then detects, names and logs sessions and runs + // nothing, which is how someone sees it work before deciding what it + // should run. Decided 2026-09-17, on the first install from the + // package. for action in self .on_game_start .actions @@ -247,6 +247,72 @@ pub fn local_dir() -> Option { std::env::var_os("LOCALAPPDATA").map(|local| PathBuf::from(local).join(APP_DIR_NAME)) } +/// The configuration `init` writes: `config.example.toml` at the root of the +/// repository, compiled in, so the file a user starts from and the one the +/// repository documents are the same bytes. +pub const STARTER: &str = include_str!("../config.example.toml"); + +/// What writing the starter configuration did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Starter { + /// There was no file; there is one now. + Written, + /// A file was there and was left alone. + Kept, + /// A file was there and `force` replaced it. + Overwritten, +} + +/// The decision alone, so it can be tested without a disk. +pub fn starter_outcome(exists: bool, force: bool) -> Starter { + match (exists, force) { + (false, _) => Starter::Written, + (true, false) => Starter::Kept, + (true, true) => Starter::Overwritten, + } +} + +/// Write the starter configuration to `path`, creating its folder. A file +/// already there is kept unless `force`: the installer runs this on every +/// install and upgrade, and a configuration someone edited must survive +/// both. The outcome is logged at `info`, under `setup`. +pub fn write_starter(path: &Path, force: bool) -> Result { + let outcome = starter_outcome(path.exists(), force); + if outcome != Starter::Kept { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create `{}`", parent.display()))?; + } + std::fs::write(path, STARTER) + .with_context(|| format!("cannot write `{}`", path.display()))?; + } + match outcome { + Starter::Written => tracing::info!( + target: crate::logging::target::SETUP, + path = %path.display(), + "Starter configuration written" + ), + Starter::Kept => tracing::info!( + target: crate::logging::target::SETUP, + path = %path.display(), + "Configuration kept: one is already there (--force replaces it)" + ), + Starter::Overwritten => tracing::info!( + target: crate::logging::target::SETUP, + path = %path.display(), + "Configuration replaced by the starter one, as asked" + ), + } + Ok(outcome) +} + +/// Where `init` writes when not told otherwise: the roaming profile. +pub fn starter_path() -> Result { + Ok(roaming_dir() + .context("cannot determine %APPDATA%")? + .join(CONFIG_FILE_NAME)) +} + /// First existing candidate, or the first candidate at all so error messages /// point at a sensible location. pub fn default_path() -> Result { @@ -286,8 +352,31 @@ mod tests { assert_eq!(config.detection.poll_interval, Duration::from_secs(2)); assert_eq!(config.detection.stop_delay, Duration::from_secs(2)); assert_eq!(config.general.log_level, "info"); - // A config with no actions at all does nothing, so it is rejected. - assert!(config.validate().is_err()); + // No actions at all is valid: the watcher observes and runs nothing. + assert!(config.validate().is_ok()); + } + + #[test] + fn the_starter_is_written_once_and_replaced_only_on_request() { + assert_eq!(starter_outcome(false, false), Starter::Written); + assert_eq!(starter_outcome(false, true), Starter::Written); + assert_eq!(starter_outcome(true, false), Starter::Kept); + assert_eq!(starter_outcome(true, true), Starter::Overwritten); + } + + #[test] + fn write_starter_keeps_what_is_there_unless_forced() { + let dir = + std::env::temp_dir().join(format!("gamemode-executor-starter-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("sub").join(CONFIG_FILE_NAME); + assert_eq!(write_starter(&path, false).unwrap(), Starter::Written); + std::fs::write(&path, "# edited\n").unwrap(); + assert_eq!(write_starter(&path, false).unwrap(), Starter::Kept); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "# edited\n"); + assert_eq!(write_starter(&path, true).unwrap(), Starter::Overwritten); + assert_eq!(std::fs::read_to_string(&path).unwrap(), STARTER); + std::fs::remove_dir_all(&dir).unwrap(); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 94c338b..89daad1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,12 +9,14 @@ compile_error!("GameModeExecutor only targets Windows"); pub mod actions; pub mod build_info; +pub mod cli; pub mod config; pub mod detect; pub mod engine; pub mod exit; pub mod logging; pub mod marker; +pub mod purge; pub mod registry; pub mod sensor; pub mod service; diff --git a/src/logging.rs b/src/logging.rs index 70fd00f..1b7d8d6 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -46,9 +46,13 @@ pub mod target { pub const GAME: &str = "game"; /// Running the executables from the configuration. pub const COMMANDS: &str = "commands"; + /// Setting the program up and taking it down: the starter configuration, + /// 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"; /// Every category, in the order they appear in a session. - pub const ALL: &[&str] = &[WATCHER, GAME, COMMANDS]; + pub const ALL: &[&str] = &[WATCHER, GAME, COMMANDS, SETUP]; /// Width of the category column, so messages line up whatever the category. pub(super) const WIDTH: usize = 8; @@ -286,6 +290,14 @@ pub fn init(level: &str, log_dir: Option<&Path>, console: bool) -> Result<()> { // thread would save nothing at this volume and costs the only lines // that really matter: the release profile aborts on panic, so // nothing is dropped and a buffered crash report is never flushed. + // + // Two processes write this file at once when `stop` or the + // installer asks a running watcher to quit. Append mode opens it + // with FILE_APPEND_DATA and not FILE_WRITE_DATA, so Windows + // places every WriteFile at the end of the file itself, and the + // formatter hands a whole line to one write: lines interleave, + // never tear. Measured on 2026-09-18 with 80 processes writing at + // once -- 80 lines, all intact. let path = dir.join(LOG_FILE_NAME); let file = std::fs::OpenOptions::new() .create(true) diff --git a/src/main.rs b/src/main.rs index b97d82b..6845236 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,98 +1,15 @@ -//! GameModeExecutor: watch for a running game and run configured executables -//! when it starts and stops. +//! The console executable: what people type. +//! +//! Every command lives in the library's `cli` module, shared with the +//! windowless twin; this file only adds what a console is for -- an error +//! printed where the person can read it, and an exit code a shell waits for. -use std::path::PathBuf; +use clap::Parser; -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand, ValueEnum}; - -use game_mode_executor::config::{self, Config}; -use game_mode_executor::detect::known_games::KnownGames; -use game_mode_executor::detect::presence_writer; -use game_mode_executor::detect::process::Snapshot; -use game_mode_executor::{actions, build_info, detect, exit, logging, marker, service, task}; - -/// Default config file shipped with the program, also used by `init`. -const EXAMPLE_CONFIG: &str = include_str!("../config.example.toml"); - -#[derive(Parser, Debug)] -#[command( - name = "gamemode-executor", - version = build_info::VERSION, - long_version = build_info::LONG_VERSION, - about, - long_about = None -)] -struct Cli { - /// Path to the configuration file. Defaults to config.toml next to the - /// executable, then %APPDATA%\GameModeExecutor\config.toml. - #[arg(short, long, global = true, value_name = "PATH")] - config: Option, - - /// Override general.log_level. - #[arg(long, global = true, value_name = "LEVEL")] - log_level: Option, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Watch for games and run the configured actions (default). - /// - /// For an unattended instance use `gamemode-executorw.exe`, which is the - /// same watcher with no console at all. `install-task` registers that one. - Run { - /// Accepted and ignored. A task registered before this binary had a - /// windowless twin still passes it, and refusing it would stop that - /// task dead at the next logon. - #[arg(long, hide = true)] - hidden: bool, - }, - /// Print what the detectors currently see, then exit. - Status, - /// Run one set of actions immediately, without any detection. - Trigger { - #[arg(value_enum)] - event: TriggerEvent, - }, - /// Ask whether Windows knows a given executable as a game. - Check { - /// Full path of an executable. Omit when using --pid. - path: Option, - /// Inspect a running process instead, by process id. Shows what the - /// naming code actually reads, which is the only way to tell a missing - /// entry from an unreadable process. - #[arg(long, conflicts_with = "path")] - pid: Option, - }, - /// Check that the configuration file is valid. - Validate, - /// Write a starter configuration file. - Init { - /// Overwrite an existing file. - #[arg(long)] - force: bool, - }, - /// Register a per-user logon task that starts the watcher hidden. - InstallTask { - /// Delay after logon, e.g. `15s` or `1m`. - #[arg(long, default_value = "15s")] - delay: String, - }, - /// Remove the logon task. - UninstallTask, -} - -#[derive(Copy, Clone, Debug, ValueEnum)] -enum TriggerEvent { - Start, - Stop, -} +use game_mode_executor::{cli, exit}; fn main() -> std::process::ExitCode { - match run() { + match cli::run(cli::Cli::parse(), true) { Ok(()) => std::process::ExitCode::SUCCESS, Err(error) => { eprintln!("Error: {error:#}"); @@ -100,295 +17,3 @@ fn main() -> std::process::ExitCode { } } } - -fn run() -> Result<()> { - let cli = Cli::parse(); - match cli.command { - Some(Commands::Init { force }) => return cmd_init(cli.config.as_deref(), force), - Some(Commands::InstallTask { delay }) => { - let delay = humantime::parse_duration(&delay) - .with_context(|| format!("cannot read `{delay}` as a delay"))?; - let path = resolve_config_path(cli.config.clone())?; - return task::install(&path, delay); - } - Some(Commands::UninstallTask) => return task::uninstall(), - Some(Commands::Check { path, pid }) => return cmd_check(path.as_deref(), pid), - _ => {} - } - - let path = resolve_config_path(cli.config.clone())?; - let config = Config::load(&path)?; - let level = cli - .log_level - .unwrap_or_else(|| config.general.log_level.clone()); - - match cli.command { - Some(Commands::Validate) => { - println!("Configuration `{}` is valid.", path.display()); - Ok(()) - } - Some(Commands::Status) => { - logging::init(&level, None, true)?; - cmd_status(&config) - } - Some(Commands::Trigger { event }) => { - logging::init(&level, None, true)?; - // The commands and nothing else: no detection, no session, no - // marker. A trigger is for testing what the commands do. - let (label, actions) = match event { - TriggerEvent::Start => ("game_start", &config.on_game_start), - TriggerEvent::Stop => ("game_stop", &config.on_game_stop), - }; - actions::run_all(actions, &actions::ActionContext::new(label, None)); - Ok(()) - } - _ => cmd_run(config, &path, &level), - } -} - -fn cmd_run(config: Config, config_path: &std::path::Path, level: &str) -> Result<()> { - // This binary is a console program, so it always has one to log to. - service::serve(config, config_path, level, true) -} - -fn print_pending(pending: &marker::Pending, marker: &marker::Marker) { - println!( - " game : {}", - pending.game.as_deref().unwrap_or("not named") - ); - println!( - " since : {}", - pending.since.as_deref().unwrap_or("unknown") - ); - println!(" file : {}", marker.path().display()); -} - -fn cmd_status(_config: &Config) -> Result<()> { - // First, because when someone is diagnosing a machine that is not theirs, - // knowing which build they are looking at comes before anything it reports. - println!("Build : {}", build_info::VERSION); - println!(" commit : {}", build_info::COMMIT_DISPLAY); - println!(" documentation : {}", build_info::DOCS_URL); - - let snapshot = Snapshot::take()?; - - // The detector itself. - let mut game_running = false; - match presence_writer::registered_exe() { - Ok(exe) => { - println!("Presence writer : {}", exe.display()); - println!( - " Microsoft default : {}", - if presence_writer::is_microsoft_default(&exe) { - "yes" - } else { - "NO - something else owns the registration" - } - ); - match presence_writer::running_pid(&exe) { - Some(pid) => { - game_running = true; - println!(" running : YES (pid {pid}) - a game is running"); - } - None => println!(" running : no - no game running"), - } - } - Err(error) => println!("Presence writer : unavailable ({error:#})"), - } - - // The marker means one of two things, and the writer tells them apart: a - // session open right now, which is the watcher doing its job, or one that - // never closed, which the watcher settles at its next start. Read with a - // game on, the first wording used to claim the second, and was wrong. - match marker::Marker::in_local_dir() { - Some(marker) => match (marker.pending(), game_running) { - (Some(pending), true) => { - println!("Session marker : present - a game session is open, as expected"); - print_pending(&pending, &marker); - } - (Some(pending), false) => { - println!( - "Session marker : PRESENT with no game running - the last session \ - never closed; the stop commands run when the watcher next starts" - ); - print_pending(&pending, &marker); - } - (None, true) => println!( - "Session marker : NONE while a game is running - the watcher has not \ - recorded this session; is it running? ({})", - marker.path().display() - ), - (None, false) => { - println!("Session marker : none ({})", marker.path().display()) - } - }, - None => println!("Session marker : unavailable, no local profile"), - } - - // Naming only, never detection. - let known = KnownGames::load(); - match &known { - Ok(known) => { - let counts = known.counts(); - println!(r"Known Game List (HKCU\System\GameConfigStore\Children)"); - println!(" entries seen : {}", counts.entries); - println!(" executable paths : {}", counts.exe_paths); - println!( - " parent directories : {} paths, {} names", - counts.parent_paths, counts.parent_names - ); - println!(" packaged titles : {}", counts.packages); - if !known.skipped_generic.is_empty() { - println!( - " names too generic : {}", - known.skipped_generic.join(", ") - ); - } - // Every match, with what the GPU says about it. A title brings - // several: seeing them ranked is the only way to tell whether the - // right one would be picked. - let candidates = known.candidates(&snapshot); - if candidates.is_empty() { - println!(" matching processes : none"); - } else { - let load = detect::gpu::rendering_load(std::time::Duration::from_millis(500)) - .unwrap_or_default(); - println!(" matching processes : {}", candidates.len()); - for candidate in &candidates { - let share = candidate - .process_id - .and_then(|pid| load.get(&pid)) - .copied() - .unwrap_or(0.0); - println!(" {share:>6.1}% rendering {}", candidate.describe()); - } - match detect::most_active(candidates, &load) { - Some(best) => println!(" would be named : {}", best.describe()), - None => println!(" would be named : none"), - } - } - } - Err(error) => println!("Known Game List : unavailable ({error:#})"), - } - - print_foreground(&snapshot, known.as_ref().ok()); - - println!("Processes visible : {}", snapshot.processes.len()); - Ok(()) -} - -fn cmd_check(path: Option<&str>, pid: Option) -> Result<()> { - let known = detect::known_games::KnownGames::load()?; - - if let Some(pid) = pid { - return check_pid(&known, pid); - } - - let path = path.expect("clap requires a path when --pid is absent"); - match known.match_exe(path) { - Some(kind) => println!("{path}\n -> game, matched by {}", kind.label()), - None => println!("{path}\n -> not a known game"), - } - Ok(()) -} - -/// Show what the naming code actually reads for one process. Without this, -/// a process that cannot be opened is indistinguishable from one Windows -/// simply does not list as a game. -fn check_pid(known: &KnownGames, pid: u32) -> Result<()> { - let snapshot = Snapshot::take()?; - let name = snapshot - .by_pid(pid) - .map(|process| process.name.clone()) - .unwrap_or_else(|| "(not running)".to_owned()); - println!("pid {pid}: {name}"); - - let identity = detect::process::identity(pid); - let image = identity.path; - match &image { - Some(image) => println!(" image path : {image}"), - None => println!(" image path : UNREADABLE (cannot open the process)"), - } - - let family = identity.package_family; - match &family { - Some(family) => println!(" package family : {family}"), - None => println!(" package family : none (not packaged, or unreadable)"), - } - - let verdict = image - .as_deref() - .and_then(|image| known.match_exe(image)) - .map(|kind| kind.label().to_owned()) - .or_else(|| { - family - .as_deref() - .filter(|family| known.match_package(family)) - .map(|_| "package family".to_owned()) - }); - match verdict { - Some(kind) => println!(" -> game, matched by {kind}"), - None => println!(" -> not a known game"), - } - Ok(()) -} - -/// The foreground process is what Game Mode itself applies to, so it is the -/// interesting one to check against the Known Game List. -fn print_foreground( - snapshot: &detect::process::Snapshot, - known: Option<&detect::known_games::KnownGames>, -) { - let Some(pid) = detect::process::foreground_pid() else { - println!("Foreground : none"); - return; - }; - let name = snapshot - .by_pid(pid) - .map(|process| process.name.clone()) - .unwrap_or_else(|| "?".to_owned()); - let path = detect::process::full_path(pid); - println!("Foreground : {name} (pid {pid})"); - match &path { - Some(path) => println!(" path : {path}"), - None => println!(" path : (not readable)"), - } - let verdict = match (known, &path) { - (Some(known), Some(path)) => match known.match_exe(path) { - Some(kind) => format!("yes, via {}", kind.label()), - None => "no".to_owned(), - }, - _ => "unknown".to_owned(), - }; - println!(" Windows calls it a game: {verdict}"); -} - -fn cmd_init(explicit: Option<&std::path::Path>, force: bool) -> Result<()> { - let path = match explicit { - Some(path) => path.to_path_buf(), - None => config::roaming_dir() - .context("cannot determine %APPDATA%")? - .join(config::CONFIG_FILE_NAME), - }; - if path.exists() && !force { - anyhow::bail!( - "`{}` already exists (use --force to overwrite)", - path.display() - ); - } - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("cannot create `{}`", parent.display()))?; - } - std::fs::write(&path, EXAMPLE_CONFIG) - .with_context(|| format!("cannot write `{}`", path.display()))?; - println!("Wrote {}", path.display()); - Ok(()) -} - -fn resolve_config_path(explicit: Option) -> Result { - match explicit { - Some(path) => Ok(path), - None => config::default_path(), - } -} diff --git a/src/purge.rs b/src/purge.rs new file mode 100644 index 0000000..5330b2b --- /dev/null +++ b/src/purge.rs @@ -0,0 +1,569 @@ +//! `purge`: remove every trace of the program, on request, whichever way it +//! was installed. +//! +//! The rule, from `docs/design/08-distribution.md`: it removes what it +//! recognises as its own and leaves the rest, saying what it left. Its own +//! is the logon task, the configuration wherever it was found, the log +//! wherever it was written, the session marker, the two profile folders +//! once they are empty, and last the executables -- through Windows +//! Installer when the installer owns them, through a detached shell that +//! waits for this process to exit when they were unpacked by hand. A task +//! it did not register, a folder holding anything else, a file it does not +//! know: left alone. +//! +//! It refuses while a game session is open. A purge then would leave the +//! machine on its gaming configuration with nothing left to restore it, +//! which is the one thing this must not do. And the watcher is stopped +//! first, gracefully, the way *Quit* stops it. +//! +//! The plan is computed from what exists and shown before anything is +//! removed; `--yes` skips the question for scripts. Computing it is separate +//! 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::service; +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 { + /// Windows Installer registered this product; `msiexec /x` removes it, + /// its registration and its folder. + Installed { product_code: String }, + /// Unpacked by hand: these files, the zip's documentation tree when it + /// is there, then the folder if that leaves it empty. + Unpacked { + dir: PathBuf, + files: Vec, + docs: Option, + }, +} + +/// Where the program's traces may be on this machine. +#[derive(Debug, Clone, Default)] +pub struct Layout { + pub watcher_running: bool, + pub task_registered: bool, + pub config_candidates: Vec, + pub log: Option, + pub marker: Option, + pub local_dir: Option, + pub roaming_dir: Option, + pub exe_dir: Option, + pub product_code: Option, +} + +/// What a purge will do, in order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Plan { + pub stop_watcher: bool, + pub remove_task: bool, + pub files: Vec, + /// Removed only if empty once the files are gone, deepest first. + pub dirs: Vec, + pub program: Option, +} + +/// 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"]; +const EXECUTABLES: [&str; 2] = ["gamemode-executor.exe", "gamemode-executorw.exe"]; + +impl Plan { + /// Everything in `layout` that exists, in the order it goes. + pub fn compute(layout: &Layout) -> Self { + let mut files = Vec::new(); + let mut push = |path: &Path| { + if path.is_file() && !files.iter().any(|known: &PathBuf| known == path) { + files.push(path.to_path_buf()); + } + }; + for candidate in &layout.config_candidates { + push(candidate); + } + if let Some(log) = &layout.log { + push(log); + } + if let Some(marker) = &layout.marker { + push(marker); + } + + // Deepest first, so a parent is judged after its children are gone. + // The log's folder counts as ours only inside the program's own + // places; a log sent elsewhere leaves its folder behind. + let mut dirs = Vec::new(); + let ours = [&layout.local_dir, &layout.exe_dir]; + if let Some(log) = &layout.log + && let Some(parent) = log.parent() + && ours + .into_iter() + .flatten() + .any(|place| parent.starts_with(place) && parent != place) + && parent.is_dir() + { + dirs.push(parent.to_path_buf()); + } + for dir in [&layout.local_dir, &layout.roaming_dir] + .into_iter() + .flatten() + { + if dir.is_dir() && !dirs.contains(dir) { + dirs.push(dir.clone()); + } + } + + let program = match (&layout.product_code, &layout.exe_dir) { + (Some(code), _) => Some(Program::Installed { + product_code: code.clone(), + }), + (None, Some(dir)) => { + let files: Vec = EXECUTABLES + .iter() + .chain(BUNDLE_FILES.iter()) + .map(|name| dir.join(name)) + .filter(|path| path.is_file()) + .collect(); + // The zip's documentation tree, recognised by its first page. + let docs = dir.join("docs"); + let docs = docs.join("getting-started.md").is_file().then_some(docs); + (!files.is_empty()).then(|| Program::Unpacked { + dir: dir.clone(), + files, + docs, + }) + } + (None, None) => None, + }; + + Self { + stop_watcher: layout.watcher_running, + remove_task: layout.task_registered, + files, + dirs, + program, + } + } + + pub fn is_empty(&self) -> bool { + !self.stop_watcher + && !self.remove_task + && self.files.is_empty() + && self.dirs.is_empty() + && self.program.is_none() + } + + /// One line per thing, for the question. + pub fn describe(&self) -> Vec { + let mut lines = Vec::new(); + if self.stop_watcher { + lines.push("stop the running watcher".to_owned()); + } + if self.remove_task { + lines.push(format!("remove the logon task `{}`", task::TASK_NAME)); + } + for file in &self.files { + lines.push(format!("delete {}", file.display())); + } + for dir in &self.dirs { + lines.push(format!("remove {} if it is then empty", dir.display())); + } + match &self.program { + Some(Program::Installed { product_code }) => lines.push(format!( + "uninstall the program through Windows Installer (product {product_code})" + )), + Some(Program::Unpacked { dir, files, docs }) => { + for file in files { + lines.push(format!( + "delete {}, once this command has exited", + file.display() + )); + } + if let Some(docs) = docs { + lines.push(format!("delete the documentation tree {}", docs.display())); + } + lines.push(format!("remove {} if it is then empty", dir.display())); + } + None => {} + } + lines + } +} + +/// What this machine holds, read once. `config_path` is the file the command +/// line resolved to, which may not be either default candidate. +pub fn discover(config: Option<&config::Config>, config_path: &Path) -> Layout { + let mut candidates = config::candidate_paths(); + if !candidates.iter().any(|candidate| candidate == config_path) { + candidates.insert(0, config_path.to_path_buf()); + } + let local_dir = config::local_dir(); + let log_dir = config + .and_then(|config| config.general.log_dir.clone()) + .or_else(|| local_dir.as_ref().map(|dir| dir.join("logs"))); + Layout { + watcher_running: win::SingleInstance::is_held(service::INSTANCE), + task_registered: task::exists(), + config_candidates: candidates, + log: log_dir.map(|dir| dir.join(logging::LOG_FILE_NAME)), + marker: local_dir.as_ref().map(|dir| dir.join(marker::FILE_NAME)), + local_dir, + roaming_dir: config::roaming_dir(), + exe_dir: std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)), + product_code: installed_product(), + } +} + +/// Carry the plan out. The executables go last and outlive this process: +/// the caller prints nothing after this returns. +pub fn execute(plan: &Plan) -> Result<()> { + if plan.stop_watcher { + service::stop()?; + println!("Watcher stopped."); + } + if plan.remove_task { + task::uninstall()?; + } + for file in &plan.files { + std::fs::remove_file(file).with_context(|| format!("cannot delete {}", file.display()))?; + println!("Deleted {}.", file.display()); + } + for dir in &plan.dirs { + match std::fs::remove_dir(dir) { + Ok(()) => println!("Removed {}.", dir.display()), + // Not empty, or already gone: either way it is not ours to force. + Err(_) => println!("Left {} alone: it holds something else.", dir.display()), + } + } + match &plan.program { + Some(Program::Installed { product_code }) => { + // Once this process is gone: it lives in the folder the installer + // is about to empty, and Windows Installer would find it in use. + after_exit(&[format!( + "Start-Process msiexec.exe -ArgumentList '/x {product_code} /passive'" + )])?; + println!("Windows Installer will now remove the program."); + } + Some(Program::Unpacked { dir, files, docs }) => { + let mut steps: Vec = files + .iter() + .map(|file| { + format!( + "Remove-Item -LiteralPath {} -Force -ErrorAction SilentlyContinue", + quoted(file) + ) + }) + .collect(); + if let Some(docs) = docs { + steps.push(format!( + "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction SilentlyContinue", + quoted(docs) + )); + } + // The folder itself: only if that left it empty, which is what + // Remove-Item without -Recurse does. + steps.push(format!( + "Remove-Item -LiteralPath {} -ErrorAction SilentlyContinue", + quoted(dir) + )); + after_exit(&steps)?; + println!("The executables will be deleted once this command has exited."); + // A folder cannot go while a shell sits in it, and the shell this + // was typed into usually does. Seen in the field on 2026-09-17. + if std::env::current_dir().is_ok_and(|here| here.starts_with(dir)) { + println!( + "This window is inside {}; the folder itself stays until you leave it.", + dir.display() + ); + } + } + None => {} + } + 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 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!( + "gamemode-executor-purge-{}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn touch(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"x").unwrap(); + } + + #[test] + fn the_plan_lists_only_what_exists() { + let root = scratch(); + let local = root.join("local"); + let roaming = root.join("roaming"); + let exe_dir = root.join("program"); + touch(&roaming.join("config.toml")); + touch(&local.join("logs").join("gamemode-executor.log")); + touch(&exe_dir.join("gamemode-executor.exe")); + touch(&exe_dir.join("LICENSE")); + let layout = Layout { + watcher_running: false, + task_registered: true, + config_candidates: vec![exe_dir.join("config.toml"), roaming.join("config.toml")], + log: Some(local.join("logs").join("gamemode-executor.log")), + marker: Some(local.join(marker::FILE_NAME)), + local_dir: Some(local.clone()), + roaming_dir: Some(roaming.clone()), + exe_dir: Some(exe_dir.clone()), + product_code: None, + }; + + let plan = Plan::compute(&layout); + + assert!(!plan.stop_watcher); + assert!(plan.remove_task); + // The config next to the executable and the marker do not exist. + assert_eq!( + plan.files, + vec![ + roaming.join("config.toml"), + local.join("logs").join("gamemode-executor.log") + ] + ); + assert_eq!( + plan.dirs, + vec![local.join("logs"), local.clone(), roaming.clone()] + ); + assert_eq!( + plan.program, + Some(Program::Unpacked { + dir: exe_dir.clone(), + files: vec![ + exe_dir.join("gamemode-executor.exe"), + exe_dir.join("LICENSE") + ], + docs: None, + }) + ); + } + + #[test] + fn an_installed_product_goes_through_the_installer() { + let layout = Layout { + product_code: Some("{00000000-0000-0000-0000-000000000000}".to_owned()), + exe_dir: Some(PathBuf::from(r"C:\nowhere")), + ..Layout::default() + }; + let plan = Plan::compute(&layout); + assert_eq!( + plan.program, + Some(Program::Installed { + product_code: "{00000000-0000-0000-0000-000000000000}".to_owned() + }) + ); + assert!(plan.files.is_empty() && plan.dirs.is_empty()); + } + + #[test] + fn nothing_there_is_an_empty_plan() { + let plan = Plan::compute(&Layout::default()); + assert!(plan.is_empty()); + assert!(plan.describe().is_empty()); + } + + #[test] + fn a_log_sent_elsewhere_is_deleted_but_its_folder_is_not_ours() { + let root = scratch(); + let elsewhere = root.join("elsewhere"); + touch(&elsewhere.join("gamemode-executor.log")); + let layout = Layout { + log: Some(elsewhere.join("gamemode-executor.log")), + local_dir: Some(root.join("local")), + ..Layout::default() + }; + let plan = Plan::compute(&layout); + assert_eq!(plan.files, vec![elsewhere.join("gamemode-executor.log")]); + assert!(plan.dirs.is_empty(), "{:?}", plan.dirs); + } + + /// 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(); + let local = root.join("local"); + let kept = root.join("kept"); + touch(&local.join("logs").join("gamemode-executor.log")); + touch(&kept.join("config.toml")); + touch(&kept.join("something-else.txt")); + let plan = Plan { + stop_watcher: false, + remove_task: false, + files: vec![ + local.join("logs").join("gamemode-executor.log"), + kept.join("config.toml"), + ], + dirs: vec![local.join("logs"), local.clone(), kept.clone()], + program: None, + }; + + execute(&plan).unwrap(); + + assert!(!local.exists(), "empty folders go"); + assert!( + kept.join("something-else.txt").exists(), + "a folder holding something else stays" + ); + } + + /// The hand-installed case hands the executables to a shell that waits + /// for this process to exit. The test cannot exit, so the shell is told + /// to wait for a process that already has. + #[test] + fn an_unpacked_program_is_deleted_by_the_shell_once_the_process_is_gone() { + let dir = scratch().join("program"); + let exe = dir.join("gamemode-executor.exe"); + let license = dir.join("LICENSE"); + let page = dir.join("docs").join("getting-started.md"); + touch(&exe); + touch(&license); + touch(&page); + let gone = Command::new("cmd.exe") + .args(["/c", "exit"]) + .spawn() + .unwrap(); + let pid = gone.id(); + gone.wait_with_output().unwrap(); + + after_process( + pid, + &[ + format!( + "Remove-Item -LiteralPath {} -Force -ErrorAction SilentlyContinue", + quoted(&exe) + ), + format!( + "Remove-Item -LiteralPath {} -Force -ErrorAction SilentlyContinue", + quoted(&license) + ), + format!( + "Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction SilentlyContinue", + quoted(&dir.join("docs")) + ), + format!( + "Remove-Item -LiteralPath {} -ErrorAction SilentlyContinue", + quoted(&dir) + ), + ], + ) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(15); + while dir.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(200)); + } + assert!(!exe.exists(), "the executable was deleted"); + assert!(!page.exists(), "the documentation tree was deleted"); + assert!(!dir.exists(), "the folder went once empty"); + } +} diff --git a/src/service.rs b/src/service.rs index e72a3af..9e19fff 100644 --- a/src/service.rs +++ b/src/service.rs @@ -13,9 +13,14 @@ //! still runs the stop commands, because it is right for a `Quit` and costs //! nothing, but restoring the profile after a session end is Lot 9's marker //! file, not this. +//! +//! `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. use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; @@ -29,6 +34,16 @@ use crate::{engine, logging, sensor, tray, win}; /// its own, shorter patience anyway. const SESSION_END_GRACE: Duration = Duration::from_secs(20); +/// The single-instance mutex, session-local. The watcher holds it for its +/// whole life; `stop` and `purge` read it to know whether one is running and +/// when it has gone. +pub const INSTANCE: &str = "GameModeExecutor"; + +/// How long `stop` waits for the watcher to have gone. The stop commands run +/// on the way out, so this must outlast a slow one, and it only bounds a +/// watcher that is wedged. +const STOP_PATIENCE: Duration = Duration::from_secs(30); + /// Run the watcher until it is stopped. /// /// `console` says whether this process has a console: it gates both the log's @@ -51,7 +66,7 @@ pub fn serve( // Installed as early as the log exists, so a panic anywhere after this // leaves a FATAL line behind rather than a process that simply vanished. logging::install_panic_hook(); - let _instance = SingleInstance::acquire("GameModeExecutor")?; + let _instance = SingleInstance::acquire(INSTANCE)?; // Before any window exists, or the process stays DPI-unaware for its whole // life and the notification icon is built at the wrong size. @@ -138,3 +153,44 @@ pub fn serve( tracing::info!(target: logging::target::WATCHER, "Stopped"); Ok(()) } + +/// What `stop` found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stopped { + /// A watcher was running; it has gone. + Stopped, + /// None was running. + 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. +/// +/// 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 { + if !SingleInstance::is_held(INSTANCE) { + tracing::info!(target: logging::target::SETUP, "No watcher was running"); + return Ok(Stopped::NotRunning); + } + let asked = Instant::now(); + loop { + // May find no window yet, or none any more: the mutex is the verdict. + let _ = win::close_session_window(); + if !SingleInstance::is_held(INSTANCE) { + tracing::info!( + target: logging::target::SETUP, + waited = ?asked.elapsed(), + "Watcher stopped, as asked" + ); + return Ok(Stopped::Stopped); + } + anyhow::ensure!( + asked.elapsed() < STOP_PATIENCE, + "the watcher did not stop within {STOP_PATIENCE:?}" + ); + std::thread::sleep(Duration::from_millis(250)); + } +} diff --git a/src/task.rs b/src/task.rs index f2d877c..2638b16 100644 --- a/src/task.rs +++ b/src/task.rs @@ -24,9 +24,45 @@ pub const TASK_NAME: &str = "GameModeExecutor\\Watcher"; /// binary, which is the one the user types and therefore the one running now. const WATCHER_EXE: &str = "gamemode-executorw.exe"; -/// Create (or replace) a logon task that starts the watcher with no console. -/// Runs only while the user is logged on, so no password and no elevation. -pub fn install(config_path: &Path, delay: Duration) -> Result<()> { +/// What `install` did about the task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Registration { + /// There was no task; there is one now. + Registered, + /// A task was there and was left alone. + Kept, + /// A task was there and `force` replaced it. + Replaced, +} + +/// The decision alone, so it can be tested without Task Scheduler. +pub fn registration(exists: bool, force: bool) -> Registration { + match (exists, force) { + (false, _) => Registration::Registered, + (true, false) => Registration::Kept, + (true, true) => Registration::Replaced, + } +} + +/// Register the logon task that starts the watcher with no console, then +/// start it, so the icon appears now rather than at the next logon. Runs +/// only while the user is logged on, so no password and no elevation. +/// +/// A task that is already there is kept unless `force`: the installer calls +/// this on every install and upgrade, and must not undo a delay or a path +/// the user chose. Decided 2026-09-17, on the first install from the package. +/// Every outcome is logged at `info`, under `setup`. +pub fn install(config_path: &Path, delay: Duration, force: bool) -> Result { + let outcome = registration(exists(), force); + if outcome == Registration::Kept { + tracing::info!( + target: crate::logging::target::SETUP, + task = TASK_NAME, + "Logon task kept: one is already registered (--force replaces it)" + ); + start()?; + return Ok(outcome); + } let here = std::env::current_exe().context("cannot locate the running executable")?; let exe = here.with_file_name(WATCHER_EXE); if !exe.exists() { @@ -62,22 +98,64 @@ pub fn install(config_path: &Path, delay: Duration) -> Result<()> { let _ = std::fs::remove_file(&temp); result?; - println!("Scheduled task `{TASK_NAME}` created for {user}."); - println!(" folder : \\{TASK_FOLDER} in Task Scheduler"); - println!(" program : {}", exe.display()); - println!(" config : {}", config_path.display()); - println!(" delay : {delay:?} after logon, no execution time limit"); + match outcome { + Registration::Registered => tracing::info!( + target: crate::logging::target::SETUP, + task = TASK_NAME, + user = %user, + program = %exe.display(), + config = %config_path.display(), + delay = ?delay, + "Logon task registered: it starts the watcher at every logon, with no execution time limit" + ), + _ => tracing::info!( + target: crate::logging::target::SETUP, + task = TASK_NAME, + user = %user, + program = %exe.display(), + config = %config_path.display(), + delay = ?delay, + "Logon task replaced, as asked" + ), + } + start()?; + Ok(outcome) +} + +/// Run the task now. A watcher already running keeps the single-instance +/// mutex, so a second start exits at once and nothing doubles. +fn start() -> Result<()> { + run_schtasks(&["/Run", "/TN", TASK_NAME])?; + tracing::info!( + target: crate::logging::target::SETUP, + task = TASK_NAME, + "Watcher started through its task; its icon appears in the notification area" + ); Ok(()) } +/// Remove the logon task. A task that is not there is not an error: the +/// outcome is logged either way. pub fn uninstall() -> Result<()> { + if !exists() { + tracing::info!( + target: crate::logging::target::SETUP, + task = TASK_NAME, + "No logon task to remove" + ); + return Ok(()); + } run_schtasks(&["/Delete", "/TN", TASK_NAME, "/F"])?; - println!("Scheduled task `{TASK_NAME}` deleted."); // The folder is left behind on purpose: anything else the user put in it -- // the elevated tasks a recipe asks for, for instance -- is theirs, and // removing a folder that still holds their work would be worse than // leaving an empty one they can delete in a click. - println!(" the \\{TASK_FOLDER} folder is left in place, empty or not."); + tracing::info!( + target: crate::logging::target::SETUP, + task = TASK_NAME, + folder = TASK_FOLDER, + "Logon task removed; the folder in Task Scheduler is left in place, empty or not" + ); Ok(()) } @@ -191,11 +269,30 @@ fn current_user() -> Option { } } -fn run_schtasks(args: &[&str]) -> Result<()> { - let output = Command::new("schtasks") - .args(args) +/// Whether the logon task is registered. `schtasks /Query` exits non-zero +/// for a task that does not exist, which is the whole answer. +pub fn exists() -> bool { + schtasks(&["/Query", "/TN", TASK_NAME]) .output() - .context("cannot run schtasks.exe")?; + .map(|output| output.status.success()) + .unwrap_or(false) +} + +/// `schtasks.exe` with its output captured and no console window of its +/// own. A console program started from a parent that has no visible console +/// -- the installer's custom action, the windowless watcher -- opens one for +/// itself, and the user sees it flash: seen on 2026-09-18, on the first +/// install from the package. +fn schtasks(args: &[&str]) -> Command { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut command = Command::new("schtasks"); + command.args(args).creation_flags(CREATE_NO_WINDOW); + command +} + +fn run_schtasks(args: &[&str]) -> Result<()> { + let output = schtasks(args).output().context("cannot run schtasks.exe")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); @@ -208,6 +305,14 @@ fn run_schtasks(args: &[&str]) -> Result<()> { mod tests { use super::*; + #[test] + fn a_task_is_registered_once_and_replaced_only_on_request() { + assert_eq!(registration(false, false), Registration::Registered); + assert_eq!(registration(false, true), Registration::Registered); + assert_eq!(registration(true, false), Registration::Kept); + assert_eq!(registration(true, true), Registration::Replaced); + } + #[test] fn the_definition_disables_the_traps() { let xml = definition( diff --git a/src/win.rs b/src/win.rs index ea07894..c0d79ed 100644 --- a/src/win.rs +++ b/src/win.rs @@ -11,17 +11,19 @@ use windows::Win32::Foundation::{ }; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::Threading::{ - CreateEventW, CreateMutexW, INFINITE, SetEvent, WaitForSingleObject, + CreateEventW, CreateMutexW, INFINITE, OpenMutexW, SYNCHRONIZATION_SYNCHRONIZE, SetEvent, + WaitForSingleObject, }; use windows::Win32::UI::HiDpi::{ DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext, }; use windows::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetMessageW, MSG, - PostMessageW, PostQuitMessage, RegisterClassExW, TranslateMessage, WINDOW_EX_STYLE, WM_APP, - WM_DESTROY, WM_ENDSESSION, WM_QUERYENDSESSION, WNDCLASSEXW, WS_OVERLAPPED, + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, EnumWindows, GetClassNameW, + GetMessageW, MSG, PostMessageW, PostQuitMessage, RegisterClassExW, TranslateMessage, + WINDOW_EX_STYLE, WM_APP, WM_CLOSE, WM_DESTROY, WM_ENDSESSION, WM_QUERYENDSESSION, WNDCLASSEXW, + WS_OVERLAPPED, }; -use windows::core::{HSTRING, PCWSTR}; +use windows::core::{BOOL, HSTRING, PCWSTR}; /// A manual-reset event used to unblock every wait in the program at once. /// @@ -163,6 +165,46 @@ unsafe extern "system" fn window_proc( } } +/// The window class of the session window, which is how another process +/// 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. +/// +/// `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 { + 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 + // 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 }; + } + return BOOL(0); + } + BOOL(1) + } + let mut 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 + // 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"); + Ok(()) +} + /// A top-level window that is never shown. /// /// It was built for one message, `WM_QUERYENDSESSION`, to preserve what the @@ -193,7 +235,7 @@ impl SessionWindow { grace, }); - let class_name = HSTRING::from("GameModeExecutorSession"); + let class_name = HSTRING::from(SESSION_CLASS); // SAFETY: `None` asks for the calling executable's own module. let instance = unsafe { GetModuleHandleW(None) }.context("GetModuleHandleW failed")?; @@ -334,6 +376,27 @@ impl SingleInstance { } Ok(Self { handle }) } + + /// Whether some process holds the mutex `name`, without taking it. + /// + /// `acquire` would answer the same question, but it creates the mutex + /// when nobody holds it, and a watcher starting in that instant would + /// read the probe as a running instance and exit. Opening an existing + /// mutex creates nothing, and the handle is closed before returning so + /// the object does not outlive the process that owns it. + pub fn is_held(name: &str) -> bool { + let name = HSTRING::from(format!("Local\\{name}")); + // SAFETY: `name` is NUL-terminated and outlives the call; a handle + // that comes back is closed here and kept nowhere. + match unsafe { OpenMutexW(SYNCHRONIZATION_SYNCHRONIZE, false, PCWSTR(name.as_ptr())) } { + Ok(handle) => { + // SAFETY: the handle was just opened and is not used again. + unsafe { _ = CloseHandle(handle) }; + true + } + Err(_) => false, + } + } } impl Drop for SingleInstance {