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