From 0f64148beaadd677ca5bb7c039203cbb8e8fb3c7 Mon Sep 17 00:00:00 2001 From: RAMA <143774106+Nomadcxx@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:32:21 +1000 Subject: [PATCH] Add gamer-mode plugin (#168) * feat: add gamer-mode plugin Live CPU, RAM, swap, GPU, VRAM, load and network readings in the bar and a panel, plus a one-click mode that suspends background resource hogs and restores exactly what it suspended. * chore: rebuild the thumbnail with the upstream generator Produced by assets.noctalia.dev/plugins/thumbnail-generator.html with the title, the Gaming tag, the panel screenshot and the Red accent, rather than composed by hand at the same dimensions. * fix(gamer-mode): write the session before suspending anything Enable suspended targets first and dropped the writeSnapshot return, so a failed write left processes frozen and units stopped with no record to restore them from, while the service published gamer mode as off and switched the power profile. The snapshot now lands on disk first, and a failed write aborts the enable with the machine untouched and an error notification. Writing first can record a target whose suspend command then failed. That direction is safe: thawing is unconditional and SIGCONT to a running process is a no-op, and a stop target restarts only after a live probe says it is still down. --- gamer-mode/README.md | 397 +++++++ gamer-mode/logo.svg | 60 + gamer-mode/panel.luau | 636 +++++++++++ gamer-mode/plugin.toml | 94 ++ gamer-mode/service.luau | 1828 +++++++++++++++++++++++++++++++ gamer-mode/thumbnail.webp | Bin 0 -> 46380 bytes gamer-mode/translations/en.json | 111 ++ gamer-mode/widget.luau | 116 ++ 8 files changed, 3242 insertions(+) create mode 100644 gamer-mode/README.md create mode 100644 gamer-mode/logo.svg create mode 100644 gamer-mode/panel.luau create mode 100644 gamer-mode/plugin.toml create mode 100644 gamer-mode/service.luau create mode 100644 gamer-mode/thumbnail.webp create mode 100644 gamer-mode/translations/en.json create mode 100644 gamer-mode/widget.luau 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 0000000000000000000000000000000000000000..cf6630d1722e7c6cb4c832cf8f922e9591844d90 GIT binary patch literal 46380 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 literal 0 HcmV?d00001 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