From 277ea6f6b4ad5555ddca88c8fec92098c684991a Mon Sep 17 00:00:00 2001 From: Nicolas Nistal Date: Fri, 11 Sep 2026 00:56:12 -0400 Subject: [PATCH] Keep the lock screen's fingerprint idle while the display is blanked Nobody can touch the sensor once the lock has blanked the display, so abort the fingerprint PAM conversation and stop the retry timer until the display comes back. A reader that fails the instant it is asked now backs off to 30s instead of being re-armed at the base interval. Co-Authored-By: Claude Fable 5.1 --- shell/plugins/lock/Service.qml | 78 ++++++++++++-- test/shell.d/lock-blank-fingerprint-test.sh | 106 ++++++++++++++++++++ 2 files changed, 174 insertions(+), 10 deletions(-) diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 94d43b68ebd..420dbeb69fe 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -22,6 +22,24 @@ Item { property bool fingerprintAuthenticating: false property bool passwordPamConfigured: false property bool fingerprintConfigured: false + // Nobody can touch the sensor while the display is blanked, so the + // fingerprint PAM is aborted for that stretch instead of being retried + // every timeout until the display comes back. + property bool fingerprintSuspended: false + // A reader that fails the moment it is asked -- a backend in a bad way + // rather than a finger that did not match -- would otherwise be asked again + // every retry for the whole lock. Back those attempts off, and forget the + // streak as soon as one lasts long enough to have been a real touch or the + // user turns up at the display. + readonly property int fingerprintRetryBase: 250 + readonly property int fingerprintRetryFloor: 2000 + readonly property int fingerprintRetryMax: 30000 + readonly property int fingerprintInstantFailure: 1000 + readonly property int fingerprintRetryDelay: fingerprintFailureStreak === 0 + ? fingerprintRetryBase + : Math.min(fingerprintRetryMax, fingerprintRetryFloor * Math.pow(2, fingerprintFailureStreak - 1)) + property int fingerprintFailureStreak: 0 + property double fingerprintStartedAt: 0 property bool previewVisible: false property string enteredPassword: "" property string pendingPassword: "" @@ -130,6 +148,9 @@ Item { failedAttempts = 0 authenticatingPassword = false fingerprintAuthenticating = false + fingerprintSuspended = false + fingerprintFailureStreak = 0 + fingerprintStartedAt = 0 fingerprintRetryTimer.stop() if (passwordPam.active) passwordPam.abort() if (fingerprintPam.active) fingerprintPam.abort() @@ -179,12 +200,50 @@ Item { root.monitorDpmsKnown = false if (!wakeProcess.running) wakeProcess.running = true if (lockRequested) armBlankTimer() + resumeFingerprint() } function runBlank() { root.displaysBlank = true root.monitorDpmsKnown = false if (!blankProcess.running) blankProcess.running = true + suspendFingerprint() + } + + function suspendFingerprint() { + if (fingerprintSuspended) return + + fingerprintSuspended = true + fingerprintRetryTimer.stop() + if (fingerprintPam.active) fingerprintPam.abort() + fingerprintAuthenticating = false + } + + function resumeFingerprint() { + resetFingerprintBackoff() + if (!fingerprintSuspended) return + + fingerprintSuspended = false + if (lockRequested && fingerprintConfigured) startFingerprint() + } + + function resetFingerprintBackoff() { + if (fingerprintFailureStreak === 0) return + + fingerprintFailureStreak = 0 + if (fingerprintRetryTimer.running) fingerprintRetryTimer.restart() + } + + function scheduleFingerprintRetry() { + if (!lockRequested || !fingerprintConfigured || fingerprintSuspended) return + + // An attempt no finger had time to reach is the reader failing, not the + // user missing; only those compound. + var attempt = fingerprintStartedAt > 0 ? Date.now() - fingerprintStartedAt : 0 + if (attempt >= fingerprintInstantFailure) fingerprintFailureStreak = 0 + else if (fingerprintFailureStreak < 16) fingerprintFailureStreak += 1 + + fingerprintRetryTimer.restart() } function screenBlank(screenName) { @@ -245,10 +304,11 @@ Item { } function startFingerprint() { - if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return + if (fingerprintSuspended || !lockRequested || !sessionLock.secure || !fingerprintConfigured) return if (fingerprintPam.active || fingerprintAuthenticating) return fingerprintAuthenticating = true + fingerprintStartedAt = Date.now() if (!fingerprintPam.start()) { fingerprintAuthenticating = false } @@ -258,11 +318,8 @@ Item { fingerprintAuthenticating = false if (!lockRequested) return - if (result === PamResult.Success) { - finishUnlock() - } else if (fingerprintConfigured) { - fingerprintRetryTimer.restart() - } + if (result === PamResult.Success) finishUnlock() + else scheduleFingerprintRetry() } WlSessionLock { @@ -390,13 +447,13 @@ Item { onError: function(error) { root.fingerprintAuthenticating = false - if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart() + root.scheduleFingerprintRetry() } } Timer { id: fingerprintRetryTimer - interval: 250 + interval: root.fingerprintRetryDelay repeat: false onTriggered: root.startFingerprint() } @@ -491,8 +548,8 @@ Item { return } // Only a password check in flight should hold the display up. The - // fingerprint PAM stays armed for the whole lock, so gating on - // `authenticating` here would keep the panel lit until unlock. + // fingerprint PAM stays armed for the whole time the display is up, so + // gating on `authenticating` here would keep the panel lit until unlock. if (root.lockRequested && !root.authenticatingPassword) root.runBlank() } } @@ -537,6 +594,7 @@ Item { // for, so the blank state has to be given up here or a visible lock // wallpaper stays frozen until the next keypress. root.displaysBlank = false + root.resumeFingerprint() root.requestSessionLock() // A monitor still coming up has no workspace, so cannot answer yet. diff --git a/test/shell.d/lock-blank-fingerprint-test.sh b/test/shell.d/lock-blank-fingerprint-test.sh index 1c9cd9d6e8b..52e9d24ad67 100755 --- a/test/shell.d/lock-blank-fingerprint-test.sh +++ b/test/shell.d/lock-blank-fingerprint-test.sh @@ -30,4 +30,110 @@ assert( !/onAuthenticatingChanged:/.test(serviceQml), 'the combined authenticating state no longer drives the blank timer' ) + +// Nobody can touch the sensor once the panel is dark, so the reader is not +// polled through the blank: the PAM conversation is dropped and the retry +// timer stopped until the display comes back. +assert( + /function runBlank\(\) \{[\s\S]*?suspendFingerprint\(\)[\s\S]*?\n \}/.test(serviceQml), + 'blanking the display suspends the fingerprint' +) + +assert( + /function suspendFingerprint\(\) \{[\s\S]*?fingerprintRetryTimer\.stop\(\)[\s\S]*?if \(fingerprintPam\.active\) fingerprintPam\.abort\(\)/.test(serviceQml), + 'suspending stops the retry timer and aborts the fingerprint PAM' +) + +assert( + /startFingerprint\(\) \{\s*if \(fingerprintSuspended \|\|/.test(serviceQml), + 'the fingerprint prompt does not start again while suspended' +) + +assert( + /function scheduleFingerprintRetry\(\) \{\s*if \([^)]*fingerprintSuspended\) return/.test(serviceQml), + 'an aborted conversation does not re-arm the retry timer while suspended' +) + +// The display coming back is the only thing that brings the reader back, and +// it must survive a wake that is itself the first keystroke of a password. +assert( + /function runWake\(\) \{[\s\S]*?resumeFingerprint\(\)[\s\S]*?\n \}/.test(serviceQml), + 'waking the display resumes the fingerprint' +) + +assert( + /function resumeFingerprint\(\) \{[\s\S]*?if \(lockRequested && fingerprintConfigured\) startFingerprint\(\)/.test(serviceQml), + 'resuming only re-arms a lock that still wants a fingerprint' +) + +const fingerprintToggles = serviceQml.match(/function (?:suspend|resume)Fingerprint\(\) \{[\s\S]*?\n \}/g) || [] + +assert( + fingerprintToggles.length === 2, + 'the display drives the fingerprint through one suspend and one resume' +) + +assert( + fingerprintToggles.every(body => !/[Pp]assword/.test(body)), + 'suspending and resuming never touch a password in flight' +) + +assert( + /resetAuthenticationState\(\) \{[\s\S]*?fingerprintSuspended = false/.test(serviceQml), + 'a fresh lock starts with the fingerprint unsuspended' +) + +// A reader that errors the instant it is asked answers faster than a finger +// can arrive, so retrying it at the base interval is a hot loop for as long as +// the lock is up. Only those attempts compound, and the display waking clears +// the streak so a recovered reader is live again straight away. +assert( + /else scheduleFingerprintRetry\(\)/.test(serviceQml) && + /onError: function\(error\) \{[\s\S]*?root\.scheduleFingerprintRetry\(\)/.test(serviceQml), + 'every unsuccessful fingerprint conversation reschedules through the backoff' +) + +assert( + !/fingerprintRetryTimer\.restart\(\)/.test(serviceQml.replace(/function (?:reset|schedule)Fingerprint\w+\(\) \{[\s\S]*?\n \}/g, '')), + 'nothing re-arms the retry timer behind the backoff' +) + +assert( + /interval: root\.fingerprintRetryDelay/.test(serviceQml), + 'the retry timer takes its interval from the backoff' +) + +const retryDelay = serviceQml.match(/readonly property int fingerprintRetryDelay:[\s\S]*?\n property/) +assert(retryDelay, 'the retry delay is derived from the failure streak') +assert( + /fingerprintFailureStreak === 0\s*\?\s*fingerprintRetryBase/.test(retryDelay[0]), + 'a fingerprint that took a real touch to fail is retried at the base interval' +) +assert( + /Math\.min\(fingerprintRetryMax, fingerprintRetryFloor \* Math\.pow\(2, fingerprintFailureStreak - 1\)\)/.test(retryDelay[0]), + 'instant failures back off exponentially up to the cap' +) + +const backoff = {} +for (const [, name, value] of serviceQml.matchAll(/readonly property int (fingerprintRetry\w+|fingerprintInstantFailure): (\d+)/g)) { + backoff[name] = Number(value) +} + +assert(backoff.fingerprintRetryBase === 250, 'the base retry interval is unchanged') +assert(backoff.fingerprintRetryFloor >= 2000, 'a failing reader is left alone for at least a couple of seconds') +assert(backoff.fingerprintRetryMax === 30000, 'the backoff is capped at 30s') +assert( + backoff.fingerprintInstantFailure > 0 && backoff.fingerprintInstantFailure <= backoff.fingerprintRetryFloor, + 'an attempt is only instant if it ended sooner than a finger could reach the reader' +) + +assert( + /function resumeFingerprint\(\) \{\s*resetFingerprintBackoff\(\)/.test(serviceQml), + 'the display waking clears the backoff even when the fingerprint was never suspended' +) + +assert( + /resetAuthenticationState\(\) \{[\s\S]*?fingerprintFailureStreak = 0/.test(serviceQml), + 'a fresh lock starts with no backoff' +) JS