Merge branch 'fingerprint-lid-aware' into quattro

This commit is contained in:
David Heinemeier Hansson
2026-07-23 13:52:31 -07:00
12 changed files with 360 additions and 50 deletions
+25 -2
View File
@@ -24,6 +24,9 @@ Item {
readonly property int fieldFontSize: Math.round(Style.font.heading * 1.125)
readonly property int passwordDotFontSize: Math.round(Style.font.heading * 1.33)
readonly property int passwordDotLetterSpacing: Math.round(Style.font.heading * 0.19)
// Space to keep clear on each side of the field for the fingerprint icon
// (icon width plus a gap) so the centered dots never run under it.
readonly property real fingerprintReserve: fingerprintConfigured ? Math.round(fingerprintIcon.implicitWidth + 12) : 0
// Shrink the dots to fit once the password outgrows the field, so every
// keystroke stays visible — otherwise long passwords clip with no feedback.
readonly property real passwordDotScale: dotMetrics.advanceWidth > 0
@@ -130,9 +133,11 @@ Item {
id: passwordInput
anchors.fill: parent
anchors.topMargin: inputField.borderTop
anchors.rightMargin: inputField.borderRight + 18
// Reserve the fingerprint icon's width on both sides so the centered
// dots stay symmetric and never slide under the icon as they grow.
anchors.rightMargin: inputField.borderRight + 18 + root.fingerprintReserve
anchors.bottomMargin: inputField.borderBottom
anchors.leftMargin: inputField.borderLeft + 18
anchors.leftMargin: inputField.borderLeft + 18 + root.fingerprintReserve
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
activeFocusOnPress: true
@@ -190,6 +195,24 @@ Item {
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Fingerprint hint pinned inside the field's right edge when a sensor is
// enrolled, so the user knows they can touch to unlock instead of typing.
// Matches hyprlock, which draws its fingerprint icon in the same spot.
Text {
id: fingerprintIcon
objectName: "fingerprintIndicator"
anchors.right: parent.right
anchors.rightMargin: inputField.borderRight + 18
anchors.verticalCenter: parent.verticalCenter
visible: root.fingerprintConfigured
text: "󰈷"
color: Color.lock.placeholder
font.family: Style.font.family
font.pixelSize: Math.round(root.fieldFontSize * 1.1)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
+30 -7
View File
@@ -22,6 +22,9 @@ Item {
property bool fingerprintAuthenticating: false
property bool passwordPamConfigured: false
property bool fingerprintConfigured: false
// Lid shut → the reader is unreachable, so hide the affordance and don't
// bother scanning; fall back to the password like the polkit dialog does.
property bool laptopClosed: false
property bool previewVisible: false
property string enteredPassword: ""
property string pendingPassword: ""
@@ -34,6 +37,8 @@ Item {
readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure
readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating
// Fingerprint is only offered when a sensor is enrolled and the lid is open.
readonly property bool fingerprintAvailable: fingerprintConfigured && !laptopClosed
function realScreenCount() {
var screens = Quickshell.screens || []
@@ -82,6 +87,10 @@ Item {
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
}
function refreshLaptopClosed() {
if (!laptopClosedProc.running) laptopClosedProc.running = true
}
function logEvent(event) {
lastEvent = event
lastEventAt = new Date().toISOString()
@@ -115,6 +124,7 @@ Item {
Qt.callLater(function() {
root.refreshBackground()
root.refreshFingerprintStatus()
root.refreshLaptopClosed()
})
return true
@@ -182,7 +192,7 @@ Item {
}
function startFingerprint() {
if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return
if (!lockRequested || !sessionLock.secure || !fingerprintAvailable) return
if (fingerprintPam.active || fingerprintAuthenticating) return
fingerprintAuthenticating = true
@@ -197,7 +207,7 @@ Item {
if (!lockRequested) return
if (result === PamResult.Success) {
finishUnlock()
} else if (fingerprintConfigured) {
} else if (fingerprintAvailable) {
fingerprintRetryTimer.restart()
}
}
@@ -245,7 +255,7 @@ Item {
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
fingerprintConfigured: root.fingerprintAvailable
authenticatingPassword: root.authenticatingPassword
failureMessage: root.failureMessage
failedAttempts: root.failedAttempts
@@ -275,7 +285,7 @@ Item {
anchors.fill: parent
backgroundPath: root.backgroundPath
backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured
fingerprintConfigured: root.fingerprintAvailable
authenticatingPassword: false
failureMessage: ""
failedAttempts: 0
@@ -324,7 +334,7 @@ Item {
onError: function(error) {
root.fingerprintAuthenticating = false
if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart()
if (root.lockRequested && root.fingerprintAvailable) fingerprintRetryTimer.restart()
}
}
@@ -356,8 +366,19 @@ Item {
stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true }
onExited: {
root.fingerprintConfigured = String(fingerprintCheckStdout.text || "").trim() === "yes"
if (root.lockRequested && root.fingerprintConfigured) root.startFingerprint()
else if (!root.fingerprintConfigured && fingerprintPam.active) fingerprintPam.abort()
if (root.lockRequested && root.fingerprintAvailable) root.startFingerprint()
else if (!root.fingerprintAvailable && fingerprintPam.active) fingerprintPam.abort()
}
}
Process {
id: laptopClosedProc
command: ["bash", "-c", "omarchy-hw-laptop-closed && echo closed || echo open"]
stdout: StdioCollector { id: laptopClosedStdout; waitForEnd: true }
onExited: {
root.laptopClosed = String(laptopClosedStdout.text || "").trim() === "closed"
if (root.lockRequested && root.fingerprintAvailable) root.startFingerprint()
else if (!root.fingerprintAvailable && fingerprintPam.active) fingerprintPam.abort()
}
}
@@ -425,6 +446,7 @@ Item {
Component.onCompleted: {
refreshBackground()
refreshFingerprintStatus()
refreshLaptopClosed()
}
IpcHandler {
@@ -450,6 +472,7 @@ Item {
realScreens: root.realScreenCount(),
passwordPam: root.passwordPamConfigured,
fingerprint: root.fingerprintConfigured,
laptopClosed: root.laptopClosed,
authenticating: root.authenticating,
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt
+46 -13
View File
@@ -32,24 +32,34 @@ Item {
property bool responseVisible: false
property bool failed: false
property bool errorFlash: false
property bool fingerprintFirst: false
// pam_fprintd appears in the polkit PAM stack (a sensor is enrolled).
property bool fingerprintConfigured: false
// Lid shut right now — the reader is physically unreachable, so we fall back
// to the password even when a sensor is enrolled. Refreshed per request.
property bool laptopClosed: false
property int shakeOffset: 0
readonly property bool dialogVisible: polkitAgent.isActive || closing
readonly property bool fingerprintWaiting: dialogVisible && !responseRequired && !submitted && (fingerprintFirst || promptLooksFingerprint(currentPrompt + " " + currentSupplementary))
readonly property int cardWidth: Math.min(Style.space(312), Math.max(Style.space(260), panel.width - Style.gapsOut * 2))
// We show one method at a time. Fingerprint owns the dialog while PAM is
// waiting on the reader (lid open, sensor enrolled); the moment PAM asks for
// a password — including immediately when the lid is shut and the clamshell
// gate skips pam_fprintd — we switch to the password field instead.
readonly property bool fingerprintMode: fingerprintConfigured && !laptopClosed && dialogVisible && !responseRequired && !submitted && !errorFlash
readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - Style.gapsOut * 2) : fieldHeight + contentMargin * 2
function promptLooksFingerprint(text) {
return PolkitModel.promptLooksFingerprint(text)
}
// Password mode is a wide field; fingerprint mode collapses to a square that
// just frames the centered sensor icon.
readonly property int cardWidth: fingerprintMode ? cardHeight : Math.min(Style.space(312), Math.max(Style.space(260), panel.width - Style.gapsOut * 2))
function authorizationLabel(message) {
return PolkitModel.authorizationLabel(message)
}
function loadPamConfig(raw) {
fingerprintFirst = PolkitModel.fingerprintFirstFromPamConfig(raw)
fingerprintConfigured = PolkitModel.fingerprintConfiguredFromPamConfig(raw)
}
function refreshLidState() {
if (!laptopClosedProc.running) laptopClosedProc.running = true
}
function resetSnapshot() {
@@ -83,13 +93,16 @@ Item {
closing = false
submitted = false
passwordInput.text = ""
refreshLidState()
syncFromFlow()
Qt.callLater(refocus)
}
function refocus() {
if (!dialogVisible) return
if (fingerprintWaiting) keyCatcher.forceActiveFocus()
// In fingerprint mode there is no field to type into — park focus on the
// key catcher so Escape still cancels; otherwise focus the password field.
if (fingerprintMode) keyCatcher.forceActiveFocus()
else passwordInput.forceActiveFocus()
}
@@ -149,10 +162,17 @@ Item {
watchChanges: true
printErrors: false
onLoaded: root.loadPamConfig(text())
onLoadFailed: root.fingerprintFirst = false
onLoadFailed: root.fingerprintConfigured = false
onFileChanged: reload()
}
Process {
id: laptopClosedProc
command: ["bash", "-c", "omarchy-hw-laptop-closed && echo closed || echo open"]
stdout: StdioCollector { id: laptopClosedOut; waitForEnd: true }
onExited: root.laptopClosed = String(laptopClosedOut.text || "").trim() === "closed"
}
PolkitAgent {
id: polkitAgent
path: "/org/omarchy/PolkitAgent"
@@ -248,8 +268,22 @@ Item {
}
}
// Fingerprint mode shows just the sensor icon, centered and alone \u2014 no
// padlock, no field, no prompt text.
OpticalGlyph {
anchors.centerIn: parent
width: Math.round(root.fieldHeight * 0.7)
height: width
visible: root.fingerprintMode
text: "\udb80\ude37"
fontFamily: root.fontFamily
fontSize: Math.round(root.fieldHeight * 0.7)
color: root.errorFlash ? Color.polkit.textError : root.accent
}
Row {
id: cardRow
visible: !root.fingerprintMode
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
@@ -287,8 +321,7 @@ Item {
color: root.errorFlash ? Color.polkit.textError : root.foreground
cursorVisible: activeFocus && !root.submitted && !root.errorFlash
readOnly: root.submitted || root.errorFlash
enabled: root.dialogVisible && !root.fingerprintWaiting
visible: !root.fingerprintWaiting
enabled: root.dialogVisible
onAccepted: root.submitResponse()
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
@@ -308,7 +341,7 @@ Item {
font.family: root.fontFamily
font.pixelSize: Style.font.iconLarge
elide: Text.ElideRight
visible: passwordInput.visible && passwordInput.text.length === 0
visible: passwordInput.text.length === 0
}
Rectangle {
+6 -3
View File
@@ -3,13 +3,16 @@ function promptLooksFingerprint(text) {
return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1
}
function fingerprintFirstFromPamConfig(raw) {
function fingerprintConfiguredFromPamConfig(raw) {
// Fingerprint is available whenever pam_fprintd appears anywhere in the auth
// stack — it need not be the first module. A clamshell gate (pam_exec) may
// legitimately precede it to skip fingerprint while the lid is closed.
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (!line || line.charAt(0) === "#") continue
if (!line.match(/^auth\s+/)) continue
return line.indexOf("pam_fprintd.so") !== -1
if (line.indexOf("pam_fprintd.so") !== -1) return true
}
return false
}
@@ -23,7 +26,7 @@ function authorizationLabel(message) {
if (typeof module !== "undefined") {
module.exports = {
promptLooksFingerprint: promptLooksFingerprint,
fingerprintFirstFromPamConfig: fingerprintFirstFromPamConfig,
fingerprintConfiguredFromPamConfig: fingerprintConfiguredFromPamConfig,
authorizationLabel: authorizationLabel
}
}