Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
function secondsFromConfig(value, fallback) {
|
||||
var n = Number(value)
|
||||
if (!isFinite(n) || n < 0) return fallback
|
||||
return Math.floor(n)
|
||||
}
|
||||
|
||||
function eventParts(event, count) {
|
||||
try {
|
||||
if (event && event.parse) return event.parse(count)
|
||||
} catch (error) {
|
||||
}
|
||||
return String(event && event.data ? event.data : "").split(",")
|
||||
}
|
||||
|
||||
function screensaverWindowsAfter(windows, address, visible) {
|
||||
var key = String(address || "")
|
||||
if (!key) {
|
||||
var current = windows || {}
|
||||
var existingCount = 0
|
||||
for (var currentKey in current) {
|
||||
if (current[currentKey]) existingCount++
|
||||
}
|
||||
return { windows: current, count: existingCount }
|
||||
}
|
||||
|
||||
var next = {}
|
||||
var count = 0
|
||||
for (var existing in windows || {}) {
|
||||
if (existing !== key && windows[existing]) {
|
||||
next[existing] = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
next[key] = true
|
||||
count++
|
||||
}
|
||||
|
||||
return {
|
||||
windows: next,
|
||||
count: count
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
secondsFromConfig: secondsFromConfig,
|
||||
eventParts: eventParts,
|
||||
screensaverWindowsAfter: screensaverWindowsAfter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import "IdleModel.js" as IdleModel
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Injected by blob-shell (the first-party service loader).
|
||||
property var shell: null
|
||||
|
||||
readonly property string home: Quickshell.env("HOME")
|
||||
readonly property string stayAwakeStateDir: home + "/.local/state/blob/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.blob.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("blob 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", "[[ $(blob-shell lock isLocked 2>/dev/null) == \"true\" ]] || blob-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", "blob-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", "blob-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/blob/indicators\" && touch \"$HOME/.local/state/blob/indicators/stay-awake\""
|
||||
: "rm -f \"$HOME/.local/state/blob/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/blob/indicators\"; if [[ -f $HOME/.local/state/blob/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()
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.idle",
|
||||
"name": "Idle",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Quickshell-based idle detection for screensaver, lock, and display wake.",
|
||||
"kinds": [
|
||||
"service"
|
||||
],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"service": "Service.qml"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user