// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. pragma Singleton import Quickshell import Quickshell.Io import Quickshell.Wayland import QtQuick // Desktop modes as state. One file per mode under $XDG_RUNTIME_DIR, holding // "0" or "1"; a missing file means off. The runtime directory is tmpfs, so a // reboot resets every mode with no cleanup code here. // // The files are the interface, not this singleton: statusctl reads and writes // them directly so it works while the shell is down, and the FileView watch // means an external write repaints the drawer with no polling. It also means // a mode set from outside still fires its effects, because the watch reaches // the same handler a tile click would. Singleton { id: root readonly property string dir: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" readonly property bool dnd: dndFile.value // Set by the game detector below, on its own file so it never fights the // manual toggle: presentation is the OR, and a game ending clears only its // half. A manual "on" therefore survives a game session. readonly property bool gaming: gamingFile.value readonly property bool presentation: presFile.value || gamingFile.value // Stored as "auto-lock disabled", so the missing-file default of off is // the wanted default of locking on. The row inverts it for the label. readonly property bool nolock: nolockFile.value // Breaktimer, read only. The daemon publishes these three files and owns // them; the shell calls verbs and never writes them, because the daemon // loop rewrites state and phase on every transition. // // A stopped daemon is read from the state file rather than probed: both // paths that end it, stop_daemon and the cleanup trap, write "stopped" // there. A daemon lost to SIGKILL leaves a stale "running" and the drawer // shows a frozen countdown, which is a visible wrong answer the start // button resolves. That is cheaper than a liveness probe per repaint. readonly property string btState: btStateFile.value readonly property string btPhase: btPhaseFile.value readonly property int btRemain: btRemainFile.value readonly property bool btRunning: root.btState === "running" || root.btState === "paused" readonly property bool btPaused: root.btState === "paused" readonly property int activeCount: (root.dnd ? 1 : 0) + (root.presentation ? 1 : 0) + (root.nolock ? 1 : 0) // Set once by shell.qml. IdleInhibitor does nothing with a null window, // and the singleton has no window of its own to offer. property var inhibitWindow: null // What dnd and nolock were before presentation mode drove them, so turning // presentation mode off restores them rather than clearing them. Held here // rather than in a file: they are meaningful only while presentation mode // is on, and presentation mode does not survive a reboot. // // Known limit: on a shell restart while presentation is already on, // onPresentationChanged can run before these FileViews have loaded, so the // recorded prior values depend on which file loads first. property bool dndBeforePresentation: false property bool nolockBeforePresentation: false // Deadline for a timed disable, owned by the singleton rather than the row // because the drawer page (and so the row) is destroyed on close while the // effect has to outlive it. Shell-lifetime only: a restart while a timed // disable is pending leaves the lock off until it is toggled, which is // safe because the user opted into the disable. property double nolockUntil: 0 function setMode(name, on) { if (name === "dnd") { dndFile.write(on); } else if (name === "presentation") { presFile.write(on); } else if (name === "nolock") { // Any write clears a pending deadline; the timed path sets it back. root.nolockUntil = 0; nolockFile.write(on); } } // Disable auto-lock, optionally for a number of minutes. Zero or a // non-number means until toggled back. function disableLockFor(minutes) { const m = Number(minutes); root.setMode("nolock", true); if (m > 0) root.nolockUntil = Date.now() + m * 60000; } function toggleMode(name) { if (name === "dnd") root.setMode("dnd", !root.dnd); else if (name === "presentation") root.setMode("presentation", !root.presentation); else if (name === "nolock") root.setMode("nolock", !root.nolock); } // The detector calls this every poll; a write on an unchanged value would // still land as an atomic write and re-fire the FileView watch for nothing. function setGaming(on) { if (on !== root.gaming) gamingFile.write(on); } // The timed disable re-enables itself. Runs only while a deadline is // pending, so no tick burns while the mode is off or permanently on. Timer { id: nolockTimer interval: 1000 repeat: true running: root.nolockUntil > 0 onTriggered: { if (Date.now() >= root.nolockUntil) root.setMode("nolock", false); } } // Effects follow the mode rather than the setter, so a mode set by // statusctl while the drawer is closed asserts them too. onPresentationChanged: { if (root.presentation) { root.dndBeforePresentation = root.dnd; root.nolockBeforePresentation = root.nolock; root.setMode("dnd", true); root.setMode("nolock", true); // pause/resume write the state file whether or not a daemon // runs, so send the verb only when one exists. if (root.btRunning) root.runBreaktimer("pause"); } else { root.setMode("dnd", root.dndBeforePresentation); root.setMode("nolock", root.nolockBeforePresentation); if (root.btRunning) root.runBreaktimer("resume"); } } function runBreaktimer(verb) { breakProc.command = ["breaktimer.sh", verb]; breakProc.running = false; breakProc.running = true; } // Seconds as m:ss. The daemon rewrites the remaining seconds every five // seconds, its tick, so this counts down in five second steps and does // not interpolate: a local one second timer would be a second clock // drifting against the first, correcting itself with a visible jump, and // it would keep ticking while the daemon is frozen outside the work // window or paused. function btCountdown() { const s = Math.max(0, root.btRemain); return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); } // breaktimer owns its own state file; this only calls its verbs. Two // writers on that file would race with its daemon loop, which rewrites it // on every phase change. Process { id: breakProc onExited: code => { if (code !== 0) console.warn("status: breaktimer.sh " + breakProc.command[1] + " exited " + code); } } // --- game detection --------------------------------------------------- // // A game is a fullscreen app that already holds the screen, and the idle // lock, DND and breaktimer are all unwanted while it runs, so presentation // is set automatically. This machine's games come from Steam and launch // either under gamescope or directly, and the DuckStation and PCSX2 emulators // run standalone, so all four are tested and any one is enough. // // The steam pattern is bracketed so it cannot match this very command // line: pgrep -f reads the whole argv, and the literal "steamapps/common" // here would otherwise match the sh running the check and report a game // forever. The character class makes the needle miss itself while still // matching a real game path. gamescope and the emulators use pgrep -x, an // exact match on the process name, which cannot self-match. Process { id: gameProc command: ["sh", "-c", "pgrep -x gamescope >/dev/null 2>&1 || pgrep -f 'steamapps/commo[n]' >/dev/null 2>&1 || pgrep -x duckstation-qt >/dev/null 2>&1 || pgrep -x pcsx2-qt >/dev/null 2>&1"] onExited: code => root.setGaming(code === 0) } // Process lifecycle is not a file to watch, so this polls. A game launch // does not need sub-second latency to present; a missed start corrects on // the next tick. triggeredOnStart covers a shell start while a game is // already up. Timer { interval: 5000 repeat: true running: true triggeredOnStart: true onTriggered: { // Re-assigning running on an already-running Process is a no-op, // so it is cleared first. gameProc.running = false; gameProc.running = true; } } // Wayland idle inhibit. The compositor advertises // zwp_idle_inhibit_manager_v1 and hypridle honours it, so no D-Bus path // is needed even though elogind runs here. nolock is the sole holder; // presentation drives nolock, so it needs no second inhibitor. IdleInhibitor { window: root.inhibitWindow enabled: root.nolock && root.inhibitWindow !== null } component ModeFile: FileView { id: mf property bool value: false // FileView is documented to fire fileChanged on its own setText, so a // write would re-enter this handler. Comparing before assigning makes // that harmless: the reparse yields the value just written and the // binding does not change. function reparse() { const t = mf.text().trim(); const v = (t === "1"); if (v !== mf.value) mf.value = v; } function write(on) { const s = on ? "1\n" : "0\n"; mf.value = on; mf.setText(s); } // Both are the documented defaults in 0.3.1, set explicitly because // the CLI depends on them: statusctl watches close_write,moved_to // precisely because an atomic write lands as a rename, so a future // release flipping this default would break the watcher silently. atomicWrites: true watchChanges: true printErrors: false onFileChanged: mf.reload() onLoaded: mf.reparse() // A missing file is the off state, not an error worth logging. onLoadFailed: mf.value = false } // A daemon file holding a word. Same watch and same missing-file rule as // ModeFile, without a write path: this side only reads. component WordFile: FileView { id: wf property string value: "stopped" function reparse() { const t = wf.text().trim(); if (t !== wf.value) wf.value = t; } watchChanges: true printErrors: false onFileChanged: wf.reload() onLoaded: wf.reparse() // No file means no daemon, which is the stopped state, not an error. onLoadFailed: wf.value = "stopped" } // The remaining seconds. Validated as an integer rather than trusted: // the file is written every tick and a read can catch it mid-write, and // an empty string coerces to 0 silently while NaN would propagate into // the countdown as "NaN:aN". component SecondsFile: FileView { id: sf property int value: 0 function reparse() { const n = parseInt(sf.text().trim(), 10); const v = (isNaN(n) || n < 0) ? 0 : n; if (v !== sf.value) sf.value = v; } watchChanges: true printErrors: false onFileChanged: sf.reload() onLoaded: sf.reparse() onLoadFailed: sf.value = 0 } ModeFile { id: dndFile; path: root.dir + "/status.dnd" } ModeFile { id: presFile; path: root.dir + "/status.presentation" } ModeFile { id: nolockFile; path: root.dir + "/status.nolock" } // Written only by the game detector above, never by statusctl. ModeFile { id: gamingFile; path: root.dir + "/status.gaming" } WordFile { id: btStateFile; path: root.dir + "/breaktimer.state" } WordFile { id: btPhaseFile; path: root.dir + "/breaktimer.phase" } SecondsFile { id: btRemainFile; path: root.dir + "/breaktimer.remain" } }