From 14921a956ea998362e82b82fac4b092d9bcdfe02 Mon Sep 17 00:00:00 2001 From: Paulo Geyer Date: Sat, 19 Sep 2026 13:57:52 -0300 Subject: [PATCH 1/4] Treat idle timeout 0 as disabled, not immediate idle.screensaver / idle.lock of 0 was included in Math.min(), so IdleMonitor's timeout became 0. Releasing a Wayland idle inhibitor (SDL's default screensaver inhibit) then reported idle immediately and locked the session even when lock had been "disabled" by setting it to 0. Treat 0 as disabled when computing the first idle deadline, do not start the corresponding action, and leave IdleMonitor off when both timeouts are 0. Fixes #10860 --- shell/plugins/services/idle/IdleModel.js | 22 +++++++++++++++++++ shell/plugins/services/idle/Service.qml | 27 +++++++++++++++--------- test/shell.d/idle-test.sh | 23 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/shell/plugins/services/idle/IdleModel.js b/shell/plugins/services/idle/IdleModel.js index 8e2315d0..f13769ee 100644 --- a/shell/plugins/services/idle/IdleModel.js +++ b/shell/plugins/services/idle/IdleModel.js @@ -4,6 +4,26 @@ function secondsFromConfig(value, fallback) { return Math.floor(n) } +// 0 means the action is disabled, not "fire immediately". min(0, 300) would +// otherwise make IdleMonitor report idle as soon as an inhibitor is released. +function firstIdleTimeout(screensaverSeconds, lockSeconds) { + var times = [] + if (screensaverSeconds > 0) times.push(screensaverSeconds) + if (lockSeconds > 0) times.push(lockSeconds) + if (times.length === 0) return 0 + var min = times[0] + for (var i = 1; i < times.length; i++) { + if (times[i] < min) min = times[i] + } + return min +} + +function delayAfterFirstIdle(timeoutSeconds, firstIdleSeconds) { + if (!(timeoutSeconds > 0)) return 0 + var delay = timeoutSeconds - firstIdleSeconds + return delay > 0 ? delay : 0 +} + function eventParts(event, count) { try { if (event && event.parse) return event.parse(count) @@ -46,6 +66,8 @@ function screensaverWindowsAfter(windows, address, visible) { if (typeof module !== "undefined") { module.exports = { secondsFromConfig: secondsFromConfig, + firstIdleTimeout: firstIdleTimeout, + delayAfterFirstIdle: delayAfterFirstIdle, eventParts: eventParts, screensaverWindowsAfter: screensaverWindowsAfter } diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 1453920d..c0545a73 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -20,9 +20,12 @@ Item { ? 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 screensaverEnabled: screensaverTimeoutSeconds > 0 + readonly property bool lockEnabled: lockTimeoutSeconds > 0 + readonly property bool idleTimersEnabled: screensaverEnabled || lockEnabled + readonly property int firstIdleTimeoutSeconds: IdleModel.firstIdleTimeout(screensaverTimeoutSeconds, lockTimeoutSeconds) + readonly property int screensaverDelaySeconds: IdleModel.delayAfterFirstIdle(screensaverTimeoutSeconds, firstIdleTimeoutSeconds) + readonly property int lockDelaySeconds: IdleModel.delayAfterFirstIdle(lockTimeoutSeconds, firstIdleTimeoutSeconds) readonly property bool idleEnabled: stayAwakeStateLoaded && !stayAwake readonly property string screensaverClass: "org.omarchy.screensaver" @@ -91,11 +94,15 @@ Item { root.screensaverStartedThisCycle = false resetScreensaverWindows() - if (root.screensaverDelaySeconds === 0) launchScreensaver() - else screensaverTimer.restart() + if (root.screensaverEnabled) { + if (root.screensaverDelaySeconds === 0) launchScreensaver() + else screensaverTimer.restart() + } - if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate") - else lockTimer.restart() + if (root.lockEnabled) { + if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate") + else lockTimer.restart() + } } function cancelIdleCycle(reason) { @@ -172,7 +179,7 @@ Item { function handleIdleChanged() { logEvent("idle-monitor", idleMonitor.isIdle ? "idle" : "active") - if (!root.idleEnabled) return + if (!root.idleEnabled || !root.idleTimersEnabled) return if (idleMonitor.isIdle) startIdleCycle() else handleActiveSignal() @@ -250,8 +257,8 @@ Item { IdleMonitor { id: idleMonitor - enabled: root.idleEnabled - timeout: root.firstIdleTimeoutSeconds + enabled: root.idleEnabled && root.idleTimersEnabled + timeout: Math.max(1, root.firstIdleTimeoutSeconds) respectInhibitors: true onIsIdleChanged: root.handleIdleChanged() } diff --git a/test/shell.d/idle-test.sh b/test/shell.d/idle-test.sh index 62a19c2c..2b4deeba 100644 --- a/test/shell.d/idle-test.sh +++ b/test/shell.d/idle-test.sh @@ -5,11 +5,34 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" run_node_test <<'JS' +const fs = require('fs') const idle = requireFromRoot('shell/plugins/services/idle/IdleModel.js') +const serviceSource = fs.readFileSync(root + '/shell/plugins/services/idle/Service.qml', 'utf8') assertEqual(idle.secondsFromConfig('42.9', 10), 42, 'idle floors configured seconds') assertEqual(idle.secondsFromConfig('-1', 10), 10, 'idle rejects negative seconds') assertEqual(idle.secondsFromConfig('nope', 10), 10, 'idle rejects invalid seconds') +assertEqual(idle.secondsFromConfig(0, 300), 0, 'idle keeps an explicit zero timeout') +assertEqual(idle.firstIdleTimeout(150, 300), 150, 'idle uses the sooner of screensaver and lock') +assertEqual(idle.firstIdleTimeout(0, 300), 300, 'idle ignores a disabled screensaver when computing first idle') +assertEqual(idle.firstIdleTimeout(150, 0), 150, 'idle ignores a disabled lock when computing first idle') +assertEqual(idle.firstIdleTimeout(0, 0), 0, 'idle has no first-idle timeout when both actions are disabled') +assertEqual(idle.delayAfterFirstIdle(300, 150), 150, 'idle delays lock until its own timeout') +assertEqual(idle.delayAfterFirstIdle(150, 150), 0, 'idle fires an action immediately when it is the first timeout') +assertEqual(idle.delayAfterFirstIdle(0, 150), 0, 'idle does not schedule a disabled action') + +assert( + serviceSource.includes('IdleModel.firstIdleTimeout(screensaverTimeoutSeconds, lockTimeoutSeconds)'), + 'idle ignores a zero timeout when computing the first idle deadline' +) +assert( + /enabled: root\.idleEnabled && root\.idleTimersEnabled/.test(serviceSource), + 'idle monitor is off when both screensaver and lock are disabled' +) +assert( + /if \(root\.lockEnabled\) \{\s*\n\s*if \(root\.lockDelaySeconds === 0\) lockSystem/.test(serviceSource), + 'idle does not lock immediately when lock timeout is disabled' +) assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data') assertDeepEqual( From 377b15be4b4ad7e88a3bac47f49059e45a954644 Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:29:30 +0200 Subject: [PATCH 2/4] Don't fire a pending idle action whose timeout was set to 0 The timers' intervals are bound to the configured delays, so setting the lock or screensaver to 0 while the screensaver is up turns a pending timer's interval to 0 and it fires at once. Check the action is still enabled when its timer fires. Co-Authored-By: Claude Opus 5.5 --- shell/plugins/services/idle/Service.qml | 4 ++-- test/shell.d/idle-test.sh | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index c0545a73..9321150d 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -267,14 +267,14 @@ Item { id: screensaverTimer interval: root.screensaverDelaySeconds * 1000 repeat: false - onTriggered: root.launchScreensaver() + onTriggered: if (root.screensaverEnabled) root.launchScreensaver() } Timer { id: lockTimer interval: root.lockDelaySeconds * 1000 repeat: false - onTriggered: if (root.idleEnabled && root.idledThisCycle) root.lockSystem("lock-timeout") + onTriggered: if (root.idleEnabled && root.idledThisCycle && root.lockEnabled) root.lockSystem("lock-timeout") } Timer { diff --git a/test/shell.d/idle-test.sh b/test/shell.d/idle-test.sh index 2b4deeba..d5bd7afa 100644 --- a/test/shell.d/idle-test.sh +++ b/test/shell.d/idle-test.sh @@ -33,6 +33,14 @@ assert( /if \(root\.lockEnabled\) \{\s*\n\s*if \(root\.lockDelaySeconds === 0\) lockSystem/.test(serviceSource), 'idle does not lock immediately when lock timeout is disabled' ) +assert( + /id: screensaverTimer[\s\S]*?onTriggered: if \(root\.screensaverEnabled\) root\.launchScreensaver\(\)/.test(serviceSource), + 'idle does not launch a pending screensaver once its timeout is set to 0' +) +assert( + /id: lockTimer[\s\S]*?onTriggered: if \([^)]*root\.lockEnabled\) root\.lockSystem\("lock-timeout"\)/.test(serviceSource), + 'idle does not fire a pending lock once its timeout is set to 0' +) assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data') assertDeepEqual( From 3839d0b4e0054c292d93ce143f39d91dce88f1dd Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:45:45 +0200 Subject: [PATCH 3/4] Keep a pending idle action on its deadline when the timings change With 0 meaning off, setting the screensaver to 0 while the screensaver is up moves the first idle deadline to the lock's, which turns the pending lock's bound interval to 0 and locks at once, minutes early. Set each timer's interval when the cycle starts, so a change to shell.json takes effect from the next cycle. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex Medium --- shell/plugins/services/idle/Service.qml | 14 ++++++++++---- test/shell.d/idle-test.sh | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 9321150d..860a3b23 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -94,14 +94,22 @@ Item { root.screensaverStartedThisCycle = false resetScreensaverWindows() + // Set this cycle's deadlines once: a bound interval would restart a pending + // timer from the moment shell.json changes, locking early or late. if (root.screensaverEnabled) { if (root.screensaverDelaySeconds === 0) launchScreensaver() - else screensaverTimer.restart() + else { + screensaverTimer.interval = root.screensaverDelaySeconds * 1000 + screensaverTimer.restart() + } } if (root.lockEnabled) { if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate") - else lockTimer.restart() + else { + lockTimer.interval = root.lockDelaySeconds * 1000 + lockTimer.restart() + } } } @@ -265,14 +273,12 @@ Item { Timer { id: screensaverTimer - interval: root.screensaverDelaySeconds * 1000 repeat: false onTriggered: if (root.screensaverEnabled) root.launchScreensaver() } Timer { id: lockTimer - interval: root.lockDelaySeconds * 1000 repeat: false onTriggered: if (root.idleEnabled && root.idledThisCycle && root.lockEnabled) root.lockSystem("lock-timeout") } diff --git a/test/shell.d/idle-test.sh b/test/shell.d/idle-test.sh index d5bd7afa..a5057a26 100644 --- a/test/shell.d/idle-test.sh +++ b/test/shell.d/idle-test.sh @@ -41,6 +41,16 @@ assert( /id: lockTimer[\s\S]*?onTriggered: if \([^)]*root\.lockEnabled\) root\.lockSystem\("lock-timeout"\)/.test(serviceSource), 'idle does not fire a pending lock once its timeout is set to 0' ) +assert( + /lockTimer\.interval = root\.lockDelaySeconds \* 1000\s*\n\s*lockTimer\.restart\(\)/.test(serviceSource) && + !/interval: root\.lockDelaySeconds/.test(serviceSource), + 'idle keeps a pending lock on its deadline when shell.json changes mid-cycle' +) +assert( + /screensaverTimer\.interval = root\.screensaverDelaySeconds \* 1000\s*\n\s*screensaverTimer\.restart\(\)/.test(serviceSource) && + !/interval: root\.screensaverDelaySeconds/.test(serviceSource), + 'idle keeps a pending screensaver on its deadline when shell.json changes mid-cycle' +) assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data') assertDeepEqual( From 3cebdc3412e6bb53dbffc57c36edff29b9c0446f Mon Sep 17 00:00:00 2001 From: Omarchybot <317366263+omarchybot@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:30:46 +0200 Subject: [PATCH 4/4] End a running idle cycle when both timeouts are set to 0 With both at 0 the monitor is disabled and handleIdleChanged returns early, so a cycle already running when shell.json changed never cancels: omarchy-system-wake never runs, and if the screensaver never opened a window nothing else ends it. Cancel it the way turning on stay-awake does. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex Medium --- shell/plugins/services/idle/Service.qml | 3 +++ test/shell.d/idle-test.sh | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 860a3b23..8fb45a6c 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -263,6 +263,9 @@ Item { return applyStayAwake(!value, true, "ipc") } + // With both timeouts at 0 the monitor stops reporting, so nothing else would end a running cycle. + onIdleTimersEnabledChanged: if (!idleTimersEnabled) cancelIdleCycle("idle-timers-disabled") + IdleMonitor { id: idleMonitor enabled: root.idleEnabled && root.idleTimersEnabled diff --git a/test/shell.d/idle-test.sh b/test/shell.d/idle-test.sh index a5057a26..450c8d5a 100644 --- a/test/shell.d/idle-test.sh +++ b/test/shell.d/idle-test.sh @@ -51,6 +51,10 @@ assert( !/interval: root\.screensaverDelaySeconds/.test(serviceSource), 'idle keeps a pending screensaver on its deadline when shell.json changes mid-cycle' ) +assert( + /onIdleTimersEnabledChanged: if \(!idleTimersEnabled\) cancelIdleCycle\(/.test(serviceSource), + 'idle ends a running cycle when both timeouts are set to 0' +) assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data') assertDeepEqual(