219 lines
8.7 KiB
QML
219 lines
8.7 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|