Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/common/panels/lock/LockContext.qml
2026-08-10 20:10:50 -04:00

261 lines
9 KiB
QML

import qs
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
Scope {
id: root
enum ActionEnum { Unlock, Poweroff, Reboot }
signal shouldReFocus()
signal unlocked(targetAction: var)
signal failed()
// These properties are in the context and not individual lock surfaces
// so all surfaces can share the same state.
property string currentText: ""
property bool unlockInProgress: false
property bool showFailure: false
property bool fingerprintsConfigured: false
property var targetAction: LockContext.ActionEnum.Unlock
property bool alsoInhibitIdle: false
// This is deliberately an integration preview, not a biometric factor.
// The FPC1020's KEY_WAKEUP edge proves only that something asserted the
// line; it does not prove a matched enrolled fingerprint. Until the
// attested souveraine-fpd path exists, neither a pulse nor a touch hold
// may reach unlocked() or StepUpAuth.
readonly property bool provisionalFingerprintEnabled:
Config.options?.lock?.fingerprintPreview?.enabled ?? false
readonly property int provisionalFingerprintHoldMs:
Config.options?.lock?.fingerprintPreview?.holdMs ?? 3000
property bool provisionalFingerprintHolding: false
property bool provisionalFingerprintConfirmed: false
property bool provisionalFingerprintPulseSeen: false
property real provisionalFingerprintHoldProgress: 0
property double provisionalFingerprintHoldStartedAt: 0
property string provisionalFingerprintPulseToken: ""
signal provisionalFingerprintPulse()
signal provisionalFingerprintPreviewConfirmed()
function resetTargetAction() {
root.targetAction = LockContext.ActionEnum.Unlock;
}
function clearText() {
root.currentText = "";
}
function resetClearTimer() {
passwordClearTimer.restart();
}
function reset() {
root.resetTargetAction();
root.clearText();
root.unlockInProgress = false;
stopFingerPam();
root.resetProvisionalFingerprint();
}
function beginProvisionalFingerprintHold() {
if (!root.provisionalFingerprintEnabled) return;
root.provisionalFingerprintConfirmed = false;
root.provisionalFingerprintHolding = true;
root.provisionalFingerprintHoldStartedAt = Date.now();
root.provisionalFingerprintHoldProgress = 0;
provisionalFingerprintHoldTimer.start();
}
function cancelProvisionalFingerprintHold() {
if (!root.provisionalFingerprintHolding) return;
root.provisionalFingerprintHolding = false;
root.provisionalFingerprintHoldProgress = 0;
provisionalFingerprintHoldTimer.stop();
}
function confirmProvisionalFingerprintHold() {
if (!root.provisionalFingerprintHolding) return;
root.provisionalFingerprintHolding = false;
root.provisionalFingerprintHoldProgress = 1;
provisionalFingerprintHoldTimer.stop();
root.provisionalFingerprintConfirmed = true;
root.provisionalFingerprintPreviewConfirmed();
provisionalFingerprintConfirmTimer.restart();
console.log("[fingerprint-preview] hold confirmed; no authentication granted");
}
// Called by the narrow future producer seam (`fingerprint.signal`). This
// records only that an input path reached the surface. Any same-user IPC
// caller can cause this today, so it is intentionally visual-only.
function noteProvisionalFingerprintPulse() {
if (!root.provisionalFingerprintEnabled)
return { ok: false, code: "not_enabled", reason: "fingerprint preview is disabled" };
root.provisionalFingerprintPulseSeen = true;
root.provisionalFingerprintPulse();
provisionalFingerprintPulseTimer.restart();
console.log("[fingerprint-preview] pulse observed; no authentication granted");
return { ok: true, status: "observed" };
}
function resetProvisionalFingerprint() {
root.provisionalFingerprintHolding = false;
root.provisionalFingerprintConfirmed = false;
root.provisionalFingerprintPulseSeen = false;
root.provisionalFingerprintHoldProgress = 0;
provisionalFingerprintHoldTimer.stop();
provisionalFingerprintConfirmTimer.stop();
provisionalFingerprintPulseTimer.stop();
}
// blueline-fingerprintd is the only source that may write this root-owned
// status file. Its record says an FPC IRQ reached the daemon; it is not a
// match, and this listener has no authority beyond the preview pulse.
FileView {
id: provisionalFingerprintPulseFile
path: "/run/blueline-fingerprintd/preview-pulse"
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: {
try {
const record = JSON.parse(provisionalFingerprintPulseFile.text());
const token = `${record.sequence}:${record.at_ms}`;
if (token === root.provisionalFingerprintPulseToken) return;
root.provisionalFingerprintPulseToken = token;
// Do not replay an old event merely because the lock surface
// appeared after it. A new reader assertion has a fresh wall
// timestamp and reaches the same visual-only method as the IPC
// diagnostic seam.
const ageMs = Date.now() - Number(record.at_ms);
if (Number(record.sequence) > 0 && ageMs >= 0 && ageMs < 5000)
root.noteProvisionalFingerprintPulse();
} catch (error) {
console.warn("[fingerprint-preview] invalid FPC pulse record:", error);
}
}
}
Timer {
id: provisionalFingerprintHoldTimer
interval: 50
repeat: true
onTriggered: {
const elapsed = Date.now() - root.provisionalFingerprintHoldStartedAt;
root.provisionalFingerprintHoldProgress = Math.min(1,
elapsed / Math.max(1, root.provisionalFingerprintHoldMs));
if (root.provisionalFingerprintHoldProgress >= 1)
root.confirmProvisionalFingerprintHold();
}
}
Timer {
id: provisionalFingerprintConfirmTimer
interval: 3500
onTriggered: root.provisionalFingerprintConfirmed = false
}
Timer {
id: provisionalFingerprintPulseTimer
interval: 3500
onTriggered: root.provisionalFingerprintPulseSeen = false
}
Timer {
id: passwordClearTimer
interval: 10000
onTriggered: {
root.reset();
}
}
onCurrentTextChanged: {
if (currentText.length > 0) {
showFailure = false;
GlobalStates.screenUnlockFailed = false;
}
GlobalStates.screenLockContainsCharacters = currentText.length > 0;
passwordClearTimer.restart();
}
function tryUnlock(alsoInhibitIdle = false) {
root.alsoInhibitIdle = alsoInhibitIdle;
root.unlockInProgress = true;
pam.start();
}
function tryFingerUnlock() {
if (root.fingerprintsConfigured) {
fingerPam.start();
}
}
function stopFingerPam() {
if (fingerPam.active) {
fingerPam.abort();
}
}
Process {
id: fingerprintCheckProc
running: true
command: ["bash", "-c", "fprintd-list $(whoami)"]
stdout: StdioCollector {
id: fingerprintOutputCollector
onStreamFinished: {
root.fingerprintsConfigured = fingerprintOutputCollector.text.includes("Fingerprints for user");
}
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
// console.warn("[LockContext] fprintd-list command exited with error:", exitCode, exitStatus);
root.fingerprintsConfigured = false;
}
}
}
PamContext {
id: pam
// pam_unix will ask for a response for the password prompt
onPamMessage: {
if (this.responseRequired) {
this.respond(root.currentText);
}
}
// pam_unix won't send any important messages so all we need is the completion status.
onCompleted: result => {
if (result == PamResult.Success) {
root.unlocked(root.targetAction);
stopFingerPam();
} else {
root.clearText();
root.unlockInProgress = false;
GlobalStates.screenUnlockFailed = true;
root.showFailure = true;
}
}
}
PamContext {
id: fingerPam
configDirectory: "pam"
config: "fprintd.conf"
onCompleted: result => {
if (result == PamResult.Success) {
root.unlocked(root.targetAction);
stopFingerPam();
} else if (result == PamResult.Error) { // if timeout or etc..
tryFingerUnlock()
}
}
}
}