Files
Vladimir Kostelei c6c40dbced fix: fingerprint lid open (#3448)
* lock: keep the fingerprint reader usable across suspend

The reader stopped responding after the lid was closed and opened, after a long
idle period, and after suspend.

The shell held a PAM session for as long as the screen was locked, so a suspend
always landed on a running verify. fprintd then suspends the device under the
verify, pam_fprintd's ReleaseDevice fails with "device is still busy", and the
claim is never released, so every later Claim fails until fprintd restarts. The
reader is now released when the monitors blank and armed again when they come
back, so a suspend has nothing left to wedge.

The PAM profile drops to max-tries=1 and sets an explicit timeout. pam_fprintd
only counts a wrong finger, so one finished session is now one wrong scan and
maxFprintTries means the number of scans it names rather than five times that.
A timeout expiry arrives as PamResult.Error, exactly like a device fault, so a
session that ran at least as long as the configured timeout is treated as a
planned renewal instead of spending the error budget and painting an error.

A suspend that still lands while the screen is on wedges as before. Recovery now
waits out fprintd's idle timeout instead of climbing to it, which brings the
reader back about 37 seconds after resume instead of about 98.

SessionService gains lidOpened from UPower, because logind can ignore the lid on
AC and resume alone is not enough. The lock PAM state machine is fixed alongside:
PamResult.Failed was dropped, a pending retry outlived the contexts that owned
it, and an availability check reset the wrong-scan counter.

* lock: time the fingerprint session from the verify, not the claim

pam_fprintd's timeout runs from the verify, but the session age was measured
from start(), which folds the claim into it. A slow claim plus a verify that
failed before its own timeout could add up past the timeout and be taken for a
planned renewal, skipping the backoff. A session that never reached a verify had
the same problem.

The age now starts at the message pam_fprintd sends when the verify begins, so a
session that never verified can never qualify as an expiry, and a slow claim no
longer ages a fault. The message is translated and quickshell clears it before
completed, so only its timing is used, never its text.

That message arrives after pam_fprintd has already armed its deadline, because
VerifyStart is asynchronous, so the age read here runs short of the real timeout
and a slack makes it up. The bias is deliberate. A fault misread as a timeout
costs one extra retry, while a timeout misread as a fault parks the reader behind
a backoff that grows to a minute, which on an idle lock screen would leave it
unusable most of the time.

Also corrects the comment that claimed a timeout is reported without a message.

---------

(cherry picked from commit d7074a53eb)
2026-10-07 13:05:09 -04:00

107 lines
3.8 KiB
Go

package qmlchecks
import (
"os"
"regexp"
"strconv"
"strings"
"testing"
)
func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Lock/LockScreenContent.qml")
if err != nil {
t.Fatalf("read lock screen QML: %v", err)
}
content := string(data)
textInputPasswordField := regexp.MustCompile(`(?s)TextInput\s*\{[^{}]*id:\s*passwordField`)
if textInputPasswordField.MatchString(content) {
t.Fatalf("passwordField must not be a TextInput because TextInput can route physical keyboard input through IME")
}
if !strings.Contains(content, "Keys.onPressed") || !strings.Contains(content, "event.text") {
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
}
// Wayland IMEs commit unconsumed printable keys as text-input text rather
// than forwarding raw keys, so the lock screen needs an IME commit sink
// alongside raw key handling.
if !strings.Contains(content, "id: imeCommitSink") {
t.Fatalf("passwordField must keep the imeCommitSink TextInput so IME-routed keyboards can type (#2950)")
}
if !strings.Contains(content, "Qt.ImhSensitiveData") {
t.Fatalf("imeCommitSink must advertise hidden-text hints so IMEs treat it as a password field")
}
}
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
if err != nil {
t.Fatalf("read lock screen PAM QML: %v", err)
}
content := string(data)
for _, required := range []string{
"SettingsData.lockPamExternallyManaged",
"SettingsData.lockU2fPamPath",
"customU2fPamActive",
"u2fSuppressedByPrimaryPam",
} {
if !strings.Contains(content, required) {
t.Fatalf("lock screen PAM must contain %q", required)
}
}
if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") {
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
}
}
func TestLockScreenFprintProfileMatchesPamQml(t *testing.T) {
profile, err := os.ReadFile("../../../quickshell/assets/pam/fprint")
if err != nil {
t.Fatalf("read fprint PAM profile: %v", err)
}
qml, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
if err != nil {
t.Fatalf("read lock screen PAM QML: %v", err)
}
// Pam.qml tells a pam_fprintd timeout expiry apart from a device fault by
// how long the session ran, so a profile change that is not mirrored in
// sessionTimeoutMs makes real faults look like planned renewals.
profileTimeout := regexp.MustCompile(`pam_fprintd\.so[^\n]*\btimeout=(\d+)`).FindSubmatch(profile)
if profileTimeout == nil {
t.Fatalf("fprint profile must set an explicit pam_fprintd timeout")
}
qmlTimeout := regexp.MustCompile(`sessionTimeoutMs:\s*(\d+)`).FindSubmatch(qml)
if qmlTimeout == nil {
t.Fatalf("Pam.qml must declare sessionTimeoutMs")
}
seconds, err := strconv.Atoi(string(profileTimeout[1]))
if err != nil {
t.Fatalf("parse profile timeout: %v", err)
}
millis, err := strconv.Atoi(string(qmlTimeout[1]))
if err != nil {
t.Fatalf("parse sessionTimeoutMs: %v", err)
}
if seconds*1000 != millis {
t.Fatalf("sessionTimeoutMs is %dms but the fprint profile sets timeout=%d", millis, seconds)
}
// fprintd 1.94.0 and older ignore this option when it is longer than two
// digits and silently fall back to 30 seconds.
if len(profileTimeout[1]) > 2 {
t.Fatalf("pam_fprintd timeout=%s is too long for fprintd <= 1.94.0, which would ignore it", profileTimeout[1])
}
// One finished session must equal one wrong finger, otherwise
// SettingsData.maxFprintTries silently means a multiple of the scans it names.
if !regexp.MustCompile(`pam_fprintd\.so[^\n]*\bmax-tries=1\b`).Match(profile) {
t.Fatalf("fprint profile must use max-tries=1 so the shell counts wrong scans one for one")
}
}