From aea57958326360a1dc15509c02397594da20538e Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sat, 23 Aug 2025 20:43:04 +1000 Subject: 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 --- modules/lock/Center.qml | 162 ++++++++++++++++++++++++++++++++++++++++++- modules/lock/Content.qml | 9 --- modules/lock/InputField.qml | 8 +-- modules/lock/LockSurface.qml | 10 ++- modules/lock/Media.qml | 2 +- modules/lock/NotifDock.qml | 6 +- modules/lock/Pam.qml | 127 +++++++++++++++++++++++++++++++-- 7 files changed, 295 insertions(+), 29 deletions(-) (limited to 'modules') diff --git a/modules/lock/Center.qml b/modules/lock/Center.qml index 91fcf2f..4db0376 100644 --- a/modules/lock/Center.qml +++ b/modules/lock/Center.qml @@ -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 diff --git a/modules/lock/Content.qml b/modules/lock/Content.qml index 6975a89..21c1331 100644 --- a/modules/lock/Content.qml +++ b/modules/lock/Content.qml @@ -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 diff --git a/modules/lock/InputField.qml b/modules/lock/InputField.qml index 9472d41..9360a1e 100644 --- a/modules/lock/InputField.qml +++ b/modules/lock/InputField.qml @@ -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 diff --git a/modules/lock/LockSurface.qml b/modules/lock/LockSurface.qml index 625abb5..c0c962c 100644 --- a/modules/lock/LockSurface.qml +++ b/modules/lock/LockSurface.qml @@ -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 } } diff --git a/modules/lock/Media.qml b/modules/lock/Media.qml index 59bed16..bb2fc7b 100644 --- a/modules/lock/Media.qml +++ b/modules/lock/Media.qml @@ -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 diff --git a/modules/lock/NotifDock.qml b/modules/lock/NotifDock.qml index 1cfcd28..a986052 100644 --- a/modules/lock/NotifDock.qml +++ b/modules/lock/NotifDock.qml @@ -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 {} diff --git a/modules/lock/Pam.qml b/modules/lock/Pam.qml index 2675405..0186c2f 100644 --- a/modules/lock/Pam.qml +++ b/modules/lock/Pam.qml @@ -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(); } } } -- cgit v1.2.3-freya