diff --git a/gamer-mode/README.md b/gamer-mode/README.md
new file mode 100644
index 0000000..5daaed6
--- /dev/null
+++ b/gamer-mode/README.md
@@ -0,0 +1,397 @@
+# Gamer Mode
+
+Live CPU, RAM, and GPU readings in the bar, plus a one-click mode that suspends
+background resource hogs and restores what was running before.
+
+> Requires Noctalia v5 and plugin API 19. Noctalia v4 uses a different QML
+> plugin format and will not list or load this source.
+
+## Plugin
+
+| Field | Value |
+| --- | --- |
+| ID | `nomadcxx/gamer-mode` |
+| Entries | Bar widget: `gamermode`; panel: `main`; service: `service` |
+
+## Requirements
+
+- `pgrep` and `pkill` from procps probe and signal process targets
+- `systemctl` controls service and timer targets
+- `docker` controls container targets
+- `powerprofilesctl` from power-profiles-daemon switches the power profile
+
+Each tool matters only if you target that kind. Without `powerprofilesctl` the
+panel hides its power row and the toggle still works.
+
+## Usage
+
+| Gesture | Action |
+| --- | --- |
+| Left-click | Opens the panel. Set **Left-click action** to `toggle` to toggle instead. |
+| Right-click | Toggles gamer mode, whatever **Left-click action** says. |
+
+The glyph takes the accent colour while gamer mode runs.
+
+The tooltip carries the live readings:
+
+```
+CPU 25% 59°C | RAM 10.9G | GPU 18% 61°C | VRAM 2.5G
+```
+
+The panel shows a bar per reading, the power profile selector, the suspend
+profile selector, what the plugin has suspended, and the maintenance actions.
+
+Pick `light` or `heavy` in the panel and press Enable, and that profile applies
+for the session. A plugin reads its own settings and cannot write them, so the
+choice rides along with the enable rather than changing the **Gamer mode
+profile** setting. While gamer mode runs the selector gives way to a label,
+because the session already fixed the profile. To change it, disable first.
+
+Drive it from a shell or a keybind:
+
+```sh
+noctalia msg plugin nomadcxx/gamer-mode:service all toggle
+noctalia msg plugin nomadcxx/gamer-mode:service all enable
+noctalia msg plugin nomadcxx/gamer-mode:service all disable
+```
+
+Toggle the panel:
+
+```sh
+noctalia msg panel-toggle nomadcxx/gamer-mode:main
+```
+
+### Maintenance
+
+Three one-shot cleanups sit at the foot of the panel. None of them is part of
+gamer mode, and turning gamer mode off does not undo any of them.
+
+| Action | What it does | Needs a password |
+| --- | --- | --- |
+| Clear shader caches | Deletes the Mesa, NVIDIA `GLCache`, RADV, and Steam shader caches | No |
+| Drop page cache | `sync`, then `vm.drop_caches=3` | Yes |
+| Reclaim swap | `swapoff -a && swapon -a`, pulling swapped pages back into RAM | Yes |
+
+Clearing shader caches takes two clicks. The first measures and the button
+reports the size, the second deletes. Games recompile shaders on their next
+launch, so that launch is slower. This is the one to reach for when a driver
+update leaves stale shaders behind.
+
+The plugin expands every cache path from a fixed list and drops any that
+resolves outside your home directory, so the `rm` only ever sees the paths
+above, and only those that exist.
+
+Dropping the page cache frees the RAM the kernel uses to cache files. The kernel
+refills it, and the pages it discards are ones it would otherwise have reused, so
+this buys less than the number in `free` suggests.
+
+Reclaiming swap only runs when what is swapped out fits in free RAM with a tenth
+of total held back. Otherwise it reports that there is no room and does nothing,
+because succeeding into an out-of-memory kill would defeat the point.
+
+## Settings
+
+| Setting | Default | Description |
+| --- | --- | --- |
+| Bar icon | `device-gamepad-2` | Glyph shown in the bar. Names a glyph from the shell's registry. |
+| Left-click action | `open_panel` | Opens the panel or toggles gamer mode. Right-click toggles either way. |
+| Poll interval | `3` | Seconds between metric updates: 2, 3, or 5. |
+| Gamer mode profile | `light` | Selects which target profile a toggle applies. |
+| Auto performance profile | On | Switches to the `performance` power profile while gamer mode runs, then hands back the previous one. |
+| Show temperatures | On | Includes CPU and GPU temperatures in the tooltip and panel. |
+| Suspend targets (JSON) | Empty | Replaces the built-in target list. See below. |
+
+The setting names the profile a bar click applies. The panel selector overrides
+it for the enable it is sent with, and the bar's right-click toggle does not see
+that selection, so it uses the setting.
+
+## Target list
+
+`targets` takes a JSON array. Each entry needs a `match`, a `kind`, and the
+`profiles` it belongs to. `action` is optional.
+
+```json
+[
+ {"match": "awww-daemon", "kind": "process", "action": "freeze", "profiles": ["light", "heavy"]},
+ {"match": "qbittorrent", "kind": "process", "action": "freeze", "profiles": ["light", "heavy"]},
+ {"match": "ollama.service", "kind": "system-service", "action": "stop", "profiles": ["light", "heavy"]},
+ {"match": "fstrim.timer", "kind": "system-timer", "action": "stop", "profiles": ["light", "heavy"]},
+ {"match": "brave", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
+ {"match": "jellyfin.service","kind": "system-service", "action": "stop", "profiles": ["heavy"]}
+]
+```
+
+An empty setting uses the built-in list: 173 entries, 94 of them in `light`.
+Breadth costs almost nothing, because a target that is not running probes as
+`down`, so the plugin never touches it and never restores it. An entry for
+software you do not have costs one `pgrep`.
+
+The plugin falls back to the built-in list when the setting holds invalid JSON or
+when every entry in it fails validation, and logs the reason. It drops single bad
+entries and honours the rest, so one typo costs you one target.
+
+`match` compares through `pgrep -x`, so it needs the whole name. Substrings and
+patterns do not match. The plugin quotes every value for the shell and refuses a
+`match` holding a newline, carriage return, or NUL at parse time.
+
+### Actions
+
+`action` defaults to `stop`.
+
+| | `stop` | `freeze` |
+| --- | --- | --- |
+| Mechanism | `pkill` / `systemctl stop` / `docker stop` | `SIGSTOP` / `docker pause` |
+| Probes | read-only, never elevated | read-only, never elevated |
+| Frees RAM | Yes | No, pages stay resident |
+| Frees VRAM | Yes | No |
+| Halts CPU use | Yes | Yes |
+| Halts disk I/O | Yes | Yes |
+| Keeps state | No, the target shuts down | Yes, the target resumes where it stopped |
+| Restarts | Units, timers, and containers | Always |
+
+Pick `freeze` for anything you return to: a browser, an editor, a wallpaper
+daemon. Pick `stop` when you need the memory back. A local model runtime such as
+`ollama` holds VRAM until the service stops, and freezing keeps every VRAM page
+allocated, so those targets use `stop`.
+
+Network connections drop while a target sits frozen. That suits a torrent client
+and hurts a chat app.
+
+### Kinds
+
+| `kind` | Probe | `stop` | Start | `freeze` | Thaw |
+| --- | --- | --- | --- | --- | --- |
+| `process` | `pgrep -x` | `pkill -x` | none, see below | `pkill -STOP -x` | `pkill -CONT -x` |
+| `user-service` | `systemctl --user is-active` | `systemctl --user stop` | `systemctl --user start` | `systemctl --user kill --kill-whom=all -s SIGSTOP` | same with `SIGCONT` |
+| `system-service` | `systemctl is-active` | `pkexec systemctl stop` | `pkexec systemctl start` | `pkexec systemctl kill --kill-whom=all -s SIGSTOP` | same with `SIGCONT` |
+| `user-timer` | `systemctl --user is-active` | `systemctl --user stop` | `systemctl --user start` | invalid | invalid |
+| `system-timer` | `systemctl is-active` | `pkexec systemctl stop` | `pkexec systemctl start` | invalid | invalid |
+| `container` | `docker inspect -f '{{.State.Running}}'` | `docker stop` | `docker start` | `docker pause` | `docker unpause` |
+
+### Timers
+
+Stopping `foo.service` leaves `foo.timer` free to fire it again five minutes into
+your session, so scheduled work needs its own target. A stock Arch install
+enables `fstrim.timer`, `smartd.timer`, `paccache.timer`, and the package-cache
+timers, and each one stalls disk I/O mid-game.
+
+The timer kinds require the `.timer` suffix on `match`. `systemctl is-active
+fstrim` resolves to `fstrim.service`, so the plugin rejects a timer entry without
+the suffix at parse time.
+
+### Processes do not restart
+
+`kind: "process"` has no start command. A bare process name carries no argv, no
+environment, and no working directory, so the plugin cannot relaunch one. That
+makes `process` with `action: "stop"` a one-way trip. The plugin allows it and
+logs a warning, and every built-in process entry uses `freeze`. To get something
+back, target the unit or container that supervises it.
+
+### Protected targets
+
+The plugin refuses some targets whatever the setting says, because stopping them
+ends your session, kills your audio or network, or kills the game gamer mode
+serves. It drops such an entry at parse time with a logged reason, and every
+command builder refuses it again at action time, so a session file written by an
+older version cannot act on one either.
+
+```
+session/display niri hyprland sway river wayfire labwc gnome-shell kwin_wayland
+ plasmashell Xorg Xwayland greetd sddm gdm
+the shell noctalia quickshell
+audio pipewire pipewire-pulse wireplumber pulseaudio
+core IPC systemd systemd-logind dbus-broker dbus-daemon elogind
+network NetworkManager wpa_supplicant iwd systemd-networkd
+game stack steam steamwebhelper gamescope wine wineserver proton lutris
+ heroic bottles gamemoded
+```
+
+Matching ignores case and any `.service`, `.timer`, or `.socket` suffix, so
+`Steam`, `steam`, and `steam.service` all fail. The list takes no override. A
+wrong entry here costs you a dead session or a killed game, and an override field
+is the one people copy from a forum post without reading. To act on one of these,
+use Feral GameMode's `start=` and `end=` script hooks in `gamemode.ini`.
+
+### System units ask for a password once
+
+Stopping or starting a system unit needs authorisation. The plugin runs all the
+units of one operation through a single `pkexec /usr/bin/systemctl` call, which
+asks once and then does the whole batch as root.
+
+Two simpler approaches were measured first and each cost a password prompt per
+unit, seven units meaning seven dialogs:
+
+- One `systemctl` per unit. polkit's `auth_admin_keep` retains an authorisation
+ against the subject that gave it, and the subject systemd reports is the
+ calling process, so seven processes are seven subjects with nothing to reuse.
+- One `systemctl` naming all seven units. systemctl issues its `StopUnit` calls
+ in parallel, so every polkit check is outstanding before any of them has an
+ answer, and again nothing can reuse an authorisation that does not exist yet.
+
+`org.freedesktop.policykit.exec` is `auth_admin` with no retention, so each
+`pkexec` prompts. The built-in targets only ever stop system units and never
+freeze them, so an enable costs one prompt and a disable costs one. A custom
+target list that mixes stops and freezes pays one prompt per operation.
+
+Without `pkexec` the plugin falls back to calling `systemctl` directly, which
+still works through systemd's own polkit check at the cost of the prompt-per-unit
+behaviour. It logs the downgrade at startup.
+
+You need an authentication agent running for any prompt to appear. Most desktops
+start one; standalone compositors often do not. Check with:
+
+```sh
+pgrep -af 'polkit.*agent'
+```
+
+If nothing is listed, install one such as `mate-polkit`, `polkit-gnome` or
+`hyprpolkitagent` and start it with your session. Without an agent the calls fail
+and each one is logged.
+
+To skip the prompt entirely, grant the units you target. Scope the rule to those
+units: a blanket rule lets anything running as you stop or start any system unit.
+In `/etc/polkit-1/rules.d/49-gamermode.rules`:
+
+```javascript
+polkit.addRule(function (action, subject) {
+ var units = ["sonarr.service", "radarr.service", "fstrim.timer"];
+ if (action.id == "org.freedesktop.systemd1.manage-units"
+ && subject.isInGroup("wheel")
+ && units.indexOf(action.lookup("unit")) >= 0) {
+ return polkit.Result.YES;
+ }
+});
+```
+
+## Targets left out of the built-in list
+
+Each of these suits someone and makes a poor default. Paste what you want into
+`targets`.
+
+**Voice chat.** Freezing these cuts voice during the activity the plugin serves.
+
+```json
+{"match": "discord", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
+{"match": "vesktop", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
+{"match": "slack", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
+{"match": "element-desktop", "kind": "process", "action": "freeze", "profiles": ["heavy"]}
+```
+
+**Music.** Plenty of people game with music playing.
+
+```json
+{"match": "spotify", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
+{"match": "spotifyd.service", "kind": "user-service", "action": "stop", "profiles": ["heavy"]},
+{"match": "mpd.service", "kind": "user-service", "action": "stop", "profiles": ["heavy"]}
+```
+
+**Recording and streaming.** Plenty of people stream the game they play.
+
+```json
+{"match": "obs", "kind": "process", "action": "freeze", "profiles": ["heavy"]},
+{"match": "gpu-screen-recorder", "kind": "process", "action": "freeze", "profiles": ["heavy"]}
+```
+
+**Language runtimes.** `java`, `dotnet`, and `node` burn CPU, and they run games
+too. Minecraft and every PrismLauncher or MultiMC instance runs as `java`. Unity
+and .NET titles run as `dotnet`. Freezing those freezes the game. Add them only
+if nothing you play uses them.
+
+**Container and VM daemons.** Stopping `docker.service` takes down every
+container, and starting it again leaves their previous states behind. Stopping
+`libvirtd` kills running guests. Target single containers with
+`kind: "container"`, which pauses and unpauses through the cgroup freezer.
+
+**Shared databases.** Other services tend to depend on `postgresql`, `mysqld`,
+and `redis`. The built-in `heavy` profile does cover `elasticsearch` and
+`opensearch`, whose JVM heaps often top the RAM table on a development box.
+
+**VRAM without stopping the daemon.** `ollama` unloads models while staying up.
+No target kind covers this, so use a Feral GameMode hook:
+
+```ini
+[custom]
+start=/usr/bin/ollama stop --all
+```
+
+## Restore semantics
+
+Enabling writes a session snapshot to the plugin data directory
+(`session.json`). For every target in the active profile it records whether the
+target was `running` or `active` or already down, which `action` applied, the
+power profile in effect, and the kernel boot ID.
+
+The snapshot lands on disk before anything is suspended. It is the only record
+of what was running beforehand, so a target suspended without one is
+unrecoverable: disable would read the session, find none, and return. If the
+write fails, enabling stops there with the machine untouched and tells you why.
+
+Disable runs two passes, because the two actions need different logic.
+
+The plugin probes each **`stop` target** again and starts it back when it is
+still down. So it leaves alone anything you stopped before enabling gamer mode,
+and anything you restarted by hand while gamer mode ran. It logs and skips a
+missing unit or container, and never fails hard partway.
+
+The plugin thaws every **`freeze` target** without probing. A frozen process
+still appears in `pgrep`, so no probe distinguishes "still frozen" from
+"running", and `SIGCONT` to a process that is not stopped exits 0 and changes
+nothing. Thawing blind beats probing here: it cannot misread a state, cannot
+stomp a manual restart, and cannot leave something frozen after a probe fails to
+run.
+
+The snapshot sits on disk, so gamer mode survives a shell restart. Reload
+Quickshell mid-session and the panel still reports it as on with the same suspend
+list, and disable still restores.
+
+Enabling twice does nothing. A second enable would re-probe and record the
+targets it had suspended as "was down", losing what it needs to restore them.
+
+### After a reboot
+
+A session file outlives a reboot, so the plugin compares the boot ID at startup.
+A different ID means the session went stale: nothing that was frozen still
+exists, and units that were stopped may have come back on their own.
+
+A stale session gets a full restore pass before the plugin clears it. A stopped
+unit that is not `enabled` is still down after a reboot, and putting it
+back is what you were told would happen. Frozen targets died with the reboot, so
+their thaw does nothing. This can fire a few `systemctl start` calls
+soon after login, and the plugin logs each one.
+
+Within the same boot the plugin always keeps the session, even when everything
+looks like it is running, because that is the case where something may still sit
+frozen and need thawing. A session written before version 0.2.0 carries no boot
+ID, and the plugin treats it as current so it does not abandon targets that may
+still be suspended.
+
+## Notes
+
+- The plugin stores its session snapshot in Noctalia's plugin data directory. It
+ makes no network requests.
+- `renice` is absent by design. It looks like the safe middle ground and is not.
+ With `RLIMIT_NICE=0`, the default, an unprivileged process lowers priority and
+ never raises it back, so a renice would degrade every process it touched for
+ the life of that process. `freeze` gives you the reversible option instead.
+ Feral GameMode needs membership in a `gamemode` group to renice at all for the
+ same reason.
+- No I/O weighting for user units. cgroup v2 delegates `cpu`, `memory`, and
+ `pids` to the user manager, and not `io`.
+- The bar widget carries the readings in its tooltip. Plugin API 19 does hand a
+ widget `onHover(entered)`, so a richer hover surface is possible and is not
+ built.
+- The panel shows no per-core CPU breakdown and no top-process list.
+- Nothing places the widget on your bar for you. The manifest has no field for a
+ default bar section, and a plugin can read its settings but not write them, so
+ bar layout stays yours. Add it under **Settings → Bar**.
+- VRAM appears where the shell reports it, which means NVML on NVIDIA.
+- The plugin toggles no compositor effects. Animations, blur, and shadows belong
+ to your compositor's own config.
+- Feral GameMode exposes a `ClientCount` property that emits changes and a
+ `GameRegistered` signal on `com.feralinteractive.GameMode`, and `org.scx.Loader`
+ switches sched_ext schedulers. Arming gamer mode from either one would work and
+ is not built.
+
+## License
+
+MIT
diff --git a/gamer-mode/logo.svg b/gamer-mode/logo.svg
new file mode 100644
index 0000000..6f157ee
--- /dev/null
+++ b/gamer-mode/logo.svg
@@ -0,0 +1,60 @@
+
diff --git a/gamer-mode/panel.luau b/gamer-mode/panel.luau
new file mode 100644
index 0000000..48a2def
--- /dev/null
+++ b/gamer-mode/panel.luau
@@ -0,0 +1,636 @@
+--!nonstrict
+--
+-- Gamer Mode panel. Like the widget it owns no state: it renders what the service
+-- publishes and sends commands back through `noctalia.state`.
+--
+-- Interactive props (onClick/onChange) must be the *names* of global functions -- the
+-- ui bridge resolves handlers by name and cannot call a Lua closure.
+
+local MIB_PER_GIB = 1024
+
+-- The panel is wide enough that a control stretched across it reads badly: a select
+-- holding the word "balanced" does not want four hundred pixels. The label takes the left,
+-- a spacer eats the slack, and the control keeps a fixed, readable width on the right.
+local CONTROL_LABEL_WIDTH = 130
+local CONTROL_WIDTH = 200
+
+local M = {}
+
+local metrics = noctalia.state.get("metrics") or { available = false }
+local gameMode = noctalia.state.get("game_mode") or { enabled = false, busy = false, suspended = {} }
+local power = noctalia.state.get("power") or { available = false, profiles = {} }
+local cleanup = noctalia.state.get("cleanup") or {}
+local nonceCounter = 0
+
+-- The logo is embedded rather than read from the plugin directory because there is no API
+-- that reports where that directory is: the materialised path contains the name of the
+-- source the plugin was installed from, which differs per machine. The data directory is
+-- reported, so the file is written there once and referenced from disk.
+--
+-- gamer-mode/logo.svg holds the same bytes and is the copy to edit; tests/logo.lua fails
+-- if the two drift apart.
+local LOGO_SVG = [==[
+
+]==]
+
+local function logoPath()
+ local directory = noctalia.pluginDataDir()
+ if not directory then
+ return nil
+ end
+ local path = directory .. "/logo.svg"
+ if not noctalia.fileExists(path) then
+ if not noctalia.writeFile(path, LOGO_SVG) then
+ noctalia.log("gamermode: could not write the panel logo")
+ return nil
+ end
+ end
+ return path
+end
+
+local function tr(key, subst)
+ return noctalia.tr(key, subst)
+end
+
+local function clamp(value)
+ local number = tonumber(value) or 0
+ return math.max(0, math.min(1, number))
+end
+
+local function percent(fraction)
+ return string.format("%d%%", math.floor(clamp(fraction) * 100 + 0.5))
+end
+
+local function gibibytes(mib)
+ return (tonumber(mib) or 0) / MIB_PER_GIB
+end
+
+local function usedOfTotal(usedMib, totalMib)
+ return string.format("%.1f / %.1f GiB", gibibytes(usedMib), gibibytes(totalMib))
+end
+
+local function withTemp(detail, showTemps, temp)
+ if showTemps and tonumber(temp) then
+ return detail .. string.format(" %d°C", math.floor(tonumber(temp) + 0.5))
+ end
+ return detail
+end
+
+-- buildRows produces one entry per reading the machine actually reports. Unsupported
+-- readings are absent rather than drawn as an empty bar, which would look like a real
+-- idle reading.
+function M.buildRows(m, showTemps)
+ local rows = {}
+ if type(m) ~= "table" or not m.available then
+ return rows
+ end
+
+ rows[#rows + 1] = {
+ label = "CPU",
+ glyph = "cpu-usage",
+ progress = clamp(m.cpuPerc),
+ detail = withTemp(percent(m.cpuPerc), showTemps, m.cpuTemp),
+ }
+ rows[#rows + 1] = {
+ label = "RAM",
+ glyph = "memory",
+ progress = clamp(m.memPerc),
+ detail = usedOfTotal(m.memUsedMb, m.memTotalMb),
+ }
+
+ -- Swap only when the machine has some. A zero total is not a bar at 0%, it is a
+ -- machine with swap turned off, and drawing it would invite a reading that is not
+ -- there.
+ if m.swapTotalMb and m.swapTotalMb > 0 then
+ rows[#rows + 1] = {
+ label = "Swap",
+ glyph = "storage",
+ progress = clamp(m.swapPerc),
+ detail = usedOfTotal(m.swapUsedMb, m.swapTotalMb),
+ }
+ end
+
+ if m.gpuAvailable and m.gpuPerc then
+ rows[#rows + 1] = {
+ label = "GPU",
+ glyph = "gpu-usage",
+ progress = clamp(m.gpuPerc),
+ detail = withTemp(percent(m.gpuPerc), showTemps, m.gpuTemp),
+ }
+ end
+ if m.vramUsedMb and m.vramTotalMb then
+ rows[#rows + 1] = {
+ label = "VRAM",
+ glyph = "storage",
+ progress = clamp(m.vramPerc),
+ detail = usedOfTotal(m.vramUsedMb, m.vramTotalMb),
+ }
+ end
+ return rows
+end
+
+local function perSecond(bytes)
+ local value = tonumber(bytes) or 0
+ if value >= 1024 * 1024 then
+ return string.format("%.1f MB/s", value / (1024 * 1024))
+ elseif value >= 1024 then
+ return string.format("%.0f KB/s", value / 1024)
+ end
+ return string.format("%.0f B/s", value)
+end
+
+-- Readings that are not a proportion of anything. Load average needs a core count to
+-- become a percentage and the shell does not report one; a network rate has no ceiling to
+-- measure against. Both are shown as figures rather than invented into bars.
+function M.buildFigures(m)
+ local figures = {}
+ if type(m) ~= "table" or not m.available then
+ return figures
+ end
+ if m.load1 and m.load5 and m.load15 then
+ figures[#figures + 1] = {
+ label = tr("panel.load"),
+ glyph = "performance",
+ detail = string.format("%.2f %.2f %.2f", m.load1, m.load5, m.load15),
+ }
+ end
+ if m.netRxPerSec and m.netTxPerSec then
+ figures[#figures + 1] = {
+ label = tr("panel.network"),
+ glyph = "antenna-bars-5",
+ detail = perSecond(m.netRxPerSec) .. " " .. perSecond(m.netTxPerSec),
+ }
+ end
+ return figures
+end
+
+function M.suspendedLines(gm)
+ local lines = {}
+ for _, target in ipairs((type(gm) == "table" and gm.suspended) or {}) do
+ -- Frozen and stopped are materially different to a user reading this list: one
+ -- resumes exactly where it left off, the other was shut down and restarted.
+ local state = target.action == "freeze" and tr("panel.frozen") or tr("panel.stopped")
+ lines[#lines + 1] = string.format("%s (%s, %s)", tostring(target.match), tostring(target.kind), state)
+ end
+ return lines
+end
+
+-- M.powerProfileAt resolves the zero-based index the select reports back to a profile
+-- name, or nil when the index no longer matches the published list.
+function M.powerProfileAt(index)
+ local position = (tonumber(index) or -1) + 1
+ local profiles = (type(power) == "table" and power.profiles) or {}
+ return profiles[position]
+end
+
+-- ── suspend profile ──
+
+local SUSPEND_PROFILES = { "light", "heavy" }
+
+function M.suspendProfiles()
+ return { SUSPEND_PROFILES[1], SUSPEND_PROFILES[2] }
+end
+
+-- The chosen profile travels with the enable command rather than changing the setting,
+-- because a plugin reads its own settings and cannot write them. It starts from whatever
+-- the setting says and then lives as long as the loaded panel entry, which outlives any
+-- one opening of the panel but not a shell restart or a plugin reload.
+--
+-- The bar's right-click toggle cannot see this: the widget is a separate entry with its
+-- own state, so it enables the profile named in the settings. Selecting here and then
+-- right-clicking the icon is the one path where the two disagree.
+local selectedProfileName = nil
+
+function M.selectedProfile()
+ if selectedProfileName then
+ return selectedProfileName
+ end
+ local configured = noctalia.getConfig("profile")
+ return configured == "heavy" and "heavy" or "light"
+end
+
+function M.selectSuspendProfile(index)
+ local name = SUSPEND_PROFILES[(tonumber(index) or -1) + 1]
+ if not name then
+ noctalia.log("gamermode: ignoring an out-of-range suspend profile selection")
+ return false
+ end
+ selectedProfileName = name
+ return true
+end
+
+local function selectedSuspendIndex()
+ local current = M.selectedProfile()
+ for index, name in ipairs(SUSPEND_PROFILES) do
+ if name == current then
+ return index - 1
+ end
+ end
+ return 0
+end
+
+local function selectedPowerIndex()
+ for index, profile in ipairs(power.profiles or {}) do
+ if profile == power.active then
+ return index - 1
+ end
+ end
+ return 0
+end
+
+-- ── rendering ──
+
+local function metricRow(row)
+ return ui.column({ gap = 4 }, {
+ ui.row({ align = "center", gap = 8 }, {
+ ui.glyph({ name = row.glyph, size = 14, color = "on_surface_variant" }),
+ ui.label({ text = row.label, color = "on_surface_variant", width = 48 }),
+ -- A spacer pushes the detail right. `align` is not a label prop: setting it
+ -- left the text unaligned and the shell logged a warning on every render.
+ ui.spacer({ flexGrow = 1 }),
+ ui.label({ text = row.detail, color = "on_surface_variant", fontSize = 11 }),
+ }),
+ ui.progress({
+ height = 4,
+ progress = row.progress,
+ fill = "primary",
+ track = "surface_variant",
+ radius = 2,
+ }),
+ })
+end
+
+local function figureRow(figure)
+ return ui.row({ align = "center", gap = 8 }, {
+ ui.glyph({ name = figure.glyph, size = 14, color = "on_surface_variant" }),
+ ui.label({ text = figure.label, color = "on_surface_variant", width = 48 }),
+ ui.spacer({ flexGrow = 1 }),
+ ui.label({ text = figure.detail, color = "on_surface_variant", fontSize = 11 }),
+ })
+end
+
+local function toggleButton()
+ if gameMode.busy then
+ return ui.button({ text = tr("panel.working"), enabled = false, variant = "ghost" })
+ end
+ return ui.button({
+ text = gameMode.enabled and tr("panel.disable") or tr("panel.enable"),
+ glyph = gameMode.enabled and "player-stop-filled" or "player-play-filled",
+ selected = gameMode.enabled,
+ onClick = "onToggleGameMode",
+ })
+end
+
+local function header()
+ local logo = logoPath()
+ local mark = logo
+ and ui.image({ path = logo, width = 42, height = 42, fit = "contain" })
+ -- The glyph is the fallback for the one case that can fail: no plugin data
+ -- directory to write the logo into.
+ or ui.glyph({ name = "device-gamepad-2", size = 22, color = "primary" })
+
+ return ui.row({ align = "center", gap = 10 }, {
+ mark,
+ ui.column({ gap = 0, flexGrow = 1 }, {
+ ui.label({ text = tr("panel.title"), fontSize = 17, fontWeight = "bold" }),
+ ui.label({
+ text = gameMode.enabled and tr("panel.state_on") or tr("panel.state_off"),
+ fontSize = 11,
+ color = gameMode.enabled and "primary" or "on_surface_variant",
+ }),
+ }),
+ toggleButton(),
+ -- The panel also dismisses on an outside click, but a visible control is the one
+ -- people look for, and every other panel in the shell has one.
+ ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.close"), onClick = "onCloseClicked" }),
+ })
+end
+
+-- ── maintenance ──
+
+-- Deleting the shader caches is the one action here that destroys something, so the button
+-- asks first and names the size it is about to remove.
+local shadersArmed = false
+
+local function maintenanceSection(children)
+ children[#children + 1] = ui.separator({})
+ children[#children + 1] = ui.label({
+ text = tr("cleanup.title"),
+ color = "on_surface_variant",
+ fontSize = 12,
+ fontWeight = "bold",
+ })
+
+ local working = cleanup.running ~= nil
+ children[#children + 1] = ui.row({ gap = 8 }, {
+ ui.button({
+ -- The size arrives from the service, which measured it when the button armed.
+ text = shadersArmed and tr("cleanup.shaders_confirm", { size = cleanup.shaderSize or "?" })
+ or tr("cleanup.shaders"),
+ glyph = shadersArmed and "alert-triangle" or "storage",
+ variant = shadersArmed and "primary" or "ghost",
+ selected = shadersArmed,
+ enabled = not working,
+ flexGrow = 1,
+ onClick = "onClearShaders",
+ }),
+ })
+ children[#children + 1] = ui.row({ gap = 8 }, {
+ ui.button({
+ text = tr("cleanup.pagecache"),
+ glyph = "memory",
+ variant = "ghost",
+ enabled = not working,
+ flexGrow = 1,
+ onClick = "onDropPageCache",
+ }),
+ ui.button({
+ text = tr("cleanup.swap"),
+ glyph = "performance",
+ variant = "ghost",
+ enabled = not working,
+ flexGrow = 1,
+ onClick = "onReclaimSwap",
+ }),
+ })
+
+ if type(cleanup.message) == "string" and cleanup.message ~= "" then
+ children[#children + 1] = ui.label({
+ text = cleanup.message,
+ fontSize = 11,
+ color = cleanup.ok == false and "error" or "on_surface_variant",
+ })
+ end
+end
+
+local function powerRow()
+ if not power.available then
+ return ui.label({ text = tr("panel.power_unavailable"), color = "on_surface_variant", fontSize = 11 })
+ end
+ return ui.row({ align = "center", gap = 8 }, {
+ ui.label({ text = tr("panel.power_profile"), color = "on_surface_variant", width = CONTROL_LABEL_WIDTH }),
+ ui.spacer({ flexGrow = 1 }),
+ ui.select({
+ options = power.profiles,
+ selectedIndex = selectedPowerIndex(),
+ width = CONTROL_WIDTH,
+ onChange = "onPowerProfileChanged",
+ }),
+ })
+end
+
+-- While gamer mode runs, the profile in force is whatever the session recorded, so the
+-- selector gives way to a plain label. Picking a different profile means disabling first.
+local function modeProfileRow()
+ if gameMode.enabled then
+ return ui.row({ align = "center", gap = 8 }, {
+ ui.label({ text = tr("panel.mode_profile"), color = "on_surface_variant", width = CONTROL_LABEL_WIDTH }),
+ ui.spacer({ flexGrow = 1 }),
+ ui.label({ text = tr("panel.profiles." .. tostring(gameMode.profile or "light")), width = CONTROL_WIDTH }),
+ })
+ end
+ local options = {}
+ for index, name in ipairs(M.suspendProfiles()) do
+ options[index] = tr("panel.profiles." .. name)
+ end
+ return ui.row({ align = "center", gap = 8 }, {
+ ui.label({ text = tr("panel.mode_profile"), color = "on_surface_variant", width = CONTROL_LABEL_WIDTH }),
+ ui.spacer({ flexGrow = 1 }),
+ ui.select({
+ options = options,
+ selectedIndex = selectedSuspendIndex(),
+ width = CONTROL_WIDTH,
+ onChange = "onSuspendProfileChanged",
+ }),
+ })
+end
+
+local function sectionLabel(key, count)
+ local text = tr(key)
+ if count then
+ text = text .. " (" .. tostring(count) .. ")"
+ end
+ return ui.label({ text = text, color = "on_surface_variant", fontSize = 12, fontWeight = "bold" })
+end
+
+local function suspendedSection(children)
+ if not gameMode.enabled then
+ return
+ end
+ children[#children + 1] = ui.separator({})
+ local lines = M.suspendedLines(gameMode)
+ children[#children + 1] = sectionLabel("panel.suspended", #lines > 0 and #lines or nil)
+ if #lines == 0 then
+ children[#children + 1] = ui.label({ text = tr("panel.nothing_suspended"), fontSize = 11 })
+ return
+ end
+ for _, line in ipairs(lines) do
+ children[#children + 1] = ui.label({ text = line, fontSize = 11, color = "on_surface_variant" })
+ end
+end
+
+local function body()
+ local showTemps = noctalia.getConfig("show_temps") ~= false
+ local children = {}
+
+ local rows = M.buildRows(metrics, showTemps)
+ if #rows == 0 then
+ children[#children + 1] = ui.label({
+ text = tr("panel.metrics_unavailable"),
+ color = "on_surface_variant",
+ fontSize = 11,
+ })
+ else
+ children[#children + 1] = sectionLabel("panel.performance")
+ for _, row in ipairs(rows) do
+ children[#children + 1] = metricRow(row)
+ end
+ for _, figure in ipairs(M.buildFigures(metrics)) do
+ children[#children + 1] = figureRow(figure)
+ end
+ if not metrics.gpuAvailable then
+ children[#children + 1] = ui.label({
+ text = tr("panel.gpu_unsupported"),
+ color = "on_surface_variant",
+ fontSize = 11,
+ })
+ end
+ end
+
+ children[#children + 1] = ui.separator({})
+ children[#children + 1] = powerRow()
+ children[#children + 1] = modeProfileRow()
+ suspendedSection(children)
+ maintenanceSection(children)
+
+ return ui.column({ gap = 12, flexGrow = 1 }, children)
+end
+
+local function footer()
+ return ui.row({ align = "center", gap = 8 }, {
+ ui.spacer({ flexGrow = 1 }),
+ ui.button({ text = tr("panel.settings"), glyph = "plugin", variant = "ghost", onClick = "onOpenSettings" }),
+ })
+end
+
+local function render()
+ panel.render(ui.column({ padding = 20, gap = 14, flexGrow = 1 }, {
+ header(),
+ ui.scroll({ flexGrow = 1, gap = 12 }, { body() }),
+ footer(),
+ }))
+end
+
+local function sendCommand(action, extra)
+ nonceCounter = nonceCounter + 1
+ local command = { nonce = noctalia.nowMs() * 1000 + nonceCounter, action = action }
+ for key, value in pairs(extra or {}) do
+ command[key] = value
+ end
+ noctalia.state.set("command", command)
+end
+
+-- ── shell entry points (must be globals) ──
+
+function onOpen()
+ metrics = noctalia.state.get("metrics") or metrics
+ gameMode = noctalia.state.get("game_mode") or gameMode
+ power = noctalia.state.get("power") or power
+ render()
+end
+
+-- The command carries the chosen profile so one button both picks and applies. Disabling
+-- ignores it: the session already records which profile was in force.
+function onToggleGameMode()
+ sendCommand("toggle", { profile = M.selectedProfile() })
+end
+
+function onSuspendProfileChanged(index)
+ if M.selectSuspendProfile(index) then
+ render()
+ end
+end
+
+function onPowerProfileChanged(index)
+ local profile = M.powerProfileAt(index)
+ if not profile then
+ noctalia.log("gamermode: ignoring an out-of-range power profile selection")
+ return
+ end
+ sendCommand("set-power-profile", { profile = profile })
+end
+
+function onOpenSettings()
+ noctalia.openSettings()
+end
+
+-- The first click measures and arms; the second deletes. Anything else the user does in
+-- the panel is a chance to have changed their mind, so the arming does not persist past a
+-- different cleanup being started.
+function onClearShaders()
+ if shadersArmed then
+ shadersArmed = false
+ sendCommand("cleanup", { job = "shaders" })
+ else
+ shadersArmed = true
+ sendCommand("cleanup", { job = "shaders-measure" })
+ end
+ render()
+end
+
+function onDropPageCache()
+ shadersArmed = false
+ sendCommand("cleanup", { job = "pagecache" })
+ render()
+end
+
+function onReclaimSwap()
+ shadersArmed = false
+ sendCommand("cleanup", { job = "swap" })
+ render()
+end
+
+function onCloseClicked()
+ panel.close()
+end
+
+noctalia.state.watch("metrics", function(value)
+ metrics = type(value) == "table" and value or { available = false }
+ render()
+end)
+
+noctalia.state.watch("game_mode", function(value)
+ gameMode = type(value) == "table" and value or { enabled = false, busy = false, suspended = {} }
+ render()
+end)
+
+noctalia.state.watch("power", function(value)
+ power = type(value) == "table" and value or { available = false, profiles = {} }
+ render()
+end)
+
+noctalia.state.watch("cleanup", function(value)
+ cleanup = type(value) == "table" and value or {}
+ render()
+end)
+
+return M
diff --git a/gamer-mode/plugin.toml b/gamer-mode/plugin.toml
new file mode 100644
index 0000000..2d952cf
--- /dev/null
+++ b/gamer-mode/plugin.toml
@@ -0,0 +1,94 @@
+id = "nomadcxx/gamer-mode"
+name = "Gamer Mode"
+version = "0.6.4"
+plugin_api = 19
+author = "Nomadcxx"
+license = "MIT"
+dependencies = ["pgrep", "pkill", "powerprofilesctl", "systemctl"]
+tags = ["bar", "panel", "service", "gaming", "system", "hardware"]
+icon = "device-gamepad-2"
+description = "Live CPU/RAM/GPU metrics and a one-click gamer mode that suspends and restores background resource hogs."
+
+[[setting]]
+key = "glyph"
+type = "glyph"
+label_key = "settings.glyph.label"
+description_key = "settings.glyph.description"
+default = "device-gamepad-2"
+
+[[setting]]
+key = "click_action"
+type = "select"
+label_key = "settings.click_action.label"
+description_key = "settings.click_action.description"
+default = "open_panel"
+options = [
+ { value = "open_panel", label_key = "settings.click_action.options.open_panel" },
+ { value = "toggle", label_key = "settings.click_action.options.toggle" },
+]
+
+[[setting]]
+key = "poll_interval"
+type = "select"
+label_key = "settings.poll_interval.label"
+description_key = "settings.poll_interval.description"
+default = "3"
+options = [
+ { value = "2", label_key = "settings.poll_interval.options.2" },
+ { value = "3", label_key = "settings.poll_interval.options.3" },
+ { value = "5", label_key = "settings.poll_interval.options.5" },
+]
+
+[[setting]]
+key = "profile"
+type = "select"
+label_key = "settings.profile.label"
+description_key = "settings.profile.description"
+default = "light"
+options = [
+ { value = "light", label_key = "settings.profile.options.light" },
+ { value = "heavy", label_key = "settings.profile.options.heavy" },
+]
+
+[[setting]]
+key = "auto_performance"
+type = "bool"
+label_key = "settings.auto_performance.label"
+description_key = "settings.auto_performance.description"
+default = true
+
+[[setting]]
+key = "show_temps"
+type = "bool"
+label_key = "settings.show_temps.label"
+description_key = "settings.show_temps.description"
+default = true
+
+[[setting]]
+key = "targets"
+type = "string"
+label_key = "settings.targets.label"
+description_key = "settings.targets.description"
+default = ""
+advanced = true
+
+[[service]]
+id = "service"
+entry = "service.luau"
+
+[[panel]]
+id = "main"
+entry = "panel.luau"
+width = 560
+height = 820
+placement = "attached"
+position = "auto"
+# "on_demand" takes keyboard focus only when you click inside the panel, so opening it
+# from the bar leaves focus where it was. "none" would refuse focus entirely, but the
+# shell requires dismiss_on_outside_click = false alongside it, which would leave the
+# panel on screen until you click the bar icon again.
+keyboard_focus = "on_demand"
+
+[[widget]]
+id = "gamermode"
+entry = "widget.luau"
diff --git a/gamer-mode/service.luau b/gamer-mode/service.luau
new file mode 100644
index 0000000..e69ffac
--- /dev/null
+++ b/gamer-mode/service.luau
@@ -0,0 +1,1828 @@
+--!nonstrict
+--
+-- Gamer Mode service: publishes system metrics and owns the snapshot-based game-mode
+-- engine. The panel and widget are thin readers of `noctalia.state`; every mutation
+-- arrives here as a `command` state write.
+--
+-- Luau's sandbox gives plugins no `io`, no `os.execute`/`os.remove` and no `load`, so
+-- all filesystem access goes through noctalia.readFile/writeFile/removeFile/mkdirAll
+-- and the snapshot is stored as JSON via noctalia.json.
+
+local M = {}
+
+local BYTES_PER_MIB = 1048576
+
+local function clampFraction(percent)
+ local value = tonumber(percent)
+ if not value then
+ return 0
+ end
+ return math.max(0, math.min(1, value / 100))
+end
+
+-- normalize converts a noctalia.systemStats() sample into the published `metrics`
+-- shape: percentages as 0-1 fractions, memory in MiB.
+--
+-- Optional readings stay nil rather than becoming zero, so the UI can say "unsupported"
+-- instead of drawing an empty bar that looks like a real 0% reading.
+function M.normalize(raw)
+ if type(raw) ~= "table" then
+ return nil
+ end
+
+ local cpu = type(raw.cpu) == "table" and raw.cpu or {}
+ local ram = type(raw.ram) == "table" and raw.ram or {}
+ local gpu = type(raw.gpu) == "table" and raw.gpu or {}
+
+ local metrics = {
+ cpuPerc = clampFraction(cpu.usagePercent),
+ cpuTemp = tonumber(cpu.tempC),
+ memPerc = clampFraction(ram.usagePercent),
+ memUsedMb = tonumber(ram.usedMb) or 0,
+ memTotalMb = tonumber(ram.totalMb) or 0,
+ gpuAvailable = false,
+ }
+
+ local gpuPercent = tonumber(gpu.usagePercent)
+ if gpuPercent then
+ metrics.gpuPerc = clampFraction(gpuPercent)
+ metrics.gpuAvailable = true
+ end
+ local gpuTemp = tonumber(gpu.tempC)
+ if gpuTemp then
+ metrics.gpuTemp = gpuTemp
+ metrics.gpuAvailable = true
+ end
+
+ -- VRAM needs both halves: a used figure without a total cannot be drawn as a ratio.
+ local vramUsed = tonumber(gpu.vramUsedBytes)
+ local vramTotal = tonumber(gpu.vramTotalBytes)
+ if vramUsed and vramTotal and vramTotal > 0 then
+ metrics.vramUsedMb = vramUsed / BYTES_PER_MIB
+ metrics.vramTotalMb = vramTotal / BYTES_PER_MIB
+ metrics.vramPerc = math.max(0, math.min(1, vramUsed / vramTotal))
+ metrics.gpuAvailable = true
+ end
+
+ -- Swap reports used and total but no percentage of its own, and a machine with swap
+ -- turned off reports a total of zero, which is a ratio with no meaning.
+ local swap = type(raw.swap) == "table" and raw.swap or {}
+ local swapUsed = tonumber(swap.usedMb)
+ local swapTotal = tonumber(swap.totalMb)
+ if swapUsed and swapTotal and swapTotal > 0 then
+ metrics.swapUsedMb = swapUsed
+ metrics.swapTotalMb = swapTotal
+ metrics.swapPerc = math.max(0, math.min(1, swapUsed / swapTotal))
+ end
+
+ -- Load average arrives as a three-element array. It is not a percentage of anything
+ -- without a core count, so it is carried through as numbers and shown as numbers.
+ if type(raw.loadAvg) == "table" then
+ local one, five, fifteen = tonumber(raw.loadAvg[1]), tonumber(raw.loadAvg[2]), tonumber(raw.loadAvg[3])
+ if one and five and fifteen then
+ metrics.load1, metrics.load5, metrics.load15 = one, five, fifteen
+ end
+ end
+
+ -- Totals across every interface. The per-interface breakdown is left alone: on a
+ -- machine running containers it is mostly bridges and veth pairs.
+ local net = type(raw.net) == "table" and raw.net or {}
+ local rx = tonumber(net.rxBytesPerSec)
+ local tx = tonumber(net.txBytesPerSec)
+ if rx and tx then
+ metrics.netRxPerSec = math.max(0, rx)
+ metrics.netTxPerSec = math.max(0, tx)
+ end
+
+ return metrics
+end
+
+-- ── suspend targets ──
+
+local VALID_KINDS = {
+ process = true,
+ ["user-service"] = true,
+ ["system-service"] = true,
+ ["user-timer"] = true,
+ ["system-timer"] = true,
+ container = true,
+}
+
+-- Timer kinds share systemctl with the service kinds but must name a *.timer unit, and
+-- cannot be frozen -- a timer has no process to signal.
+local TIMER_KINDS = { ["user-timer"] = true, ["system-timer"] = true }
+
+local VALID_ACTIONS = { stop = true, freeze = true }
+
+-- Targets that must never be stopped or frozen. Acting on any of these ends the session,
+-- kills audio or network, or kills the game gamer mode exists to serve. Deliberately not
+-- overridable: the cost of a wrong entry is a dead session, and an override flag is
+-- exactly the field a user copies from a forum post without reading.
+local DENIED = {}
+for _, name in ipairs({
+ -- session and display
+ "niri", "hyprland", "sway", "river", "wayfire", "labwc", "gnome-shell",
+ "kwin_wayland", "plasmashell", "xorg", "xwayland", "greetd", "sddm", "gdm",
+ -- the shell hosting this plugin
+ "noctalia", "quickshell",
+ -- audio
+ "pipewire", "pipewire-pulse", "wireplumber", "pulseaudio",
+ -- core IPC and session management
+ "systemd", "systemd-logind", "dbus-broker", "dbus-daemon", "elogind",
+ -- network
+ "networkmanager", "wpa_supplicant", "iwd", "systemd-networkd",
+ -- the game stack itself
+ "steam", "steamwebhelper", "gamescope", "wine", "wineserver", "proton",
+ "lutris", "heroic", "bottles", "gamemoded",
+ -- GPU driver daemons. Stopping one of these mid-session costs the thing gamer mode
+ -- exists to protect: nvidia-persistenced holds the driver state that keeps a card
+ -- from reinitialising, and nvidia-powerd manages the dynamic power budget that lets
+ -- it reach its boost clocks.
+ "nvidia-persistenced", "nvidia-powerd", "nvidia-suspend", "nvidia-resume",
+ "nvidia-hibernate", "amdgpu", "amd-pstate", "switcheroo-control",
+}) do
+ DENIED[name] = true
+end
+
+local UNIT_SUFFIXES = { ".service", ".timer", ".socket" }
+
+-- baseName lowercases and strips a unit suffix so "Steam", "steam" and "steam.service"
+-- all collapse onto the same denylist key.
+local function baseName(match)
+ local name = tostring(match):lower()
+ for _, suffix in ipairs(UNIT_SUFFIXES) do
+ if #name > #suffix and name:sub(-#suffix) == suffix then
+ return name:sub(1, #name - #suffix)
+ end
+ end
+ return name
+end
+
+function M.isDenied(match)
+ if type(match) ~= "string" or match == "" then
+ return false
+ end
+ return DENIED[baseName(match)] == true
+end
+
+-- Defaults aim to be plausible on an arbitrary Linux desktop rather than tuned to one
+-- machine. Breadth is close to free: a target that is not running probes as `down`, so it
+-- is never acted on and never restored -- an entry for absent software costs one pgrep.
+-- The risk is never "too many entries", it is "an entry that is present but should not be
+-- touched", which is what the denylist above exists for.
+--
+-- `light` is background-only: nothing the user could be interacting with, and nothing that
+-- produces sound, voice or video they would want during a game. `heavy` adds the big
+-- foreground consumers as freezes plus the self-hosted service stacks as stops.
+local LIGHT = { "light", "heavy" }
+local HEAVY = { "heavy" }
+
+local function processes(names, profiles, out)
+ for _, name in ipairs(names) do
+ -- Always freeze: a bare process has no argv to relaunch from, so stopping one is
+ -- unrecoverable.
+ out[#out + 1] = { match = name, kind = "process", action = "freeze", profiles = profiles }
+ end
+ return out
+end
+
+local function units(names, kind, profiles, out)
+ for _, name in ipairs(names) do
+ out[#out + 1] = { match = name, kind = kind, action = "stop", profiles = profiles }
+ end
+ return out
+end
+
+local function buildDefaults()
+ local out = {}
+
+ -- ── light: wallpaper and desktop-effect daemons ──
+ -- Animated and video wallpapers cost real GPU time. Freezing stops the rendering,
+ -- which is the entire win, and SIGCONT restores it perfectly -- where killing the
+ -- daemon would need a full relaunch-and-rewallpaper dance.
+ -- swww was archived and renamed to awww in Oct 2025; both names ship.
+ processes({
+ "awww-daemon", "swww-daemon", "hyprpaper", "swaybg", "wpaperd", "mpvpaper",
+ "glpaper", "wbg", "oguri", "linux-wallpaperengine", "gslapper",
+ }, LIGHT, out)
+
+ -- ── light: torrent and usenet ──
+ -- Sustained disk I/O plus network saturation; usenet unpack and par2 repair also burn
+ -- a lot of CPU.
+ processes({
+ "qbittorrent", "qbittorrent-nox", "transmission-daemon", "transmission-gtk",
+ "deluged", "deluge-gtk", "rtorrent", "aria2c", "ktorrent",
+ "sabnzbd", "sabnzbdplus", "nzbget",
+ }, LIGHT, out)
+ units({
+ "transmission.service", "qbittorrent-nox.service", "deluged.service",
+ "aria2.service", "sabnzbd.service", "nzbget.service",
+ }, "system-service", LIGHT, out)
+
+ -- ── light: cloud sync ──
+ processes({
+ "syncthing", "dropbox", "nextcloud", "insync", "megasync", "onedrive",
+ "maestral", "rclone", "seafile-applet", "owncloud",
+ }, LIGHT, out)
+ units({ "syncthing.service", "onedrive.service" }, "user-service", LIGHT, out)
+
+ -- ── light: backup ──
+ processes({ "borg", "restic", "duplicati", "rsnapshot", "kopia" }, LIGHT, out)
+ units({
+ "borgmatic.timer", "restic-backup.timer", "snapper-timeline.timer",
+ "snapper-cleanup.timer", "duplicati.timer",
+ }, "system-timer", LIGHT, out)
+
+ -- ── light: file indexers ──
+ processes({
+ "baloo_file", "baloo_file_extractor", "tracker-miner-fs-3", "tracker-extract-3",
+ "recollindex", "updatedb", "plocate",
+ }, LIGHT, out)
+ units({ "plocate-updatedb.timer", "updatedb.timer", "mlocate.timer" }, "system-timer", LIGHT, out)
+
+ -- ── light: scheduled maintenance ──
+ -- Stopping a service does nothing if its timer re-fires it mid-game. fstrim in
+ -- particular stalls I/O hard.
+ units({
+ "fstrim.timer", "smartd.timer", "paccache.timer", "pamac-cleancache.timer",
+ "reflector.timer", "archlinux-keyring-wkd-sync.timer", "pacman-filesdb-refresh.timer",
+ "systemd-tmpfiles-clean.timer", "man-db.timer", "dnf-makecache.timer",
+ "snapd.refresh.timer", "flatpak-system-update.timer", "e2scrub_all.timer",
+ }, "system-timer", LIGHT, out)
+
+ -- ── light: update daemons ──
+ units({
+ "packagekit.service", "pamac-daemon.service", "snapd.service",
+ "unattended-upgrades.service",
+ }, "system-service", LIGHT, out)
+
+ -- ── light: AI / LLM runtimes ──
+ -- These hold VRAM, which only a real stop releases -- freezing keeps every page
+ -- resident. Service-kind only, because process + stop is unrecoverable.
+ units({
+ "ollama.service", "localai.service", "comfyui.service", "open-webui.service",
+ }, "system-service", LIGHT, out)
+
+ -- ── light: antivirus and telemetry ──
+ units({
+ "clamav-daemon.service", "clamd.service", "clamav-freshclam.service",
+ }, "system-service", LIGHT, out)
+ units({ "clamav-freshclam.timer", "rkhunter.timer" }, "system-timer", LIGHT, out)
+ units({
+ "whoopsie.service", "apport.service", "abrtd.service", "teamviewerd.service",
+ "anydesk.service",
+ }, "system-service", LIGHT, out)
+
+ -- ── light: phone and emulator tooling ──
+ processes({ "adb", "scrcpy" }, LIGHT, out)
+
+ -- ── heavy: browsers ──
+ -- Usually the single largest consumer of both RAM and CPU. Frozen, not killed: nobody
+ -- wants their tabs gone when they quit a game.
+ processes({
+ "brave", "chrome", "google-chrome", "chromium", "firefox", "librewolf",
+ "vivaldi-bin", "opera", "microsoft-edge", "thorium", "zen-browser", "waterfox",
+ "qutebrowser",
+ }, HEAVY, out)
+
+ -- ── heavy: editors, language servers, builds ──
+ -- `java`, `dotnet` and `node` are deliberately absent: they are game runtimes as well
+ -- as build tools. Minecraft and every PrismLauncher instance run as `java`, Unity and
+ -- .NET titles as `dotnet` -- freezing them would freeze the game.
+ processes({
+ "code", "codium", "code-oss", "cursor", "zed", "idea", "pycharm", "webstorm",
+ "clion", "goland", "rider", "rustrover", "android-studio", "sublime_text",
+ }, HEAVY, out)
+ processes({
+ "rust-analyzer", "gopls", "clangd", "pylsp", "pyright",
+ "typescript-language-server", "jdtls", "lua-language-server", "omnisharp", "ccls",
+ }, HEAVY, out)
+ processes({
+ "cargo", "rustc", "gradle", "tsc", "webpack", "vite", "esbuild", "ninja", "make",
+ "cc1plus", "ccache", "sccache", "distccd",
+ }, HEAVY, out)
+ processes({ "claude", "opencode", "codex", "aider" }, HEAVY, out)
+
+ -- ── heavy: CI runners ──
+ units({
+ "gitlab-runner.service", "buildkite-agent.service", "jenkins.service",
+ }, "system-service", HEAVY, out)
+
+ -- ── heavy: self-hosted media stack ──
+ units({
+ "sonarr.service", "radarr.service", "lidarr.service", "readarr.service",
+ "prowlarr.service", "bazarr.service", "jackett.service", "jellyseerr.service",
+ "overseerr.service", "ombi.service", "tautulli.service",
+ }, "system-service", HEAVY, out)
+ units({
+ "jellyfin.service", "plexmediaserver.service", "emby-server.service",
+ "audiobookshelf.service", "navidrome.service", "komga.service", "kavita.service",
+ "photoprism.service", "calibre-server.service",
+ }, "system-service", HEAVY, out)
+
+ -- ── heavy: JVM databases ──
+ -- Multi-gigabyte heaps. Other databases (postgres, mysql, redis) are omitted because
+ -- other services depend on them.
+ units({ "elasticsearch.service", "opensearch.service" }, "system-service", HEAVY, out)
+
+ return out
+end
+
+M.DEFAULT_TARGETS = buildDefaults()
+
+-- Every match string is interpolated into a shell command, so control characters are
+-- refused outright rather than escaped.
+local function validateShellValue(value)
+ if type(value) ~= "string" or value == "" or value:find("[\n\r%z]") then
+ return nil
+ end
+ return value
+end
+
+M.validateShellValue = validateShellValue
+
+-- validEntry returns ok plus a human reason, so parseTargets can tell the user which of
+-- their entries was dropped and why rather than only how many.
+local function validEntry(entry)
+ if type(entry) ~= "table" then
+ return false, "not an object"
+ end
+ if not VALID_KINDS[entry.kind] then
+ return false, "unknown kind " .. tostring(entry.kind)
+ end
+ if not validateShellValue(entry.match) then
+ return false, "match is empty or contains a control character"
+ end
+ if M.isDenied(entry.match) then
+ return false, "'" .. entry.match .. "' is protected and can never be suspended"
+ end
+ -- `systemctl is-active fstrim` resolves to fstrim.service, so a timer target that does
+ -- not name its unit would silently act on the wrong one.
+ if TIMER_KINDS[entry.kind] and entry.match:lower():sub(-6) ~= ".timer" then
+ return false, "timer target '" .. entry.match .. "' must name a .timer unit"
+ end
+ if entry.action ~= nil and not VALID_ACTIONS[entry.action] then
+ return false, "unknown action " .. tostring(entry.action)
+ end
+ if entry.action == "freeze" and TIMER_KINDS[entry.kind] then
+ return false, "a timer cannot be frozen, only stopped"
+ end
+ if type(entry.profiles) ~= "table" or #entry.profiles == 0 then
+ return false, "profiles must be a non-empty array"
+ end
+ for _, profile in ipairs(entry.profiles) do
+ if type(profile) ~= "string" or profile == "" then
+ return false, "profile names must be non-empty strings"
+ end
+ end
+ return true
+end
+
+local function copyTargets(list)
+ local out = {}
+ for index, entry in ipairs(list) do
+ local profiles = {}
+ for profileIndex, profile in ipairs(entry.profiles) do
+ profiles[profileIndex] = profile
+ end
+ out[index] = {
+ match = entry.match,
+ kind = entry.kind,
+ action = entry.action,
+ profiles = profiles,
+ }
+ end
+ return out
+end
+
+-- parseTargets decodes the `targets` setting, dropping individual invalid entries.
+-- An unset, unparseable or wholly invalid setting falls back to DEFAULT_TARGETS so a
+-- typo can never leave gamer mode with an empty kill list and no explanation.
+function M.parseTargets(raw)
+ if type(raw) == "string" and raw ~= "" then
+ local decoded, decodeError = noctalia.json.decode(raw)
+ if type(decoded) ~= "table" then
+ noctalia.log("gamermode: ignoring invalid targets setting: " .. tostring(decodeError or "not a JSON array"))
+ else
+ local out = {}
+ for _, entry in ipairs(decoded) do
+ local ok, reason = validEntry(entry)
+ if ok then
+ out[#out + 1] = {
+ match = entry.match,
+ kind = entry.kind,
+ action = entry.action,
+ profiles = entry.profiles,
+ }
+ else
+ noctalia.log("gamermode: dropped target: " .. tostring(reason))
+ end
+ end
+ if #out > 0 then
+ -- Allowed, but the user should know it is a one-way trip.
+ for _, entry in ipairs(out) do
+ if entry.kind == "process" and M.actionOf(entry) == "stop" then
+ noctalia.log(
+ "gamermode: '" .. entry.match .. "' is a process with action=stop, which is "
+ .. "unrecoverable -- a bare process has no argv to relaunch from. "
+ .. "Use action=freeze, or target the unit that supervises it."
+ )
+ end
+ end
+ return copyTargets(out)
+ end
+ noctalia.log("gamermode: targets setting had no usable entries, using defaults")
+ end
+ end
+ return copyTargets(M.DEFAULT_TARGETS)
+end
+
+function M.targetsForProfile(targets, profile)
+ local out = {}
+ for _, target in ipairs(targets) do
+ for _, tagged in ipairs(target.profiles) do
+ if tagged == profile then
+ out[#out + 1] = target
+ break
+ end
+ end
+ end
+ return out
+end
+
+-- ── shell commands per target kind ──
+
+-- shellQuote returns nil for anything that cannot be represented safely, and every
+-- command builder propagates that nil rather than emitting a half-quoted command.
+local function shellQuote(value)
+ if not validateShellValue(value) then
+ return nil
+ end
+ return "'" .. value:gsub("'", "'\\''") .. "'"
+end
+
+M.shellQuote = shellQuote
+
+-- safeMatch is the single choke point for shell interpolation: it refuses protected
+-- targets and anything that cannot be quoted. Builders return nil rather than emitting a
+-- command, so a denied entry surviving in an old session file still cannot act.
+local function safeMatch(target)
+ if M.isDenied(target.match) then
+ noctalia.log("gamermode: refusing to act on protected target " .. tostring(target.match))
+ return nil
+ end
+ return shellQuote(target.match)
+end
+
+-- Probes read state only, so none of them needs elevation -- `systemctl is-active`
+-- works unprivileged even for system units.
+function M.probeCmd(target)
+ local match = safeMatch(target)
+ if not match then
+ return nil
+ end
+ if target.kind == "process" then
+ return "pgrep -x " .. match
+ elseif target.kind == "user-service" or target.kind == "user-timer" then
+ return "systemctl --user is-active " .. match
+ elseif target.kind == "system-service" or target.kind == "system-timer" then
+ return "systemctl is-active " .. match
+ elseif target.kind == "container" then
+ return "docker inspect -f '{{.State.Running}}' " .. match
+ end
+ return nil
+end
+
+-- Changing a system unit needs authorisation, and systemctl already knows how to ask for
+-- it: the call goes over D-Bus, systemd asks polkit, and polkit asks the desktop's
+-- authentication agent to prompt. `org.freedesktop.systemd1.manage-units` resolves to
+-- auth_admin_keep for an active local session, so an administrator is prompted once and
+-- the answer is cached for the rest of the batch.
+--
+-- This is why none of the builders below shell out to sudo. `sudo -n` cannot prompt at
+-- all, so on any machine without a NOPASSWD rule -- which is most of them -- every system
+-- target failed and gamer mode quietly did a fraction of its job.
+--
+-- Callers must route these through the privileged lane: see needsPrivilege.
+function M.needsPrivilege(kind)
+ return kind == "system-service" or kind == "system-timer"
+end
+
+function M.stopCmd(target)
+ local match = safeMatch(target)
+ if not match then
+ return nil
+ end
+ if target.kind == "process" then
+ return "pkill -x " .. match
+ elseif target.kind == "user-service" or target.kind == "user-timer" then
+ return "systemctl --user stop " .. match
+ elseif target.kind == "system-service" or target.kind == "system-timer" then
+ return "systemctl stop " .. match
+ elseif target.kind == "container" then
+ return "docker stop " .. match
+ end
+ return nil
+end
+
+-- Processes have no generic start: a bare process name carries no argv, environment or
+-- working directory, so gamer mode cannot honestly relaunch one. Users who need a
+-- process brought back should target the unit that supervises it instead.
+function M.startCmd(target)
+ local match = safeMatch(target)
+ if not match then
+ return nil
+ end
+ if target.kind == "user-service" or target.kind == "user-timer" then
+ return "systemctl --user start " .. match
+ elseif target.kind == "system-service" or target.kind == "system-timer" then
+ return "systemctl start " .. match
+ elseif target.kind == "container" then
+ return "docker start " .. match
+ end
+ return nil
+end
+
+-- actionOf normalises the optional `action` field. Absent means "stop", which keeps every
+-- pre-existing config and session file meaning exactly what it did before.
+function M.actionOf(target)
+ return target.action == "freeze" and "freeze" or "stop"
+end
+
+-- Authorising system units once, rather than once per unit.
+--
+-- Two approaches were measured on a live machine and both cost one password prompt per
+-- unit -- seven units, seven dialogs:
+--
+-- * one `systemctl` process per unit. polkit's auth_admin_keep retains an authorisation
+-- against the subject that gave it, and the subject systemd reports is the calling
+-- process, so seven processes are seven subjects with nothing to reuse.
+-- * one `systemctl` process naming all seven units. systemctl issues its StopUnit calls
+-- in parallel, so all seven polkit checks are outstanding before any of them has an
+-- answer, and again none can reuse a retained authorisation.
+--
+-- pkexec authorises the exec itself, once, and systemd performs no polkit check at all for
+-- a caller running as root. That makes one dialog a property of the design rather than a
+-- hoped-for cache hit. org.freedesktop.policykit.exec is auth_admin with no keep, so each
+-- pkexec prompts -- which is why a verb's units all go in one invocation. The built-in
+-- targets only ever stop system units, never freeze them, so an enable and a disable are
+-- one prompt each; a config that mixes both verbs pays one per verb.
+--
+-- Batching costs per-unit exit codes. It is affordable because nothing depends on them:
+-- the snapshot records what a target was doing before, not whether its stop returned zero,
+-- and restore probes live state rather than trusting a recorded outcome. systemctl still
+-- names each unit it could not act on, and that stderr is logged whole.
+local BATCH_ARGS = {
+ stop = "stop",
+ start = "start",
+ freeze = "kill --kill-whom=all -s SIGSTOP",
+ thaw = "kill --kill-whom=all -s SIGCONT",
+}
+
+-- pkexec resolves a bare program name against a sanitised PATH, so the absolute path is
+-- the dependable form. init() replaces these from the live system.
+M.systemctlPath = "/usr/bin/systemctl"
+M.canElevate = true
+
+local function batchPrefix(verb)
+ local args = BATCH_ARGS[verb]
+ if not args then
+ return nil
+ end
+ if not M.canElevate then
+ -- No pkexec: fall back to asking systemd directly. It still works, at the cost of
+ -- the prompt-per-unit behaviour described above.
+ return "systemctl " .. args
+ end
+ return "pkexec " .. M.systemctlPath .. " " .. args
+end
+
+-- Timers have no process to signal, so they can only be stopped and started.
+local function batchable(verb, kind)
+ if not M.needsPrivilege(kind) then
+ return false
+ end
+ if verb == "freeze" or verb == "thaw" then
+ return kind == "system-service"
+ end
+ return true
+end
+
+-- Returns the batched command and the targets it covers, so the caller can report against
+-- exactly what went in. Targets refused by safeMatch are left out of both.
+function M.batchCmd(verb, targets)
+ local prefix = batchPrefix(verb)
+ if not prefix then
+ return nil, {}
+ end
+ local quoted, covered = {}, {}
+ for _, target in ipairs(targets) do
+ if batchable(verb, target.kind) then
+ local match = safeMatch(target)
+ if match then
+ quoted[#quoted + 1] = match
+ covered[#covered + 1] = target
+ end
+ end
+ end
+ if #quoted == 0 then
+ return nil, {}
+ end
+ return prefix .. " " .. table.concat(quoted, " "), covered
+end
+
+-- freeze suspends a target in place: SIGSTOP for processes and units, docker pause for
+-- containers. Unlike stop it is perfectly reversible and loses no state, which makes it
+-- the right action for anything the user might return to -- a browser, an editor, an
+-- animated wallpaper daemon.
+--
+-- renice was measured and rejected as the reversible option: with RLIMIT_NICE=0 an
+-- unprivileged process can lower priority but never raise it back, so it would
+-- permanently degrade anything it touched.
+function M.freezeCmd(target)
+ local match = safeMatch(target)
+ if not match then
+ return nil
+ end
+ if target.kind == "process" then
+ return "pkill -STOP -x " .. match
+ elseif target.kind == "user-service" then
+ -- --kill-whom=all is explicit because `systemctl kill --help` does not state its
+ -- default, and freezing a unit must reach every process in its cgroup.
+ return "systemctl --user kill --kill-whom=all -s SIGSTOP " .. match
+ elseif target.kind == "system-service" then
+ return "systemctl kill --kill-whom=all -s SIGSTOP " .. match
+ elseif target.kind == "container" then
+ -- docker pause is the cgroup freezer: an exact match for freeze semantics.
+ return "docker pause " .. match
+ end
+ -- Timer kinds fall through: there is no process to signal.
+ return nil
+end
+
+function M.thawCmd(target)
+ local match = safeMatch(target)
+ if not match then
+ return nil
+ end
+ if target.kind == "process" then
+ return "pkill -CONT -x " .. match
+ elseif target.kind == "user-service" then
+ return "systemctl --user kill --kill-whom=all -s SIGCONT " .. match
+ elseif target.kind == "system-service" then
+ return "systemctl kill --kill-whom=all -s SIGCONT " .. match
+ elseif target.kind == "container" then
+ return "docker unpause " .. match
+ end
+ return nil
+end
+
+-- wasState maps probe stdout to the state recorded in the snapshot. Only "running" and
+-- "active" count as up; everything else -- including transitional states and any output
+-- that could not be read -- is "down", so nothing gets restarted on a guess.
+function M.wasState(kind, output)
+ local text = (tostring(output or "")):gsub("%s+", "")
+ if kind == "process" then
+ return text ~= "" and "running" or "down"
+ elseif kind == "container" then
+ return text == "true" and "running" or "down"
+ end
+ return text == "active" and "active" or "down"
+end
+
+-- ── session snapshot ──
+
+local SNAPSHOT_VERSION = 1
+local UP_STATES = { running = true, active = true }
+
+local function snapshotPath()
+ local directory = noctalia.pluginDataDir()
+ if not directory then
+ return nil
+ end
+ return directory .. "/session.json"
+end
+
+-- The kernel boot id changes on every boot, which makes it an exact staleness marker for a
+-- session file. More reliable than inferring staleness from live state: a frozen process
+-- still appears in pgrep, so "is everything running again?" false-positives on every freeze
+-- target.
+function M.currentBootId()
+ local contents = noctalia.readFile("/proc/sys/kernel/random/boot_id")
+ if not contents then
+ return nil
+ end
+ local id = contents:gsub("%s+", "")
+ return id ~= "" and id or nil
+end
+
+function M.buildSnapshot(profile, powerProfileBefore, probedTargets)
+ return {
+ version = SNAPSHOT_VERSION,
+ profile = profile,
+ power_profile_before = powerProfileBefore,
+ boot_id = M.currentBootId(),
+ targets = probedTargets,
+ }
+end
+
+-- restorePlan answers "which stopped targets may be started again?". A target qualifies
+-- only if it was up when gamer mode began and is still down now: that keeps a manual
+-- restart from being stomped and keeps something that was already off from being started.
+-- `stateOf` returns the live state string, or nil when it could not be determined -- in
+-- which case the target is left alone.
+--
+-- Freeze targets are deliberately excluded; they go through thawPlan, which needs no
+-- probe at all.
+function M.restorePlan(snap, stateOf)
+ local plan = {}
+ for _, target in ipairs((snap and snap.targets) or {}) do
+ if UP_STATES[target.was] and M.actionOf(target) == "stop" and stateOf(target.match) == "down" then
+ plan[#plan + 1] = target
+ end
+ end
+ return plan
+end
+
+-- thawPlan returns every frozen target, with no live check. A frozen process still shows
+-- up in pgrep, so there is no probe that could distinguish "still frozen" from "running",
+-- and SIGCONT to a process that is not stopped is a verified no-op. Thawing
+-- unconditionally is therefore both simpler and strictly safer: it cannot misread a
+-- state, cannot stomp a manual restart, and cannot leave something frozen because a probe
+-- failed to start.
+function M.thawPlan(snap)
+ local plan = {}
+ for _, target in ipairs((snap and snap.targets) or {}) do
+ if UP_STATES[target.was] and M.actionOf(target) == "freeze" then
+ plan[#plan + 1] = target
+ end
+ end
+ return plan
+end
+
+local function validSnapshotTarget(entry)
+ return type(entry) == "table"
+ and validateShellValue(entry.match) ~= nil
+ and VALID_KINDS[entry.kind] ~= nil
+ and type(entry.was) == "string"
+ and (entry.action == nil or VALID_ACTIONS[entry.action] == true)
+end
+
+function M.writeSnapshot(snap)
+ local path = snapshotPath()
+ if not path then
+ noctalia.log("gamermode: no plugin data dir, cannot persist the session")
+ return false
+ end
+
+ local encoded, encodeError = noctalia.json.encode(snap)
+ if not encoded then
+ noctalia.log("gamermode: could not encode the session: " .. tostring(encodeError))
+ return false
+ end
+
+ noctalia.mkdirAll(noctalia.pluginDataDir())
+ -- Write-then-rename: a shell crash mid-write must not leave a half-written session
+ -- that would be read back as "no session" while targets are still suspended.
+ local temporary = path .. ".tmp"
+ if not noctalia.writeFile(temporary, encoded) then
+ noctalia.log("gamermode: could not write the session file")
+ return false
+ end
+ if not noctalia.renameFile(temporary, path) then
+ noctalia.removeFile(temporary)
+ noctalia.log("gamermode: could not replace the session file")
+ return false
+ end
+ return true
+end
+
+-- readSnapshot returns nil for anything it cannot trust. Callers treat nil as "gamer
+-- mode is off", which is the safe reading: it never invents targets to restart.
+function M.readSnapshot()
+ local path = snapshotPath()
+ if not path then
+ return nil
+ end
+ local contents = noctalia.readFile(path)
+ if not contents then
+ return nil
+ end
+
+ local decoded, decodeError = noctalia.json.decode(contents)
+ if type(decoded) ~= "table" then
+ noctalia.log("gamermode: ignoring unreadable session file: " .. tostring(decodeError or "not an object"))
+ return nil
+ end
+ if decoded.version ~= SNAPSHOT_VERSION then
+ noctalia.log("gamermode: ignoring session file with version " .. tostring(decoded.version))
+ return nil
+ end
+ if type(decoded.targets) ~= "table" then
+ noctalia.log("gamermode: ignoring session file without a target list")
+ return nil
+ end
+
+ -- Keep the entries that are still usable rather than discarding the whole session:
+ -- dropping it would report gamer mode as off while targets stay suspended.
+ local targets = {}
+ local dropped = 0
+ for _, entry in ipairs(decoded.targets) do
+ if validSnapshotTarget(entry) then
+ targets[#targets + 1] = {
+ match = entry.match,
+ kind = entry.kind,
+ action = entry.action,
+ was = entry.was,
+ }
+ else
+ dropped = dropped + 1
+ end
+ end
+ if dropped > 0 then
+ noctalia.log("gamermode: dropped " .. dropped .. " unusable entries from the session file")
+ end
+
+ return {
+ version = decoded.version,
+ profile = type(decoded.profile) == "string" and decoded.profile or "light",
+ power_profile_before = type(decoded.power_profile_before) == "string" and decoded.power_profile_before or nil,
+ boot_id = type(decoded.boot_id) == "string" and decoded.boot_id or nil,
+ targets = targets,
+ }
+end
+
+function M.deleteSnapshot()
+ local path = snapshotPath()
+ if path then
+ noctalia.removeFile(path)
+ end
+end
+
+-- ── runtime ──
+
+local COMMAND_TIMEOUT_MS = 10000
+-- A privileged command can sit at a polkit password dialog. Ten seconds is not enough time
+-- to read a prompt and type a password, and a timeout there kills the command mid-dialog.
+local PRIVILEGED_TIMEOUT_MS = 120000
+local DEFAULT_POLL_SECONDS = 3
+
+local busy = false
+local lastHandledNonce = 0
+local powerState = { available = false, profiles = {} }
+
+local function trim(value)
+ return (tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", ""))
+end
+
+-- The shell caps how many child processes may be in flight at once (8 in the build this
+-- was written against) and runAsync refuses rather than queueing once that cap is hit.
+-- Fanning the whole target list out in one pass therefore loses every command past the
+-- eighth, and a probe that never ran reads as "down" -- so enable would record an idle
+-- machine and suspend nothing. Commands go through a queue instead, a few at a time.
+--
+-- The cap is shared with the rest of the shell, so a refusal does not always mean our own
+-- slots are full. Staying well under it leaves room for other plugins and makes a refusal
+-- rare enough to treat as transient.
+local MAX_IN_FLIGHT = 4
+
+-- Privileged commands run one at a time. Polkit caches an administrator's answer, but only
+-- once it has one: firing four at a shell with no cached authorisation races four password
+-- dialogs onto the screen. Serialised, the first prompts and the rest ride the cache.
+local lanes = {
+ default = { queued = {}, inFlight = 0, limit = MAX_IN_FLIGHT, timeoutMs = COMMAND_TIMEOUT_MS },
+ privileged = { queued = {}, inFlight = 0, limit = 1, timeoutMs = PRIVILEGED_TIMEOUT_MS },
+}
+local pumping = false
+-- Across both lanes, because the shell's cap is global: whether a refusal is worth waiting
+-- out depends on anything of ours still running, not just this lane.
+local totalInFlight = 0
+local pump
+
+-- Returns whether it disposed of anything, which is what tells the caller a second round
+-- is worth attempting rather than spinning.
+local function pumpLane(lane)
+ local progressed = false
+ while lane.inFlight < lane.limit and #lane.queued > 0 do
+ local job = table.remove(lane.queued, 1)
+ lane.inFlight = lane.inFlight + 1
+ totalInFlight = totalInFlight + 1
+ local started = noctalia.runAsync(job.command, function(result)
+ lane.inFlight = lane.inFlight - 1
+ totalInFlight = totalInFlight - 1
+ job.callback(result)
+ pump()
+ end, lane.timeoutMs)
+ if not started then
+ lane.inFlight = lane.inFlight - 1
+ totalInFlight = totalInFlight - 1
+ if totalInFlight > 0 then
+ -- Something is still running and will pump again when it finishes, so the
+ -- job keeps its place rather than being reported as a failure.
+ table.insert(lane.queued, 1, job)
+ return progressed
+ end
+ -- Nothing is running to trigger a later pump. update() retries the queues on
+ -- the poll tick, but this job has waited long enough to answer now.
+ noctalia.log("gamermode: could not start command: " .. job.command)
+ job.callback(nil)
+ end
+ progressed = true
+ end
+ return progressed
+end
+
+-- A command that completes synchronously calls back into pump from inside pumpLane.
+-- Letting that recurse would nest one stack frame per queued command and overflow on a
+-- full target list, so the outer call keeps ownership of both queues.
+pump = function()
+ if pumping then
+ return
+ end
+ pumping = true
+ -- Rounds, not one pass: a callback firing inside pumpLane can queue work for the lane
+ -- that was already visited this pass -- probes finishing is exactly what queues the
+ -- suspends -- and its own pump() call was swallowed by the guard above. Without the
+ -- loop that work sits in the queue with nothing left to start it.
+ local progressed = true
+ while progressed do
+ -- Privileged first: it is the lane that may block on a password dialog, so it
+ -- should be waiting on the human rather than on our own bookkeeping.
+ progressed = pumpLane(lanes.privileged)
+ progressed = pumpLane(lanes.default) or progressed
+ end
+ pumping = false
+end
+
+-- run always invokes `callback` exactly once. A command that could not be built or
+-- could not be started yields nil, which every caller reads as "state unknown" -- so a
+-- busy shell degrades into doing nothing rather than into a wrong decision.
+local function run(command, callback, privileged)
+ if not command then
+ callback(nil)
+ return
+ end
+ local lane = privileged and lanes.privileged or lanes.default
+ lane.queued[#lane.queued + 1] = { command = command, callback = callback }
+ pump()
+end
+
+local function succeeded(result)
+ return result ~= nil and result.exitCode == 0
+end
+
+local function describeFailure(result)
+ if result == nil then
+ return "command did not start"
+ end
+ local stderr = trim(result.stderr)
+ if stderr ~= "" then
+ return stderr
+ end
+ if result.timedOut then
+ return "timed out"
+ end
+ return "exit code " .. tostring(result.exitCode)
+end
+
+local VALID_PROFILES = { light = true, heavy = true }
+
+local function configuredProfile()
+ local profile = noctalia.getConfig("profile")
+ return profile == "heavy" and "heavy" or "light"
+end
+
+-- resolveProfile lets a command name the profile to apply for one session. The panel needs
+-- this because a plugin reads its own settings and cannot write them, so choosing a profile
+-- in the panel has to travel with the enable rather than change the setting.
+local function resolveProfile(override)
+ if override == nil then
+ return configuredProfile()
+ end
+ if VALID_PROFILES[override] then
+ return override
+ end
+ noctalia.log("gamermode: ignoring unknown profile " .. tostring(override))
+ return configuredProfile()
+end
+
+local function autoPerformance()
+ return noctalia.getConfig("auto_performance") ~= false
+end
+
+-- ── power profiles ──
+
+-- parsePowerProfiles reads `powerprofilesctl list`, whose entries are lines like
+-- "* balanced:" (the leading star marks the active one) followed by indented details.
+function M.parsePowerProfiles(text)
+ local profiles = {}
+ local active = nil
+ for line in tostring(text or ""):gmatch("[^\n]+") do
+ local star, name = line:match("^%s*(%*?)%s*([%w][%w%-_]*):%s*$")
+ if name then
+ profiles[#profiles + 1] = name
+ if star == "*" then
+ active = name
+ end
+ end
+ end
+ return profiles, active
+end
+
+local function publishPower()
+ noctalia.state.set("power", powerState)
+end
+
+local function powerSupports(profile)
+ for _, candidate in ipairs(powerState.profiles) do
+ if candidate == profile then
+ return true
+ end
+ end
+ return false
+end
+
+-- refreshPower republishes the power group and hands the active profile to `done`.
+-- Without powerprofilesctl the group is marked unavailable and `done` receives nil, so
+-- gamer mode still runs -- it just does not switch profiles.
+function M.refreshPower(done)
+ done = done or function() end
+ if not noctalia.commandExists("powerprofilesctl") then
+ powerState = { available = false, profiles = {} }
+ publishPower()
+ done(nil)
+ return
+ end
+ run("powerprofilesctl list", function(result)
+ local profiles, active = M.parsePowerProfiles(succeeded(result) and result.stdout or "")
+ powerState = { available = true, profiles = profiles, active = active }
+ publishPower()
+ done(active)
+ end)
+end
+
+function M.setPowerProfile(profile)
+ if not powerState.available then
+ noctalia.log("gamermode: powerprofilesctl is unavailable")
+ return
+ end
+ if type(profile) ~= "string" or not powerSupports(profile) then
+ noctalia.log("gamermode: refusing unsupported power profile " .. tostring(profile))
+ return
+ end
+ run("powerprofilesctl set " .. shellQuote(profile), function(result)
+ if not succeeded(result) then
+ noctalia.log("gamermode: could not set the power profile: " .. describeFailure(result))
+ end
+ M.refreshPower()
+ end)
+end
+
+-- ── published state ──
+
+function M.publishMetrics()
+ local metrics = M.normalize(noctalia.systemStats())
+ if metrics then
+ metrics.available = true
+ else
+ -- No system monitor: say so rather than publishing zeroes that read as real
+ -- idle readings.
+ metrics = { available = false, gpuAvailable = false }
+ end
+ noctalia.state.set("metrics", metrics)
+end
+
+M.pollMetrics = M.publishMetrics
+
+-- publishGameMode derives the whole UI-visible state from the session file, so a shell
+-- restart mid-session still shows gamer mode as on with the right suspend list.
+function M.publishGameMode()
+ local snap = M.readSnapshot()
+ local suspended = {}
+ if snap then
+ for _, target in ipairs(snap.targets) do
+ if UP_STATES[target.was] then
+ suspended[#suspended + 1] = {
+ match = target.match,
+ kind = target.kind,
+ action = M.actionOf(target),
+ }
+ end
+ end
+ end
+ noctalia.state.set("game_mode", {
+ enabled = snap ~= nil,
+ busy = busy,
+ profile = snap and snap.profile or configuredProfile(),
+ suspended = suspended,
+ power_profile_before = snap and snap.power_profile_before or nil,
+ })
+end
+
+-- ── enable ──
+
+-- probeAll fans out one probe per target and reports once every answer is in. Results
+-- keep the configured target order so snapshots are stable across runs.
+local function probeAll(targets, done)
+ local slots = {}
+ local pending = #targets
+ if pending == 0 then
+ done({})
+ return
+ end
+
+ local function settle()
+ if pending > 0 then
+ return
+ end
+ local probed = {}
+ for index = 1, #targets do
+ if slots[index] then
+ probed[#probed + 1] = slots[index]
+ end
+ end
+ done(probed)
+ end
+
+ for index, target in ipairs(targets) do
+ local command = M.probeCmd(target)
+ if not command then
+ noctalia.log("gamermode: skipping unusable target " .. tostring(target.match))
+ pending = pending - 1
+ settle()
+ else
+ run(command, function(result)
+ slots[index] = {
+ match = target.match,
+ kind = target.kind,
+ action = M.actionOf(target),
+ was = M.wasState(target.kind, result and result.stdout),
+ }
+ pending = pending - 1
+ settle()
+ end)
+ end
+ end
+end
+
+-- suspendAll suspends every target that was up, using each target's own action. A failure
+-- is logged and the flow continues: a missing NOPASSWD rule for one system unit must not
+-- abandon the rest.
+local function suspendAll(probed, done)
+ local jobs = {}
+
+ -- System units go out in one invocation per verb, so the user answers one prompt
+ -- rather than one per unit. Everything else stays per-target: pkill and docker need
+ -- no authorisation, so batching them would only blur which one failed.
+ local grouped = { freeze = {}, stop = {} }
+ local loose = {}
+ for _, entry in ipairs(probed) do
+ if UP_STATES[entry.was] then
+ local action = M.actionOf(entry)
+ if batchable(action, entry.kind) then
+ table.insert(grouped[action], entry)
+ else
+ table.insert(loose, { entry = entry, action = action })
+ end
+ end
+ end
+
+ for verb, entries in pairs(grouped) do
+ local command, covered = M.batchCmd(verb, entries)
+ if command then
+ jobs[#jobs + 1] = { command = command, action = verb, entries = covered, privileged = true }
+ end
+ end
+ for _, item in ipairs(loose) do
+ local command = item.action == "freeze" and M.freezeCmd(item.entry) or M.stopCmd(item.entry)
+ if command then
+ jobs[#jobs + 1] = { command = command, action = item.action, entries = { item.entry } }
+ else
+ noctalia.log("gamermode: no " .. item.action .. " command for " .. tostring(item.entry.match))
+ end
+ end
+
+ local pending = #jobs
+ if pending == 0 then
+ done(0)
+ return
+ end
+ local suspended = 0
+ for _, job in ipairs(jobs) do
+ run(job.command, function(result)
+ if succeeded(result) then
+ suspended = suspended + #job.entries
+ else
+ local names = {}
+ for _, entry in ipairs(job.entries) do
+ names[#names + 1] = entry.match
+ end
+ noctalia.log(
+ "gamermode: could not " .. job.action .. " " .. table.concat(names, ", ")
+ .. ": " .. describeFailure(result)
+ )
+ end
+ pending = pending - 1
+ if pending == 0 then
+ done(suspended)
+ end
+ end, job.privileged)
+ end
+end
+
+function M.enable(profileOverride)
+ if busy then
+ return
+ end
+ -- Idempotent: an existing session means gamer mode is already on, and probing again
+ -- would overwrite the recorded "was" states with the suspended ones.
+ if M.readSnapshot() then
+ M.publishGameMode()
+ return
+ end
+
+ busy = true
+ M.publishGameMode()
+
+ local profile = resolveProfile(profileOverride)
+ local targets = M.targetsForProfile(M.parseTargets(noctalia.getConfig("targets")), profile)
+
+ local function withPowerBefore(powerBefore)
+ probeAll(targets, function(probed)
+ -- The session file is the only record of what was running before gamer mode
+ -- touched it, so it is written before anything is suspended. A process frozen
+ -- with SIGSTOP or a unit stopped with nothing on disk to name it cannot be
+ -- restored: disable reads the session, finds none, and returns. A failed
+ -- write therefore aborts the enable with the machine still untouched.
+ --
+ -- Ordering it this way can leave the snapshot naming a target whose suspend
+ -- command then failed, which is the harmless direction. Thawing is
+ -- unconditional and SIGCONT to a running process is a no-op, and a stop
+ -- target is only restarted after a live probe says it is still down.
+ if not M.writeSnapshot(M.buildSnapshot(profile, powerBefore, probed)) then
+ busy = false
+ M.publishGameMode()
+ noctalia.notifyError(
+ noctalia.tr("notify.session_failed_title"),
+ noctalia.tr("notify.session_failed")
+ )
+ return
+ end
+ suspendAll(probed, function(stopped)
+ busy = false
+ M.publishGameMode()
+ if autoPerformance() and powerState.available and powerSupports("performance") then
+ M.setPowerProfile("performance")
+ end
+ if stopped > 0 then
+ noctalia.notify(noctalia.tr("notify.enabled_title"), noctalia.trp("notify.suspended_count", stopped))
+ else
+ noctalia.notify(noctalia.tr("notify.enabled_title"), noctalia.tr("notify.nothing_suspended"))
+ end
+ end)
+ end)
+ end
+
+ -- Capture the profile to hand back before switching away from it.
+ if autoPerformance() then
+ M.refreshPower(withPowerBefore)
+ else
+ withPowerBefore(nil)
+ end
+end
+
+-- ── disable ──
+
+function M.disable()
+ if busy then
+ return
+ end
+ local snap = M.readSnapshot()
+ if not snap then
+ M.publishGameMode()
+ return
+ end
+
+ busy = true
+ M.publishGameMode()
+
+ local thawTargets = M.thawPlan(snap)
+ -- Filter to stop targets recorded as up; the "still down" half of the check is a live
+ -- probe per target below, so a manual restart in the meantime wins.
+ local candidates = M.restorePlan(snap, function()
+ return "down"
+ end)
+
+ local restored = 0
+ -- Held at one until every job has been queued, so a batch that completes inline cannot
+ -- finish the session while later phases are still being set up.
+ local pending = 1
+
+ local function finish()
+ M.deleteSnapshot()
+ busy = false
+ M.publishGameMode()
+ if autoPerformance() and snap.power_profile_before then
+ M.setPowerProfile(snap.power_profile_before)
+ end
+ if restored > 0 then
+ noctalia.notify(noctalia.tr("notify.disabled_title"), noctalia.trp("notify.restored_count", restored))
+ else
+ noctalia.notify(noctalia.tr("notify.disabled_title"), noctalia.tr("notify.nothing_restored"))
+ end
+ end
+
+ local function step()
+ pending = pending - 1
+ if pending == 0 then
+ finish()
+ end
+ end
+
+ -- launch counts one job whatever it covers, so a batch and a single command are the
+ -- same thing to the caller.
+ local function launch(command, privileged, covered, verb)
+ pending = pending + 1
+ run(command, function(result)
+ if succeeded(result) then
+ restored = restored + #covered
+ else
+ local names = {}
+ for _, target in ipairs(covered) do
+ names[#names + 1] = target.match
+ end
+ noctalia.log(
+ "gamermode: could not " .. verb .. " " .. table.concat(names, ", ")
+ .. ": " .. describeFailure(result)
+ )
+ end
+ step()
+ end, privileged)
+ end
+
+ -- Freeze targets: thaw unconditionally, no probe. A frozen process still appears in
+ -- pgrep so no probe could tell "still frozen" from "running", and SIGCONT to a running
+ -- process is a verified no-op.
+ local thawCommand, thawCovered = M.batchCmd("thaw", thawTargets)
+ if thawCommand then
+ launch(thawCommand, true, thawCovered, "thaw")
+ end
+ for _, target in ipairs(thawTargets) do
+ if not batchable("thaw", target.kind) then
+ local command = M.thawCmd(target)
+ if command then
+ launch(command, false, { target }, "thaw")
+ else
+ noctalia.log("gamermode: no thaw command for " .. tostring(target.match))
+ end
+ end
+ end
+
+ -- Stop targets: probe every candidate, then start only those still down. The probes
+ -- are unprivileged and run first so the single privileged start covers exactly the
+ -- units that need it, rather than prompting for units already back up.
+ local stillDown = {}
+
+ local function startStillDown()
+ local startCommand, startCovered = M.batchCmd("start", stillDown)
+ if startCommand then
+ launch(startCommand, true, startCovered, "restart")
+ end
+ for _, target in ipairs(stillDown) do
+ if not batchable("start", target.kind) then
+ local command = M.startCmd(target)
+ if command then
+ launch(command, false, { target }, "restart")
+ end
+ end
+ end
+ step()
+ end
+
+ local probesLeft = #candidates
+ if probesLeft == 0 then
+ startStillDown()
+ else
+ local function probeDone()
+ probesLeft = probesLeft - 1
+ if probesLeft == 0 then
+ startStillDown()
+ end
+ end
+ for _, target in ipairs(candidates) do
+ if not M.startCmd(target) then
+ -- Processes cannot be relaunched generically; say so once, per target.
+ noctalia.log(
+ "gamermode: cannot restart " .. tostring(target.match) .. " (" .. tostring(target.kind) .. ")"
+ )
+ probeDone()
+ else
+ run(M.probeCmd(target), function(probeResult)
+ if M.wasState(target.kind, probeResult and probeResult.stdout) == "down" then
+ stillDown[#stillDown + 1] = target
+ end
+ -- Anything already back up, by hand or by its own supervisor, is left
+ -- out of the start batch.
+ probeDone()
+ end)
+ end
+ end
+ end
+end
+
+-- toggle passes the override through to enable. Turning gamer mode off needs no profile:
+-- the session records which one was applied.
+function M.toggle(profileOverride)
+ if M.readSnapshot() then
+ M.disable()
+ else
+ M.enable(profileOverride)
+ end
+end
+
+-- ── maintenance ──
+--
+-- One-shot cleanups, run on demand from the panel rather than as part of gamer mode.
+-- Nothing here is undone by disabling gamer mode: a deleted cache is gone and a dropped
+-- page cache refills on its own, so none of it belongs in the session snapshot.
+
+-- Shader caches, in the locations the drivers and Steam actually use. Deleting one costs
+-- a slower first launch while it recompiles and nothing else, which is the trade people
+-- want after a driver update leaves stale shaders behind.
+-- Covering both vendors, because which of these exists is the clearest sign of which
+-- driver stack a machine runs.
+local SHADER_CACHES = {
+ -- Mesa: AMD radeonsi and RADV, Intel, and the software rasterisers. The _db suffix is
+ -- the newer single-file format; a machine mid-upgrade has both.
+ "~/.cache/mesa_shader_cache",
+ "~/.cache/mesa_shader_cache_db",
+ "~/.cache/radv_builtin_shaders",
+ -- AMDVLK and the AMD Pro stack keep their own, separate from Mesa's.
+ "~/.cache/AMD",
+ -- NVIDIA moved GLCache from ~/.nv to ~/.cache/nvidia. Drivers old enough to use the
+ -- first are still in service, so both are listed.
+ "~/.cache/nvidia/GLCache",
+ "~/.nv/GLCache",
+ -- Steam's own, for the native package and the Flatpak. A library on a second drive
+ -- keeps its shadercache beside it and is not covered: finding those means parsing
+ -- libraryfolders.vdf, which is more machinery than this is worth.
+ "~/.local/share/Steam/steamapps/shadercache",
+ "~/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/shadercache",
+}
+
+-- Every path is expanded from the fixed list above and then checked to be under the home
+-- directory. The list is not user-supplied today, and this makes sure a future setting
+-- cannot turn `rm -rf` on something outside it.
+function M.shaderCachePaths()
+ local home = noctalia.expandPath("~")
+ if not home or home == "" or home == "/" then
+ return {}
+ end
+ local prefix = home:sub(-1) == "/" and home or (home .. "/")
+ local found = {}
+ for _, entry in ipairs(SHADER_CACHES) do
+ local path = noctalia.expandPath(entry)
+ if type(path) == "string" and path:sub(1, #prefix) == prefix and noctalia.fileExists(path) then
+ found[#found + 1] = path
+ end
+ end
+ return found
+end
+
+function M.shaderSizeCmd(paths)
+ if not paths or #paths == 0 then
+ return nil
+ end
+ local quoted = {}
+ for _, path in ipairs(paths) do
+ quoted[#quoted + 1] = shellQuote(path)
+ end
+ -- -c adds a grand total as the last line; -s keeps each argument to one line.
+ return "du -sbc " .. table.concat(quoted, " ") .. " | tail -1 | cut -f1"
+end
+
+function M.shaderClearCmd(paths)
+ if not paths or #paths == 0 then
+ return nil
+ end
+ local quoted = {}
+ for _, path in ipairs(paths) do
+ quoted[#quoted + 1] = shellQuote(path)
+ end
+ -- `--` stops a path that begins with a dash being read as an option.
+ return "rm -rf -- " .. table.concat(quoted, " ")
+end
+
+-- sysctl writes /proc/sys/vm/drop_caches without needing a root shell, so the elevated
+-- half stays a single fixed argv with nothing interpolated into it.
+function M.dropCachesCmd()
+ return "pkexec /usr/bin/sysctl -w vm.drop_caches=3"
+end
+
+-- swapoff has to complete before swapon starts, which needs the two joined. The string is
+-- a fixed literal: nothing the user controls reaches it.
+function M.reclaimSwapCmd()
+ return "pkexec /bin/sh -c 'swapoff -a && swapon -a'"
+end
+
+-- Reclaiming swap reads every swapped page back into RAM. If it does not fit, swapoff
+-- fails partway or the machine starts killing things, so the check runs first.
+function M.canReclaimSwap(raw)
+ local swap = type(raw) == "table" and type(raw.swap) == "table" and raw.swap or nil
+ local ram = type(raw) == "table" and type(raw.ram) == "table" and raw.ram or nil
+ local swapUsed = swap and tonumber(swap.usedMb)
+ if not swapUsed then
+ return false, "swap_unknown"
+ end
+ if swapUsed <= 0 then
+ return false, "swap_empty"
+ end
+ local total = ram and tonumber(ram.totalMb)
+ local used = ram and tonumber(ram.usedMb)
+ if not total or not used then
+ return false, "swap_unknown"
+ end
+ -- A tenth of RAM in headroom, so this does not succeed straight into an out-of-memory
+ -- kill of the game it was meant to help.
+ if swapUsed > (total - used) - (total * 0.1) then
+ return false, "swap_no_room"
+ end
+ return true
+end
+
+local cleanupJob = nil
+local shaderSize = nil
+
+local function publishCleanup(message, ok)
+ noctalia.state.set("cleanup", {
+ running = cleanupJob,
+ message = message,
+ ok = ok,
+ shaderSize = shaderSize,
+ })
+end
+
+local function finishCleanup(messageKey, ok, subst)
+ cleanupJob = nil
+ publishCleanup(subst and noctalia.tr(messageKey, subst) or noctalia.tr(messageKey), ok)
+end
+
+-- Measuring is its own job because the panel arms the delete before performing it, and a
+-- confirmation that names a size is worth the round trip. Nothing is removed here.
+local function measureShaderCaches()
+ local paths = M.shaderCachePaths()
+ if #paths == 0 then
+ shaderSize = nil
+ finishCleanup("cleanup.shaders_none", true)
+ return
+ end
+ run(M.shaderSizeCmd(paths), function(result)
+ shaderSize = M.humanBytes(tonumber(trim(result and result.stdout)) or 0)
+ finishCleanup("cleanup.shaders_confirm", nil, { size = shaderSize })
+ end)
+end
+
+local function clearShaderCaches()
+ local paths = M.shaderCachePaths()
+ if #paths == 0 then
+ shaderSize = nil
+ finishCleanup("cleanup.shaders_none", true)
+ return
+ end
+ local removed = shaderSize
+ run(M.shaderClearCmd(paths), function(result)
+ if succeeded(result) then
+ shaderSize = nil
+ finishCleanup("cleanup.shaders_done", true, { size = removed or "?" })
+ else
+ noctalia.log("gamermode: could not clear shader caches: " .. describeFailure(result))
+ finishCleanup("cleanup.failed", false)
+ end
+ end)
+end
+
+local function dropPageCache()
+ -- sync first so dirty pages are written out; dropping them unwritten would lose data.
+ run("sync", function()
+ run(M.dropCachesCmd(), function(result)
+ if succeeded(result) then
+ finishCleanup("cleanup.pagecache_done", true)
+ else
+ noctalia.log("gamermode: could not drop the page cache: " .. describeFailure(result))
+ finishCleanup("cleanup.failed", false)
+ end
+ end, true)
+ end)
+end
+
+local function reclaimSwap()
+ local ok, reason = M.canReclaimSwap(noctalia.systemStats())
+ if not ok then
+ finishCleanup("cleanup." .. reason, false)
+ return
+ end
+ run(M.reclaimSwapCmd(), function(result)
+ if succeeded(result) then
+ finishCleanup("cleanup.swap_done", true)
+ else
+ noctalia.log("gamermode: could not reclaim swap: " .. describeFailure(result))
+ finishCleanup("cleanup.failed", false)
+ end
+ end, true)
+end
+
+local CLEANUP_JOBS = {
+ ["shaders-measure"] = measureShaderCaches,
+ shaders = clearShaderCaches,
+ pagecache = dropPageCache,
+ swap = reclaimSwap,
+}
+
+function M.humanBytes(bytes)
+ local value = tonumber(bytes) or 0
+ if value >= 1024 * 1024 * 1024 then
+ return string.format("%.1f GiB", value / (1024 * 1024 * 1024))
+ elseif value >= 1024 * 1024 then
+ return string.format("%.0f MiB", value / (1024 * 1024))
+ end
+ return string.format("%.0f KiB", value / 1024)
+end
+
+function M.runCleanup(job)
+ if cleanupJob then
+ return
+ end
+ local runner = CLEANUP_JOBS[job]
+ if not runner then
+ noctalia.log("gamermode: ignoring an unknown cleanup job " .. tostring(job))
+ return
+ end
+ cleanupJob = job
+ publishCleanup(noctalia.tr("cleanup.running"), nil)
+ runner()
+end
+
+-- ── diagnostics ──
+
+-- What this machine reports, written to the shell log.
+--
+-- GPU readings come from the shell, which uses NVML for NVIDIA and sysfs for everything
+-- else, and the two do not expose the same fields. Rather than guess at what an AMD or
+-- Intel box provides, this prints the raw sample so anyone can say what their hardware
+-- actually reports. It is also the first thing to run when a target refuses to act.
+function M.diagnose()
+ local raw = noctalia.systemStats()
+ noctalia.log("gamermode diagnose: stats = " .. tostring(noctalia.json.encode(raw)))
+ noctalia.log("gamermode diagnose: metrics = " .. tostring(noctalia.json.encode(M.normalize(raw))))
+
+ local tools = {}
+ for _, name in ipairs({ "pgrep", "pkill", "systemctl", "pkexec", "docker", "powerprofilesctl", "du" }) do
+ tools[#tools + 1] = name .. "=" .. tostring(noctalia.commandExists(name) == true)
+ end
+ noctalia.log("gamermode diagnose: tools " .. table.concat(tools, " "))
+ noctalia.log(
+ "gamermode diagnose: elevation pkexec=" .. tostring(M.canElevate) .. " systemctl=" .. tostring(M.systemctlPath)
+ )
+
+ local caches = M.shaderCachePaths()
+ noctalia.log("gamermode diagnose: shader caches = " .. (#caches > 0 and table.concat(caches, " ") or "none"))
+
+ local snap = M.readSnapshot()
+ noctalia.log(
+ "gamermode diagnose: session = "
+ .. (snap and (snap.profile .. ", " .. #snap.targets .. " targets") or "none")
+ )
+end
+
+-- ── commands ──
+
+-- Commands arrive as `command` state writes from the panel and widget. The nonce makes
+-- a replayed or duplicated write a no-op instead of a second toggle.
+function M.handleCommand(command)
+ if type(command) ~= "table" then
+ noctalia.log("gamermode: ignoring a malformed command")
+ return
+ end
+ local nonce = tonumber(command.nonce)
+ if nonce then
+ if nonce <= lastHandledNonce then
+ return
+ end
+ lastHandledNonce = nonce
+ end
+
+ local action = command.action
+ if action == "toggle" then
+ M.toggle(command.profile)
+ elseif action == "enable" then
+ M.enable(command.profile)
+ elseif action == "disable" then
+ M.disable()
+ elseif action == "set-power-profile" then
+ M.setPowerProfile(command.profile)
+ elseif action == "cleanup" then
+ M.runCleanup(command.job)
+ elseif action == "diagnose" then
+ M.diagnose()
+ else
+ noctalia.log("gamermode: unknown command action " .. tostring(action))
+ end
+end
+
+-- reconcileSession decides what a session file found at startup means. A session from a
+-- previous boot is stale: nothing it froze still exists, and units it stopped may have come
+-- back on their own. It still gets a full restore pass before being cleared, because a
+-- stopped unit that is not `enabled` really is still down, and putting it back is what the
+-- user was told would happen.
+--
+-- Within the same boot a session is always kept, even if everything looks running -- that
+-- is precisely the case where something may still be frozen and needs thawing.
+function M.reconcileSession()
+ local snap = M.readSnapshot()
+ if not snap then
+ M.publishGameMode()
+ return
+ end
+
+ local current = M.currentBootId()
+ -- A snapshot with no boot id was written by an older version; treat it as current
+ -- rather than abandoning targets that may still be suspended. Likewise when the boot
+ -- id cannot be read at all.
+ if not snap.boot_id or not current or snap.boot_id == current then
+ M.publishGameMode()
+ return
+ end
+
+ noctalia.log("gamermode: session predates the current boot, restoring and clearing it")
+ M.disable()
+end
+
+function M.init()
+ local directory = noctalia.pluginDataDir()
+ if directory then
+ noctalia.mkdirAll(directory)
+ end
+
+ -- Resolve elevation before anything can need it. Without pkexec the plugin still works
+ -- through systemd's own polkit check, so this downgrades rather than disables.
+ M.canElevate = noctalia.commandExists("pkexec") == true
+ if not M.canElevate then
+ noctalia.log(
+ "gamermode: pkexec not found, so system units will ask for a password once per "
+ .. "unit instead of once per batch"
+ )
+ else
+ run("command -v systemctl", function(result)
+ local path = result and trim(result.stdout) or ""
+ if path ~= "" and path:sub(1, 1) == "/" then
+ M.systemctlPath = path
+ end
+ end)
+ end
+
+ -- Publish immediately so a panel opened before the first poll is not empty.
+ M.publishGameMode()
+ M.publishMetrics()
+ -- Reconciliation can restore the previous power profile, so it waits until the power
+ -- state is known.
+ M.refreshPower(function()
+ M.reconcileSession()
+ end)
+
+ local seconds = tonumber(noctalia.getConfig("poll_interval")) or DEFAULT_POLL_SECONDS
+ noctalia.setUpdateInterval(math.max(1, seconds) * 1000)
+end
+
+-- ── shell entry points (must be globals) ──
+
+function update()
+ M.publishMetrics()
+ -- A queue can only stall if every start was refused while nothing of ours was running,
+ -- which needs the rest of the shell to hold the whole process cap. The poll tick is the
+ -- one thing guaranteed to keep firing, so it is what gets the queue moving again.
+ pump()
+end
+
+function onConfigChanged()
+ local seconds = tonumber(noctalia.getConfig("poll_interval")) or DEFAULT_POLL_SECONDS
+ noctalia.setUpdateInterval(math.max(1, seconds) * 1000)
+ -- The configured profile shows in the panel even while gamer mode is off.
+ M.publishGameMode()
+end
+
+function onIpc(event)
+ if event == "toggle" then
+ M.toggle()
+ elseif event == "enable" then
+ M.enable()
+ elseif event == "disable" then
+ M.disable()
+ elseif event == "diagnose" then
+ M.diagnose()
+ end
+end
+
+noctalia.state.watch("command", M.handleCommand)
+M.init()
+
+return M
diff --git a/gamer-mode/thumbnail.webp b/gamer-mode/thumbnail.webp
new file mode 100644
index 0000000..cf6630d
Binary files /dev/null and b/gamer-mode/thumbnail.webp differ
diff --git a/gamer-mode/translations/en.json b/gamer-mode/translations/en.json
new file mode 100644
index 0000000..e9eba94
--- /dev/null
+++ b/gamer-mode/translations/en.json
@@ -0,0 +1,111 @@
+{
+ "widget": {
+ "tooltip_loading": "Gamer Mode: loading metrics..."
+ },
+ "settings": {
+ "glyph": {
+ "label": "Bar icon",
+ "description": "Glyph shown on the bar."
+ },
+ "click_action": {
+ "label": "Left-click action",
+ "description": "What left-clicking the bar icon does. Right-click always toggles gamer mode.",
+ "options": {
+ "open_panel": "Open panel",
+ "toggle": "Toggle gamer mode"
+ }
+ },
+ "poll_interval": {
+ "label": "Poll interval",
+ "description": "Seconds between metric updates.",
+ "options": {
+ "2": "2s",
+ "3": "3s",
+ "5": "5s"
+ }
+ },
+ "profile": {
+ "label": "Gamer mode profile",
+ "description": "How aggressive the suspend list is.",
+ "options": {
+ "light": "Light",
+ "heavy": "Heavy"
+ }
+ },
+ "auto_performance": {
+ "label": "Auto performance profile",
+ "description": "Switch power profile to performance while gamer mode is on."
+ },
+ "show_temps": {
+ "label": "Show temperatures",
+ "description": "Include CPU/GPU temperatures in tooltip and panel."
+ },
+ "targets": {
+ "label": "Suspend targets (JSON)",
+ "description": "Advanced: JSON array of {match, kind, profiles}. Kind: process, user-service, system-service, container."
+ }
+ },
+ "panel": {
+ "close": "Close",
+ "disable": "Disable",
+ "enable": "Enable",
+ "frozen": "frozen",
+ "gpu_unsupported": "GPU metrics unsupported",
+ "load": "Load",
+ "metrics_unavailable": "System metrics unavailable",
+ "mode_profile": "Suspend profile",
+ "network": "Net",
+ "nothing_suspended": "Nothing suspended",
+ "performance": "Performance",
+ "power": {
+ "power-saver": "Power saver",
+ "balanced": "Balanced",
+ "performance": "Performance"
+ },
+ "power_profile": "Power profile",
+ "power_unavailable": "powerprofilesctl unavailable",
+ "profiles": {
+ "light": "Light",
+ "heavy": "Heavy"
+ },
+ "settings": "Settings",
+ "state_off": "Off",
+ "state_on": "Running",
+ "stopped": "stopped",
+ "suspended": "Suspended",
+ "title": "gamer-mode",
+ "working": "Working..."
+ },
+ "notify": {
+ "enabled_title": "Gamer mode enabled",
+ "disabled_title": "Gamer mode disabled",
+ "suspended_count": {
+ "one": "Suspended 1 target.",
+ "other": "Suspended {count} targets."
+ },
+ "restored_count": {
+ "one": "Restored 1 target.",
+ "other": "Restored {count} targets."
+ },
+ "nothing_suspended": "No background targets were running.",
+ "nothing_restored": "Nothing needed restarting.",
+ "session_failed_title": "Gamer mode could not start",
+ "session_failed": "The session file could not be written, so nothing was suspended."
+ },
+ "cleanup": {
+ "title": "Maintenance",
+ "shaders": "Clear shader caches",
+ "shaders_confirm": "Delete {size}?",
+ "pagecache": "Drop page cache",
+ "swap": "Reclaim swap",
+ "running": "Working...",
+ "shaders_done": "Cleared {size} of shader cache.",
+ "shaders_none": "No shader caches found.",
+ "pagecache_done": "Page cache dropped.",
+ "swap_done": "Swap reclaimed into RAM.",
+ "swap_empty": "Nothing is swapped out.",
+ "swap_unknown": "Swap usage could not be read.",
+ "swap_no_room": "Not enough free RAM to hold what is swapped out.",
+ "failed": "That did not work. See the log."
+ }
+}
diff --git a/gamer-mode/widget.luau b/gamer-mode/widget.luau
new file mode 100644
index 0000000..84c4179
--- /dev/null
+++ b/gamer-mode/widget.luau
@@ -0,0 +1,116 @@
+--!nonstrict
+--
+-- Bar widget: a glyph plus a live tooltip. It owns no state -- it renders whatever the
+-- service publishes and sends commands back through `noctalia.state`.
+
+local PANEL_ID = "nomadcxx/gamer-mode:main"
+local MIB_PER_GIB = 1024
+
+local M = {}
+
+local metrics = noctalia.state.get("metrics") or { available = false }
+local gameMode = noctalia.state.get("game_mode") or { enabled = false, suspended = {} }
+local nonceCounter = 0
+
+local function gibibytes(mib)
+ return string.format("%.1fG", (tonumber(mib) or 0) / MIB_PER_GIB)
+end
+
+local function percent(fraction)
+ return string.format("%d%%", math.floor((tonumber(fraction) or 0) * 100 + 0.5))
+end
+
+-- withTemp appends a temperature only when there is one to show, so a machine without
+-- sensors reads as "GPU 18%" rather than "GPU 18% 0°C".
+local function withTemp(label, showTemps, temp)
+ if showTemps and tonumber(temp) then
+ return label .. string.format(" %d°C", math.floor(tonumber(temp) + 0.5))
+ end
+ return label
+end
+
+function M.formatTooltip(m, showTemps, gm)
+ m = m or {}
+ if not m.available then
+ return noctalia.tr("widget.tooltip_loading")
+ end
+
+ local parts = {
+ withTemp("CPU " .. percent(m.cpuPerc), showTemps, m.cpuTemp),
+ "RAM " .. gibibytes(m.memUsedMb),
+ }
+ -- GPU and VRAM segments are omitted entirely when unsupported: an empty reading is
+ -- more honest than a zero that looks like an idle GPU.
+ if m.gpuAvailable and m.gpuPerc then
+ parts[#parts + 1] = withTemp("GPU " .. percent(m.gpuPerc), showTemps, m.gpuTemp)
+ end
+ if m.vramUsedMb then
+ parts[#parts + 1] = "VRAM " .. gibibytes(m.vramUsedMb)
+ end
+
+ local tooltip = table.concat(parts, " | ")
+ if gm and gm.enabled then
+ tooltip = noctalia.tr("notify.enabled_title") .. " | " .. tooltip
+ end
+ return tooltip
+end
+
+local function render()
+ barWidget.setGlyph(noctalia.getConfig("glyph") or "device-gamepad-2")
+ barWidget.setTooltip(M.formatTooltip(metrics, noctalia.getConfig("show_temps") ~= false, gameMode))
+ -- Accent the glyph while gamer mode is on, so the bar shows the state at a glance
+ -- without needing the tooltip.
+ barWidget.setGlyphColor(gameMode.enabled and "primary" or "on_surface")
+end
+
+local function sendCommand(action)
+ nonceCounter = nonceCounter + 1
+ noctalia.state.set("command", {
+ nonce = noctalia.nowMs() * 1000 + nonceCounter,
+ action = action,
+ })
+end
+
+-- Left-click opens the panel by default. A first click should show what the machine is
+-- doing, not suspend a list of programs the user has not read yet.
+function M.onClick()
+ if (noctalia.getConfig("click_action") or "open_panel") == "toggle" then
+ sendCommand("toggle")
+ else
+ noctalia.togglePanel(PANEL_ID)
+ end
+end
+
+-- Right-click always toggles, whatever click_action says, so the one-click path stays
+-- available without opening the panel first.
+function M.onRightClick()
+ sendCommand("toggle")
+end
+
+noctalia.state.watch("metrics", function(value)
+ metrics = type(value) == "table" and value or { available = false }
+ render()
+end)
+
+noctalia.state.watch("game_mode", function(value)
+ gameMode = type(value) == "table" and value or { enabled = false, suspended = {} }
+ render()
+end)
+
+-- ── shell entry points (must be globals) ──
+
+function onClick()
+ M.onClick()
+end
+
+function onRightClick()
+ M.onRightClick()
+end
+
+function onConfigChanged()
+ render()
+end
+
+render()
+
+return M