Files
dotfiles/scripts/gamemode-watch.py
T
funman300 3d08abb060 gamemode-watch: single-instance flock guard
Prevents a manually-launched watcher and the spawn-at-startup one from
racing the same winwatch source; the second instance exits immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:54:17 -07:00

161 lines
4.7 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 fcntl
import json
import os
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
def acquire_single_instance():
# Hold an exclusive lock so a second watcher (e.g. spawn-at-startup plus a
# manual launch) exits instead of racing the same winwatch source. The open
# file must stay referenced for the process lifetime to keep the lock.
rt = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
f = open(os.path.join(rt, "gamemode-watch.lock"), "w")
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return None
return f
if __name__ == "__main__":
_lock = acquire_single_instance()
if _lock is None:
sys.exit(0) # another instance already running
signal.signal(signal.SIGTERM, _on_sigterm)
try:
main()
except KeyboardInterrupt:
pass
sys.exit(0)