0ab08e8561
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>
143 lines
4.1 KiB
Python
Executable File
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)
|