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