Files
omarchy/shell/plugins/services/idle/Service.qml
T
David Heinemeier HanssonandClaude Opus 5.5 c231097df7 Answer omarchy-shell calls over the shell's own socket (#13435)
* Answer omarchy-shell calls over the shell's own socket

Every omarchy-shell call started a qs ipc client, ~45ms of startup for one
IPC call: a theme switch makes two, and every script-driven OSD, toggle
refresh and lock query paid it too.

The shell now serves a socket in XDG_RUNTIME_DIR, named from its config
path and Wayland display as qs ipc selects its instance, and omarchy-shell
tries it first through socat, which starts in ~5ms. First-party handlers
register as ShellIpc, an IpcHandler that qs ipc still reaches, and the
socket calls only the functions a handler declares with their exact
argument count, allowed by name so QObject methods such as destroy() stay
out of reach.

When the shell ran nothing it answers SKIP, and omarchy-shell asks qs ipc
for its exact answer, so errors, third-party plugins and an unreachable
socket behave as before. A call that may have run is never retried: a
timeout or a connection closed without an answer reports the shell as not
responding.

omarchy-shell shell ping takes ~13-18ms instead of ~61ms, and omarchy-osd
reaches the screen in ~36ms instead of ~77ms. Output and exit status match
the qs ipc path across 26 calls, errors and quiet mode included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only accept whole socket replies and retry only unmade connections

A reply cut off after its OK prefix passed for the whole answer, and an
empty reply with socat failing was retried through qs ipc although the
request might already have been delivered.

An answer now counts only once its record separator arrived. socat's own
errors join the reply, so only its connect error, a socket nothing
listens on, falls back to qs ipc beside an explicit SKIP; anything else
is reported as not responding rather than retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:46:00 +02:00

