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:
@@ -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)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ ln -sf "$(pwd)/scripts/waybar-restart.sh" ~/.local/bin/waybar-restart
|
||||
ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot
|
||||
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
|
||||
ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session
|
||||
ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch
|
||||
|
||||
echo "==> Enabling systemd user services"
|
||||
mkdir -p ~/.config/systemd/user
|
||||
|
||||
@@ -45,6 +45,7 @@ spawn-at-startup "hypridle"
|
||||
spawn-at-startup "wl-paste" "--watch" "cliphist" "store"
|
||||
spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1"
|
||||
spawn-at-startup "xwayland-satellite"
|
||||
spawn-at-startup "gamemode-watch"
|
||||
|
||||
binds {
|
||||
Mod+Q repeat=false { close-window; }
|
||||
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gamemode-watch — drive gamemode-session from niri's window event stream.
|
||||
|
||||
Subscribes to `niri msg --json event-stream`, tracks which open windows are
|
||||
games (matched by app_id), and calls `gamemode-session add/del winwatch` on the
|
||||
empty<->non-empty transition. 'winwatch' is just another reference-counted
|
||||
source, so it coexists with the Mod+G toggle and the Feral gamemode.ini hooks.
|
||||
|
||||
Launched at niri startup (spawn-at-startup "gamemode-watch"). Reconnects if the
|
||||
event stream drops while niri is still alive, and always releases the source on
|
||||
exit so gamemode can never get stuck on when the watcher stops.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# app_id patterns that count as "a game" — mirrors the niri window rules.
|
||||
GAME_PATTERNS = [
|
||||
re.compile(r"steam_app_"),
|
||||
re.compile(r"^(net-runelite-client-RuneLite|runelite|RuneLite)$"),
|
||||
re.compile(r"gamescope", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def is_game(app_id):
|
||||
if not app_id:
|
||||
return False
|
||||
return any(p.search(app_id) for p in GAME_PATTERNS)
|
||||
|
||||
|
||||
class Watcher:
|
||||
"""Pure event logic. `emit(action)` is called with "add"/"del" on the
|
||||
transition into/out of "at least one game window open"."""
|
||||
|
||||
def __init__(self, emit):
|
||||
self.games = set() # ids of currently-open game windows
|
||||
self.active = False
|
||||
self.emit = emit
|
||||
|
||||
def _reconcile(self):
|
||||
want = len(self.games) > 0
|
||||
if want and not self.active:
|
||||
self.active = True
|
||||
self.emit("add")
|
||||
elif not want and self.active:
|
||||
self.active = False
|
||||
self.emit("del")
|
||||
|
||||
def handle(self, ev):
|
||||
if "WindowsChanged" in ev: # full snapshot (sent on connect)
|
||||
self.games = {
|
||||
w["id"]
|
||||
for w in ev["WindowsChanged"]["windows"]
|
||||
if is_game(w.get("app_id"))
|
||||
}
|
||||
self._reconcile()
|
||||
elif "WindowOpenedOrChanged" in ev: # fired on open AND on any change
|
||||
w = ev["WindowOpenedOrChanged"]["window"]
|
||||
if is_game(w.get("app_id")):
|
||||
self.games.add(w["id"])
|
||||
else:
|
||||
self.games.discard(w["id"])
|
||||
self._reconcile()
|
||||
elif "WindowClosed" in ev: # only carries the id, no app_id
|
||||
self.games.discard(ev["WindowClosed"]["id"])
|
||||
self._reconcile()
|
||||
|
||||
|
||||
def call(action):
|
||||
subprocess.run(
|
||||
["gamemode-session", action, "winwatch"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def niri_alive():
|
||||
try:
|
||||
return subprocess.run(
|
||||
["niri", "msg", "version"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).returncode == 0
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def stream_lines():
|
||||
proc = subprocess.Popen(
|
||||
["niri", "msg", "--json", "event-stream"],
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
yield line
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def main():
|
||||
watcher = Watcher(call)
|
||||
try:
|
||||
while True:
|
||||
for line in stream_lines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
watcher.handle(ev)
|
||||
# stream ended; stop if niri is gone, else reconnect
|
||||
if not niri_alive():
|
||||
break
|
||||
time.sleep(2)
|
||||
finally:
|
||||
if watcher.active:
|
||||
call("del") # never leave gamemode stuck on
|
||||
|
||||
|
||||
def _on_sigterm(signum, frame):
|
||||
# SIGTERM (how niri/logout stops us) -> KeyboardInterrupt so main()'s
|
||||
# `finally` runs and releases the winwatch source — never leave gamemode on.
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGTERM, _on_sigterm)
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for gamemode-watch's pure event logic (no niri, no subprocess).
|
||||
Loads the hyphenated script by path and drives Watcher with synthetic events,
|
||||
asserting the exact sequence of add/del calls."""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
MOD_PATH = os.path.join(HERE, "..", "gamemode-watch.py")
|
||||
spec = importlib.util.spec_from_file_location("gamemode_watch", MOD_PATH)
|
||||
gw = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(gw)
|
||||
|
||||
fails = 0
|
||||
|
||||
|
||||
def check(desc, got, want):
|
||||
global fails
|
||||
if got == want:
|
||||
print(f"ok - {desc}")
|
||||
else:
|
||||
print(f"FAIL - {desc} (got {got!r} want {want!r})")
|
||||
fails = 1
|
||||
|
||||
|
||||
# --- is_game ---------------------------------------------------------------
|
||||
check("steam_app_ matches", gw.is_game("steam_app_480"), True)
|
||||
check("bare steam_app_ matches", gw.is_game("steam_app_"), True)
|
||||
check("RuneLite matches", gw.is_game("RuneLite"), True)
|
||||
check("runelite matches", gw.is_game("runelite"), True)
|
||||
check("runelite full id matches", gw.is_game("net-runelite-client-RuneLite"), True)
|
||||
check("gamescope matches", gw.is_game("gamescope"), True)
|
||||
check("Gamescope (case) matches", gw.is_game("Gamescope"), True)
|
||||
check("Alacritty is not a game", gw.is_game("Alacritty"), False)
|
||||
check("bolt-launcher excluded", gw.is_game("bolt-launcher"), False)
|
||||
check("empty app_id is not a game", gw.is_game(""), False)
|
||||
check("None app_id is not a game", gw.is_game(None), False)
|
||||
|
||||
|
||||
# --- Watcher transitions ---------------------------------------------------
|
||||
def drive(events):
|
||||
calls = []
|
||||
w = gw.Watcher(lambda action: calls.append(action))
|
||||
for ev in events:
|
||||
w.handle(ev)
|
||||
return calls
|
||||
|
||||
|
||||
def win(wid, app_id):
|
||||
return {"WindowOpenedOrChanged": {"window": {"id": wid, "app_id": app_id}}}
|
||||
|
||||
|
||||
def closed(wid):
|
||||
return {"WindowClosed": {"id": wid}}
|
||||
|
||||
|
||||
def snapshot(pairs):
|
||||
return {"WindowsChanged": {"windows": [{"id": i, "app_id": a} for i, a in pairs]}}
|
||||
|
||||
|
||||
check("empty snapshot: no calls", drive([snapshot([])]), [])
|
||||
check("non-game window: no calls", drive([win(1, "Alacritty")]), [])
|
||||
check("one game opens: add", drive([win(1, "steam_app_10")]), ["add"])
|
||||
check(
|
||||
"second game keeps active (single add)",
|
||||
drive([win(1, "steam_app_10"), win(2, "RuneLite")]),
|
||||
["add"],
|
||||
)
|
||||
check(
|
||||
"close one of two: still active",
|
||||
drive([win(1, "steam_app_10"), win(2, "RuneLite"), closed(2)]),
|
||||
["add"],
|
||||
)
|
||||
check(
|
||||
"close last game: add then del",
|
||||
drive([win(1, "steam_app_10"), closed(1)]),
|
||||
["add", "del"],
|
||||
)
|
||||
check(
|
||||
"snapshot already containing a game: add (restart mid-game)",
|
||||
drive([snapshot([(1, "Alacritty"), (5, "steam_app_9")])]),
|
||||
["add"],
|
||||
)
|
||||
check(
|
||||
"game window changes to non-game app_id: del",
|
||||
drive([win(1, "steam_app_10"), win(1, "Alacritty")]),
|
||||
["add", "del"],
|
||||
)
|
||||
check(
|
||||
"repeated OpenedOrChanged for same game: single add",
|
||||
drive([win(1, "gamescope"), win(1, "gamescope"), win(1, "gamescope")]),
|
||||
["add"],
|
||||
)
|
||||
|
||||
sys.exit(fails)
|
||||
Reference in New Issue
Block a user