Compare commits
11 Commits
6e26cc62aa
...
3d08abb060
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d08abb060 | |||
| 0ab08e8561 | |||
| 98f6c56fe3 | |||
| 1fd160f19d | |||
| 259de11868 | |||
| 2c9cff6bb9 | |||
| 77b2866e3d | |||
| 4b1773ac4e | |||
| 05707afc04 | |||
| e0c12ff0c9 | |||
| 5b24b21610 |
@@ -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 1–2.
|
||||
|
||||
- [ ] **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 1–2.
|
||||
|
||||
- [ ] **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 1–5.
|
||||
|
||||
- [ ] **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.
|
||||
@@ -0,0 +1,3 @@
|
||||
[custom]
|
||||
start=/home/alex/.local/bin/gamemode-session add feral
|
||||
end=/home/alex/.local/bin/gamemode-session del feral
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+127
@@ -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
|
||||
Executable
+160
@@ -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)
|
||||
Executable
+44
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user