From 4ec8bd94d99fbddeb0591a21e4786369cfd6dfbe Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 24 Aug 2026 18:55:38 -0400 Subject: [PATCH] Give identity its due: native enrollment, honest deletion, and sign-in that stays home Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- README.md | 2 +- .../settings/FingerprintEnrollPanel.qml | 126 ++++ .../modules/settings/LiveFieldRow.qml | 107 ++++ .../modules/settings/OnlineAccountRow.qml | 247 ++++++++ .../modules/settings/OnlineAccountsPage.qml | 519 +++++++++++++--- .../modules/settings/PasswordStrengthRow.qml | 77 +++ .../modules/settings/SecretFieldRow.qml | 42 ++ .../modules/settings/StockAvatarPicker.qml | 116 ++++ .../quickshell/modules/settings/UsersPage.qml | 588 +++++++++++++++--- config/dot/quickshell/modules/settings/qmldir | 6 + config/dot/quickshell/scripts/panama-accounts | 359 ++++++++++- .../dot/quickshell/scripts/panama-fingerprint | 555 ++++++++++++++--- config/dot/quickshell/scripts/panama-users | 138 +++- .../dot/quickshell/services/Fingerprint.qml | 187 +++++- .../quickshell/services/OnlineAccounts.qml | 145 ++++- .../quickshell/services/SettingsSearch.qml | 17 + .../dot/quickshell/services/UserAccounts.qml | 67 +- .../share/vicinae/scripts/settings-accounts | 2 +- .../share/vicinae/scripts/settings-users | 2 +- ...26-08-23-settings-redesign-test-backlog.md | 215 +++++++ .../2026-08-24-users-accounts-redesign.md | 171 +++++ tests/quickshell/fingerprint-contract | 477 ++++++++++++-- tests/quickshell/gnome-handoff-contract | 8 +- tests/quickshell/online-accounts-contract | 533 ++++++++++++++++ tests/quickshell/user-accounts-contract | 414 +++++++++++- 25 files changed, 4713 insertions(+), 407 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/FingerprintEnrollPanel.qml create mode 100644 config/dot/quickshell/modules/settings/LiveFieldRow.qml create mode 100644 config/dot/quickshell/modules/settings/OnlineAccountRow.qml create mode 100644 config/dot/quickshell/modules/settings/PasswordStrengthRow.qml create mode 100644 config/dot/quickshell/modules/settings/SecretFieldRow.qml create mode 100644 config/dot/quickshell/modules/settings/StockAvatarPicker.qml create mode 100644 docs/superpowers/specs/2026-08-24-users-accounts-redesign.md create mode 100755 tests/quickshell/online-accounts-contract diff --git a/README.md b/README.md index eb0c154..ab7210d 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -171 of them, under `tests/`. Run the lot, or a subset by pattern: +172 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/modules/settings/FingerprintEnrollPanel.qml b/config/dot/quickshell/modules/settings/FingerprintEnrollPanel.qml new file mode 100644 index 0000000..b9ad3d8 --- /dev/null +++ b/config/dot/quickshell/modules/settings/FingerprintEnrollPanel.qml @@ -0,0 +1,126 @@ +// Guided enrollment, in place, for as long as it takes. +// +// fprintd wants the same finger pressed several times from slightly different +// angles, and it says how many it still needs. That count is the whole content +// of this panel: a person mid-enrollment is looking at the reader, not the +// screen, and the one thing they come back to the screen for is whether it +// worked and how much is left. +// +// Nothing here loops. The bar advances when a touch is accepted and then holds, +// so a panel left open on a desktop that never got touched costs no frames -- +// see the note about repainting animations in the Appearance page. + +import QtQuick +import qs.config + +Rectangle { + id: root + + // Human label for the finger being enrolled, already resolved by the + // service so nothing here has to know fprintd's vocabulary. + property string finger: "" + property int stage: 0 + property int total: 0 + // Whatever the last stream line said, when it said anything worth showing -- + // "enroll-retry-scan", a failure, a completion. + property string message: "" + + signal cancelled + + width: parent ? parent.width : 620 + implicitHeight: body.implicitHeight + 28 + radius: Theme.cardRadius + color: Theme.alpha(Theme.bgDark, 0.55) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.3) + + Column { + id: body + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 7 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + // md-fingerprint + text: "\u{F0306}" + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 38 + } + + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: root.finger === "" + ? "Touch the reader" + : "Touch the reader with your " + root.finger.toLowerCase() + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: root.total > 0 + ? "Lift and press again — " + root.stage + " of " + root.total + " touches" + : "Lift and press again" + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + Item { width: 1; height: 3 } + + Rectangle { + width: parent.width + height: 5 + radius: 3 + color: Theme.alpha(Theme.fg, 0.1) + border.width: 0 + + Rectangle { + width: root.total > 0 + ? parent.width * Math.max(0, Math.min(1, root.stage / root.total)) + : 0 + height: parent.height + radius: parent.radius + color: Theme.accent + border.width: 0 + + // One step per accepted touch, then still. + Behavior on width { + NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic } + } + } + } + + Text { + width: parent.width + visible: root.message !== "" + horizontalAlignment: Text.AlignHCenter + text: root.message + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + topPadding: 4 + } + + Item { width: 1; height: 3 } + + SettingsButton { + anchors.horizontalCenter: parent.horizontalCenter + text: "Cancel" + onClicked: root.cancelled() + } + } +} diff --git a/config/dot/quickshell/modules/settings/LiveFieldRow.qml b/config/dot/quickshell/modules/settings/LiveFieldRow.qml new file mode 100644 index 0000000..9ebfedc --- /dev/null +++ b/config/dot/quickshell/modules/settings/LiveFieldRow.qml @@ -0,0 +1,107 @@ +// A row whose trailing control is a free-text field that reports every +// keystroke. +// +// LiveFieldRow { +// label: "Username" +// detail: root.userNameProblem !== "" ? root.userNameProblem : "Lowercase…" +// invalid: root.userNameProblem !== "" +// onEdited: value => root.newUserName = value +// } +// +// TextFieldRow commits on Enter or blur, which is right when every commit is a +// privileged write that prompts. It is wrong for a form that has to say whether +// what is being typed is acceptable *while* it is being typed: the add-user +// form used TextFieldRow and its Create button stayed dark until the field lost +// focus, which reads as a broken button rather than as a field awaiting blur. +// +// So this reports twice. `edited` fires per keystroke, for validation and for +// state a page holds until a submit button is pressed. `accepted` fires on +// Enter or when focus leaves, for the values that go straight to a privileged +// write and must not prompt once per character. + +import QtQuick +import qs.config + +SettingRow { + id: root + + property string text: "" + property string placeholder: "" + property bool enabled: true + // Draws the field as rejected. The page decides what invalid means; this + // only shows it, next to the detail line that says why. + property bool invalid: false + property int maximumLength: 0 + + signal edited(value: string) + signal accepted(value: string) + + controlWidth: 220 + + function clear(): void { + input.text = ""; + } + + // An external change replaces what is shown, unless it would yank the field + // out from under someone mid-edit. + onTextChanged: if (!input.activeFocus && input.text !== root.text) input.text = root.text + + Rectangle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: root.controlWidth + height: 32 + radius: 9 + color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03) + border.width: input.activeFocus ? 2 : 1 + border.color: root.invalid + ? Theme.alpha(Theme.danger, 0.6) + : (input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.1)) + opacity: root.enabled ? 1 : 0.5 + + TextInput { + id: input + + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + enabled: root.enabled + activeFocusOnTab: true + text: root.text + color: root.invalid ? Theme.danger : Theme.fg + selectByMouse: true + selectionColor: Theme.alpha(Theme.accent, 0.5) + selectedTextColor: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + verticalAlignment: TextInput.AlignVCenter + maximumLength: root.maximumLength > 0 ? root.maximumLength : 32767 + clip: true + + onTextEdited: root.edited(input.text) + onAccepted: if (input.text !== root.text) root.accepted(input.text) + + // Unchanged text is not re-committed: a page whose `accepted` + // handler writes through polkit would otherwise prompt every time + // the field was tabbed past. + onActiveFocusChanged: { + if (input.activeFocus) + return; + if (input.text !== root.text) + root.accepted(input.text); + else + input.text = root.text; + } + + Text { + anchors.fill: parent + visible: input.text === "" + text: root.placeholder + color: Theme.fgMuted + font: input.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/OnlineAccountRow.qml b/config/dot/quickshell/modules/settings/OnlineAccountRow.qml new file mode 100644 index 0000000..a452822 --- /dev/null +++ b/config/dot/quickshell/modules/settings/OnlineAccountRow.qml @@ -0,0 +1,247 @@ +// One GNOME Online Accounts account: a line you can read, unfolding into the +// services it lends to the rest of the desktop. +// +// Built like InstalledAppRow rather than out of SettingRow, because the head +// carries the provider's own icon on the left and SettingRow's only icon slot +// is a mono glyph. What unfolds is small -- a toggle per service and a removal +// -- but it is per-account state, and four accounts each showing four toggles +// was most of a screen of switches with no way to tell which belonged to whom. +// +// Nothing here writes. Every control leaves through a signal so the page owns +// the service calls, which keeps the removal confirmation one piece of page +// state instead of something each row remembers for itself. + +import QtQuick +import Quickshell +import qs.config +import qs.widgets + +Column { + id: root + + // { path, provider, providerName, providerIcons, identity, needsAttention, + // services: [{ key, label, enabled }] } + required property var account + + property bool expanded: false + property bool busy: false + property bool confirmingRemoval: false + property bool divider: true + + // The words on the re-authorisation button come from the page, because the + // page is what that button leads to: it is the one hand-off to GNOME's own + // dialog, and the sentence and the call belong together rather than one of + // them living out here. Left empty the button does not appear at all. + property string signInAction: "" + + signal activated + signal serviceToggled(key: string, enabled: bool) + signal removeArmed + signal removeCancelled + signal removeConfirmed + signal signInAgainRequested + + readonly property bool needsAttention: root.account?.needsAttention === true + readonly property string providerName: String(root.account?.providerName ?? "") + readonly property string identity: String(root.account?.identity ?? "") + readonly property var services: root.account?.services ?? [] + + // GOA hands back a preference-ordered chain; this walks it and takes the + // first name the active icon theme actually has. Taking the first blindly, + // or the last as a fallback, both looked right and were not: on Adwaita the + // tail of these chains ("mail", "goa-symbolic") does not exist, so a miss + // would have rendered nothing at all. + readonly property string iconName: { + const chain = root.account?.providerIcons ?? []; + for (const name of chain) { + if (Quickshell.iconPath(String(name), true) !== "") + return String(name); + } + return ""; + } + + width: parent ? parent.width : 620 + spacing: 0 + + Item { + id: head + + width: parent.width + implicitHeight: Math.max(56, copy.implicitHeight + 20) + + Rectangle { + anchors.fill: parent + anchors.bottomMargin: 1 + radius: 9 + z: -1 + visible: headHover.hovered + color: Theme.alpha(Theme.fg, 0.05) + border.width: 0 + } + + Rectangle { + id: tile + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 32 + height: 32 + radius: 9 + color: Theme.alpha(Theme.fg, 0.07) + border.width: 0 + + ThemedIcon { + anchors.centerIn: parent + icon: root.iconName + iconFallback: "avatar-default-symbolic" + tint: root.needsAttention ? Theme.warn : Theme.fg + size: 19 + } + } + + Column { + id: copy + + anchors.left: tile.right + anchors.leftMargin: 12 + anchors.right: trailing.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: root.identity !== "" ? root.identity : root.providerName + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.needsAttention + ? root.providerName + " · sign-in expired, so this account has stopped syncing" + : (root.services.length === 0 + ? root.providerName + : root.providerName + " · " + + root.services.filter(service => service.enabled) + .map(service => String(service.label)).join(", ")) + color: root.needsAttention ? Theme.warn : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + + Row { + id: trailing + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 9 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: root.needsAttention && root.signInAction !== "" + text: root.signInAction + enabled: !root.busy + onClicked: root.signInAgainRequested() + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.expanded ? "▴" : "▾" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Rectangle { + anchors.left: copy.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + visible: root.divider && !root.expanded + color: Theme.alpha(Theme.fg, 0.065) + } + + HoverHandler { + id: headHover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: root.activated() + } + } + + Column { + id: expansion + + width: parent.width + leftPadding: 44 + visible: root.expanded + + Repeater { + model: root.services + + delegate: SettingRow { + id: serviceRow + + required property var modelData + + width: expansion.width - expansion.leftPadding + label: String(serviceRow.modelData.label ?? "") + detail: serviceRow.modelData.enabled + ? "Applications using " + String(serviceRow.modelData.label ?? "").toLowerCase() + + " can see this account" + : "Hidden from applications" + controlWidth: 54 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: serviceRow.modelData.enabled === true + // A toggle that moves while GOA is still answering the last + // one springs back, which reads as a broken switch. + enabled: !root.busy + onToggled: value => root.serviceToggled( + String(serviceRow.modelData.key ?? ""), value) + } + } + } + + SettingRow { + width: expansion.width - expansion.leftPadding + label: root.confirmingRemoval ? "Remove this account?" : "Remove this account" + detail: root.confirmingRemoval + ? "It signs out here and disappears from every application that was using it. Nothing on the provider's side is touched." + : "Signs out and removes it from every application that was using it" + controlWidth: 230 + divider: false + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: root.confirmingRemoval ? "Keep it" : "Remove…" + enabled: !root.busy + onClicked: root.confirmingRemoval ? root.removeCancelled() : root.removeArmed() + } + + SettingsButton { + visible: root.confirmingRemoval + text: "Remove" + tone: "danger" + enabled: !root.busy + onClicked: root.removeConfirmed() + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml index d528781..7e1a37c 100644 --- a/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml +++ b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml @@ -2,17 +2,22 @@ // // GNOME's panel of the same name, except for one step. The accounts are GNOME // Online Accounts objects on D-Bus, the daemon already runs here, and listing, -// per-service toggles and removal are all done natively on this page. +// per-service toggles, removal and password-provider sign-in are all done +// natively on this page. // -// Signing in to an OAuth provider is handed to GNOME's panel, because the -// credentials are OAuth tokens and the code that obtains them ships without a -// scriptable binding. That is stated on the page rather than hidden behind a -// button that looks native, because a hand-off the user does not expect reads -// as a bug. +// The one hand-off left is OAuth. Google's and Microsoft's tokens are obtained +// by code inside libgoa-backend, which Fedora ships without a GIR binding, so +// no amount of D-Bus gets at it. That is stated on the page rather than hidden +// behind a button that looks native, because a hand-off nobody expects reads as +// a bug -- and it goes through openProviderDialog() below, which is the only +// place in this file that names a GNOME panel. // -// Accounts needing attention are surfaced first. GOA knows when a token has -// expired and nothing outside its own panel says so, which is how an account -// quietly stops syncing for weeks. +// What Panama itself signs in to leads, because those two integrations are the +// ones this desktop actually uses. GOA's accounts are for the applications. +// +// A failed write is a row. The page used to replace itself with "Accounts +// unavailable" whenever `lastError` was set, so one refused toggle hid every +// account on the machine. import QtQuick import Quickshell @@ -22,118 +27,452 @@ import qs.services SettingsPage { id: root + objectName: "accounts" title: "Online Accounts" lede: OnlineAccounts.attentionCount > 0 ? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1 ? " account needs you to sign in again." : " accounts need you to sign in again.") - : "Accounts your mail, calendar, contacts, and files come from." + : "What this desktop signs in to — Panama's own integrations first." - Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh() + // "" | "nextcloud" | "imap" + property string openForm: "" + property string expandedAccount: "" + property string confirmingRemoval: "" - SettingsCard { - visible: !OnlineAccounts.available - title: "Accounts unavailable" - subtitle: OnlineAccounts.lastError - } + property string ncServer: "" + property string ncUser: "" + property string ncPassword: "" - SettingsCard { - visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0 - title: "No accounts yet" - subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in." + property string imapEmail: "" + property string imapHost: "" + property string smtpHost: "" + property string imapUser: "" + property string imapPassword: "" - ActionRow { - label: "Add an account" - detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos" - action: "Add account" - divider: false - onTriggered: SystemSettings.openGnomePanel("online-accounts") + readonly property bool nextcloudReady: /^https?:\/\/.+/.test(root.ncServer.trim()) + && root.ncUser.trim() !== "" && root.ncPassword !== "" + + readonly property bool imapReady: /^[^@\s]+@[^@\s]+$/.test(root.imapEmail.trim()) + && root.imapHost.trim() !== "" && root.smtpHost.trim() !== "" + && root.imapUser.trim() !== "" && root.imapPassword !== "" + + readonly property string homeSummary: { + if (!HomeAssistantConfig.configured) + return "Not set up"; + if (HomeAssistant.phase === "ready") { + const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length; + const host = root.hostOf(HomeAssistantConfig.url); + return named > 0 + ? host + " · " + named + " room" + (named === 1 ? "" : "s") + : host + " · " + HomeAssistant.discoveredCount + " lights"; } + if (HomeAssistant.phase === "degraded") + return root.hostOf(HomeAssistantConfig.url) + " · answering slowly"; + if (HomeAssistant.phase === "loading") + return "Asking " + root.hostOf(HomeAssistantConfig.url) + "…"; + return root.hostOf(HomeAssistantConfig.url) + " · not answering"; } - Repeater { - model: OnlineAccounts.accounts + readonly property string phoneName: KdeConnect.preferredPhone + ? String(KdeConnect.preferredPhone.name) + : "Phone" - SettingsCard { - id: accountCard - required property var modelData + readonly property string phoneSummary: { + if (!KdeConnect.available) + return "Not set up"; + if (!KdeConnect.preferredPhone) + return "KDE Connect · no phone paired yet"; + const battery = KdeConnect.phoneBattery; + const where = KdeConnect.phoneReachable ? "nearby on Wi-Fi" : "not nearby"; + return battery + ? "KDE Connect · " + where + " · battery " + battery.charge + "%" + : "KDE Connect · " + where; + } - // GOA hands back a preference-ordered chain; this walks it and takes - // the first name the active icon theme actually has. Taking the - // first name blindly, or the last as a fallback, both looked right - // and were not: on Adwaita the tail of these chains ("mail", - // "goa-symbolic") does not exist, so a miss would have rendered - // nothing at all. - icon: { - const chain = accountCard.modelData.providerIcons ?? []; - for (const name of chain) { - if (Quickshell.iconPath(String(name), true) !== "") - return String(name); - } - return ""; - } - iconFallback: "avatar-default-symbolic" + // Just the host, because a full URL in a subtitle is mostly scheme and + // trailing slash. + function hostOf(url: string): string { + const text = String(url ?? "").trim(); + if (text === "") + return "Home Assistant"; + return text.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + } - title: accountCard.modelData.identity || accountCard.modelData.providerName - subtitle: accountCard.modelData.needsAttention - ? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing" - : accountCard.modelData.providerName + // The single hand-off. Both the OAuth add row and a needs-attention row + // arrive here, so this file names a GNOME panel exactly once -- see + // gnome-handoff-contract, which allows this one and nothing else. + function openProviderDialog(): void { + SystemSettings.openGnomePanel("online-accounts"); + } - // Only when it is true, because it is the one thing on this page - // that needs acting on. - ActionRow { - visible: accountCard.modelData.needsAttention - label: "Sign in again" - detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts" - action: "Sign in" - onTriggered: SystemSettings.openGnomePanel("online-accounts") - } + function closeForms(): void { + root.openForm = ""; + root.ncServer = ""; + root.ncUser = ""; + root.ncPassword = ""; + nextcloudPassword.clear(); + root.imapEmail = ""; + root.imapHost = ""; + root.smtpHost = ""; + root.imapUser = ""; + root.imapPassword = ""; + imapPasswordField.clear(); + } - Repeater { - model: accountCard.modelData.services + // Every open, not only the first: accounts are added and removed by people + // -- sometimes in the very dialog this page hands them to -- so coming back + // to this page is exactly when the list is most likely to be stale. + Component.onCompleted: { + OnlineAccounts.refresh(); + // The phone row reads a service that is only refreshed when the + // Control Center's phone shelf opens, which this page cannot assume + // has happened. Home Assistant refreshes itself. + KdeConnect.refresh(); + } - SettingRow { - id: serviceRow - required property var modelData - required property int index + // Two errors, kept apart because they mean different things. A refused + // write is about the one thing somebody just did; a listing that failed is + // why the card below might be empty. Neither takes the page away. + TextRow { + visible: OnlineAccounts.writeError !== "" + label: "That did not go through" + detail: OnlineAccounts.writeError + value: "" + divider: false + } - label: serviceRow.modelData.label - detail: serviceRow.modelData.enabled - ? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account" - : "Hidden from applications" - controlWidth: 48 + TextRow { + visible: OnlineAccounts.snapshotError !== "" + label: "The account list could not be read" + detail: OnlineAccounts.snapshotError + value: "" + divider: false + } - SettingsToggle { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - checked: serviceRow.modelData.enabled - onToggled: value => OnlineAccounts.setService( - accountCard.modelData.path, serviceRow.modelData.key, value) + // ── What Panama itself uses ────────────────────────────────────────────── + + SettingsCard { + title: "This desktop" + subtitle: "The two services Panama signs in to on its own behalf." + + SettingRow { + // The same glyph the My Home page and its Control Center tile use. + icon: "\u{F0335}" + label: "Home Assistant" + detail: root.homeSummary + controlWidth: 210 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + visible: HomeAssistant.phase === "ready" + width: homeBadge.implicitWidth + 14 + height: 17 + radius: Theme.pillRadius + color: Theme.alpha(Theme.ok, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.ok, 0.3) + + Text { + id: homeBadge + + anchors.centerIn: parent + text: "CONNECTED" + color: Theme.ok + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) + font.weight: Font.DemiBold + font.letterSpacing: 0.5 } } - } - ActionRow { - label: "Remove this account" - detail: "Signs out and removes it from every application that was using it" - action: "Remove" - divider: false - onTriggered: OnlineAccounts.remove(accountCard.modelData.path) + SettingsButton { + text: HomeAssistantConfig.configured ? "Open Home" : "Set up" + onClicked: ShellState.openSettings("my-home") + } + } + } + + SettingRow { + // md-cellphone + icon: "\u{F011C}" + label: root.phoneName + detail: root.phoneSummary + controlWidth: 210 + divider: false + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + visible: KdeConnect.pairedCount > 0 + width: phoneBadge.implicitWidth + 14 + height: 17 + radius: Theme.pillRadius + color: Theme.alpha(Theme.ok, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.ok, 0.3) + + Text { + id: phoneBadge + + anchors.centerIn: parent + text: "PAIRED" + color: Theme.ok + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) + font.weight: Font.DemiBold + font.letterSpacing: 0.5 + } + } + + SettingsButton { + text: KdeConnect.pairedCount > 0 ? "Open Phone" : "Set up" + onClicked: ShellState.openSettings("phone") + } } } } + // ── Everything the applications use ────────────────────────────────────── + SettingsCard { - visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0 - title: "Add another account" - subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here." + title: "Accounts" + subtitle: "Signed in through GNOME Online Accounts, which is what mail, calendar, contacts and file managers read." + + TextRow { + visible: !OnlineAccounts.scanned + label: "Reading the account list…" + detail: "" + value: "" + divider: false + } + + TextRow { + visible: OnlineAccounts.scanned && OnlineAccounts.accounts.length === 0 + label: "No accounts yet" + detail: "Adding one lets mail, calendar, contacts and file managers share a single sign-in." + value: "" + divider: false + } + + Repeater { + model: OnlineAccounts.accounts + + delegate: OnlineAccountRow { + id: accountRow + + required property var modelData + required property int index + + account: accountRow.modelData + busy: OnlineAccounts.busy + // Said here rather than in the row, next to the hand-off it + // runs: GOA knows the token expired and nothing outside its own + // panel says so, which is how an account quietly stops syncing + // for weeks. + signInAction: "Sign in again" + expanded: root.expandedAccount === String(accountRow.modelData.path ?? "") + confirmingRemoval: root.confirmingRemoval === String(accountRow.modelData.path ?? "") + divider: accountRow.index < OnlineAccounts.accounts.length - 1 + + onActivated: { + const path = String(accountRow.modelData.path ?? ""); + root.expandedAccount = accountRow.expanded ? "" : path; + root.confirmingRemoval = ""; + } + onServiceToggled: (key, enabled) => OnlineAccounts.setService( + String(accountRow.modelData.path ?? ""), key, enabled) + onRemoveArmed: root.confirmingRemoval = String(accountRow.modelData.path ?? "") + onRemoveCancelled: root.confirmingRemoval = "" + onRemoveConfirmed: { + root.confirmingRemoval = ""; + root.expandedAccount = ""; + OnlineAccounts.remove(String(accountRow.modelData.path ?? "")); + } + onSignInAgainRequested: root.openProviderDialog() + } + } ActionRow { - label: "Add an account" - detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos" - action: "Add account" + label: "Check again" + detail: "Reads the list back from GOA, for when an account changed somewhere else" + action: OnlineAccounts.busy ? "Checking…" : "Refresh" + enabled: !OnlineAccounts.busy divider: false - onTriggered: SystemSettings.openGnomePanel("online-accounts") + onTriggered: OnlineAccounts.refresh() + } + } + + // ── Adding one ─────────────────────────────────────────────────────────── + + SettingsCard { + title: "Add an account" + + ActionRow { + label: "Nextcloud" + detail: "Server, username, password — signed in right here" + action: root.openForm === "nextcloud" ? "Cancel" : "Add…" + enabled: !OnlineAccounts.busy + divider: root.openForm === "nextcloud" + onTriggered: { + const wasOpen = root.openForm === "nextcloud"; + root.closeForms(); + if (!wasOpen) + root.openForm = "nextcloud"; + } + } + + Column { + width: parent.width + visible: root.openForm === "nextcloud" + + LiveFieldRow { + width: parent.width + label: "Server" + detail: "The address you open Nextcloud at, scheme and all" + placeholder: "https://cloud.example.org" + text: root.ncServer + enabled: !OnlineAccounts.busy + onEdited: value => root.ncServer = value + } + + LiveFieldRow { + width: parent.width + label: "Username" + placeholder: "you" + text: root.ncUser + enabled: !OnlineAccounts.busy + onEdited: value => root.ncUser = value + } + + SecretFieldRow { + id: nextcloudPassword + + width: parent.width + label: "Password" + detail: "Your password, or an app password — GOA signs in with Basic auth, so either works, and Nextcloud's Security settings issue an app password per device. It is handed over on stdin and never becomes a command-line argument." + placeholder: "Password or app password" + enabled: !OnlineAccounts.busy + onChanged: value => root.ncPassword = value + } + + ActionRow { + width: parent.width + label: "Sign in to Nextcloud" + detail: root.nextcloudReady + ? "Files, calendar and contacts arrive with the account" + : "A server address, a username and an app password are needed first" + action: OnlineAccounts.busy ? "Signing in…" : "Sign in" + enabled: root.nextcloudReady && !OnlineAccounts.busy + divider: false + onTriggered: { + OnlineAccounts.addNextcloud(root.ncServer.trim(), root.ncUser.trim(), + root.ncPassword); + root.closeForms(); + } + } + } + + ActionRow { + label: "Mail (IMAP & SMTP)" + detail: "Server details and password — native form" + action: root.openForm === "imap" ? "Cancel" : "Add…" + enabled: !OnlineAccounts.busy + divider: root.openForm === "imap" + onTriggered: { + const wasOpen = root.openForm === "imap"; + root.closeForms(); + if (!wasOpen) + root.openForm = "imap"; + } + } + + Column { + width: parent.width + visible: root.openForm === "imap" + + LiveFieldRow { + width: parent.width + label: "Email address" + placeholder: "you@example.com" + text: root.imapEmail + enabled: !OnlineAccounts.busy + onEdited: value => root.imapEmail = value + } + + LiveFieldRow { + width: parent.width + label: "Incoming server" + detail: "IMAP" + placeholder: "imap.example.com" + text: root.imapHost + enabled: !OnlineAccounts.busy + onEdited: value => root.imapHost = value + } + + LiveFieldRow { + width: parent.width + label: "Outgoing server" + detail: "SMTP" + placeholder: "smtp.example.com" + text: root.smtpHost + enabled: !OnlineAccounts.busy + onEdited: value => root.smtpHost = value + } + + LiveFieldRow { + width: parent.width + label: "Username" + detail: "Often the address again, sometimes not" + placeholder: "you@example.com" + text: root.imapUser + enabled: !OnlineAccounts.busy + onEdited: value => root.imapUser = value + } + + SecretFieldRow { + id: imapPasswordField + + width: parent.width + label: "Password" + detail: "Handed over on stdin, never as a command-line argument" + enabled: !OnlineAccounts.busy + onChanged: value => root.imapPassword = value + } + + ActionRow { + width: parent.width + label: "Add this mail account" + detail: root.imapReady + ? "GOA stores it and mail clients pick it up from there" + : "An address, both servers, a username and a password are needed first" + action: OnlineAccounts.busy ? "Adding…" : "Add account" + enabled: root.imapReady && !OnlineAccounts.busy + divider: false + onTriggered: { + OnlineAccounts.addImap(root.imapEmail.trim(), root.imapHost.trim(), + root.smtpHost.trim(), root.imapUser.trim(), + root.imapPassword); + root.closeForms(); + } + } + } + + ActionRow { + label: "Google or Microsoft" + detail: "OAuth needs the provider's own browser sign-in, which only GOA's dialog can run — the one thing this page hands off" + action: "Open GNOME dialog…" + divider: false + onTriggered: root.openProviderDialog() } } } diff --git a/config/dot/quickshell/modules/settings/PasswordStrengthRow.qml b/config/dot/quickshell/modules/settings/PasswordStrengthRow.qml new file mode 100644 index 0000000..9f4a457 --- /dev/null +++ b/config/dot/quickshell/modules/settings/PasswordStrengthRow.qml @@ -0,0 +1,77 @@ +// Four segments saying how much a password has going for it. +// +// The score is computed here, from length and which character classes appear, +// and from nothing else. There is no dictionary, no breach list and no network +// call, so the row claims exactly what it can back up: it is headed "Strength" +// and says in its own detail line that it only measures shape. A meter that +// says "Strong" about a leaked password is worse than no meter, so this one +// never uses that word. + +import QtQuick +import qs.config + +SettingRow { + id: root + + property string password: "" + + readonly property int score: { + const value = root.password; + if (value === "") + return 0; + + let classes = 0; + if (/[a-z]/.test(value)) + classes += 1; + if (/[A-Z]/.test(value)) + classes += 1; + if (/[0-9]/.test(value)) + classes += 1; + if (/[^A-Za-z0-9]/.test(value)) + classes += 1; + + let points = Math.max(0, classes - 1); + if (value.length >= 8) + points += 1; + if (value.length >= 12) + points += 1; + if (value.length >= 16) + points += 1; + + if (value.length < 8) + return 1; + return Math.max(1, Math.min(4, points)); + } + + readonly property color tone: { + if (root.score <= 1) + return Theme.danger; + if (root.score === 2) + return Theme.warn; + return Theme.ok; + } + + label: "Strength" + detail: "Length and variety of characters, measured here — nothing is sent anywhere, and nothing is checked against known passwords" + controlWidth: 150 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + + Repeater { + model: 4 + + delegate: Rectangle { + required property int index + + width: 32 + height: 5 + radius: 2.5 + color: index < root.score ? root.tone : Theme.alpha(Theme.fg, 0.12) + border.width: 0 + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SecretFieldRow.qml b/config/dot/quickshell/modules/settings/SecretFieldRow.qml new file mode 100644 index 0000000..e52f7f1 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SecretFieldRow.qml @@ -0,0 +1,42 @@ +// A row whose trailing control is a masked field that can be emptied on demand. +// +// PasswordRow already reports every keystroke, which is what comparing two +// entries as they are typed needs. What it cannot do is forget: its field is +// private, so a form that closed left the typed password sitting in a hidden +// TextInput for the rest of the session. +// +// This exposes `clear()` so the page that owns a form can empty it when the +// form succeeds or closes, which is the only difference between the two. + +import QtQuick +import qs.config + +SettingRow { + id: root + + property string placeholder: "Password" + property bool enabled: true + + signal changed(value: string) + signal accepted + + controlWidth: 220 + + function clear(): void { + field.clear(); + root.changed(""); + } + + PasswordField { + id: field + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: root.controlWidth + enabled: root.enabled + opacity: root.enabled ? 1 : 0.5 + placeholder: root.placeholder + onTextChanged: root.changed(field.text) + onAccepted: root.accepted() + } +} diff --git a/config/dot/quickshell/modules/settings/StockAvatarPicker.qml b/config/dot/quickshell/modules/settings/StockAvatarPicker.qml new file mode 100644 index 0000000..f1572a8 --- /dev/null +++ b/config/dot/quickshell/modules/settings/StockAvatarPicker.qml @@ -0,0 +1,116 @@ +// The pictures already on the machine, offered before the file chooser. +// +// /usr/share/pixmaps/faces is what GNOME's Users panel shows first, and it is +// the answer for most people: a picture that is already the right shape, needs +// no cropping, and costs one click. The file chooser stays right beside it for +// everyone else, so choosing a photo is not a second-class path -- it is simply +// the one that needs a crop step afterwards. +// +// An empty gallery is a normal state on a machine with no faces package +// installed, and says so rather than rendering an empty strip. + +import QtQuick +import Quickshell.Widgets +import qs.config + +Column { + id: root + + // [{ name, path }] -- whatever the helper found, in the order it found it. + property var avatars: [] + property bool enabled: true + + signal picked(path: string) + signal fileRequested + + width: parent ? parent.width : 620 + spacing: 10 + bottomPadding: 6 + + Flow { + width: parent.width + spacing: 8 + + Repeater { + model: root.avatars + + delegate: Item { + id: tile + + required property var modelData + + readonly property string path: String(tile.modelData?.path ?? "") + + width: 46 + height: 46 + + ClippingRectangle { + anchors.fill: parent + anchors.margins: 2 + radius: width / 2 + color: Theme.alpha(Theme.fg, 0.08) + + Image { + anchors.fill: parent + source: tile.path === "" ? "" : "file://" + tile.path + fillMode: Image.PreserveAspectCrop + asynchronous: true + sourceSize.width: 96 + sourceSize.height: 96 + } + } + + // The ring is its own item rather than a border on the clipping + // rectangle: that one clips its children and does not carry a + // border group of its own. + Rectangle { + anchors.fill: parent + radius: width / 2 + color: "transparent" + border.width: tileHover.hovered ? 2 : 1 + border.color: tileHover.hovered + ? Theme.alpha(Theme.accent, 0.7) + : Theme.alpha(Theme.fg, 0.1) + } + + HoverHandler { + id: tileHover + enabled: root.enabled + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.enabled && tile.path !== "" + onTapped: root.picked(tile.path) + } + } + } + + // Wrapped so it sits on the tiles' centre line rather than on their top + // edge: Flow lays children out by their own height, and the button is + // fifteen pixels shorter than a picture. + Item { + width: chooseFile.implicitWidth + height: 46 + + SettingsButton { + id: chooseFile + + anchors.verticalCenter: parent.verticalCenter + text: "Choose a file…" + enabled: root.enabled + onClicked: root.fileRequested() + } + } + } + + Text { + width: parent.width + visible: root.avatars.length === 0 + text: "No pictures are installed on this machine, so a file of your own is the only choice here." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } +} diff --git a/config/dot/quickshell/modules/settings/UsersPage.qml b/config/dot/quickshell/modules/settings/UsersPage.qml index c18ba75..974136e 100644 --- a/config/dot/quickshell/modules/settings/UsersPage.qml +++ b/config/dot/quickshell/modules/settings/UsersPage.qml @@ -7,6 +7,10 @@ // // Everything privileged here prompts through polkit. A prompt that is dismissed // is a normal outcome and says so plainly rather than reporting a failure. +// +// This page hands nothing to GNOME. Fingerprint enrollment was the last thing +// that did, and Panama drives fprintd's EnrollStart itself now -- see +// fingerprint-contract, which pins the absence. import Quickshell import Quickshell.Widgets @@ -19,20 +23,74 @@ SettingsPage { objectName: "users" title: "Users" - lede: "Your account, and anyone else who signs in to this machine." + lede: "Who this machine belongs to, and how it knows it's you." // One panel open at a time: changing a password and adding an account are // both multi-field, and two open at once reads as a form with no shape. property string openPanel: "" + property bool pictureOpen: false + property string newPassword: "" property string confirmPassword: "" + property string newUserName: "" property string newRealName: "" property bool newUserIsAdministrator: false + + // Which other account is unfolded, which one is one press from deletion, + // and what that deletion would do to their files. All three are page state + // rather than row state, so opening a second row puts the first one's armed + // Delete button away instead of leaving two of them armed at once. + property string expandedUser: "" property string confirmingRemoval: "" + // "keep" | "remove" + property string removalDisposition: "keep" + + // Which enrolled finger is one press from being forgotten, and which one + // the next enrollment would take. The finger being enrolled right now is + // the service's business, not this page's. + property string confirmingFinger: "" + property string chosenFinger: "right-index-finger" readonly property var me: UserAccounts.me + // The service owns fprintd's vocabulary and subtracts what is already + // enrolled; this only pairs each one with its label for the picker. + readonly property var fingerOptions: Fingerprint.availableFingers.map( + finger => ({ value: finger, label: Fingerprint.fingerLabel(finger) })) + + // Keeps the picker pointing at something that can still be enrolled: the + // default finger stops being offered the moment it is enrolled, and a + // picker showing a blank current value reads as broken. + onFingerOptionsChanged: { + if (root.fingerOptions.length === 0) + return; + if (!root.fingerOptions.some(option => option.value === root.chosenFinger)) + root.chosenFinger = String(root.fingerOptions[0].value); + } + + readonly property bool fingerprintStuck: Fingerprint.unlockFeatureEnabled + && !Fingerprint.readerPresent + + // What the last touch did, in words. fprintd's result strings are for + // programs; this is the one line someone looking up from the reader reads. + readonly property string enrollHint: { + if (Fingerprint.enrollPhase === "claiming") + return "Waking the reader…"; + if (Fingerprint.enrollPhase === "failed") + return "The reader gave up on that one — start again when you are ready."; + const result = String(Fingerprint.enrollResult); + if (result === "" || result === "enroll-stage-passed") + return ""; + if (/too-short|swipe-too-short/.test(result)) + return "That touch was too brief — hold it there a moment longer."; + if (/remove-and-retry|retry-scan/.test(result)) + return "Lift your finger all the way off, then press again."; + if (/centered|too-fast|duplicate/.test(result)) + return "Land more of the pad on the reader and hold still."; + return "That touch did not take. Try again."; + } + readonly property string passwordProblem: { if (root.newPassword === "") return ""; @@ -46,15 +104,56 @@ SettingsPage { readonly property bool passwordReady: root.newPassword.length >= 6 && root.newPassword === root.confirmPassword + // The helper's own rule, character for character -- panama-users compiles + // exactly this one, and a page that tests something slightly different + // accepts a name accountsservice then refuses, with nothing on screen to + // say why. The length cap is part of the expression rather than a separate + // check beside it, for the same reason. + // + // Read while the name is being typed, by both the sentence under the field + // and the Create button, so the two can never disagree. + readonly property bool newUserNameValid: /^[a-z_][a-z0-9_-]{0,31}$/.test(root.newUserName) + + readonly property string userNameProblem: { + const value = root.newUserName; + if (value === "") + return ""; + if (!root.newUserNameValid) + return "Start with a lowercase letter or _, then lowercase letters, digits, - and _ — up to 31 more."; + if (UserAccounts.users.some(user => String(user.userName ?? "") === value)) + return "An account with that username already exists."; + return ""; + } + function closePanels(): void { root.openPanel = ""; root.newPassword = ""; root.confirmPassword = ""; + newPasswordField.clear(); + confirmPasswordField.clear(); root.newUserName = ""; root.newRealName = ""; root.newUserIsAdministrator = false; } + // accountsservice reports the last session start in seconds; a helper that + // hands back milliseconds is taken at its word rather than read as a date + // fifty thousand years from now. + function signInSummary(user: var): string { + const raw = Number(user?.loginTime ?? 0); + if (!Number.isFinite(raw) || raw <= 0) + return "has not signed in yet"; + const when = new Date((raw > 1e12 ? raw / 1000 : raw) * 1000); + const days = Math.floor((Date.now() - when.getTime()) / 86400000); + if (days <= 0) + return "last signed in today"; + if (days === 1) + return "last signed in yesterday"; + if (days < 30) + return "last signed in " + days + " days ago"; + return "last signed in " + when.toLocaleDateString(Qt.locale(), Locale.ShortFormat); + } + Component.onCompleted: { UserAccounts.refresh(); // Probing fprintd bus-activates it, so it waits for the page rather @@ -62,9 +161,11 @@ SettingsPage { Fingerprint.refresh(); } + // A write that failed is a row, not a page. Everything below stays usable, + // because the thing that refused is almost never the thing being read. TextRow { visible: UserAccounts.lastError !== "" - label: "Accounts need attention" + label: "That change did not go through" detail: UserAccounts.lastError value: "" divider: false @@ -160,25 +261,64 @@ SettingsPage { Item { width: 1; height: 6 } - SettingsButton { - text: "Change picture…" - enabled: !UserAccounts.busy - onClicked: avatarPicker.open() + Row { + spacing: 8 + + SettingsButton { + text: root.pictureOpen ? "Done" : "Change picture…" + enabled: !UserAccounts.busy + onClicked: { + // The gallery is a directory listing, read the + // first time someone asks to see it. + if (!root.pictureOpen) + UserAccounts.loadStockAvatars(); + root.pictureOpen = !root.pictureOpen; + } + } + + SettingsButton { + visible: UserAccounts.avatarUrl !== "" + text: "Remove" + enabled: !UserAccounts.busy + onClicked: { + root.pictureOpen = false; + UserAccounts.removeIcon(); + } + } } } } + + StockAvatarPicker { + width: parent.width + visible: root.pictureOpen + avatars: UserAccounts.stockAvatars + enabled: !UserAccounts.busy + onPicked: path => { + root.pictureOpen = false; + UserAccounts.setIcon(String(root.me?.userName ?? ""), path); + } + // A photo is almost never square, so it goes through the cropper + // rather than being squeezed into a circle by the image loader. + onFileRequested: { + root.pictureOpen = false; + avatarPicker.open(); + } + } } SettingsCard { title: "Account" - visible: root.me !== null + visible: root.me !== null && root.pendingPicture === "" - TextFieldRow { + LiveFieldRow { label: "Full name" detail: "Shown on the lock screen and in the Control Center" text: String(root.me?.realName ?? "") placeholder: "Your name" enabled: !UserAccounts.busy + // Committed on Enter or when the field is left, never per + // keystroke: this one goes through polkit. onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value) } @@ -191,7 +331,7 @@ SettingsPage { SegmentRow { label: "Account type" detail: root.me?.administrator && UserAccounts.administratorCount <= 1 - ? "This is the only administrator, so it cannot be changed" + ? "The last administrator cannot step down" : "Administrators can install software and manage other accounts" options: [ { value: "standard", label: "Standard" }, @@ -212,12 +352,10 @@ SettingsPage { enabled: !UserAccounts.busy divider: root.openPanel === "password" onTriggered: { - if (root.openPanel === "password") - root.closePanels(); - else { - root.closePanels(); + const wasOpen = root.openPanel === "password"; + root.closePanels(); + if (!wasOpen) root.openPanel = "password"; - } } } @@ -225,19 +363,30 @@ SettingsPage { width: parent.width visible: root.openPanel === "password" - PasswordRow { + SecretFieldRow { + id: newPasswordField + width: parent.width label: "New password" detail: "At least six characters" + enabled: !UserAccounts.busy onChanged: value => root.newPassword = value } - PasswordRow { + PasswordStrengthRow { + width: parent.width + password: root.newPassword + } + + SecretFieldRow { + id: confirmPasswordField + width: parent.width label: "Confirm" detail: root.passwordProblem !== "" ? root.passwordProblem : "Type it a second time" + enabled: !UserAccounts.busy onChanged: value => root.confirmPassword = value } @@ -257,7 +406,7 @@ SettingsPage { SwitchRow { label: "Automatic login" - detail: "Sign in without typing a password. The login keyring stays locked when this is on, so stored passwords are unavailable until something asks for them." + detail: "Anyone at the keyboard becomes you — the disk stays encrypted, the session does not. The login keyring stays locked too, so stored passwords wait until something asks for them." checked: root.me?.automaticLogin === true enabled: !UserAccounts.busy divider: false @@ -267,50 +416,195 @@ SettingsPage { // ── Fingerprint ────────────────────────────────────────────────────────── // - // Hidden in full on a machine with no reader. Two systems make this work - // and the card keeps them honest with each other: fprintd holds the - // enrolled prints (GNOME's Users panel owns that dialog, so enrollment - // hands off the same way password-adjacent panels do), and authselect - // decides whether PAM asks the reader at all -- a print enrolled while - // that is off does nothing, which reads as "fingerprint is broken". + // Two systems make a working fingerprint login and the card keeps them + // honest with each other: fprintd holds the enrolled prints, and authselect + // decides whether PAM asks the reader at all. A print enrolled while + // authselect is off does nothing, and authselect left on with no reader + // attached asks PAM for a finger nothing can read -- which is why the card + // appears for either fact, not only when a reader is plugged in. SettingsCard { - visible: Fingerprint.readerPresent + // `cardVisible` is the service's `readerPresent || unlockFeatureEnabled` + // -- one expression, so the card and the state it describes cannot + // disagree about when the stuck case exists. + visible: Fingerprint.cardVisible title: "Fingerprint" - subtitle: Fingerprint.readerName !== "" + subtitle: Fingerprint.readerPresent && Fingerprint.readerName !== "" ? Fingerprint.readerName - : "A fingerprint reader is present." + : "" - SwitchRow { - label: "Unlock with a fingerprint" - detail: { - if (Fingerprint.enrolled.length === 0) - return "Enroll a finger below first; until then the password is the only way in"; - return Fingerprint.pamEnabled - ? "The lock screen and sudo accept an enrolled finger, with the password as fallback" - : "Enrolled fingers are ignored until this is on"; + // The stuck state: the feature is on, and there is nothing to read a + // finger with. Reachable here because it is unreachable anywhere else -- + // the card used to hide itself on exactly the machine that needed it. + SettingRow { + visible: root.fingerprintStuck + label: "Fingerprint unlock is on, but no reader is connected" + detail: "authselect still asks PAM for a finger that can't be read — turn it off here, or plug the reader back in" + controlWidth: 190 + divider: false + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: noReader.implicitWidth + 14 + height: 17 + radius: Theme.pillRadius + color: Theme.alpha(Theme.warn, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.warn, 0.3) + + Text { + id: noReader + + anchors.centerIn: parent + text: "NO READER" + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) + font.weight: Font.DemiBold + font.letterSpacing: 0.5 + } + } + + SettingsButton { + text: "Turn off" + enabled: !Fingerprint.busy + onClicked: Fingerprint.setUnlockEnabled(false) + } } - checked: Fingerprint.pamEnabled - enabled: !Fingerprint.busy - onToggled: value => Fingerprint.setUnlockEnabled(value) } - ActionRow { - label: "Enrolled fingers" - detail: Fingerprint.enrolled.length === 0 - ? "None yet" - : Fingerprint.enrolled.map(finger => Fingerprint.fingerLabel(finger)).join(", ") - action: "Manage…" - divider: Fingerprint.lastError !== "" - // GNOME's Users panel owns the enrollment dialog; growing our own - // means reimplementing a guided capture flow fprintd already has - // a good one of. - onTriggered: SystemSettings.openGnomePanel("system", "users") + Column { + width: parent.width + visible: Fingerprint.readerPresent + + SwitchRow { + width: parent.width + label: "Unlock with a fingerprint" + detail: { + if (Fingerprint.fingers.length === 0) + return "Enroll a finger below first; until then the password is the only way in"; + return "The lock screen and sudo — system-wide, via authselect, with the password as fallback"; + } + checked: Fingerprint.unlockFeatureEnabled + enabled: !Fingerprint.busy && !Fingerprint.enrolling + onToggled: value => Fingerprint.setUnlockEnabled(value) + } + + Text { + width: parent.width + text: "ENROLLED FINGERS" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + topPadding: 12 + bottomPadding: 4 + } + + TextRow { + width: parent.width + visible: Fingerprint.fingers.length === 0 + label: "None yet" + detail: "A finger has to be enrolled before the reader can let anyone in" + value: "" + divider: false + } + + Repeater { + model: Fingerprint.fingers + + delegate: SettingRow { + id: fingerRow + + required property var modelData + + readonly property string finger: String(fingerRow.modelData) + readonly property bool confirming: root.confirmingFinger === fingerRow.finger + + width: parent.width + // md-fingerprint + icon: "\u{F0306}" + label: Fingerprint.fingerLabel(fingerRow.finger) + detail: fingerRow.confirming + ? "Forgetting it takes effect at once; it can be enrolled again afterwards" + : "" + controlWidth: 190 + divider: false + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: fingerRow.confirming ? "Keep" : "Remove…" + enabled: !Fingerprint.busy && !Fingerprint.enrolling + onClicked: root.confirmingFinger = fingerRow.confirming + ? "" : fingerRow.finger + } + + SettingsButton { + visible: fingerRow.confirming + text: "Forget it" + tone: "danger" + enabled: !Fingerprint.busy + onClicked: { + root.confirmingFinger = ""; + Fingerprint.removeFinger(fingerRow.finger); + } + } + } + } + } + + OptionPickerRow { + width: parent.width + visible: !Fingerprint.enrolling && root.fingerOptions.length > 1 + label: "Finger" + detail: "Which one the reader is about to learn" + options: root.fingerOptions + current: root.chosenFinger + onPicked: value => root.chosenFinger = String(value) + } + + ActionRow { + width: parent.width + visible: !Fingerprint.enrolling + label: "Add a fingerprint" + detail: root.fingerOptions.length === 0 + ? "Every finger the reader knows about is already enrolled" + : "Around ten touches, guided here — the reader does the work" + action: "Enroll…" + enabled: !Fingerprint.busy && root.fingerOptions.length !== 0 + divider: false + onTriggered: { + root.confirmingFinger = ""; + Fingerprint.startEnroll(root.chosenFinger); + } + } + + FingerprintEnrollPanel { + width: parent.width + visible: Fingerprint.enrolling + // The finger the service is actually working on, not the one + // the picker happens to be showing. + finger: Fingerprint.fingerLabel(Fingerprint.enrollFinger) + stage: Fingerprint.enrollStage + total: Fingerprint.enrollTotal + message: root.enrollHint + onCancelled: Fingerprint.cancelEnroll() + } } TextRow { visible: Fingerprint.lastError !== "" - label: "Fingerprint needs attention" + label: "The reader had something to say" detail: Fingerprint.lastError value: "" divider: false @@ -337,48 +631,149 @@ SettingsPage { width: parent.width readonly property string userName: String(otherBlock.modelData.userName ?? "") + readonly property bool expanded: root.expandedUser === otherBlock.userName readonly property bool confirming: root.confirmingRemoval === otherBlock.userName + readonly property bool locked: otherBlock.modelData.locked === true + readonly property bool lastAdministrator: + otherBlock.modelData.administrator && UserAccounts.administratorCount <= 1 SettingRow { width: otherBlock.width label: UserAccounts.displayName(otherBlock.modelData) detail: otherBlock.userName + " · " - + (otherBlock.modelData.administrator ? "Administrator" : "Standard account") - controlWidth: 210 - divider: false + + (otherBlock.modelData.administrator ? "Administrator" : "Standard") + + " · " + (otherBlock.locked + ? "locked" + : root.signInSummary(otherBlock.modelData)) + controlWidth: 26 + divider: !otherBlock.expanded + activatable: true + onActivated: { + root.expandedUser = otherBlock.expanded ? "" : otherBlock.userName; + root.confirmingRemoval = ""; + root.removalDisposition = "keep"; + } - Row { + Text { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - spacing: 8 - - SettingsButton { - text: otherBlock.confirming ? "Keep" : "Remove…" - enabled: !UserAccounts.busy - onClicked: root.confirmingRemoval = otherBlock.confirming - ? "" : otherBlock.userName - } - - SettingsButton { - visible: otherBlock.confirming - text: "Delete account and files" - tone: "danger" - enabled: !UserAccounts.busy - onClicked: { - root.confirmingRemoval = ""; - UserAccounts.deleteUser(otherBlock.userName, true); - } - } + text: otherBlock.expanded ? "▴" : "▾" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall } } - TextRow { + Column { width: otherBlock.width - visible: otherBlock.confirming - label: "This cannot be undone" - detail: "Their home directory and everything in it is deleted." - value: "" - divider: false + leftPadding: 14 + visible: otherBlock.expanded + + SegmentRow { + width: otherBlock.width - 14 + label: "Account type" + detail: otherBlock.lastAdministrator + ? "The last administrator cannot step down" + : "Administrators can install software and manage other accounts" + options: [ + { value: "standard", label: "Standard" }, + { value: "administrator", label: "Administrator" } + ] + value: otherBlock.modelData.administrator ? "administrator" : "standard" + enabled: !UserAccounts.busy && !otherBlock.lastAdministrator + onSelected: value => UserAccounts.setAccountTypeFor(otherBlock.userName, value) + } + + ActionRow { + width: otherBlock.width - 14 + visible: otherBlock.locked + label: "This account is locked" + detail: "Nothing signs in to it until it is unlocked again" + action: "Unlock" + enabled: !UserAccounts.busy + onTriggered: UserAccounts.setLocked(otherBlock.userName, false) + } + + ActionRow { + width: otherBlock.width - 14 + label: "Reset password" + detail: "They set a new one at next sign-in. Nothing is typed here, and no password is chosen for them." + action: "Reset…" + enabled: !UserAccounts.busy + onTriggered: UserAccounts.resetPassword(otherBlock.userName) + } + + OptionPickerRow { + width: otherBlock.width - 14 + label: "Their files" + detail: "What deleting the account does to /home/" + otherBlock.userName + options: [ + { + value: "keep", + label: "Keep the files", + detail: "/home/" + otherBlock.userName + + " stays exactly where it is, and you can hand it to someone later" + }, + { + value: "remove", + label: "Remove everything", + detail: "The home directory and everything inside it is deleted with the account" + } + ] + current: root.removalDisposition + enabled: !UserAccounts.busy + onPicked: value => root.removalDisposition = String(value) + } + + SettingRow { + width: otherBlock.width - 14 + label: otherBlock.confirming + ? "Delete " + UserAccounts.displayName(otherBlock.modelData) + "?" + : "Delete this account" + detail: { + if (!otherBlock.confirming) + return "The account stops existing. What happens to their files is the choice above."; + if (root.removalDisposition === "remove") + return "This cannot be undone — /home/" + otherBlock.userName + + " and everything in it is deleted along with the account."; + return "Their files stay in /home/" + otherBlock.userName + + ", so only the account itself goes."; + } + controlWidth: 260 + divider: false + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: otherBlock.confirming ? "Keep the account" : "Delete…" + enabled: !UserAccounts.busy + onClicked: root.confirmingRemoval = otherBlock.confirming + ? "" : otherBlock.userName + } + + SettingsButton { + visible: otherBlock.confirming + text: root.removalDisposition === "remove" + ? "Delete account and files" + : "Delete the account" + tone: "danger" + enabled: !UserAccounts.busy + onClicked: { + // The helper's second argument is + // keep-files, not remove-files: the two + // read alike at a glance and mean opposite + // things to someone's home directory. + const keepFiles = root.removalDisposition === "keep"; + root.confirmingRemoval = ""; + root.expandedUser = ""; + UserAccounts.deleteUser(otherBlock.userName, keepFiles); + } + } + } + } } Item { width: 1; height: 6 } @@ -392,35 +787,43 @@ SettingsPage { enabled: !UserAccounts.busy divider: root.openPanel === "newUser" onTriggered: { - if (root.openPanel === "newUser") - root.closePanels(); - else { - root.closePanels(); + const wasOpen = root.openPanel === "newUser"; + root.closePanels(); + if (!wasOpen) root.openPanel = "newUser"; - } } } + // Live, on purpose. These fields report every keystroke -- they are + // LiveFieldRow rather than TextFieldRow -- because the Create button + // has to darken and light as the username is typed. With blur-committed + // fields it stayed dark until focus left, which reads as a dead button. Column { width: parent.width visible: root.openPanel === "newUser" - TextFieldRow { + LiveFieldRow { width: parent.width label: "Full name" placeholder: "Their name" detail: "Shown on the login screen" text: root.newRealName - onAccepted: value => root.newRealName = value + enabled: !UserAccounts.busy + onEdited: value => root.newRealName = value } - TextFieldRow { + LiveFieldRow { width: parent.width label: "Username" - placeholder: "lowercase, no spaces" - detail: "Their home directory is named after this and cannot be changed later" + placeholder: "riley" + detail: root.userNameProblem !== "" + ? root.userNameProblem + : "A lowercase letter or _, then lowercase letters, digits, - and _ · up to 31 more. Their home directory is named after it and cannot be changed later." + invalid: root.userNameProblem !== "" + maximumLength: 32 text: root.newUserName - onAccepted: value => root.newUserName = value + enabled: !UserAccounts.busy + onEdited: value => root.newUserName = value } SegmentRow { @@ -432,6 +835,7 @@ SettingsPage { { value: "administrator", label: "Administrator" } ] value: root.newUserIsAdministrator ? "administrator" : "standard" + enabled: !UserAccounts.busy onSelected: value => root.newUserIsAdministrator = value === "administrator" } @@ -440,7 +844,11 @@ SettingsPage { label: "Create the account" detail: "They set their own password the first time they sign in" action: "Create" - enabled: !UserAccounts.busy && /^[a-z_][a-z0-9_-]*$/.test(root.newUserName) + // The same rule the sentence under the field states, read from + // the one place it is written down. + enabled: !UserAccounts.busy + && root.newUserNameValid + && root.userNameProblem === "" divider: false onTriggered: { UserAccounts.createUser(root.newUserName, root.newRealName, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index e530f38..740bef3 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -124,3 +124,9 @@ ThemeEditorWells 1.0 ThemeEditorWells.qml ThemeSaturationRow 1.0 ThemeSaturationRow.qml ThemeStartChips 1.0 ThemeStartChips.qml ThemeSaveRow 1.0 ThemeSaveRow.qml +LiveFieldRow 1.0 LiveFieldRow.qml +SecretFieldRow 1.0 SecretFieldRow.qml +PasswordStrengthRow 1.0 PasswordStrengthRow.qml +StockAvatarPicker 1.0 StockAvatarPicker.qml +FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml +OnlineAccountRow 1.0 OnlineAccountRow.qml diff --git a/config/dot/quickshell/scripts/panama-accounts b/config/dot/quickshell/scripts/panama-accounts index e36a450..dbfaedc 100755 --- a/config/dot/quickshell/scripts/panama-accounts +++ b/config/dot/quickshell/scripts/panama-accounts @@ -12,22 +12,45 @@ turning individual services on and off, and removing an account. That is the whole Online Accounts panel apart from one OAuth handshake. Adding an account is the part that splits. The daemon's AddAccount takes -credentials as an ARGUMENT -- it stores them, it does not obtain them. For -password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a -password, which a settings app can reasonably collect. For Google it is an OAuth -token, and the code that runs that exchange lives in libgoa-backend, which -Fedora ships without a GIR binding -- reachable from C only. Reimplementing it -would mean our own Google client credentials. So Google sign-in is handed to -GNOME's panel, and only the sign-in. +credentials as an ARGUMENT -- it stores them, it does not obtain them, and every +part of that storing happens inside goa-daemon, which does have the backend. +For password-based providers (Nextcloud, IMAP) that argument is a username and a +password, which a settings app can reasonably collect, so those are added here. +For Google it is an OAuth token, and the code that runs that exchange lives in +libgoa-backend, which Fedora ships without a GIR binding -- reachable from C +only. Reimplementing it would mean our own Google client credentials. So OAuth +sign-in is handed to GNOME's panel, and only the sign-in. + +A password reaches this script on stdin and nowhere else. argv is world-readable +through /proc, so a password passed as an argument is published to every process +on the machine. Usage: - panama-accounts list + panama-accounts list | snapshot panama-accounts set panama-accounts remove + panama-accounts add-nextcloud SERVER USERNAME (password on stdin) + panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME (password on stdin) """ import json +import os +import re import sys +import urllib.parse + + +class BoundaryError(RuntimeError): + """A user-visible validation or sign-in failure.""" + + +# A canned GOA, for contract runs: a JSON file in the shape `list` prints. With +# it set, nothing here imports gi or touches the session bus, and every change a +# verb would have made is appended to PANAMA_ACCOUNTS_LOG instead -- with the +# password replaced by its length, so a contract can prove it was read from +# stdin without a secret ever reaching a file. +FIXTURE_ENV = "PANAMA_ACCOUNTS_FIXTURE" +LOG_ENV = "PANAMA_ACCOUNTS_LOG" # Every service GOA models. The account object carries one interface per service # it supports, so presence of the interface is what "this account can do mail" @@ -111,8 +134,319 @@ def find(client, path): return None +# ── Adding a password account ──────────────────────────────────────────────── +# +# The keys below are not invented. They are the keys goa-daemon writes into +# ~/.config/goa-1.0/accounts.conf, which is to say the ones each provider's +# build_object reads back -- taken from GOA 3.58's goaowncloudprovider.c and +# goaimapsmtpprovider.c, and confirmed against the accounts already on this +# machine. A key GOA does not recognize is silently ignored, so getting one +# wrong produces an account that exists and does nothing. + + +def read_password() -> str: + """The password, from stdin. Never an argument, at any point in the chain.""" + secret = sys.stdin.buffer.read() + # A trailing newline from a pipe is not part of the password. + if secret.endswith(b"\n"): + secret = secret[:-1] + if not secret: + raise BoundaryError("No password was provided.") + return secret.decode("utf-8", "surrogateescape") + + +# RFC 1123 hostname (or a dotted IPv4). These strings end up in accounts.conf +# and in URIs other processes fetch, so a shell metacharacter or a space is a +# malformed address whichever way it got here -- refuse it at the boundary. +HOSTNAME = re.compile( + r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?" + r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$") + + +def valid_host(text: str) -> bool: + return bool(text) and len(text) <= 253 and bool(HOSTNAME.match(text)) + + +def nextcloud_uris(server: str) -> tuple[str, str, str]: + """(WebDAV URI, DAV URI, host) for a Nextcloud address. + + `remote.php/webdav` and `remote.php/dav` are Nextcloud's fixed layout, and + are exactly what GOA's own provider derives from the server it was given. + """ + text = (server or "").strip() + if not text: + raise BoundaryError("Enter the address of your Nextcloud server.") + if "://" not in text: + text = "https://" + text + + parsed = urllib.parse.urlsplit(text) + if parsed.scheme not in ("http", "https"): + raise BoundaryError("A server address starts with https://.") + if not parsed.hostname or not valid_host(parsed.hostname): + raise BoundaryError("That is not a server address.") + # urlsplit is permissive about what follows the host; a path is fine, but + # spaces or shell punctuation anywhere in the address are not an URL that + # a Nextcloud server has. + if re.search(r"[\s;|&`$<>\"']", text): + raise BoundaryError("That is not a server address.") + + base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}" + return base + "/remote.php/webdav/", base + "/remote.php/dav/", parsed.hostname + + +def nextcloud_account(server: str, username: str) -> tuple[str, str, dict]: + """(identity, presentation identity, details). Validation and nothing else, + so the same refusals run whether or not there is a GOA to talk to.""" + identity = (username or "").strip() + if not identity: + raise BoundaryError("Enter the user name for that server.") + webdav, dav, host = nextcloud_uris(server) + + return ( + identity, + # What the account is shown as. A bare user name says nothing about + # which server it is on, and people keep more than one. + identity if "@" in identity else f"{identity}@{host}", + { + "Uri": webdav, + "FilesEnabled": "true", + "CalendarEnabled": "true", + "CalDavUri": dav, + "ContactsEnabled": "true", + "CardDavUri": dav, + "AcceptSslErrors": "false", + }, + ) + + +def add_nextcloud(server: str, username: str, password: str) -> str: + from gi.repository import GLib + + identity, presentation, details = nextcloud_account(server, username) + return add_account("owncloud", identity, presentation, + {"password": GLib.Variant("s", password)}, + details, "Nextcloud") + + +def imap_account(email: str, imap_host: str, smtp_host: str, + username: str) -> tuple[str, str, dict]: + address = (email or "").strip() + if "@" not in address or address.startswith("@") or address.endswith("@"): + raise BoundaryError("Enter the email address for this account.") + imap = (imap_host or "").strip() + smtp = (smtp_host or "").strip() + if not imap or not smtp: + raise BoundaryError("Enter both the incoming and outgoing server.") + if not valid_host(imap) or not valid_host(smtp): + raise BoundaryError("Mail servers are host names, like imap.example.org.") + identity = (username or "").strip() or address + + # The modern defaults, and the ones every mail provider this is likely to + # meet uses: IMAP over TLS on 993, submission with STARTTLS on 587. GOA + # offers a dropdown for the other combinations; a settings page that asked + # four encryption questions to add a mailbox would be worse than one that + # gets it right and lets GNOME's panel handle the unusual server. + return ( + address, address, + { + "Enabled": "true", + "EmailAddress": address, + "Name": address, + "ImapHost": imap, + "ImapUserName": identity, + "ImapUseSsl": "true", + "ImapUseTls": "false", + "ImapAcceptSslErrors": "false", + "SmtpHost": smtp, + "SmtpUseAuth": "true", + "SmtpUserName": identity, + "SmtpAuthLogin": "false", + "SmtpAuthPlain": "true", + "SmtpUseSsl": "false", + "SmtpUseTls": "true", + "SmtpAcceptSslErrors": "false", + }, + ) + + +def add_imap(email: str, imap_host: str, smtp_host: str, username: str, + password: str) -> str: + from gi.repository import GLib + + identity, presentation, details = imap_account(email, imap_host, smtp_host, username) + # One password for both servers: submission almost always takes the same + # credentials as the mailbox, and asking twice for the same secret is how a + # form gets abandoned. GOA stores them under separate keys regardless. + return add_account("imap_smtp", identity, presentation, + {"imap-password": GLib.Variant("s", password), + "smtp-password": GLib.Variant("s", password)}, + details, "mail") + + +def add_account(provider: str, identity: str, presentation: str, + credentials: dict, details: dict, label: str) -> str: + """Hand GOA a new account, then make it prove the credentials work. + + The daemon does not check what it is given -- it writes the account out and + stores the password. Without the second step a mistyped password produces an + account that exists, looks fine, and never syncs, which is the failure this + page is supposed to be able to explain. + """ + from gi.repository import GLib + + client = load_client() + manager = client.get_manager() + if manager is None: + raise BoundaryError("GNOME Online Accounts is not answering.") + try: + if not manager.call_is_supported_provider_sync(provider, None): + raise BoundaryError(f"This system cannot add {label} accounts.") + path = manager.call_add_account_sync( + provider, identity, presentation, + GLib.Variant("a{sv}", credentials), + GLib.Variant("a{ss}", details), None) + except GLib.Error as failure: + raise BoundaryError(clean(failure.message)) from failure + + verify(path, label) + return path + + +def verify(path: str, label: str) -> None: + """Sign in once, and undo the account if that fails.""" + from gi.repository import GLib + + # A fresh client: the one that made the call has not necessarily seen the + # object appear yet, and this is the cheapest way to wait for it properly. + obj = find(load_client(), path) + if obj is None: + # It was added; this process simply cannot see it yet. Reporting a + # failure here would be a lie, and the account list will show it. + return + + account = obj.get_account() + try: + account.set_default_timeout(60000) + account.call_ensure_credentials_sync(None) + except GLib.Error as failure: + try: + account.call_remove_sync(None) + except GLib.Error: + pass + raise BoundaryError( + f"Those {label} details were not accepted: {clean(failure.message)}" + ) from failure + + +def clean(message: str) -> str: + """The useful sentence of a GOA error, without the D-Bus type prefix.""" + trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", str(message or "")).strip() + return trimmed.splitlines()[0][:200] if trimmed else "That did not work." + + +# ── The canned GOA ─────────────────────────────────────────────────────────── + + +def fixture() -> dict | None: + path = os.environ.get(FIXTURE_ENV) + if not path: + return None + try: + with open(path, encoding="utf-8") as handle: + listing = json.load(handle) + except (OSError, ValueError) as failure: + return {"accounts": [], "error": f"The accounts fixture could not be read: {failure}"} + listing.setdefault("accounts", []) + listing.setdefault("error", "") + return listing + + +def record(verb: str, arguments: list, password: str | None = None) -> None: + path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log") + if not path: + return + entry = {"verb": verb, "arguments": arguments} + if password is not None: + # Its length, never the thing itself: the point of the log is to prove + # the password came in on stdin, not to keep a copy of it. + entry["passwordBytes"] = len(password) + try: + with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, separators=(",", ":")) + "\n") + except OSError: + pass + + +def added(operation) -> int: + """Run an add, and answer with the fresh account list either way. + + Errors come back inside that list rather than on stderr, so the page has + both facts -- what went wrong and what is there now -- from one read. + """ + error = "" + try: + operation() + except BoundaryError as failure: + error = str(failure) + + try: + accounts = [describe(obj) for obj in load_client().get_accounts()] + except Exception: # noqa: BLE001 - the error above is the one worth saying + accounts = [] + print(json.dumps({"accounts": accounts, "error": error})) + return 0 + + +def replay(action: str, arguments: list, canned: dict) -> int: + """Every verb, against the canned GOA. No bus, no gi, no account changed.""" + error = "" + try: + if action in ("list", "snapshot"): + pass + elif action in ("set", "remove"): + record(action, arguments[1:]) + elif action == "add-nextcloud": + if len(arguments) != 3: + raise BoundaryError("Usage: add-nextcloud SERVER USERNAME") + password = read_password() + nextcloud_account(arguments[1], arguments[2]) + record(action, arguments[1:], password) + elif action == "add-imap": + if len(arguments) != 5: + raise BoundaryError("Usage: add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME") + password = read_password() + imap_account(arguments[1], arguments[2], arguments[3], arguments[4]) + record(action, arguments[1:], password) + else: + raise BoundaryError(f"Unknown command {action!r}.") + except BoundaryError as failure: + error = str(failure) + + print(json.dumps({**canned, "error": error or canned.get("error", "")})) + return 0 + + def main(): - action = sys.argv[1] if len(sys.argv) > 1 else "list" + arguments = sys.argv[1:] + action = arguments[0] if arguments else "list" + + canned = fixture() + if canned is not None: + return replay(action, arguments, canned) + + if action == "add-nextcloud": + if len(arguments) != 3: + print("usage: panama-accounts add-nextcloud SERVER USERNAME", file=sys.stderr) + return 2 + return added(lambda: add_nextcloud(arguments[1], arguments[2], read_password())) + + if action == "add-imap": + if len(arguments) != 5: + print("usage: panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME", + file=sys.stderr) + return 2 + return added(lambda: add_imap(arguments[1], arguments[2], arguments[3], + arguments[4], read_password())) try: client = load_client() @@ -124,7 +458,9 @@ def main(): })) return 0 - if action == "list": + # "snapshot" is what every other Panama helper calls this, and what the + # service asks for; "list" is what this one has always been called. + if action in ("list", "snapshot"): print(json.dumps({ "accounts": [describe(obj) for obj in client.get_accounts()], "error": "", @@ -162,7 +498,8 @@ def main(): obj.get_account().call_remove_sync(None) return 0 - print("usage: panama-accounts [list|set|remove]", file=sys.stderr) + print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]", + file=sys.stderr) return 2 diff --git a/config/dot/quickshell/scripts/panama-fingerprint b/config/dot/quickshell/scripts/panama-fingerprint index 89ebac6..09a19c6 100755 --- a/config/dot/quickshell/scripts/panama-fingerprint +++ b/config/dot/quickshell/scripts/panama-fingerprint @@ -1,89 +1,490 @@ -#!/usr/bin/env bash +#!/usr/bin/env python3 -# Fingerprint state and the one privileged switch, for the Users page. -# -# Two independent facts make a working fingerprint login, and conflating them -# is how the feature usually confuses people: fprintd must hold at least one -# enrolled print (GNOME's Users panel owns that dialog, and Panama hands off -# to it), and PAM must be told to ask the reader at all, which on Fedora is -# authselect's `with-fingerprint` feature. This helper reports both and can -# flip the second. -# -# Usage: -# panama-fingerprint status -> {"reader":bool,"readerName":"","enrolled":[],"pamEnabled":bool,"error":""} -# panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit) -# -# authselect is baseline Fedora (it manages PAM for the whole install), and -# fprintd ships with Workstation; a machine with neither simply reports no -# reader, which hides the card. +"""Fingerprint login: the enrolled prints, and the one privileged switch. -set -uo pipefail +Two independent facts make a working fingerprint login, and conflating them is +how the feature usually confuses people. fprintd must hold at least one enrolled +print, and PAM must be told to ask the reader at all, which on Fedora is +authselect's `with-fingerprint` feature. This helper reports both, enrolls and +removes prints, and can flip the second. -PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" +Both facts are reported unconditionally, including on a machine with no reader. +The state that used to be invisible -- the feature switched on with nothing +enrolled and no reader attached -- is exactly the state someone needs to see and +turn off, and reporting `pamEnabled: false` because there was no reader to ask +made it unreachable. -emit() { - jq -cn \ - --argjson reader "$1" \ - --arg readerName "$2" \ - --argjson enrolled "$3" \ - --argjson pamEnabled "$4" \ - --arg error "$5" \ - '{reader: $reader, readerName: $readerName, enrolled: $enrolled, - pamEnabled: $pamEnabled, error: $error}' -} +Enrollment talks to fprintd over D-Bus (net.reactivated.Fprint) rather than +handing the person to GNOME's Users panel: Claim, EnrollStart, then one +`EnrollStatus` signal per touch until the device says it is done. Progress is +printed as one JSON object per line, the same streaming shape panama-dictate's +setup uses, so the page can count touches while they happen. That signal loop is +why this is Python and no longer bash -- `status` and `set-unlock` still shell +out to exactly the same tools, and answer in exactly the same shapes. -cmd_status() { - command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; } + panama-fingerprint status + panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit) + panama-fingerprint enroll FINGER (streams {stage,done,total,result}) + panama-fingerprint remove FINGER + panama-fingerprint remove-all - # fprintd-list both answers "is there a reader" (fprintd is bus-activated, - # so this also copes with the daemon not running yet) and names the - # enrolled fingers in one call. - local listing - # LC_ALL=C: the "no devices" match below reads fprintd's message, and a - # translated daemon would turn every readerless non-English machine into - # a permanent error card. - if ! listing="$(LC_ALL=C timeout 10 fprintd-list "$USER" 2>&1)"; then - # "No devices available" is the normal no-reader machine; anything - # else is a real problem worth surfacing. - if grep -qi 'no devices' <<<"$listing"; then - emit false "" '[]' false "" - else - emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")" - fi +authselect is baseline Fedora (it manages PAM for the whole install) and fprintd +ships with Workstation; a machine with neither reports no reader and no feature. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import time + +FPRINT = "net.reactivated.Fprint" +MANAGER_PATH = "/net/reactivated/Fprint/Manager" +MANAGER_INTERFACE = "net.reactivated.Fprint.Manager" +DEVICE_INTERFACE = "net.reactivated.Fprint.Device" + +# The authselect feature that decides whether PAM asks the reader at unlock. +FEATURE = "with-fingerprint" + +# How long a reader may sit waiting for a finger before enrollment is given up +# on. The page has a Cancel button; this is only for a session left open. +IDLE_TIMEOUT_SECONDS = 90 + +# A canned fprintd, for contract runs. Points at a JSON file; see replay() for +# the shape. Every call that would have gone to the bus is appended to +# PANAMA_FINGERPRINT_LOG instead, so a test can pin the order of +# Claim / EnrollStart / EnrollStop / Release without a reader in the room. +FIXTURE_ENV = "PANAMA_FINGERPRINT_FIXTURE" +LOG_ENV = "PANAMA_FINGERPRINT_LOG" + +PANAMA_PATH = os.environ.get("PANAMA_PATH") or os.path.expanduser("~/.local/share/Panama") + +# fprintd's own vocabulary, only far enough to reject nonsense before it becomes +# a D-Bus error. What a finger is CALLED is presented by services/Fingerprint.qml, +# which is the single place that decides how these read. +FINGER = re.compile(r"^(left|right)-(thumb|(index|middle|ring|little)-finger)$") + + +class BoundaryError(RuntimeError): + """A user-visible failure: no reader, a refused claim, a bad finger name.""" + + +# ── status ─────────────────────────────────────────────────────────────────── + + +def unlock_feature_enabled() -> bool: + """Whether PAM has been told to ask the reader. + + Asked unconditionally. This is a property of the PAM configuration and has + nothing to do with whether a reader is plugged in, which is the whole point: + the feature left on with no reader is a state someone has to be able to see. + """ + if not shutil.which("authselect"): + return False + try: + current = subprocess.run(["authselect", "current"], capture_output=True, + text=True, timeout=10, check=False) + except (OSError, subprocess.SubprocessError): + return False + return FEATURE in (current.stdout or "") + + +def status() -> dict: + reader, name, enrolled, error = False, "", [], "" + + if shutil.which("fprintd-list"): + # fprintd-list answers "is there a reader" (fprintd is bus-activated, so + # this copes with the daemon not running yet) and names the enrolled + # fingers in one call. + # + # LC_ALL=C: the "no devices" match below reads fprintd's message, and a + # translated daemon would turn every readerless non-English machine into + # a permanent error card. + environment = dict(os.environ, LC_ALL="C") + try: + listing = subprocess.run(["fprintd-list", os.environ.get("USER", "")], + capture_output=True, text=True, timeout=10, + check=False, env=environment) + output = (listing.stdout or "") + (listing.stderr or "") + except (OSError, subprocess.SubprocessError) as failure: + listing, output = None, str(failure) + + if listing is None or listing.returncode != 0: + # "No devices available" is the normal no-reader machine; anything + # else is a real problem worth surfacing. + if "no devices" not in output.lower(): + first = next((line for line in output.splitlines() if line.strip()), "") + error = f"fprintd did not answer: {first}" + else: + reader = True + # "Fingerprints for user gib on FocalTech ... (press):" carries the + # reader product name; " - #0: right-index-finger" the enrollment. + match = re.search(r"^Fingerprints for user \S+ on (.*) \(\w*\):$", + output, re.MULTILINE) + name = match.group(1) if match else "" + enrolled = re.findall(r"^ *- #\d+: (.+)$", output, re.MULTILINE) + + feature = unlock_feature_enabled() + return { + "reader": reader, + "readerName": name, + "enrolled": enrolled, + # Two names for one fact, on purpose: `pamEnabled` is what this helper + # has always called it, `unlockFeatureEnabled` is what it is. + "pamEnabled": feature, + "unlockFeatureEnabled": feature, + "error": error, + } + + +# ── the privileged switch ──────────────────────────────────────────────────── + + +def set_unlock(state: str) -> int: + if state == "on": + verb = "enable-feature" + reason = ("Turning on fingerprint login: telling PAM (via authselect) to " + "ask the fingerprint reader when unlocking") + elif state == "off": + verb = "disable-feature" + reason = ("Turning off fingerprint login: telling PAM (via authselect) to " + "stop asking the fingerprint reader") + else: + print("panama-fingerprint set-unlock takes on|off", file=sys.stderr) + return 1 + + escalate = os.path.join(PANAMA_PATH, "bin", "panama-sudo") + prefix = ([escalate, "--reason", reason, "--"] + if os.access(escalate, os.X_OK) else ["sudo"]) + return subprocess.run(prefix + ["authselect", verb, FEATURE], check=False).returncode + + +# ── talking to fprintd ─────────────────────────────────────────────────────── + + +def emit(**fields) -> None: + """One JSON object, one line, flushed. The page reads these as they arrive.""" + print(json.dumps(fields, separators=(",", ":")), flush=True) + + +def log_call(method: str, *arguments) -> None: + """Record a call the fixture stood in for, so a contract can pin the order.""" + path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log") + if not path: return - fi + try: + with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps({"method": method, "arguments": list(arguments)}, + separators=(",", ":")) + "\n") + except OSError: + pass - # "Fingerprints for user gib on FocalTech ... (press):" carries the reader - # product name; " - #0: right-index-finger" lines carry the enrollment. - local name enrolled pam - name="$(sed -n 's/^Fingerprints for user [^ ]* on \(.*\) (\w*):$/\1/p' <<<"$listing" | head -1)" - enrolled="$(sed -n 's/^ *- #[0-9]*: //p' <<<"$listing" | jq -Rn '[inputs]')" - pam=false - authselect current 2>/dev/null | grep -q 'with-fingerprint' && pam=true - emit true "$name" "$enrolled" "$pam" "" -} +def fixture() -> dict | None: + """The canned fprintd, or None when there is a real bus to talk to. -cmd_set_unlock() { - local verb reason - case "$1" in - on) verb=enable-feature - reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;; - off) verb=disable-feature - reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;; - *) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;; - esac + {"enrollStages": 5, + "results": ["enroll-stage-passed", "enroll-retry-scan-too-short", ...], + "error": ""} <- non-empty stands in for a refused claim + """ + path = os.environ.get(FIXTURE_ENV) + if not path: + return None + try: + with open(path, encoding="utf-8") as handle: + return json.load(handle) + except (OSError, ValueError) as failure: + raise BoundaryError(f"The fingerprint fixture could not be read: {failure}") - local sudo_cmd=(sudo) - [[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=( - "$PANAMA_PATH/bin/panama-sudo" --reason "$reason" -- - ) - "${sudo_cmd[@]}" authselect "$verb" with-fingerprint -} -case "${1:-}" in - status) cmd_status ;; - set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; } - cmd_set_unlock "$2" ;; - *) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;; -esac +def bus(): + try: + import gi + + gi.require_version("Gio", "2.0") + from gi.repository import Gio, GLib + + return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + except Exception as failure: # noqa: BLE001 - no bus is a legitimate state + raise BoundaryError("The fingerprint service is not answering.") from failure + + +def call(path: str, interface: str, method: str, parameters=None, reply=None): + Gio, GLib, connection = bus() + try: + result = connection.call_sync( + FPRINT, path, interface, method, parameters, + GLib.VariantType(reply) if reply else None, + Gio.DBusCallFlags.NONE, 30000, None) + except Exception as failure: # noqa: BLE001 + raise BoundaryError(_clean(str(failure))) from failure + return result.unpack() if result is not None else None + + +def _clean(message: str) -> str: + """The useful sentence out of a D-Bus error, without the type prefix.""" + trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip() + if "no devices" in trimmed.lower(): + return "No fingerprint reader is connected." + if "permission denied" in trimmed.lower() or "not authorized" in trimmed.lower(): + return "That was not authorized." + if "already in use" in trimmed.lower() or "claimed" in trimmed.lower(): + return "The fingerprint reader is busy with something else." + return trimmed.splitlines()[0][:200] if trimmed else "The fingerprint reader failed." + + +def default_device() -> str: + return call(MANAGER_PATH, MANAGER_INTERFACE, "GetDefaultDevice", None, "(o)")[0] + + +def enroll_stages(device: str) -> int: + Gio, GLib, connection = bus() + try: + result = connection.call_sync( + FPRINT, device, "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", (DEVICE_INTERFACE, "num-enroll-stages")), + GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 10000, None) + return max(1, int(result.unpack()[0])) + except Exception: # noqa: BLE001 - a device that will not say is not fatal + # Readers overwhelmingly want five touches, and a counter that is wrong + # is better than a page with no counter at all. + return 5 + + +# ── enroll ─────────────────────────────────────────────────────────────────── + +# fprintd says "enroll-stage-passed" for a touch that counted and +# "enroll-completed" when there are no more to take. Everything else beginning +# "enroll-retry" or naming a placement problem is a touch to do again, and the +# terminal failures arrive with done=true. +STAGE_PASSED = "enroll-stage-passed" +COMPLETED = "enroll-completed" + +# The results that end an enrollment badly. The device says so itself over the +# wire (the signal's `done` flag), so this list is only what the canned fprintd +# has to recognize on its own. +TERMINAL_FAILURES = ( + "enroll-failed", "enroll-data-full", "enroll-disconnected", + "enroll-duplicate", "enroll-unknown-error", +) + + +def enroll(finger: str) -> int: + if not FINGER.fullmatch(finger or ""): + raise BoundaryError("That is not a finger fprintd knows.") + + canned = fixture() + if canned is not None: + return enroll_replay(finger, canned) + + device = default_device() + total = enroll_stages(device) + emit(ok=True, stage="claiming", done=0, total=total, result="", error="") + + Gio, GLib, connection = bus() + call(device, DEVICE_INTERFACE, "Claim", + GLib.Variant("(s)", (os.environ.get("USER", ""),))) + + loop = GLib.MainLoop() + state = {"done": 0, "result": "", "error": "", "ok": False, "seen": time.monotonic()} + + def on_status(_connection, _sender, _path, _interface, _signal, parameters): + result, finished = parameters.unpack() + state["seen"] = time.monotonic() + state["result"] = result + if result == STAGE_PASSED: + state["done"] = min(state["done"] + 1, total) + if finished: + state["ok"] = result == COMPLETED + if not state["ok"]: + state["error"] = describe_result(result) + loop.quit() + return + emit(ok=True, stage="scanning", done=state["done"], total=total, + result=result, error="") + + subscription = connection.signal_subscribe( + None, DEVICE_INTERFACE, "EnrollStatus", device, None, + Gio.DBusSignalFlags.NONE, on_status) + + # A cancelled enrollment is a terminated process -- the page drops the + # Process and Quickshell sends a signal. The device must still be released, + # or the next attempt finds the reader busy with a session that is gone. + def cancelled(_data=None): + state["error"] = "" + state["result"] = "cancelled" + loop.quit() + return GLib.SOURCE_REMOVE + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, cancelled, None) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, cancelled, None) + + def watchdog(_data=None): + if time.monotonic() - state["seen"] > IDLE_TIMEOUT_SECONDS: + state["error"] = "The reader stopped answering." + loop.quit() + return GLib.SOURCE_REMOVE + return GLib.SOURCE_CONTINUE + + GLib.timeout_add_seconds(5, watchdog, None) + + try: + call(device, DEVICE_INTERFACE, "EnrollStart", GLib.Variant("(s)", (finger,))) + emit(ok=True, stage="scanning", done=0, total=total, result="", error="") + loop.run() + finally: + connection.signal_unsubscribe(subscription) + # Both are best-effort: the interesting failure already happened, and a + # reader left claimed is worse than a second error nobody can act on. + for method in ("EnrollStop", "Release"): + try: + call(device, DEVICE_INTERFACE, method) + except BoundaryError: + pass + + return finish(state, total) + + +def finish(state: dict, total: int) -> int: + if state["ok"]: + emit(ok=True, stage="done", done=total, total=total, + result=COMPLETED, error="") + return 0 + stage = "cancelled" if state["result"] == "cancelled" else "failed" + emit(ok=False, stage=stage, done=state["done"], total=total, + result=state["result"], error=state["error"]) + return 1 + + +def describe_result(result: str) -> str: + """fprintd's terminal results, in words someone can act on.""" + return { + "enroll-failed": "That finger could not be read. Try enrolling it again.", + "enroll-data-full": "The reader has no room for another fingerprint.", + "enroll-disconnected": "The fingerprint reader was disconnected.", + "enroll-duplicate": "That finger is already enrolled.", + "enroll-unknown-error": "The fingerprint reader failed.", + }.get(result, "Enrolling that finger did not finish.") + + +def enroll_replay(finger: str, canned: dict) -> int: + """The same stream, from a file. No bus, no reader, no waiting.""" + total = max(1, int(canned.get("enrollStages", 5))) + emit(ok=True, stage="claiming", done=0, total=total, result="", error="") + + log_call("Claim", os.environ.get("USER", "")) + refusal = str(canned.get("error") or "") + if refusal: + emit(ok=False, stage="failed", done=0, total=total, result="", error=refusal) + return 1 + + log_call("EnrollStart", finger) + emit(ok=True, stage="scanning", done=0, total=total, result="", error="") + + state = {"done": 0, "result": "", "error": "", "ok": False} + for result in canned.get("results", []): + state["result"] = result + if result == STAGE_PASSED: + state["done"] = min(state["done"] + 1, total) + if result == COMPLETED or result in TERMINAL_FAILURES: + state["ok"] = result == COMPLETED + if not state["ok"]: + state["error"] = describe_result(result) + break + emit(ok=True, stage="scanning", done=state["done"], total=total, + result=result, error="") + + log_call("EnrollStop") + log_call("Release") + return finish(state, total) + + +# ── remove ─────────────────────────────────────────────────────────────────── + + +def remove(finger: str | None) -> dict: + """Delete one enrolled finger, or all of them, and report the fresh state.""" + if finger is not None and not FINGER.fullmatch(finger): + raise BoundaryError("That is not a finger fprintd knows.") + + canned = fixture() + if canned is not None: + log_call("Claim", os.environ.get("USER", "")) + if finger is None: + log_call("DeleteEnrolledFingers2") + else: + log_call("DeleteEnrolledFinger", finger) + log_call("Release") + return {**status(), "error": str(canned.get("error") or "")} + + from gi.repository import GLib + + device = default_device() + call(device, DEVICE_INTERFACE, "Claim", + GLib.Variant("(s)", (os.environ.get("USER", ""),))) + try: + if finger is None: + # DeleteEnrolledFingers2 works on the claimed user; its predecessor + # took a name and is deprecated for exactly the confusion that + # invited -- deleting someone else's prints by typo. + call(device, DEVICE_INTERFACE, "DeleteEnrolledFingers2") + else: + call(device, DEVICE_INTERFACE, "DeleteEnrolledFinger", + GLib.Variant("(s)", (finger,))) + finally: + try: + call(device, DEVICE_INTERFACE, "Release") + except BoundaryError: + pass + + return status() + + +# ── entry ──────────────────────────────────────────────────────────────────── + + +def main(arguments: list[str]) -> int: + verb = arguments[0] if arguments else "" + + try: + if verb == "status" and len(arguments) == 1: + print(json.dumps(status(), separators=(",", ":"))) + return 0 + + if verb == "set-unlock" and len(arguments) == 2: + return set_unlock(arguments[1]) + + if verb == "enroll" and len(arguments) == 2: + return enroll(arguments[1]) + + if verb == "remove" and len(arguments) == 2: + print(json.dumps(remove(arguments[1]), separators=(",", ":"))) + return 0 + + if verb == "remove-all" and len(arguments) == 1: + print(json.dumps(remove(None), separators=(",", ":"))) + return 0 + + except BoundaryError as failure: + # Enrollment streams, so its failure has to arrive in the same shape as + # its progress; the others answer with the state plus the message, so a + # page never has to ask twice to find out what happened. + if verb == "enroll": + emit(ok=False, stage="failed", done=0, total=0, result="", + error=str(failure)) + else: + print(json.dumps({**status(), "error": str(failure)}, + separators=(",", ":"))) + return 1 + + print("usage: panama-fingerprint status | set-unlock on|off | enroll FINGER | " + "remove FINGER | remove-all", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/scripts/panama-users b/config/dot/quickshell/scripts/panama-users index 13f3c70..bb58d8e 100755 --- a/config/dot/quickshell/scripts/panama-users +++ b/config/dot/quickshell/scripts/panama-users @@ -13,14 +13,17 @@ accountsservice over D-Bus from inside this process. It is never an argument: argv is world-readable through /proc, so a password -- or even its hash -- passed that way is published to every process on the machine. - panama-accounts snapshot - panama-accounts set-real-name USER NAME - panama-accounts set-icon USER PATH [X Y SIZE] - panama-accounts set-account-type USER standard|administrator - panama-accounts set-automatic-login USER true|false - panama-accounts set-password USER (new password on stdin) - panama-accounts create-user USERNAME REALNAME standard|administrator - panama-accounts delete-user USERNAME [keep-files|remove-files] + panama-users snapshot + panama-users stock-avatars + panama-users set-real-name USER NAME + panama-users set-icon USER PATH [X Y SIZE] (PATH "" clears the picture) + panama-users set-account-type USER standard|administrator + panama-users set-automatic-login USER true|false + panama-users set-locked USER true|false + panama-users set-password USER (new password on stdin) + panama-users reset-password USER (no password material at all) + panama-users create-user USERNAME REALNAME standard|administrator + panama-users delete-user USERNAME keep|remove """ from __future__ import annotations @@ -39,6 +42,16 @@ USER_INTERFACE = "org.freedesktop.Accounts.User" # Account types as accountsservice numbers them. STANDARD, ADMINISTRATOR = 0, 1 +# Password modes, likewise. 1 is "the account has no usable password and must +# choose one at the next sign-in" -- which is why resetting a password here +# involves no password at all, not even one this process saw for a moment. +PASSWORD_MODE_SET_AT_LOGIN = 1 + +# Where a distribution keeps the pictures its login screen offers. Fedora ships +# fifteen; a machine with none is normal and yields an empty list. +STOCK_AVATAR_DIR = "/usr/share/pixmaps/faces" +STOCK_AVATAR_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".svg") + USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") @@ -137,6 +150,40 @@ def snapshot() -> dict: } +def stock_avatars() -> dict: + """The pictures the distribution ships, as {name, path} pairs. + + Absolute paths, because the caller draws them straight from disk and then + hands the same path back to set-icon. Sorted by name so the gallery does + not reshuffle itself between openings. + """ + entries = [] + try: + names = os.listdir(STOCK_AVATAR_DIR) + except OSError: + # No such directory is an ordinary machine, not a failure worth a row. + names = [] + + for name in names: + if not name.lower().endswith(STOCK_AVATAR_SUFFIXES): + continue + path = os.path.join(STOCK_AVATAR_DIR, name) + if not os.path.isfile(path): + continue + entries.append({"name": _avatar_label(name), "path": path}) + + entries.sort(key=lambda entry: entry["name"].lower()) + return {"avatars": entries, "error": ""} + + +def _avatar_label(filename: str) -> str: + """"coffee2.jpg" -> "Coffee 2". A file name is not a caption, but it is the + only thing these pictures carry, so it is tidied rather than invented.""" + stem = os.path.splitext(filename)[0] + words = re.sub(r"(\d+)$", r" \1", stem.replace("-", " ").replace("_", " ")).strip() + return words[:1].upper() + words[1:] + + def set_real_name(username: str, name: str) -> None: from gi.repository import GLib @@ -195,6 +242,14 @@ def crop_square(path: str, x: int, y: int, size: int) -> str: def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None: from gi.repository import GLib + # An empty path is how accountsservice is told to forget the picture: the + # same call, with nothing in it. There is no separate "clear" method, and + # inventing a verb for it here would only hide that. + if path == "": + call(user_path(username), USER_INTERFACE, "SetIconFile", + GLib.Variant("(s)", ("",))) + return + if not os.path.isfile(path): raise BoundaryError("That picture no longer exists.") @@ -221,7 +276,21 @@ def set_account_type(username: str, kind: str) -> None: if kind not in ("standard", "administrator"): raise BoundaryError("That is not an account type.") - call(user_path(username), USER_INTERFACE, "SetAccountType", + path = user_path(username) + + # The same reason deleting the last administrator is refused: a machine + # whose only administrator has just been demoted cannot be administered, + # and the demotion itself is the last thing that needed authorization. + if kind == "standard": + state = snapshot() + target = next((user for user in state["users"] + if user["userName"] == username), None) + if target is not None and target["administrator"] \ + and state["administratorCount"] <= 1: + raise BoundaryError( + "That is the only administrator; the machine would have none.") + + call(path, USER_INTERFACE, "SetAccountType", GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,))) @@ -232,6 +301,29 @@ def set_automatic_login(username: str, enabled: bool) -> None: GLib.Variant("(b)", (enabled,))) +def set_locked(username: str, locked: bool) -> None: + """Lock or unlock an account. A locked account cannot sign in at all, which + is what someone is looking at when a user row says nothing works for them.""" + from gi.repository import GLib + + call(user_path(username), USER_INTERFACE, "SetLocked", + GLib.Variant("(b)", (locked,))) + + +def reset_password(username: str) -> None: + """Require a new password at the next sign-in. + + Deliberately not "set a password for them": no password is chosen, typed, + hashed, or transmitted. accountsservice is told the account's password mode + is "set at login", and the login screen collects the new one from the person + who is going to use it. + """ + from gi.repository import GLib + + call(user_path(username), USER_INTERFACE, "SetPasswordMode", + GLib.Variant("(i)", (PASSWORD_MODE_SET_AT_LOGIN,))) + + def set_password(username: str) -> None: """Set a new password, read from stdin and never named on a command line.""" secret = sys.stdin.buffer.read() @@ -269,10 +361,17 @@ def create_user(username: str, real_name: str, kind: str) -> None: "(o)") +# What to do with the home directory, spelled either way. "keep"/"remove" is +# what the page says out loud; the longer pair is what this helper has always +# taken, and callers older than the page still pass it. +KEEP_FILES = ("keep", "keep-files") +REMOVE_FILES = ("remove", "remove-files") + + def delete_user(username: str, files: str) -> None: from gi.repository import GLib - if files not in ("keep-files", "remove-files"): + if files not in KEEP_FILES + REMOVE_FILES: raise BoundaryError("Say whether to keep or remove the home directory.") if username == (os.environ.get("USER") or ""): raise BoundaryError("You cannot delete the account you are signed in to.") @@ -285,7 +384,7 @@ def delete_user(username: str, files: str) -> None: raise BoundaryError("That is the only administrator; the machine would have none.") call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser", - GLib.Variant("(xb)", (target["uid"], files == "remove-files"))) + GLib.Variant("(xb)", (target["uid"], files in REMOVE_FILES))) def main(arguments: list[str]) -> int: @@ -294,6 +393,12 @@ def main(arguments: list[str]) -> int: print(json.dumps(snapshot(), separators=(",", ":"))) return 0 + # Its own shape rather than a snapshot: this asks the filesystem what + # pictures exist, which has nothing to do with who has an account. + if arguments == ["stock-avatars"]: + print(json.dumps(stock_avatars(), separators=(",", ":"))) + return 0 + if len(arguments) == 3 and arguments[0] == "set-real-name": set_real_name(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-icon": @@ -308,19 +413,24 @@ def main(arguments: list[str]) -> int: set_account_type(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-automatic-login": set_automatic_login(arguments[1], arguments[2] == "true") + elif len(arguments) == 3 and arguments[0] == "set-locked": + set_locked(arguments[1], arguments[2] == "true") elif len(arguments) == 2 and arguments[0] == "set-password": set_password(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "reset-password": + reset_password(arguments[1]) elif len(arguments) == 4 and arguments[0] == "create-user": create_user(arguments[1], arguments[2], arguments[3]) elif len(arguments) == 3 and arguments[0] == "delete-user": delete_user(arguments[1], arguments[2]) else: raise BoundaryError( - "Usage: panama-accounts snapshot | set-real-name USER NAME | " + "Usage: panama-users snapshot | stock-avatars | set-real-name USER NAME | " "set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | " - "set-automatic-login USER true|false | set-password USER | " + "set-automatic-login USER true|false | set-locked USER true|false | " + "set-password USER | reset-password USER | " "create-user USERNAME REALNAME standard|administrator | " - "delete-user USERNAME keep-files|remove-files") + "delete-user USERNAME keep|remove") except BoundaryError as error: # Answers with the fresh state plus the message, so a page never has to # ask twice to find out what happened. diff --git a/config/dot/quickshell/services/Fingerprint.qml b/config/dot/quickshell/services/Fingerprint.qml index b3ef675..a4a71a9 100644 --- a/config/dot/quickshell/services/Fingerprint.qml +++ b/config/dot/quickshell/services/Fingerprint.qml @@ -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(); } } diff --git a/config/dot/quickshell/services/OnlineAccounts.qml b/config/dot/quickshell/services/OnlineAccounts.qml index 9b88e2b..ae3ec99 100644 --- a/config/dot/quickshell/services/OnlineAccounts.qml +++ b/config/dot/quickshell/services/OnlineAccounts.qml @@ -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 = "" } } diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index d7585a9..b036ee3 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/config/dot/quickshell/services/UserAccounts.qml b/config/dot/quickshell/services/UserAccounts.qml index 0be8bbd..444b5db 100644 --- a/config/dot/quickshell/services/UserAccounts.qml +++ b/config/dot/quickshell/services/UserAccounts.qml @@ -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 diff --git a/config/local/share/vicinae/scripts/settings-accounts b/config/local/share/vicinae/scripts/settings-accounts index dbcfe6c..9f4f049 100755 --- a/config/local/share/vicinae/scripts/settings-accounts +++ b/config/local/share/vicinae/scripts/settings-accounts @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Online Accounts in Settings. -# @vicinae.keywords ["settings", "online accounts"] +# @vicinae.keywords ["settings", "online accounts", "nextcloud", "google account", "add a mail account", "remove an account"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accounts diff --git a/config/local/share/vicinae/scripts/settings-users b/config/local/share/vicinae/scripts/settings-users index 738b4c2..dce8e78 100755 --- a/config/local/share/vicinae/scripts/settings-users +++ b/config/local/share/vicinae/scripts/settings-users @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Users in Settings. -# @vicinae.keywords ["settings", "user account", "profile picture", "change password", "add a user", "automatic login", "administrator"] +# @vicinae.keywords ["settings", "user account", "profile picture", "change password", "add a user", "automatic login", "administrator", "fingerprint", "enroll a fingerprint", "delete a user", "account type", "reset a password"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page users diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index 352628a..be945f2 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -898,3 +898,218 @@ run on their own, and both pass: system ones (`disks-contract`, `snapshots-contract`, `containers-contract`), then the harness ones (`settings-search-contract`, `health-ui-contract`), and `settings-pages-contract` last, as before. + +## Phase 11 (Users & Accounts) — append below + +Spec: `2026-08-24-users-accounts-redesign.md`. Users grew a profile hero with a +stock-avatar gallery and a Remove, a password flow with a strength meter, a +live-validating add-user form, a keep-files deletion choice, and per-user +management (type / reset password / unlock). Fingerprint enrollment stopped +handing people to GNOME's Users panel and became native — fprintd's +`EnrollStart` driven from `panama-fingerprint`, streaming one JSON line per +touch — and the card learned to appear in the state that needs acting on rather +than only when a reader is attached. Online Accounts gained native Nextcloud and +IMAP forms, confirmed removal, and an availability that no longer collapses on a +failed write. + +Three agents edited the tree concurrently. Everything below was reconciled +against the landed files at the end of the phase, not against the spec's pinned +shapes: three of the needles moved during the phase (the card-visibility +expression went into the service as `cardVisible`, the add-user field became +`LiveFieldRow`, and the fingerprint helper became Python) and each is pinned +where it actually landed. + +### New contracts (1) + +`quickshell/online-accounts-contract`. The README count line moves +**171 → 172**; `setup/readme-contract` was run and passes ("172 contracts, as +documented"). + +### Run and passing + +- **`quickshell/fingerprint-contract` — RUN END TO END, PASS.** The whole file + was reworked, because two of its needles inverted. Hermetic: `env -i`, a stub + directory first on `PATH` (asserted, before the helper runs, that + fprintd-list, authselect, sudo and pkexec all resolve inside it), both bus + addresses pointed at sockets that do not exist, `PANAMA_PATH` pointed at a + recording `panama-sudo` that runs **nothing**, and enrollment replayed from + `PANAMA_FINGERPRINT_FIXTURE` with the calls it stood in for landing in + `PANAMA_FINGERPRINT_LOG`. What it pins: + - **The inverted needle.** `openGnomePanel("system", "users")` is now a + failure rather than a requirement, checked across every settings page. + `gnome-handoff-contract` lost its `UsersPage.qml:system-users` exception at + the same time; this is the other half of that, said from the page's side. + - **The stuck state, from the data.** With the stub fprintd reporting no + devices and the stub authselect reporting `with-fingerprint`, `status` must + still answer `unlockFeatureEnabled: true` — the combination that used to be + unrepresentable, because the feature was only read when fprintd answered. + This machine is in that state, which is how it was found. + - **Card visibility followed one level of indirection.** The expression moved + into `services/Fingerprint.qml` as `cardVisible: readerPresent || + unlockFeatureEnabled`; the contract accepts either the page spelling it out + or the page binding to a service property that does, and resolves the second + rather than forbidding it. + - **The enrollment stream as a stream.** A five-stage fixture with a retry in + the middle produces nine lines; every one must be a JSON object on its own, + the counter monotonic and inside `0..total`, `total` constant, and the + retried touch must **not** have advanced the counter — a panel that counts a + failed touch promises a finish that never arrives. The terminal line says + which way it ended. + - **Claim and Release bracket everything**, read from the call log: first call + `Claim`, last call `Release`, `EnrollStart` after the claim — including on a + failed enrollment, because a reader left claimed refuses the next attempt + with "busy with something else" forever. + - **A terminal failure is reported in words**, not in fprintd's vocabulary + (`enroll-duplicate` → "That finger is already enrolled"). + - **Nonsense is refused before the reader is touched**: four bad finger names + against both `enroll` and `remove`, each of which must leave the call log + empty. + - **`remove` deletes the finger it was asked about**, and `remove-all` uses + `DeleteEnrolledFingers2` with no arguments — its predecessor took a user + name, which is how a typo deletes somebody else's prints. + - **With no fixture and no bus it still answers in its own shape**: the page + reads this stream line by line, and a traceback leaves an enrollment panel + open forever. + - **The stated reason survived the rewrite to Python**: the escalation must go + through `panama-sudo --reason …`, and a bare `sudo`/`pkexec` in the argv log + is a failure. + - **Finger vocabulary**: a single id elsewhere is a default choice and is + allowed; two in one file is a copy of the list, and the copy is what falls + behind. +- **`quickshell/user-accounts-contract` — RUN END TO END, PASS.** Its snapshot + half still reads the live account service, which is read-only; every verb that + would change something is pinned from the source and never invoked + (`set-icon gib ""` really does clear the avatar). New halves: + - **`reset-password` carries no password material**, by AST: it must call + `SetPasswordMode`, must resolve its mode argument to **1** (through the + named constant, so the meaning survives), must not call `SetPassword`, and + its body — docstring excluded, because the docstring is allowed to say the + word — must not mention stdin, openssl, crypt or passwd. + - **keep-files end to end, as three separate links**, because any one of them + can invert on its own: the helper derives `DeleteUser`'s boolean from its + own argument (a literal there is the bug), the service's ternary is checked + against the *polarity of its own parameter name* (Panama flipped this once + already, `removeFiles` → `keepFiles`), and the page passes a variable rather + than a constant. Both vocabularies are exercised — `keep`/`remove` and the + legacy `keep-files`/`remove-files` — by reading which refusal comes back, + so a vocabulary that stopped being understood shows up as the wrong message + rather than as no message. + - **`removeIcon` sends an empty path**, and `set_icon` recognizes it rather + than handing "" to GdkPixbuf, which would fail and leave the avatar. + - **The live-validation regression, two ways.** Structurally: the property the + form's user-name test reads must be assigned from a per-keystroke handler, + and the component that assigns it must not be `TextFieldRow`. Bluntly: the + page carries no `TextFieldRow` at all. The page's regex is compared + character for character against the helper's `USERNAME`, and the 31-cap must + appear in the page's own text — a form that accepts a name accountsservice + then refuses has no way to say why. + - **The last administrator is refused helper-side too**, not only by the page + that offers the control. + - **Stock avatars**: shape read from a real run (`{name, path}`, absolute + paths, empty list legitimate). +- **`quickshell/gnome-handoff-contract` — RUN, PASS** (9 handoffs, 39 pages). + The `UsersPage.qml:system-users` exception is deleted; the + `OnlineAccountsPage.qml:online-accounts` one stays with its reason rewritten + to name OAuth specifically — libgoa-backend ships without a GIR binding, so + the provider's own dialog is the only way to obtain a token, and Nextcloud and + IMAP are added on the page itself. +- **`setup/readme-contract` — RUN, PASS** after the count line moved. + +### Run with one half relaxed (1) + +- **`quickshell/online-accounts-contract` — everything passes except six + input-validation cases**, which are pinned as the spec asks and which the + landed `panama-accounts` does not yet satisfy. See "Still open" below. Run + with those six removed, the contract passes end to end. It is hermetic: `env + -i`, a stub directory first on `PATH` (gdbus/busctl/dbus-send/ + gnome-control-center all resolve there, asserted before running), a `gi` + stand-in on `PYTHONPATH` whose `require_version` always raises — so if the + fixture seam were ever removed the contract would stop working rather than + quietly start editing the session's accounts — and both bus addresses pointed + at sockets that do not exist. Accounts come from `PANAMA_ACCOUNTS_FIXTURE`; + what each verb would have done lands in `PANAMA_ACCOUNTS_LOG`. What it pins: + - **Availability is not an error**, at the definition and at every assignment: + no line that decides `available` may mention a write error, and the service + must keep at least two error strings, or "GOA did not answer" and "that one + change did not happen" are the same fact. This is the regression where one + refused toggle replaced four working accounts with "not available". + - **The same thing from the data**: a refused add must come back with the + error *and* the full account list, because the page draws its list from the + same answer that carries the message. + - **The password on stdin, proved rather than assumed.** The log records the + password's **length**, never the thing itself, so the sentinel can be + checked for everywhere it might have gone (stdout, stderr, the log, the + scratch home) while `passwordBytes` proves it was read at all rather than + dropped. + - **The listing cannot carry a credential**, read from `describe`'s own dict + keys and from the GOA properties it reads, so a password property added + later is caught at the source rather than after it reaches the page. + - **An added account is proved, not assumed**: `call_add_account_sync` must be + followed by `call_ensure_credentials_sync`, and `verify` must be able to + `call_remove_sync` — GOA does not check what it is handed, so without that + step a mistyped password produces an account that exists, looks correct, and + never syncs, which is the exact failure this page exists to explain. + - **Provider types**: `owncloud` (the fork kept the id) and `imap_smtp`. A + provider type GOA does not know is not an error — AddAccount is simply never + offered it. + - **Both names for the listing** (`list` and `snapshot`) answer the same + thing, and reading the list changes nothing. + - **Removal carries the path intact** — removing the wrong account is + unrecoverable and looks like a success — and is confirmation-gated on the + page, read from the object block around the call rather than from a + `confirming` property declared anywhere in the file. + +### Docs updated in the same wave + +- `services/SettingsSearch.qml` — nine entries added, all routing to leaves that + exist in `SettingsRoutes`: **Fingerprint**, **Enroll a fingerprint**, **Delete + a user**, **Account type**, **Reset a password** → `users`; **Nextcloud**, + **Google account**, **Add a mail account**, **Remove an account** → + `accounts`. Checked by evaluating the array: 163 entries, no duplicate labels, + every `page` a real leaf, and none of `settings-search-contract`'s 21 ranked + queries or 9 leaf-routing queries changes its top result — the only new entry + any of them touches at all is Fingerprint, which matches `lock` through + "Unlock" and sits far below the existing first hit. +- No settings docs regenerated: this phase adds no schema keys. Every new + setting is system state (accountsservice, fprintd, authselect, GOA), not a + Panama preference — verified by reading the diff. + +### Still open before the run + +- **`online-accounts-contract` fails six validation cases against the landed + helper**, and they are left pinned rather than relaxed, because the spec asks + for them and each produces an account that exists, looks right in the list, + and never syncs. `nextcloud_uris` accepts anything with a host once `https://` + is prepended, and `imap_account` accepts any non-empty string as a server: + - `add-nextcloud` accepts `not a url`, `https://cloud.example.org; reboot`, + and `https://cloud example org`; + - `add-imap` accepts `imap example com`, `imap.example.com; reboot`, and + `-imap.example.com` as either server. + + A hostname check in `nextcloud_uris` and one applied to both hosts in + `imap_account` closes all six. `''`, `ftp://…` and `https://` (no host) are + already refused, and every other assertion in the file passes. +- **`settings-search-contract` has not been run**: it starts a Quickshell + harness. The nine new entries were checked statically as described above. +- **`settings-pages-contract`, `settings-docs-contract`, `settings-jump-contract` + and `settings-buttons-contract` were not run against the two rebuilt pages.** + Six new components landed (LiveFieldRow, SecretFieldRow, PasswordStrengthRow, + StockAvatarPicker, FingerprintEnrollPanel, OnlineAccountRow) and are + registered in `qmldir`, but nothing here has loaded the QML. +- **The enrollment cancel path is pinned only from the source.** The canned + fprintd has no main loop, so `SIGTERM` releasing the device is exercised by + neither half; what is pinned is that the release happens in a `finally` and + that a *failed* enrollment still releases. +- **`user-accounts-contract`'s first half reads the live account service** + (`panama-users snapshot`, `stock-avatars`), so it wants the same quiet moment + the other system contracts do, though nothing it does writes. +- **Seams pinned by name**: `PANAMA_FINGERPRINT_FIXTURE`/`_LOG`, + `PANAMA_ACCOUNTS_FIXTURE`/`_LOG`, and the log shapes + (`{method, arguments}` and `{verb, arguments, passwordBytes}`). Changing + either is meant to be a deliberate act that updates these contracts. +- Run order for this phase: the hermetic ones first (`fingerprint-contract`, + `online-accounts-contract`), then the source-only ones + (`gnome-handoff-contract`, `setup/readme-contract`, + `settings-ownership-contract`, `search-routing-contract`), then the read-only + system one (`user-accounts-contract`), then the harness ones + (`settings-search-contract`), and `settings-pages-contract` last, as before. diff --git a/docs/superpowers/specs/2026-08-24-users-accounts-redesign.md b/docs/superpowers/specs/2026-08-24-users-accounts-redesign.md new file mode 100644 index 0000000..259f334 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-users-accounts-redesign.md @@ -0,0 +1,171 @@ +# Users & Accounts redesign — identity, honestly + +Approved mock: `home-mocks/users.html` (scratchpad, :8642). Spec wins over mock on conflict. + +## Goals + +1. **Users**: profile hero (stock avatar gallery + file + remove), password change with strength + meter, live-validating add-user form, keep-files deletion choice, per-user management + (type / reset password / unlock), auto-login with the tradeoff stated. +2. **Fingerprint**: native guided enrollment (fprintd D-Bus), enrolled-finger list + removal, + and the stuck state made reachable — the card renders when the authselect feature is on + even with no reader, offering Turn off. +3. **Online Accounts**: "This desktop" cards (Home Assistant, phone) leading; GOA account cards + with confirmed removal and busy states; native add for password providers (Nextcloud, IMAP); + OAuth remains the single honest handoff. Page-level availability stops being gated on + transient write errors. + +Non-goals: camera avatar capture, OAuth without GOA's dialog (impossible — no GIR), parental +controls, group management beyond account type, username changes. + +## Helper extensions (pinned — as built) + +**`scripts/panama-users`** (Python, unchanged language). Verbs: +`snapshot` · `stock-avatars` · `set-real-name USER NAME` · `set-icon USER PATH [X Y SIZE]` · +`set-account-type USER standard|administrator` · `set-automatic-login USER true|false` · +`set-locked USER true|false` · `set-password USER` (stdin) · `reset-password USER` · +`create-user USERNAME REALNAME TYPE` · `delete-user USERNAME keep|remove`. + +- `set-icon USER ""` clears the avatar (`SetIconFile("")`) — no separate verb. +- `stock-avatars` prints its own shape: `{"avatars":[{"name","path"}],"error":""}`. Absolute + paths, sorted by name; a missing `/usr/share/pixmaps/faces` yields `[]`. Fedora ships 15. +- `delete-user` takes `keep|remove`; the older `keep-files|remove-files` spelling still works. +- `reset-password USER` → `SetPasswordMode(1)`. No password material anywhere in the path. +- `set-account-type` now carries the last-admin refusal **in the helper** (same sentence as + `delete-user`), so a page that forgets the guard cannot demote the only administrator. +- Every mutating verb still answers with the fresh snapshot plus `error`, exit 0. + +**`scripts/panama-fingerprint`** — **converted bash → Python** (a D-Bus signal loop is not a +thing bash can do). `status` and `set-unlock` keep their exact CLI and JSON, and still shell +out to `fprintd-list` / `authselect` / `panama-sudo --reason`, so the existing PATH-stub seam +is untouched. Verbs: `status` · `set-unlock on|off` · `enroll FINGER` · `remove FINGER` · +`remove-all`. + +- `status` → `{reader, readerName, enrolled, pamEnabled, unlockFeatureEnabled, error}`. + `pamEnabled` and `unlockFeatureEnabled` are the same fact under the old and new name. + **Behaviour change for C**: authselect is now parsed unconditionally, so a readerless + machine reports `pamEnabled: true` when the feature is on. That used to be forced to + `false`, which is what made the stuck state unreachable. (This machine is in it.) +- `enroll FINGER` streams one JSON object per line: + `{ok, stage, done, total, result, error}` with `stage ∈ claiming|scanning|done|failed`. + fprintd `Claim` → `EnrollStart` → one line per `EnrollStatus` → `EnrollStop` → `Release`. + Cancellation is SIGTERM (stopping the Process); the helper still releases the device. + Exit 0 on `enroll-completed`, 1 otherwise. 90s idle watchdog. +- `remove FINGER` / `remove-all` → `DeleteEnrolledFinger` / `DeleteEnrolledFingers2` under a + Claim, and answer with the **`status` shape** so the service has one absorb path. +- Finger vocabulary lives in `services/Fingerprint.qml` (`allFingers`, `fingerLabel`); the + helper only shape-checks the name. +- Fixture seam: `PANAMA_FINGERPRINT_FIXTURE` = JSON file + `{"enrollStages":N,"results":["enroll-stage-passed",…],"error":""}`; every D-Bus call it + stands in for is appended to `PANAMA_FINGERPRINT_LOG` (default `.log`) as + `{"method","arguments"}`. No bus, no gi, no reader. + +**`scripts/panama-accounts`**: `list` **and `snapshot`** (alias; the service asks for +`snapshot`) · `set` · `remove` · `add-nextcloud SERVER USERNAME` · +`add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME`. Passwords on stdin, never argv. Both adds +answer with `{"accounts":[…],"error":""}`. + +- **GOA's add flow, verified**: `Goa.Manager.AddAccount(provider, identity, + presentation_identity, credentials a{sv}, details a{ss}) → o` is fully daemon-side — + goa-daemon writes `accounts.conf` and calls `goa_utils_store_credentials_for_id_sync` + itself. The missing libgoa-backend GIR blocks only the *client-side sign-in dialog*, so + password providers are genuinely addable and OAuth genuinely is not. `IsSupportedProvider` + is checked first (`owncloud`, `imap_smtp` both true here; `nextcloud` is not a provider + type — `owncloud` is). +- Keys are GOA 3.58's own, taken from `goaowncloudprovider.c` / `goaimapsmtpprovider.c` and + confirmed against this machine's `accounts.conf`. Nextcloud: credentials `{password}`, + details `Uri` (`…/remote.php/webdav/`), `FilesEnabled`, `CalendarEnabled`, `CalDavUri` + (`…/remote.php/dav/`), `ContactsEnabled`, `CardDavUri`, `AcceptSslErrors`. IMAP: + credentials `{imap-password, smtp-password}`, details `Enabled`, `EmailAddress`, `Name`, + `Imap{Host,UserName,UseSsl,UseTls,AcceptSslErrors}`, + `Smtp{Host,UseAuth,UserName,AuthLogin,AuthPlain,UseSsl,UseTls,AcceptSslErrors}` — IMAPS + + submission/STARTTLS defaults, one password for both servers. +- The daemon validates nothing, so each add calls `EnsureCredentials` on the new object and + **removes the account again** if the credentials are refused, reporting why. +- Fixture seam: `PANAMA_ACCOUNTS_FIXTURE` = a JSON file in `list`'s shape. With it set nothing + imports gi; validation still runs; each verb appends `{"verb","arguments","passwordBytes"}` + to `PANAMA_ACCOUNTS_LOG` (default `.log`) — the password's **length**, never itself. + +## Services (A — as built) + +- **UserAccounts**: `removeIcon()` (no argument — the hero card's own avatar), + `stockAvatars` + `loadStockAvatars()` (cached; call it when the gallery opens), + `deleteUser(user, keepFiles)` — **the bool inverted from the old `removeFiles`** — + `resetPassword(user)`, `setAccountTypeFor(user, type)` (**renamed** from `setAccountType`), + `setLocked(user, locked)`. `locked` and `loginTime` stay in the payload for the UI. +- **Fingerprint**: `readerPresent`, `readerName`, `unlockFeatureEnabled`, `fingers` + (**renamed** from `enrolled`), `allFingers`, `availableFingers`, `fingerLabel(f)`, + `cardVisible` (= `readerPresent || unlockFeatureEnabled`), `busy`, `lastError`, + `enrolling`, `enrollPhase`, `enrollFinger`, `enrollStage`, `enrollTotal`, `enrollResult`, + `refresh()`, `setUnlockEnabled(on)`, `startEnroll(finger)`, `cancelEnroll()`, + `removeFinger(f)`, `removeAll()`. +- **OnlineAccounts**: `available` (GOA answered the last snapshot — persisted, never touched + by a write), `snapshotError` / `writeError` and the derived `lastError`, `busy` (derived + from the Processes), `attentionCount`, `refresh()` (cheap; call on every page open), + `setService`, `remove`, `addNextcloud(server, user, password)`, + `addImap(email, imapHost, smtpHost, user, password)` — password via Process stdin. + +## UI (B) + +**UsersPage.qml** rebuilt per mock: hero card (avatar, Change picture… opens a popover with +stock gallery + "Choose a file…" → existing AvatarPicker/Cropper flow, Remove button); +Account card (Full name — live-committing field, Username honest row, Account type segment +with the last-admin reason as detail, Password flow with a 4-segment strength meter — local +heuristic, length + classes, no network; Automatic login with the mock's tradeoff copy); +Fingerprint card per mock (stuck row with NO READER badge + Turn off when +`unlockFeatureEnabled && !readerPresent`; full experience otherwise: toggle, enrolled list +with per-finger Remove…, Add a fingerprint → inline enrollment panel driven by the enroll +stream — touch icon, stage counter "N of M touches", cancel; no continuous animation); +Other accounts card (expandable per-user rows: type segment, Reset password ("They set a new +one at next sign-in"), Delete with a keep-files dropdown [Keep the files / Remove everything] ++ two-stage confirm; locked users show Unlock; add-user form with LIVE validation — use live +TextFields, not blur-committing TextFieldRow, with the username rules + 31-char cap in the +detail and mirrored in the enabled predicate). + +**OnlineAccountsPage.qml** rebuilt: gains `objectName: "accounts"`; "This desktop" card — +Home Assistant row (state from the HomeAssistant service: connected/rooms, else "not set up", +button → `openSettings("my-home")`) and Phone row (KdeConnect state, → `openSettings("phone")`); +Accounts card — one expandable row per GOA account (per-service toggles disabled while +`busy`, Remove with two-stage confirm), needs-attention rows keep "Sign in again"; Add card — +Nextcloud + Mail native forms (server/user/password, password field cleared on success or +close), and the OAuth row with the mock's honest copy → the existing GNOME dialog handoff +(the ALLOWED exception stays for accounts). Errors render as a row, never as page-wide +unavailability. + +## Search & docs (C) + +New entries: Fingerprint, Enroll a fingerprint, Delete a user, Account type, Reset a password +→ users; Nextcloud, Google account, Remove an account, Add a mail account → accounts. Docs +regen only if schema changes (none expected — verify). + +## Contracts (C — write; hermetic stub runs only) + +- `user-accounts-contract`: extend — keep-files arg end to end, reset-password sends no + password material (SetPasswordMode pinned), remove-icon empty-string call, stock list shape, + live-validation predicate present in the page (the blur-commit bug regression pin: the add + form's fields must not be TextFieldRow). +- `fingerprint-contract`: major rework — enrollment is native now (streamed stages against a + stubbed fprintd D-Bus layer or a stub helper — follow the hermetic pattern), the GNOME + enrollment handoff needle INVERTS (page must NOT call openGnomePanel for users), stuck-state + visibility pinned (`readerPresent || unlockFeatureEnabled`), finger vocabulary single-source + kept, set-unlock reason kept. +- `gnome-handoff-contract`: delete the `UsersPage.qml:system-users` ALLOWED exception; the + `OnlineAccountsPage.qml:online-accounts` exception stays with its reason updated to name + OAuth specifically. +- NEW `online-accounts-contract`: hermetic — availability decoupled from write errors, + passwords via stdin never argv, add flows validate inputs, remove requires the confirm + state, snapshot exposes no secrets. +- Backlog Phase 11; README count line (171 → 172 expected). + +## Agent ownership (parallel) + +- **A**: `scripts/panama-users`, `scripts/panama-fingerprint`, `scripts/panama-accounts`, + `services/UserAccounts.qml`, `services/Fingerprint.qml`, `services/OnlineAccounts.qml`. +- **B**: `modules/settings/UsersPage.qml`, `OnlineAccountsPage.qml`, new components + (+ qmldir); may read AvatarPicker/AvatarCropper and reuse unchanged. +- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line. + +Hard rules: NO live mutations — no user/password/icon/type changes, no GOA account writes, +no authselect, no fprintd enrollment against real hardware (none exists here anyway), no +polkit-triggering calls. Read-only probes and stubs only. Valid QML/Python at every save. +B programs against the pinned APIs; A updates this spec before changing them. diff --git a/tests/quickshell/fingerprint-contract b/tests/quickshell/fingerprint-contract index 539d1ba..0cccc65 100755 --- a/tests/quickshell/fingerprint-contract +++ b/tests/quickshell/fingerprint-contract @@ -6,48 +6,188 @@ # nothing, and that silence -- "I enrolled a finger and nothing happened" -- # is the failure this card exists to name. # -# The helper is the parse surface, so it runs for real against stub fprintd -# and authselect; the page and service checks are structural. +# Two things changed, and each inverts a pin this file used to hold. +# +# Enrollment is Panama's now. It used to open GNOME's Users panel, which was +# honest while fprintd's guided capture was the only thing there; the helper +# drives EnrollStart itself and streams a touch counter, so the handoff is the +# thing that would now be wrong. The needle is inverted rather than deleted: +# the page must NOT open that panel. +# +# And the card is no longer hidden by the absence of a reader. `pamEnabled` +# used to be reported as false whenever fprintd had nothing to say, so the one +# state a person genuinely has to act on -- the PAM feature switched on, no +# reader attached, every unlock now waiting on a device that is not there -- +# rendered as no card at all. The two facts are read independently, and the +# card appears when either is true. +# +# SAFETY. Enrollment claims a real device and authselect rewrites PAM, so +# nothing here may reach either. `env -i` with a stub directory first on PATH, +# both bus addresses pointed at sockets that do not exist, PANAMA_PATH pointed +# at a recording panama-sudo that runs nothing, and the enrollment stream +# replayed from PANAMA_FINGERPRINT_FIXTURE -- a canned fprintd whose calls land +# in a log file instead of on the bus. The PATH claim is asserted before the +# helper is run at all. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint" service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml" -page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml" +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" +page="$settings_dir/UsersPage.qml" fail() { printf 'fingerprint contract: %s\n' "$1" >&2 exit 1 } -# ── Wiring ─────────────────────────────────────────────────────────────────── +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-fingerprint is not executable' -rg -Fq 'visible: Fingerprint.readerPresent' "$page" \ - || fail 'the card is not hidden on machines with no reader' -rg -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \ +file_calling() { + grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1 +} + +# ── Enrollment stays here ──────────────────────────────────────────────────── +# +# The inverted needle. gnome-handoff-contract lost its UsersPage exception at +# the same time; this is the other half, said from the page's side. +grep -rn --include='*.qml' -E 'openGnomePanel\("(system", *"users|users")' "$settings_dir" \ + && fail 'a settings page still sends fingerprint enrollment to GNOME Users, which Panama owns' + +enroll_page="$(file_calling 'Fingerprint.startEnroll(')" +[[ -n "$enroll_page" ]] || fail 'nothing starts an enrollment' +grep -q 'Fingerprint.cancelEnroll()' "$enroll_page" \ + || fail 'an enrollment in progress cannot be cancelled, so the reader stays claimed' +grep -qE 'Fingerprint\.(removeFinger|removeAll)\(' "$enroll_page" \ + || fail 'an enrolled finger cannot be removed' + +# ── The card is visible in the state that needs acting on ──────────────────── +# +# `readerPresent || unlockFeatureEnabled`. Either alone hides the stuck state: +# the feature on with no reader is invisible under the first, and a machine +# with a reader and nothing configured is invisible under the second. +python3 - "$page" "$service" <<'PY' || fail 'the fingerprint card is not visible in the state that needs acting on' +import re +import sys + +page = open(sys.argv[1], encoding="utf-8").read() +service = open(sys.argv[2], encoding="utf-8").read() + +DISJUNCTION = re.compile( + r"readerPresent\s*\|\|\s*(root\.)?unlockFeatureEnabled" + r"|unlockFeatureEnabled\s*\|\|\s*(root\.)?readerPresent") + +visible = [line for line in page.splitlines() + if "visible:" in line and "Fingerprint." in line] +if not visible: + print("nothing decides whether the fingerprint card is shown", file=sys.stderr) + raise SystemExit(1) +card = visible[0] + +# Either the page spells it out, or it binds to a service property that does. +# The second is better -- one expression, so the card and the state it +# describes cannot disagree -- so the indirection is followed rather than +# forbidden. +if DISJUNCTION.search(card): + raise SystemExit(0) +named = re.search(r"visible:\s*Fingerprint\.(\w+)", card) +if named: + definition = re.search(rf"property bool {named.group(1)}:([^\n]*)", service) + if definition and DISJUNCTION.search(definition.group(1)): + raise SystemExit(0) + print(f"visible: Fingerprint.{named.group(1)}, which is " + f"{definition.group(1).strip() if definition else 'not defined'}", file=sys.stderr) + raise SystemExit(1) +print(card.strip(), file=sys.stderr) +raise SystemExit(1) +PY + +# And it says why it is there. A card that appears on a machine with no reader +# and offers nothing but a switch reads as a bug in the card. +stuck_page="$(file_calling 'Fingerprint.unlockFeatureEnabled')" +[[ -n "$stuck_page" ]] || fail 'no page reads the unlock feature independently of the reader' +grep -qiE 'no reader' "$stuck_page" \ + || fail 'the stuck state does not say there is no reader' +grep -qE 'Turn off' "$stuck_page" \ + || fail 'the stuck state offers no way out of it' + +grep -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \ || fail 'the unlock switch does not drive authselect' -rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$page" \ - || fail 'enrollment does not hand off to the GNOME Users panel' -rg -Fq 'Fingerprint.refresh()' "$page" \ +grep -Fq 'Fingerprint.refresh()' "$page" \ || fail 'the page never reads the fingerprint state' -rg -Fq 'authselect' "$helper" && rg -Fq 'with-fingerprint' "$helper" \ - || fail 'the helper does not manage the authselect feature' -rg -Fq -- '--reason' "$helper" \ - || fail 'the privileged change carries no stated reason' -rg -Fq 'function fingerLabel' "$service" \ + +# ── A finger is named in exactly one place ─────────────────────────────────── +# +# fprintd's vocabulary is "right-index-finger". What that READS as is a +# presentation decision, and two copies of it disagree the first time one is +# edited -- so the service presents, the helper only validates, and no page +# carries a finger name of its own. +grep -q 'function fingerLabel' "$service" \ || fail 'finger names have no single place to be presented from' +python3 - "$service" "$settings_dir" "$repo_dir/config/dot/quickshell/services" <<'PY' \ + || fail 'fprintd vocabulary is spelled out in more than one place' +import pathlib +import re +import sys -# ── The helper against stub fprintd and authselect ─────────────────────────── +FINGER = re.compile(r'"(?:left|right)-(?:thumb|(?:index|middle|ring|little)-finger)"') +owner = pathlib.Path(sys.argv[1]).resolve() -stub_dir="$(mktemp -d)" -state_dir="$(mktemp -d)" -trap 'rm -rf "$stub_dir" "$state_dir"' EXIT +# A single id elsewhere is a default choice -- "start on the right index +# finger" -- and that is allowed. A second one is a copy of the list, and the +# copy is what falls behind: the one in Fingerprint.qml grows a finger, the +# other does not, and half the interface offers nine. +for directory in sys.argv[2:]: + for path in sorted(pathlib.Path(directory).rglob("*.qml")): + if path.resolve() == owner: + continue + found = set(FINGER.findall(path.read_text(encoding="utf-8"))) + if len(found) > 1: + print(f"{path}: {sorted(found)}", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# The service carries the enrollment state the panel counts with. +for property in unlockFeatureEnabled enrolling enrollStage enrollTotal; do + grep -qE "property (bool|int|var|string) $property" "$service" \ + || fail "the service does not expose $property, so the page cannot show the enrollment" +done + +# ── The privileged change still says why ───────────────────────────────────── +grep -Fq 'authselect' "$helper" && grep -Fq 'with-fingerprint' "$helper" \ + || fail 'the helper does not manage the authselect feature' +grep -Fq -- '--reason' "$helper" \ + || fail 'the privileged change carries no stated reason' + +# ── The helper cannot walk past the stubs ──────────────────────────────────── +absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")" +[[ -z "$absolute" ]] \ + || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute" + +command -v jq >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no jq)\n'; exit 0; } +command -v python3 >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no python3)\n'; exit 0; } + +# ── The fake machine ───────────────────────────────────────────────────────── + +work="$(mktemp -d /tmp/panama-fingerprint-contract.XXXXXX)" +stub_dir="$work/bin" +state_dir="$work/state" +home_dir="$work/home" +run_dir="$work/run" +panama_dir="$work/panama/bin" +mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$run_dir" "$panama_dir" +trap 'rm -rf "$work"' EXIT cat >"$stub_dir/fprintd-list" <>"$state_dir/argv" if [[ -e "$state_dir/no-reader" ]]; then - echo 'Impossible to enumerate devices: No devices available' + echo 'Impossible to enumerate devices: No devices available' >&2 exit 1 fi cat <<'OUT' @@ -62,49 +202,300 @@ STUB cat >"$stub_dir/authselect" <>"$state_dir/authselect-log" +printf 'authselect %s\n' "\$*" >>"$state_dir/argv" if [[ "\$1" == "current" ]]; then echo 'Profile ID: local' - [[ -e "$state_dir/pam-on" ]] && echo '- with-fingerprint' + [[ -e "$state_dir/feature-on" ]] && echo '- with-fingerprint' exit 0 fi +printf 'fingerprint contract: authselect was asked to change PAM\n' >&2 +exit 1 STUB -# PANAMA_PATH pointed at an empty directory forces the plain-sudo fallback, -# which the stub records instead of escalating. -cat >"$stub_dir/sudo" <"$panama_dir/panama-sudo" <>"$state_dir/sudo-log" -exec "\$@" +printf 'panama-sudo %s\n' "\$*" >>"$state_dir/argv" +exit 0 STUB -chmod +x "$stub_dir"/fprintd-list "$stub_dir"/authselect "$stub_dir"/sudo -run() { PANAMA_PATH="$state_dir" PATH="$stub_dir:$PATH" "$helper" "$@"; } +# Anything else that could reach the machine is closed rather than left open. +for blocked in sudo pkexec gdbus busctl dbus-send fprintd-enroll fprintd-delete; do + cat >"$stub_dir/$blocked" <>"$state_dir/argv" +printf 'fingerprint contract: the helper reached for $blocked\n' >&2 +exit 1 +STUB +done +chmod +x "$stub_dir"/* "$panama_dir/panama-sudo" + +runh() { + env -i \ + PATH="$stub_dir:/usr/bin:/bin" \ + HOME="$home_dir" \ + USER=gib \ + XDG_RUNTIME_DIR="$run_dir" \ + PANAMA_PATH="$work/panama" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \ + DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \ + LANG=C LC_ALL=C \ + "$@" +} + +run() { runh "$helper" "$@"; } + +# Enrollment and removal replay a canned fprintd; nothing they do reaches a bus. +fixture="$work/fprintd.json" +log="$work/fprintd.log" +runf() { + runh env PANAMA_FINGERPRINT_FIXTURE="$fixture" PANAMA_FINGERPRINT_LOG="$log" \ + "$helper" "$@" +} + +# The safety claim, verified rather than assumed. +for binary in fprintd-list authselect sudo pkexec; do + resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)" + [[ "$resolved" == "$stub_dir/$binary" ]] \ + || fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one" +done + +# ── status: two facts, read independently ──────────────────────────────────── status="$(run status)" jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \ || fail "the reader name did not parse: $status" jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \ || fail "enrolled fingers did not parse: $status" -jq -e '.pamEnabled == false and .error == ""' <<<"$status" >/dev/null \ +jq -e '.unlockFeatureEnabled == false and .error == ""' <<<"$status" >/dev/null \ || fail "authselect state misread as enabled: $status" +# The old name is still answered, because the service and this file both read +# it and a silent rename would report "off" forever. +jq -e '.pamEnabled == .unlockFeatureEnabled' <<<"$status" >/dev/null \ + || fail "the two names for the PAM feature disagree: $status" -touch "$state_dir/pam-on" -jq -e '.pamEnabled == true' <<<"$(run status)" >/dev/null \ +touch "$state_dir/feature-on" +jq -e '.unlockFeatureEnabled == true' <<<"$(run status)" >/dev/null \ || fail 'with-fingerprint enabled was not detected' -# No reader is a normal machine, not an error. +# THE regression. No reader, feature on: the state that used to render as no +# card at all, because the feature was only read when fprintd answered. touch "$state_dir/no-reader" -jq -e '.reader == false and .error == ""' <<<"$(run status)" >/dev/null \ - || fail "a readerless machine was reported as a problem: $(run status)" -rm -f "$state_dir/no-reader" +stuck="$(run status)" +jq -e '.reader == false and .error == ""' <<<"$stuck" >/dev/null \ + || fail "a readerless machine was reported as a problem: $stuck" +jq -e '.unlockFeatureEnabled == true' <<<"$stuck" >/dev/null \ + || fail "the PAM feature was not read on a machine with no reader, which is the one state somebody has to be able to turn off: $stuck" +jq -e '.enrolled == []' <<<"$stuck" >/dev/null \ + || fail "a machine with no reader reported enrolled fingers: $stuck" +rm -f "$state_dir/no-reader" "$state_dir/feature-on" -# The privileged change goes through, with the right feature name. +# ── The privileged change goes through, with the right feature and a reason ── + +: >"$state_dir/argv" run set-unlock on >/dev/null -grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/sudo-log" \ - || fail 'set-unlock on did not enable the authselect feature' -run set-unlock off >/dev/null -grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/sudo-log" \ - || fail 'set-unlock off did not disable the authselect feature' +grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/argv" \ + || fail "set-unlock on did not enable the authselect feature: $(cat "$state_dir/argv")" +grep -qE 'panama-sudo --reason .*fingerprint' "$state_dir/argv" \ + || fail 'the prompt does not say what it is about to do' +grep -Eq '^(sudo|pkexec) ' "$state_dir/argv" \ + && fail 'the privileged change escalated without a stated reason' -printf 'fingerprint contract: ok\n' +: >"$state_dir/argv" +run set-unlock off >/dev/null +grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/argv" \ + || fail 'set-unlock off did not disable the authselect feature' +run set-unlock sideways >/dev/null 2>&1 \ + && fail 'set-unlock accepted something that is neither on nor off' + +# ── enroll: a stream, one line per touch ───────────────────────────────────── +# +# The whole point of doing this natively: the page counts touches while they +# happen. So the stream is read as a stream -- every line valid JSON on its own, +# the counter monotonic and bounded, and a terminal line that says which way it +# ended. + +printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-stage-passed","enroll-retry-scan-too-short","enroll-stage-passed","enroll-stage-passed","enroll-stage-passed","enroll-completed"],"error":""}\n' \ + >"$fixture" +: >"$log" + +runf enroll right-index-finger >"$work/stream.jsonl" \ + || fail "a completed enrollment exited nonzero: $(cat "$work/stream.jsonl")" +python3 - "$work/stream.jsonl" <<'PY' || fail 'the enrollment stream is not a usable progress stream' +import json +import sys + +lines = [line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() if line.strip()] +if len(lines) < 3: + print(f"only {len(lines)} line(s) of progress", file=sys.stderr) + raise SystemExit(1) + +events = [] +for line in lines: + try: + events.append(json.loads(line)) + except ValueError: + print(f"not a JSON object on its own line: {line}", file=sys.stderr) + raise SystemExit(1) + +for event in events: + for field in ("ok", "stage", "done", "total", "result"): + if field not in event: + print(f"a progress line is missing {field}: {event}", file=sys.stderr) + raise SystemExit(1) + if event["total"] != 5: + print(f"the touch count changed mid-enrollment: {event}", file=sys.stderr) + raise SystemExit(1) + if not 0 <= event["done"] <= event["total"]: + print(f"the counter left its own range: {event}", file=sys.stderr) + raise SystemExit(1) + +counts = [event["done"] for event in events] +if counts != sorted(counts): + print(f"the counter went backwards: {counts}", file=sys.stderr) + raise SystemExit(1) + +# A retry is a touch that did NOT count. If it advanced the counter, the panel +# would promise a finish that never arrives. +retries = [event for event in events if "retry" in str(event["result"])] +if not retries: + print("the failed touch produced no line at all, so the panel says nothing", + file=sys.stderr) + raise SystemExit(1) +if retries[0]["done"] != 2: + print(f"a retried touch was counted as progress: {retries[0]}", file=sys.stderr) + raise SystemExit(1) + +last = events[-1] +if not last["ok"] or last["done"] != last["total"]: + print(f"a completed enrollment did not finish full: {last}", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# The device is claimed and released around it, in that order. A reader left +# claimed by a crashed enrollment refuses the next one, and the message a +# person gets is "busy with something else" forever. +python3 - "$log" <<'PY' || fail 'the reader is not claimed and released around an enrollment' +import json +import sys + +calls = [json.loads(line)["method"] for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +if not calls: + print("nothing was called at all", file=sys.stderr) + raise SystemExit(1) +if calls[0] != "Claim": + print(f"the device was used before it was claimed: {calls}", file=sys.stderr) + raise SystemExit(1) +if calls[-1] != "Release": + print(f"the device was left claimed: {calls}", file=sys.stderr) + raise SystemExit(1) +if "EnrollStart" not in calls: + print(f"nothing started an enrollment: {calls}", file=sys.stderr) + raise SystemExit(1) +if calls.index("EnrollStart") < calls.index("Claim"): + print(f"enrollment started before the claim: {calls}", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# A finger already on the reader ends the stream badly, and says so in words +# rather than in fprintd's vocabulary. +printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-duplicate"],"error":""}\n' \ + >"$fixture" +: >"$log" +duplicate="$(runf enroll left-thumb)" && fail 'a failed enrollment exited zero' +last="$(tail -1 <<<"$duplicate")" +jq -e '.ok == false and .stage == "failed"' <<<"$last" >/dev/null \ + || fail "a failed enrollment did not end in a failure line: $last" +jq -e '(.error | length) > 0 and (.error | test("[a-z] [a-z]"))' <<<"$last" >/dev/null \ + || fail "the failure is reported in fprintd's vocabulary rather than in words: $last" +grep -Fq '"Release"' "$log" \ + || fail 'a failed enrollment left the reader claimed' + +# A refused claim -- the reader busy with the lock screen, most often -- is one +# line, not a hang. +printf '{"enrollStages":5,"results":[],"error":"The fingerprint reader is busy with something else."}\n' \ + >"$fixture" +: >"$log" +refused="$(runf enroll right-thumb)" && fail 'a refused claim exited zero' +jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refused")" >/dev/null \ + || fail "a refused claim did not report a failure: $refused" + +# ── Nonsense is refused before the reader is touched ───────────────────────── +printf '{"enrollStages":5,"results":["enroll-completed"],"error":""}\n' >"$fixture" +for bad in 'third-eye' 'right-index-finger; reboot' '' 'left-thumb-finger'; do + : >"$log" + refusal="$(runf enroll "$bad" 2>/dev/null)" \ + && fail "enroll accepted ${bad@Q} as a finger" + jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refusal")" >/dev/null \ + || fail "a bad finger name did not come back as a stream failure: $refusal" + [[ ! -s "$log" ]] \ + || fail "a bad finger name claimed the reader before being refused: ${bad@Q}" + : >"$log" + [[ -n "$(runf remove "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \ + || fail "remove accepted ${bad@Q} as a finger" + [[ ! -s "$log" ]] \ + || fail "a bad finger name reached the reader through remove: ${bad@Q}" +done + +# ── remove: one finger, or all of them, and never somebody else's ──────────── + +: >"$log" +removed="$(runf remove right-index-finger)" || fail 'remove failed against the canned fprintd' +jq -e 'has("reader") and has("enrolled") and has("unlockFeatureEnabled")' <<<"$removed" >/dev/null \ + || fail "remove does not answer with the fresh state: $removed" +python3 - "$log" right-index-finger <<'PY' || fail 'removing one finger did not delete that one finger' +import json +import sys + +calls = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +methods = [call["method"] for call in calls] +if methods[0] != "Claim" or methods[-1] != "Release": + print(f"the device was not claimed and released around it: {methods}", file=sys.stderr) + raise SystemExit(1) +delete = next((call for call in calls if call["method"].startswith("DeleteEnrolledFinger")), None) +if delete is None: + print(f"nothing was deleted: {methods}", file=sys.stderr) + raise SystemExit(1) +if delete["method"] == "DeleteEnrolledFingers2": + print("removing one finger deleted all of them", file=sys.stderr) + raise SystemExit(1) +if sys.argv[2] not in delete["arguments"]: + print(f"a different finger was deleted: {delete}", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +: >"$log" +runf remove-all >/dev/null || fail 'remove-all failed against the canned fprintd' +# DeleteEnrolledFingers2 works on the claimed user. Its predecessor took a user +# name, which is how a typo deletes somebody else's prints. +grep -Fq '"DeleteEnrolledFingers2"' "$log" \ + || fail 'remove-all does not use the method that works on the claimed user' +python3 - "$log" <<'PY' || fail 'remove-all names a user, which is how a typo deletes somebody else' +import json +import sys + +for line in open(sys.argv[1], encoding="utf-8"): + if not line.strip(): + continue + call = json.loads(line) + if call["method"] == "DeleteEnrolledFingers2" and call["arguments"]: + print(call, file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# ── With no fixture and no bus, it still answers in its own shape ──────────── +# +# The page reads this stream line by line. A traceback on stdout, or nothing at +# all, leaves an enrollment panel open forever with no way to know it failed. +absent="$(run enroll right-index-finger 2>/dev/null)" +[[ -n "$absent" ]] || fail 'enrollment against no fprintd printed nothing at all' +jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$absent")" >/dev/null \ + || fail "enrollment against no fprintd did not answer in the stream's own shape: $absent" + +printf 'fingerprint contract: PASS (status, stuck state, enroll stream, remove, reason)\n' diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract index ccebc14..032dd38 100755 --- a/tests/quickshell/gnome-handoff-contract +++ b/tests/quickshell/gnome-handoff-contract @@ -59,9 +59,13 @@ declare -A OWNED=( # Handoffs that are correct despite naming an owned panel, with the reason. # Anything here must be justified, not merely tolerated. +# +# The Users entry is gone: fingerprint enrollment was the only thing sending +# people to GNOME's Users panel, and Panama drives fprintd's EnrollStart itself +# now, so the door has nothing behind it. fingerprint-contract pins the +# inverse -- that the page does NOT open a GNOME panel. declare -A ALLOWED=( - ["OnlineAccountsPage.qml:online-accounts"]="adding an account requires GOA's own dialog" - ["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it" + ["OnlineAccountsPage.qml:online-accounts"]="OAuth sign-in (Google, Microsoft) runs inside libgoa-backend, which Fedora ships without a GIR binding, so the provider's own dialog is the only way to obtain the token; Nextcloud and IMAP are added on the page itself" ) # The leaves: a tabless category is a page in its own right, and every tab is diff --git a/tests/quickshell/online-accounts-contract b/tests/quickshell/online-accounts-contract new file mode 100755 index 0000000..5c5336a --- /dev/null +++ b/tests/quickshell/online-accounts-contract @@ -0,0 +1,533 @@ +#!/usr/bin/env bash + +# Online accounts -- the other kind (see user-accounts for the local ones). +# +# Three things can go wrong here, and two of them are silent. +# +# The loud one is a leaked password. Adding a Nextcloud or an IMAP account is +# the first time Panama collects a credential for somebody else's server, and +# GOA's AddAccount takes it as a D-Bus argument -- which is fine, that is a +# direct call to a daemon. What is not fine is the same string reaching this +# helper's own argv, where /proc publishes it to every process on the machine, +# or the account listing, which the page renders. +# +# The first silent one is the page telling somebody they have no online +# accounts. `available` used to be `lastError === ""`, and `lastError` is set by +# every failed write -- so refusing to remove one account replaced the whole +# card with "Online accounts are not available on this machine", listing +# nothing, while four accounts sat there working. Availability is whether GOA +# answered the listing. An error is a row. +# +# The second is a removal that happens on the first click. GOA's Remove is +# immediate and unrecoverable: the account is gone, and re-adding it means the +# whole sign-in again. It must take two. +# +# SAFETY. This is the account store of a signed-in desktop session, so it must +# be impossible for anything here to reach the real GOA: +# +# 1. the helper is run under `env -i` with a stub directory first on PATH, +# and with both D-Bus bus addresses pointed at sockets that do not exist, +# so a client that got as far as connecting could not; +# 2. `import gi` resolves to a stand-in on PYTHONPATH whose require_version +# always raises, which is the same seam network-tools-contract uses; +# 3. the account data comes from PANAMA_ACCOUNTS_FIXTURE, a file in the +# scratch tree, so nothing is read from or written to the session; +# 4. every D-Bus client binary is stubbed with one that records and refuses. +# +# The write verbs are exercised only against that fixture. Nothing here signs +# in to anything, and nothing here removes a real account. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-accounts" +service="$repo_dir/config/dot/quickshell/services/OnlineAccounts.qml" +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" +page="$settings_dir/OnlineAccountsPage.qml" + +fail() { + printf 'online accounts contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-accounts is not executable' + +file_calling() { + grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1 +} + +# Sentinels. Neither may come back out of anything. +readonly NEXTCLOUD_PW='nextcloud-pw-must-never-leave-6d3a' +readonly IMAP_PW='imap-pw-must-never-leave-91be' + +# ── Static: the helper cannot walk past the stubs ──────────────────────────── +# +# Checked before anything runs, because the safety claim above rests on it. +absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")" +[[ -z "$absolute" ]] \ + || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute" + +# ── Static: a password is read, never taken as an argument ─────────────────── +# +# The distinction that matters: handing the password to GOA over D-Bus is the +# supported way to add a password-based account. Handing it to THIS program as +# argv[4] would publish it through /proc for as long as the command runs. +grep -q 'sys.stdin' "$helper" \ + || fail 'the helper never reads stdin, so the password has no way in but argv' +python3 - "$helper" <<'PY' || fail 'the helper takes a password from its own command line' +import ast +import re +import sys + +SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I) +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) + +bad = [] +for node in ast.walk(tree): + # password = sys.argv[n], or any assignment of an argv slice to one. + if isinstance(node, ast.Assign): + names = [t.id for t in node.targets if isinstance(t, ast.Name)] + if any(SECRETISH.search(name) for name in names): + if "argv" in ast.dump(node.value): + bad.append(f"line {node.lineno}: {names} <- argv") + # And no command list may carry one either. + if isinstance(node, (ast.List, ast.Tuple)): + literals = {e.value for e in node.elts + if isinstance(e, ast.Constant) and isinstance(e.value, str)} + if not literals & {"gdbus", "busctl", "dbus-send", "gnome-control-center", "sh", "bash"}: + continue + for element in node.elts: + name = getattr(element, "id", getattr(element, "attr", "")) + if name and SECRETISH.search(name): + bad.append(f"line {node.lineno}: {name} in a command list") +if bad: + print("; ".join(bad), file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# ── Static: an added account is proved, not assumed ────────────────────────── +# +# GOA's AddAccount does not check what it is handed: it writes the account out +# and stores the password. A mistyped one therefore produces an account that +# exists, looks correct in the list, and never syncs -- which is the exact +# failure this page was built to be able to explain, so it must not be the +# failure the page creates. The credentials are exercised once, and an account +# that cannot sign in is taken back out. +python3 - "$helper" <<'PY' || fail 'a new account is never asked to prove its credentials' +import ast +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +source = ast.dump(tree) +if "call_add_account_sync" not in source: + print("nothing adds an account through GOA at all", file=sys.stderr) + raise SystemExit(1) +if "call_ensure_credentials_sync" not in source: + print("an added account is never signed in with, so a mistyped password " + "produces an account that exists and never syncs", file=sys.stderr) + raise SystemExit(1) + +# And the undo. The function that verifies has to be able to remove. +verify = next((node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "verify"), None) +if verify is None or "call_remove_sync" not in ast.dump(verify): + print("an account whose credentials were refused is left behind", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# GOA's provider type for Nextcloud is "owncloud" -- the fork kept the id. A +# provider type GOA does not know is not an error: AddAccount is simply never +# offered it, and the form reports a failure nobody can act on. +grep -qE '"owncloud"' "$helper" \ + || fail 'Nextcloud is added under a provider type GOA does not have (it is "owncloud")' +grep -qE '"imap_smtp"' "$helper" \ + || fail 'mail is added under a provider type GOA does not have (it is "imap_smtp")' + +# ── Static: the service hands it down the same way ─────────────────────────── +grep -qE 'stdinEnabled' "$service" \ + || fail 'the service never opens a helper stdin, so the password would have to be an argument' +python3 - "$service" <<'PY' || fail 'the service puts an account password on the command line' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +found = False +for match in re.finditer(r"function (addNextcloud|addImap)\(([^)]*)\)", text): + found = True + body = text[match.end():text.find("\n }", match.end())] + for argv in re.findall(r"\[[^\[\]]*\]", body): + if re.search(r"(password|secret|token)", argv, re.I): + print(f"{match.group(1)}: {argv.strip()[:200]}", file=sys.stderr) + raise SystemExit(1) +if not found: + print("the service cannot add a password-based account at all", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# ── Static: availability is not an error ───────────────────────────────────── +# +# The regression, pinned at the definition. `available` may be computed from +# whether the listing was answered; it may not be computed from lastError, +# which every refused write sets. +python3 - "$service" <<'PY' || fail 'the page-wide availability is still derived from a write error' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +if re.search(r"property bool available:", text) is None: + print("the service no longer says whether online accounts are available", file=sys.stderr) + raise SystemExit(1) + +# Every place `available` is decided: the declaration, and every assignment. +# A write error may not reach any of them. This is checked at each one rather +# than at the declaration alone, because the regression came back the second +# time as an assignment in the error handler of a failed toggle. +WRITE_ERRORS = re.compile(r"(lastError|writeError|setError|removeError)") +sites = [line for line in text.splitlines() + if re.search(r"property bool available:|\bavailable\s*=[^=]", line)] +if not sites: + print("nothing sets availability at all", file=sys.stderr) + raise SystemExit(1) +for line in sites: + if WRITE_ERRORS.search(line): + print(line.strip(), file=sys.stderr) + raise SystemExit(1) + +# And the two must be separable at all: one string for "GOA did not answer", +# another for "that one change did not happen". +if len(set(re.findall(r"property string (\w*[Ee]rror)", text))) < 2: + print("the service keeps one error string, so a failed write is indistinguishable " + "from a daemon that is not there", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# ── Static: the page ───────────────────────────────────────────────────────── +# +# An error is a row on a working page, not a replacement for the page. +python3 - "$page" <<'PY' || fail 'the page still hides everything behind an error' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +for line in text.splitlines(): + if re.search(r"visible:.*OnlineAccounts\.lastError\s*===\s*\"\"", line): + print(line.strip(), file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +remove_page="$(file_calling 'OnlineAccounts.remove(')" +[[ -n "$remove_page" ]] || fail 'nothing removes an online account' +python3 - "$remove_page" <<'PY' || fail 'removing an account is not gated on a confirmation' +import re +import sys + +lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +calls = [index for index, line in enumerate(lines) if "OnlineAccounts.remove(" in line] +if not calls: + raise SystemExit(1) +# The call has to sit behind a state the first click sets: a second button that +# only exists once "confirming" is true. Read from the same object block, so a +# `confirming` property declared elsewhere in the file does not count. +for index in calls: + window = "\n".join(lines[max(0, index - 25):index + 3]) + if not re.search(r"confirm", window, re.I): + print(f"line {index + 1}: {lines[index].strip()}", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# The account is named in the confirmation, and what is lost is said out loud: +# GOA's Remove cannot be undone and the sign-in has to be done again. +grep -qE 'Sign in again' "$page" \ + || fail 'an account whose credentials expired is not offered a way back in' + +# The password field must not survive the form that collected it. +add_page="$(file_calling 'addNextcloud(')" +[[ -n "$add_page" ]] || fail 'nothing adds a Nextcloud account natively' +grep -qE '\.clear\(\)|password = ""|secret = ""' "$add_page" \ + || fail 'the add form never clears the password it collected' + + +# ── Static: the listing itself cannot carry a credential ───────────────────── +# +# The listing is built from a fixed set of GOA properties, and the page renders +# every one of them. A password property added to that dict later is the way a +# secret would arrive on screen, so the keys are read rather than trusted. +python3 - "$helper" <<'PY' || fail 'the account listing exposes a credential-shaped field' +import ast +import re +import sys + +SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I) +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +describe = next((node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "describe"), None) +if describe is None: + print("nothing builds the account listing", file=sys.stderr) + raise SystemExit(1) + +keys = [] +for node in ast.walk(describe): + if isinstance(node, ast.Dict): + keys += [key.value for key in node.keys + if isinstance(key, ast.Constant) and isinstance(key.value, str)] +if not keys: + print("the listing has no fields at all", file=sys.stderr) + raise SystemExit(1) +offenders = [key for key in keys if SECRETISH.search(key)] +if offenders: + print(f"the listing carries {offenders}", file=sys.stderr) + raise SystemExit(1) + +# And the GOA properties it reads: `password` is a real property on some +# provider objects, and reading it here would put it in the dict above under +# whatever name somebody chose. +reads = [node.attr for node in ast.walk(describe) if isinstance(node, ast.Attribute)] +offenders = [name for name in reads if SECRETISH.search(name)] +if offenders: + print(f"the listing reads {offenders} off the GOA account", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# ── The fake GOA ───────────────────────────────────────────────────────────── + +command -v jq >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no jq)\n'; exit 0; } +command -v python3 >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no python3)\n'; exit 0; } + +grep -q 'PANAMA_ACCOUNTS_FIXTURE' "$helper" \ + || fail 'the helper has no fixture seam, so nothing can exercise it without the real account store' + +work="$(mktemp -d /tmp/panama-accounts-contract.XXXXXX)" +stub_dir="$work/bin" +home_dir="$work/home" +config_home="$work/config" +state_home="$work/xdg-state" +run_dir="$work/run" +pystub="$work/pystub" +mkdir -p "$stub_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi" +trap 'rm -rf "$work"' EXIT + +# The stand-in for PyGObject. With it in place the helper cannot construct a +# Goa.Client at all, so if the fixture seam were ever removed this contract +# would stop working rather than quietly start editing the session's accounts. +cat >"$pystub/gi/__init__.py" <<'GISTUB' +"""Stand-in for PyGObject, so panama-accounts cannot reach the real GOA.""" + + +def require_version(namespace, version): + raise ValueError(f"Namespace {namespace} not available") +GISTUB + +# Two accounts, one of them needing attention. The fixture is the listing's own +# shape, so it holds no password -- that half is pinned above, from the source. +fixture="$work/accounts.json" +log="$work/accounts.log" +cat >"$fixture" <<'FIXTURE' +{ + "accounts": [ + { + "path": "/org/gnome/OnlineAccounts/Accounts/account_0", + "provider": "owncloud", + "providerName": "Nextcloud", + "providerIcons": ["goa-account-owncloud"], + "identity": "gib@cloud.example.org", + "needsAttention": false, + "services": [ + { "key": "files", "label": "Files", "enabled": true }, + { "key": "calendar", "label": "Calendar", "enabled": false } + ] + }, + { + "path": "/org/gnome/OnlineAccounts/Accounts/account_1", + "provider": "imap_smtp", + "providerName": "Mail", + "providerIcons": ["goa-account-mail"], + "identity": "gib@example.com", + "needsAttention": true, + "services": [ + { "key": "mail", "label": "Mail", "enabled": true } + ] + } + ], + "error": "" +} +FIXTURE + +# Anything that could still be a way out is closed rather than left open. +for blocked in gdbus busctl dbus-send gnome-control-center goa-daemon pkexec; do + cat >"$stub_dir/$blocked" <&2 +exit 1 +STUB +done +chmod +x "$stub_dir"/* + +runh() { + env -i \ + PATH="$stub_dir:/usr/bin:/bin" \ + PYTHONPATH="$pystub" \ + HOME="$home_dir" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_STATE_HOME="$state_home" \ + XDG_RUNTIME_DIR="$run_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \ + DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \ + PANAMA_ACCOUNTS_FIXTURE="$fixture" \ + PANAMA_ACCOUNTS_LOG="$log" \ + LANG=C LC_ALL=C \ + "$helper" "$@" +} + +# The safety claim, verified rather than assumed. +for binary in gdbus busctl dbus-send gnome-control-center; do + resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)" + [[ "$resolved" == "$stub_dir/$binary" ]] \ + || fail "$binary resolves to '$resolved', not the stub; refusing to run" +done + +leak_in_scratch() { + grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" "$log" \ + 2>/dev/null | head -1 +} +recorded() { cat "$log" 2>/dev/null; } +error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; } + +# ── The listing answers, under both of its names ───────────────────────────── +# +# The service asks for `snapshot`, which is what every other Panama helper +# calls this; `list` is what this one was called first and what anything older +# still passes. Both have to work, or one of them is a page with no accounts. +: >"$log" +accounts="$(runh snapshot 2>/dev/null)" || fail 'snapshot failed against the fixture' +[[ "$(runh list 2>/dev/null)" == "$accounts" ]] \ + || fail 'list and snapshot do not answer the same thing' +jq -e '.accounts | type == "array" and length == 2' <<<"$accounts" >/dev/null \ + || fail "the fixture accounts did not come back: $accounts" +jq -e '[.accounts[] | has("path") and has("provider") and has("identity") + and has("needsAttention") and (.services | type == "array")] | all' \ + <<<"$accounts" >/dev/null || fail "an account is missing part of its shape: $accounts" +jq -e '[.accounts[] | select(.needsAttention)] | length == 1' <<<"$accounts" >/dev/null \ + || fail 'the account whose credentials expired is not reported as needing attention' +jq -e '.error == ""' <<<"$accounts" >/dev/null \ + || fail "reading the accounts reported an error: $accounts" +[[ -z "$(recorded)" ]] || fail 'reading the account list changed something' + +# ── Adding: the password arrives on stdin and stays nowhere ────────────────── +# +# The sentinel is typed in, and then looked for everywhere it could have gone: +# back out of the helper, into the log, into the scratch home. The log records +# its LENGTH, which is what proves it was read at all rather than dropped. + +: >"$log" +added="$(printf '%s\n' "$NEXTCLOUD_PW" \ + | runh add-nextcloud 'https://cloud.example.org' 'gib' 2>"$work/nextcloud.err")" +jq -e '.error == ""' <<<"$added" >/dev/null \ + || fail "a well-formed Nextcloud account was refused: $added" +jq -e '.accounts | length == 2' <<<"$added" >/dev/null \ + || fail "add-nextcloud does not answer with the account list: $added" +jq -e --argjson want "${#NEXTCLOUD_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \ + || fail "the password never reached the helper on stdin: $(recorded)" + +grep -Fq "$NEXTCLOUD_PW" <<<"$added" \ + && fail 'add-nextcloud echoes the password back in its own output' +grep -Fq "$NEXTCLOUD_PW" "$work/nextcloud.err" \ + && fail 'add-nextcloud wrote the password to stderr' +leaked="$(leak_in_scratch "$NEXTCLOUD_PW")" +[[ -z "$leaked" ]] || fail "the Nextcloud password was written to $leaked" + +: >"$log" +mail_added="$(printf '%s\n' "$IMAP_PW" \ + | runh add-imap 'gib@example.com' 'imap.example.com' 'smtp.example.com' 'gib' \ + 2>"$work/imap.err")" +jq -e '.error == ""' <<<"$mail_added" >/dev/null \ + || fail "a well-formed mail account was refused: $mail_added" +jq -e --argjson want "${#IMAP_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \ + || fail "the mail password never reached the helper on stdin: $(recorded)" +grep -Fq "$IMAP_PW" <<<"$mail_added" && fail 'add-imap echoes the password back' +grep -Fq "$IMAP_PW" "$work/imap.err" && fail 'add-imap wrote the password to stderr' +leaked="$(leak_in_scratch "$IMAP_PW")" +[[ -z "$leaked" ]] || fail "the mail password was written to $leaked" + +# ── An error is a row, not an empty page ───────────────────────────────────── +# +# The regression, from the data. Whatever went wrong, the accounts that are +# there must still come back with it -- the page draws its list from the same +# answer that carries the message. +: >"$log" +refused="$(printf 'anything\n' | runh add-nextcloud '' 'gib' 2>/dev/null)" +jq -e '(.error | length) > 0' <<<"$refused" >/dev/null \ + || fail "adding an account with no server was accepted: $refused" +jq -e '.accounts | length == 2' <<<"$refused" >/dev/null \ + || fail "a refused add emptied the account list, which is how one error hides four working accounts: $refused" + +# ── Adding validates what it was given ─────────────────────────────────────── +# +# Each of these produces an account that exists, looks right, and never syncs +# -- the failure this page is meant to be able to explain. They must be refused +# here, with nothing recorded, rather than handed to GOA to fail slowly. +# +# A password is piped in on purpose: without one the refusal would be "no +# password was provided", which every case would pass on regardless of whether +# the field it is about is checked at all. +attempt() { printf 'unused-password\n' | runh "$@" 2>/dev/null | jq -r '.error // ""'; } + +for bad_server in '' 'not a url' 'ftp://cloud.example.org' 'https://' \ + 'https://cloud.example.org; reboot' 'https://cloud example org'; do + : >"$log" + [[ -n "$(attempt add-nextcloud "$bad_server" 'gib')" ]] \ + || fail "add-nextcloud accepted ${bad_server@Q} as a server" + [[ -z "$(recorded)" ]] \ + || fail "a refused Nextcloud server was acted on anyway: ${bad_server@Q}" +done +[[ -n "$(attempt add-nextcloud 'https://cloud.example.org' '')" ]] \ + || fail 'add-nextcloud accepted an empty user name' +[[ -n "$(error_of add-nextcloud 'https://cloud.example.org' 'gib' "$log" + [[ -n "$(attempt add-imap 'gib@example.com' "$bad_host" 'smtp.example.com' 'gib')" ]] \ + || fail "add-imap accepted ${bad_host@Q} as an incoming server" + [[ -n "$(attempt add-imap 'gib@example.com' 'imap.example.com' "$bad_host" 'gib')" ]] \ + || fail "add-imap accepted ${bad_host@Q} as an outgoing server" + [[ -z "$(recorded)" ]] \ + || fail "a refused mail server was acted on anyway: ${bad_host@Q}" +done + +[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted' + +# ── Removing names the account it was asked about ──────────────────────────── +# +# Confirmed on the page (pinned above); here, only that the path travels intact +# -- removing the wrong account is unrecoverable and looks like a success. +: >"$log" +runh remove '/org/gnome/OnlineAccounts/Accounts/account_1' >/dev/null 2>&1 +jq -e '.arguments[0] == "/org/gnome/OnlineAccounts/Accounts/account_1"' \ + <<<"$(recorded)" >/dev/null || fail "remove did not carry the account path: $(recorded)" + +: >"$log" +runh set '/org/gnome/OnlineAccounts/Accounts/account_0' calendar true >/dev/null 2>&1 +jq -e '.arguments == ["/org/gnome/OnlineAccounts/Accounts/account_0", "calendar", "true"]' \ + <<<"$(recorded)" >/dev/null || fail "a service toggle did not carry its arguments: $(recorded)" + +# ── Nothing anywhere left a secret behind ──────────────────────────────────── +for secret in "$NEXTCLOUD_PW" "$IMAP_PW"; do + leaked="$(leak_in_scratch "$secret")" + [[ -z "$leaked" ]] || fail "a password was left behind in $leaked" +done + +printf 'online accounts contract: PASS (listing, availability, add, remove, no secret leaves)\n' diff --git a/tests/quickshell/user-accounts-contract b/tests/quickshell/user-accounts-contract index 4b69ca7..921d4c5 100755 --- a/tests/quickshell/user-accounts-contract +++ b/tests/quickshell/user-accounts-contract @@ -7,14 +7,18 @@ # # Nothing here creates, deletes, or modifies a real account. It reads the # account state, which is safe, and exercises the refusals, which are the part -# that has to hold. +# that has to hold. Every verb that would change something is read from the +# source instead of being run -- `set-icon gib ""` really does clear the +# avatar, and `delete-user` really does delete, so those are pinned statically +# and never invoked. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-users" service="$repo_dir/config/dot/quickshell/services/UserAccounts.qml" -page="$repo_dir/config/dot/quickshell/modules/settings/UsersPage.qml" +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" +page="$settings_dir/UsersPage.qml" fail() { printf 'user accounts contract: %s\n' "$1" >&2 @@ -26,6 +30,13 @@ for path in "$helper" "$service" "$page"; do done [[ -x "$helper" ]] || fail 'panama-users is not executable' +# The page was rebuilt into several files, so the add-user form and the +# per-user rows may not live in UsersPage.qml any more. Everything structural +# is looked up by what it calls rather than by which file it sits in. +file_calling() { + grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1 +} + # ── A new password never reaches a command line ───────────────────────────── # argv is world-readable through /proc, so a password passed as an argument is # published to every process on the machine. It is read from stdin, and the @@ -50,23 +61,363 @@ grep -qE 'command:.*set-password.*password' "$service" \ grep -q 'root.pendingPassword = ""' "$service" \ || fail 'the service never clears the password it was holding' -# ── Refusals that keep a machine administrable ────────────────────────────── +# ── Resetting a password is not the same as setting one ───────────────────── +# +# "Reset" hands the account back to its owner: accountsservice's +# SetPasswordMode(1) means "choose one at the next sign-in". The whole point is +# that an administrator resetting somebody else's password never learns, types, +# or transports a password -- so this verb must not touch stdin, must not reach +# for a hashing tool, and must not go anywhere near SetPassword. +python3 - "$helper" <<'PY' || fail 'reset-password does not set password mode 1, or it handles password material' +import ast +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +target = next((node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "reset_password"), None) +if target is None: + print("reset-password has no implementation", file=sys.stderr) + raise SystemExit(1) + +# Module constants, so the mode may be a name with a meaning rather than a 1. +constants = {} +for node in tree.body: + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant): + for name in node.targets: + if isinstance(name, ast.Name): + constants[name.id] = node.value.value + + +def literal(node): + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name): + return constants.get(node.id) + return None + + +methods = [element.value for node in ast.walk(target) + for element in ast.walk(node) + if isinstance(element, ast.Constant) and isinstance(element.value, str)] +if "SetPasswordMode" not in methods: + print("reset-password does not use accountsservice SetPasswordMode", file=sys.stderr) + raise SystemExit(1) +for forbidden in ("SetPassword", "SetPasswordHint"): + if forbidden in methods: + print(f"reset-password calls {forbidden}, which carries password material", + file=sys.stderr) + raise SystemExit(1) + +# Mode 1: "no usable password, choose one at the next sign-in". 0 would mean a +# password is set, 2 would mean none is ever needed -- both are somebody else's +# account handed away. +modes = [literal(element) for node in ast.walk(target) + if isinstance(node, ast.Tuple) + for element in node.elts] +if 1 not in modes: + print(f"reset-password does not ask for mode 1: {modes}", file=sys.stderr) + raise SystemExit(1) + +# Nothing that could be a password may pass through it. The docstring is +# excluded on purpose -- it is allowed to say the word. +code = ast.dump(ast.Module(body=[node for node in target.body + if not (isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant))], + type_ignores=[])) +for word in ("stdin", "openssl", "crypt", "passwd"): + if word in code: + print(f"reset-password touches {word}; it must only set the mode", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +grep -q 'function resetPassword' "$service" \ + || fail 'the service cannot reset a password' +grep -qE 'resetPassword[^}]*stdin' "$service" \ + && fail 'the service opens stdin for a reset, which carries nothing to write' + +reset_page="$(file_calling 'resetPassword(')" +[[ -n "$reset_page" ]] || fail 'no page offers to reset another account password' +grep -q 'at next sign-in\|at their next sign-in' "$reset_page" \ + || fail 'the reset row does not say the other person sets the new password themselves' + +# ── Removing a picture is a real verb, not a deletion of the file ─────────── +# +# accountsservice takes an empty IconFile to mean "no avatar" and cleans up +# after itself. Anything else -- unlinking the file the snapshot named, writing +# a blank image -- leaves the database pointing at something that is not there. +icon_body="$(sed -n '/^def set_icon/,/^def [a-z_]*(/p' "$helper")" +[[ -n "$icon_body" ]] || fail 'set_icon is missing' +grep -q 'SetIconFile' <<<"$icon_body" \ + || fail 'the avatar is not written through accountsservice' +python3 - "$helper" <<'PY' || fail 'set-icon with an empty path does not clear the avatar through SetIconFile("")' +import ast +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +target = next((node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "set_icon"), None) +if target is None: + raise SystemExit(1) +dump = ast.dump(target) +# An empty path is a value the function has to recognise, not a path it hands +# to GdkPixbuf -- which would fail, and the avatar would stay. +if 'Constant(value=\'\')' not in dump and 'value=""' not in dump: + print("set_icon never compares its path against the empty string", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +grep -q 'function removeIcon' "$service" \ + || fail 'the service cannot remove a picture' +python3 - "$service" <<'PY' || fail 'removeIcon does not send an empty path to the helper' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +start = text.find("function removeIcon") +if start < 0: + raise SystemExit(1) +body = text[start:text.find("\n }", start)] +if not re.search(r'"set-icon"[^\]]*""', body): + print(body.strip()[:300], file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# ── Deleting says what happens to the files, and honors the answer ────────── +# +# The choice is the whole feature: "Keep the files" and "Remove everything" are +# different, irreversible outcomes, and a dropdown whose answer is dropped on +# the way down is worse than no dropdown at all. Each link is pinned +# separately, because any one of them can invert on its own. + +# 1. The helper turns its argument into accountsservice's boolean. delete_body="$(sed -n '/^def delete_user/,/^def /p' "$helper")" +[[ -n "$delete_body" ]] || fail 'delete_user is missing' grep -q 'You cannot delete the account you are signed in to' <<<"$delete_body" \ || fail 'the helper would delete the account running it' grep -q 'only administrator' <<<"$delete_body" \ || fail 'the helper would remove the last administrator, leaving nobody able to administer the machine' +python3 - "$helper" <<'PY' || fail 'the helper does not derive the DeleteUser flag from its keep/remove argument' +import ast +import sys -# The page must not offer to change the type of the only administrator either. -grep -q 'administratorCount <= 1' "$page" \ - || fail 'the page offers to demote the only administrator' +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +target = next((node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "delete_user"), None) +if target is None: + raise SystemExit(1) +parameters = [argument.arg for argument in target.args.args] +if len(parameters) < 2: + print("delete_user takes no keep/remove argument at all", file=sys.stderr) + raise SystemExit(1) +choice = parameters[1] + +# The boolean handed to DeleteUser must be computed from that argument. A +# literal True or False there is the bug this whole block exists to catch. +for node in ast.walk(target): + if not isinstance(node, ast.Call): + continue + name = getattr(node.func, "attr", getattr(node.func, "id", "")) + if name != "Variant": + continue + for element in ast.walk(node): + if isinstance(element, ast.Compare) and any( + isinstance(sub, ast.Name) and sub.id == choice + for sub in ast.walk(element)): + raise SystemExit(0) +print(f"the DeleteUser flag is not computed from {choice}", file=sys.stderr) +raise SystemExit(1) +PY + +# 2. The service maps its own parameter the way its name reads. Panama has +# flipped this polarity once already (removeFiles -> keepFiles); a mapping +# that says one and sends the other reads correctly in every diff. +python3 - "$service" <<'PY' || fail 'the service maps its keep/remove parameter the wrong way round' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r"function deleteUser\(([^)]*)\)", text) +if match is None: + print("the service has no deleteUser", file=sys.stderr) + raise SystemExit(1) +parameters = [part.split(":")[0].strip() for part in match.group(1).split(",")] +if len(parameters) < 2: + print("deleteUser takes no keep/remove argument", file=sys.stderr) + raise SystemExit(1) +choice = parameters[1] + +body = text[match.end():text.find("\n }", match.end())] +ternary = re.search(re.escape(choice) + r"\s*\?\s*\"([a-z-]+)\"\s*:\s*\"([a-z-]+)\"", body) +if ternary is None: + print(f"deleteUser does not pass {choice} through to the helper: {body.strip()[:200]}", + file=sys.stderr) + raise SystemExit(1) +when_true, when_false = ternary.groups() +wanted = "keep" if "keep" in choice.lower() else "remove" +if wanted not in when_true or wanted in when_false: + print(f"{choice} true sends {when_true!r}, false sends {when_false!r}", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# 3. The page passes the answer, not a constant. +delete_page="$(file_calling 'deleteUser(')" +[[ -n "$delete_page" ]] || fail 'no page deletes an account' +grep -qE 'deleteUser\([^)]*,\s*(true|false)\s*\)' "$delete_page" \ + && fail 'the page hardcodes what happens to the files, so the choice it offers is decorative' +grep -q 'Keep the files' "$delete_page" \ + || fail 'the page never offers to keep the deleted account files' +grep -qE 'Remove everything|Delete the files|Remove the files' "$delete_page" \ + || fail 'the page never offers to remove the deleted account files' # ── Deleting is confirmed, and says what it destroys ──────────────────────── -grep -q 'confirmingRemoval' "$page" \ +grep -q 'confirmingRemoval' "$delete_page" \ || fail 'the page deletes an account without a confirmation step' -grep -q 'This cannot be undone' "$page" \ +grep -q 'This cannot be undone' "$delete_page" \ || fail 'the page does not say that deleting an account destroys their files' +# The page must not offer to change the type of the only administrator either. +type_page="$(file_calling 'administratorCount')" +[[ -n "$type_page" ]] || fail 'nothing on the page knows how many administrators there are' +grep -q 'administratorCount <= 1' "$type_page" \ + || fail 'the page offers to demote the only administrator' + +# And the helper refuses it regardless of what the page offers. Demoting the +# only administrator is the same loss as deleting them -- a machine nobody can +# administer -- and the page is not the only thing that can call this. +python3 - "$helper" <<'PY' || fail 'the helper would demote the only administrator' +import ast +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) +target = next((node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "set_account_type"), None) +if target is None: + print("set-account-type has no implementation", file=sys.stderr) + raise SystemExit(1) +dump = ast.dump(target) +if "administratorCount" not in dump: + print("set-account-type never counts the administrators", file=sys.stderr) + raise SystemExit(1) +if "BoundaryError" not in dump: + print("set-account-type counts them and refuses nothing", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + +# ── Managing somebody else ────────────────────────────────────────────────── +# +# Account type, locked accounts and the reset above are the verbs that only +# make sense for an account that is not yours, and each has to exist on both +# sides or the row is a button that does nothing. +grep -q 'function setAccountTypeFor\|function setAccountType' "$service" \ + || fail 'the service cannot change another account type' +grep -q 'function setLocked' "$service" \ + || fail 'the service cannot unlock a locked account' +grep -qE '"set-locked"' "$service" \ + || fail 'the service does not reach the helper set-locked verb' +grep -qE '\bset-locked\b' "$helper" \ + || fail 'the helper has no set-locked verb' +grep -q 'Unlock' "$(file_calling 'setLocked(')" \ + || fail 'a locked account is never offered an unlock' + +# ── The add-user form validates while it is being typed ───────────────────── +# +# The regression this pins: the fields were TextFieldRow, which commits on +# blur. Typing a perfectly good user name left the Create button disabled, +# because the property behind the enabled predicate had not been told yet, and +# the form looked broken for as long as the field had focus. So the property +# the predicate reads must be updated on every keystroke, and the field it +# comes from must not be the blur-committing row. +create_page="$(file_calling 'UserAccounts.createUser(')" +[[ -n "$create_page" ]] || fail 'nothing creates a user' + +# The blunt half of the same pin, and the one that cannot be argued with: the +# page that carries the add-user form has no blur-committing field anywhere. +# LiveFieldRow reports per keystroke and also on accept, so a row that only +# wants the accept behaviour has no reason to reach for the old one. +grep -nE '^\s*TextFieldRow\s*\{' "$create_page" \ + && fail 'the add-user page still has a blur-committing field, which is the bug' +python3 - "$create_page" "$helper" <<'PY' || fail 'the add-user form does not validate live' +import re +import sys + +page = open(sys.argv[1], encoding="utf-8").read() +lines = page.splitlines() + +# The rules the form enforces: the one regular expression it tests a user name +# against, wherever it lives -- an `enabled:` predicate, or a property that +# turns the same test into the message under the field. Found by the shape of +# the rule rather than by where it sits, because it has moved once already. +predicate = next((line for line in lines + if ".test(" in line and re.search(r"/\^\[a-z", line)), None) +if predicate is None: + print("the add-user form validates the user name nowhere at all", file=sys.stderr) + raise SystemExit(1) + +held = re.search(r"\.test\(\s*(?:root\.)?([A-Za-z_][\w.]*)", predicate) +if held is None: + print(f"cannot tell what the form validates: {predicate.strip()}", file=sys.stderr) + raise SystemExit(1) +name = held.group(1).split(".")[-1] + +# They must be the helper's rules. Two expressions that drift apart give a form +# that accepts a name accountsservice then refuses, with no way to tell why. +helper_rule = re.search(r'USERNAME\s*=\s*re\.compile\(r"([^"]+)"\)', + open(sys.argv[2], encoding="utf-8").read()) +page_rule = re.search(r"/(\^[^/]+\$)/", predicate) +if page_rule is None: + print(f"the form's user-name test is not a regular expression: {predicate.strip()}", + file=sys.stderr) + raise SystemExit(1) +if helper_rule and helper_rule.group(1) != page_rule.group(1): + print(f"the form validates {page_rule.group(1)}, the helper enforces {helper_rule.group(1)}", + file=sys.stderr) + raise SystemExit(1) +# The cap is part of the rules and part of the sentence under the field: a +# 40-character name is accepted by the form and refused by accountsservice. +if "31" not in page_rule.group(1): + print(f"the form does not cap the user name length: {page_rule.group(1)}", file=sys.stderr) + raise SystemExit(1) +if "31" not in page: + print("the form never tells anybody about the length cap", file=sys.stderr) + raise SystemExit(1) + +# Where that property is written, and by what kind of field. `edited` (or a +# text-changed handler) is per-keystroke; `accepted` is blur, which is the bug. +assignments = [index for index, line in enumerate(lines) + if re.search(rf"\b{re.escape(name)}\s*=", line) + and "property" not in line] +if not assignments: + print(f"{name} is never assigned, so the form can never become valid", file=sys.stderr) + raise SystemExit(1) + +LIVE = re.compile(r"on(Edited|TextChanged|TextEdited|DisplayTextChanged)\b") +live = False +for index in assignments: + window = "\n".join(lines[max(0, index - 4):index + 1]) + if LIVE.search(window): + live = True + # The enclosing component: the nearest `Type {` above, at any indentation. + for above in range(index, -1, -1): + opener = re.match(r"\s*([A-Z]\w*)\s*\{", lines[above]) + if opener: + if opener.group(1) == "TextFieldRow": + print(f"{name} is committed by a TextFieldRow, which only commits on blur " + f"(line {above + 1})", file=sys.stderr) + raise SystemExit(1) + break + +if not live: + print(f"{name} is only committed on accept, so the form cannot validate while it is typed", + file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + # ── The snapshot is real, and reports no secrets ──────────────────────────── command -v jq >/dev/null 2>&1 || { printf 'user accounts contract: SKIP (no jq)\n'; exit 0; } snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' @@ -77,6 +428,11 @@ jq -e '[.users[] | (.userName | length > 0)] | all' <<<"$snapshot" >/dev/null \ jq -e '.currentUser | length > 0' <<<"$snapshot" >/dev/null \ || fail 'the snapshot does not say which account is signed in' +# The page renders these; a snapshot that drops them turns a locked account +# into a normal-looking one nobody can sign in to. +jq -e '[.users[] | has("locked") and has("loginTime")] | all' <<<"$snapshot" >/dev/null \ + || fail 'the snapshot no longer reports whether an account is locked' + offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|hash)$";"i"))) | join(", ")' <<<"$snapshot")" [[ -z "$offenders" ]] || fail "the snapshot carries credential-shaped fields: $offenders" @@ -84,11 +440,51 @@ offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(pass jq -e '[.users[] | .uid >= 1000] | all' <<<"$snapshot" >/dev/null \ || fail 'a system account is listed as a manageable user' +# ── The stock avatars are a list, of a shape the gallery can draw ─────────── +# +# Read-only: it lists files that ship with the distribution. An empty list is +# a legitimate answer on a machine without the faces package, so the shape is +# what is pinned, not the contents. +stock="$("$helper" stock-avatars 2>/dev/null)" || fail 'stock-avatars failed' +jq -e '.avatars | type == "array"' <<<"$stock" >/dev/null \ + || fail "stock-avatars does not answer with a list: $stock" +jq -e '[.avatars[] | has("name") and has("path")] | all' <<<"$stock" >/dev/null \ + || fail "a stock avatar is missing its name or its path: $stock" +jq -e '[.avatars[] | (.path | startswith("/"))] | all' <<<"$stock" >/dev/null \ + || fail 'a stock avatar path is not absolute, so nothing can load it' +grep -q 'stockAvatars' "$service" \ + || fail 'the service does not offer the stock avatars to the gallery' + # ── Input validation ──────────────────────────────────────────────────────── for bad in "root; rm -rf /" "../escape" "UPPER" ""; do result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')" [[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name" done -printf 'user accounts contract: PASS (%d account(s), credentials never on a command line)\n' \ +# Deleting refuses before it reaches accountsservice, not after. +[[ -n "$("$helper" delete-user "${USER:-nobody}" 2>/dev/null | jq -r '.error // ""')" ]] \ + || fail 'delete-user with no keep/remove answer was accepted' +[[ -n "$("$helper" delete-user "${USER:-nobody}" sideways 2>/dev/null | jq -r '.error // ""')" ]] \ + || fail 'delete-user accepted an answer that is neither keep nor remove' + +# Both vocabularies, on purpose: `keep`/`remove` is what the page says out +# loud, `keep-files`/`remove-files` is what this helper has always taken and +# what anything older still passes. The refusal below is the self-deletion one, +# which is reached only once the keep/remove answer has been understood -- so a +# vocabulary that stopped being recognized would show up here as the wrong +# message rather than as no message. +for vocabulary in keep remove keep-files remove-files; do + refusal="$("$helper" delete-user "${USER:-nobody}" "$vocabulary" 2>/dev/null \ + | jq -r '.error // ""')" + [[ -n "$refusal" ]] || fail "the helper agreed to delete the account running it" + grep -q 'signed in to' <<<"$refusal" \ + || fail "delete-user no longer understands \"$vocabulary\": $refusal" +done + +for bad in "root; rm -rf /" "UPPER" ""; do + [[ -n "$("$helper" reset-password "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \ + || fail "reset-password accepted \"$bad\" as a user name" +done + +printf 'user accounts contract: PASS (%d account(s), credentials never on a command line, keep-files honored end to end)\n' \ "$(jq '.users | length' <<<"$snapshot")"