lock: add fprint support (#429)

* lock: add fprint support

* lock: better fprint detection

* lock: cap error retries

* nix: fix fprint pam for nix

* lock: reset fprint tries

* lock: minor pam fixes

Delay fprint error retries
Reset fprint error retries on lock

* lock: loading indicator passwd state

Instead of fprint state cause no way of detecting that

* dashboard: better visualiser

* lock: better fprint availability check

* lock: better in/out anim

Animating layout sizes is a bad idea :woe:
Use scale instead

* lock: add better error/fail messages

* lock: less fprint icon states

Already shown by message

* lock: fix fprint reset

* lock: include passwd pam

* lock: flash message on change

* lock: fix message anim

Also wrap message instead of eliding

* lock: better messages for no fprint
This commit is contained in:
2 * r + 2 * t 2025-08-23 20:43:04 +10:00 committed by GitHub
parent f747d82b93
commit aea5795832
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 311 additions and 29 deletions

3
assets/pam.d/fprint Normal file
View file

@ -0,0 +1,3 @@
#%PAM-1.0
auth required pam_fprintd.so max-tries=1

6
assets/pam.d/passwd Normal file
View file

@ -0,0 +1,6 @@
#%PAM-1.0
auth required pam_faillock.so preauth
auth [success=1 default=bad] pam_unix.so nullok
auth [default=die] pam_faillock.so authfail
auth required pam_faillock.so authsucc

View file

@ -2,6 +2,8 @@ import Quickshell.Io
JsonObject {
property bool recolourLogo: false
property bool enableFprint: true
property int maxFprintTries: 3
property Sizes sizes: Sizes {}
component Sizes: JsonObject {

View file

@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import qs.components
import qs.components.controls
import qs.components.images
import qs.services
import qs.config
@ -17,6 +18,7 @@ ColumnLayout {
readonly property int centerWidth: Config.lock.sizes.centerWidth * centerScale
Layout.preferredWidth: centerWidth
Layout.fillWidth: false
Layout.fillHeight: true
spacing: Appearance.spacing.large * 2
@ -148,9 +150,34 @@ ColumnLayout {
anchors.margins: Appearance.padding.small
spacing: Appearance.spacing.normal
MaterialIcon {
Layout.leftMargin: Appearance.padding.smaller
text: "lock"
Item {
implicitWidth: implicitHeight
implicitHeight: fprintIcon.implicitHeight + Appearance.padding.small * 2
MaterialIcon {
id: fprintIcon
anchors.centerIn: parent
animate: true
text: {
if (root.lock.pam.fprint.tries >= Config.lock.maxFprintTries)
return "fingerprint_off";
if (root.lock.pam.fprint.active)
return "fingerprint";
return "lock";
}
color: root.lock.pam.fprint.tries >= Config.lock.maxFprintTries ? Colours.palette.m3error : Colours.palette.m3onSurface
opacity: root.lock.pam.passwd.active ? 0 : 1
Behavior on opacity {
Anim {}
}
}
StyledBusyIndicator {
anchors.fill: parent
running: root.lock.pam.passwd.active
}
}
InputField {
@ -186,6 +213,135 @@ ColumnLayout {
}
}
StyledText {
id: message
readonly property Pam pam: root.lock.pam
readonly property string msg: {
if (pam.fprintState === "error")
return qsTr("ERROR: %1").arg(pam.fprint.message);
if (pam.state === "error")
return qsTr("ERROR: %1").arg(pam.passwd.message);
if (pam.lockMessage)
return pam.lockMessage;
if (pam.state === "max" && pam.fprintState === "max")
return qsTr("Maximum password and fingerprint attempts reached.");
if (pam.state === "max") {
if (pam.fprint.available)
return qsTr("Maximum password attempts reached. Please use fingerprint.");
return qsTr("Maximum password attempts reached.");
}
if (pam.fprintState === "max")
return qsTr("Maximum fingerprint attempts reached. Please use password.");
if (pam.state === "fail") {
if (pam.fprint.available)
return qsTr("Incorrect password. Please try again or use fingerprint.");
return qsTr("Incorrect password. Please try again.");
}
if (pam.fprintState === "fail")
return qsTr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(Config.lock.maxFprintTries);
return "";
}
Layout.fillWidth: true
Layout.topMargin: -Appearance.spacing.large
scale: 0.7
opacity: 0
color: Colours.palette.m3error
font.pointSize: Appearance.font.size.small
font.family: Appearance.font.family.mono
horizontalAlignment: Qt.AlignHCenter
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
onMsgChanged: {
if (msg) {
if (opacity > 0) {
animate = true;
text = msg;
animate = false;
exitAnim.stop();
if (scale < 1)
appearAnim.restart();
else
flashAnim.restart();
} else {
text = msg;
exitAnim.stop();
appearAnim.restart();
}
} else {
appearAnim.stop();
flashAnim.stop();
exitAnim.start();
}
}
Connections {
target: root.lock.pam
function onFlashMsg(): void {
exitAnim.stop();
if (message.scale < 1)
appearAnim.restart();
else
flashAnim.restart();
}
}
Anim {
id: appearAnim
target: message
properties: "scale,opacity"
to: 1
onFinished: flashAnim.restart()
}
SequentialAnimation {
id: flashAnim
loops: 2
MessageAnim {
to: 0.3
}
MessageAnim {
to: 1
}
}
ParallelAnimation {
id: exitAnim
Anim {
target: message
property: "scale"
to: 0.7
duration: Appearance.anim.durations.large
}
Anim {
target: message
property: "opacity"
to: 0
duration: Appearance.anim.durations.large
}
}
}
component MessageAnim: NumberAnimation {
target: message
property: "opacity"
duration: Appearance.anim.durations.small
easing.type: Easing.Linear
}
component Anim: NumberAnimation {
duration: Appearance.anim.durations.normal
easing.type: Easing.BezierSpline

View file

@ -8,17 +8,12 @@ RowLayout {
id: root
required property var lock
property real centerScale
anchors.fill: parent
anchors.margins: Appearance.padding.large
spacing: Appearance.spacing.large * 2
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.normal
scale: root.centerScale
StyledRect {
Layout.fillWidth: true
@ -62,16 +57,12 @@ RowLayout {
}
Center {
Layout.leftMargin: -(1 - scale) * implicitWidth / 2
Layout.rightMargin: -(1 - scale) * implicitWidth / 2
scale: root.centerScale
lock: root.lock
}
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.normal
scale: root.centerScale
StyledRect {
Layout.fillWidth: true

View file

@ -40,19 +40,15 @@ Item {
anchors.centerIn: parent
text: {
if (root.pam.active)
if (root.pam.passwd.active)
return qsTr("Loading...");
if (root.pam.state === "error")
return qsTr("An error occured");
if (root.pam.state === "max")
return qsTr("You have reached the maximum number of tries");
if (root.pam.state === "fail")
return qsTr("Incorrect password");
return qsTr("Enter your password");
}
animate: true
color: root.pam.active ? Colours.palette.m3secondary : root.pam.state ? Colours.palette.m3error : Colours.palette.m3outline
color: root.pam.passwd.active ? Colours.palette.m3secondary : Colours.palette.m3outline
font.pointSize: Appearance.font.size.normal
font.family: Appearance.font.family.mono

View file

@ -14,7 +14,6 @@ WlSessionLockSurface {
required property Pam pam
readonly property alias unlocking: unlockAnim.running
readonly property bool animating: initAnim.running || unlockAnim.running
color: "transparent"
@ -44,7 +43,7 @@ WlSessionLockSurface {
}
Anim {
target: content
property: "centerScale"
property: "scale"
to: 0
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
@ -132,7 +131,7 @@ WlSessionLockSurface {
}
Anim {
target: content
property: "centerScale"
property: "scale"
to: 1
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
@ -219,8 +218,13 @@ WlSessionLockSurface {
Content {
id: content
anchors.centerIn: parent
width: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio - Appearance.padding.large * 2
height: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult - Appearance.padding.large * 2
lock: root
opacity: 0
scale: 0
}
}

View file

@ -19,7 +19,7 @@ Item {
Image {
anchors.fill: parent
source: root.lock.animating ? "" : (Players.active?.trackArtUrl ?? "")
source: Players.active?.trackArtUrl ?? ""
asynchronous: true
fillMode: Image.PreserveAspectCrop

View file

@ -42,7 +42,7 @@ ColumnLayout {
anchors.centerIn: parent
asynchronous: true
active: opacity > 0
opacity: root.lock.animating || Notifs.list.length > 0 ? 0 : 1
opacity: Notifs.list.length > 0 ? 0 : 1
sourceComponent: ColumnLayout {
spacing: Appearance.spacing.large
@ -51,7 +51,7 @@ ColumnLayout {
asynchronous: true
source: `file://${Quickshell.shellDir}/assets/dino.png`
fillMode: Image.PreserveAspectFit
sourceSize.width: root.lock.animating ? 0 : clipRect.width * 0.8
sourceSize.width: clipRect.width * 0.8
layer.enabled: true
layer.effect: Colouriser {
@ -84,7 +84,7 @@ ColumnLayout {
clip: true
model: ScriptModel {
values: root.lock.animating ? [] : [...new Set(Notifs.list.map(notif => notif.appName))].reverse()
values: [...new Set(Notifs.list.map(notif => notif.appName))].reverse()
}
delegate: NotifGroup {}

View file

@ -1,4 +1,6 @@
import qs.config
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Pam
import QtQuick
@ -9,12 +11,16 @@ Scope {
required property WlSessionLock lock
readonly property alias passwd: passwd
readonly property bool active: passwd.active
readonly property alias fprint: fprint
property string lockMessage
property string state
property string fprintState
property string buffer
signal flashMsg
function handleKey(event: KeyEvent): void {
if (passwd.active)
if (passwd.active || state === "max")
return;
if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
@ -34,6 +40,16 @@ Scope {
PamContext {
id: passwd
config: "passwd"
configDirectory: Quickshell.shellDir + "/assets/pam.d"
onMessageChanged: {
if (message.startsWith("The account is locked"))
root.lockMessage = message;
else if (root.lockMessage && message.endsWith(" left to unlock)"))
root.lockMessage += "\n" + message;
}
onResponseRequiredChanged: {
if (!responseRequired)
return;
@ -53,22 +69,125 @@ Scope {
else if (res === PamResult.Failed)
root.state = "fail";
root.flashMsg();
stateReset.restart();
}
}
PamContext {
id: fprint
property bool available
property int tries
property int errorTries
function checkAvail(): void {
if (!available || !Config.lock.enableFprint || !root.lock.secure) {
abort();
return;
}
tries = 0;
errorTries = 0;
start();
}
config: "fprint"
configDirectory: Quickshell.shellDir + "/assets/pam.d"
onCompleted: res => {
if (!available)
return;
if (res === PamResult.Success)
return root.lock.unlock();
if (res === PamResult.Error) {
root.fprintState = "error";
errorTries++;
if (errorTries < 5) {
abort();
errorRetry.restart();
}
} else if (res === PamResult.MaxTries) {
// Isn't actually the real max tries as pam only reports completed
// when max tries is reached.
tries++;
if (tries < Config.lock.maxFprintTries) {
// Restart if not actually real max tries
root.fprintState = "fail";
start();
} else {
root.fprintState = "max";
abort();
}
}
root.flashMsg();
fprintStateReset.start();
}
}
Process {
id: availProc
command: ["sh", "-c", "fprintd-list $USER"]
onExited: code => {
fprint.available = code === 0;
fprint.checkAvail();
}
}
Timer {
id: errorRetry
interval: 800
onTriggered: fprint.start()
}
Timer {
id: stateReset
interval: 4000
onTriggered: root.state = ""
onTriggered: {
if (root.state !== "max")
root.state = "";
}
}
Timer {
id: fprintStateReset
interval: 4000
onTriggered: {
root.fprintState = "";
fprint.errorTries = 0;
}
}
Connections {
target: root.lock
function onSecureChanged(): void {
if (root.lock.secure) {
availProc.running = true;
root.buffer = "";
root.state = "";
root.fprintState = "";
root.lockMessage = "";
}
}
function onUnlock(): void {
root.buffer = "";
fprint.abort();
}
}
Connections {
target: Config.lock
function onEnableFprintChanged(): void {
fprint.checkAvail();
}
}
}

View file

@ -74,6 +74,11 @@
nativeBuildInputs = [gcc];
buildInputs = [aubio pipewire];
patchPhase = ''
substituteInPlace assets/pam.d/fprint \
--replace-fail pam_fprintd.so /run/current-system/sw/lib/security/pam_fprintd.so
'';
buildPhase = ''
mkdir -p bin
g++ -std=c++17 -Wall -Wextra \