Give identity its due: native enrollment, honest deletion, and sign-in that stays home
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## 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
|
```sh
|
||||||
panama test # everything
|
panama test # everything
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,22 @@
|
|||||||
//
|
//
|
||||||
// GNOME's panel of the same name, except for one step. The accounts are GNOME
|
// 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,
|
// 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
|
// The one hand-off left is OAuth. Google's and Microsoft's tokens are obtained
|
||||||
// credentials are OAuth tokens and the code that obtains them ships without a
|
// by code inside libgoa-backend, which Fedora ships without a GIR binding, so
|
||||||
// scriptable binding. That is stated on the page rather than hidden behind a
|
// no amount of D-Bus gets at it. That is stated on the page rather than hidden
|
||||||
// button that looks native, because a hand-off the user does not expect reads
|
// behind a button that looks native, because a hand-off nobody expects reads as
|
||||||
// as a bug.
|
// 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
|
// What Panama itself signs in to leads, because those two integrations are the
|
||||||
// expired and nothing outside its own panel says so, which is how an account
|
// ones this desktop actually uses. GOA's accounts are for the applications.
|
||||||
// quietly stops syncing for weeks.
|
//
|
||||||
|
// 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 QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
@@ -22,118 +27,452 @@ import qs.services
|
|||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
objectName: "accounts"
|
||||||
title: "Online Accounts"
|
title: "Online Accounts"
|
||||||
lede: OnlineAccounts.attentionCount > 0
|
lede: OnlineAccounts.attentionCount > 0
|
||||||
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
|
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
|
||||||
? " account needs you to sign in again."
|
? " account needs you to sign in again."
|
||||||
: " accounts need 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 {
|
property string ncServer: ""
|
||||||
visible: !OnlineAccounts.available
|
property string ncUser: ""
|
||||||
title: "Accounts unavailable"
|
property string ncPassword: ""
|
||||||
subtitle: OnlineAccounts.lastError
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
property string imapEmail: ""
|
||||||
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
|
property string imapHost: ""
|
||||||
title: "No accounts yet"
|
property string smtpHost: ""
|
||||||
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
|
property string imapUser: ""
|
||||||
|
property string imapPassword: ""
|
||||||
|
|
||||||
ActionRow {
|
readonly property bool nextcloudReady: /^https?:\/\/.+/.test(root.ncServer.trim())
|
||||||
label: "Add an account"
|
&& root.ncUser.trim() !== "" && root.ncPassword !== ""
|
||||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
|
||||||
action: "Add account"
|
readonly property bool imapReady: /^[^@\s]+@[^@\s]+$/.test(root.imapEmail.trim())
|
||||||
divider: false
|
&& root.imapHost.trim() !== "" && root.smtpHost.trim() !== ""
|
||||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
&& 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 {
|
readonly property string phoneName: KdeConnect.preferredPhone
|
||||||
model: OnlineAccounts.accounts
|
? String(KdeConnect.preferredPhone.name)
|
||||||
|
: "Phone"
|
||||||
|
|
||||||
SettingsCard {
|
readonly property string phoneSummary: {
|
||||||
id: accountCard
|
if (!KdeConnect.available)
|
||||||
required property var modelData
|
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
|
// Just the host, because a full URL in a subtitle is mostly scheme and
|
||||||
// the first name the active icon theme actually has. Taking the
|
// trailing slash.
|
||||||
// first name blindly, or the last as a fallback, both looked right
|
function hostOf(url: string): string {
|
||||||
// and were not: on Adwaita the tail of these chains ("mail",
|
const text = String(url ?? "").trim();
|
||||||
// "goa-symbolic") does not exist, so a miss would have rendered
|
if (text === "")
|
||||||
// nothing at all.
|
return "Home Assistant";
|
||||||
icon: {
|
return text.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||||
const chain = accountCard.modelData.providerIcons ?? [];
|
}
|
||||||
for (const name of chain) {
|
|
||||||
if (Quickshell.iconPath(String(name), true) !== "")
|
|
||||||
return String(name);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
iconFallback: "avatar-default-symbolic"
|
|
||||||
|
|
||||||
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
// The single hand-off. Both the OAuth add row and a needs-attention row
|
||||||
subtitle: accountCard.modelData.needsAttention
|
// arrive here, so this file names a GNOME panel exactly once -- see
|
||||||
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
// gnome-handoff-contract, which allows this one and nothing else.
|
||||||
: accountCard.modelData.providerName
|
function openProviderDialog(): void {
|
||||||
|
SystemSettings.openGnomePanel("online-accounts");
|
||||||
|
}
|
||||||
|
|
||||||
// Only when it is true, because it is the one thing on this page
|
function closeForms(): void {
|
||||||
// that needs acting on.
|
root.openForm = "";
|
||||||
ActionRow {
|
root.ncServer = "";
|
||||||
visible: accountCard.modelData.needsAttention
|
root.ncUser = "";
|
||||||
label: "Sign in again"
|
root.ncPassword = "";
|
||||||
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
|
nextcloudPassword.clear();
|
||||||
action: "Sign in"
|
root.imapEmail = "";
|
||||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
root.imapHost = "";
|
||||||
}
|
root.smtpHost = "";
|
||||||
|
root.imapUser = "";
|
||||||
|
root.imapPassword = "";
|
||||||
|
imapPasswordField.clear();
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
// Every open, not only the first: accounts are added and removed by people
|
||||||
model: accountCard.modelData.services
|
// -- 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 {
|
// Two errors, kept apart because they mean different things. A refused
|
||||||
id: serviceRow
|
// write is about the one thing somebody just did; a listing that failed is
|
||||||
required property var modelData
|
// why the card below might be empty. Neither takes the page away.
|
||||||
required property int index
|
TextRow {
|
||||||
|
visible: OnlineAccounts.writeError !== ""
|
||||||
|
label: "That did not go through"
|
||||||
|
detail: OnlineAccounts.writeError
|
||||||
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
|
||||||
label: serviceRow.modelData.label
|
TextRow {
|
||||||
detail: serviceRow.modelData.enabled
|
visible: OnlineAccounts.snapshotError !== ""
|
||||||
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
|
label: "The account list could not be read"
|
||||||
: "Hidden from applications"
|
detail: OnlineAccounts.snapshotError
|
||||||
controlWidth: 48
|
value: ""
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
|
||||||
SettingsToggle {
|
// ── What Panama itself uses ──────────────────────────────────────────────
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
SettingsCard {
|
||||||
checked: serviceRow.modelData.enabled
|
title: "This desktop"
|
||||||
onToggled: value => OnlineAccounts.setService(
|
subtitle: "The two services Panama signs in to on its own behalf."
|
||||||
accountCard.modelData.path, serviceRow.modelData.key, value)
|
|
||||||
|
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 {
|
SettingsButton {
|
||||||
label: "Remove this account"
|
text: HomeAssistantConfig.configured ? "Open Home" : "Set up"
|
||||||
detail: "Signs out and removes it from every application that was using it"
|
onClicked: ShellState.openSettings("my-home")
|
||||||
action: "Remove"
|
}
|
||||||
divider: false
|
}
|
||||||
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
|
}
|
||||||
|
|
||||||
|
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 {
|
SettingsCard {
|
||||||
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
|
title: "Accounts"
|
||||||
title: "Add another account"
|
subtitle: "Signed in through GNOME Online Accounts, which is what mail, calendar, contacts and file managers read."
|
||||||
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
|
|
||||||
|
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 {
|
ActionRow {
|
||||||
label: "Add an account"
|
label: "Check again"
|
||||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
detail: "Reads the list back from GOA, for when an account changed somewhere else"
|
||||||
action: "Add account"
|
action: OnlineAccounts.busy ? "Checking…" : "Refresh"
|
||||||
|
enabled: !OnlineAccounts.busy
|
||||||
divider: false
|
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: "[email protected]"
|
||||||
|
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: "[email protected]"
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
//
|
//
|
||||||
// Everything privileged here prompts through polkit. A prompt that is dismissed
|
// Everything privileged here prompts through polkit. A prompt that is dismissed
|
||||||
// is a normal outcome and says so plainly rather than reporting a failure.
|
// 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
|
||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
@@ -19,20 +23,74 @@ SettingsPage {
|
|||||||
|
|
||||||
objectName: "users"
|
objectName: "users"
|
||||||
title: "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
|
// 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.
|
// both multi-field, and two open at once reads as a form with no shape.
|
||||||
property string openPanel: ""
|
property string openPanel: ""
|
||||||
|
property bool pictureOpen: false
|
||||||
|
|
||||||
property string newPassword: ""
|
property string newPassword: ""
|
||||||
property string confirmPassword: ""
|
property string confirmPassword: ""
|
||||||
|
|
||||||
property string newUserName: ""
|
property string newUserName: ""
|
||||||
property string newRealName: ""
|
property string newRealName: ""
|
||||||
property bool newUserIsAdministrator: false
|
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: ""
|
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
|
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: {
|
readonly property string passwordProblem: {
|
||||||
if (root.newPassword === "")
|
if (root.newPassword === "")
|
||||||
return "";
|
return "";
|
||||||
@@ -46,15 +104,56 @@ SettingsPage {
|
|||||||
readonly property bool passwordReady: root.newPassword.length >= 6
|
readonly property bool passwordReady: root.newPassword.length >= 6
|
||||||
&& root.newPassword === root.confirmPassword
|
&& 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 {
|
function closePanels(): void {
|
||||||
root.openPanel = "";
|
root.openPanel = "";
|
||||||
root.newPassword = "";
|
root.newPassword = "";
|
||||||
root.confirmPassword = "";
|
root.confirmPassword = "";
|
||||||
|
newPasswordField.clear();
|
||||||
|
confirmPasswordField.clear();
|
||||||
root.newUserName = "";
|
root.newUserName = "";
|
||||||
root.newRealName = "";
|
root.newRealName = "";
|
||||||
root.newUserIsAdministrator = false;
|
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: {
|
Component.onCompleted: {
|
||||||
UserAccounts.refresh();
|
UserAccounts.refresh();
|
||||||
// Probing fprintd bus-activates it, so it waits for the page rather
|
// Probing fprintd bus-activates it, so it waits for the page rather
|
||||||
@@ -62,9 +161,11 @@ SettingsPage {
|
|||||||
Fingerprint.refresh();
|
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 {
|
TextRow {
|
||||||
visible: UserAccounts.lastError !== ""
|
visible: UserAccounts.lastError !== ""
|
||||||
label: "Accounts need attention"
|
label: "That change did not go through"
|
||||||
detail: UserAccounts.lastError
|
detail: UserAccounts.lastError
|
||||||
value: ""
|
value: ""
|
||||||
divider: false
|
divider: false
|
||||||
@@ -160,25 +261,64 @@ SettingsPage {
|
|||||||
|
|
||||||
Item { width: 1; height: 6 }
|
Item { width: 1; height: 6 }
|
||||||
|
|
||||||
SettingsButton {
|
Row {
|
||||||
text: "Change picture…"
|
spacing: 8
|
||||||
enabled: !UserAccounts.busy
|
|
||||||
onClicked: avatarPicker.open()
|
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 {
|
SettingsCard {
|
||||||
title: "Account"
|
title: "Account"
|
||||||
visible: root.me !== null
|
visible: root.me !== null && root.pendingPicture === ""
|
||||||
|
|
||||||
TextFieldRow {
|
LiveFieldRow {
|
||||||
label: "Full name"
|
label: "Full name"
|
||||||
detail: "Shown on the lock screen and in the Control Center"
|
detail: "Shown on the lock screen and in the Control Center"
|
||||||
text: String(root.me?.realName ?? "")
|
text: String(root.me?.realName ?? "")
|
||||||
placeholder: "Your name"
|
placeholder: "Your name"
|
||||||
enabled: !UserAccounts.busy
|
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)
|
onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +331,7 @@ SettingsPage {
|
|||||||
SegmentRow {
|
SegmentRow {
|
||||||
label: "Account type"
|
label: "Account type"
|
||||||
detail: root.me?.administrator && UserAccounts.administratorCount <= 1
|
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"
|
: "Administrators can install software and manage other accounts"
|
||||||
options: [
|
options: [
|
||||||
{ value: "standard", label: "Standard" },
|
{ value: "standard", label: "Standard" },
|
||||||
@@ -212,12 +352,10 @@ SettingsPage {
|
|||||||
enabled: !UserAccounts.busy
|
enabled: !UserAccounts.busy
|
||||||
divider: root.openPanel === "password"
|
divider: root.openPanel === "password"
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (root.openPanel === "password")
|
const wasOpen = root.openPanel === "password";
|
||||||
root.closePanels();
|
root.closePanels();
|
||||||
else {
|
if (!wasOpen)
|
||||||
root.closePanels();
|
|
||||||
root.openPanel = "password";
|
root.openPanel = "password";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,19 +363,30 @@ SettingsPage {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.openPanel === "password"
|
visible: root.openPanel === "password"
|
||||||
|
|
||||||
PasswordRow {
|
SecretFieldRow {
|
||||||
|
id: newPasswordField
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: "New password"
|
label: "New password"
|
||||||
detail: "At least six characters"
|
detail: "At least six characters"
|
||||||
|
enabled: !UserAccounts.busy
|
||||||
onChanged: value => root.newPassword = value
|
onChanged: value => root.newPassword = value
|
||||||
}
|
}
|
||||||
|
|
||||||
PasswordRow {
|
PasswordStrengthRow {
|
||||||
|
width: parent.width
|
||||||
|
password: root.newPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
SecretFieldRow {
|
||||||
|
id: confirmPasswordField
|
||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: "Confirm"
|
label: "Confirm"
|
||||||
detail: root.passwordProblem !== ""
|
detail: root.passwordProblem !== ""
|
||||||
? root.passwordProblem
|
? root.passwordProblem
|
||||||
: "Type it a second time"
|
: "Type it a second time"
|
||||||
|
enabled: !UserAccounts.busy
|
||||||
onChanged: value => root.confirmPassword = value
|
onChanged: value => root.confirmPassword = value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +406,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SwitchRow {
|
SwitchRow {
|
||||||
label: "Automatic login"
|
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
|
checked: root.me?.automaticLogin === true
|
||||||
enabled: !UserAccounts.busy
|
enabled: !UserAccounts.busy
|
||||||
divider: false
|
divider: false
|
||||||
@@ -267,50 +416,195 @@ SettingsPage {
|
|||||||
|
|
||||||
// ── Fingerprint ──────────────────────────────────────────────────────────
|
// ── Fingerprint ──────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Hidden in full on a machine with no reader. Two systems make this work
|
// Two systems make a working fingerprint login and the card keeps them
|
||||||
// and the card keeps them honest with each other: fprintd holds the
|
// honest with each other: fprintd holds the enrolled prints, and authselect
|
||||||
// enrolled prints (GNOME's Users panel owns that dialog, so enrollment
|
// decides whether PAM asks the reader at all. A print enrolled while
|
||||||
// hands off the same way password-adjacent panels do), and authselect
|
// authselect is off does nothing, and authselect left on with no reader
|
||||||
// decides whether PAM asks the reader at all -- a print enrolled while
|
// attached asks PAM for a finger nothing can read -- which is why the card
|
||||||
// that is off does nothing, which reads as "fingerprint is broken".
|
// appears for either fact, not only when a reader is plugged in.
|
||||||
|
|
||||||
SettingsCard {
|
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"
|
title: "Fingerprint"
|
||||||
subtitle: Fingerprint.readerName !== ""
|
subtitle: Fingerprint.readerPresent && Fingerprint.readerName !== ""
|
||||||
? Fingerprint.readerName
|
? Fingerprint.readerName
|
||||||
: "A fingerprint reader is present."
|
: ""
|
||||||
|
|
||||||
SwitchRow {
|
// The stuck state: the feature is on, and there is nothing to read a
|
||||||
label: "Unlock with a fingerprint"
|
// finger with. Reachable here because it is unreachable anywhere else --
|
||||||
detail: {
|
// the card used to hide itself on exactly the machine that needed it.
|
||||||
if (Fingerprint.enrolled.length === 0)
|
SettingRow {
|
||||||
return "Enroll a finger below first; until then the password is the only way in";
|
visible: root.fingerprintStuck
|
||||||
return Fingerprint.pamEnabled
|
label: "Fingerprint unlock is on, but no reader is connected"
|
||||||
? "The lock screen and sudo accept an enrolled finger, with the password as fallback"
|
detail: "authselect still asks PAM for a finger that can't be read — turn it off here, or plug the reader back in"
|
||||||
: "Enrolled fingers are ignored until this is on";
|
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 {
|
Column {
|
||||||
label: "Enrolled fingers"
|
width: parent.width
|
||||||
detail: Fingerprint.enrolled.length === 0
|
visible: Fingerprint.readerPresent
|
||||||
? "None yet"
|
|
||||||
: Fingerprint.enrolled.map(finger => Fingerprint.fingerLabel(finger)).join(", ")
|
SwitchRow {
|
||||||
action: "Manage…"
|
width: parent.width
|
||||||
divider: Fingerprint.lastError !== ""
|
label: "Unlock with a fingerprint"
|
||||||
// GNOME's Users panel owns the enrollment dialog; growing our own
|
detail: {
|
||||||
// means reimplementing a guided capture flow fprintd already has
|
if (Fingerprint.fingers.length === 0)
|
||||||
// a good one of.
|
return "Enroll a finger below first; until then the password is the only way in";
|
||||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
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 {
|
TextRow {
|
||||||
visible: Fingerprint.lastError !== ""
|
visible: Fingerprint.lastError !== ""
|
||||||
label: "Fingerprint needs attention"
|
label: "The reader had something to say"
|
||||||
detail: Fingerprint.lastError
|
detail: Fingerprint.lastError
|
||||||
value: ""
|
value: ""
|
||||||
divider: false
|
divider: false
|
||||||
@@ -337,48 +631,149 @@ SettingsPage {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
|
|
||||||
readonly property string userName: String(otherBlock.modelData.userName ?? "")
|
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 confirming: root.confirmingRemoval === otherBlock.userName
|
||||||
|
readonly property bool locked: otherBlock.modelData.locked === true
|
||||||
|
readonly property bool lastAdministrator:
|
||||||
|
otherBlock.modelData.administrator && UserAccounts.administratorCount <= 1
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
width: otherBlock.width
|
width: otherBlock.width
|
||||||
label: UserAccounts.displayName(otherBlock.modelData)
|
label: UserAccounts.displayName(otherBlock.modelData)
|
||||||
detail: otherBlock.userName + " · "
|
detail: otherBlock.userName + " · "
|
||||||
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard account")
|
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard")
|
||||||
controlWidth: 210
|
+ " · " + (otherBlock.locked
|
||||||
divider: false
|
? "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.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 8
|
text: otherBlock.expanded ? "▴" : "▾"
|
||||||
|
color: Theme.fgMuted
|
||||||
SettingsButton {
|
font.family: Theme.fontFamily
|
||||||
text: otherBlock.confirming ? "Keep" : "Remove…"
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextRow {
|
Column {
|
||||||
width: otherBlock.width
|
width: otherBlock.width
|
||||||
visible: otherBlock.confirming
|
leftPadding: 14
|
||||||
label: "This cannot be undone"
|
visible: otherBlock.expanded
|
||||||
detail: "Their home directory and everything in it is deleted."
|
|
||||||
value: ""
|
SegmentRow {
|
||||||
divider: false
|
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 }
|
Item { width: 1; height: 6 }
|
||||||
@@ -392,35 +787,43 @@ SettingsPage {
|
|||||||
enabled: !UserAccounts.busy
|
enabled: !UserAccounts.busy
|
||||||
divider: root.openPanel === "newUser"
|
divider: root.openPanel === "newUser"
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (root.openPanel === "newUser")
|
const wasOpen = root.openPanel === "newUser";
|
||||||
root.closePanels();
|
root.closePanels();
|
||||||
else {
|
if (!wasOpen)
|
||||||
root.closePanels();
|
|
||||||
root.openPanel = "newUser";
|
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 {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.openPanel === "newUser"
|
visible: root.openPanel === "newUser"
|
||||||
|
|
||||||
TextFieldRow {
|
LiveFieldRow {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: "Full name"
|
label: "Full name"
|
||||||
placeholder: "Their name"
|
placeholder: "Their name"
|
||||||
detail: "Shown on the login screen"
|
detail: "Shown on the login screen"
|
||||||
text: root.newRealName
|
text: root.newRealName
|
||||||
onAccepted: value => root.newRealName = value
|
enabled: !UserAccounts.busy
|
||||||
|
onEdited: value => root.newRealName = value
|
||||||
}
|
}
|
||||||
|
|
||||||
TextFieldRow {
|
LiveFieldRow {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: "Username"
|
label: "Username"
|
||||||
placeholder: "lowercase, no spaces"
|
placeholder: "riley"
|
||||||
detail: "Their home directory is named after this and cannot be changed later"
|
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
|
text: root.newUserName
|
||||||
onAccepted: value => root.newUserName = value
|
enabled: !UserAccounts.busy
|
||||||
|
onEdited: value => root.newUserName = value
|
||||||
}
|
}
|
||||||
|
|
||||||
SegmentRow {
|
SegmentRow {
|
||||||
@@ -432,6 +835,7 @@ SettingsPage {
|
|||||||
{ value: "administrator", label: "Administrator" }
|
{ value: "administrator", label: "Administrator" }
|
||||||
]
|
]
|
||||||
value: root.newUserIsAdministrator ? "administrator" : "standard"
|
value: root.newUserIsAdministrator ? "administrator" : "standard"
|
||||||
|
enabled: !UserAccounts.busy
|
||||||
onSelected: value => root.newUserIsAdministrator = value === "administrator"
|
onSelected: value => root.newUserIsAdministrator = value === "administrator"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +844,11 @@ SettingsPage {
|
|||||||
label: "Create the account"
|
label: "Create the account"
|
||||||
detail: "They set their own password the first time they sign in"
|
detail: "They set their own password the first time they sign in"
|
||||||
action: "Create"
|
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
|
divider: false
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
UserAccounts.createUser(root.newUserName, root.newRealName,
|
UserAccounts.createUser(root.newUserName, root.newRealName,
|
||||||
|
|||||||
@@ -124,3 +124,9 @@ ThemeEditorWells 1.0 ThemeEditorWells.qml
|
|||||||
ThemeSaturationRow 1.0 ThemeSaturationRow.qml
|
ThemeSaturationRow 1.0 ThemeSaturationRow.qml
|
||||||
ThemeStartChips 1.0 ThemeStartChips.qml
|
ThemeStartChips 1.0 ThemeStartChips.qml
|
||||||
ThemeSaveRow 1.0 ThemeSaveRow.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
|
||||||
|
|||||||
@@ -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.
|
whole Online Accounts panel apart from one OAuth handshake.
|
||||||
|
|
||||||
Adding an account is the part that splits. The daemon's AddAccount takes
|
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
|
credentials as an ARGUMENT -- it stores them, it does not obtain them, and every
|
||||||
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
|
part of that storing happens inside goa-daemon, which does have the backend.
|
||||||
password, which a settings app can reasonably collect. For Google it is an OAuth
|
For password-based providers (Nextcloud, IMAP) that argument is a username and a
|
||||||
token, and the code that runs that exchange lives in libgoa-backend, which
|
password, which a settings app can reasonably collect, so those are added here.
|
||||||
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
|
For Google it is an OAuth token, and the code that runs that exchange lives in
|
||||||
would mean our own Google client credentials. So Google sign-in is handed to
|
libgoa-backend, which Fedora ships without a GIR binding -- reachable from C
|
||||||
GNOME's panel, and only the sign-in.
|
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:
|
Usage:
|
||||||
panama-accounts list
|
panama-accounts list | snapshot
|
||||||
panama-accounts set <object-path> <service> <true|false>
|
panama-accounts set <object-path> <service> <true|false>
|
||||||
panama-accounts remove <object-path>
|
panama-accounts remove <object-path>
|
||||||
|
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 json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import sys
|
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
|
# 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"
|
# it supports, so presence of the interface is what "this account can do mail"
|
||||||
@@ -111,8 +134,319 @@ def find(client, path):
|
|||||||
return None
|
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():
|
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:
|
try:
|
||||||
client = load_client()
|
client = load_client()
|
||||||
@@ -124,7 +458,9 @@ def main():
|
|||||||
}))
|
}))
|
||||||
return 0
|
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({
|
print(json.dumps({
|
||||||
"accounts": [describe(obj) for obj in client.get_accounts()],
|
"accounts": [describe(obj) for obj in client.get_accounts()],
|
||||||
"error": "",
|
"error": "",
|
||||||
@@ -162,7 +498,8 @@ def main():
|
|||||||
obj.get_account().call_remove_sync(None)
|
obj.get_account().call_remove_sync(None)
|
||||||
return 0
|
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
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +1,490 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
# Fingerprint state and the one privileged switch, for the Users page.
|
"""Fingerprint login: the enrolled prints, and the one privileged switch.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
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() {
|
Enrollment talks to fprintd over D-Bus (net.reactivated.Fprint) rather than
|
||||||
jq -cn \
|
handing the person to GNOME's Users panel: Claim, EnrollStart, then one
|
||||||
--argjson reader "$1" \
|
`EnrollStatus` signal per touch until the device says it is done. Progress is
|
||||||
--arg readerName "$2" \
|
printed as one JSON object per line, the same streaming shape panama-dictate's
|
||||||
--argjson enrolled "$3" \
|
setup uses, so the page can count touches while they happen. That signal loop is
|
||||||
--argjson pamEnabled "$4" \
|
why this is Python and no longer bash -- `status` and `set-unlock` still shell
|
||||||
--arg error "$5" \
|
out to exactly the same tools, and answer in exactly the same shapes.
|
||||||
'{reader: $reader, readerName: $readerName, enrolled: $enrolled,
|
|
||||||
pamEnabled: $pamEnabled, error: $error}'
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_status() {
|
panama-fingerprint status
|
||||||
command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; }
|
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,
|
authselect is baseline Fedora (it manages PAM for the whole install) and fprintd
|
||||||
# so this also copes with the daemon not running yet) and names the
|
ships with Workstation; a machine with neither reports no reader and no feature.
|
||||||
# enrolled fingers in one call.
|
"""
|
||||||
local listing
|
|
||||||
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
|
from __future__ import annotations
|
||||||
# translated daemon would turn every readerless non-English machine into
|
|
||||||
# a permanent error card.
|
import json
|
||||||
if ! listing="$(LC_ALL=C timeout 10 fprintd-list "$USER" 2>&1)"; then
|
import os
|
||||||
# "No devices available" is the normal no-reader machine; anything
|
import re
|
||||||
# else is a real problem worth surfacing.
|
import shutil
|
||||||
if grep -qi 'no devices' <<<"$listing"; then
|
import subprocess
|
||||||
emit false "" '[]' false ""
|
import sys
|
||||||
else
|
import time
|
||||||
emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")"
|
|
||||||
fi
|
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
|
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() {
|
{"enrollStages": 5,
|
||||||
local verb reason
|
"results": ["enroll-stage-passed", "enroll-retry-scan-too-short", ...],
|
||||||
case "$1" in
|
"error": ""} <- non-empty stands in for a refused claim
|
||||||
on) verb=enable-feature
|
"""
|
||||||
reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;;
|
path = os.environ.get(FIXTURE_ENV)
|
||||||
off) verb=disable-feature
|
if not path:
|
||||||
reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;;
|
return None
|
||||||
*) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;;
|
try:
|
||||||
esac
|
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
|
def bus():
|
||||||
status) cmd_status ;;
|
try:
|
||||||
set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; }
|
import gi
|
||||||
cmd_set_unlock "$2" ;;
|
|
||||||
*) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;;
|
gi.require_version("Gio", "2.0")
|
||||||
esac
|
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:]))
|
||||||
|
|||||||
@@ -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 --
|
argv is world-readable through /proc, so a password -- or even its hash --
|
||||||
passed that way is published to every process on the machine.
|
passed that way is published to every process on the machine.
|
||||||
|
|
||||||
panama-accounts snapshot
|
panama-users snapshot
|
||||||
panama-accounts set-real-name USER NAME
|
panama-users stock-avatars
|
||||||
panama-accounts set-icon USER PATH [X Y SIZE]
|
panama-users set-real-name USER NAME
|
||||||
panama-accounts set-account-type USER standard|administrator
|
panama-users set-icon USER PATH [X Y SIZE] (PATH "" clears the picture)
|
||||||
panama-accounts set-automatic-login USER true|false
|
panama-users set-account-type USER standard|administrator
|
||||||
panama-accounts set-password USER (new password on stdin)
|
panama-users set-automatic-login USER true|false
|
||||||
panama-accounts create-user USERNAME REALNAME standard|administrator
|
panama-users set-locked USER true|false
|
||||||
panama-accounts delete-user USERNAME [keep-files|remove-files]
|
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
|
from __future__ import annotations
|
||||||
@@ -39,6 +42,16 @@ USER_INTERFACE = "org.freedesktop.Accounts.User"
|
|||||||
# Account types as accountsservice numbers them.
|
# Account types as accountsservice numbers them.
|
||||||
STANDARD, ADMINISTRATOR = 0, 1
|
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}$")
|
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:
|
def set_real_name(username: str, name: str) -> None:
|
||||||
from gi.repository import GLib
|
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:
|
def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None:
|
||||||
from gi.repository import GLib
|
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):
|
if not os.path.isfile(path):
|
||||||
raise BoundaryError("That picture no longer exists.")
|
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"):
|
if kind not in ("standard", "administrator"):
|
||||||
raise BoundaryError("That is not an account type.")
|
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,)))
|
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,)))
|
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:
|
def set_password(username: str) -> None:
|
||||||
"""Set a new password, read from stdin and never named on a command line."""
|
"""Set a new password, read from stdin and never named on a command line."""
|
||||||
secret = sys.stdin.buffer.read()
|
secret = sys.stdin.buffer.read()
|
||||||
@@ -269,10 +361,17 @@ def create_user(username: str, real_name: str, kind: str) -> None:
|
|||||||
"(o)")
|
"(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:
|
def delete_user(username: str, files: str) -> None:
|
||||||
from gi.repository import GLib
|
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.")
|
raise BoundaryError("Say whether to keep or remove the home directory.")
|
||||||
if username == (os.environ.get("USER") or ""):
|
if username == (os.environ.get("USER") or ""):
|
||||||
raise BoundaryError("You cannot delete the account you are signed in to.")
|
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.")
|
raise BoundaryError("That is the only administrator; the machine would have none.")
|
||||||
|
|
||||||
call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser",
|
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:
|
def main(arguments: list[str]) -> int:
|
||||||
@@ -294,6 +393,12 @@ def main(arguments: list[str]) -> int:
|
|||||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||||
return 0
|
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":
|
if len(arguments) == 3 and arguments[0] == "set-real-name":
|
||||||
set_real_name(arguments[1], arguments[2])
|
set_real_name(arguments[1], arguments[2])
|
||||||
elif len(arguments) == 3 and arguments[0] == "set-icon":
|
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])
|
set_account_type(arguments[1], arguments[2])
|
||||||
elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
|
elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
|
||||||
set_automatic_login(arguments[1], arguments[2] == "true")
|
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":
|
elif len(arguments) == 2 and arguments[0] == "set-password":
|
||||||
set_password(arguments[1])
|
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":
|
elif len(arguments) == 4 and arguments[0] == "create-user":
|
||||||
create_user(arguments[1], arguments[2], arguments[3])
|
create_user(arguments[1], arguments[2], arguments[3])
|
||||||
elif len(arguments) == 3 and arguments[0] == "delete-user":
|
elif len(arguments) == 3 and arguments[0] == "delete-user":
|
||||||
delete_user(arguments[1], arguments[2])
|
delete_user(arguments[1], arguments[2])
|
||||||
else:
|
else:
|
||||||
raise BoundaryError(
|
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-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 | "
|
"create-user USERNAME REALNAME standard|administrator | "
|
||||||
"delete-user USERNAME keep-files|remove-files")
|
"delete-user USERNAME keep|remove")
|
||||||
except BoundaryError as error:
|
except BoundaryError as error:
|
||||||
# Answers with the fresh state plus the message, so a page never has to
|
# Answers with the fresh state plus the message, so a page never has to
|
||||||
# ask twice to find out what happened.
|
# ask twice to find out what happened.
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ pragma Singleton
|
|||||||
|
|
||||||
// The fingerprint reader, for the Users page.
|
// The fingerprint reader, for the Users page.
|
||||||
//
|
//
|
||||||
// Two facts, owned by two different systems: fprintd holds the enrolled
|
// Two facts, owned by two different systems: fprintd holds the enrolled prints,
|
||||||
// prints (GNOME's Users panel owns the enrollment dialog and Panama hands off
|
// and authselect decides whether PAM asks the reader at unlock. Both come
|
||||||
// to it), and authselect decides whether PAM asks the reader at unlock. Both
|
// through scripts/panama-fingerprint, and the one privileged change -- flipping
|
||||||
// come through scripts/panama-fingerprint, and the one privileged change --
|
// authselect's with-fingerprint feature -- prompts through polkit with a stated
|
||||||
// flipping authselect's with-fingerprint feature -- prompts through polkit
|
// reason, like everything else on that page.
|
||||||
// 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
|
// Read when the page opens rather than at shell startup: probing fprintd
|
||||||
// bus-activates the daemon, and most sessions never open this page.
|
// bus-activates the daemon, and most sessions never open this page.
|
||||||
@@ -23,55 +33,134 @@ Singleton {
|
|||||||
|
|
||||||
property bool readerPresent: false
|
property bool readerPresent: false
|
||||||
property string readerName: ""
|
property string readerName: ""
|
||||||
property var enrolled: []
|
// The fingers fprintd currently holds a print for, by fprintd's own names.
|
||||||
property bool pamEnabled: false
|
property var fingers: []
|
||||||
|
property bool unlockFeatureEnabled: false
|
||||||
property bool scanned: false
|
property bool scanned: false
|
||||||
property bool busy: false
|
|
||||||
property string lastError: ""
|
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
|
// "right-index-finger" -> "Right index finger". Presentation lives here
|
||||||
// rather than in the page, the way PowerProfiles.label does, so nothing
|
// rather than in the page, the way PowerProfiles.label does.
|
||||||
// can disagree about what a finger is called.
|
|
||||||
function fingerLabel(finger: string): string {
|
function fingerLabel(finger: string): string {
|
||||||
const words = String(finger).split("-").join(" ");
|
const words = String(finger).split("-").join(" ");
|
||||||
return words.slice(0, 1).toUpperCase() + words.slice(1);
|
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 {
|
function refresh(): void {
|
||||||
if (!query.running)
|
if (!query.running)
|
||||||
query.running = true;
|
query.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUnlockEnabled(on: bool): void {
|
function setUnlockEnabled(on: bool): void {
|
||||||
if (root.busy)
|
if (apply.running)
|
||||||
return;
|
return;
|
||||||
root.busy = true;
|
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"];
|
apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"];
|
||||||
apply.running = true;
|
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 {
|
Process {
|
||||||
id: query
|
id: query
|
||||||
command: [root.helperPath, "status"]
|
command: [root.helperPath, "status"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
@@ -87,8 +176,42 @@ Singleton {
|
|||||||
}
|
}
|
||||||
// Re-read rather than assuming: authselect may refuse, and the
|
// Re-read rather than assuming: authselect may refuse, and the
|
||||||
// prompt may have been dismissed.
|
// 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: {
|
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();
|
root.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ pragma Singleton
|
|||||||
// The daemon already runs in this session -- gvfs activates it, and accounts
|
// 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
|
// 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
|
// 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
|
// Signing in is the exception, and only for OAuth providers. GOA's AddAccount
|
||||||
// AddAccount takes credentials as an argument rather than obtaining them, and
|
// takes credentials as an argument rather than obtaining them, which is exactly
|
||||||
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
|
// what a Nextcloud or IMAP form can supply; what it cannot supply is a Google
|
||||||
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
|
// token, because the code that runs that exchange lives in libgoa-backend,
|
||||||
// panel and the user comes straight back here.
|
// 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
|
// 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.
|
// people, not by the system, so there is nothing to poll for.
|
||||||
@@ -29,68 +35,141 @@ Singleton {
|
|||||||
// services: [{key,label,enabled}] }]
|
// services: [{key,label,enabled}] }]
|
||||||
property var accounts: []
|
property var accounts: []
|
||||||
property bool scanned: false
|
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
|
// Accounts whose stored credentials have stopped working -- an expired
|
||||||
// token, a changed password. GOA knows, and nothing outside its own panel
|
// 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.
|
// 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 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 {
|
function refresh(): void {
|
||||||
if (!list.running)
|
if (!list.running)
|
||||||
list.running = true;
|
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
|
// Enabling a service clears GOA's "disabled" flag; the helper owns that
|
||||||
// inversion so the UI can speak in terms of what is on.
|
// inversion so the UI can speak in terms of what is on.
|
||||||
function setService(path: string, service: string, enabled: bool): void {
|
function setService(path: string, service: string, enabled: bool): void {
|
||||||
if (root.busy)
|
if (write.running)
|
||||||
return;
|
return;
|
||||||
root.busy = true;
|
root.writeError = "";
|
||||||
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
|
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
|
||||||
write.running = true;
|
write.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(path: string): void {
|
function remove(path: string): void {
|
||||||
if (root.busy)
|
if (write.running)
|
||||||
return;
|
return;
|
||||||
root.busy = true;
|
root.writeError = "";
|
||||||
write.command = [root.helperPath, "remove", path];
|
write.command = [root.helperPath, "remove", path];
|
||||||
write.running = true;
|
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 {
|
Process {
|
||||||
id: list
|
id: list
|
||||||
command: [root.helperPath, "list"]
|
command: [root.helperPath, "snapshot"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: write
|
id: write
|
||||||
stderr: StdioCollector {
|
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
|
// Re-read rather than assuming the write landed: GOA may refuse, and a
|
||||||
// toggle that sprang back is the honest outcome.
|
// toggle that sprang back is the honest outcome.
|
||||||
onExited: {
|
onExited: root.refresh()
|
||||||
root.busy = false;
|
}
|
||||||
root.refresh();
|
|
||||||
|
Process {
|
||||||
|
id: add
|
||||||
|
stdinEnabled: true
|
||||||
|
onStarted: {
|
||||||
|
add.write(root.pendingPassword + "\n");
|
||||||
|
// Held for as long as it takes to hand over, and no longer.
|
||||||
|
root.pendingPassword = "";
|
||||||
|
add.stdinEnabled = false;
|
||||||
}
|
}
|
||||||
|
// The helper answers with the fresh account list plus whatever went
|
||||||
|
// wrong, so a successful add lands on the page without a second read.
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : root.accounts;
|
||||||
|
// An add that failed says why here. It is a write error:
|
||||||
|
// GOA answered, so the page is still working.
|
||||||
|
root.writeError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.writeError = "Could not read the accounts helper's output.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
|
||||||
|
}
|
||||||
|
onExited: root.pendingPassword = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ Singleton {
|
|||||||
{ label: "Add a user", detail: "Create another account on this machine", page: "users" },
|
{ label: "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: "Automatic login", detail: "Sign in without typing a password", page: "users" },
|
||||||
{ label: "Administrator", detail: "Which accounts can manage this machine", 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: "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: "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" },
|
{ 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: "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: "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" },
|
{ 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: "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: "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" },
|
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ Singleton {
|
|||||||
property bool scanned: false
|
property bool scanned: false
|
||||||
property string lastError: ""
|
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
|
// Guards read the Process objects rather than a derived "busy" binding. A
|
||||||
// binding hands back its cached value inside the handler that changes it,
|
// 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 --
|
// 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))]);
|
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]);
|
root.run(["set-account-type", userName, kind]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,12 +149,28 @@ Singleton {
|
|||||||
root.run(["set-automatic-login", userName, enabled ? "true" : "false"]);
|
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 {
|
function createUser(userName: string, realName: string, kind: string): void {
|
||||||
root.run(["create-user", userName, realName, kind]);
|
root.run(["create-user", userName, realName, kind]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteUser(userName: string, removeFiles: bool): void {
|
// keepFiles, not removeFiles: the page asks "Keep the files" or "Remove
|
||||||
root.run(["delete-user", userName, removeFiles ? "remove-files" : "keep-files"]);
|
// 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
|
// The password goes to the helper's stdin and nowhere else: never an
|
||||||
@@ -177,6 +219,25 @@ Singleton {
|
|||||||
onExited: root.pendingPassword = ""
|
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 {
|
Process {
|
||||||
id: mutation
|
id: mutation
|
||||||
// The helper answers with the fresh state, so the page updates from the
|
// The helper answers with the fresh state, so the page updates from the
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Online Accounts in Settings.
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accounts
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Users in Settings.
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page users
|
||||||
|
|||||||
@@ -898,3 +898,218 @@ run on their own, and both pass:
|
|||||||
system ones (`disks-contract`, `snapshots-contract`, `containers-contract`),
|
system ones (`disks-contract`, `snapshots-contract`, `containers-contract`),
|
||||||
then the harness ones (`settings-search-contract`, `health-ui-contract`), and
|
then the harness ones (`settings-search-contract`, `health-ui-contract`), and
|
||||||
`settings-pages-contract` last, as before.
|
`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.
|
||||||
|
|||||||
@@ -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 `<fixture>.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 `<fixture>.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.
|
||||||
@@ -6,48 +6,188 @@
|
|||||||
# nothing, and that silence -- "I enrolled a finger and nothing happened" --
|
# nothing, and that silence -- "I enrolled a finger and nothing happened" --
|
||||||
# is the failure this card exists to name.
|
# is the failure this card exists to name.
|
||||||
#
|
#
|
||||||
# The helper is the parse surface, so it runs for real against stub fprintd
|
# Two things changed, and each inverts a pin this file used to hold.
|
||||||
# and authselect; the page and service checks are structural.
|
#
|
||||||
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint"
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint"
|
||||||
service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml"
|
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() {
|
fail() {
|
||||||
printf 'fingerprint contract: %s\n' "$1" >&2
|
printf 'fingerprint contract: %s\n' "$1" >&2
|
||||||
exit 1
|
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" \
|
file_calling() {
|
||||||
|| fail 'the card is not hidden on machines with no reader'
|
grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1
|
||||||
rg -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \
|
}
|
||||||
|
|
||||||
|
# ── 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'
|
|| fail 'the unlock switch does not drive authselect'
|
||||||
rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$page" \
|
grep -Fq 'Fingerprint.refresh()' "$page" \
|
||||||
|| fail 'enrollment does not hand off to the GNOME Users panel'
|
|
||||||
rg -Fq 'Fingerprint.refresh()' "$page" \
|
|
||||||
|| fail 'the page never reads the fingerprint state'
|
|| 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'
|
# ── A finger is named in exactly one place ───────────────────────────────────
|
||||||
rg -Fq -- '--reason' "$helper" \
|
#
|
||||||
|| fail 'the privileged change carries no stated reason'
|
# fprintd's vocabulary is "right-index-finger". What that READS as is a
|
||||||
rg -Fq 'function fingerLabel' "$service" \
|
# 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'
|
|| 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)"
|
# A single id elsewhere is a default choice -- "start on the right index
|
||||||
state_dir="$(mktemp -d)"
|
# finger" -- and that is allowed. A second one is a copy of the list, and the
|
||||||
trap 'rm -rf "$stub_dir" "$state_dir"' EXIT
|
# 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" <<STUB
|
cat >"$stub_dir/fprintd-list" <<STUB
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
printf 'fprintd-list %s\n' "\$*" >>"$state_dir/argv"
|
||||||
if [[ -e "$state_dir/no-reader" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
cat <<'OUT'
|
cat <<'OUT'
|
||||||
@@ -62,49 +202,300 @@ STUB
|
|||||||
|
|
||||||
cat >"$stub_dir/authselect" <<STUB
|
cat >"$stub_dir/authselect" <<STUB
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
echo "\$*" >>"$state_dir/authselect-log"
|
printf 'authselect %s\n' "\$*" >>"$state_dir/argv"
|
||||||
if [[ "\$1" == "current" ]]; then
|
if [[ "\$1" == "current" ]]; then
|
||||||
echo 'Profile ID: local'
|
echo 'Profile ID: local'
|
||||||
[[ -e "$state_dir/pam-on" ]] && echo '- with-fingerprint'
|
[[ -e "$state_dir/feature-on" ]] && echo '- with-fingerprint'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
printf 'fingerprint contract: authselect was asked to change PAM\n' >&2
|
||||||
|
exit 1
|
||||||
STUB
|
STUB
|
||||||
|
|
||||||
# PANAMA_PATH pointed at an empty directory forces the plain-sudo fallback,
|
# Records the escalation and runs NOTHING. The reason is the point: this page's
|
||||||
# which the stub records instead of escalating.
|
# prompt has to say what it is about to do, and the only way to see that is to
|
||||||
cat >"$stub_dir/sudo" <<STUB
|
# catch the arguments before they become an authselect invocation.
|
||||||
|
cat >"$panama_dir/panama-sudo" <<STUB
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
echo "\$*" >>"$state_dir/sudo-log"
|
printf 'panama-sudo %s\n' "\$*" >>"$state_dir/argv"
|
||||||
exec "\$@"
|
exit 0
|
||||||
STUB
|
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" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '$blocked %s\n' "\$*" >>"$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)"
|
status="$(run status)"
|
||||||
jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \
|
jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \
|
||||||
|| fail "the reader name did not parse: $status"
|
|| fail "the reader name did not parse: $status"
|
||||||
jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \
|
jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \
|
||||||
|| fail "enrolled fingers did not parse: $status"
|
|| 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"
|
|| 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"
|
touch "$state_dir/feature-on"
|
||||||
jq -e '.pamEnabled == true' <<<"$(run status)" >/dev/null \
|
jq -e '.unlockFeatureEnabled == true' <<<"$(run status)" >/dev/null \
|
||||||
|| fail 'with-fingerprint enabled was not detected'
|
|| 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"
|
touch "$state_dir/no-reader"
|
||||||
jq -e '.reader == false and .error == ""' <<<"$(run status)" >/dev/null \
|
stuck="$(run status)"
|
||||||
|| fail "a readerless machine was reported as a problem: $(run status)"
|
jq -e '.reader == false and .error == ""' <<<"$stuck" >/dev/null \
|
||||||
rm -f "$state_dir/no-reader"
|
|| 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
|
run set-unlock on >/dev/null
|
||||||
grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/sudo-log" \
|
grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/argv" \
|
||||||
|| fail 'set-unlock on did not enable the authselect feature'
|
|| fail "set-unlock on did not enable the authselect feature: $(cat "$state_dir/argv")"
|
||||||
run set-unlock off >/dev/null
|
grep -qE 'panama-sudo --reason .*fingerprint' "$state_dir/argv" \
|
||||||
grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/sudo-log" \
|
|| fail 'the prompt does not say what it is about to do'
|
||||||
|| fail 'set-unlock off did not disable the authselect feature'
|
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'
|
||||||
|
|||||||
@@ -59,9 +59,13 @@ declare -A OWNED=(
|
|||||||
|
|
||||||
# Handoffs that are correct despite naming an owned panel, with the reason.
|
# Handoffs that are correct despite naming an owned panel, with the reason.
|
||||||
# Anything here must be justified, not merely tolerated.
|
# 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=(
|
declare -A ALLOWED=(
|
||||||
["OnlineAccountsPage.qml:online-accounts"]="adding an account requires GOA's own dialog"
|
["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"
|
||||||
["UsersPage.qml:system-users"]="fingerprint enrollment requires fprintd's guided capture flow, and GNOME's Users panel carries the only good dialog for it"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# The leaves: a tabless category is a page in its own right, and every tab is
|
# The leaves: a tabless category is a page in its own right, and every tab is
|
||||||
|
|||||||
Executable
+533
@@ -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": "[email protected]",
|
||||||
|
"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": "[email protected]",
|
||||||
|
"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" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'online accounts contract: the helper reached for $blocked\n' >&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 '[email protected]' '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' </dev/null)" ]] \
|
||||||
|
|| fail 'add-nextcloud with no password at all was accepted'
|
||||||
|
|
||||||
|
for bad_address in '' 'not-an-address' 'gib@' '@example.com'; do
|
||||||
|
[[ -n "$(attempt add-imap "$bad_address" 'imap.example.com' 'smtp.example.com' 'gib')" ]] \
|
||||||
|
|| fail "add-imap accepted ${bad_address@Q} as an address"
|
||||||
|
done
|
||||||
|
for bad_host in '' 'imap example com' 'imap.example.com; reboot' '-imap.example.com'; do
|
||||||
|
: >"$log"
|
||||||
|
[[ -n "$(attempt add-imap '[email protected]' "$bad_host" 'smtp.example.com' 'gib')" ]] \
|
||||||
|
|| fail "add-imap accepted ${bad_host@Q} as an incoming server"
|
||||||
|
[[ -n "$(attempt add-imap '[email protected]' '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'
|
||||||
@@ -7,14 +7,18 @@
|
|||||||
#
|
#
|
||||||
# Nothing here creates, deletes, or modifies a real account. It reads the
|
# 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
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-users"
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-users"
|
||||||
service="$repo_dir/config/dot/quickshell/services/UserAccounts.qml"
|
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() {
|
fail() {
|
||||||
printf 'user accounts contract: %s\n' "$1" >&2
|
printf 'user accounts contract: %s\n' "$1" >&2
|
||||||
@@ -26,6 +30,13 @@ for path in "$helper" "$service" "$page"; do
|
|||||||
done
|
done
|
||||||
[[ -x "$helper" ]] || fail 'panama-users is not executable'
|
[[ -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 ─────────────────────────────
|
# ── A new password never reaches a command line ─────────────────────────────
|
||||||
# argv is world-readable through /proc, so a password passed as an argument is
|
# 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
|
# 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" \
|
grep -q 'root.pendingPassword = ""' "$service" \
|
||||||
|| fail 'the service never clears the password it was holding'
|
|| 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")"
|
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" \
|
grep -q 'You cannot delete the account you are signed in to' <<<"$delete_body" \
|
||||||
|| fail 'the helper would delete the account running it'
|
|| fail 'the helper would delete the account running it'
|
||||||
grep -q 'only administrator' <<<"$delete_body" \
|
grep -q 'only administrator' <<<"$delete_body" \
|
||||||
|| fail 'the helper would remove the last administrator, leaving nobody able to administer the machine'
|
|| 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.
|
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
|
||||||
grep -q 'administratorCount <= 1' "$page" \
|
target = next((node for node in ast.walk(tree)
|
||||||
|| fail 'the page offers to demote the only administrator'
|
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 ────────────────────────
|
# ── 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'
|
|| 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'
|
|| 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 ────────────────────────────
|
# ── 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; }
|
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'
|
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 \
|
jq -e '.currentUser | length > 0' <<<"$snapshot" >/dev/null \
|
||||||
|| fail 'the snapshot does not say which account is signed in'
|
|| 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")"
|
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"
|
[[ -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 \
|
jq -e '[.users[] | .uid >= 1000] | all' <<<"$snapshot" >/dev/null \
|
||||||
|| fail 'a system account is listed as a manageable user'
|
|| 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 ────────────────────────────────────────────────────────
|
# ── Input validation ────────────────────────────────────────────────────────
|
||||||
for bad in "root; rm -rf /" "../escape" "UPPER" ""; do
|
for bad in "root; rm -rf /" "../escape" "UPPER" ""; do
|
||||||
result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')"
|
result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')"
|
||||||
[[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name"
|
[[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name"
|
||||||
done
|
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")"
|
"$(jq '.users | length' <<<"$snapshot")"
|
||||||
|
|||||||
Reference in New Issue
Block a user