A mode is on because something is true right now: a game is running, a window is fullscreen on a given display, a workspace is focused, the clock is inside a window. That is asked again rather than fired once, and it is the whole reason schedules could be included here without the usual failure modes. A machine asleep at 23:30, rebooted at 02:00, or opened at 08:00 into a window that has already passed all reach the right answer by being asked again; an alarm gets all three wrong. The midnight-crossing rule is the part worth being careful about: a window belongs to the day it STARTS on, so a Friday-only 23:30-07:00 covers Saturday morning and must not cover Saturday night. That arithmetic was tested as pure logic before anything was built on it, including every malformed input failing closed -- silencing someone because a time string was wrong is the worst way this could fail. This does not take over the manual timed session. FocusSession already owns that, with its capsule, shortcut, Quick Settings entry and contracts, so modes defer entirely while one runs. Two writers of Do Not Disturb would each restore whatever the other happened to leave behind. Gaming hands over rather than being duplicated. The hook was silencing notifications itself, which would have made exactly those two owners -- and Gaming.active only polls while its settings page is open, so a mode could not have seen a game reliably in any case. The hook reports the game over IPC now and the mode decides what that means, the Gaming page points at it, and gamingSilenceNotifications is retired from the schema, since a setting nothing reads is the dead row this work keeps removing. Sleep ships disabled. A desktop that starts silencing someone on first boot has overstepped, whatever the default hour. Three contracts moved with it. gaming-contract asserted the hook uses setDnd, which was right before and wrong now; the shell-side assertions that setDnd and dndState exist stay, because a toggle would flip an already-silent machine back on. The new contract is proven to fail by breaking the midnight rule and by letting modes run alongside a manual session. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
346 lines
13 KiB
Python
Executable File
346 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""Gaming: what the machine is doing, and what it should do while you play.
|
|
|
|
The interesting part is not reporting gamemode's state -- it is reacting to it.
|
|
gamemode can run a script when a game asks for it and another when the game
|
|
exits, so `hook start` and `hook end` are what let Panama switch the power
|
|
profile and silence notifications for exactly the duration of a game, and put
|
|
both back afterwards. Everything else here is honest reporting.
|
|
|
|
panama-gaming snapshot
|
|
panama-gaming set-overlay true|false
|
|
panama-gaming set-overlay-preset fps|detailed
|
|
panama-gaming install-hooks | remove-hooks
|
|
panama-gaming hook start|end (called by gamemode, not by a person)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
MANGOHUD_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "MangoHud" / "MangoHud.conf"
|
|
GAMEMODE_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "gamemode.ini"
|
|
ENVIRONMENT_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "environment.d" / "panama-mangohud.conf"
|
|
|
|
# What the overlay shows. Deliberately two presets rather than exposing every
|
|
# MangoHud key: this is a settings page, not a config file with a nicer font.
|
|
PRESETS = {
|
|
"fps": ["fps", "frametime=0", "no_display=0", "position=top-left",
|
|
"font_size=22", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
|
|
"detailed": ["fps", "frametime", "gpu_stats", "gpu_temp", "gpu_power",
|
|
"cpu_stats", "cpu_temp", "ram", "vram", "position=top-left",
|
|
"font_size=20", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
|
|
}
|
|
|
|
|
|
class BoundaryError(RuntimeError):
|
|
"""A user-visible failure."""
|
|
|
|
|
|
def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess:
|
|
try:
|
|
return subprocess.run(command, capture_output=True, text=True,
|
|
timeout=timeout, check=False)
|
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
raise BoundaryError(f"{command[0]} did not answer.") from error
|
|
|
|
|
|
def read_int(path: Path) -> int | None:
|
|
try:
|
|
return int(path.read_text().strip())
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def gpus() -> list[dict]:
|
|
"""Every AMD/Intel GPU with a hwmon node, warmest first.
|
|
|
|
Read from sysfs rather than a tool: it costs nothing, needs no daemon, and
|
|
a page that polls while it is open should not be spawning processes.
|
|
"""
|
|
found = []
|
|
for hwmon in sorted(Path("/sys/class/hwmon").glob("hwmon*")):
|
|
try:
|
|
name = (hwmon / "name").read_text().strip()
|
|
except OSError:
|
|
continue
|
|
if name not in ("amdgpu", "i915", "xe", "nouveau"):
|
|
continue
|
|
device = (hwmon / "device").resolve()
|
|
temperature = read_int(hwmon / "temp1_input")
|
|
power = read_int(hwmon / "power1_average")
|
|
used = read_int(device / "mem_info_vram_used")
|
|
total = read_int(device / "mem_info_vram_total")
|
|
model = ""
|
|
try:
|
|
model = (device / "product_name").read_text().strip()
|
|
except OSError:
|
|
model = ""
|
|
found.append({
|
|
"driver": name,
|
|
"model": model,
|
|
"temperatureC": round(temperature / 1000, 1) if temperature else None,
|
|
"watts": round(power / 1000000, 1) if power else None,
|
|
"vramUsedBytes": used or 0,
|
|
"vramTotalBytes": total or 0,
|
|
# A card with no VRAM reported is the integrated one sharing system
|
|
# memory; saying "0 of 0 GB" would look broken.
|
|
"discrete": bool(total and total > 1073741824),
|
|
})
|
|
found.sort(key=lambda entry: (not entry["discrete"], entry["driver"]))
|
|
return found
|
|
|
|
|
|
def governor() -> str:
|
|
try:
|
|
return Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").read_text().strip()
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def game_mode() -> dict:
|
|
available = bool(shutil.which("gamemoded"))
|
|
state = {"available": available, "active": False, "daemonRunning": False,
|
|
"governorWhileGaming": "", "governorNow": governor()}
|
|
if not available:
|
|
return state
|
|
status = run(["gamemoded", "-s"]).stdout.strip()
|
|
state["active"] = "is active" in status
|
|
state["daemonRunning"] = "gamemode is" in status
|
|
|
|
# What it would switch the governor to, from its own configuration.
|
|
for path in (GAMEMODE_CONFIG, Path("/usr/share/gamemode/gamemode.ini")):
|
|
try:
|
|
text = path.read_text()
|
|
except OSError:
|
|
continue
|
|
found = re.search(r"^\s*desiredgov\s*=\s*(\S+)", text, re.M)
|
|
if found:
|
|
state["governorWhileGaming"] = found.group(1)
|
|
break
|
|
return state
|
|
|
|
|
|
def hooks_installed() -> bool:
|
|
try:
|
|
text = GAMEMODE_CONFIG.read_text()
|
|
except OSError:
|
|
return False
|
|
return "panama-gaming hook start" in text
|
|
|
|
|
|
def overlay() -> dict:
|
|
installed = bool(shutil.which("mangohud"))
|
|
preset = ""
|
|
if MANGOHUD_CONFIG.is_file():
|
|
try:
|
|
body = MANGOHUD_CONFIG.read_text()
|
|
preset = "detailed" if "gpu_stats" in body else "fps"
|
|
except OSError:
|
|
preset = ""
|
|
return {
|
|
"installed": installed,
|
|
"configured": MANGOHUD_CONFIG.is_file(),
|
|
"preset": preset or "fps",
|
|
# Global enablement is an environment variable read at session start, so
|
|
# a change here does not affect anything already running.
|
|
"globallyEnabled": ENVIRONMENT_CONFIG.is_file(),
|
|
"toggleKey": "Shift_R+F12",
|
|
}
|
|
|
|
|
|
def library() -> dict:
|
|
root = Path.home() / ".local/share/Steam"
|
|
games = len(list((root / "steamapps").glob("*.acf"))) if (root / "steamapps").is_dir() else 0
|
|
tools = []
|
|
for directory in (root / "compatibilitytools.d", root / "steamapps/common"):
|
|
if not directory.is_dir():
|
|
continue
|
|
for entry in sorted(directory.iterdir()):
|
|
if entry.is_dir() and entry.name.lower().startswith("proton"):
|
|
tools.append({
|
|
"name": entry.name,
|
|
"community": directory.name == "compatibilitytools.d",
|
|
})
|
|
return {
|
|
"path": str(root),
|
|
"games": games,
|
|
"protonBuilds": tools,
|
|
"gamescope": bool(shutil.which("gamescope")),
|
|
"steam": bool(shutil.which("steam")),
|
|
}
|
|
|
|
|
|
def snapshot() -> dict:
|
|
return {
|
|
"gameMode": {**game_mode(), "hooksInstalled": hooks_installed()},
|
|
"gpus": gpus(),
|
|
"overlay": overlay(),
|
|
"library": library(),
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def set_overlay_preset(preset: str) -> None:
|
|
if preset not in PRESETS:
|
|
raise BoundaryError("That is not an overlay preset.")
|
|
MANGOHUD_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
header = ("# Written by Panama's Gaming settings.\n"
|
|
"# Edits here are replaced when the preset changes.\n")
|
|
MANGOHUD_CONFIG.write_text(header + "\n".join(PRESETS[preset]) + "\n", encoding="utf-8")
|
|
|
|
|
|
def set_overlay(enabled: bool) -> None:
|
|
if not shutil.which("mangohud"):
|
|
raise BoundaryError("MangoHud is not installed.")
|
|
if enabled:
|
|
if not MANGOHUD_CONFIG.is_file():
|
|
set_overlay_preset("fps")
|
|
ENVIRONMENT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
ENVIRONMENT_CONFIG.write_text(
|
|
"# Written by Panama's Gaming settings.\n"
|
|
"# Read when the session starts, so this reaches applications\n"
|
|
"# launched afterwards rather than ones already running.\n"
|
|
"MANGOHUD=1\n", encoding="utf-8")
|
|
else:
|
|
try:
|
|
ENVIRONMENT_CONFIG.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def install_hooks() -> None:
|
|
"""Point gamemode's start and end hooks at this script.
|
|
|
|
Written by editing rather than replacing: gamemode.ini is the user's file
|
|
and may hold settings this page does not manage.
|
|
"""
|
|
script = str(Path(__file__).resolve())
|
|
lines = []
|
|
if GAMEMODE_CONFIG.is_file():
|
|
lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
|
|
if "panama-gaming hook" not in line]
|
|
text = "\n".join(lines)
|
|
if "[custom]" not in text:
|
|
text += "\n\n[custom]\n"
|
|
text = re.sub(r"\[custom\]\n",
|
|
f"[custom]\nstart={script} hook start\nend={script} hook end\n",
|
|
text, count=1)
|
|
GAMEMODE_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
GAMEMODE_CONFIG.write_text(text.strip() + "\n", encoding="utf-8")
|
|
|
|
|
|
def remove_hooks() -> None:
|
|
if not GAMEMODE_CONFIG.is_file():
|
|
return
|
|
lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
|
|
if "panama-gaming hook" not in line]
|
|
GAMEMODE_CONFIG.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
|
|
|
|
|
|
def hook(phase: str) -> None:
|
|
"""Called by gamemode when a game starts and when it stops.
|
|
|
|
Reads what the user asked for from the settings file directly: the shell may
|
|
not be running, and a hook that depends on a running shell would silently do
|
|
nothing for someone who restarted it mid-session.
|
|
"""
|
|
if phase not in ("start", "end"):
|
|
raise BoundaryError("That is not a hook phase.")
|
|
|
|
settings_path = (Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
/ "panama" / "settings.json")
|
|
try:
|
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
settings = {}
|
|
|
|
scripts = Path(__file__).resolve().parent
|
|
# What was true before the game started, so ending it restores that rather
|
|
# than imposing a default. Without this, finishing a game would silently
|
|
# undo a power profile or a Do Not Disturb the user had chosen themselves.
|
|
state_path = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-gaming.json"
|
|
|
|
if phase == "start":
|
|
before = {}
|
|
if settings.get("gamingPerformanceProfile", True):
|
|
current = run([str(scripts / "panama-power-profile"), "list"], timeout=15)
|
|
try:
|
|
before["profile"] = json.loads(current.stdout or "{}").get("active", "")
|
|
except json.JSONDecodeError:
|
|
before["profile"] = ""
|
|
run([str(scripts / "panama-power-profile"), "set", "performance"], timeout=15)
|
|
|
|
# Reported, not acted on. Silencing is a focus mode's job now, and two
|
|
# things writing Do Not Disturb would each restore whatever the other
|
|
# happened to leave behind. The hook knows a game started; what that
|
|
# should mean is decided in one place.
|
|
if shutil.which("qs"):
|
|
run(["qs", "ipc", "call", "focus", "gameStarted"], timeout=10)
|
|
|
|
try:
|
|
state_path.write_text(json.dumps(before), encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
|
|
if settings.get("gamingNotifyOnStart", False) and shutil.which("notify-send"):
|
|
run(["notify-send", "-a", "Panama", "Game Mode",
|
|
"Performance profile engaged."], timeout=10)
|
|
return
|
|
|
|
# end
|
|
try:
|
|
before = json.loads(state_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
before = {}
|
|
if before.get("profile"):
|
|
run([str(scripts / "panama-power-profile"), "set", before["profile"]], timeout=15)
|
|
if shutil.which("qs"):
|
|
run(["qs", "ipc", "call", "focus", "gameEnded"], timeout=10)
|
|
try:
|
|
state_path.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def main(arguments: list[str]) -> int:
|
|
try:
|
|
if arguments == ["snapshot"]:
|
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
|
return 0
|
|
if len(arguments) == 2 and arguments[0] == "hook":
|
|
hook(arguments[1])
|
|
return 0
|
|
if len(arguments) == 2 and arguments[0] == "set-overlay":
|
|
set_overlay(arguments[1] == "true")
|
|
elif len(arguments) == 2 and arguments[0] == "set-overlay-preset":
|
|
set_overlay_preset(arguments[1])
|
|
elif arguments == ["install-hooks"]:
|
|
install_hooks()
|
|
elif arguments == ["remove-hooks"]:
|
|
remove_hooks()
|
|
else:
|
|
raise BoundaryError(
|
|
"Usage: panama-gaming snapshot | set-overlay true|false | "
|
|
"set-overlay-preset fps|detailed | install-hooks | remove-hooks | "
|
|
"hook start|end")
|
|
except BoundaryError as error:
|
|
state = snapshot()
|
|
state["error"] = str(error)
|
|
print(json.dumps(state, separators=(",", ":")))
|
|
return 0
|
|
|
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|