363 lines
11 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import "IdleModel.js" as IdleModel
import qs.Commons
Item {
id: root
// Injected by omarchy-shell (the first-party service loader).
property var shell: null
readonly property string home: Quickshell.env("HOME")
readonly property string stayAwakeStateDir: home + "/.local/state/omarchy/indicators"
readonly property string stayAwakeStatePath: stayAwakeStateDir + "/stay-awake"
readonly property int defaultScreensaverSeconds: 150
readonly property int defaultLockSeconds: 300
readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle
? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({}))
readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds)
readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds)
readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds)
readonly property int screensaverDelaySeconds: Math.max(0, screensaverTimeoutSeconds - firstIdleTimeoutSeconds)
readonly property int lockDelaySeconds: Math.max(0, lockTimeoutSeconds - firstIdleTimeoutSeconds)
readonly property bool idleEnabled: stayAwakeStateLoaded && !stayAwake
readonly property string screensaverClass: "org.omarchy.screensaver"
property bool stayAwake: false
property bool stayAwakeStateLoaded: false
property bool hasPendingStayAwakePersist: false
property bool pendingStayAwakePersist: false
property bool idledThisCycle: false
property bool screensaverStartedThisCycle: false
property string lastEvent: "starting"
property string lastEventAt: ""
property var screensaverWindows: ({})
property int screensaverWindowCount: 0
function secondsFromConfig(value, fallback) {
return IdleModel.secondsFromConfig(value, fallback)
}
function nowIso() {
return new Date().toISOString()
}
function logEvent(event, details) {
var suffix = details === undefined || details === null || details === "" ? "" : ": " + String(details)
root.lastEventAt = nowIso()
root.lastEvent = event + suffix
console.log("omarchy idle " + root.lastEventAt + " " + root.lastEvent)
}
function runProcess(process, label, command) {
if (process.running) {
logEvent("process-skip", label + " already running")
return false
}
logEvent("process-start", label + " " + command)
process.command = ["bash", "-lc", command]
process.running = true
return true
}
function launchScreensaver() {
root.screensaverStartedThisCycle = true
screensaverLaunchGraceTimer.restart()
runProcess(screensaverProcess, "screensaver", "[[ $(omarchy-shell lock isLocked 2>/dev/null) == \"true\" ]] || omarchy-launch-screensaver")
}
function lockSystem(reason) {
logEvent("lock-system", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverLaunchGraceTimer.stop()
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
runProcess(lockProcess, "lock", "omarchy-system-lock")
}
function startIdleCycle() {
if (root.idledThisCycle) {
logEvent("idle-cycle-already-running")
return
}
logEvent("idle-cycle-start", "screensaver=" + root.screensaverTimeoutSeconds + " lock=" + root.lockTimeoutSeconds)
root.idledThisCycle = true
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
if (root.screensaverDelaySeconds === 0) launchScreensaver()
else screensaverTimer.restart()
if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate")
else lockTimer.restart()
}
function cancelIdleCycle(reason) {
logEvent("idle-cycle-cancel", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverLaunchGraceTimer.stop()
if (root.idledThisCycle) runProcess(wakeProcess, "wake", "omarchy-system-wake")
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
}
function resetScreensaverWindows() {
root.screensaverWindows = ({})
root.screensaverWindowCount = 0
}
function setScreensaverWindow(address, visible) {
var next = IdleModel.screensaverWindowsAfter(root.screensaverWindows, address, visible)
root.screensaverWindows = next.windows
root.screensaverWindowCount = next.count
}
function handleScreensaverWindowOpened(address) {
setScreensaverWindow(address, true)
screensaverLaunchGraceTimer.stop()
}
function handleScreensaverWindowClosed(address) {
setScreensaverWindow(address, false)
if (!root.idleEnabled || !root.idledThisCycle || !root.screensaverStartedThisCycle) return
if (root.screensaverWindowCount > 0) return
// The user dismissed the screensaver before the lock deadline. Treat that
// as activity and cancel the pending lock; the lock timer is only allowed
// to fire while the screensaver remains up.
root.cancelIdleCycle("screensaver-dismissed")
}
function eventParts(event, count) {
return IdleModel.eventParts(event, count)
}
function handleHyprlandEvent(event) {
var name = String(event && event.name ? event.name : "")
if (name === "openwindow") {
var open = eventParts(event, 4)
if (String(open[2] || "") === root.screensaverClass) root.handleScreensaverWindowOpened(open[0])
} else if (name === "closewindow") {
var close = eventParts(event, 1)
var address = String(close[0] || "")
if (root.screensaverWindows[address]) root.handleScreensaverWindowClosed(address)
}
}
function handleActiveSignal() {
if (!root.idledThisCycle) return
// Starting the screensaver can make the compositor report activity. Keep
// the lock timer running once the screensaver exists (or during its short
// launch grace); Hyprland window events cancel the cycle if it exits before
// the normal lock deadline.
if (root.screensaverStartedThisCycle && (root.screensaverWindowCount > 0 || screensaverLaunchGraceTimer.running)) {
logEvent("idle-monitor-active", "screensaver cycle remains armed")
return
}
cancelIdleCycle("activity")
}
function handleIdleChanged() {
logEvent("idle-monitor", idleMonitor.isIdle ? "idle" : "active")
if (!root.idleEnabled) return
if (idleMonitor.isIdle) startIdleCycle()
else handleActiveSignal()
}
function statusJson() {
return JSON.stringify({
enabled: root.idleEnabled,
stayAwake: root.stayAwake,
stayAwakeStateLoaded: root.stayAwakeStateLoaded,
stayAwakeStatePath: root.stayAwakeStatePath,
idle: idleMonitor.isIdle,
inIdleCycle: root.idledThisCycle,
screensaverStarted: root.screensaverStartedThisCycle,
screensaver: root.screensaverTimeoutSeconds,
lock: root.lockTimeoutSeconds,
screensaverDelay: root.screensaverDelaySeconds,
lockDelay: root.lockDelaySeconds,
screensaverWindows: root.screensaverWindowCount,
timers: {
screensaver: screensaverTimer.running,
lock: lockTimer.running,
screensaverLaunchGrace: screensaverLaunchGraceTimer.running
},
processes: {
screensaver: screensaverProcess.running,
lock: lockProcess.running,
wake: wakeProcess.running
},
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt
})
}
function persistStayAwake(value) {
var command = value
? "mkdir -p \"$HOME/.local/state/omarchy/indicators\" && touch \"$HOME/.local/state/omarchy/indicators/stay-awake\""
: "rm -f \"$HOME/.local/state/omarchy/indicators/stay-awake\""
if (stayAwakeStateWriter.running) {
root.pendingStayAwakePersist = !!value
root.hasPendingStayAwakePersist = true
return
}
stayAwakeStateWriter.command = ["bash", "-lc", command]
stayAwakeStateWriter.running = true
}
function refreshStayAwakeState() {
if (!stayAwakeStateProbe.running) stayAwakeStateProbe.running = true
}
function applyStayAwake(value, persist, reason) {
var enabled = !!value
var changed = !root.stayAwakeStateLoaded || root.stayAwake !== enabled
if (persist) persistStayAwake(enabled)
root.stayAwake = enabled
root.stayAwakeStateLoaded = true
if (!changed) return enabled ? "disabled" : "enabled"
logEvent("stay-awake", (enabled ? "enabled" : "disabled") + (reason ? " " + reason : ""))
if (enabled) cancelIdleCycle("stay-awake")
else Qt.callLater(root.handleIdleChanged)
return enabled ? "disabled" : "enabled"
}
function setIdleEnabled(value) {
return applyStayAwake(!value, true, "ipc")
}
IdleMonitor {
id: idleMonitor
enabled: root.idleEnabled
timeout: root.firstIdleTimeoutSeconds
respectInhibitors: true
onIsIdleChanged: root.handleIdleChanged()
}
Timer {
id: screensaverTimer
interval: root.screensaverDelaySeconds * 1000
repeat: false
onTriggered: root.launchScreensaver()
}
Timer {
id: lockTimer
interval: root.lockDelaySeconds * 1000
repeat: false
onTriggered: if (root.idleEnabled && root.idledThisCycle) root.lockSystem("lock-timeout")
}
Timer {
id: screensaverLaunchGraceTimer
interval: 3000
repeat: false
onTriggered: {
if (root.idleEnabled && root.idledThisCycle && root.screensaverStartedThisCycle && root.screensaverWindowCount === 0 && !idleMonitor.isIdle) {
root.cancelIdleCycle("screensaver-not-running")
}
}
}
Connections {
target: Hyprland
function onRawEvent(event) { root.handleHyprlandEvent(event) }
}
Process {
id: screensaverProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "screensaver exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: lockProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "lock exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: wakeProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "wake exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: stayAwakeStateProbe
command: ["bash", "-c", "mkdir -p \"$HOME/.local/state/omarchy/indicators\"; if [[ -f $HOME/.local/state/omarchy/indicators/stay-awake ]]; then echo yes; else echo no; fi"]
stdout: SplitParser {
onRead: function(line) { root.applyStayAwake(String(line).trim() === "yes", false, "state-file") }
}
onExited: function() { stayAwakeStateDirWatcher.reload() }
}
Process {
id: stayAwakeStateWriter
onExited: function() {
if (root.hasPendingStayAwakePersist) {
var pending = root.pendingStayAwakePersist
root.hasPendingStayAwakePersist = false
root.persistStayAwake(pending)
return
}
root.refreshStayAwakeState()
}
}
FileView {
id: stayAwakeStateDirWatcher
path: root.stayAwakeStateDir
watchChanges: true
printErrors: false
onFileChanged: root.refreshStayAwakeState()
}
Component.onCompleted: {
logEvent("service-ready")
refreshStayAwakeState()
}
ShellIpc {
target: "idle"
function status(): string {
return root.statusJson()
}
function debug(): string {
return root.statusJson()
}
function enable(): string {
return root.setIdleEnabled(true)
}
function disable(): string {
return root.setIdleEnabled(false)
}
function toggle(): string {
return root.setIdleEnabled(!root.idleEnabled)
}
}
}