gamemode: global auto-trigger via niri window watch

Add scripts/gamemode-watch.py, launched at niri startup, which subscribes
to niri's event stream and toggles gamemode whenever a game window
(steam_app_*, RuneLite, gamescope) is open — so Steam games trigger
gamemode with zero per-game setup instead of needing gamemoderun %command%.

'winwatch' is a third reference-counted source alongside feral + manual.
Pure Watcher class is unit-tested; SIGTERM/finally guarantee cleanup so
the watcher stopping never leaves gamemode stuck on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-15 21:51:52 -07:00
parent 98f6c56fe3
commit 0ab08e8561
5 changed files with 287 additions and 14 deletions
@@ -8,16 +8,24 @@
Add a compositor-level "gamemode": while a game is running, inhibit idle/blanking,
silence notifications, switch to the `performance` power profile (and bump the fan),
and hide waybar — then revert everything cleanly when the game ends. One script,
`scripts/gamemode-session.sh`, is the whole engine. It is driven by **two independent
sources** and reference-counted, so the two triggers can overlap safely:
`scripts/gamemode-session.sh`, is the whole engine. It is driven by **three
independent sources** and reference-counted, so the triggers can overlap safely:
- **Automatic** — Feral `gamemoded` custom start/end hooks (any game launched with
`gamemoderun`).
- **Manual** — a `Mod+G` niri keybind, for games that don't call gamemode.
- **Window watch (`winwatch`)** — `scripts/gamemode-watch.py`, launched at niri
startup, subscribes to niri's event stream and toggles gamemode whenever a game
window (app_id matching `steam_app_*`, RuneLite, or gamescope) is open. This makes
Steam games automatic with **zero per-game setup** — the primary trigger.
- **Feral (`feral`)** — Feral `gamemoded` custom start/end hooks, for
gamemode-aware launchers run with `gamemoderun` that the window watch may not match.
- **Manual (`manual`)** — a `Mod+G` niri keybind, for anything else.
Effects apply on the first activation (0→1 active sources) and revert on the last
deactivation (1→0). Also removes three dead VRR window-rules found during review.
> **Addendum (2026-07-15):** The window-watch source was added after the initial
> design so Steam games trigger gamemode globally instead of needing
> `gamemoderun %command%` per game. See "Window watch" below.
## Current Behaviour
- **Compositor:** niri 26.04, single internal panel `eDP-1` (BOE, 2256×1504@60,
@@ -174,22 +182,47 @@ Mod+G { spawn "gamemode-session" "toggle"; }
Leave a one-line comment on the `steam_app_` rule noting to re-add
`variable-refresh-rate true` if a VRR-capable external monitor is ever attached.
### 5. `scripts/gamemode-watch.py` (new) — window watch
A Python daemon (Python already used by the theme generator) launched via
`spawn-at-startup "gamemode-watch"`. It runs `niri msg --json event-stream` and
maintains the set of open **game** windows:
- `is_game(app_id)` matches `steam_app_` (substring), the RuneLite ids
(`net-runelite-client-RuneLite|runelite|RuneLite`), and `gamescope`
(case-insensitive) — mirrors the niri window rules. Bolt's *launcher* window is
deliberately excluded (it would trigger before you're in-game).
- `WindowsChanged` (connect snapshot) rebuilds the set; `WindowOpenedOrChanged`
adds/removes by app_id; `WindowClosed` (id only) discards by id.
- On empty→non-empty it calls `gamemode-session add winwatch`; non-empty→empty,
`del winwatch`.
- Event logic is a pure `Watcher` class (injected `emit`) so it unit-tests without
niri or subprocesses (`scripts/tests/gamemode-watch.test.py`).
- Robustness: reconnects if the stream drops while niri is alive; a SIGTERM handler
and a `finally` guarantee `del winwatch` on exit, so the watcher stopping can
never leave gamemode stuck on.
## Files Touched
- `scripts/gamemode-session.sh`**new**, the engine.
- `mako/config`**new**, adds the `do-not-disturb` mode (plus current settings).
- `scripts/gamemode-watch.py`**new**, niri-event-stream window watch (primary
auto-trigger). Tested by `scripts/tests/gamemode-watch.test.py`.
- `mako/config`**new**, adds the `do-not-disturb` mode (plus current settings);
also un-ignored in `.gitignore` (was previously untracked by choice).
- `gamemode.ini`**new**, Feral start/end hooks.
- `niri/config.kdl` — add `Mod+G` bind; remove 3 dead VRR rules; add one comment.
- `install.sh` — add the `gamemode-session` symlink + `gamemode.ini`/`mako/config`
placement, matching how existing scripts/configs are linked (or create the
symlink manually if install.sh doesn't enumerate).
- `niri/config.kdl` — add `Mod+G` bind; `spawn-at-startup "gamemode-watch"`; remove
3 dead VRR rules; add one comment.
- `install.sh` — link `gamemode-session`, `gamemode-watch`, `gamemode.ini`, and
`mako/config` (the last was never linked before).
## Usage
- **Steam:** set launch options to `gamemoderun %command%` (per-game or as a global
default) → fully automatic enter/revert.
- **Other games / anything gamemode doesn't catch:** `Mod+G` to toggle.
- Both converge on `gamemode-session`; overlapping is safe.
- **Steam / RuneLite / gamescope games:** nothing to do — the window watch turns
gamemode on when a game window opens and off when the last one closes.
- **Gamemode-aware launchers** (Lutris/Heroic via `gamemoderun`): covered by the
Feral hooks even if their window app_id isn't matched.
- **Anything else / manual override:** `Mod+G` to toggle.
- All three converge on `gamemode-session`; overlapping is safe (reference-counted).
## Out of Scope (YAGNI)