From 10eb5f6ec3c4110dd27e76a6db975996db015187 Mon Sep 17 00:00:00 2001 From: hkoeck Date: Mon, 12 Oct 2026 17:16:18 +0200 Subject: [PATCH] lock: show the PAM prompt and track the conversation state (#3691) Pam.qml holds the prompt text PAM asked for and separates "a response is in flight" from "the conversation is open", so the UI stops treating an open conversation as busy. The lock screen shows the prompt instead of a hardcoded "Password", and clears the field when a new prompt arrives. --- quickshell/Modules/Lock/Pam.qml | 87 ++++++++++++++++++- .../Modules/Lock/Widgets/LockAuthWidget.qml | 40 +++++---- 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 22b598d30..b55d240c1 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -24,6 +24,15 @@ Scope { property string u2fPendingMode property string buffer + // the prompt pam asked for, e.g. "PIN: " + property string authPromptText: "" + property bool responseSubmitted: false + property bool awaitingUserInput: false + property bool responseInFlight: false + // the conversation is held open while waiting, so passwd.active is not "busy" + readonly property bool authBusy: passwd.active && root.responseInFlight + // stops a conversation that never prompts from being reopened forever + property int autoRestartCount: 0 property var attemptInfoMessages: [] property bool lockoutAnnouncedThisAttempt: false @@ -53,6 +62,37 @@ Scope { u2f.checkAvail(); } + // open the conversation without answering it, so the prompt is known before typing + function beginAuthSession(): void { + if (passwd.active || root.unlockInProgress || !root.lockSecured) + return; + if (root.autoRestartCount >= 3) + return; + root.autoRestartCount = root.autoRestartCount + 1; + root.responseSubmitted = false; + root.awaitingUserInput = false; + root.responseInFlight = false; + eagerStart.restart(); + } + + function submitPassword(): void { + root.responseSubmitted = true; + if (!passwd.active) { + passwd.start(); + return; + } + if (passwd.responseRequired) { + root.responseSubmitted = false; + root.awaitingUserInput = false; + root.responseInFlight = true; + passwdActiveTimeout.restart(); + passwd.respond(root.buffer); + } else { + // kept and sent when the prompt arrives; a stack that never asks still times out + passwdActiveTimeout.restart(); + } + } + function completeUnlock(): void { if (!root.unlockInProgress) { root.unlockInProgress = true; @@ -187,10 +227,15 @@ Scope { root.attemptInfoMessages = root.attemptInfoMessages.concat([message]); } - onResponseRequiredChanged: { + // not responseRequiredChanged: that only fires on change, so two prompts in + // a row leave the second unhandled + onPamMessage: { if (!responseRequired) return; + root.authPromptText = (message || "").replace(/:\s*$/, "").trim(); + root.autoRestartCount = 0; + const notice = root.attemptInfoMessages.filter(m => m !== message); if (notice.length > 0) { root.lockMessage = notice.join("\n"); @@ -198,7 +243,17 @@ Scope { } root.attemptInfoMessages = []; - respond(root.buffer); + // each prompt needs its own submission, or the second gets the first answer + if (root.responseSubmitted) { + root.responseSubmitted = false; + root.awaitingUserInput = false; + root.responseInFlight = true; + respond(root.buffer); + } else { + root.awaitingUserInput = true; + root.responseInFlight = false; + passwdActiveTimeout.running = false; + } } onCompleted: res => { @@ -236,6 +291,12 @@ Scope { else if (res === PamResult.Failed) root.state = "fail"; + root.responseSubmitted = false; + root.awaitingUserInput = false; + root.responseInFlight = false; + if (res !== PamResult.MaxTries) + root.beginAuthSession(); + root.flashMsg(); stateReset.restart(); } @@ -248,7 +309,11 @@ Scope { if (passwd.active) { root.attemptInfoMessages = []; root.lockoutAnnouncedThisAttempt = false; - passwdActiveTimeout.restart(); + root.authPromptText = ""; + if (root.responseSubmitted) + passwdActiveTimeout.restart(); + else + passwdActiveTimeout.running = false; } else { passwdActiveTimeout.running = false; } @@ -431,7 +496,7 @@ Scope { } function startForAlternativeAuth(): void { - if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active) + if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || root.authBusy || active) return; abort(); root.u2fPending = true; @@ -548,6 +613,16 @@ Scope { onTriggered: root.cancelU2fPending() } + Timer { + id: eagerStart + + interval: 50 + onTriggered: { + if (!passwd.active && root.lockSecured && !root.unlockInProgress) + passwd.start(); + } + } + Timer { id: passwdActiveTimeout @@ -602,6 +677,10 @@ Scope { root.attemptInfoMessages = []; root.lockoutAnnouncedThisAttempt = false; root.resetAuthFlows(); + root.responseSubmitted = false; + root.awaitingUserInput = false; + root.autoRestartCount = 0; + root.beginAuthSession(); fprint.tries = 0; fprint.errorTries = 0; if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded) diff --git a/quickshell/Modules/Lock/Widgets/LockAuthWidget.qml b/quickshell/Modules/Lock/Widgets/LockAuthWidget.qml index 23006e920..832a6afb7 100644 --- a/quickshell/Modules/Lock/Widgets/LockAuthWidget.qml +++ b/quickshell/Modules/Lock/Widgets/LockAuthWidget.qml @@ -30,7 +30,7 @@ Item { readonly property bool ring: style === "ring" readonly property bool morph: style === "expressive" readonly property bool minimal: style === "minimal" - readonly property bool authenticating: (pam?.passwd.active ?? false) && !unlocking + readonly property bool authenticating: (pam?.authBusy ?? false) && !unlocking readonly property bool failed: pamState !== "" readonly property var morphShapes: ["cookie4", "clover4", "sunny", "cookie9", "softBurst", "pentagon", "oval", "cookie6"] readonly property string morphShape: morphShapes[passwordBuffer.length % morphShapes.length] @@ -113,7 +113,7 @@ Item { } function canStartSecurityKeyUnlock() { - return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !unlocking; + return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.authBusy && !pam.u2f.active && !pam.u2fPending && !unlocking; } function triggerSecurityKeyUnlock() { @@ -161,6 +161,12 @@ Item { if (keyboardController.isKeyboardActive) keyboardController.hide(); } + + // onStateChanged only fires when the conversation ends, too late between prompts + function onAwaitingUserInputChanged() { + if (root.pam.awaitingUserInput) + passwordField.clear(); + } } ColumnLayout { @@ -534,7 +540,7 @@ Item { return root.accentColor; return root.contained ? Theme.surfaceVariantText : Theme.withAlpha(root.plainColor, Theme.pendingOpacity); } - opacity: root.pam?.passwd.active && !root.morph ? 0 : 1 + opacity: root.pam?.authBusy && !root.morph ? 0 : 1 Behavior on opacity { NumberAnimation { @@ -652,8 +658,8 @@ Item { activeFocusOnTab: !root.demoMode onTextChanged: cursorPosition = text.length onAccepted: { - if (!root.demoMode && !root.unlocking && !root.pam.passwd.active && !root.pam.u2fPending) - root.pam.passwd.start(); + if (!root.demoMode && !root.unlocking && !root.pam.authBusy && !root.pam.u2fPending) + root.pam.submitPassword(); } Keys.onPressed: event => handleKey(event) @@ -686,7 +692,7 @@ Item { return; } - if (root.pam.passwd.active) { + if (root.pam.authBusy) { root.log.debug("PAM is active, ignoring input"); event.accepted = true; return; @@ -809,7 +815,7 @@ Item { return; const committed = text; text = ""; - if (root.demoMode || root.unlocking || root.pam.passwd.active) + if (root.demoMode || root.unlocking || root.pam.authBusy) return; passwordField.insertText(committed); } @@ -848,14 +854,16 @@ Item { return I18n.tr("Insert your security key..."); return I18n.tr("Touch your security key..."); } - if (root.pam.passwd.active) + if (root.pam.authBusy) return I18n.tr("Authenticating...", "lock screen status text while the password is checked"); if (root.passwordVisibility !== "always") return ""; + if (root.pam.authPromptText !== "") + return root.pam.authPromptText; return I18n.tr("Password", "lock screen password field placeholder") + "…"; } color: { - if (root.unlocking || (root.pam?.passwd.active ?? false)) + if (root.unlocking || (root.pam?.authBusy ?? false)) return root.accentColor; return root.contained ? Theme.outline : Theme.withAlpha(root.plainColor, Theme.pendingOpacity); } @@ -969,7 +977,7 @@ Item { x: passwordDisplay.x + passwordDisplay.cursorRectangle.x y: passwordDisplay.y + passwordDisplay.cursorRectangle.y height: passwordDisplay.cursorRectangle.height - shown: !root.demoMode && root.showPasswordField && !root.ring && passwordField.activeFocus && !(root.pam?.passwd.active ?? false) && !(root.pam?.u2fPending ?? false) && !root.unlocking + shown: !root.demoMode && root.showPasswordField && !root.ring && passwordField.activeFocus && !(root.pam?.authBusy ?? false) && !(root.pam?.u2fPending ?? false) && !root.unlocking readonly property int fieldCursorPosition: passwordField.cursorPosition readonly property string fieldText: passwordField.text @@ -994,7 +1002,7 @@ Item { anchors.verticalCenter: parent.verticalCenter iconName: parent.showPassword ? "visibility_off" : "visibility" buttonSize: Theme.buttonHeightXS - visible: !root.demoMode && !root.ring && !root.minimal && root.passwordBuffer.length > 0 && !(root.pam?.passwd.active ?? false) && !root.unlocking + visible: !root.demoMode && !root.ring && !root.minimal && root.passwordBuffer.length > 0 && !(root.pam?.authBusy ?? false) && !root.unlocking enabled: visible onClicked: parent.showPassword = !parent.showPassword } @@ -1031,7 +1039,7 @@ Item { anchors.verticalCenter: parent.verticalCenter iconName: "keyboard" buttonSize: Theme.buttonHeightXS - visible: !root.demoMode && !root.ring && !root.minimal && !(root.pam?.passwd.active ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false) + visible: !root.demoMode && !root.ring && !root.minimal && !(root.pam?.authBusy ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false) enabled: visible && root.showPasswordField onClicked: { if (keyboardController.isKeyboardActive) @@ -1049,7 +1057,7 @@ Item { anchors.verticalCenter: parent.verticalCenter width: Theme.iconSize height: Theme.iconSize - visible: !root.demoMode && !root.ring && !root.morph && ((root.pam?.passwd.active ?? false) || root.unlocking) + visible: !root.demoMode && !root.ring && !root.morph && ((root.pam?.authBusy ?? false) || root.unlocking) DIcon { anchors.centerIn: parent @@ -1073,7 +1081,7 @@ Item { size: Theme.iconSize contained: root.style === "expressive" color: root.style === "expressive" ? Theme.onPrimaryContainer : root.accentColor - visible: (root.pam?.passwd.active ?? false) && !root.unlocking + visible: (root.pam?.authBusy ?? false) && !root.unlocking running: visible } } @@ -1089,11 +1097,11 @@ Item { anchors.verticalCenter: parent.verticalCenter iconName: "keyboard_return" buttonSize: Theme.buttonHeightXS - visible: !root.ring && !root.minimal && (root.demoMode || (!(root.pam?.passwd.active ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false))) + visible: !root.ring && !root.minimal && (root.demoMode || (!(root.pam?.authBusy ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false))) enabled: !root.demoMode && root.showPasswordField onClicked: { if (!root.demoMode && !root.unlocking && !root.pam.u2fPending) - root.pam.passwd.start(); + root.pam.submitPassword(); } Behavior on opacity {