diff --git a/AGENTS.md b/AGENTS.md index c35760e..688c16c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,12 @@ These decide most questions before they are asked. must stay the cheapest thing it does; during a game the watcher waits on a handle and does nothing. A change to either is measured on the whole installed process against the last release -- processor over minutes, - private memory, handles -- with `presence-probe cost` and `footprint` - for the steps, and the figures go in the record and the changelog. + private memory, handles -- with `presence-probe cost`, `footprint` and + `menu-cost` for the steps, and the figures go in the record and the + changelog. A system library can keep what it loaded for the life of its + caller, whatever its close function says: PDH and the shell did + (`docs/design/16-footprint.md`). Measure what a call leaves, not only + what it takes. - **Strict and simple over clever.** An unambiguous state ("it is off, fix the file") beats a fallback whose behaviour needs explaining. Put the strict option first and argue for a fallback only if it protects something @@ -220,7 +224,9 @@ commands; do not. Task Scheduler exports them, and carry placeholders the release check verifies. Read and write them with that encoding. - `TrackPopupMenuEx` is modal and re-enters the window procedure; never hold a - `RefCell` borrow across it, or across `ShellExecuteW`. + `RefCell` borrow across it, or across any call that can show UI. The + shell is no longer called from the watcher at all: the menu's entries go + through the `open` helper, `src/open.rs`, and a new one should too. - `FindWindow` cannot find a window whose class was registered by another process; use `EnumWindows`. - A process started after `WM_QUERYENDSESSION` dies with diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7b592..4a74f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,15 @@ a section is written. ## [Unreleased] -Nothing yet. +### Changed + +- Telling a game from its launcher by what the graphics card is drawing no + longer adds some 3.5 MB to the watcher's memory for the rest of its run: + the counters are read the lighter way Windows offers for them. +- *Edit configuration*, *Open log* and *Documentation* no longer leave some + 1.2 MB in the watcher for the rest of its run, and more for each kind of + file: a short-lived helper opens them and takes that cost with it when it + ends. ## [0.3.0] - 2026-09-23 diff --git a/Cargo.toml b/Cargo.toml index 097209a..932f2c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ windows = { version = "0.62", features = [ # MsiEnumRelatedProducts: whether the installer owns the executables, which # decides how `purge` removes them. "Win32_System_ApplicationInstallationAndServicing", + # CoInitializeEx: the apartment Microsoft asks for before ShellExecuteEx, + # in the helper that opens what the menu offers. + "Win32_System_Com", # The updater: WinHTTP for the release, CNG for the SHA-256 of what it # fetched. Microsoft's libraries, no HTTP or hashing crate. "Win32_Networking_WinHttp", @@ -48,7 +51,8 @@ windows = { version = "0.62", features = [ # FindFirstChangeNotificationW: the configuration folder, watched for the # live reload. "Win32_Storage_FileSystem", - # RegNotifyChangeKeyValue: Windows' game list, watched rather than polled. + # RegQueryInfoKeyW: the last-write time of Windows' game list, asked at + # each idle look, since its change notification never arrives (Lot 15). "Win32_System_Registry", # WNDCLASSEXW names HBRUSH, HICON and HCURSOR, so the window class needs Gdi # even though this program never draws anything. @@ -64,6 +68,8 @@ windows = { version = "0.62", features = [ # K32EnumProcesses: the process ids alone, which the idle look takes # every poll instead of a Toolhelp snapshot, about 130 times cheaper. "Win32_System_ProcessStatus", + # The PerfLib consumer functions: the GPU Engine counters the refinement + # reads, without what PDH keeps for the process's life (Lot 16). "Win32_System_Performance", "Win32_System_SystemInformation", "Win32_System_Threading", diff --git a/docs/design/06-notification-icon.md b/docs/design/06-notification-icon.md index e72ec44..3f0bfa1 100644 --- a/docs/design/06-notification-icon.md +++ b/docs/design/06-notification-icon.md @@ -11,7 +11,7 @@ exits cleanly, and the icon survives killing and restarting Explorer. Closed - [x] `Shell_NotifyIcon` with `NOTIFYICON_VERSION_4`, on the Lot 5 window - [x] Context menu: edit configuration · open log · documentation · quit -- [x] All three opened through `ShellExecuteW`, with a Notepad fallback when nothing claims `.toml` +- [x] All three opened through `ShellExecuteW`, with a Notepad fallback when nothing claims `.toml` — in a helper process that ends since 2026-09-23, for what the shell leaves in its caller ([Lot 16](16-footprint.md)) - [x] The documentation entry opens the build's own commit on GitHub - [x] Re-added on `TaskbarCreated`, so an Explorer restart does not lose it - [x] Follows the taskbar theme, and re-reads it when the menu is about to open diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 3224581..985a585 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -499,7 +499,8 @@ leak. Naming a game (every process asked its path and package) leaves after: a cost paid once, at the first session, not a leak. It accounts for about twenty handles in the probe; the watcher gains some 260 over its first sessions, and the rest is not yet traced. Both are proposed as a -separate piece of work — the lot's own cost is nil. +separate piece of work, [Lot 16](16-footprint.md) — the lot's own cost is +nil. ## What it changed in the program — built 2026-09-23 diff --git a/docs/design/16-footprint.md b/docs/design/16-footprint.md new file mode 100644 index 0000000..1ba92bd --- /dev/null +++ b/docs/design/16-footprint.md @@ -0,0 +1,296 @@ +# Lot 16 — What the watcher keeps + +**Status: done 2026-09-23.** Proposed the same day in +[Lot 15](15-marked-games.md)'s record, which found that reading the GPU +counters left 3.7 MB behind and that the installed watcher had gained some +260 handles over its first sessions, only twenty of them traced; taken up +at once, at the maintainer's request. + +- [x] The instrument: `presence-probe footprint` step by step, `menu-cost` for what one click leaves over five minutes, `gpu-load` to set the GPU reader beside Windows' own `typeperf`, and a scratch watcher driven through sessions and reloads — 2026-09-23 +- [x] Where the handles come from — 2026-09-23, below: the GPU read, the menu itself, the shell and the update check; sessions, reloads and configuration faults leave nothing +- [x] The GPU counters read through PerfLib instead of PDH — built 2026-09-23: 0.23 MB and 18 handles left instead of 3.6–3.9 MB and 19, the values checked against `typeperf` +- [x] The menu's shell opens: a proposal below, the maintainer's call — taken 2026-09-23, built the same night: `open`, a hidden command the watcher starts its own executable with +- [x] The helper verified from the menu: the file, the log and the documentation open, in front, and the watcher keeps what a plain `CreateProcess` keeps — 2026-09-23, 23:44, below: three handles for the three +- [x] What *Check for updates* leaves: a proposal below, the maintainer's call — left as it is, 2026-09-23 +- [x] Measured on the whole installed process against 0.3.0: just started, after a session whose refinement reads the counters, after the menu's clicks — 2026-09-23, the table at the end, with where 0.3.0's side comes from +- [x] What the watcher costs, written for the people who use it in [How it works](../how-it-works.md), from those figures — 2026-09-23 +- [x] Verified in the field: a session whose refinement reads the counters, on the maintainer's machine — 2026-09-23, Battlefield 6, below: *bf6.exe (75% of the rendering)* through PerfLib, 18 handles and 0.24 MB for the read +- [x] The menu opened a second and a third time, to tell a cost paid once from a leak — 2026-09-23, 18:00 and 18:01: nothing more, below + +**Done when** a session, a reload and each entry of the menu leave the +watcher holding no more than a figure written here over what it held just +started, measured on the whole installed process against 0.3.0 — and every +figure the documentation gives for what the watcher costs is one measured. + +## Sessions, reloads and faults leave nothing, 2026-09-23 + +A watcher built from this branch before any change (0.3.0, `da26e0f2`), on +a scratch configuration — two-second poll, one-second stop delay, the +refinement three seconds in, three `cmd /c exit 0` commands — with the +installed one stopped. Sessions made by activating Windows' presence +writer, as `presence-probe activate` does; reloads by rewriting the file +with another `log_level`. Three runs: + +| | handles | private | threads | +| --- | --- | --- | --- | +| just started | 168–170 | 2.1–2.2 MB | 6 | +| after the first session | 174 | 2.3 MB | 6, then 3 | +| after the other sessions, up to five, and the reloads | 174 | 2.3–2.4 MB | 3 | + +The three threads that go are the loader's own workers, which Windows ends +when they have been idle. The first run read differently — 225 handles +after its third session and 371, with 12 threads, after its first reload — +and two runs after it, the second sampling four times a second, never +did it again. Its size is the size of a shell call, measured below, and +nothing in that run is known to have made one: recorded as not reproduced, +not explained. + +A configuration made unusable then fixed, twice, on the same scratch +watcher — each time two notifications, the icon red and back, the engine +frozen then built anew — left 171 handles against 169 before, and the +same private bytes, 17:16–17:18. + +None of these sessions read the GPU counters. The refinement reads them +only when two processes or more match the game ([Lot 3](03-game-naming.md)); +a presence writer started by hand has no game, and the log said +*Refinement has nothing to arbitrate* every time. + +## What each step leaves + +`presence-probe footprint` runs the watcher's steps one at a time in one +process and reports its private bytes and handles after each. A step that +leaves the same after ten more is a cost paid once; one that leaves more is +a leak. None leaks. The steps that cost anything: + +| Step, the first time | private | handles | +| --- | --- | --- | +| naming a game: every process asked its path and package | +0.1 to +0.2 MB | +2 | +| reading the GPU counters through PDH, 0.3.0 | **+3.6 to +3.9 MB** | +19 | +| reading them through PerfLib, this lot | +0.23 MB | +18 | +| opening a program through the shell, as the menu opens a file | **+1.45 MB** | **+152** | + +`presence-probe menu-cost` makes one click's worth of work in a fresh +process and samples it for five minutes. The shell is asked to run a hidden +`cmd /c exit 0`, which takes the same road as *Edit configuration* without +putting anything on screen; *Check for updates* goes over the network as +the menu's does: + +| One click | 1 s after | 30 s | 120 s | 300 s | +| --- | --- | --- | --- | --- | +| a shell open, `ShellExecuteW` | +1.48 MB, +167 handles, +6 threads | +1.35 MB, +150, +5 | +1.23 MB, +147, +1 | **+1.20 MB, +141, +0** | +| *Check for updates*, WinHTTP | +1.48 MB, +162, +6 | +1.43 MB, +156, +6 | +1.08 MB, +125, +2 | **+1.03 MB, +111, +1** | +| a plain `CreateProcess` | +0.01 MB, +2, +0 | | | | + +The threads are the thread pool's and expire within five minutes; the +handles and the memory stay for the life of the process. Initialising COM +first, as Microsoft asks callers of `ShellExecute` to do, changed nothing +— 314 handles and 10 threads either way — and neither did making the call +on a thread that ends afterwards (312 and 10). The cost belongs to the +shell, not to how it is called. + +What this says of the watcher that read 424 handles after four sessions in +Lot 15 is inferred, not measured: one shell open would account for some 150 +of its 260 — the maintainer changed the poll interval in their file at +13:36 that day, by a road not recorded — and a naming or two for a few +more. The rest is not traced to a step; the whole-process measurement +below is where it will be looked for again. + +## The GPU counters: PDH keeps what it loaded + +What Microsoft documents: `PdhCloseQuery` "frees all memory associated with +the query" — the query's own. What PDH loads to resolve a counter path is +the process's, and nothing in the documentation releases it. Measured in a +fresh PowerShell process: `pdh.dll` is the only module added, adding the +counter is the step that leaves the memory and seven handles, and closing +the query gives two handles back. + +*GPU Engine* is a V2 counter set: registered by the display kernel, +`dxgmms2.sys`, provider *GPU Performance Counters*, two counters, *Running +Time* and *Utilization Percentage*. For V2 counter sets Microsoft documents +a second consumer, the PerfLib functions, for collecting "with minimal +dependencies and overhead". Its guide recommends PDH for most applications +and says these are harder to use; what is harder is a documented byte +layout to walk, which is written once and tested without Windows. + + +How the reader does it, each step as the guide describes: + +- The counter set is found by its English name among those + `PerfEnumerateCounterSet` lists — 162 here, 33 ms the first time — and + the counter by its English name in the set, since hard-coding their + identifiers needs the provider's symbol file, which is not published. +- The counter's registered type is checked: `0x20510500`, + `PERF_100NSEC_TIMER`, whose formula is `100 × (N1 − N0) / (D1 − D0)` with + `D` the sample's time in 100 ns units + ([Calculating Counter Values](https://learn.microsoft.com/en-us/windows/win32/perfctrs/calculating-counter-values)). + Another type is refused, not guessed at: no opinion, as when the counters + cannot be read at all. +- One query, every instance (`*`), two samples a second apart; an instance + missing from one of them, or whose value went backwards, says nothing — + Microsoft's own example drops those samples too. +- `PERF_DATA_HEADER`, `PERF_COUNTER_HEADER`, `PERF_MULTI_INSTANCES`, + `PERF_INSTANCE_HEADER` and `PERF_COUNTER_DATA` are read by offset with + every size bounded by the block around it; a cut or inconsistent block is + an error, and ten tests build such blocks by hand. + +Set beside `typeperf "\GPU Engine(*engtype_3D)\Utilization Percentage"` +over the same seconds, 17:10:34–40, the desktop idle: the same three +processes at the same magnitudes — 2.39, 1.65 and 1.08 % against 2.87, +1.71 and 1.09 % for the first second — and in the same order in three of +the four seconds compared, the two windows half a second apart. A game's +load, tens of percent on one process, is the field run's to confirm. + +## The field run, 2026-09-23 + +This branch's build (`ca26a358`) installed on the maintainer's machine at +17:18 through the logon task, as a release installs it, and its handles, +private bytes and threads sampled once a second from outside, a line each +time one of them changed. The maintainer played Battlefield 6 and did +what the menu offers, noting the minute of each gesture: + +| Time | What happened | handles | private | threads | +| --- | --- | --- | --- | --- | +| 17:18:40 | just started | 162 | 2.09 MB | 6, then 3 | +| 17:45:29 | *Game detected: EAAntiCheat.GameServiceLauncher.exe*, the commands run | 167 | 2.28 MB | 3 | +| 17:45:50 | *Game identified more precisely: bf6.exe (75% of the rendering)* — the counters read through PerfLib | 185 | 2.52 MB | 3 | +| 17:47 | the pointer on the icon, its tooltip shown | 184 | 2.52 MB | 3 | +| 17:48:11 | the menu opened, then closed by a click beside it | **225** | **3.62 MB** | 6 | +| 17:49:06 | *Edit configuration*, VS Code opened, closed unsaved | **373** | 4.36 MB | 12 | +| 17:50 | `gamemode-executor status` in a terminal, another process | 365 | 4.23 MB | 8 | +| 17:51:59 | *Game no longer detected: bf6.exe*, the stop commands run | 363 | 4.16 MB | 6 | + +The refinement's read is the one the probe measured: 18 handles and +0.24 MB, and a name as sure as PDH's — the same game read *75% of the +rendering* on 2026-09-16 through PDH ([Lot 3](03-game-naming.md)). The +shell open is too: 147 handles. What the probe had not measured is the +menu itself: **41 handles and 1.1 MB** the first time it is shown. The +menu is plain — strings, `TrackPopupMenuEx`, the foreground window its +documentation requires, dark by `uxtheme` — so the cost is Windows' own, +for a process's first menu. Paid once: the maintainer opened and closed +the menu again at 18:00 and at 18:01, and the watcher read 357 handles +before and after both, its private bytes 4.18 then 4.22 MB. The tooltip +costs nothing: the shell draws it, in its own process. + +**Not the dark theme.** The maintainer asked whether the undocumented +`SetPreferredAppMode` was the cause. `presence-probe menu-cost menu` and +`menu-dark` build the tray's menu on a hidden window of a fresh process and +show it three times, each closed by a timer after a second, the second +variant after the call the tray makes at start. Twice each, 18:10: + +| | the call itself | first menu | second and third | +| --- | --- | --- | --- | +| light | — | +55 handles, +0.63 and +0.71 MB | nothing | +| dark | +1 handle, +0.03 MB | +55 handles, +0.70 and +0.71 MB | nothing | + +The same cost either way, within the noise of private bytes; the call +costs a handle. The figures differ from the watcher's, 41 handles and +1.1 MB, because the watcher had loaded some of it before and the probe's +window never became the foreground one — it gained no thread where the +watcher gained three — but the comparison is between two runs of the same +probe, and it answers the question. + +Between the session's end and 17:57 the count went down from 363 to 357 +by itself, as the thread pool let its idle threads go. + +Those steps, had they all happened that day, would make some 215 of the +261 handles Lot 15 read (424 − 163): 41 for the menu, some 150 for a shell +open, 19 for PDH, a few for naming. Inferred, not measured — which of them +did happen then is not recorded, and the rest stays untraced. + +## Proposed, for the maintainer's decision + +Both taken as recommended on 2026-09-23, after the maintainer had opened +the log from the menu at 23:30 and the watcher gained 84 handles more than +the configuration's opening had left: a first open of another kind of file +pays again. + +**The helper as built.** The menu's four entries call `open::open`, which +starts the watcher's own executable with `open `, no console, its +standard handles closed, passes it the right to bring a window to the +front (`AllowSetForegroundWindow` — the menu made the watcher the +foreground process), and returns; a thread waits for the helper's exit to +say in the log how it went. The helper does what Microsoft documents for a +caller that exits right after: COM as a single-threaded apartment before +the shell is called, and `ShellExecuteExW` with `SEE_MASK_NOASYNC`, which +the documentation requires of a process that terminates soon after the +call, plus `SEE_MASK_FLAG_LOG_USAGE`, which it asks of a launch the user +asked for. A file the shell has no program for opens in Notepad, as +before. + + +**Verified on the maintainer's machine, 2026-09-23.** The helper's build +(`1aab95f6`) installed at 23:43 through the logon task and sampled as +before; the maintainer opened the menu and chose the three entries in +turn, and saw each window come to the front, no console: + +| Time | What happened | handles | private | +| --- | --- | --- | --- | +| 23:43:51 | just started, idle | 162 | 1.93 MB | +| 23:44:29 | the menu opened | 214 | 2.62 MB | +| 23:44:32 | *Edit configuration*: VS Code, *Opened* in the log | 216 | 2.67 MB | +| 23:44:42 | *Open log*: Notepad | 216 | | +| 23:44:50 | *Documentation*: the browser | 217 | 2.77 MB | + +Three handles for the three, where the build without the helper had kept +147 for the configuration alone and 84 more for the log. The menu's own +first opening read 52 handles this time and 0.7 MB, against 41 and 1.1 MB +on the earlier build: Windows' cost, and not the same each time. + +**The menu's shell opens, through a short-lived helper.** *Edit +configuration*, *Open log folder*, *Documentation* and the release page +all go through `ShellExecuteW`, and the first of them leaves 141 handles +and 1.2 MB for the rest of the watcher's life. The watcher could start its +own executable with a hidden command that makes that one call — and the +Notepad fallback when a `.toml` has no association — and exits: a plain +`CreateProcess` leaves two handles. What it costs: one hidden command, the +open's outcome read from the helper's exit code, some 30 ms on a click. +Recommended: it is the larger of the two costs a player can see in Task +Manager, and the code it takes is small and ordinary. + +**The menu itself, left as it is.** Its 41 handles and 1.1 MB are what +Windows takes to show a process's first menu; a menu cannot be shown +without them, and no other road to the same menu is cheaper. The second +and third openings added nothing: a cost paid once, nothing to do. + +**What *Check for updates* leaves, left as it is.** 111 handles and 1 MB +after the threads expire. The check is a click made once a release; a +successful update replaces the watcher anyway; moving the check into a +helper would mean handing its verdict back across processes — a second +protocol, for a figure the helper saves once a month. Written down in +[How it works](../how-it-works.md) instead, with the other figures. + +## Against 0.3.0 + +What each step leaves in the whole watcher, over what it held just +started. This lot's side is the installed watcher, sampled from outside, +on the maintainer's machine on 2026-09-23. 0.3.0's side is assembled: the +start and the four-session figure are 0.3.0's own installed process, from +Lot 15; the shell's cost was read on this lot's first build, whose menu +still called the shell itself, as 0.3.0's does; PDH's is the probe's. + +| Step | 0.3.0 | this lot | +| --- | --- | --- | +| just started | 157–163 handles, 1.9–2.0 MB | 160–162 handles, 1.9–2.1 MB | +| a session whose refinement reads the GPU counters | +19 handles, +3.6 to +3.9 MB | +18 handles, +0.24 MB | +| the menu, first opened | +41 to +52 handles, +0.7 to +1.1 MB | the same: Windows' own | +| *Edit configuration* | +147 handles, +0.8 MB | +2 handles | +| *Open log* after it | +84 handles, +0.9 MB | nothing | +| *Check for updates*, once its threads have gone | +111 handles, +1.0 MB | the same, left as it is | +| sessions, reloads, configuration faults | nothing | nothing | + +The figure the lot promises, then: a session leaves 20 handles and a +quarter of a megabyte, the menu Windows' fifty and a megabyte, its +entries nothing but *Check for updates*. A session, the menu, the +configuration and the log together left some 300 handles and 6 MB on +0.3.0; they leave some 70 handles and 1.2 MB. + +**The review before the pull request, 2026-09-23**, found and fixed: the +three calls that size their answer and are asked again — the list of +counter sets, a set's registration, a sample — looped for as long as +Windows said the buffer was too small, on the engine's thread, where a +refinement that never returns would hide the session's end; they now try +four times and give no opinion after. A log line said Notepad had been +tried for an address, which it never is. `AGENTS.md` named `ShellExecuteW` +as a call the tray makes; it points at the helper now, and its cost +principle says what this lot learnt: measure what a call leaves, not only +what it takes. diff --git a/docs/design/README.md b/docs/design/README.md index 8167176..e30ae4d 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -27,6 +27,7 @@ session rather than when the code compiles. Each has its own page. | 13 | [Updating](13-updating.md) | done | | 14 | [Release notes people can read](14-release-notes.md) | done | | 15 | [Games Windows knows only from you](15-marked-games.md) | done | +| 16 | [What the watcher keeps](16-footprint.md) | done | **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/how-it-works.md b/docs/how-it-works.md index 83a747a..b7e548b 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -116,6 +116,25 @@ signal mid-session, which does happen. Set `log_level = "debug"` and the log states plainly when Windows released the signal and whether the game had already exited by then. +## What it costs your machine + +Measured on the whole watcher, as Windows counts it, on a gaming PC in +September 2026. Memory here is private memory — Task Manager's *Commit +size* column in *Details*; its *Memory* column shows less. + +| When | Processor | Memory | +| --- | --- | --- | +| Waiting for a game, looking every two seconds | 0.03 % of one core | about 2 MB | +| During a game | nothing measurable: it waits for Windows to wake it | unchanged | +| Naming a game from what the graphics card draws, once a session | | about 0.25 MB, once | +| The icon's menu, the first time it opens | | about 1 MB, once — Windows' cost for a program's first menu | +| *Edit configuration*, *Open log*, *Documentation* | | nothing that stays: a short-lived helper opens them and takes the cost with it | +| *Check for updates* | | about 1 MB, until the watcher next starts | + +Over one evening — a Battlefield 6 session, five hours of GTA Online, the +menu used, and the rest idle, six hours in all — the watcher used 1.25 +seconds of processor time. + ## Why there are two executables Windows makes a program choose, when it is built, between two kinds: diff --git a/docs/reference.md b/docs/reference.md index 8a61031..13caecd 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -42,6 +42,7 @@ way. | `update [--check]` | Look for a newer release on GitHub; with `--check`, say so and stop. Otherwise download it, verify it against the release's `SHA256SUMS.txt` and install it the way the package or the zip's shell does — a running watcher hands its game session to the new one. The one command that connects to anything. Logged under `update`. | | `stop [--handover]` | Stop the running watcher the way *Quit* in its menu does — mid-game, the stop commands run on the way out — and wait until it has gone. With `--handover` an open game session is left to the watcher that follows: the stop commands do not run, and the next start resumes the session with nothing run twice — for an update or an upgrade, where one follows within seconds. None running is not an error. The task is left alone; `install-task` starts it again. The installer runs this before removing (plain) or replacing (`--handover`) the executables. Logged under `setup`. | | `purge [--yes]` | Remove every trace of the program: the logon task, the configuration, the log, the session marker, the executables. It lists what it will remove and asks; `--yes` is for scripts. Refuses while a game is running. See [Removing it](how-it-works.md#removing-it). | +| `open ` | Not in the help, because it is the menu's own: *Edit configuration*, *Open log*, *Documentation* and a release page start the watcher's executable with it, so that what the shell loads to open a file stays in a process that ends rather than in the watcher. Opens `` the way Explorer would — a file no program is associated with, in Notepad — and exits: 0 when it opened, 1 when nothing could. | Global options: `--config `, `--log-level `, `--version`. diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 678a430..e85174d 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -23,6 +23,11 @@ //! several ways of being told the game list changed, at once //! presence-probe cost [rounds] time what an idle poll costs, today and with Lot 15 //! presence-probe footprint what each step of the watcher leaves in memory and handles +//! presence-probe menu-cost +//! what one click of the menu leaves, over five minutes, +//! or the menu itself, light or dark, shown three times +//! presence-probe gpu-load [ms] [rounds] +//! the rendering load the refinement reads, busiest first //! presence-probe microsoft-list ... //! whether Microsoft's own game list covers each executable //! presence-probe activate activate the class ourselves and time it @@ -983,9 +988,268 @@ fn cmd_footprint() -> windows::core::Result<()> { let _ = sensor.rendering_load(std::time::Duration::from_millis(200)); } say("reading them three times more"); + // What a click on *Edit configuration* costs: the menu opens the file + // through the shell. A hidden `cmd /c exit 0` takes the same road + // without putting anything on screen. + let shell = |times: usize| { + use windows::Win32::UI::Shell::ShellExecuteW; + use windows::Win32::UI::WindowsAndMessaging::SW_HIDE; + use windows::core::w; + for _ in 0..times { + // SAFETY: every string is a NUL-terminated literal. + unsafe { + ShellExecuteW( + None, + w!("open"), + w!("cmd.exe"), + w!("/c exit 0"), + None, + SW_HIDE, + ) + }; + } + std::thread::sleep(std::time::Duration::from_secs(2)); + }; + shell(1); + say("opening a program through the shell once, as the menu does"); + shell(3); + say("three times more"); Ok(()) } +// ------------------------------------------------------- the GPU reader -- + +/// The rendering load the refinement reads, every `ms` milliseconds, for +/// `rounds` rounds: the processes with any, busiest first. Run beside +/// `typeperf "\GPU Engine(*engtype_3D)\Utilization Percentage"` to check +/// the reader against Windows' own tool. +fn cmd_gpu_load(ms: u64, rounds: u32) -> windows::core::Result<()> { + use game_mode_executor::detect::gpu; + for _ in 0..rounds { + match gpu::rendering_load(std::time::Duration::from_millis(ms)) { + Ok(load) => { + let mut busiest: Vec<_> = load.into_iter().collect(); + busiest.sort_by(|a, b| b.1.total_cmp(&a.1)); + let line: Vec = busiest + .iter() + .take(5) + .map(|(pid, share)| format!("{pid}={share:.2}%")) + .collect(); + println!("{} {}", timestamp(), line.join(" ")); + } + Err(error) => println!("{} cannot read: {error:#}", timestamp()), + } + } + Ok(()) +} + +/// What one click of the menu leaves in this process, sampled for five +/// minutes: `open`, the shell road *Edit configuration* takes, here to a +/// hidden `cmd /c exit 0` so nothing shows; `check`, *Check for updates* +/// over the network; `spawn`, a plain CreateProcess, for comparison. +fn cmd_menu_cost(click: &str) -> windows::core::Result<()> { + use windows::Win32::UI::Shell::ShellExecuteW; + use windows::Win32::UI::WindowsAndMessaging::SW_HIDE; + use windows::core::w; + + let threads = || { + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + let me = std::process::id(); + let mut count = 0; + // SAFETY: the snapshot is closed below; the entry carries its size. + unsafe { + let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) else { + return 0; + }; + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut more = Thread32First(snapshot, &mut entry).is_ok(); + while more { + if entry.th32OwnerProcessID == me { + count += 1; + } + more = Thread32Next(snapshot, &mut entry).is_ok(); + } + let _ = windows::Win32::Foundation::CloseHandle(snapshot); + } + count + }; + let say = |step: &str| { + let (private, handles) = footprint(); + println!( + "{private:>7.2} MB private {handles:>5} handles {:>3} threads {step}", + threads() + ); + }; + say("before"); + match click { + "menu" | "menu-dark" => { + show_menus(click == "menu-dark", &say); + return Ok(()); + } + "open" => { + // SAFETY: every string is a NUL-terminated literal. + unsafe { + ShellExecuteW( + None, + w!("open"), + w!("cmd.exe"), + w!("/c exit 0"), + None, + SW_HIDE, + ) + }; + } + "check" => { + use game_mode_executor::update; + if let Ok(context) = update::Context::of_this_process(None, None) { + let verdict = update::check_now(&context); + println!("check: {:?}", verdict.map(|_| "answered")); + } + } + "spawn" => { + let _ = std::process::Command::new("cmd.exe") + .args(["/c", "exit", "0"]) + .status(); + } + other => { + eprintln!( + "usage: presence-probe menu-cost , not `{other}`" + ); + std::process::exit(2); + } + } + for (wait, at) in [(1, 1), (4, 5), (25, 30), (90, 120), (180, 300)] { + std::thread::sleep(std::time::Duration::from_secs(wait)); + say(&format!("{click}, {at} s after")); + } + Ok(()) +} + +/// The tray's menu, built the tray's way on a hidden window of this process +/// and shown three times, each closed by a timer after a second -- with or +/// without asking uxtheme for dark menus first, as the tray does at start. +/// It is on screen for that second. +fn show_menus(dark: bool, say: &dyn Fn(&str)) { + use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, WPARAM}; + use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW}; + use windows::Win32::UI::WindowsAndMessaging::{ + AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow, + EndMenu, GetCursorPos, KillTimer, MF_DISABLED, MF_GRAYED, MF_SEPARATOR, MF_STRING, + PostMessageW, RegisterClassW, SetForegroundWindow, SetTimer, TPM_NONOTIFY, TPM_RETURNCMD, + TPM_RIGHTBUTTON, TrackPopupMenuEx, WINDOW_EX_STYLE, WM_NULL, WNDCLASSW, WS_POPUP, + }; + use windows::core::{PCSTR, w}; + + extern "system" fn procedure(window: HWND, message: u32, w: WPARAM, l: LPARAM) -> LRESULT { + // SAFETY: the arguments are the ones Windows passed in. + unsafe { DefWindowProcW(window, message, w, l) } + } + extern "system" fn close(_: HWND, _: u32, _: usize, _: u32) { + // SAFETY: no arguments; ends whatever menu this thread shows. + let _ = unsafe { EndMenu() }; + } + + if dark { + // The tray's call, as `tray::dark` makes it: ordinals 135 and 136. + // SAFETY: the name is a literal; the pointers resolved by ordinal are + // called with the signatures the tray uses on this build. + unsafe { + if let Ok(uxtheme) = LoadLibraryW(w!("uxtheme.dll")) { + if let Some(set) = GetProcAddress(uxtheme, PCSTR(135 as *const u8)) { + let set: unsafe extern "system" fn(i32) -> i32 = std::mem::transmute(set); + set(1); + } + if let Some(flush) = GetProcAddress(uxtheme, PCSTR(136 as *const u8)) { + let flush: unsafe extern "system" fn() = std::mem::transmute(flush); + flush(); + } + } + } + say("after SetPreferredAppMode"); + } + + // SAFETY: the class name is a literal, the procedure lives for the + // process, and the window is destroyed at the end. + let window = unsafe { + let instance = GetModuleHandleW(None).unwrap_or_default(); + let class = WNDCLASSW { + lpfnWndProc: Some(procedure), + hInstance: instance.into(), + lpszClassName: w!("presence-probe-menu"), + ..Default::default() + }; + RegisterClassW(&class); + CreateWindowExW( + WINDOW_EX_STYLE(0), + w!("presence-probe-menu"), + w!(""), + WS_POPUP, + 0, + 0, + 0, + 0, + None, + None, + Some(instance.into()), + None, + ) + }; + let Ok(window) = window else { + println!("no window"); + return; + }; + say("after creating a hidden window"); + + for round in 1..=3 { + // SAFETY: as the tray's `show_menu`: the strings are literals, the menu + // is destroyed on every path, the timer is killed after the menu + // closed, and nothing is borrowed across the modal loop. + unsafe { + let Ok(menu) = CreatePopupMenu() else { + return; + }; + let _ = AppendMenuW( + menu, + MF_STRING | MF_DISABLED | MF_GRAYED, + 0, + w!("GameModeExecutor - no game detected"), + ); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, None); + let _ = AppendMenuW(menu, MF_STRING, 1, w!("Edit configuration")); + let _ = AppendMenuW(menu, MF_STRING, 2, w!("Open log")); + let _ = AppendMenuW(menu, MF_STRING, 3, w!("Documentation")); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, None); + let _ = AppendMenuW(menu, MF_STRING, 4, w!("Check for updates")); + let _ = AppendMenuW(menu, MF_SEPARATOR, 0, None); + let _ = AppendMenuW(menu, MF_STRING, 5, w!("Quit")); + let mut at = POINT::default(); + let _ = GetCursorPos(&mut at); + let timer = SetTimer(None, 0, 1000, Some(close)); + let _ = SetForegroundWindow(window); + let _ = TrackPopupMenuEx( + menu, + TPM_RIGHTBUTTON.0 | TPM_RETURNCMD.0 | TPM_NONOTIFY.0, + at.x, + at.y, + window, + None, + ); + let _ = PostMessageW(Some(window), WM_NULL, WPARAM(0), LPARAM(0)); + let _ = KillTimer(None, timer); + let _ = DestroyMenu(menu); + } + std::thread::sleep(std::time::Duration::from_secs(2)); + say(&format!("after the menu, shown and closed, {round}")); + } + // SAFETY: created above, destroyed once. + let _ = unsafe { DestroyWindow(window) }; +} + fn main() -> windows::core::Result<()> { let seconds = || { std::env::args() @@ -1002,6 +1266,17 @@ fn main() -> windows::core::Result<()> { } Some("activate") => cmd_activate(5, 60), Some("footprint") => cmd_footprint(), + Some("gpu-load") => cmd_gpu_load( + std::env::args() + .nth(2) + .and_then(|value| value.parse().ok()) + .unwrap_or(1000), + std::env::args() + .nth(3) + .and_then(|value| value.parse().ok()) + .unwrap_or(5), + ), + Some("menu-cost") => cmd_menu_cost(std::env::args().nth(2).as_deref().unwrap_or("")), Some("watch-methods") => match std::env::args().nth(3) { Some(sid) => cmd_watch_methods(seconds(), &sid), None => { diff --git a/src/cli.rs b/src/cli.rs index c78ca70..1b1096a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -126,6 +126,14 @@ pub enum Command { #[arg(long)] yes: bool, }, + /// Open a file, a folder or an address the way the shell does, then + /// exit. What the watcher's menu runs, so that the shell's cost ends + /// with this process instead of staying in the watcher. + #[command(hide = true)] + Open { + /// What to open. + what: String, + }, } #[derive(Copy, Clone, Debug, ValueEnum)] @@ -175,6 +183,7 @@ pub fn run(cli: Cli, console: bool) -> Result<()> { } Some(Command::Check { path, pid }) => return check(path.as_deref(), pid), Some(Command::Purge { yes }) => return purge_command(cli.config, yes), + Some(Command::Open { what }) => return crate::open::open_here(&what), _ => {} } @@ -642,6 +651,26 @@ mod tests { )); } + #[test] + fn the_menus_helper_takes_what_to_open_and_is_not_offered() { + // A path with spaces arrives whole: the watcher passes it as one + // argument, quoted by std for `CommandLineToArgvW`, and std reads + // it back the same way. + match parse(&["open", r"C:\Users\A B\config.toml"]).command { + Some(Command::Open { what }) => assert_eq!(what, r"C:\Users\A B\config.toml"), + other => panic!("{other:?}"), + } + use clap::CommandFactory; + let help = Cli::command().render_help().to_string(); + assert!( + !help + .lines() + .any(|line| line.trim_start().starts_with("open ")), + "{help}" + ); + assert!(help.contains("purge"), "the list the check reads: {help}"); + } + #[test] fn check_takes_a_path_or_a_pid_but_not_both() { assert!(matches!( diff --git a/src/detect/gpu.rs b/src/detect/gpu.rs index 3f30127..83bd21d 100644 --- a/src/detect/gpu.rs +++ b/src/detect/gpu.rs @@ -9,38 +9,62 @@ //! presentation through ETW, but that needs administrator rights or membership //! of *Performance Log Users*. These counters need neither on most accounts, //! and every caller here treats a failure to read them as "no opinion". +//! +//! The counters are read through the PerfLib V2 consumer functions, not PDH. +//! *GPU Engine* is a V2 counter set, registered by the display kernel, and +//! Microsoft offers these functions "when you need to collect V2 countersets +//! with minimal dependencies and overhead". PDH read the same values, but +//! kept what it loaded to resolve the counter's path for the rest of the +//! process's life: some 3.7 MB, left by the first read. Through PerfLib the +//! same read leaves 0.2 MB. `docs/design/16-footprint.md` has the +//! measurements. +//! +//! The counter set and its counter are found by their English names, as +//! Microsoft's consumer guide says to do without the provider's symbol +//! file, and the counter's registered type is checked, since the formula +//! depends on it. What PerfLib hands back is a documented byte layout, +//! parsed here in plain code with every size bounded, so the parsing is +//! tested without Windows. +//! use std::collections::HashMap; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; +use windows::Win32::Foundation::{ERROR_NOT_ENOUGH_MEMORY, HANDLE}; use windows::Win32::System::Performance::{ - PDH_FMT_COUNTERVALUE_ITEM_W, PDH_FMT_DOUBLE, PDH_HCOUNTER, PDH_HQUERY, PdhAddEnglishCounterW, - PdhCloseQuery, PdhCollectQueryData, PdhGetFormattedCounterArrayW, PdhOpenQueryW, + PERF_COUNTER_IDENTIFIER, PERF_COUNTER_RATE, PERF_DATA_HEADER, PERF_DELTA_COUNTER, + PERF_DISPLAY_PERCENT, PERF_ERROR_RETURN, PERF_MULTIPLE_INSTANCES, + PERF_REG_COUNTER_ENGLISH_NAMES, PERF_REG_COUNTERSET_ENGLISH_NAME, PERF_REG_COUNTERSET_STRUCT, + PERF_SIZE_LARGE, PERF_TIMER_100NS, PERF_TYPE_COUNTER, PerfAddCounters, PerfCloseQueryHandle, + PerfEnumerateCounterSet, PerfOpenQueryHandle, PerfQueryCounterData, + PerfQueryCounterSetRegistrationInfo, PerfRegInfoType, }; -use windows::core::PCWSTR; +use windows::core::GUID; -/// Wildcard over every process and every engine of every adapter. -const COUNTER_PATH: &str = r"\GPU Engine(*)\Utilization Percentage"; +/// The counter set, and the counter in it, by their English names. +const COUNTER_SET: &str = "GPU Engine"; +const COUNTER: &str = "Utilization Percentage"; -const PDH_MORE_DATA: u32 = 0x8000_07D2; +/// `PERF_100NSEC_TIMER`, as `winperf.h` composes it: a percentage of the +/// elapsed time, computed as `100 * (N1 - N0) / (D1 - D0)` over two samples, +/// with `D` the sample's time in 100 ns units. +/// +const PERF_100NSEC_TIMER: u32 = PERF_SIZE_LARGE + | PERF_TYPE_COUNTER + | PERF_COUNTER_RATE + | PERF_TIMER_100NS + | PERF_DELTA_COUNTER + | PERF_DISPLAY_PERCENT; /// Engines that mean "this process is drawing". Video decode and copy engines /// are busy for a video player or a download too, so they are left out. const RENDERING_ENGINES: &[&str] = &["3d", "vr", "compute"]; -/// A PDH query, closed on drop. -struct Query(PDH_HQUERY); - -impl Drop for Query { - fn drop(&mut self) { - // SAFETY: the handle came from `PdhOpenQueryW` and is closed once. - unsafe { PdhCloseQuery(self.0) }; - } -} - -fn wide(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() -} +/// How many times a call that sizes its answer is asked again. Once is the +/// rule; more happens when instances appear between two calls. A bound, so +/// that an answer that never fits is "no opinion" rather than a refinement +/// that never returns -- it runs on the engine's thread. +const ATTEMPTS: usize = 4; /// Rendering load per process id, as a percentage summed over the engines of /// every adapter. Values can exceed 100 on a multi-adapter or multi-engine @@ -49,101 +73,334 @@ fn wide(value: &str) -> Vec { /// Utilisation is a rate, so it needs two samples. `interval` is how long to /// wait between them; a second is what Task Manager uses. pub fn rendering_load(interval: std::time::Duration) -> Result> { - let mut handle = PDH_HQUERY::default(); - // SAFETY: a null data source means the live machine; `handle` is a valid - // out pointer, and the query it receives is owned by `Query` below. - let status = unsafe { PdhOpenQueryW(PCWSTR::null(), 0, &mut handle) }; - if status != 0 { - bail!("PdhOpenQueryW failed (0x{status:08X})"); - } - let query = Query(handle); - - let path = wide(COUNTER_PATH); - let mut counter = PDH_HCOUNTER::default(); - // SAFETY: `path` is NUL-terminated and outlives the call; the query is - // open, and the counter lives and dies with it. - let status = unsafe { PdhAddEnglishCounterW(query.0, PCWSTR(path.as_ptr()), 0, &mut counter) }; - if status != 0 { - bail!("cannot add the GPU Engine counter (0x{status:08X})"); - } - - // A rate needs a baseline and a second reading. - // SAFETY: the query is open for as long as `query` lives. - let status = unsafe { PdhCollectQueryData(query.0) }; - if status != 0 { - bail!("first PdhCollectQueryData failed (0x{status:08X})"); - } + let (set, counter) = locate()?; + let query = Query::open(&set, counter)?; + let first = query.sample()?; std::thread::sleep(interval); - // SAFETY: as above. - let status = unsafe { PdhCollectQueryData(query.0) }; - if status != 0 { - bail!("second PdhCollectQueryData failed (0x{status:08X})"); - } - - collect(counter) -} - -fn collect(counter: PDH_HCOUNTER) -> Result> { - let mut bytes = 0u32; - let mut items = 0u32; - - // First call sizes the buffer and is expected to fail with PDH_MORE_DATA. - // SAFETY: with no buffer the API only writes the two sizes. - let status = unsafe { - PdhGetFormattedCounterArrayW(counter, PDH_FMT_DOUBLE, &mut bytes, &mut items, None) - }; - if status != PDH_MORE_DATA { - bail!("sizing the counter array failed (0x{status:08X})"); - } - if items == 0 { - return Ok(HashMap::new()); - } - - // Allocated as items rather than bytes so the buffer is aligned for them. - // PDH writes the instance name strings into the tail of the same block. - let size = size_of::(); - let mut buffer: Vec = - Vec::with_capacity(bytes as usize / size + 1); - // SAFETY: the capacity is at least `bytes` bytes, which is what `bytes` - // tells the API it may write, so the items and the strings behind them - // all land inside the allocation. - let status = unsafe { - PdhGetFormattedCounterArrayW( - counter, - PDH_FMT_DOUBLE, - &mut bytes, - &mut items, - Some(buffer.as_mut_ptr()), - ) - }; - if status != 0 { - bail!("reading the counter array failed (0x{status:08X})"); - } - // SAFETY: the API initialised exactly `items` structs at the front of the - // buffer. The strings it wrote after them stay inside the capacity, so the - // `szName` pointers remain valid until `buffer` is dropped -- which is - // after the loop below. - unsafe { buffer.set_len(items as usize) }; + let second = query.sample()?; + Ok(load_between( + &parse_sample(&first)?, + &parse_sample(&second)?, + )) +} + +/// The counter set's identifier and the counter's, found by name, with the +/// counter's type checked against the formula `load_between` applies. +fn locate() -> Result<(GUID, u32)> { + let set = counter_sets()? + .into_iter() + .find(|set| { + registration(set, PERF_REG_COUNTERSET_ENGLISH_NAME) + .is_ok_and(|name| utf16_at(&name, 0).as_deref() == Some(COUNTER_SET)) + }) + .with_context(|| format!("no `{COUNTER_SET}` counter set on this machine"))?; + let names = registration(&set, PERF_REG_COUNTER_ENGLISH_NAMES)?; + let counter = counter_names(&names) + .into_iter() + .find_map(|(id, name)| (name == COUNTER).then_some(id)) + .with_context(|| format!("no `{COUNTER}` counter in `{COUNTER_SET}`"))?; + let structure = registration(&set, PERF_REG_COUNTERSET_STRUCT)?; + match counter_type(&structure, counter) { + Some(PERF_100NSEC_TIMER) => Ok((set, counter)), + other => bail!("`{COUNTER}` has type {other:X?}, not the PERF_100NSEC_TIMER read here"), + } +} + +/// Every counter set registered on this machine. +fn counter_sets() -> Result> { + let mut count = 0u32; + let mut sets = Vec::new(); + for _ in 0..ATTEMPTS { + // SAFETY: the slice carries its own length, which bounds the write; + // `count` is a local. + let status = unsafe { PerfEnumerateCounterSet(None, Some(&mut sets), &mut count) }; + match status { + 0 => { + sets.truncate(count as usize); + return Ok(sets); + } + _ if status == ERROR_NOT_ENOUGH_MEMORY.0 => { + sets = vec![GUID::zeroed(); count as usize]; + } + _ => bail!("PerfEnumerateCounterSet failed ({status})"), + } + } + bail!("the list of counter sets never fit its buffer") +} + +/// One piece of a counter set's registration, sized then read. +fn registration(set: &GUID, code: PerfRegInfoType) -> Result> { + let mut size = 0u32; + let mut buffer = Vec::new(); + for _ in 0..ATTEMPTS { + // SAFETY: `set` outlives the call; the slice carries its own length, + // which bounds the write; `size` is a local. + let status = unsafe { + PerfQueryCounterSetRegistrationInfo(None, set, code, 0, Some(&mut buffer), &mut size) + }; + match status { + 0 => { + buffer.truncate(size as usize); + return Ok(buffer); + } + _ if status == ERROR_NOT_ENOUGH_MEMORY.0 => buffer = vec![0; size as usize], + _ => bail!("PerfQueryCounterSetRegistrationInfo failed ({status})"), + } + } + bail!("a counter set's registration never fit its buffer") +} + +/// A PerfLib query handle, closed on drop. +struct Query(HANDLE); + +impl Drop for Query { + fn drop(&mut self) { + // SAFETY: the handle came from `PerfOpenQueryHandle` and is closed once. + unsafe { PerfCloseQueryHandle(self.0) }; + } +} + +/// What `PerfAddCounters` takes for a multi-instance counter set: the +/// identifier, then the instance name -- `*`, every instance -- padded to a +/// multiple of eight bytes, as in Microsoft's own sample. +#[repr(C)] +struct Specification { + identifier: PERF_COUNTER_IDENTIFIER, + instance: [u16; 4], +} + +impl Query { + fn open(set: &GUID, counter: u32) -> Result { + let mut handle = HANDLE::default(); + // SAFETY: a null machine is this one; `handle` is a valid out pointer, + // and the query it receives is owned by `Query` below. + let status = unsafe { PerfOpenQueryHandle(None, &mut handle) }; + if status != 0 { + bail!("PerfOpenQueryHandle failed ({status})"); + } + let query = Self(handle); + let mut specification = Specification { + identifier: PERF_COUNTER_IDENTIFIER { + CounterSetGuid: *set, + Size: size_of::() as u32, + CounterId: counter, + // Every instance id: the name filter is what selects. + InstanceId: u32::MAX, + ..Default::default() + }, + instance: [u16::from(b'*'), 0, 0, 0], + }; + let size = specification.identifier.Size; + // SAFETY: the pointer is to the whole block, whose first field is the + // identifier (`repr(C)`); the block is `size` bytes long, as the call + // is told, and lives on this frame for the whole call. + let status = unsafe { + PerfAddCounters( + query.0, + (&raw mut specification).cast::(), + size, + ) + }; + // The call can succeed while refusing the one specification it was + // given; that answer is in the block itself. + let refused = specification.identifier.Status; + if status != 0 || refused != 0 { + bail!("PerfAddCounters failed ({status}, {refused})"); + } + Ok(query) + } + + /// One sample, as the bytes PerfLib wrote. + fn sample(&self) -> Result> { + let mut size = 0u32; + // Eight-byte words, so the header's 64-bit fields are aligned. + let mut words: Vec = Vec::new(); + for _ in 0..ATTEMPTS { + // SAFETY: the buffer is `len * 8` bytes, eight-aligned, and the + // call is told exactly that; `size` is a local. + let status = unsafe { + PerfQueryCounterData( + self.0, + Some(words.as_mut_ptr().cast::()), + (words.len() * 8) as u32, + &mut size, + ) + }; + match status { + 0 => { + return Ok(words + .iter() + .flat_map(|word| word.to_le_bytes()) + .take(size as usize) + .collect()); + } + _ if status == ERROR_NOT_ENOUGH_MEMORY.0 => { + words = vec![0; (size as usize).div_ceil(8)]; + } + _ => bail!("PerfQueryCounterData failed ({status})"), + } + } + bail!("the counter data never fit its buffer") + } +} + +// ------------------------------------------------ the documented layouts -- + +fn u32_at(bytes: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes( + bytes.get(at..at.checked_add(4)?)?.try_into().ok()?, + )) +} + +fn u64_at(bytes: &[u8], at: usize) -> Option { + Some(u64::from_le_bytes( + bytes.get(at..at.checked_add(8)?)?.try_into().ok()?, + )) +} + +/// A NUL-terminated UTF-16LE string starting at `at`; `None` when the NUL +/// is missing, which a well-formed block never does. +fn utf16_at(bytes: &[u8], at: usize) -> Option { + let mut units = Vec::new(); + for pair in bytes.get(at..)?.as_chunks::<2>().0 { + match u16::from_le_bytes(*pair) { + 0 => return Some(String::from_utf16_lossy(&units)), + unit => units.push(unit), + } + } + None +} + +/// The counters' names: a `PERF_STRING_BUFFER_HEADER` -- its size and a +/// count -- then one `PERF_STRING_COUNTER_HEADER` per counter, an id and +/// the offset of its name from the start of the block. +fn counter_names(block: &[u8]) -> Vec<(u32, String)> { + let count = u32_at(block, 4).unwrap_or(0) as usize; + (0..count) + .map_while(|i| { + let at = 8 + i * 8; + Some((u32_at(block, at)?, u32_at(block, at + 4)?)) + }) + .filter_map(|(id, offset)| Some((id, utf16_at(block, offset as usize)?))) + .collect() +} + +/// A counter's type: a `PERF_COUNTERSET_REG_INFO` of 32 bytes -- its +/// fourth field the number of counters -- then that many +/// `PERF_COUNTER_REG_INFO` of 48 bytes, id first and type second. +fn counter_type(block: &[u8], counter: u32) -> Option { + const SET: usize = 32; + const COUNTER: usize = 48; + let count = u32_at(block, 24)? as usize; + (0..count) + .map_while(|i| { + let at = SET + i * COUNTER; + Some((u32_at(block, at)?, u32_at(block, at + 4)?)) + }) + .find_map(|(id, kind)| (id == counter).then_some(kind)) +} + +/// One sample of a single counter over every instance: when, and each +/// instance's raw value by name and id. +#[derive(Debug, PartialEq)] +struct Sample { + /// `PerfTime100NSec`, the time base of a `PERF_100NSEC_TIMER`. + time: i64, + values: HashMap<(String, u32), u64>, +} + +/// A `PERF_DATA_HEADER` of 48 bytes, then one `PERF_COUNTER_HEADER` of 16 +/// -- status, type, size -- of type `PERF_MULTIPLE_INSTANCES`: a +/// `PERF_MULTI_INSTANCES` of 8 bytes, its second field the count, then per +/// instance a `PERF_INSTANCE_HEADER` -- its size, its id, its name, padded +/// -- and a `PERF_COUNTER_DATA` -- the value's size, the block's, the value. +fn parse_sample(bytes: &[u8]) -> Result { + const DATA_HEADER: usize = 48; + const COUNTER_HEADER: usize = 16; + let bad = || anyhow::anyhow!("the counter data is not laid out as documented"); + + let total = u32_at(bytes, 0).ok_or_else(bad)? as usize; + let bytes = bytes.get(..total).ok_or_else(bad)?; + if u32_at(bytes, 4) != Some(1) { + bail!("one counter was asked for, not {:?}", u32_at(bytes, 4)); + } + let time = u64_at(bytes, 16).ok_or_else(bad)? as i64; + + let status = u32_at(bytes, DATA_HEADER).ok_or_else(bad)?; + let kind = u32_at(bytes, DATA_HEADER + 4).ok_or_else(bad)?; + if kind == PERF_ERROR_RETURN.0 as u32 { + bail!("the provider answered with error {status}"); + } + if kind != PERF_MULTIPLE_INSTANCES.0 as u32 { + bail!("unexpected result type {kind}"); + } + let end = DATA_HEADER + u32_at(bytes, DATA_HEADER + 8).ok_or_else(bad)? as usize; + let block = bytes.get(..end).ok_or_else(bad)?; + + let instances = DATA_HEADER + COUNTER_HEADER; + let count = u32_at(block, instances + 4).ok_or_else(bad)?; + let mut at = instances + 8; + let mut values = HashMap::new(); + for _ in 0..count { + // Each size covers its own header, so neither can be under eight + // bytes, and every step moves forward. + let header = u32_at(block, at).ok_or_else(bad)? as usize; + if header < 8 { + return Err(bad()); + } + let id = u32_at(block, at + 4).ok_or_else(bad)?; + let data = at.checked_add(header).ok_or_else(bad)?; + // The name ends inside its own block, or it is not a name. + let name = block + .get(..data) + .and_then(|instance| utf16_at(instance, at + 8)) + .ok_or_else(bad)?; + let value_size = u32_at(block, data).ok_or_else(bad)?; + let data_size = u32_at(block, data + 4).ok_or_else(bad)? as usize; + if data_size < 8 { + return Err(bad()); + } + let value = match value_size { + 8 => u64_at(block, data + 8), + 4 => u32_at(block, data + 8).map(u64::from), + _ => None, + } + .ok_or_else(bad)?; + values.insert((name, id), value); + at = data.checked_add(data_size).ok_or_else(bad)?; + } + Ok(Sample { time, values }) +} +/// The rendering load per process between two samples. An instance that +/// is not in both, or whose value went backwards -- an engine that came or +/// went, which Microsoft's example drops too -- says nothing. +fn load_between(first: &Sample, second: &Sample) -> HashMap { + let elapsed = second.time - first.time; let mut load: HashMap = HashMap::new(); - for item in &buffer { - // SAFETY: `szName` points into `buffer`'s tail, still allocated and - // NUL-terminated by PDH. - let name = unsafe { item.szName.to_string() }.unwrap_or_default(); - let Some((pid, engine)) = parse_instance(&name) else { + if elapsed <= 0 { + return load; + } + for (key, &value) in &second.values { + let Some((pid, engine)) = parse_instance(&key.0) else { continue; }; if !RENDERING_ENGINES.contains(&engine.as_str()) { continue; } - // SAFETY: the array was requested as PDH_FMT_DOUBLE, so this is the - // union member PDH wrote. - let value = unsafe { item.FmtValue.Anonymous.doubleValue }; - if value.is_finite() && value > 0.0 { - *load.entry(pid).or_default() += value; + let Some(busy) = first + .values + .get(key) + .and_then(|&before| value.checked_sub(before)) + else { + continue; + }; + let share = 100.0 * busy as f64 / elapsed as f64; + if share > 0.0 { + *load.entry(pid).or_default() += share; } } - Ok(load) + load } /// Instance names look like @@ -164,6 +421,192 @@ fn parse_instance(name: &str) -> Option<(u32, String)> { mod tests { use super::*; + fn utf16(text: &str) -> Vec { + text.encode_utf16() + .chain(std::iter::once(0)) + .flat_map(u16::to_le_bytes) + .collect() + } + + fn pad(bytes: &mut Vec) { + while !bytes.len().is_multiple_of(8) { + bytes.push(0); + } + } + + /// A sample laid out as PerfLib lays one out: `time`, then each + /// instance's name, id and 8-byte value. + fn sample_bytes(time: i64, instances: &[(&str, u32, u64)]) -> Vec { + let mut body = Vec::new(); + for (name, id, value) in instances { + let mut header = vec![0; 8]; + header.extend(utf16(name)); + pad(&mut header); + let size = header.len() as u32; + header[0..4].copy_from_slice(&size.to_le_bytes()); + header[4..8].copy_from_slice(&id.to_le_bytes()); + body.extend(header); + body.extend(8u32.to_le_bytes()); + body.extend(16u32.to_le_bytes()); + body.extend(value.to_le_bytes()); + } + let mut instances_block = Vec::new(); + instances_block.extend((8 + body.len() as u32).to_le_bytes()); + instances_block.extend((instances.len() as u32).to_le_bytes()); + instances_block.extend(body); + + let mut counter = Vec::new(); + counter.extend(0u32.to_le_bytes()); + counter.extend((PERF_MULTIPLE_INSTANCES.0 as u32).to_le_bytes()); + counter.extend((16 + instances_block.len() as u32).to_le_bytes()); + counter.extend(0u32.to_le_bytes()); + counter.extend(instances_block); + + let mut bytes = Vec::new(); + bytes.extend((48 + counter.len() as u32).to_le_bytes()); + bytes.extend(1u32.to_le_bytes()); + bytes.extend(0i64.to_le_bytes()); // PerfTimeStamp + bytes.extend(time.to_le_bytes()); // PerfTime100NSec + bytes.extend(10_000_000i64.to_le_bytes()); // PerfFreq + bytes.extend([0; 16]); // SystemTime + bytes.extend(counter); + bytes + } + + const GAME_3D: &str = "pid_4242_luid_0x00000000_0x0001A2B3_phys_0_eng_0_engtype_3D"; + const GAME_COPY: &str = "pid_4242_luid_0x00000000_0x0001A2B3_phys_0_eng_5_engtype_Copy"; + const LAUNCHER_3D: &str = "pid_77_luid_0x00000000_0x0001A2B3_phys_0_eng_0_engtype_3D"; + + #[test] + fn a_sample_is_read_as_laid_out() { + let bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000), (LAUNCHER_3D, 0, 20)]); + let sample = parse_sample(&bytes).unwrap(); + assert_eq!(sample.time, 5_000); + assert_eq!(sample.values.len(), 2); + assert_eq!(sample.values[&(GAME_3D.to_owned(), 3)], 1_000); + assert_eq!(sample.values[&(LAUNCHER_3D.to_owned(), 0)], 20); + } + + #[test] + fn an_empty_sample_is_empty() { + let sample = parse_sample(&sample_bytes(1, &[])).unwrap(); + assert!(sample.values.is_empty()); + } + + #[test] + fn a_cut_sample_is_refused_not_misread() { + let bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000), (LAUNCHER_3D, 0, 20)]); + for cut in [0, 10, 48, 60, 72, 100, bytes.len() - 1] { + // The header's own total says more than there is. + assert!(parse_sample(&bytes[..cut]).is_err(), "cut at {cut}"); + } + } + + #[test] + fn a_count_larger_than_the_block_is_refused() { + let mut bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000)]); + // PERF_MULTI_INSTANCES' count, just after the two headers. + bytes[68..72].copy_from_slice(&9u32.to_le_bytes()); + assert!(parse_sample(&bytes).is_err()); + } + + #[test] + fn an_error_from_the_provider_is_an_error() { + let mut bytes = sample_bytes(5_000, &[(GAME_3D, 3, 1_000)]); + bytes[52..56].copy_from_slice(&(PERF_ERROR_RETURN.0 as u32).to_le_bytes()); + assert!(parse_sample(&bytes).is_err()); + } + + #[test] + fn load_is_the_busy_share_of_the_interval() { + // One second in 100 ns units; the game's 3D engine busy 600 ms of it, + // the launcher's 5 ms, the game's copy engine all of it. + let first = parse_sample(&sample_bytes( + 0, + &[(GAME_3D, 0, 0), (GAME_COPY, 0, 0), (LAUNCHER_3D, 0, 100)], + )) + .unwrap(); + let second = parse_sample(&sample_bytes( + 10_000_000, + &[ + (GAME_3D, 0, 6_000_000), + (GAME_COPY, 0, 10_000_000), + (LAUNCHER_3D, 0, 50_100), + ], + )) + .unwrap(); + let load = load_between(&first, &second); + assert!((load[&4242] - 60.0).abs() < 1e-9, "copy engine left out"); + assert!((load[&77] - 0.5).abs() < 1e-9); + } + + #[test] + fn an_engine_that_came_or_went_says_nothing() { + let first = parse_sample(&sample_bytes(0, &[(GAME_3D, 0, 500)])).unwrap(); + let second = parse_sample(&sample_bytes( + 10_000_000, + &[(GAME_3D, 0, 100), (LAUNCHER_3D, 0, 9_000)], + )) + .unwrap(); + // The game's value went backwards, the launcher was not there before. + assert!(load_between(&first, &second).is_empty()); + } + + #[test] + fn no_time_elapsed_is_no_load() { + let sample = parse_sample(&sample_bytes(7, &[(GAME_3D, 0, 500)])).unwrap(); + assert!(load_between(&sample, &sample).is_empty()); + } + + #[test] + fn counter_names_are_read_from_their_offsets() { + let mut block = vec![0; 8 + 2 * 8]; + let first = block.len() as u32; + block.extend(utf16("Running Time")); + let second = block.len() as u32; + block.extend(utf16("Utilization Percentage")); + block[4..8].copy_from_slice(&2u32.to_le_bytes()); + for (i, (id, offset)) in [(1u32, first), (2, second)].into_iter().enumerate() { + block[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes()); + block[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes()); + } + assert_eq!( + counter_names(&block), + vec![ + (1, "Running Time".to_owned()), + (2, "Utilization Percentage".to_owned()) + ] + ); + // A name whose offset points outside the block is left out. + block[12..16].copy_from_slice(&9_999u32.to_le_bytes()); + assert_eq!(counter_names(&block).len(), 1); + } + + #[test] + fn a_counter_type_is_found_by_id() { + let mut block = vec![0; 32 + 2 * 48]; + block[24..28].copy_from_slice(&2u32.to_le_bytes()); + for (i, (id, kind)) in [(1u32, 0x0001_0100u32), (2, PERF_100NSEC_TIMER)] + .into_iter() + .enumerate() + { + let at = 32 + i * 48; + block[at..at + 4].copy_from_slice(&id.to_le_bytes()); + block[at + 4..at + 8].copy_from_slice(&kind.to_le_bytes()); + } + assert_eq!(counter_type(&block, 2), Some(PERF_100NSEC_TIMER)); + assert_eq!(counter_type(&block, 1), Some(0x0001_0100)); + assert_eq!(counter_type(&block, 3), None); + assert_eq!(counter_type(&block[..40], 2), None); + } + + #[test] + fn the_timer_type_is_the_one_registered() { + // What `GPU Engine\Utilization Percentage` declares, read on + // 2026-09-23: 0x20510500, `PERF_100NSEC_TIMER` in winperf.h. + assert_eq!(PERF_100NSEC_TIMER, 0x2051_0500); + } + #[test] fn instance_names_are_parsed() { assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index eb2015b..3445873 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod engine; pub mod exit; pub mod logging; pub mod marker; +pub mod open; pub mod package; pub mod purge; pub mod registry; diff --git a/src/open.rs b/src/open.rs new file mode 100644 index 0000000..3c965d7 --- /dev/null +++ b/src/open.rs @@ -0,0 +1,135 @@ +//! Opening a file, a folder or an address the way the user's settings say, +//! for the entries of the menu -- in a helper process that ends. +//! +//! `ShellExecute` loads the shell's machinery into the process that calls it +//! and leaves it there: 141 handles and 1.2 MB for the rest of the process's +//! life, measured on 2026-09-23, and a first open of another kind of file +//! adds more (`docs/design/16-footprint.md`). The watcher lives for days, so +//! it never calls the shell itself: it starts its own executable with the +//! hidden `open` command, which makes that one call and exits, and the cost +//! goes with it. A plain `CreateProcess` leaves two handles. +//! +//! The helper does what Microsoft documents for a caller that exits right +//! after: COM initialised as a single-threaded apartment before the shell is +//! called, and `ShellExecuteExW` with `SEE_MASK_NOASYNC`, so that the launch +//! has finished when the process ends. +//! + +use std::path::Path; + +use anyhow::{Context, Result}; +use windows::Win32::System::Com::{ + COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE, CoInitializeEx, CoUninitialize, +}; +use windows::Win32::UI::Shell::{ + SEE_MASK_FLAG_LOG_USAGE, SEE_MASK_NOASYNC, SHELLEXECUTEINFOW, ShellExecuteExW, +}; +use windows::Win32::UI::WindowsAndMessaging::{AllowSetForegroundWindow, SW_SHOWNORMAL}; +use windows::core::PCWSTR; + +use crate::logging::target; + +/// From the watcher: have the helper open `what`, and say in the log how +/// it went once it has. Returns at once; the menu is not held up. +pub fn open(what: &str) { + use std::os::windows::process::CommandExt; + /// CREATE_NO_WINDOW: no console, should the watcher be the console twin. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let spawned = std::env::current_exe() + .context("cannot find this executable") + .and_then(|exe| { + std::process::Command::new(exe) + .arg("open") + .arg(what) + .creation_flags(CREATE_NO_WINDOW) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .context("cannot start the helper") + }); + let mut helper = match spawned { + Ok(helper) => helper, + Err(error) => { + tracing::warn!( + target: target::WATCHER, + what, + error = %format!("{error:#}"), + "Could not open it: the helper that opens files did not start" + ); + return; + } + }; + // The window the helper opens is the user's answer to a click, and should + // come to the front. The watcher may pass that right on: the menu made it + // the foreground process. + // SAFETY: a process id, no pointers. + let _ = unsafe { AllowSetForegroundWindow(helper.id()) }; + let what = what.to_owned(); + std::thread::spawn(move || match helper.wait() { + Ok(status) if status.success() => { + tracing::debug!(target: target::WATCHER, what, "Opened"); + } + Ok(status) => tracing::warn!( + target: target::WATCHER, + what, + code = status.code(), + "Nothing opened: the shell would not take it, nor Notepad for a file" + ), + Err(error) => tracing::debug!( + target: target::WATCHER, + what, + error = %error, + "The helper that opens files could not be waited for" + ), + }); +} + +/// In the helper: open `what` through the shell, or -- for a file the +/// shell has no program for, the likely miss being a `.toml` -- in Notepad, +/// since a menu entry that silently does nothing is worse than a plain +/// editor. +pub fn open_here(what: &str) -> Result<()> { + // SAFETY: no pointers; paired with the uninitialise below, on this thread. + let com = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) }; + let opened = execute(what, None).or_else(|refused| { + if Path::new(what).is_file() { + execute("notepad.exe", Some(&format!("\"{what}\""))) + } else { + Err(refused) + } + }); + if com.is_ok() { + // SAFETY: COM was initialised above on this thread, and nothing of + // it is used after this point. + unsafe { CoUninitialize() }; + } + opened +} + +fn execute(file: &str, parameters: Option<&str>) -> Result<()> { + let verb = wide("open"); + let file_wide = wide(file); + let parameters_wide = parameters.map(wide); + let mut info = SHELLEXECUTEINFOW { + cbSize: size_of::() as u32, + // NOASYNC: this process ends right after. LOG_USAGE: a launch the + // user asked for, as Microsoft asks such calls to say. + fMask: SEE_MASK_NOASYNC | SEE_MASK_FLAG_LOG_USAGE, + lpVerb: PCWSTR(verb.as_ptr()), + lpFile: PCWSTR(file_wide.as_ptr()), + lpParameters: parameters_wide + .as_ref() + .map_or(PCWSTR::null(), |p| PCWSTR(p.as_ptr())), + nShow: SW_SHOWNORMAL.0, + ..Default::default() + }; + // SAFETY: the structure carries its own size, and every string it points + // to is NUL-terminated and outlives the call. + unsafe { ShellExecuteExW(&mut info) }.with_context(|| format!("the shell refused `{file}`")) +} + +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} diff --git a/src/tray.rs b/src/tray.rs index b6adc6a..730214a 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -27,14 +27,14 @@ use windows::Win32::UI::Shell::{ NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIIF_ERROR, NIIF_INFO, NIIF_NOSOUND, NIIF_RESPECT_QUIET_TIME, NIM_ADD, NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NOTIFY_ICON_DATA_FLAGS, NOTIFY_ICON_INFOTIP_FLAGS, NOTIFYICON_VERSION_4, NOTIFYICONDATAW, - Shell_NotifyIconW, ShellExecuteW, + Shell_NotifyIconW, }; use windows::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CreateIconFromResourceEx, CreatePopupMenu, DestroyIcon, DestroyMenu, GetSystemMetrics, HICON, IMAGE_FLAGS, LR_DEFAULTCOLOR, MF_DISABLED, MF_GRAYED, MF_SEPARATOR, - MF_STRING, PostMessageW, RegisterWindowMessageW, SM_CXSMICON, SM_CYSMICON, SW_SHOWNORMAL, - SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, TrackPopupMenuEx, WM_APP, - WM_CONTEXTMENU, WM_DPICHANGED, WM_NULL, WM_SETTINGCHANGE, + MF_STRING, PostMessageW, RegisterWindowMessageW, SM_CXSMICON, SM_CYSMICON, SetForegroundWindow, + TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, TrackPopupMenuEx, WM_APP, WM_CONTEXTMENU, + WM_DPICHANGED, WM_NULL, WM_SETTINGCHANGE, }; use windows::core::PCWSTR; @@ -955,7 +955,7 @@ fn run_update_action(action: crate::update::Action) { url, "Opening the release page" ); - open(&url, None); + crate::open::open(&url); } other => crate::update::perform(other), } @@ -964,8 +964,8 @@ fn run_update_action(action: crate::update::Action) { fn run_command(id: usize) { match id { ID_CONFIG | ID_LOG => { - // Copy the path out, then let go: ShellExecuteW can show UI of its - // own, which pumps messages like anything else. + // Copy the path out, then let go. The helper that opens it runs + // in its own process; nothing here waits on it. let path = TRAY.with(|cell| { cell.borrow().as_ref().map(|tray| { if id == ID_CONFIG { @@ -976,11 +976,11 @@ fn run_command(id: usize) { }) }); if let Some(path) = path { - open_path(&path); + crate::open::open(&path.to_string_lossy()); } } ID_DOCS => { - open(crate::build_info::DOCS_URL, None); + crate::open::open(crate::build_info::DOCS_URL); } ID_QUIT => { let stop = TRAY.with(|cell| cell.borrow().as_ref().map(|tray| Arc::clone(&tray.stop))); @@ -998,45 +998,6 @@ fn run_command(id: usize) { } } -/// Open a file the way the user's own settings say to, falling back to Notepad. -fn open_path(path: &std::path::Path) { - let target = path.to_string_lossy().into_owned(); - if open(&target, None) { - return; - } - // A `.toml` with no association is the likely miss, and a menu entry that - // silently does nothing is worse than one that opens a plain editor. - tracing::debug!( - target: crate::logging::target::WATCHER, - path = %path.display(), - "No association for this file, opening it in Notepad" - ); - open("notepad.exe", Some(&format!("\"{target}\""))); -} - -/// Returns false when the shell refused. `ShellExecuteW` hands back a fake -/// `HINSTANCE` whose value is an error code at or below 32. -fn open(target: &str, arguments: Option<&str>) -> bool { - let verb = wide("open"); - let target = wide(target); - let arguments = arguments.map(wide); - // SAFETY: every string is NUL-terminated and outlives the call, and - // nothing in the tray is borrowed while the shell may show UI. - let result = unsafe { - ShellExecuteW( - None, - PCWSTR(verb.as_ptr()), - PCWSTR(target.as_ptr()), - arguments - .as_ref() - .map_or(PCWSTR::null(), |a| PCWSTR(a.as_ptr())), - PCWSTR::null(), - SW_SHOWNORMAL, - ) - }; - result.0 as usize > 32 -} - /// Build an `HICON` at the size the shell asks for, from the compiled-in file. fn load_icon(state: State, theme: Theme, window: HWND) -> Result { let data = ICONS