Files
dotfiles/scripts/gamemode-watch.py
T
funman300 0ab08e8561 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>
2026-07-15 21:51:52 -07:00

143 lines
4.1 KiB
Python
Executable File

#!/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)