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) 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) { function eventParts(event, count) {
try { try {
if (event && event.parse) return event.parse(count) if (event && event.parse) return event.parse(count)
@@ -46,6 +66,8 @@ function screensaverWindowsAfter(windows, address, visible) {
if (typeof module !== "undefined") { if (typeof module !== "undefined") {
module.exports = { module.exports = {
secondsFromConfig: secondsFromConfig, secondsFromConfig: secondsFromConfig,
firstIdleTimeout: firstIdleTimeout,
delayAfterFirstIdle: delayAfterFirstIdle,
eventParts: eventParts, eventParts: eventParts,
screensaverWindowsAfter: screensaverWindowsAfter screensaverWindowsAfter: screensaverWindowsAfter
} }
+30 -14
View File
@@ -21,9 +21,12 @@ Item {
? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({})) ? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({}))
readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds) readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds)
readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds) readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds)
readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds) readonly property bool screensaverEnabled: screensaverTimeoutSeconds > 0
readonly property int screensaverDelaySeconds: Math.max(0, screensaverTimeoutSeconds - firstIdleTimeoutSeconds) readonly property bool lockEnabled: lockTimeoutSeconds > 0
readonly property int lockDelaySeconds: Math.max(0, lockTimeoutSeconds - firstIdleTimeoutSeconds) 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 bool idleEnabled: stayAwakeStateLoaded && !stayAwake
readonly property string screensaverClass: "org.omarchy.screensaver" readonly property string screensaverClass: "org.omarchy.screensaver"
@@ -92,11 +95,23 @@ Item {
root.screensaverStartedThisCycle = false root.screensaverStartedThisCycle = false
resetScreensaverWindows() resetScreensaverWindows()
if (root.screensaverDelaySeconds === 0) launchScreensaver() // Set this cycle's deadlines once: a bound interval would restart a pending
else screensaverTimer.restart() // 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") if (root.lockEnabled) {
else lockTimer.restart() if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate")
else {
lockTimer.interval = root.lockDelaySeconds * 1000
lockTimer.restart()
}
}
} }
function cancelIdleCycle(reason) { function cancelIdleCycle(reason) {
@@ -173,7 +188,7 @@ Item {
function handleIdleChanged() { function handleIdleChanged() {
logEvent("idle-monitor", idleMonitor.isIdle ? "idle" : "active") logEvent("idle-monitor", idleMonitor.isIdle ? "idle" : "active")
if (!root.idleEnabled) return if (!root.idleEnabled || !root.idleTimersEnabled) return
if (idleMonitor.isIdle) startIdleCycle() if (idleMonitor.isIdle) startIdleCycle()
else handleActiveSignal() else handleActiveSignal()
@@ -249,26 +264,27 @@ Item {
return applyStayAwake(!value, true, "ipc") 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 { IdleMonitor {
id: idleMonitor id: idleMonitor
enabled: root.idleEnabled enabled: root.idleEnabled && root.idleTimersEnabled
timeout: root.firstIdleTimeoutSeconds timeout: Math.max(1, root.firstIdleTimeoutSeconds)
respectInhibitors: true respectInhibitors: true
onIsIdleChanged: root.handleIdleChanged() onIsIdleChanged: root.handleIdleChanged()
} }
Timer { Timer {
id: screensaverTimer id: screensaverTimer
interval: root.screensaverDelaySeconds * 1000
repeat: false repeat: false
onTriggered: root.launchScreensaver() onTriggered: if (root.screensaverEnabled) root.launchScreensaver()
} }
Timer { Timer {
id: lockTimer id: lockTimer
interval: root.lockDelaySeconds * 1000
repeat: false 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 { Timer {
+45
View File
@@ -5,11 +5,56 @@ set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS' run_node_test <<'JS'
const fs = require('fs')
const idle = requireFromRoot('shell/plugins/services/idle/IdleModel.js') 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('42.9', 10), 42, 'idle floors configured seconds')
assertEqual(idle.secondsFromConfig('-1', 10), 10, 'idle rejects negative seconds') assertEqual(idle.secondsFromConfig('-1', 10), 10, 'idle rejects negative seconds')
assertEqual(idle.secondsFromConfig('nope', 10), 10, 'idle rejects invalid 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(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data')
assertDeepEqual( assertDeepEqual(