Give identity its due: native enrollment, honest deletion, and sign-in that stays home

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 18:55:38 -04:00
parent 5a0643357f
commit 4ec8bd94d9
25 changed files with 4713 additions and 407 deletions
+155 -32
View File
@@ -2,12 +2,22 @@ pragma Singleton
// The fingerprint reader, for the Users page.
//
// Two facts, owned by two different systems: fprintd holds the enrolled
// prints (GNOME's Users panel owns the enrollment dialog and Panama hands off
// to it), 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.
// 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.
@@ -23,55 +33,134 @@ Singleton {
property bool readerPresent: false
property string readerName: ""
property var enrolled: []
property bool pamEnabled: false
// 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 bool busy: 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, so nothing
// can disagree about what a finger is called.
// 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 (root.busy)
if (apply.running)
return;
root.busy = true;
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: {
try {
const parsed = JSON.parse(this.text);
root.readerPresent = parsed.reader === true;
root.readerName = String(parsed.readerName ?? "");
root.enrolled = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
root.pamEnabled = parsed.pamEnabled === 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;
}
}
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
@@ -87,8 +176,42 @@ Singleton {
}
// 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.busy = false;
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();
}
}
+112 -33
View File
@@ -5,13 +5,19 @@ pragma Singleton
// The daemon already runs in this session -- gvfs activates it, and accounts
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
// are D-Bus objects anything may read and modify. So listing, per-service
// toggles, and removal all happen here, natively.
// toggles, removal, and adding a password account all happen here, natively.
//
// Signing in is the exception, and only for OAuth providers. The daemon's
// AddAccount takes credentials as an argument rather than obtaining them, and
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
// panel and the user comes straight back here.
// Signing in is the exception, and only for OAuth providers. GOA's AddAccount
// takes credentials as an argument rather than obtaining them, which is exactly
// what a Nextcloud or IMAP form can supply; what it cannot supply is a Google
// token, because the code that runs that exchange lives in libgoa-backend,
// which Fedora ships without a GIR binding. So that one step is handed to
// GNOME's panel and the user comes straight back here.
//
// `available` means GOA answered the last time it was asked, and nothing else.
// It used to mean "lastError is empty", so a toggle GOA refused turned the
// whole page into "Online Accounts is not available" and hid the four working
// accounts behind it. A write that failed is a row; it is not the page.
//
// Read on demand and after every change: accounts are added and removed by
// people, not by the system, so there is nothing to poll for.
@@ -29,68 +35,141 @@ Singleton {
// services: [{key,label,enabled}] }]
property var accounts: []
property bool scanned: false
property bool busy: false
property string lastError: ""
// Whether GOA answered the last snapshot. True to begin with because
// nothing has said otherwise yet; `scanned` is what says whether anything
// has been asked at all.
property bool available: true
// Kept apart on purpose. The first is why the page might be empty; the
// second is why one thing someone just did did not happen. Only the first
// has any business deciding whether the page works.
property string snapshotError: ""
property string writeError: ""
readonly property string lastError:
root.writeError !== "" ? root.writeError : root.snapshotError
// Guards read the Process objects; this is only for the page to bind to,
// so rows can go quiet while a change is in flight.
readonly property bool busy: list.running || write.running || add.running
// Accounts whose stored credentials have stopped working -- an expired
// token, a changed password. GOA knows, and nothing outside its own panel
// ever says so, which is how an account quietly stops syncing for weeks.
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
readonly property bool available: root.lastError === ""
// Cheap enough for every page open: one short-lived process reading D-Bus
// objects that are already in memory.
function refresh(): void {
if (!list.running)
list.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.snapshotError = String(parsed.error ?? "");
root.available = root.snapshotError === "";
} catch (error) {
root.accounts = [];
root.snapshotError = "Could not read the accounts helper's output.";
root.available = false;
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
// Enabling a service clears GOA's "disabled" flag; the helper owns that
// inversion so the UI can speak in terms of what is on.
function setService(path: string, service: string, enabled: bool): void {
if (root.busy)
if (write.running)
return;
root.busy = true;
root.writeError = "";
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
write.running = true;
}
function remove(path: string): void {
if (root.busy)
if (write.running)
return;
root.busy = true;
root.writeError = "";
write.command = [root.helperPath, "remove", path];
write.running = true;
}
// ── Adding a password account ────────────────────────────────────────────
//
// The password goes to the helper's stdin and nowhere else: never an
// argument, because argv is readable by every process on this machine.
// It is written from onStarted, because a process has no stdin to write to
// until it is running -- the same pattern UserAccounts uses for a new
// password, and HomeAssistantConfig for its token.
property string pendingPassword: ""
function addNextcloud(server: string, user: string, password: string): void {
root.beginAdd(["add-nextcloud", server, user], password);
}
function addImap(email: string, imapHost: string, smtpHost: string,
user: string, password: string): void {
root.beginAdd(["add-imap", email, imapHost, smtpHost, user], password);
}
function beginAdd(arguments: var, password: string): void {
if (add.running)
return;
root.writeError = "";
root.pendingPassword = password;
add.command = [root.helperPath].concat(arguments);
add.stdinEnabled = true;
add.running = true;
}
Process {
id: list
command: [root.helperPath, "list"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.accounts = [];
root.lastError = "Could not read the accounts helper's output.";
console.warn("OnlineAccounts: could not parse helper output:", error);
}
root.scanned = true;
}
}
command: [root.helperPath, "snapshot"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
Process {
id: write
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
}
// Re-read rather than assuming the write landed: GOA may refuse, and a
// toggle that sprang back is the honest outcome.
onExited: {
root.busy = false;
root.refresh();
onExited: root.refresh()
}
Process {
id: add
stdinEnabled: true
onStarted: {
add.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
add.stdinEnabled = false;
}
// The helper answers with the fresh account list plus whatever went
// wrong, so a successful add lands on the page without a second read.
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : root.accounts;
// An add that failed says why here. It is a write error:
// GOA answered, so the page is still working.
root.writeError = String(parsed.error ?? "");
} catch (error) {
root.writeError = "Could not read the accounts helper's output.";
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
}
onExited: root.pendingPassword = ""
}
}
@@ -108,6 +108,15 @@ Singleton {
{ label: "Add a user", detail: "Create another account on this machine", page: "users" },
{ label: "Automatic login", detail: "Sign in without typing a password", page: "users" },
{ label: "Administrator", detail: "Which accounts can manage this machine", page: "users" },
// Users owns fingerprint enrollment now rather than handing it to
// GNOME's panel, and it owns the other-account verbs it used to bury
// inside an expanded row. Each of those is something people come
// looking for by name, so each gets a name to be found by.
{ label: "Fingerprint", detail: "Unlock and authorize with the reader on this machine", page: "users" },
{ label: "Enroll a fingerprint", detail: "Record a finger, one touch at a time", page: "users" },
{ label: "Delete a user", detail: "Remove an account, keeping or destroying its files", page: "users" },
{ label: "Account type", detail: "Whether an account may administer this machine", page: "users" },
{ label: "Reset a password", detail: "They set a new one at their next sign-in", page: "users" },
{ label: "Printers", detail: "Add a printer and see what is queued", page: "printers" },
{ label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" },
{ label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" },
@@ -170,6 +179,14 @@ Singleton {
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
// Adding an account is the thing people search for, and they search for
// it by the name of the service. Nextcloud and mail are added on the
// page itself; Google still goes through the provider's own sign-in,
// which is a result worth having rather than a dead end.
{ label: "Nextcloud", detail: "Add a Nextcloud server for files, calendar, and contacts", page: "accounts" },
{ label: "Google account", detail: "Sign in to Google for mail, calendar, and contacts", page: "accounts" },
{ label: "Add a mail account", detail: "Connect a mailbox by its IMAP and SMTP servers", page: "accounts" },
{ label: "Remove an account", detail: "Sign out and take an online account off this machine", page: "accounts" },
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
{ label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" },
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
@@ -25,6 +25,12 @@ Singleton {
property bool scanned: false
property string lastError: ""
// The pictures the distribution ships, as [{name, path}]. Read once and
// kept: it is a directory listing that does not change while a session is
// running, and the gallery it fills is opened and closed repeatedly.
property var stockAvatars: []
property bool stockAvatarsLoaded: false
// Guards read the Process objects rather than a derived "busy" binding. A
// binding hands back its cached value inside the handler that changes it,
// which silently turns a refresh after a successful change into a no-op --
@@ -115,7 +121,27 @@ Singleton {
String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]);
}
function setAccountType(userName: string, kind: string): void {
// Clearing the picture is the same call with nothing in it -- there is no
// separate method for it in accountsservice, and there is none here either.
// No user name: this is the hero card's own avatar, and an account you are
// not signed in to has no avatar surface to remove it from.
function removeIcon(): void {
root.settingIcon = true;
root.run(["set-icon", root.currentUser, ""]);
}
// Called when the gallery is about to be shown, not at startup: most
// sessions never open it, and it is a directory listing either way.
function loadStockAvatars(): void {
if (root.stockAvatarsLoaded || stock.running)
return;
stock.running = true;
}
// Any account, including one that is not signed in. The helper keeps the
// last-administrator refusal, so a page that forgets the guard still
// cannot leave the machine unadministrable.
function setAccountTypeFor(userName: string, kind: string): void {
root.run(["set-account-type", userName, kind]);
}
@@ -123,12 +149,28 @@ Singleton {
root.run(["set-automatic-login", userName, enabled ? "true" : "false"]);
}
// Locked accounts cannot sign in at all. Unlocking is the only half of this
// the page offers, because locking someone out is not a settings gesture.
function setLocked(userName: string, locked: bool): void {
root.run(["set-locked", userName, locked ? "true" : "false"]);
}
// No password of any kind is involved: accountsservice is told the account
// must choose one at the next sign-in, and the login screen collects it
// from the person who will use it.
function resetPassword(userName: string): void {
root.run(["reset-password", userName]);
}
function createUser(userName: string, realName: string, kind: string): void {
root.run(["create-user", userName, realName, kind]);
}
function deleteUser(userName: string, removeFiles: bool): void {
root.run(["delete-user", userName, removeFiles ? "remove-files" : "keep-files"]);
// keepFiles, not removeFiles: the page asks "Keep the files" or "Remove
// everything", and a service that inverted the sentence on its way to the
// helper is how the destructive answer gets chosen by accident.
function deleteUser(userName: string, keepFiles: bool): void {
root.run(["delete-user", userName, keepFiles ? "keep" : "remove"]);
}
// The password goes to the helper's stdin and nowhere else: never an
@@ -177,6 +219,25 @@ Singleton {
onExited: root.pendingPassword = ""
}
Process {
id: stock
command: [root.helperPath, "stock-avatars"]
stdout: StdioCollector {
onStreamFinished: {
try {
const parsed = JSON.parse(this.text);
root.stockAvatars = Array.isArray(parsed.avatars) ? parsed.avatars : [];
} catch (error) {
// A machine with no gallery is normal; an empty one reads
// the same to the page, and nothing else here depends on it.
root.stockAvatars = [];
console.warn("Accounts: could not read the stock avatars:", error);
}
root.stockAvatarsLoaded = true;
}
}
}
Process {
id: mutation
// The helper answers with the fresh state, so the page updates from the