* 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>
363 lines
11 KiB
QML
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)
|
|
}
|
|
}
|
|
}
|