Files
community-plugins/gamer-mode/service.luau
T
RAMAandGitHub 0f64148bea Add gamer-mode plugin (#168)
* feat: add gamer-mode plugin

Live CPU, RAM, swap, GPU, VRAM, load and network readings in the bar and a
panel, plus a one-click mode that suspends background resource hogs and
restores exactly what it suspended.

* chore: rebuild the thumbnail with the upstream generator

Produced by assets.noctalia.dev/plugins/thumbnail-generator.html with the
title, the Gaming tag, the panel screenshot and the Red accent, rather than
composed by hand at the same dimensions.

* fix(gamer-mode): write the session before suspending anything

Enable suspended targets first and dropped the writeSnapshot return, so
a failed write left processes frozen and units stopped with no record to
restore them from, while the service published gamer mode as off and
switched the power profile.

The snapshot now lands on disk first, and a failed write aborts the
enable with the machine untouched and an error notification.

Writing first can record a target whose suspend command then failed.
That direction is safe: thawing is unconditional and SIGCONT to a
running process is a no-op, and a stop target restarts only after a live
probe says it is still down.
2026-07-30 22:32:21 -04:00

1829 lines
68 KiB
Luau

--!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