Merge pull request #12538 from paulogeyer/fix/10860-idle-timeout-zero

Treat idle timeout 0 as disabled, not immediate
This commit is contained in:
David Heinemeier Hansson authored and GitHub committed 2026-10-04 07:44:44 -04:00
commit 7901d7d0b6
3 files changed
+97 -14

No files matched your search

+22
View File
@@ -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
}
+30 -14
View File
@@ -21,9 +21,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"
@@ -92,11 +95,23 @@ Item {
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
if (root.screensaverDelaySeconds === 0) launchScreensaver()
else screensaverTimer.restart()
// 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.interval = root.screensaverDelaySeconds * 1000
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.interval = root.lockDelaySeconds * 1000
lockTimer.restart()
}
}
}
function cancelIdleCycle(reason) {
@@ -173,7 +188,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()
@@ -249,26 +264,27 @@ 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
timeout: root.firstIdleTimeoutSeconds
enabled: root.idleEnabled && root.idleTimersEnabled
timeout: Math.max(1, root.firstIdleTimeoutSeconds)
respectInhibitors: true
onIsIdleChanged: root.handleIdleChanged()
}
Timer {
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 {
+45
View File
@@ -5,11 +5,56 @@ 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'
)
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'
)
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'
)
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(