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 {