pragma Singleton // The fingerprint reader, for the Users page. // // Two facts, owned by two different systems: fprintd holds the enrolled prints, // and authselect decides whether PAM asks the reader at unlock. Both come // through scripts/panama-fingerprint, and the one privileged change -- flipping // authselect's with-fingerprint feature -- prompts through polkit with a stated // reason, like everything else on that page. // // The two facts are kept apart deliberately. `unlockFeatureEnabled` is a // property of the PAM configuration and is reported whether or not a reader // exists, because the state worth seeing most is the one where the feature is // on and the reader is gone: nothing works, nothing says why, and the switch // that would fix it used to be hidden behind the missing hardware. Hence // `cardVisible`. // // Enrollment happens here now rather than in GNOME's Users panel. The helper // drives fprintd's own Claim/EnrollStart cycle and prints one line of JSON per // touch, which is what `enrollStage of enrollTotal` counts. // // Read when the page opens rather than at shell startup: probing fprintd // bus-activates the daemon, and most sessions never open this page. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-fingerprint" property bool readerPresent: false property string readerName: "" // The fingers fprintd currently holds a print for, by fprintd's own names. property var fingers: [] property bool unlockFeatureEnabled: false property bool scanned: false property string lastError: "" // Guards read the Process objects; this is only for the page to bind to. readonly property bool busy: apply.running || change.running // The card is worth drawing when there is a reader OR when PAM has been // told to use one. The second half is the stuck state: no reader, nothing // enrolled, and a feature switched on that quietly slows every unlock down. readonly property bool cardVisible: root.readerPresent || root.unlockFeatureEnabled // ── What a finger is called ────────────────────────────────────────────── // // fprintd's vocabulary lives here and only here, so nothing can disagree // about what a finger is named or how it reads. readonly property var allFingers: [ "right-index-finger", "right-middle-finger", "right-ring-finger", "right-little-finger", "right-thumb", "left-index-finger", "left-middle-finger", "left-ring-finger", "left-little-finger", "left-thumb" ] // The ones still worth offering: enrolling a finger twice replaces the // print rather than adding one, which is not what "Add a fingerprint" says. readonly property var availableFingers: root.allFingers.filter(finger => root.fingers.indexOf(finger) === -1) // "right-index-finger" -> "Right index finger". Presentation lives here // rather than in the page, the way PowerProfiles.label does. function fingerLabel(finger: string): string { const words = String(finger).split("-").join(" "); return words.slice(0, 1).toUpperCase() + words.slice(1); } // ── Enrollment ─────────────────────────────────────────────────────────── property bool enrolling: false // Touches taken, of touches the reader wants. Both zero until the device // has said how many it needs. property int enrollStage: 0 property int enrollTotal: 0 // The finger being enrolled, and fprintd's last word about the last touch // ("enroll-stage-passed", "enroll-retry-scan-too-short", ...). The page // turns the retries into "Try again, a little slower". property string enrollFinger: "" property string enrollResult: "" // claiming | scanning | done | failed | cancelled property string enrollPhase: "" function refresh(): void { if (!query.running) query.running = true; } function setUnlockEnabled(on: bool): void { if (apply.running) return; root.lastError = ""; apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"]; apply.running = true; } function startEnroll(finger: string): void { if (enroll.running) return; root.lastError = ""; root.enrollFinger = finger; root.enrollStage = 0; root.enrollTotal = 0; root.enrollResult = ""; root.enrollPhase = "claiming"; root.enrolling = true; enroll.command = [root.helperPath, "enroll", finger]; enroll.running = true; } // Stopping the process is the cancellation: the helper catches the signal, // stops the enrollment and releases the reader, which is the part that // matters -- a claimed device belonging to a dead process refuses the next // attempt. function cancelEnroll(): void { if (enroll.running) enroll.running = false; } function removeFinger(finger: string): void { if (change.running) return; root.lastError = ""; change.command = [root.helperPath, "remove", finger]; change.running = true; } function removeAll(): void { if (change.running) return; root.lastError = ""; change.command = [root.helperPath, "remove-all"]; change.running = true; } // Every verb that reports state answers in the same shape, so there is one // place that reads it. function absorb(text: string): void { try { const parsed = JSON.parse(text); root.readerPresent = parsed.reader === true; root.readerName = String(parsed.readerName ?? ""); root.fingers = Array.isArray(parsed.enrolled) ? parsed.enrolled : []; root.unlockFeatureEnabled = parsed.unlockFeatureEnabled === true; if (String(parsed.error ?? "") !== "") root.lastError = String(parsed.error); } catch (error) { root.readerPresent = false; root.lastError = "Could not read the fingerprint helper's output."; console.warn("Fingerprint: could not parse helper output:", error); } root.scanned = true; } Process { id: query command: [root.helperPath, "status"] stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } } Process { id: apply stderr: StdioCollector { // A dismissed polkit prompt is a normal outcome on this page, not // a failure to report. onStreamFinished: { const text = this.text.trim(); if (text !== "" && !/dismissed|not authorized/i.test(text)) root.lastError = text; } } // Re-read rather than assuming: authselect may refuse, and the // prompt may have been dismissed. onExited: root.refresh() } Process { id: change // Removing answers with the fresh state, so the list updates from the // removal itself and never has to ask again. stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } } Process { id: enroll stdout: SplitParser { // One JSON object per touch. A reader takes five or so, seconds // apart, so this is a trickle rather than a stream to drain. onRead: line => { try { const update = JSON.parse(line); root.enrollPhase = String(update.stage ?? root.enrollPhase); root.enrollTotal = Number(update.total ?? root.enrollTotal); root.enrollStage = Number(update.done ?? root.enrollStage); root.enrollResult = String(update.result ?? ""); if (update.ok === false) root.lastError = String(update.error ?? "That finger could not be enrolled."); } catch (error) { // A line that is not JSON is not worth abandoning an // enrollment over; the exit status is what decides. } } } onExited: { root.enrolling = false; // Cancelled by stopping the process: the helper had no chance to // say anything, and there is nothing to report. if (root.enrollPhase !== "done" && root.enrollPhase !== "failed") root.enrollPhase = "cancelled"; root.refresh(); } } }