Show fingerprint on lock screen and polkit, gated by lid state

Bring the fingerprint affordance to the Quickshell lock screen and polkit
dialog, matching what hyprlock did on master.

Lock screen: render the md-fingerprint glyph inside the password field's
right edge when a sensor is enrolled, reserving space so long passwords
never run under it.

Polkit dialog: show one method at a time. When a sensor is enrolled and
the reader is reachable, the dialog is just the centered fingerprint icon
(square card); the moment PAM asks for a password it switches to the
password field. Detects pam_fprintd anywhere in the auth stack now that a
gate can precede it.

Lid awareness: a closed lid means the reader is unreachable, so both
surfaces fall back to the password. polkit gets a pam_exec clamshell gate
(auth [success=1 default=ignore] before pam_fprintd) so a shut lid drops
straight to the password prompt instead of blocking on the reader for the
pam_fprintd timeout; the lock screen hides the icon and skips scanning.

The gate points at the fixed /usr/bin path the package always provides so
it survives switching between package installs and dev-link. A migration
adds the gate for existing fingerprint setups.

New helper omarchy-hw-laptop-closed (pure lid state); omarchy-hw-clamshell
now composes it with the external-monitor check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-23 13:51:07 -07:00
co-authored by Claude Opus 4.8
parent 39ca9135c4
commit 540e411edf
12 changed files with 360 additions and 50 deletions
@@ -0,0 +1,108 @@
import QtQuick
import Quickshell
import qs.Commons
ShellRoot {
id: root
readonly property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT")
readonly property string rootPath: Quickshell.env("OMARCHY_PATH")
property var failures: []
function fail(message) {
failures.push(String(message))
}
function assertTrue(condition, message) {
if (!condition) fail(message)
}
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
function writeResult() {
var payload = JSON.stringify({
ok: failures.length === 0,
failures: failures
})
if (resultPath) {
Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)])
}
}
Item { id: host; width: 800; height: 600 }
TextMetrics {
id: probe
font.family: Style.font.family
}
Timer {
interval: 1
running: true
repeat: false
onTriggered: {
try {
var component = Qt.createComponent("file://" + root.rootPath + "/shell/plugins/lock/LockView.qml", Component.PreferSynchronous)
if (component.status !== Component.Ready) {
root.fail("LockView failed to load: " + component.errorString())
return
}
var view = component.createObject(host, { width: 800, height: 600, loadBackground: false })
if (!view) {
root.fail("LockView failed to instantiate: " + component.errorString())
return
}
var indicator = view.children ? findByObjectName(view, "fingerprintIndicator") : null
root.assertTrue(indicator !== null, "fingerprint indicator exists in the lock view")
if (indicator) {
view.fingerprintConfigured = false
root.assertTrue(!indicator.visible, "fingerprint indicator is hidden when no sensor is configured")
view.fingerprintConfigured = true
root.assertTrue(indicator.visible, "fingerprint indicator is shown when a sensor is configured")
// The field reserves space for the icon so a long password can never
// slide underneath it. The reserve must exceed the icon's own width
// (leaving a gap), and the shrunk dots must fit the reserved-clear
// area even at extreme lengths.
root.assertTrue(view.fingerprintReserve > indicator.width,
"reserved space exceeds the icon width, got reserve " + view.fingerprintReserve + " vs icon " + indicator.width)
view.passwordText = "x".repeat(80)
var clearWidth = view.fieldWidth - 2 * view.fingerprintReserve
probe.font.pixelSize = Math.max(1, Math.floor(view.passwordDotFontSize * view.passwordDotScale))
probe.font.letterSpacing = view.passwordDotLetterSpacing * view.passwordDotScale
probe.text = "●".repeat(80)
root.assertTrue(probe.advanceWidth <= clearWidth,
"80 dots stay clear of the fingerprint icon, need " + probe.advanceWidth + "px of " + clearWidth)
view.fingerprintConfigured = false
root.assertTrue(view.fingerprintReserve === 0, "no space is reserved when no sensor is configured")
}
view.destroy()
} catch (error) {
root.fail("lock fingerprint indicator fixture threw: " + error)
} finally {
root.writeResult()
}
}
}
function findByObjectName(node, name) {
if (!node) return null
if (node.objectName === name) return node
var kids = node.children || []
for (var i = 0; i < kids.length; i++) {
var found = findByObjectName(kids[i], name)
if (found) return found
}
return null
}
}
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=""
QS_PID=""
cleanup() {
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
kill "$QS_PID" 2>/dev/null || true
wait "$QS_PID" 2>/dev/null || true
fi
[[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
if [[ -z ${WAYLAND_DISPLAY:-} ]]; then
pass "no Wayland compositor; skipping lock fingerprint indicator test"
exit 0
fi
if ! command -v quickshell >/dev/null 2>&1; then
pass "quickshell not installed; skipping lock fingerprint indicator test"
exit 0
fi
require_command jq
TMPDIR=$(mktemp -d)
result="$TMPDIR/result.json"
log="$TMPDIR/quickshell.log"
config_dir="$TMPDIR/lock-fingerprint-indicator"
mkdir -p "$config_dir" "$TMPDIR/home"
cp "$SHELL_TEST_DIR/fixtures/lock-fingerprint-indicator/shell.qml" "$config_dir/shell.qml"
ln -s "$ROOT/shell/Ui" "$config_dir/Ui"
ln -s "$ROOT/shell/Commons" "$config_dir/Commons"
OMARCHY_PATH="$ROOT" \
OMARCHY_QML_TEST_RESULT="$result" \
HOME="$TMPDIR/home" \
QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \
QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \
PATH="$ROOT/bin:$PATH" \
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
QS_PID=$!
for _ in {1..80}; do
[[ -s $result ]] && break
if ! kill -0 "$QS_PID" 2>/dev/null; then
sed -n '1,220p' "$log" >&2
fail "lock fingerprint indicator quickshell exited before writing result"
fi
sleep 0.1
done
[[ -s $result ]] || {
sed -n '1,220p' "$log" >&2
fail "lock fingerprint indicator test timed out"
}
if ! jq -e '.ok == true' "$result" >/dev/null; then
printf 'Lock fingerprint indicator result:\n' >&2
jq . "$result" >&2
printf 'Lock fingerprint indicator log:\n' >&2
sed -n '1,220p' "$log" >&2
fail "fingerprint indicator tracks the configured sensor"
fi
pass "fingerprint indicator tracks the configured sensor"
+13 -5
View File
@@ -23,19 +23,27 @@ assertEqual(
)
assert(
polkit.fingerprintFirstFromPamConfig(`
polkit.fingerprintConfiguredFromPamConfig(`
# comment
auth sufficient pam_fprintd.so
auth include system-auth
`),
'polkit detects fingerprint-first PAM config'
'polkit detects fingerprint in a PAM config'
)
assert(
!polkit.fingerprintFirstFromPamConfig(`
polkit.fingerprintConfiguredFromPamConfig(`
auth [success=1 default=ignore] pam_exec.so quiet /usr/bin/omarchy-hw-laptop-closed
auth sufficient pam_fprintd.so
auth required pam_unix.so
`),
'polkit detects fingerprint even behind a clamshell gate'
)
assert(
!polkit.fingerprintConfiguredFromPamConfig(`
account include system-auth
auth include system-auth
auth sufficient pam_fprintd.so
auth required pam_unix.so
`),
'polkit detects password-first PAM config'
'polkit reports no fingerprint when pam_fprintd is absent'
)
JS