Compare commits

...

11 Commits

Author SHA1 Message Date
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
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
funman300 98f6c56fe3 install.sh: link gamemode-session, gamemode.ini, and mako/config
Adds the new gamemode wiring to the installer; also links mako/config,
which was never linked before (it used to be gitignored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:39:58 -07:00
funman300 1fd160f19d niri: Mod+G gamemode toggle; drop dead VRR rules
The eDP-1 panel has no VRR, so the three variable-refresh-rate rules
(including the incorrect is-focused=true one) were no-ops. Removed;
left a breadcrumb comment for a future external VRR monitor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:38:13 -07:00
funman300 259de11868 gamemode.ini: Feral custom hooks drive gamemode-session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:37:40 -07:00
funman300 2c9cff6bb9 gamemode-session: discard effect-command stdout
makoctl/fw-fanctrl echo confirmations to stdout that leaked into add/del
output during integration. run() now discards stdout, keeps stderr.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:37:40 -07:00
funman300 77b2866e3d mako: track config, add do-not-disturb mode for gamemode
Un-ignore mako/config and bring it under dotfiles so the gamemode
do-not-disturb mode syncs across machines. [mode=do-not-disturb]
invisible=1 suppresses popups while retaining notification history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:35:01 -07:00
funman300 4b1773ac4e gamemode-session: real effects (idle, DND, perf+fan, waybar)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:32:32 -07:00
funman300 05707afc04 gamemode-session: reference-counted state machine core
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:31:52 -07:00
funman300 e0c12ff0c9 Plan: niri gamemode implementation (6 tasks, TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:29:44 -07:00
funman300 5b24b21610 Spec: niri gamemode (idle inhibit, DND, perf profile, hide waybar)
Hybrid trigger (Feral gamemode hooks + Mod+G toggle), reference-counted
so overlapping sources apply once and revert once. Also removes three
dead VRR window-rules (panel has no VRR support).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:23:34 -07:00
11 changed files with 1246 additions and 9 deletions
-1
View File
@@ -1,3 +1,2 @@
nohup.out
mako/config
__pycache__/
@@ -0,0 +1,563 @@
# Niri Gamemode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A compositor-level gamemode for niri that inhibits idle, silences notifications, switches to the performance power profile (and bumps the fan), and hides waybar while a game runs — reverting cleanly when it ends — driven by both Feral gamemode hooks and a `Mod+G` toggle.
**Architecture:** A single reference-counted bash script, `scripts/gamemode-session.sh`, tracks two "sources" (`feral`, `manual`) via marker files in a runtime state dir. It applies effects only on the first active source (0→1) and reverts only on the last (1→0), snapshotting the pre-game baseline so revert restores exactly what was there. Feral's `gamemode.ini` custom hooks call `add feral`/`del feral`; a niri keybind calls `toggle` (flips `manual`).
**Tech Stack:** bash, `systemd-inhibit`, `makoctl`, `powerprofilesctl`, `fw-fanctrl`, niri KDL config, Feral gamemode (`gamemoded`).
## Global Constraints
- Script naming: `scripts/<name>.sh` symlinked to `~/.local/bin/<name>` (no `.sh` in the bin name). New script is `scripts/gamemode-session.sh``~/.local/bin/gamemode-session`. Name is `gamemode-session`, NOT `gamemode` (avoids Feral's `gamemoderun`/`gamemoded` namespace).
- Script must be crash-tolerant and never wedge the session: every effect step is individually guarded; a missing tool or a failed sub-step degrades to a no-op, and revert always runs every restore step.
- Effects apply ONLY on 0→1 active sources and revert ONLY on 1→0. Never re-snapshot baseline while already active.
- Power profile: only change it if the baseline profile differs from `performance`; on revert, only restore if we changed it (never clobber a deliberate `performance` choice).
- All new dotfiles are tracked in `~/Documents/dotfiles` (this repo) and deployed to their live locations by symlink/copy exactly as existing configs are.
- Commit after each task. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
- Verified environment facts: `makoctl mode -a/-r <mode>` and `systemd-inhibit --what=idle` are available; hypridle already honors systemd idle inhibitors (`ignore_systemd_inhibit = false`); `setsid`, `fw-fanctrl`, `powerprofilesctl`, `pgrep`, `pkill` all present.
---
### Task 1: Reference-counted state machine (`gamemode-session.sh` core)
Build the script's dispatch + source refcounting + baseline snapshot, with effects behind a `GAMEMODE_DRYRUN` seam so the state machine is testable without touching the live session. Effect *bodies* are stubbed in this task (dryrun-logged placeholders); Task 2 fills in the real commands.
**Files:**
- Create: `scripts/gamemode-session.sh`
- Test: `scripts/tests/gamemode-session.test.sh` (new throwaway test harness, committed alongside)
**Interfaces:**
- Produces: CLI `gamemode-session {add <source>|del <source>|toggle|status|reset}`.
- `add <src>`: touch `src.<src>` marker; on 0→1 snapshot baseline + `apply_effects`.
- `del <src>`: remove `src.<src>`; on 1→0 `revert_effects` + clear baseline/idle files.
- `toggle`: flip the `manual` source.
- `status`: `active` (exit 0) / `inactive` (exit 1).
- `reset`: force-revert + wipe state dir.
- Env seams (consumed by the test): `GAMEMODE_STATE_DIR` overrides the state dir; `GAMEMODE_DRYRUN=1` makes `run()` and effect functions log instead of act.
- Produces for later tasks: `apply_effects`/`revert_effects` and per-effect functions `effect_idle_on/off`, `effect_dnd_on/off`, `effect_perf_on/off`, `effect_waybar_on/off` (Task 2 replaces their bodies).
- [ ] **Step 1: Write the failing test harness**
Create `scripts/tests/gamemode-session.test.sh`:
```bash
#!/bin/bash
# Drives gamemode-session in dryrun against a temp state dir and asserts the
# reference-counting state machine. No live effects run.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd)
SCRIPT="$HERE/gamemode-session.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export GAMEMODE_STATE_DIR="$TMP/state"
export GAMEMODE_DRYRUN=1
fail=0
check() { # desc, actual, expected
if [ "$2" = "$3" ]; then printf 'ok - %s\n' "$1"
else printf 'FAIL - %s (got [%s] want [%s])\n' "$1" "$2" "$3"; fail=1; fi
}
active() { "$SCRIPT" status >/dev/null 2>&1 && echo active || echo inactive; }
check "starts inactive" "$(active)" "inactive"
"$SCRIPT" add feral >/dev/null 2>&1
check "feral activates" "$(active)" "active"
check "src.feral marker set" "$([ -e "$GAMEMODE_STATE_DIR/src.feral" ] && echo y)" "y"
check "baseline captured" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y)" "y"
"$SCRIPT" add manual >/dev/null 2>&1
check "manual keeps active" "$(active)" "active"
"$SCRIPT" del feral >/dev/null 2>&1
check "still active on 1 left" "$(active)" "active"
"$SCRIPT" del manual >/dev/null 2>&1
check "last del deactivates" "$(active)" "inactive"
check "baseline cleared" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y || echo n)" "n"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle on activates" "$(active)" "active"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle off deactivates" "$(active)" "inactive"
"$SCRIPT" reset >/dev/null 2>&1
check "reset leaves inactive" "$(active)" "inactive"
exit $fail
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `bash scripts/tests/gamemode-session.test.sh`
Expected: FAIL / non-zero (script `gamemode-session.sh` does not exist yet — `bash: .../gamemode-session.sh: No such file`).
- [ ] **Step 3: Write the core script**
Create `scripts/gamemode-session.sh`:
```bash
#!/bin/bash
# gamemode-session — compositor-level gamemode for niri.
# Reference-counted over two sources (feral, manual). Applies effects on the
# first active source (0->1) and reverts on the last (1->0).
# Spec: docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
set -u
STATE_DIR="${GAMEMODE_STATE_DIR:-${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode}"
FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
DRYRUN="${GAMEMODE_DRYRUN:-0}"
log() { printf 'gamemode: %s\n' "$*" >&2; }
run() { if [ "$DRYRUN" = 1 ]; then log "would: $*"; else "$@"; fi; }
is_active() {
[ -n "$(find "$STATE_DIR" -maxdepth 1 -name 'src.*' -print -quit 2>/dev/null)" ]
}
snapshot_baseline() {
mkdir -p "$STATE_DIR"
powerprofilesctl get 2>/dev/null > "$STATE_DIR/baseline.profile" || : > "$STATE_DIR/baseline.profile"
if pgrep -x waybar >/dev/null 2>&1; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.waybar"
if [ -f "$FAN_AUTO" ]; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.fanauto"
}
# --- effects (bodies filled in Task 2; dryrun-safe stubs for now) -------------
effect_idle_on() { run : "idle inhibit on"; }
effect_idle_off() { run : "idle inhibit off"; }
effect_dnd_on() { run : "dnd on"; }
effect_dnd_off() { run : "dnd off"; }
effect_perf_on() { run : "perf on"; }
effect_perf_off() { run : "perf off"; }
effect_waybar_on() { run : "waybar hide"; }
effect_waybar_off(){ run : "waybar restore"; }
apply_effects() { effect_idle_on; effect_dnd_on; effect_perf_on; effect_waybar_on; }
revert_effects() { effect_idle_off; effect_dnd_off; effect_perf_off; effect_waybar_off; }
# --- state machine ------------------------------------------------------------
cmd_add() {
local src="$1" was=0
is_active && was=1
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/src.$src"
if [ "$was" = 0 ]; then
snapshot_baseline
apply_effects
log "activated (source: $src)"
else
log "already active; +source: $src"
fi
}
cmd_del() {
local src="$1"
rm -f "$STATE_DIR/src.$src"
if ! is_active; then
revert_effects
rm -f "$STATE_DIR"/baseline.* "$STATE_DIR/idle.pid"
log "deactivated (last source: $src)"
else
log "still active; -source: $src"
fi
}
cmd_toggle() {
if [ -e "$STATE_DIR/src.manual" ]; then cmd_del manual; else cmd_add manual; fi
}
cmd_status() { if is_active; then echo active; exit 0; else echo inactive; exit 1; fi; }
cmd_reset() {
revert_effects 2>/dev/null || true
rm -rf "$STATE_DIR"
log "reset"
}
case "${1:-}" in
add) [ -n "${2:-}" ] || { log "add needs a source"; exit 2; }; cmd_add "$2" ;;
del) [ -n "${2:-}" ] || { log "del needs a source"; exit 2; }; cmd_del "$2" ;;
toggle) cmd_toggle ;;
status) cmd_status ;;
reset) cmd_reset ;;
*) log "usage: gamemode-session {add|del <source>|toggle|status|reset}"; exit 2 ;;
esac
```
Make it executable: `chmod +x scripts/gamemode-session.sh`.
- [ ] **Step 4: Run the test to verify it passes + shellcheck**
Run: `bash -n scripts/gamemode-session.sh && shellcheck -x scripts/gamemode-session.sh; bash scripts/tests/gamemode-session.test.sh`
Expected: `bash -n` silent; shellcheck clean (or only style-level SC2086-type notes — fix any warnings); all `ok -` lines, exit 0.
- [ ] **Step 5: Commit**
```bash
cd ~/Documents/dotfiles
git add scripts/gamemode-session.sh scripts/tests/gamemode-session.test.sh
git commit -m "gamemode-session: reference-counted state machine core
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Real effect bodies (idle / DND / power+fan / waybar)
Replace the eight stubbed effect functions with the real commands. The Task 1 state-machine test still passes unchanged (it runs in dryrun, so effect bodies only log).
**Files:**
- Modify: `scripts/gamemode-session.sh` (the eight `effect_*` functions)
**Interfaces:**
- Consumes: `STATE_DIR`, `FAN_AUTO`, `DRYRUN`, `run()`, `log()`, and baseline files `baseline.profile`/`baseline.waybar`/`baseline.fanauto` written by `snapshot_baseline`.
- Produces: `idle.pid` file holding the systemd-inhibit PID while active.
- [ ] **Step 1: Replace the effect stubs**
In `scripts/gamemode-session.sh`, replace the entire `# --- effects` stub block with:
```bash
# --- effects ------------------------------------------------------------------
effect_idle_on() {
if [ "$DRYRUN" = 1 ]; then log "would: systemd-inhibit idle"; return; fi
command -v systemd-inhibit >/dev/null 2>&1 || { log "no systemd-inhibit"; return; }
systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block \
sleep infinity >/dev/null 2>&1 &
echo $! > "$STATE_DIR/idle.pid"
}
effect_idle_off() {
local pid; pid=$(cat "$STATE_DIR/idle.pid" 2>/dev/null || echo "")
[ -n "$pid" ] && run kill "$pid"
rm -f "$STATE_DIR/idle.pid"
}
effect_dnd_on() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -a do-not-disturb; }
effect_dnd_off() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -r do-not-disturb; }
# maps a power profile to the fw-fanctrl strategy (mirrors fan-profile.sh)
fan_for() { case "$1" in power-saver) echo lazy;; balanced) echo medium;; performance) echo agile;; *) return 1;; esac; }
effect_perf_on() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set performance
run pkill -RTMIN+8 waybar
fi
if [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
run fw-fanctrl use agile
fi
}
effect_perf_off() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set "$base"
run pkill -RTMIN+8 waybar
fi
if [ -n "$base" ] && [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
local strat; strat=$(fan_for "$base") && run fw-fanctrl use "$strat"
fi
}
effect_waybar_on() { # hide
pgrep -x waybar >/dev/null 2>&1 && run pkill -x waybar
}
effect_waybar_off() { # restore if it was running at entry
[ "$(cat "$STATE_DIR/baseline.waybar" 2>/dev/null)" = 1 ] || return 0
if [ "$DRYRUN" = 1 ]; then log "would: relaunch waybar"; else setsid waybar >/dev/null 2>&1 & fi
}
```
- [ ] **Step 2: Re-run the state-machine test (must still pass in dryrun)**
Run: `bash -n scripts/gamemode-session.sh && shellcheck -x scripts/gamemode-session.sh; bash scripts/tests/gamemode-session.test.sh`
Expected: shellcheck clean; all `ok -`, exit 0. (Effects only log under dryrun, so the state machine is unaffected.)
- [ ] **Step 3: Dryrun effect-ordering smoke (visual check of the "would:" log)**
Run: `GAMEMODE_STATE_DIR=$(mktemp -d) GAMEMODE_DRYRUN=1 bash scripts/gamemode-session.sh add feral 2>&1`
Expected output includes, in order: `would: systemd-inhibit idle`, `would: makoctl mode -a do-not-disturb`, a `would: powerprofilesctl set performance` (only if your current profile isn't already performance) and/or `would: fw-fanctrl use agile`, `would: pkill -x waybar`, then `activated (source: feral)`.
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add scripts/gamemode-session.sh
git commit -m "gamemode-session: real effects (idle, DND, perf+fan, waybar)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: mako do-not-disturb mode config
mako currently runs on defaults with no config file. Add a tracked config that folds in the current visible settings and adds the `do-not-disturb` mode so `effect_dnd_*` actually suppresses toasts.
**Files:**
- Create: `mako/config` (dotfiles) → deployed to `~/.config/mako/config`
**Interfaces:**
- Consumes: `makoctl mode -a/-r do-not-disturb` from Task 2.
- Produces: a `[mode=do-not-disturb]` block with `invisible=1`.
- [ ] **Step 1: Write the mako config**
Create `mako/config`:
```ini
background-color=#1d1f21
text-color=#c5c8c6
border-size=2
border-color=#81a2be
default-timeout=4000
[mode=do-not-disturb]
invisible=1
```
- [ ] **Step 2: Deploy + reload mako**
```bash
mkdir -p ~/.config/mako
ln -sf ~/Documents/dotfiles/mako/config ~/.config/mako/config # match existing dotfile symlink style
makoctl reload
```
- [ ] **Step 3: Verify DND suppresses, then restores**
Run:
```bash
makoctl mode -a do-not-disturb; makoctl mode # expect list to include: do-not-disturb
notify-send "gamemode test" "should NOT appear" # no toast while DND active
makoctl mode -r do-not-disturb; makoctl mode # expect: default only
notify-send "gamemode test" "SHOULD appear" # toast appears
```
Expected: after `-a`, `makoctl mode` lists `do-not-disturb` and the first notification does not show; after `-r`, list is back to `default` and the second notification shows. (If `notify-send` is missing, install `libnotify` or skip — the mode-list assertion is the load-bearing check.)
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add mako/config
git commit -m "mako: config with do-not-disturb mode for gamemode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Feral gamemode hooks (`gamemode.ini`)
Wire Feral `gamemoded`'s custom start/end hooks to the script so gamemode-aware games trigger it automatically.
**Files:**
- Create: `gamemode.ini` (dotfiles) → deployed to `~/.config/gamemode.ini`
**Interfaces:**
- Consumes: `gamemode-session add feral` / `del feral` from Tasks 12.
- [ ] **Step 1: Write `gamemode.ini`**
Create `gamemode.ini`:
```ini
[custom]
start=/home/alex/.local/bin/gamemode-session add feral
end=/home/alex/.local/bin/gamemode-session del feral
```
- [ ] **Step 2: Deploy**
```bash
ln -sf ~/Documents/dotfiles/gamemode.ini ~/.config/gamemode.ini
```
- [ ] **Step 3: Verify Feral reads the hooks**
Run: `gamemoderun gamemode-session status; echo "exit=$?"`
Expected: while `gamemoderun` holds the game session, the custom `start` hook has fired `add feral`, so `gamemode-session status` prints `active` (exit 0). When `gamemoderun` exits it fires `end``del feral`. Immediately after, `gamemode-session status` prints `inactive` (exit 1). (If it stays active, run `gamemode-session reset` and check `journalctl --user -u gamemoded` / the `gamemode.ini` path.)
- [ ] **Step 4: Commit**
```bash
cd ~/Documents/dotfiles
git add gamemode.ini
git commit -m "gamemode.ini: Feral custom hooks drive gamemode-session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: niri config — keybind + remove dead VRR rules
Add the `Mod+G` toggle and remove the three no-op VRR rules the review found (panel has no VRR).
**Files:**
- Modify: `niri/config.kdl`
**Interfaces:**
- Consumes: `gamemode-session toggle` from Tasks 12.
- [ ] **Step 1: Add the keybind**
In `niri/config.kdl`, inside the `binds { ... }` block, near the other `spawn` binds (after the `Mod+Shift+B` line), add:
```
Mod+G { spawn "gamemode-session" "toggle"; }
```
- [ ] **Step 2: Remove the dead VRR rules**
In `niri/config.kdl`:
a) In the `steam_app_` window-rule, delete the line `variable-refresh-rate true` and change the comment to note the VRR removal. The rule becomes:
```
// Fullscreen games: no border, open fullscreen.
// (Re-add `variable-refresh-rate true` here if a VRR-capable external
// monitor is ever attached — the internal eDP-1 panel has no VRR.)
window-rule {
match app-id="steam_app_"
open-fullscreen true
border {
off
}
focus-ring {
off
}
}
```
b) Delete this entire rule (it wrongly targets every focused window and is a no-op here):
```
// Generic fullscreen request (any app asking to go fullscreen)
window-rule {
match is-focused=true
variable-refresh-rate true
}
```
- [ ] **Step 3: Validate niri config**
Run: `niri validate -c ~/.config/niri/config.kdl`
Expected: `Config is valid` (or niri's success message). Fix any parse error before continuing.
- [ ] **Step 4: Verify the bind live (optional if in a niri session)**
Run: `niri msg action do-nothing 2>/dev/null; grep -n "Mod+G" ~/.config/niri/config.kdl`
Expected: the `Mod+G` line is present. After niri reloads the config (niri hot-reloads on save), pressing `Mod+G` toggles gamemode; confirm with `gamemode-session status`.
- [ ] **Step 5: Commit**
```bash
cd ~/Documents/dotfiles
git add niri/config.kdl
git commit -m "niri: Mod+G gamemode toggle; drop dead VRR rules
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Wiring + live end-to-end smoke
Create the `~/.local/bin` symlink for the script (matching every other script), update `install.sh` if it enumerates them, then run one real activate/deactivate cycle and assert observable effects revert.
**Files:**
- Create: symlink `~/.local/bin/gamemode-session``~/Documents/dotfiles/scripts/gamemode-session.sh`
- Modify: `install.sh` (only if it lists script/config symlinks; otherwise no change)
**Interfaces:**
- Consumes: everything from Tasks 15.
- [ ] **Step 1: Create the bin symlink**
```bash
ln -sf ~/Documents/dotfiles/scripts/gamemode-session.sh ~/.local/bin/gamemode-session
gamemode-session status; echo "exit=$?" # expect: inactive / exit=1
```
- [ ] **Step 2: Wire install.sh if applicable**
Inspect `install.sh` for where existing scripts/configs get symlinked (e.g. a loop over `scripts/*.sh`, or explicit `ln -sf` lines). If found, add `gamemode-session`, `~/.config/gamemode.ini`, and `~/.config/mako/config` to the same mechanism. If `install.sh` does NOT enumerate these (the existing bin symlinks appear to be created manually), skip this step and note it in the commit.
Run: `grep -nE "ln -s|scripts/|\.local/bin|\.config" install.sh | head`
Expected: shows the deploy mechanism (or nothing → manual, skip).
- [ ] **Step 3: Live end-to-end — capture baseline**
```bash
echo "profile: $(powerprofilesctl get)"
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down"
makoctl mode
```
Record these; the cycle must return to exactly this state.
- [ ] **Step 4: Activate and assert effects applied**
```bash
gamemode-session add manual
sleep 1
gamemode-session status; echo "status_exit=$?" # active / 0
systemd-inhibit --list | grep -q gamemode && echo "idle: inhibited" # idle: inhibited
makoctl mode | grep -q do-not-disturb && echo "dnd: on" # dnd: on
echo "profile now: $(powerprofilesctl get)" # performance (unless baseline already performance)
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down" # down
```
Expected: status active; idle inhibited (a `gamemode` entry in the list); DND on; profile is `performance`; waybar down.
- [ ] **Step 5: Deactivate and assert full revert**
```bash
gamemode-session del manual
sleep 2
gamemode-session status; echo "status_exit=$?" # inactive / 1
systemd-inhibit --list | grep -q gamemode && echo "idle: STILL inhibited (BUG)" || echo "idle: released"
makoctl mode | grep -q do-not-disturb && echo "dnd: STILL on (BUG)" || echo "dnd: off"
echo "profile now: $(powerprofilesctl get)" # back to Step 3 value
pgrep -x waybar >/dev/null && echo "waybar: up" || echo "waybar: down" # up (matches Step 3)
```
Expected: status inactive; idle released; DND off; profile matches the Step 3 baseline; waybar back up. If any line reports a BUG or a mismatch, run `gamemode-session reset` to force-clean, then debug the corresponding `effect_*_off` before proceeding.
- [ ] **Step 6: Manual verification note (idle timeout)**
The 5-minute blank-suppression can't be asserted in seconds. Manually verify once: activate gamemode, leave the machine idle past 5 minutes (no input), confirm the screen does NOT blank; then `del`/toggle off and confirm hypridle resumes (screen blanks on the next idle window). Record the result. If it DOES blank while active, hypridle isn't honoring the systemd idle inhibitor on this build — fall back to a Wayland idle-inhibitor helper (documented follow-up), but the systemd path matches the current hypridle config intent.
- [ ] **Step 7: Commit**
```bash
cd ~/Documents/dotfiles
git add -A install.sh # only if changed; otherwise omit
git commit -m "gamemode: wire bin symlink + live smoke verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" # allow empty note if only the symlink (untracked) changed
```
(The `~/.local/bin/gamemode-session` symlink lives outside the repo — like the other script symlinks — so it isn't committed; note that it was created.)
---
## Self-Review
**Spec coverage:**
- Reference-counted two-source state machine → Task 1. ✓
- Idle inhibit / DND / performance+fan / hide waybar effects, each with exact-baseline revert → Task 2. ✓
- Baseline snapshot (profile, waybar, fanauto, idle.pid) → Task 1 (`snapshot_baseline`) + Task 2 (`idle.pid`). ✓
- "only change profile if baseline != performance; only restore if changed" → Task 2 `effect_perf_on/off`. ✓
- mako `[mode=do-not-disturb]` config → Task 3. ✓
- `gamemode.ini` Feral hooks → Task 4. ✓
- niri `Mod+G` bind + removal of 3 dead VRR rules (incl. `is-focused=true`) + breadcrumb comment → Task 5. ✓
- `reset` escape hatch → Task 1 dispatch + used in Task 6 debugging. ✓
- `status` subcommand for future waybar module → Task 1. ✓
- Script naming `gamemode-session`, `scripts/<name>.sh``~/.local/bin/<name>` convention → Global Constraints + Task 6. ✓
- Steam `gamemoderun %command%` usage → user-side (documented in spec Usage; not a code task), reflected in Task 4 verification via `gamemoderun`. ✓
**Placeholder scan:** No TBD/TODO; every code step shows full content; no "add error handling" hand-waves (guards are written out). ✓
**Type/name consistency:** `effect_idle_on/off`, `effect_dnd_on/off`, `effect_perf_on/off`, `effect_waybar_on/off`, `apply_effects`, `revert_effects`, `snapshot_baseline`, `is_active`, `fan_for`, and the env seams `GAMEMODE_STATE_DIR`/`GAMEMODE_DRYRUN` are named identically across Tasks 1, 2, and 6. Baseline filenames `baseline.profile`/`baseline.waybar`/`baseline.fanauto` and `idle.pid`/`src.<source>` are consistent throughout. ✓
@@ -0,0 +1,236 @@
# Niri Gamemode Design
**Date:** 2026-07-15
**Status:** Approved
## Summary
Add a compositor-level "gamemode": while a game is running, inhibit idle/blanking,
silence notifications, switch to the `performance` power profile (and bump the fan),
and hide waybar — then revert everything cleanly when the game ends. One script,
`scripts/gamemode-session.sh`, is the whole engine. It is driven by **three
independent sources** and reference-counted, so the triggers can overlap safely:
- **Window watch (`winwatch`)** — `scripts/gamemode-watch.py`, launched at niri
startup, subscribes to niri's event stream and toggles gamemode whenever a game
window (app_id matching `steam_app_*`, RuneLite, or gamescope) is open. This makes
Steam games automatic with **zero per-game setup** — the primary trigger.
- **Feral (`feral`)** — Feral `gamemoded` custom start/end hooks, for
gamemode-aware launchers run with `gamemoderun` that the window watch may not match.
- **Manual (`manual`)** — a `Mod+G` niri keybind, for anything else.
Effects apply on the first activation (0→1 active sources) and revert on the last
deactivation (1→0). Also removes three dead VRR window-rules found during review.
> **Addendum (2026-07-15):** The window-watch source was added after the initial
> design so Steam games trigger gamemode globally instead of needing
> `gamemoderun %command%` per game. See "Window watch" below.
## Current Behaviour
- **Compositor:** niri 26.04, single internal panel `eDP-1` (BOE, 2256×1504@60,
**VRR not supported**). Config at `niri/config.kdl`.
- **Idle:** `hypridle` blanks the panel at 5 min, locks at 10 min, suspend-then-
hibernate at 30 min. It honors idle inhibitors (`ignore_dbus_inhibit = false`,
`ignore_systemd_inhibit = false`). A controller/turn-based game with no kbd/mouse
input therefore goes dark at 5 minutes.
- **Notifications:** `mako`, currently with no config file (running on defaults) — so
toasts pop over fullscreen games and there is no mode to suppress them.
- **Power:** `power-profiles-daemon` (AMD pstate); active profile read/set via
`powerprofilesctl`, waybar module refreshed with `pkill -RTMIN+8 waybar` (and
`-RTMIN+9`). Fan via `fw-fanctrl` with a virtual `auto` mode that maps
profile→strategy (`performance``agile`) when
`$XDG_STATE_HOME/fan-profile-auto` exists.
- **waybar:** launched directly (`spawn-at-startup "waybar"`); `waybar-restart`
script exists.
- **Feral gamemode:** `gamemoded` installed, **no `gamemode.ini`** → no custom hooks.
- **Script convention:** `scripts/<name>.sh` is symlinked to `~/.local/bin/<name>`
(no `.sh` in the bin name), e.g. `power-profile`, `fan-profile`, `screenshot`.
### Review findings (delivered separately, cleanup folded into this spec)
- 🔴 **Three VRR rules are dead code.** The panel doesn't support VRR, so all three
`variable-refresh-rate true` rules are no-ops. The `match is-focused=true` one is
also conceptually wrong (it targets every focused window, not games).
- 🟠 **Idle blanks mid-game** (the 5-min issue above) — the main problem gamemode fixes.
- 🟡 **Notifications interrupt fullscreen.**
- 🟢 `match app-id="steam_app_"` is an unanchored regex (looser than the other
anchored rules) — noted, left as-is.
## Target Behaviour
### Sources and reference counting
State lives in a runtime dir `${XDG_RUNTIME_DIR}/gamemode/`. A "source" is either
`feral` or `manual`, represented by a marker file (`src.feral`, `src.manual`).
Gamemode is **active iff at least one source marker exists**.
```
add <source>:
was_active = (any src.* exists)
touch src.<source>
if not was_active: snapshot baseline + apply effects
del <source>:
rm -f src.<source>
if no src.* remain: read baseline + revert effects, clear state dir
toggle: # what Mod+G calls
if src.manual exists → del manual else → add manual
status: # for a future waybar module; prints active|inactive, exit 0/1
```
Applying only on 0→1 and reverting only on 1→0 makes overlap safe: launch a Steam
game (feral add) *and* hit `Mod+G` (manual add), and effects apply once; effects
revert only when both are gone. Idempotent and crash-tolerant — stale markers are
harmless, and baseline is re-snapshotted only when transitioning from zero sources.
### Baseline snapshot (captured on 0→1)
Written into the state dir so revert restores exactly what was there:
- `baseline.profile``powerprofilesctl get` at entry.
- `baseline.waybar``1` if waybar was running at entry, else `0`.
- `baseline.fanauto``1` if `$XDG_STATE_HOME/fan-profile-auto` exists at entry.
- `idle.pid` — PID of the idle-inhibit process (written when it launches).
### Effects
| Effect | Enter | Revert |
|---|---|---|
| **Idle inhibit** | `systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block sleep infinity &`, record PID in `idle.pid`. hypridle honors systemd idle inhibitors, so its blank/lock/hibernate timers stop. | `kill` the recorded PID; remove `idle.pid`. |
| **Do-not-disturb** | `makoctl mode -a do-not-disturb` (requires a `[mode=do-not-disturb]` block — see mako config below). | `makoctl mode -r do-not-disturb`. |
| **Performance profile** | if `baseline.profile` != `performance`: `powerprofilesctl set performance`, then `pkill -RTMIN+8 waybar`. If `baseline.fanauto`=1: `fw-fanctrl use agile`. | restore `baseline.profile` (only if we changed it), `pkill -RTMIN+8 waybar`; if `baseline.fanauto`=1 remap fan to match the restored profile (`fan-profile`'s existing auto poll also reconciles within 5 s, so this is belt-and-suspenders). |
| **Hide waybar** | if waybar running: `pkill -x waybar`. | if `baseline.waybar`=1: relaunch `waybar` detached (`setsid waybar >/dev/null 2>&1 &`). |
All effect steps are individually guarded (`command -v`, `2>/dev/null`) so a missing
tool degrades to a no-op rather than aborting the rest — gamemode must never wedge a
session. Revert always runs every restore step regardless of individual failures.
### Failure / edge cases
- **Stale markers after a crash:** next `add` sees markers, treats itself as
already-active, and won't re-snapshot — but a manual `gamemode-session reset`
(clears the state dir and force-reverts) is provided as an escape hatch. `toggle`
from a stale-active state will `del manual` and, if that empties the sources,
revert — self-healing in the common case.
- **Idle PID already dead:** `kill` failure is ignored.
- **profile unchanged at entry** (already `performance`): we don't touch it and don't
restore it, so we never clobber a deliberate user choice.
## Implementation
### 1. `scripts/gamemode-session.sh` (new)
`bash`, mirrors the house style of the other scripts. Structure:
- Constants: `STATE_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode"`,
`FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"`.
- Helpers: `is_active`, `snapshot_baseline`, `apply_effects`, `revert_effects`,
each effect as a small function.
- Dispatch on `$1`: `add`/`del` (with `$2` = source), `toggle`, `status`, `reset`.
- Symlinked to `~/.local/bin/gamemode-session` (same as every other script; add the
symlink line to `install.sh` if it enumerates scripts, else create it manually to
match the existing ones).
Named `gamemode-session` (not `gamemode`) to avoid any collision with Feral's
`gamemoderun`/`gamemoded` namespace.
### 2. `mako/config` (new file in dotfiles → `~/.config/mako/config`)
mako has no config today; DND needs a mode block. Fold in the current visible
settings so nothing changes visually, then add the mode:
```ini
background-color=#1d1f21
text-color=#c5c8c6
border-size=2
border-color=#81a2be
default-timeout=4000
[mode=do-not-disturb]
invisible=1
```
`invisible=1` hides new notifications while the mode is active; removing the mode
restores normal display. (History is retained — nothing is lost, just not shown.)
### 3. `gamemode.ini` (new file in dotfiles → `~/.config/gamemode.ini`)
```ini
[custom]
start=/home/alex/.local/bin/gamemode-session add feral
end=/home/alex/.local/bin/gamemode-session del feral
```
Absolute path because gamemoded's exec environment is minimal. No other gamemode
tuning (Feral's defaults already set the CPU governor).
### 4. `niri/config.kdl` (edited)
**Add** the keybind (near the other `spawn` binds):
```
Mod+G { spawn "gamemode-session" "toggle"; }
```
**Remove** the three dead VRR rules:
- the `variable-refresh-rate true` line inside the `steam_app_` rule,
- the entire `match is-focused=true { variable-refresh-rate true }` rule.
Leave a one-line comment on the `steam_app_` rule noting to re-add
`variable-refresh-rate true` if a VRR-capable external monitor is ever attached.
### 5. `scripts/gamemode-watch.py` (new) — window watch
A Python daemon (Python already used by the theme generator) launched via
`spawn-at-startup "gamemode-watch"`. It runs `niri msg --json event-stream` and
maintains the set of open **game** windows:
- `is_game(app_id)` matches `steam_app_` (substring), the RuneLite ids
(`net-runelite-client-RuneLite|runelite|RuneLite`), and `gamescope`
(case-insensitive) — mirrors the niri window rules. Bolt's *launcher* window is
deliberately excluded (it would trigger before you're in-game).
- `WindowsChanged` (connect snapshot) rebuilds the set; `WindowOpenedOrChanged`
adds/removes by app_id; `WindowClosed` (id only) discards by id.
- On empty→non-empty it calls `gamemode-session add winwatch`; non-empty→empty,
`del winwatch`.
- Event logic is a pure `Watcher` class (injected `emit`) so it unit-tests without
niri or subprocesses (`scripts/tests/gamemode-watch.test.py`).
- Robustness: reconnects if the stream drops while niri is alive; a SIGTERM handler
and a `finally` guarantee `del winwatch` on exit, so the watcher stopping can
never leave gamemode stuck on.
## Files Touched
- `scripts/gamemode-session.sh`**new**, the engine.
- `scripts/gamemode-watch.py`**new**, niri-event-stream window watch (primary
auto-trigger). Tested by `scripts/tests/gamemode-watch.test.py`.
- `mako/config`**new**, adds the `do-not-disturb` mode (plus current settings);
also un-ignored in `.gitignore` (was previously untracked by choice).
- `gamemode.ini`**new**, Feral start/end hooks.
- `niri/config.kdl` — add `Mod+G` bind; `spawn-at-startup "gamemode-watch"`; remove
3 dead VRR rules; add one comment.
- `install.sh` — link `gamemode-session`, `gamemode-watch`, `gamemode.ini`, and
`mako/config` (the last was never linked before).
## Usage
- **Steam / RuneLite / gamescope games:** nothing to do — the window watch turns
gamemode on when a game window opens and off when the last one closes.
- **Gamemode-aware launchers** (Lutris/Heroic via `gamemoderun`): covered by the
Feral hooks even if their window app_id isn't matched.
- **Anything else / manual override:** `Mod+G` to toggle.
- All three converge on `gamemode-session`; overlapping is safe (reference-counted).
## Out of Scope (YAGNI)
- **gamescope wrapper / upscaling / frame limiting** — separate concern; can be
layered into a game's launch command independently.
- **Per-game profiles** — one uniform gamemode for now.
- **waybar gamemode indicator** — the `status` subcommand is provided so a
`custom/gamemode` module can be added later, but no module ships now.
- **Cross-machine sync of gamemode state** — state is per-boot runtime only, by design.
- **Touching VRR behaviour for external monitors** — left as a comment breadcrumb;
no rule added until such a monitor exists.
+3
View File
@@ -0,0 +1,3 @@
[custom]
start=/home/alex/.local/bin/gamemode-session add feral
end=/home/alex/.local/bin/gamemode-session del feral
+4
View File
@@ -41,6 +41,8 @@ ln -sf "$(pwd)/gtk-4.0/settings.ini" ~/.config/gtk-4.0/settings.ini
ln -sf "$(pwd)/xdg/mimeapps.list" ~/.config/mimeapps.list
ln -sf "$(pwd)/environment.d/wayland.conf" ~/.config/environment.d/wayland.conf
ln -sf "$(pwd)/xdg-desktop-portal/portals.conf" ~/.config/xdg-desktop-portal/portals.conf
ln -sf "$(pwd)/mako/config" ~/.config/mako/config
ln -sf "$(pwd)/gamemode.ini" ~/.config/gamemode.ini
echo "==> Installing scripts"
mkdir -p ~/.local/bin
@@ -51,6 +53,8 @@ ln -sf "$(pwd)/scripts/fan-profile.sh" ~/.local/bin/fan-profile
ln -sf "$(pwd)/scripts/waybar-restart.sh" ~/.local/bin/waybar-restart
ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
ln -sf "$(pwd)/scripts/gamemode-session.sh" ~/.local/bin/gamemode-session
ln -sf "$(pwd)/scripts/gamemode-watch.py" ~/.local/bin/gamemode-watch
echo "==> Enabling systemd user services"
mkdir -p ~/.config/systemd/user
+8
View File
@@ -0,0 +1,8 @@
background-color=#1d1f21
text-color=#c5c8c6
border-size=2
border-color=#81a2be
default-timeout=4000
[mode=do-not-disturb]
invisible=1
+5 -8
View File
@@ -45,6 +45,7 @@ spawn-at-startup "hypridle"
spawn-at-startup "wl-paste" "--watch" "cliphist" "store"
spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1"
spawn-at-startup "xwayland-satellite"
spawn-at-startup "gamemode-watch"
binds {
Mod+Q repeat=false { close-window; }
@@ -56,6 +57,7 @@ binds {
Mod+D { spawn "wofi" "--show" "drun"; }
Mod+E { spawn "thunar"; }
Mod+Shift+B { spawn "waybar-restart"; }
Mod+G { spawn "gamemode-session" "toggle"; }
Mod+H { focus-column-left; }
Mod+L { focus-column-right; }
@@ -107,7 +109,9 @@ binds {
// ── Window Rules ─────────────────────────────────────────────────────────────
// Fullscreen games: no border, open fullscreen, enable VRR
// Fullscreen games: no border, open fullscreen.
// (Re-add `variable-refresh-rate true` here if a VRR-capable external
// monitor is ever attached — the internal eDP-1 panel has no VRR.)
window-rule {
match app-id="steam_app_"
open-fullscreen true
@@ -117,13 +121,6 @@ window-rule {
focus-ring {
off
}
variable-refresh-rate true
}
// Generic fullscreen request (any app asking to go fullscreen)
window-rule {
match is-focused=true
variable-refresh-rate true
}
// Wine System Tray: hide the empty host window off-screen
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# gamemode-session — compositor-level gamemode for niri.
# Reference-counted over two sources (feral, manual). Applies effects on the
# first active source (0->1) and reverts on the last (1->0).
# Spec: docs/superpowers/specs/2026-07-15-niri-gamemode-design.md
set -u
STATE_DIR="${GAMEMODE_STATE_DIR:-${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gamemode}"
FAN_AUTO="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
DRYRUN="${GAMEMODE_DRYRUN:-0}"
log() { printf 'gamemode: %s\n' "$*" >&2; }
# Execute a side-effecting command (or log it under dryrun). Its stdout is
# discarded — effect tools like makoctl/fw-fanctrl print confirmations we don't
# want leaking into `add`/`del` output; stderr is kept so real errors surface.
run() { if [ "$DRYRUN" = 1 ]; then log "would: $*"; else "$@" >/dev/null; fi; }
is_active() {
[ -n "$(find "$STATE_DIR" -maxdepth 1 -name 'src.*' -print -quit 2>/dev/null)" ]
}
snapshot_baseline() {
mkdir -p "$STATE_DIR"
powerprofilesctl get 2>/dev/null > "$STATE_DIR/baseline.profile" || : > "$STATE_DIR/baseline.profile"
if pgrep -x waybar >/dev/null 2>&1; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.waybar"
if [ -f "$FAN_AUTO" ]; then echo 1; else echo 0; fi > "$STATE_DIR/baseline.fanauto"
}
# --- effects ------------------------------------------------------------------
effect_idle_on() {
if [ "$DRYRUN" = 1 ]; then log "would: systemd-inhibit idle"; return; fi
command -v systemd-inhibit >/dev/null 2>&1 || { log "no systemd-inhibit"; return; }
systemd-inhibit --what=idle --who=gamemode --why="gaming session" --mode=block \
sleep infinity >/dev/null 2>&1 &
echo $! > "$STATE_DIR/idle.pid"
}
effect_idle_off() {
local pid; pid=$(cat "$STATE_DIR/idle.pid" 2>/dev/null || echo "")
[ -n "$pid" ] && run kill "$pid"
rm -f "$STATE_DIR/idle.pid"
}
effect_dnd_on() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -a do-not-disturb; }
effect_dnd_off() { command -v makoctl >/dev/null 2>&1 && run makoctl mode -r do-not-disturb; }
# maps a power profile to the fw-fanctrl strategy (mirrors fan-profile.sh)
fan_for() { case "$1" in power-saver) echo lazy;; balanced) echo medium;; performance) echo agile;; *) return 1;; esac; }
effect_perf_on() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set performance
run pkill -RTMIN+8 waybar
fi
if [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
run fw-fanctrl use agile
fi
}
effect_perf_off() {
local base; base=$(cat "$STATE_DIR/baseline.profile" 2>/dev/null || echo "")
if [ -n "$base" ] && [ "$base" != performance ]; then
run powerprofilesctl set "$base"
run pkill -RTMIN+8 waybar
fi
if [ -n "$base" ] && [ "$(cat "$STATE_DIR/baseline.fanauto" 2>/dev/null)" = 1 ]; then
local strat; strat=$(fan_for "$base") && run fw-fanctrl use "$strat"
fi
}
effect_waybar_on() { # hide
pgrep -x waybar >/dev/null 2>&1 && run pkill -x waybar
}
effect_waybar_off() { # restore if it was running at entry
[ "$(cat "$STATE_DIR/baseline.waybar" 2>/dev/null)" = 1 ] || return 0
if [ "$DRYRUN" = 1 ]; then log "would: relaunch waybar"; else setsid waybar >/dev/null 2>&1 & fi
}
apply_effects() { effect_idle_on; effect_dnd_on; effect_perf_on; effect_waybar_on; }
revert_effects() { effect_idle_off; effect_dnd_off; effect_perf_off; effect_waybar_off; }
# --- state machine ------------------------------------------------------------
cmd_add() {
local src="$1" was=0
is_active && was=1
mkdir -p "$STATE_DIR"
touch "$STATE_DIR/src.$src"
if [ "$was" = 0 ]; then
snapshot_baseline
apply_effects
log "activated (source: $src)"
else
log "already active; +source: $src"
fi
}
cmd_del() {
local src="$1"
rm -f "$STATE_DIR/src.$src"
if ! is_active; then
revert_effects
rm -f "$STATE_DIR"/baseline.* "$STATE_DIR/idle.pid"
log "deactivated (last source: $src)"
else
log "still active; -source: $src"
fi
}
cmd_toggle() {
if [ -e "$STATE_DIR/src.manual" ]; then cmd_del manual; else cmd_add manual; fi
}
cmd_status() { if is_active; then echo active; exit 0; else echo inactive; exit 1; fi; }
cmd_reset() {
revert_effects 2>/dev/null || true
rm -rf "$STATE_DIR"
log "reset"
}
case "${1:-}" in
add) [ -n "${2:-}" ] || { log "add needs a source"; exit 2; }; cmd_add "$2" ;;
del) [ -n "${2:-}" ] || { log "del needs a source"; exit 2; }; cmd_del "$2" ;;
toggle) cmd_toggle ;;
status) cmd_status ;;
reset) cmd_reset ;;
*) log "usage: gamemode-session {add|del <source>|toggle|status|reset}"; exit 2 ;;
esac
+160
View File
@@ -0,0 +1,160 @@
#!/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)
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Drives gamemode-session in dryrun against a temp state dir and asserts the
# reference-counting state machine. No live effects run.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd)
SCRIPT="$HERE/gamemode-session.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export GAMEMODE_STATE_DIR="$TMP/state"
export GAMEMODE_DRYRUN=1
fail=0
check() { # desc, actual, expected
if [ "$2" = "$3" ]; then printf 'ok - %s\n' "$1"
else printf 'FAIL - %s (got [%s] want [%s])\n' "$1" "$2" "$3"; fail=1; fi
}
active() { "$SCRIPT" status >/dev/null 2>&1 && echo active || echo inactive; }
check "starts inactive" "$(active)" "inactive"
"$SCRIPT" add feral >/dev/null 2>&1
check "feral activates" "$(active)" "active"
check "src.feral marker set" "$([ -e "$GAMEMODE_STATE_DIR/src.feral" ] && echo y)" "y"
check "baseline captured" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y)" "y"
"$SCRIPT" add manual >/dev/null 2>&1
check "manual keeps active" "$(active)" "active"
"$SCRIPT" del feral >/dev/null 2>&1
check "still active on 1 left" "$(active)" "active"
"$SCRIPT" del manual >/dev/null 2>&1
check "last del deactivates" "$(active)" "inactive"
check "baseline cleared" "$([ -e "$GAMEMODE_STATE_DIR/baseline.profile" ] && echo y || echo n)" "n"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle on activates" "$(active)" "active"
"$SCRIPT" toggle >/dev/null 2>&1
check "toggle off deactivates" "$(active)" "inactive"
"$SCRIPT" reset >/dev/null 2>&1
check "reset leaves inactive" "$(active)" "inactive"
exit $fail
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Unit tests for gamemode-watch's pure event logic (no niri, no subprocess).
Loads the hyphenated script by path and drives Watcher with synthetic events,
asserting the exact sequence of add/del calls."""
import importlib.util
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_PATH = os.path.join(HERE, "..", "gamemode-watch.py")
spec = importlib.util.spec_from_file_location("gamemode_watch", MOD_PATH)
gw = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gw)
fails = 0
def check(desc, got, want):
global fails
if got == want:
print(f"ok - {desc}")
else:
print(f"FAIL - {desc} (got {got!r} want {want!r})")
fails = 1
# --- is_game ---------------------------------------------------------------
check("steam_app_ matches", gw.is_game("steam_app_480"), True)
check("bare steam_app_ matches", gw.is_game("steam_app_"), True)
check("RuneLite matches", gw.is_game("RuneLite"), True)
check("runelite matches", gw.is_game("runelite"), True)
check("runelite full id matches", gw.is_game("net-runelite-client-RuneLite"), True)
check("gamescope matches", gw.is_game("gamescope"), True)
check("Gamescope (case) matches", gw.is_game("Gamescope"), True)
check("Alacritty is not a game", gw.is_game("Alacritty"), False)
check("bolt-launcher excluded", gw.is_game("bolt-launcher"), False)
check("empty app_id is not a game", gw.is_game(""), False)
check("None app_id is not a game", gw.is_game(None), False)
# --- Watcher transitions ---------------------------------------------------
def drive(events):
calls = []
w = gw.Watcher(lambda action: calls.append(action))
for ev in events:
w.handle(ev)
return calls
def win(wid, app_id):
return {"WindowOpenedOrChanged": {"window": {"id": wid, "app_id": app_id}}}
def closed(wid):
return {"WindowClosed": {"id": wid}}
def snapshot(pairs):
return {"WindowsChanged": {"windows": [{"id": i, "app_id": a} for i, a in pairs]}}
check("empty snapshot: no calls", drive([snapshot([])]), [])
check("non-game window: no calls", drive([win(1, "Alacritty")]), [])
check("one game opens: add", drive([win(1, "steam_app_10")]), ["add"])
check(
"second game keeps active (single add)",
drive([win(1, "steam_app_10"), win(2, "RuneLite")]),
["add"],
)
check(
"close one of two: still active",
drive([win(1, "steam_app_10"), win(2, "RuneLite"), closed(2)]),
["add"],
)
check(
"close last game: add then del",
drive([win(1, "steam_app_10"), closed(1)]),
["add", "del"],
)
check(
"snapshot already containing a game: add (restart mid-game)",
drive([snapshot([(1, "Alacritty"), (5, "steam_app_9")])]),
["add"],
)
check(
"game window changes to non-game app_id: del",
drive([win(1, "steam_app_10"), win(1, "Alacritty")]),
["add", "del"],
)
check(
"repeated OpenedOrChanged for same game: single add",
drive([win(1, "gamescope"), win(1, "gamescope"), win(1, "gamescope")]),
["add"],
)
sys.exit(fails)