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:
@@ -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
|
||||
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
|
||||
// per-service toggles and removal are all done natively on this page.
|
||||
// per-service toggles, removal and password-provider sign-in are all done
|
||||
// natively on this page.
|
||||
//
|
||||
// Signing in to an OAuth provider is handed to GNOME's panel, because the
|
||||
// credentials are OAuth tokens and the code that obtains them ships without a
|
||||
// scriptable binding. That is stated on the page rather than hidden behind a
|
||||
// button that looks native, because a hand-off the user does not expect reads
|
||||
// as a bug.
|
||||
// The one hand-off left is OAuth. Google's and Microsoft's tokens are obtained
|
||||
// by code inside libgoa-backend, which Fedora ships without a GIR binding, so
|
||||
// no amount of D-Bus gets at it. That is stated on the page rather than hidden
|
||||
// behind a button that looks native, because a hand-off nobody expects reads as
|
||||
// a bug -- and it goes through openProviderDialog() below, which is the only
|
||||
// place in this file that names a GNOME panel.
|
||||
//
|
||||
// Accounts needing attention are surfaced first. GOA knows when a token has
|
||||
// expired and nothing outside its own panel says so, which is how an account
|
||||
// quietly stops syncing for weeks.
|
||||
// What Panama itself signs in to leads, because those two integrations are the
|
||||
// ones this desktop actually uses. GOA's accounts are for the applications.
|
||||
//
|
||||
// A failed write is a row. The page used to replace itself with "Accounts
|
||||
// unavailable" whenever `lastError` was set, so one refused toggle hid every
|
||||
// account on the machine.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
@@ -22,118 +27,452 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "accounts"
|
||||
title: "Online Accounts"
|
||||
lede: OnlineAccounts.attentionCount > 0
|
||||
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
|
||||
? " account needs you to sign in again."
|
||||
: " accounts need you to sign in again.")
|
||||
: "Accounts your mail, calendar, contacts, and files come from."
|
||||
: "What this desktop signs in to — Panama's own integrations first."
|
||||
|
||||
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
|
||||
// "" | "nextcloud" | "imap"
|
||||
property string openForm: ""
|
||||
property string expandedAccount: ""
|
||||
property string confirmingRemoval: ""
|
||||
|
||||
SettingsCard {
|
||||
visible: !OnlineAccounts.available
|
||||
title: "Accounts unavailable"
|
||||
subtitle: OnlineAccounts.lastError
|
||||
}
|
||||
property string ncServer: ""
|
||||
property string ncUser: ""
|
||||
property string ncPassword: ""
|
||||
|
||||
SettingsCard {
|
||||
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
|
||||
title: "No accounts yet"
|
||||
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
|
||||
property string imapEmail: ""
|
||||
property string imapHost: ""
|
||||
property string smtpHost: ""
|
||||
property string imapUser: ""
|
||||
property string imapPassword: ""
|
||||
|
||||
ActionRow {
|
||||
label: "Add an account"
|
||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||
action: "Add account"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
readonly property bool nextcloudReady: /^https?:\/\/.+/.test(root.ncServer.trim())
|
||||
&& root.ncUser.trim() !== "" && root.ncPassword !== ""
|
||||
|
||||
readonly property bool imapReady: /^[^@\s]+@[^@\s]+$/.test(root.imapEmail.trim())
|
||||
&& root.imapHost.trim() !== "" && root.smtpHost.trim() !== ""
|
||||
&& root.imapUser.trim() !== "" && root.imapPassword !== ""
|
||||
|
||||
readonly property string homeSummary: {
|
||||
if (!HomeAssistantConfig.configured)
|
||||
return "Not set up";
|
||||
if (HomeAssistant.phase === "ready") {
|
||||
const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length;
|
||||
const host = root.hostOf(HomeAssistantConfig.url);
|
||||
return named > 0
|
||||
? host + " · " + named + " room" + (named === 1 ? "" : "s")
|
||||
: host + " · " + HomeAssistant.discoveredCount + " lights";
|
||||
}
|
||||
if (HomeAssistant.phase === "degraded")
|
||||
return root.hostOf(HomeAssistantConfig.url) + " · answering slowly";
|
||||
if (HomeAssistant.phase === "loading")
|
||||
return "Asking " + root.hostOf(HomeAssistantConfig.url) + "…";
|
||||
return root.hostOf(HomeAssistantConfig.url) + " · not answering";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: OnlineAccounts.accounts
|
||||
readonly property string phoneName: KdeConnect.preferredPhone
|
||||
? String(KdeConnect.preferredPhone.name)
|
||||
: "Phone"
|
||||
|
||||
SettingsCard {
|
||||
id: accountCard
|
||||
required property var modelData
|
||||
readonly property string phoneSummary: {
|
||||
if (!KdeConnect.available)
|
||||
return "Not set up";
|
||||
if (!KdeConnect.preferredPhone)
|
||||
return "KDE Connect · no phone paired yet";
|
||||
const battery = KdeConnect.phoneBattery;
|
||||
const where = KdeConnect.phoneReachable ? "nearby on Wi-Fi" : "not nearby";
|
||||
return battery
|
||||
? "KDE Connect · " + where + " · battery " + battery.charge + "%"
|
||||
: "KDE Connect · " + where;
|
||||
}
|
||||
|
||||
// GOA hands back a preference-ordered chain; this walks it and takes
|
||||
// the first name the active icon theme actually has. Taking the
|
||||
// first name blindly, or the last as a fallback, both looked right
|
||||
// and were not: on Adwaita the tail of these chains ("mail",
|
||||
// "goa-symbolic") does not exist, so a miss would have rendered
|
||||
// nothing at all.
|
||||
icon: {
|
||||
const chain = accountCard.modelData.providerIcons ?? [];
|
||||
for (const name of chain) {
|
||||
if (Quickshell.iconPath(String(name), true) !== "")
|
||||
return String(name);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
iconFallback: "avatar-default-symbolic"
|
||||
// Just the host, because a full URL in a subtitle is mostly scheme and
|
||||
// trailing slash.
|
||||
function hostOf(url: string): string {
|
||||
const text = String(url ?? "").trim();
|
||||
if (text === "")
|
||||
return "Home Assistant";
|
||||
return text.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
||||
}
|
||||
|
||||
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||
subtitle: accountCard.modelData.needsAttention
|
||||
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||
: accountCard.modelData.providerName
|
||||
// The single hand-off. Both the OAuth add row and a needs-attention row
|
||||
// arrive here, so this file names a GNOME panel exactly once -- see
|
||||
// gnome-handoff-contract, which allows this one and nothing else.
|
||||
function openProviderDialog(): void {
|
||||
SystemSettings.openGnomePanel("online-accounts");
|
||||
}
|
||||
|
||||
// Only when it is true, because it is the one thing on this page
|
||||
// that needs acting on.
|
||||
ActionRow {
|
||||
visible: accountCard.modelData.needsAttention
|
||||
label: "Sign in again"
|
||||
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
|
||||
action: "Sign in"
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
function closeForms(): void {
|
||||
root.openForm = "";
|
||||
root.ncServer = "";
|
||||
root.ncUser = "";
|
||||
root.ncPassword = "";
|
||||
nextcloudPassword.clear();
|
||||
root.imapEmail = "";
|
||||
root.imapHost = "";
|
||||
root.smtpHost = "";
|
||||
root.imapUser = "";
|
||||
root.imapPassword = "";
|
||||
imapPasswordField.clear();
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: accountCard.modelData.services
|
||||
// Every open, not only the first: accounts are added and removed by people
|
||||
// -- sometimes in the very dialog this page hands them to -- so coming back
|
||||
// to this page is exactly when the list is most likely to be stale.
|
||||
Component.onCompleted: {
|
||||
OnlineAccounts.refresh();
|
||||
// The phone row reads a service that is only refreshed when the
|
||||
// Control Center's phone shelf opens, which this page cannot assume
|
||||
// has happened. Home Assistant refreshes itself.
|
||||
KdeConnect.refresh();
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
id: serviceRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
// Two errors, kept apart because they mean different things. A refused
|
||||
// write is about the one thing somebody just did; a listing that failed is
|
||||
// why the card below might be empty. Neither takes the page away.
|
||||
TextRow {
|
||||
visible: OnlineAccounts.writeError !== ""
|
||||
label: "That did not go through"
|
||||
detail: OnlineAccounts.writeError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
label: serviceRow.modelData.label
|
||||
detail: serviceRow.modelData.enabled
|
||||
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
|
||||
: "Hidden from applications"
|
||||
controlWidth: 48
|
||||
TextRow {
|
||||
visible: OnlineAccounts.snapshotError !== ""
|
||||
label: "The account list could not be read"
|
||||
detail: OnlineAccounts.snapshotError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: serviceRow.modelData.enabled
|
||||
onToggled: value => OnlineAccounts.setService(
|
||||
accountCard.modelData.path, serviceRow.modelData.key, value)
|
||||
// ── What Panama itself uses ──────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "This desktop"
|
||||
subtitle: "The two services Panama signs in to on its own behalf."
|
||||
|
||||
SettingRow {
|
||||
// The same glyph the My Home page and its Control Center tile use.
|
||||
icon: "\u{F0335}"
|
||||
label: "Home Assistant"
|
||||
detail: root.homeSummary
|
||||
controlWidth: 210
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: HomeAssistant.phase === "ready"
|
||||
width: homeBadge.implicitWidth + 14
|
||||
height: 17
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.ok, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.ok, 0.3)
|
||||
|
||||
Text {
|
||||
id: homeBadge
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: "CONNECTED"
|
||||
color: Theme.ok
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Remove this account"
|
||||
detail: "Signs out and removes it from every application that was using it"
|
||||
action: "Remove"
|
||||
divider: false
|
||||
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
|
||||
SettingsButton {
|
||||
text: HomeAssistantConfig.configured ? "Open Home" : "Set up"
|
||||
onClicked: ShellState.openSettings("my-home")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
// md-cellphone
|
||||
icon: "\u{F011C}"
|
||||
label: root.phoneName
|
||||
detail: root.phoneSummary
|
||||
controlWidth: 210
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: KdeConnect.pairedCount > 0
|
||||
width: phoneBadge.implicitWidth + 14
|
||||
height: 17
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.ok, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.ok, 0.3)
|
||||
|
||||
Text {
|
||||
id: phoneBadge
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: "PAIRED"
|
||||
color: Theme.ok
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: KdeConnect.pairedCount > 0 ? "Open Phone" : "Set up"
|
||||
onClicked: ShellState.openSettings("phone")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Everything the applications use ──────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
|
||||
title: "Add another account"
|
||||
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
|
||||
title: "Accounts"
|
||||
subtitle: "Signed in through GNOME Online Accounts, which is what mail, calendar, contacts and file managers read."
|
||||
|
||||
TextRow {
|
||||
visible: !OnlineAccounts.scanned
|
||||
label: "Reading the account list…"
|
||||
detail: ""
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: OnlineAccounts.scanned && OnlineAccounts.accounts.length === 0
|
||||
label: "No accounts yet"
|
||||
detail: "Adding one lets mail, calendar, contacts and file managers share a single sign-in."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: OnlineAccounts.accounts
|
||||
|
||||
delegate: OnlineAccountRow {
|
||||
id: accountRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
account: accountRow.modelData
|
||||
busy: OnlineAccounts.busy
|
||||
// Said here rather than in the row, next to the hand-off it
|
||||
// runs: GOA knows the token expired and nothing outside its own
|
||||
// panel says so, which is how an account quietly stops syncing
|
||||
// for weeks.
|
||||
signInAction: "Sign in again"
|
||||
expanded: root.expandedAccount === String(accountRow.modelData.path ?? "")
|
||||
confirmingRemoval: root.confirmingRemoval === String(accountRow.modelData.path ?? "")
|
||||
divider: accountRow.index < OnlineAccounts.accounts.length - 1
|
||||
|
||||
onActivated: {
|
||||
const path = String(accountRow.modelData.path ?? "");
|
||||
root.expandedAccount = accountRow.expanded ? "" : path;
|
||||
root.confirmingRemoval = "";
|
||||
}
|
||||
onServiceToggled: (key, enabled) => OnlineAccounts.setService(
|
||||
String(accountRow.modelData.path ?? ""), key, enabled)
|
||||
onRemoveArmed: root.confirmingRemoval = String(accountRow.modelData.path ?? "")
|
||||
onRemoveCancelled: root.confirmingRemoval = ""
|
||||
onRemoveConfirmed: {
|
||||
root.confirmingRemoval = "";
|
||||
root.expandedAccount = "";
|
||||
OnlineAccounts.remove(String(accountRow.modelData.path ?? ""));
|
||||
}
|
||||
onSignInAgainRequested: root.openProviderDialog()
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Add an account"
|
||||
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||
action: "Add account"
|
||||
label: "Check again"
|
||||
detail: "Reads the list back from GOA, for when an account changed somewhere else"
|
||||
action: OnlineAccounts.busy ? "Checking…" : "Refresh"
|
||||
enabled: !OnlineAccounts.busy
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
onTriggered: OnlineAccounts.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adding one ───────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Add an account"
|
||||
|
||||
ActionRow {
|
||||
label: "Nextcloud"
|
||||
detail: "Server, username, password — signed in right here"
|
||||
action: root.openForm === "nextcloud" ? "Cancel" : "Add…"
|
||||
enabled: !OnlineAccounts.busy
|
||||
divider: root.openForm === "nextcloud"
|
||||
onTriggered: {
|
||||
const wasOpen = root.openForm === "nextcloud";
|
||||
root.closeForms();
|
||||
if (!wasOpen)
|
||||
root.openForm = "nextcloud";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.openForm === "nextcloud"
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Server"
|
||||
detail: "The address you open Nextcloud at, scheme and all"
|
||||
placeholder: "https://cloud.example.org"
|
||||
text: root.ncServer
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.ncServer = value
|
||||
}
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Username"
|
||||
placeholder: "you"
|
||||
text: root.ncUser
|
||||
enabled: !OnlineAccounts.busy
|
||||
onEdited: value => root.ncUser = value
|
||||
}
|
||||
|
||||
SecretFieldRow {
|
||||
id: nextcloudPassword
|
||||
|
||||
width: parent.width
|
||||
label: "Password"
|
||||
detail: "Your password, or an app password — GOA signs in with Basic auth, so either works, and Nextcloud's Security settings issue an app password per device. It is handed over on stdin and never becomes a command-line argument."
|
||||
placeholder: "Password or app password"
|
||||
enabled: !OnlineAccounts.busy
|
||||
onChanged: value => root.ncPassword = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Sign in to Nextcloud"
|
||||
detail: root.nextcloudReady
|
||||
? "Files, calendar and contacts arrive with the account"
|
||||
: "A server address, a username and an app password are needed first"
|
||||
action: OnlineAccounts.busy ? "Signing in…" : "Sign in"
|
||||
enabled: root.nextcloudReady && !OnlineAccounts.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
OnlineAccounts.addNextcloud(root.ncServer.trim(), root.ncUser.trim(),
|
||||
root.ncPassword);
|
||||
root.closeForms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Mail (IMAP & SMTP)"
|
||||
detail: "Server details and password — native form"
|
||||
action: root.openForm === "imap" ? "Cancel" : "Add…"
|
||||
enabled: !OnlineAccounts.busy
|
||||
divider: root.openForm === "imap"
|
||||
onTriggered: {
|
||||
const wasOpen = root.openForm === "imap";
|
||||
root.closeForms();
|
||||
if (!wasOpen)
|
||||
root.openForm = "imap";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.openForm === "imap"
|
||||
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Email address"
|
||||
placeholder: "[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
|
||||
// is a normal outcome and says so plainly rather than reporting a failure.
|
||||
//
|
||||
// This page hands nothing to GNOME. Fingerprint enrollment was the last thing
|
||||
// that did, and Panama drives fprintd's EnrollStart itself now -- see
|
||||
// fingerprint-contract, which pins the absence.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
@@ -19,20 +23,74 @@ SettingsPage {
|
||||
|
||||
objectName: "users"
|
||||
title: "Users"
|
||||
lede: "Your account, and anyone else who signs in to this machine."
|
||||
lede: "Who this machine belongs to, and how it knows it's you."
|
||||
|
||||
// One panel open at a time: changing a password and adding an account are
|
||||
// both multi-field, and two open at once reads as a form with no shape.
|
||||
property string openPanel: ""
|
||||
property bool pictureOpen: false
|
||||
|
||||
property string newPassword: ""
|
||||
property string confirmPassword: ""
|
||||
|
||||
property string newUserName: ""
|
||||
property string newRealName: ""
|
||||
property bool newUserIsAdministrator: false
|
||||
|
||||
// Which other account is unfolded, which one is one press from deletion,
|
||||
// and what that deletion would do to their files. All three are page state
|
||||
// rather than row state, so opening a second row puts the first one's armed
|
||||
// Delete button away instead of leaving two of them armed at once.
|
||||
property string expandedUser: ""
|
||||
property string confirmingRemoval: ""
|
||||
// "keep" | "remove"
|
||||
property string removalDisposition: "keep"
|
||||
|
||||
// Which enrolled finger is one press from being forgotten, and which one
|
||||
// the next enrollment would take. The finger being enrolled right now is
|
||||
// the service's business, not this page's.
|
||||
property string confirmingFinger: ""
|
||||
property string chosenFinger: "right-index-finger"
|
||||
|
||||
readonly property var me: UserAccounts.me
|
||||
|
||||
// The service owns fprintd's vocabulary and subtracts what is already
|
||||
// enrolled; this only pairs each one with its label for the picker.
|
||||
readonly property var fingerOptions: Fingerprint.availableFingers.map(
|
||||
finger => ({ value: finger, label: Fingerprint.fingerLabel(finger) }))
|
||||
|
||||
// Keeps the picker pointing at something that can still be enrolled: the
|
||||
// default finger stops being offered the moment it is enrolled, and a
|
||||
// picker showing a blank current value reads as broken.
|
||||
onFingerOptionsChanged: {
|
||||
if (root.fingerOptions.length === 0)
|
||||
return;
|
||||
if (!root.fingerOptions.some(option => option.value === root.chosenFinger))
|
||||
root.chosenFinger = String(root.fingerOptions[0].value);
|
||||
}
|
||||
|
||||
readonly property bool fingerprintStuck: Fingerprint.unlockFeatureEnabled
|
||||
&& !Fingerprint.readerPresent
|
||||
|
||||
// What the last touch did, in words. fprintd's result strings are for
|
||||
// programs; this is the one line someone looking up from the reader reads.
|
||||
readonly property string enrollHint: {
|
||||
if (Fingerprint.enrollPhase === "claiming")
|
||||
return "Waking the reader…";
|
||||
if (Fingerprint.enrollPhase === "failed")
|
||||
return "The reader gave up on that one — start again when you are ready.";
|
||||
const result = String(Fingerprint.enrollResult);
|
||||
if (result === "" || result === "enroll-stage-passed")
|
||||
return "";
|
||||
if (/too-short|swipe-too-short/.test(result))
|
||||
return "That touch was too brief — hold it there a moment longer.";
|
||||
if (/remove-and-retry|retry-scan/.test(result))
|
||||
return "Lift your finger all the way off, then press again.";
|
||||
if (/centered|too-fast|duplicate/.test(result))
|
||||
return "Land more of the pad on the reader and hold still.";
|
||||
return "That touch did not take. Try again.";
|
||||
}
|
||||
|
||||
readonly property string passwordProblem: {
|
||||
if (root.newPassword === "")
|
||||
return "";
|
||||
@@ -46,15 +104,56 @@ SettingsPage {
|
||||
readonly property bool passwordReady: root.newPassword.length >= 6
|
||||
&& root.newPassword === root.confirmPassword
|
||||
|
||||
// The helper's own rule, character for character -- panama-users compiles
|
||||
// exactly this one, and a page that tests something slightly different
|
||||
// accepts a name accountsservice then refuses, with nothing on screen to
|
||||
// say why. The length cap is part of the expression rather than a separate
|
||||
// check beside it, for the same reason.
|
||||
//
|
||||
// Read while the name is being typed, by both the sentence under the field
|
||||
// and the Create button, so the two can never disagree.
|
||||
readonly property bool newUserNameValid: /^[a-z_][a-z0-9_-]{0,31}$/.test(root.newUserName)
|
||||
|
||||
readonly property string userNameProblem: {
|
||||
const value = root.newUserName;
|
||||
if (value === "")
|
||||
return "";
|
||||
if (!root.newUserNameValid)
|
||||
return "Start with a lowercase letter or _, then lowercase letters, digits, - and _ — up to 31 more.";
|
||||
if (UserAccounts.users.some(user => String(user.userName ?? "") === value))
|
||||
return "An account with that username already exists.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function closePanels(): void {
|
||||
root.openPanel = "";
|
||||
root.newPassword = "";
|
||||
root.confirmPassword = "";
|
||||
newPasswordField.clear();
|
||||
confirmPasswordField.clear();
|
||||
root.newUserName = "";
|
||||
root.newRealName = "";
|
||||
root.newUserIsAdministrator = false;
|
||||
}
|
||||
|
||||
// accountsservice reports the last session start in seconds; a helper that
|
||||
// hands back milliseconds is taken at its word rather than read as a date
|
||||
// fifty thousand years from now.
|
||||
function signInSummary(user: var): string {
|
||||
const raw = Number(user?.loginTime ?? 0);
|
||||
if (!Number.isFinite(raw) || raw <= 0)
|
||||
return "has not signed in yet";
|
||||
const when = new Date((raw > 1e12 ? raw / 1000 : raw) * 1000);
|
||||
const days = Math.floor((Date.now() - when.getTime()) / 86400000);
|
||||
if (days <= 0)
|
||||
return "last signed in today";
|
||||
if (days === 1)
|
||||
return "last signed in yesterday";
|
||||
if (days < 30)
|
||||
return "last signed in " + days + " days ago";
|
||||
return "last signed in " + when.toLocaleDateString(Qt.locale(), Locale.ShortFormat);
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
UserAccounts.refresh();
|
||||
// Probing fprintd bus-activates it, so it waits for the page rather
|
||||
@@ -62,9 +161,11 @@ SettingsPage {
|
||||
Fingerprint.refresh();
|
||||
}
|
||||
|
||||
// A write that failed is a row, not a page. Everything below stays usable,
|
||||
// because the thing that refused is almost never the thing being read.
|
||||
TextRow {
|
||||
visible: UserAccounts.lastError !== ""
|
||||
label: "Accounts need attention"
|
||||
label: "That change did not go through"
|
||||
detail: UserAccounts.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
@@ -160,25 +261,64 @@ SettingsPage {
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
|
||||
SettingsButton {
|
||||
text: "Change picture…"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: avatarPicker.open()
|
||||
Row {
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: root.pictureOpen ? "Done" : "Change picture…"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: {
|
||||
// The gallery is a directory listing, read the
|
||||
// first time someone asks to see it.
|
||||
if (!root.pictureOpen)
|
||||
UserAccounts.loadStockAvatars();
|
||||
root.pictureOpen = !root.pictureOpen;
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: UserAccounts.avatarUrl !== ""
|
||||
text: "Remove"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: {
|
||||
root.pictureOpen = false;
|
||||
UserAccounts.removeIcon();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StockAvatarPicker {
|
||||
width: parent.width
|
||||
visible: root.pictureOpen
|
||||
avatars: UserAccounts.stockAvatars
|
||||
enabled: !UserAccounts.busy
|
||||
onPicked: path => {
|
||||
root.pictureOpen = false;
|
||||
UserAccounts.setIcon(String(root.me?.userName ?? ""), path);
|
||||
}
|
||||
// A photo is almost never square, so it goes through the cropper
|
||||
// rather than being squeezed into a circle by the image loader.
|
||||
onFileRequested: {
|
||||
root.pictureOpen = false;
|
||||
avatarPicker.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Account"
|
||||
visible: root.me !== null
|
||||
visible: root.me !== null && root.pendingPicture === ""
|
||||
|
||||
TextFieldRow {
|
||||
LiveFieldRow {
|
||||
label: "Full name"
|
||||
detail: "Shown on the lock screen and in the Control Center"
|
||||
text: String(root.me?.realName ?? "")
|
||||
placeholder: "Your name"
|
||||
enabled: !UserAccounts.busy
|
||||
// Committed on Enter or when the field is left, never per
|
||||
// keystroke: this one goes through polkit.
|
||||
onAccepted: value => UserAccounts.setRealName(String(root.me?.userName ?? ""), value)
|
||||
}
|
||||
|
||||
@@ -191,7 +331,7 @@ SettingsPage {
|
||||
SegmentRow {
|
||||
label: "Account type"
|
||||
detail: root.me?.administrator && UserAccounts.administratorCount <= 1
|
||||
? "This is the only administrator, so it cannot be changed"
|
||||
? "The last administrator cannot step down"
|
||||
: "Administrators can install software and manage other accounts"
|
||||
options: [
|
||||
{ value: "standard", label: "Standard" },
|
||||
@@ -212,12 +352,10 @@ SettingsPage {
|
||||
enabled: !UserAccounts.busy
|
||||
divider: root.openPanel === "password"
|
||||
onTriggered: {
|
||||
if (root.openPanel === "password")
|
||||
root.closePanels();
|
||||
else {
|
||||
root.closePanels();
|
||||
const wasOpen = root.openPanel === "password";
|
||||
root.closePanels();
|
||||
if (!wasOpen)
|
||||
root.openPanel = "password";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,19 +363,30 @@ SettingsPage {
|
||||
width: parent.width
|
||||
visible: root.openPanel === "password"
|
||||
|
||||
PasswordRow {
|
||||
SecretFieldRow {
|
||||
id: newPasswordField
|
||||
|
||||
width: parent.width
|
||||
label: "New password"
|
||||
detail: "At least six characters"
|
||||
enabled: !UserAccounts.busy
|
||||
onChanged: value => root.newPassword = value
|
||||
}
|
||||
|
||||
PasswordRow {
|
||||
PasswordStrengthRow {
|
||||
width: parent.width
|
||||
password: root.newPassword
|
||||
}
|
||||
|
||||
SecretFieldRow {
|
||||
id: confirmPasswordField
|
||||
|
||||
width: parent.width
|
||||
label: "Confirm"
|
||||
detail: root.passwordProblem !== ""
|
||||
? root.passwordProblem
|
||||
: "Type it a second time"
|
||||
enabled: !UserAccounts.busy
|
||||
onChanged: value => root.confirmPassword = value
|
||||
}
|
||||
|
||||
@@ -257,7 +406,7 @@ SettingsPage {
|
||||
|
||||
SwitchRow {
|
||||
label: "Automatic login"
|
||||
detail: "Sign in without typing a password. The login keyring stays locked when this is on, so stored passwords are unavailable until something asks for them."
|
||||
detail: "Anyone at the keyboard becomes you — the disk stays encrypted, the session does not. The login keyring stays locked too, so stored passwords wait until something asks for them."
|
||||
checked: root.me?.automaticLogin === true
|
||||
enabled: !UserAccounts.busy
|
||||
divider: false
|
||||
@@ -267,50 +416,195 @@ SettingsPage {
|
||||
|
||||
// ── Fingerprint ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Hidden in full on a machine with no reader. Two systems make this work
|
||||
// and the card keeps them honest with each other: fprintd holds the
|
||||
// enrolled prints (GNOME's Users panel owns that dialog, so enrollment
|
||||
// hands off the same way password-adjacent panels do), and authselect
|
||||
// decides whether PAM asks the reader at all -- a print enrolled while
|
||||
// that is off does nothing, which reads as "fingerprint is broken".
|
||||
// Two systems make a working fingerprint login and the card keeps them
|
||||
// honest with each other: fprintd holds the enrolled prints, and authselect
|
||||
// decides whether PAM asks the reader at all. A print enrolled while
|
||||
// authselect is off does nothing, and authselect left on with no reader
|
||||
// attached asks PAM for a finger nothing can read -- which is why the card
|
||||
// appears for either fact, not only when a reader is plugged in.
|
||||
|
||||
SettingsCard {
|
||||
visible: Fingerprint.readerPresent
|
||||
// `cardVisible` is the service's `readerPresent || unlockFeatureEnabled`
|
||||
// -- one expression, so the card and the state it describes cannot
|
||||
// disagree about when the stuck case exists.
|
||||
visible: Fingerprint.cardVisible
|
||||
title: "Fingerprint"
|
||||
subtitle: Fingerprint.readerName !== ""
|
||||
subtitle: Fingerprint.readerPresent && Fingerprint.readerName !== ""
|
||||
? Fingerprint.readerName
|
||||
: "A fingerprint reader is present."
|
||||
: ""
|
||||
|
||||
SwitchRow {
|
||||
label: "Unlock with a fingerprint"
|
||||
detail: {
|
||||
if (Fingerprint.enrolled.length === 0)
|
||||
return "Enroll a finger below first; until then the password is the only way in";
|
||||
return Fingerprint.pamEnabled
|
||||
? "The lock screen and sudo accept an enrolled finger, with the password as fallback"
|
||||
: "Enrolled fingers are ignored until this is on";
|
||||
// The stuck state: the feature is on, and there is nothing to read a
|
||||
// finger with. Reachable here because it is unreachable anywhere else --
|
||||
// the card used to hide itself on exactly the machine that needed it.
|
||||
SettingRow {
|
||||
visible: root.fingerprintStuck
|
||||
label: "Fingerprint unlock is on, but no reader is connected"
|
||||
detail: "authselect still asks PAM for a finger that can't be read — turn it off here, or plug the reader back in"
|
||||
controlWidth: 190
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: noReader.implicitWidth + 14
|
||||
height: 17
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.warn, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.warn, 0.3)
|
||||
|
||||
Text {
|
||||
id: noReader
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: "NO READER"
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Turn off"
|
||||
enabled: !Fingerprint.busy
|
||||
onClicked: Fingerprint.setUnlockEnabled(false)
|
||||
}
|
||||
}
|
||||
checked: Fingerprint.pamEnabled
|
||||
enabled: !Fingerprint.busy
|
||||
onToggled: value => Fingerprint.setUnlockEnabled(value)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Enrolled fingers"
|
||||
detail: Fingerprint.enrolled.length === 0
|
||||
? "None yet"
|
||||
: Fingerprint.enrolled.map(finger => Fingerprint.fingerLabel(finger)).join(", ")
|
||||
action: "Manage…"
|
||||
divider: Fingerprint.lastError !== ""
|
||||
// GNOME's Users panel owns the enrollment dialog; growing our own
|
||||
// means reimplementing a guided capture flow fprintd already has
|
||||
// a good one of.
|
||||
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: Fingerprint.readerPresent
|
||||
|
||||
SwitchRow {
|
||||
width: parent.width
|
||||
label: "Unlock with a fingerprint"
|
||||
detail: {
|
||||
if (Fingerprint.fingers.length === 0)
|
||||
return "Enroll a finger below first; until then the password is the only way in";
|
||||
return "The lock screen and sudo — system-wide, via authselect, with the password as fallback";
|
||||
}
|
||||
checked: Fingerprint.unlockFeatureEnabled
|
||||
enabled: !Fingerprint.busy && !Fingerprint.enrolling
|
||||
onToggled: value => Fingerprint.setUnlockEnabled(value)
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "ENROLLED FINGERS"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.8
|
||||
topPadding: 12
|
||||
bottomPadding: 4
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Fingerprint.fingers.length === 0
|
||||
label: "None yet"
|
||||
detail: "A finger has to be enrolled before the reader can let anyone in"
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Fingerprint.fingers
|
||||
|
||||
delegate: SettingRow {
|
||||
id: fingerRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string finger: String(fingerRow.modelData)
|
||||
readonly property bool confirming: root.confirmingFinger === fingerRow.finger
|
||||
|
||||
width: parent.width
|
||||
// md-fingerprint
|
||||
icon: "\u{F0306}"
|
||||
label: Fingerprint.fingerLabel(fingerRow.finger)
|
||||
detail: fingerRow.confirming
|
||||
? "Forgetting it takes effect at once; it can be enrolled again afterwards"
|
||||
: ""
|
||||
controlWidth: 190
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: fingerRow.confirming ? "Keep" : "Remove…"
|
||||
enabled: !Fingerprint.busy && !Fingerprint.enrolling
|
||||
onClicked: root.confirmingFinger = fingerRow.confirming
|
||||
? "" : fingerRow.finger
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: fingerRow.confirming
|
||||
text: "Forget it"
|
||||
tone: "danger"
|
||||
enabled: !Fingerprint.busy
|
||||
onClicked: {
|
||||
root.confirmingFinger = "";
|
||||
Fingerprint.removeFinger(fingerRow.finger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
width: parent.width
|
||||
visible: !Fingerprint.enrolling && root.fingerOptions.length > 1
|
||||
label: "Finger"
|
||||
detail: "Which one the reader is about to learn"
|
||||
options: root.fingerOptions
|
||||
current: root.chosenFinger
|
||||
onPicked: value => root.chosenFinger = String(value)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
visible: !Fingerprint.enrolling
|
||||
label: "Add a fingerprint"
|
||||
detail: root.fingerOptions.length === 0
|
||||
? "Every finger the reader knows about is already enrolled"
|
||||
: "Around ten touches, guided here — the reader does the work"
|
||||
action: "Enroll…"
|
||||
enabled: !Fingerprint.busy && root.fingerOptions.length !== 0
|
||||
divider: false
|
||||
onTriggered: {
|
||||
root.confirmingFinger = "";
|
||||
Fingerprint.startEnroll(root.chosenFinger);
|
||||
}
|
||||
}
|
||||
|
||||
FingerprintEnrollPanel {
|
||||
width: parent.width
|
||||
visible: Fingerprint.enrolling
|
||||
// The finger the service is actually working on, not the one
|
||||
// the picker happens to be showing.
|
||||
finger: Fingerprint.fingerLabel(Fingerprint.enrollFinger)
|
||||
stage: Fingerprint.enrollStage
|
||||
total: Fingerprint.enrollTotal
|
||||
message: root.enrollHint
|
||||
onCancelled: Fingerprint.cancelEnroll()
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Fingerprint.lastError !== ""
|
||||
label: "Fingerprint needs attention"
|
||||
label: "The reader had something to say"
|
||||
detail: Fingerprint.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
@@ -337,48 +631,149 @@ SettingsPage {
|
||||
width: parent.width
|
||||
|
||||
readonly property string userName: String(otherBlock.modelData.userName ?? "")
|
||||
readonly property bool expanded: root.expandedUser === otherBlock.userName
|
||||
readonly property bool confirming: root.confirmingRemoval === otherBlock.userName
|
||||
readonly property bool locked: otherBlock.modelData.locked === true
|
||||
readonly property bool lastAdministrator:
|
||||
otherBlock.modelData.administrator && UserAccounts.administratorCount <= 1
|
||||
|
||||
SettingRow {
|
||||
width: otherBlock.width
|
||||
label: UserAccounts.displayName(otherBlock.modelData)
|
||||
detail: otherBlock.userName + " · "
|
||||
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard account")
|
||||
controlWidth: 210
|
||||
divider: false
|
||||
+ (otherBlock.modelData.administrator ? "Administrator" : "Standard")
|
||||
+ " · " + (otherBlock.locked
|
||||
? "locked"
|
||||
: root.signInSummary(otherBlock.modelData))
|
||||
controlWidth: 26
|
||||
divider: !otherBlock.expanded
|
||||
activatable: true
|
||||
onActivated: {
|
||||
root.expandedUser = otherBlock.expanded ? "" : otherBlock.userName;
|
||||
root.confirmingRemoval = "";
|
||||
root.removalDisposition = "keep";
|
||||
}
|
||||
|
||||
Row {
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: otherBlock.confirming ? "Keep" : "Remove…"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: root.confirmingRemoval = otherBlock.confirming
|
||||
? "" : otherBlock.userName
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: otherBlock.confirming
|
||||
text: "Delete account and files"
|
||||
tone: "danger"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: {
|
||||
root.confirmingRemoval = "";
|
||||
UserAccounts.deleteUser(otherBlock.userName, true);
|
||||
}
|
||||
}
|
||||
text: otherBlock.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
Column {
|
||||
width: otherBlock.width
|
||||
visible: otherBlock.confirming
|
||||
label: "This cannot be undone"
|
||||
detail: "Their home directory and everything in it is deleted."
|
||||
value: ""
|
||||
divider: false
|
||||
leftPadding: 14
|
||||
visible: otherBlock.expanded
|
||||
|
||||
SegmentRow {
|
||||
width: otherBlock.width - 14
|
||||
label: "Account type"
|
||||
detail: otherBlock.lastAdministrator
|
||||
? "The last administrator cannot step down"
|
||||
: "Administrators can install software and manage other accounts"
|
||||
options: [
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "administrator", label: "Administrator" }
|
||||
]
|
||||
value: otherBlock.modelData.administrator ? "administrator" : "standard"
|
||||
enabled: !UserAccounts.busy && !otherBlock.lastAdministrator
|
||||
onSelected: value => UserAccounts.setAccountTypeFor(otherBlock.userName, value)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: otherBlock.width - 14
|
||||
visible: otherBlock.locked
|
||||
label: "This account is locked"
|
||||
detail: "Nothing signs in to it until it is unlocked again"
|
||||
action: "Unlock"
|
||||
enabled: !UserAccounts.busy
|
||||
onTriggered: UserAccounts.setLocked(otherBlock.userName, false)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: otherBlock.width - 14
|
||||
label: "Reset password"
|
||||
detail: "They set a new one at next sign-in. Nothing is typed here, and no password is chosen for them."
|
||||
action: "Reset…"
|
||||
enabled: !UserAccounts.busy
|
||||
onTriggered: UserAccounts.resetPassword(otherBlock.userName)
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
width: otherBlock.width - 14
|
||||
label: "Their files"
|
||||
detail: "What deleting the account does to /home/" + otherBlock.userName
|
||||
options: [
|
||||
{
|
||||
value: "keep",
|
||||
label: "Keep the files",
|
||||
detail: "/home/" + otherBlock.userName
|
||||
+ " stays exactly where it is, and you can hand it to someone later"
|
||||
},
|
||||
{
|
||||
value: "remove",
|
||||
label: "Remove everything",
|
||||
detail: "The home directory and everything inside it is deleted with the account"
|
||||
}
|
||||
]
|
||||
current: root.removalDisposition
|
||||
enabled: !UserAccounts.busy
|
||||
onPicked: value => root.removalDisposition = String(value)
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: otherBlock.width - 14
|
||||
label: otherBlock.confirming
|
||||
? "Delete " + UserAccounts.displayName(otherBlock.modelData) + "?"
|
||||
: "Delete this account"
|
||||
detail: {
|
||||
if (!otherBlock.confirming)
|
||||
return "The account stops existing. What happens to their files is the choice above.";
|
||||
if (root.removalDisposition === "remove")
|
||||
return "This cannot be undone — /home/" + otherBlock.userName
|
||||
+ " and everything in it is deleted along with the account.";
|
||||
return "Their files stay in /home/" + otherBlock.userName
|
||||
+ ", so only the account itself goes.";
|
||||
}
|
||||
controlWidth: 260
|
||||
divider: false
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: otherBlock.confirming ? "Keep the account" : "Delete…"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: root.confirmingRemoval = otherBlock.confirming
|
||||
? "" : otherBlock.userName
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: otherBlock.confirming
|
||||
text: root.removalDisposition === "remove"
|
||||
? "Delete account and files"
|
||||
: "Delete the account"
|
||||
tone: "danger"
|
||||
enabled: !UserAccounts.busy
|
||||
onClicked: {
|
||||
// The helper's second argument is
|
||||
// keep-files, not remove-files: the two
|
||||
// read alike at a glance and mean opposite
|
||||
// things to someone's home directory.
|
||||
const keepFiles = root.removalDisposition === "keep";
|
||||
root.confirmingRemoval = "";
|
||||
root.expandedUser = "";
|
||||
UserAccounts.deleteUser(otherBlock.userName, keepFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { width: 1; height: 6 }
|
||||
@@ -392,35 +787,43 @@ SettingsPage {
|
||||
enabled: !UserAccounts.busy
|
||||
divider: root.openPanel === "newUser"
|
||||
onTriggered: {
|
||||
if (root.openPanel === "newUser")
|
||||
root.closePanels();
|
||||
else {
|
||||
root.closePanels();
|
||||
const wasOpen = root.openPanel === "newUser";
|
||||
root.closePanels();
|
||||
if (!wasOpen)
|
||||
root.openPanel = "newUser";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live, on purpose. These fields report every keystroke -- they are
|
||||
// LiveFieldRow rather than TextFieldRow -- because the Create button
|
||||
// has to darken and light as the username is typed. With blur-committed
|
||||
// fields it stayed dark until focus left, which reads as a dead button.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.openPanel === "newUser"
|
||||
|
||||
TextFieldRow {
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Full name"
|
||||
placeholder: "Their name"
|
||||
detail: "Shown on the login screen"
|
||||
text: root.newRealName
|
||||
onAccepted: value => root.newRealName = value
|
||||
enabled: !UserAccounts.busy
|
||||
onEdited: value => root.newRealName = value
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
LiveFieldRow {
|
||||
width: parent.width
|
||||
label: "Username"
|
||||
placeholder: "lowercase, no spaces"
|
||||
detail: "Their home directory is named after this and cannot be changed later"
|
||||
placeholder: "riley"
|
||||
detail: root.userNameProblem !== ""
|
||||
? root.userNameProblem
|
||||
: "A lowercase letter or _, then lowercase letters, digits, - and _ · up to 31 more. Their home directory is named after it and cannot be changed later."
|
||||
invalid: root.userNameProblem !== ""
|
||||
maximumLength: 32
|
||||
text: root.newUserName
|
||||
onAccepted: value => root.newUserName = value
|
||||
enabled: !UserAccounts.busy
|
||||
onEdited: value => root.newUserName = value
|
||||
}
|
||||
|
||||
SegmentRow {
|
||||
@@ -432,6 +835,7 @@ SettingsPage {
|
||||
{ value: "administrator", label: "Administrator" }
|
||||
]
|
||||
value: root.newUserIsAdministrator ? "administrator" : "standard"
|
||||
enabled: !UserAccounts.busy
|
||||
onSelected: value => root.newUserIsAdministrator = value === "administrator"
|
||||
}
|
||||
|
||||
@@ -440,7 +844,11 @@ SettingsPage {
|
||||
label: "Create the account"
|
||||
detail: "They set their own password the first time they sign in"
|
||||
action: "Create"
|
||||
enabled: !UserAccounts.busy && /^[a-z_][a-z0-9_-]*$/.test(root.newUserName)
|
||||
// The same rule the sentence under the field states, read from
|
||||
// the one place it is written down.
|
||||
enabled: !UserAccounts.busy
|
||||
&& root.newUserNameValid
|
||||
&& root.userNameProblem === ""
|
||||
divider: false
|
||||
onTriggered: {
|
||||
UserAccounts.createUser(root.newUserName, root.newRealName,
|
||||
|
||||
@@ -124,3 +124,9 @@ ThemeEditorWells 1.0 ThemeEditorWells.qml
|
||||
ThemeSaturationRow 1.0 ThemeSaturationRow.qml
|
||||
ThemeStartChips 1.0 ThemeStartChips.qml
|
||||
ThemeSaveRow 1.0 ThemeSaveRow.qml
|
||||
LiveFieldRow 1.0 LiveFieldRow.qml
|
||||
SecretFieldRow 1.0 SecretFieldRow.qml
|
||||
PasswordStrengthRow 1.0 PasswordStrengthRow.qml
|
||||
StockAvatarPicker 1.0 StockAvatarPicker.qml
|
||||
FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
|
||||
OnlineAccountRow 1.0 OnlineAccountRow.qml
|
||||
|
||||
@@ -12,22 +12,45 @@ turning individual services on and off, and removing an account. That is the
|
||||
whole Online Accounts panel apart from one OAuth handshake.
|
||||
|
||||
Adding an account is the part that splits. The daemon's AddAccount takes
|
||||
credentials as an ARGUMENT -- it stores them, it does not obtain them. For
|
||||
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
|
||||
password, which a settings app can reasonably collect. For Google it is an OAuth
|
||||
token, and the code that runs that exchange lives in libgoa-backend, which
|
||||
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
|
||||
would mean our own Google client credentials. So Google sign-in is handed to
|
||||
GNOME's panel, and only the sign-in.
|
||||
credentials as an ARGUMENT -- it stores them, it does not obtain them, and every
|
||||
part of that storing happens inside goa-daemon, which does have the backend.
|
||||
For password-based providers (Nextcloud, IMAP) that argument is a username and a
|
||||
password, which a settings app can reasonably collect, so those are added here.
|
||||
For Google it is an OAuth token, and the code that runs that exchange lives in
|
||||
libgoa-backend, which Fedora ships without a GIR binding -- reachable from C
|
||||
only. Reimplementing it would mean our own Google client credentials. So OAuth
|
||||
sign-in is handed to GNOME's panel, and only the sign-in.
|
||||
|
||||
A password reaches this script on stdin and nowhere else. argv is world-readable
|
||||
through /proc, so a password passed as an argument is published to every process
|
||||
on the machine.
|
||||
|
||||
Usage:
|
||||
panama-accounts list
|
||||
panama-accounts list | snapshot
|
||||
panama-accounts set <object-path> <service> <true|false>
|
||||
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 os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible validation or sign-in failure."""
|
||||
|
||||
|
||||
# A canned GOA, for contract runs: a JSON file in the shape `list` prints. With
|
||||
# it set, nothing here imports gi or touches the session bus, and every change a
|
||||
# verb would have made is appended to PANAMA_ACCOUNTS_LOG instead -- with the
|
||||
# password replaced by its length, so a contract can prove it was read from
|
||||
# stdin without a secret ever reaching a file.
|
||||
FIXTURE_ENV = "PANAMA_ACCOUNTS_FIXTURE"
|
||||
LOG_ENV = "PANAMA_ACCOUNTS_LOG"
|
||||
|
||||
# Every service GOA models. The account object carries one interface per service
|
||||
# it supports, so presence of the interface is what "this account can do mail"
|
||||
@@ -111,8 +134,319 @@ def find(client, path):
|
||||
return None
|
||||
|
||||
|
||||
# ── Adding a password account ────────────────────────────────────────────────
|
||||
#
|
||||
# The keys below are not invented. They are the keys goa-daemon writes into
|
||||
# ~/.config/goa-1.0/accounts.conf, which is to say the ones each provider's
|
||||
# build_object reads back -- taken from GOA 3.58's goaowncloudprovider.c and
|
||||
# goaimapsmtpprovider.c, and confirmed against the accounts already on this
|
||||
# machine. A key GOA does not recognize is silently ignored, so getting one
|
||||
# wrong produces an account that exists and does nothing.
|
||||
|
||||
|
||||
def read_password() -> str:
|
||||
"""The password, from stdin. Never an argument, at any point in the chain."""
|
||||
secret = sys.stdin.buffer.read()
|
||||
# A trailing newline from a pipe is not part of the password.
|
||||
if secret.endswith(b"\n"):
|
||||
secret = secret[:-1]
|
||||
if not secret:
|
||||
raise BoundaryError("No password was provided.")
|
||||
return secret.decode("utf-8", "surrogateescape")
|
||||
|
||||
|
||||
# RFC 1123 hostname (or a dotted IPv4). These strings end up in accounts.conf
|
||||
# and in URIs other processes fetch, so a shell metacharacter or a space is a
|
||||
# malformed address whichever way it got here -- refuse it at the boundary.
|
||||
HOSTNAME = re.compile(
|
||||
r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
||||
r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")
|
||||
|
||||
|
||||
def valid_host(text: str) -> bool:
|
||||
return bool(text) and len(text) <= 253 and bool(HOSTNAME.match(text))
|
||||
|
||||
|
||||
def nextcloud_uris(server: str) -> tuple[str, str, str]:
|
||||
"""(WebDAV URI, DAV URI, host) for a Nextcloud address.
|
||||
|
||||
`remote.php/webdav` and `remote.php/dav` are Nextcloud's fixed layout, and
|
||||
are exactly what GOA's own provider derives from the server it was given.
|
||||
"""
|
||||
text = (server or "").strip()
|
||||
if not text:
|
||||
raise BoundaryError("Enter the address of your Nextcloud server.")
|
||||
if "://" not in text:
|
||||
text = "https://" + text
|
||||
|
||||
parsed = urllib.parse.urlsplit(text)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise BoundaryError("A server address starts with https://.")
|
||||
if not parsed.hostname or not valid_host(parsed.hostname):
|
||||
raise BoundaryError("That is not a server address.")
|
||||
# urlsplit is permissive about what follows the host; a path is fine, but
|
||||
# spaces or shell punctuation anywhere in the address are not an URL that
|
||||
# a Nextcloud server has.
|
||||
if re.search(r"[\s;|&`$<>\"']", text):
|
||||
raise BoundaryError("That is not a server address.")
|
||||
|
||||
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
|
||||
return base + "/remote.php/webdav/", base + "/remote.php/dav/", parsed.hostname
|
||||
|
||||
|
||||
def nextcloud_account(server: str, username: str) -> tuple[str, str, dict]:
|
||||
"""(identity, presentation identity, details). Validation and nothing else,
|
||||
so the same refusals run whether or not there is a GOA to talk to."""
|
||||
identity = (username or "").strip()
|
||||
if not identity:
|
||||
raise BoundaryError("Enter the user name for that server.")
|
||||
webdav, dav, host = nextcloud_uris(server)
|
||||
|
||||
return (
|
||||
identity,
|
||||
# What the account is shown as. A bare user name says nothing about
|
||||
# which server it is on, and people keep more than one.
|
||||
identity if "@" in identity else f"{identity}@{host}",
|
||||
{
|
||||
"Uri": webdav,
|
||||
"FilesEnabled": "true",
|
||||
"CalendarEnabled": "true",
|
||||
"CalDavUri": dav,
|
||||
"ContactsEnabled": "true",
|
||||
"CardDavUri": dav,
|
||||
"AcceptSslErrors": "false",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def add_nextcloud(server: str, username: str, password: str) -> str:
|
||||
from gi.repository import GLib
|
||||
|
||||
identity, presentation, details = nextcloud_account(server, username)
|
||||
return add_account("owncloud", identity, presentation,
|
||||
{"password": GLib.Variant("s", password)},
|
||||
details, "Nextcloud")
|
||||
|
||||
|
||||
def imap_account(email: str, imap_host: str, smtp_host: str,
|
||||
username: str) -> tuple[str, str, dict]:
|
||||
address = (email or "").strip()
|
||||
if "@" not in address or address.startswith("@") or address.endswith("@"):
|
||||
raise BoundaryError("Enter the email address for this account.")
|
||||
imap = (imap_host or "").strip()
|
||||
smtp = (smtp_host or "").strip()
|
||||
if not imap or not smtp:
|
||||
raise BoundaryError("Enter both the incoming and outgoing server.")
|
||||
if not valid_host(imap) or not valid_host(smtp):
|
||||
raise BoundaryError("Mail servers are host names, like imap.example.org.")
|
||||
identity = (username or "").strip() or address
|
||||
|
||||
# The modern defaults, and the ones every mail provider this is likely to
|
||||
# meet uses: IMAP over TLS on 993, submission with STARTTLS on 587. GOA
|
||||
# offers a dropdown for the other combinations; a settings page that asked
|
||||
# four encryption questions to add a mailbox would be worse than one that
|
||||
# gets it right and lets GNOME's panel handle the unusual server.
|
||||
return (
|
||||
address, address,
|
||||
{
|
||||
"Enabled": "true",
|
||||
"EmailAddress": address,
|
||||
"Name": address,
|
||||
"ImapHost": imap,
|
||||
"ImapUserName": identity,
|
||||
"ImapUseSsl": "true",
|
||||
"ImapUseTls": "false",
|
||||
"ImapAcceptSslErrors": "false",
|
||||
"SmtpHost": smtp,
|
||||
"SmtpUseAuth": "true",
|
||||
"SmtpUserName": identity,
|
||||
"SmtpAuthLogin": "false",
|
||||
"SmtpAuthPlain": "true",
|
||||
"SmtpUseSsl": "false",
|
||||
"SmtpUseTls": "true",
|
||||
"SmtpAcceptSslErrors": "false",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def add_imap(email: str, imap_host: str, smtp_host: str, username: str,
|
||||
password: str) -> str:
|
||||
from gi.repository import GLib
|
||||
|
||||
identity, presentation, details = imap_account(email, imap_host, smtp_host, username)
|
||||
# One password for both servers: submission almost always takes the same
|
||||
# credentials as the mailbox, and asking twice for the same secret is how a
|
||||
# form gets abandoned. GOA stores them under separate keys regardless.
|
||||
return add_account("imap_smtp", identity, presentation,
|
||||
{"imap-password": GLib.Variant("s", password),
|
||||
"smtp-password": GLib.Variant("s", password)},
|
||||
details, "mail")
|
||||
|
||||
|
||||
def add_account(provider: str, identity: str, presentation: str,
|
||||
credentials: dict, details: dict, label: str) -> str:
|
||||
"""Hand GOA a new account, then make it prove the credentials work.
|
||||
|
||||
The daemon does not check what it is given -- it writes the account out and
|
||||
stores the password. Without the second step a mistyped password produces an
|
||||
account that exists, looks fine, and never syncs, which is the failure this
|
||||
page is supposed to be able to explain.
|
||||
"""
|
||||
from gi.repository import GLib
|
||||
|
||||
client = load_client()
|
||||
manager = client.get_manager()
|
||||
if manager is None:
|
||||
raise BoundaryError("GNOME Online Accounts is not answering.")
|
||||
try:
|
||||
if not manager.call_is_supported_provider_sync(provider, None):
|
||||
raise BoundaryError(f"This system cannot add {label} accounts.")
|
||||
path = manager.call_add_account_sync(
|
||||
provider, identity, presentation,
|
||||
GLib.Variant("a{sv}", credentials),
|
||||
GLib.Variant("a{ss}", details), None)
|
||||
except GLib.Error as failure:
|
||||
raise BoundaryError(clean(failure.message)) from failure
|
||||
|
||||
verify(path, label)
|
||||
return path
|
||||
|
||||
|
||||
def verify(path: str, label: str) -> None:
|
||||
"""Sign in once, and undo the account if that fails."""
|
||||
from gi.repository import GLib
|
||||
|
||||
# A fresh client: the one that made the call has not necessarily seen the
|
||||
# object appear yet, and this is the cheapest way to wait for it properly.
|
||||
obj = find(load_client(), path)
|
||||
if obj is None:
|
||||
# It was added; this process simply cannot see it yet. Reporting a
|
||||
# failure here would be a lie, and the account list will show it.
|
||||
return
|
||||
|
||||
account = obj.get_account()
|
||||
try:
|
||||
account.set_default_timeout(60000)
|
||||
account.call_ensure_credentials_sync(None)
|
||||
except GLib.Error as failure:
|
||||
try:
|
||||
account.call_remove_sync(None)
|
||||
except GLib.Error:
|
||||
pass
|
||||
raise BoundaryError(
|
||||
f"Those {label} details were not accepted: {clean(failure.message)}"
|
||||
) from failure
|
||||
|
||||
|
||||
def clean(message: str) -> str:
|
||||
"""The useful sentence of a GOA error, without the D-Bus type prefix."""
|
||||
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", str(message or "")).strip()
|
||||
return trimmed.splitlines()[0][:200] if trimmed else "That did not work."
|
||||
|
||||
|
||||
# ── The canned GOA ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fixture() -> dict | None:
|
||||
path = os.environ.get(FIXTURE_ENV)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
listing = json.load(handle)
|
||||
except (OSError, ValueError) as failure:
|
||||
return {"accounts": [], "error": f"The accounts fixture could not be read: {failure}"}
|
||||
listing.setdefault("accounts", [])
|
||||
listing.setdefault("error", "")
|
||||
return listing
|
||||
|
||||
|
||||
def record(verb: str, arguments: list, password: str | None = None) -> None:
|
||||
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
|
||||
if not path:
|
||||
return
|
||||
entry = {"verb": verb, "arguments": arguments}
|
||||
if password is not None:
|
||||
# Its length, never the thing itself: the point of the log is to prove
|
||||
# the password came in on stdin, not to keep a copy of it.
|
||||
entry["passwordBytes"] = len(password)
|
||||
try:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def added(operation) -> int:
|
||||
"""Run an add, and answer with the fresh account list either way.
|
||||
|
||||
Errors come back inside that list rather than on stderr, so the page has
|
||||
both facts -- what went wrong and what is there now -- from one read.
|
||||
"""
|
||||
error = ""
|
||||
try:
|
||||
operation()
|
||||
except BoundaryError as failure:
|
||||
error = str(failure)
|
||||
|
||||
try:
|
||||
accounts = [describe(obj) for obj in load_client().get_accounts()]
|
||||
except Exception: # noqa: BLE001 - the error above is the one worth saying
|
||||
accounts = []
|
||||
print(json.dumps({"accounts": accounts, "error": error}))
|
||||
return 0
|
||||
|
||||
|
||||
def replay(action: str, arguments: list, canned: dict) -> int:
|
||||
"""Every verb, against the canned GOA. No bus, no gi, no account changed."""
|
||||
error = ""
|
||||
try:
|
||||
if action in ("list", "snapshot"):
|
||||
pass
|
||||
elif action in ("set", "remove"):
|
||||
record(action, arguments[1:])
|
||||
elif action == "add-nextcloud":
|
||||
if len(arguments) != 3:
|
||||
raise BoundaryError("Usage: add-nextcloud SERVER USERNAME")
|
||||
password = read_password()
|
||||
nextcloud_account(arguments[1], arguments[2])
|
||||
record(action, arguments[1:], password)
|
||||
elif action == "add-imap":
|
||||
if len(arguments) != 5:
|
||||
raise BoundaryError("Usage: add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME")
|
||||
password = read_password()
|
||||
imap_account(arguments[1], arguments[2], arguments[3], arguments[4])
|
||||
record(action, arguments[1:], password)
|
||||
else:
|
||||
raise BoundaryError(f"Unknown command {action!r}.")
|
||||
except BoundaryError as failure:
|
||||
error = str(failure)
|
||||
|
||||
print(json.dumps({**canned, "error": error or canned.get("error", "")}))
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||
arguments = sys.argv[1:]
|
||||
action = arguments[0] if arguments else "list"
|
||||
|
||||
canned = fixture()
|
||||
if canned is not None:
|
||||
return replay(action, arguments, canned)
|
||||
|
||||
if action == "add-nextcloud":
|
||||
if len(arguments) != 3:
|
||||
print("usage: panama-accounts add-nextcloud SERVER USERNAME", file=sys.stderr)
|
||||
return 2
|
||||
return added(lambda: add_nextcloud(arguments[1], arguments[2], read_password()))
|
||||
|
||||
if action == "add-imap":
|
||||
if len(arguments) != 5:
|
||||
print("usage: panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
return added(lambda: add_imap(arguments[1], arguments[2], arguments[3],
|
||||
arguments[4], read_password()))
|
||||
|
||||
try:
|
||||
client = load_client()
|
||||
@@ -124,7 +458,9 @@ def main():
|
||||
}))
|
||||
return 0
|
||||
|
||||
if action == "list":
|
||||
# "snapshot" is what every other Panama helper calls this, and what the
|
||||
# service asks for; "list" is what this one has always been called.
|
||||
if action in ("list", "snapshot"):
|
||||
print(json.dumps({
|
||||
"accounts": [describe(obj) for obj in client.get_accounts()],
|
||||
"error": "",
|
||||
@@ -162,7 +498,8 @@ def main():
|
||||
obj.get_account().call_remove_sync(None)
|
||||
return 0
|
||||
|
||||
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
|
||||
print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
@@ -1,89 +1,490 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Fingerprint state and the one privileged switch, for the Users page.
|
||||
#
|
||||
# Two independent facts make a working fingerprint login, and conflating them
|
||||
# is how the feature usually confuses people: fprintd must hold at least one
|
||||
# enrolled print (GNOME's Users panel owns that dialog, and Panama hands off
|
||||
# to it), and PAM must be told to ask the reader at all, which on Fedora is
|
||||
# authselect's `with-fingerprint` feature. This helper reports both and can
|
||||
# flip the second.
|
||||
#
|
||||
# Usage:
|
||||
# panama-fingerprint status -> {"reader":bool,"readerName":"","enrolled":[],"pamEnabled":bool,"error":""}
|
||||
# panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
|
||||
#
|
||||
# authselect is baseline Fedora (it manages PAM for the whole install), and
|
||||
# fprintd ships with Workstation; a machine with neither simply reports no
|
||||
# reader, which hides the card.
|
||||
"""Fingerprint login: the enrolled prints, and the one privileged switch.
|
||||
|
||||
set -uo pipefail
|
||||
Two independent facts make a working fingerprint login, and conflating them is
|
||||
how the feature usually confuses people. fprintd must hold at least one enrolled
|
||||
print, and PAM must be told to ask the reader at all, which on Fedora is
|
||||
authselect's `with-fingerprint` feature. This helper reports both, enrolls and
|
||||
removes prints, and can flip the second.
|
||||
|
||||
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
Both facts are reported unconditionally, including on a machine with no reader.
|
||||
The state that used to be invisible -- the feature switched on with nothing
|
||||
enrolled and no reader attached -- is exactly the state someone needs to see and
|
||||
turn off, and reporting `pamEnabled: false` because there was no reader to ask
|
||||
made it unreachable.
|
||||
|
||||
emit() {
|
||||
jq -cn \
|
||||
--argjson reader "$1" \
|
||||
--arg readerName "$2" \
|
||||
--argjson enrolled "$3" \
|
||||
--argjson pamEnabled "$4" \
|
||||
--arg error "$5" \
|
||||
'{reader: $reader, readerName: $readerName, enrolled: $enrolled,
|
||||
pamEnabled: $pamEnabled, error: $error}'
|
||||
}
|
||||
Enrollment talks to fprintd over D-Bus (net.reactivated.Fprint) rather than
|
||||
handing the person to GNOME's Users panel: Claim, EnrollStart, then one
|
||||
`EnrollStatus` signal per touch until the device says it is done. Progress is
|
||||
printed as one JSON object per line, the same streaming shape panama-dictate's
|
||||
setup uses, so the page can count touches while they happen. That signal loop is
|
||||
why this is Python and no longer bash -- `status` and `set-unlock` still shell
|
||||
out to exactly the same tools, and answer in exactly the same shapes.
|
||||
|
||||
cmd_status() {
|
||||
command -v fprintd-list >/dev/null 2>&1 || { emit false "" '[]' false ""; return; }
|
||||
panama-fingerprint status
|
||||
panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit)
|
||||
panama-fingerprint enroll FINGER (streams {stage,done,total,result})
|
||||
panama-fingerprint remove FINGER
|
||||
panama-fingerprint remove-all
|
||||
|
||||
# fprintd-list both answers "is there a reader" (fprintd is bus-activated,
|
||||
# so this also copes with the daemon not running yet) and names the
|
||||
# enrolled fingers in one call.
|
||||
local listing
|
||||
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
|
||||
# translated daemon would turn every readerless non-English machine into
|
||||
# a permanent error card.
|
||||
if ! listing="$(LC_ALL=C timeout 10 fprintd-list "$USER" 2>&1)"; then
|
||||
# "No devices available" is the normal no-reader machine; anything
|
||||
# else is a real problem worth surfacing.
|
||||
if grep -qi 'no devices' <<<"$listing"; then
|
||||
emit false "" '[]' false ""
|
||||
else
|
||||
emit false "" '[]' false "fprintd did not answer: $(head -1 <<<"$listing")"
|
||||
fi
|
||||
authselect is baseline Fedora (it manages PAM for the whole install) and fprintd
|
||||
ships with Workstation; a machine with neither reports no reader and no feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
FPRINT = "net.reactivated.Fprint"
|
||||
MANAGER_PATH = "/net/reactivated/Fprint/Manager"
|
||||
MANAGER_INTERFACE = "net.reactivated.Fprint.Manager"
|
||||
DEVICE_INTERFACE = "net.reactivated.Fprint.Device"
|
||||
|
||||
# The authselect feature that decides whether PAM asks the reader at unlock.
|
||||
FEATURE = "with-fingerprint"
|
||||
|
||||
# How long a reader may sit waiting for a finger before enrollment is given up
|
||||
# on. The page has a Cancel button; this is only for a session left open.
|
||||
IDLE_TIMEOUT_SECONDS = 90
|
||||
|
||||
# A canned fprintd, for contract runs. Points at a JSON file; see replay() for
|
||||
# the shape. Every call that would have gone to the bus is appended to
|
||||
# PANAMA_FINGERPRINT_LOG instead, so a test can pin the order of
|
||||
# Claim / EnrollStart / EnrollStop / Release without a reader in the room.
|
||||
FIXTURE_ENV = "PANAMA_FINGERPRINT_FIXTURE"
|
||||
LOG_ENV = "PANAMA_FINGERPRINT_LOG"
|
||||
|
||||
PANAMA_PATH = os.environ.get("PANAMA_PATH") or os.path.expanduser("~/.local/share/Panama")
|
||||
|
||||
# fprintd's own vocabulary, only far enough to reject nonsense before it becomes
|
||||
# a D-Bus error. What a finger is CALLED is presented by services/Fingerprint.qml,
|
||||
# which is the single place that decides how these read.
|
||||
FINGER = re.compile(r"^(left|right)-(thumb|(index|middle|ring|little)-finger)$")
|
||||
|
||||
|
||||
class BoundaryError(RuntimeError):
|
||||
"""A user-visible failure: no reader, a refused claim, a bad finger name."""
|
||||
|
||||
|
||||
# ── status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def unlock_feature_enabled() -> bool:
|
||||
"""Whether PAM has been told to ask the reader.
|
||||
|
||||
Asked unconditionally. This is a property of the PAM configuration and has
|
||||
nothing to do with whether a reader is plugged in, which is the whole point:
|
||||
the feature left on with no reader is a state someone has to be able to see.
|
||||
"""
|
||||
if not shutil.which("authselect"):
|
||||
return False
|
||||
try:
|
||||
current = subprocess.run(["authselect", "current"], capture_output=True,
|
||||
text=True, timeout=10, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return FEATURE in (current.stdout or "")
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
reader, name, enrolled, error = False, "", [], ""
|
||||
|
||||
if shutil.which("fprintd-list"):
|
||||
# fprintd-list answers "is there a reader" (fprintd is bus-activated, so
|
||||
# this copes with the daemon not running yet) and names the enrolled
|
||||
# fingers in one call.
|
||||
#
|
||||
# LC_ALL=C: the "no devices" match below reads fprintd's message, and a
|
||||
# translated daemon would turn every readerless non-English machine into
|
||||
# a permanent error card.
|
||||
environment = dict(os.environ, LC_ALL="C")
|
||||
try:
|
||||
listing = subprocess.run(["fprintd-list", os.environ.get("USER", "")],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
check=False, env=environment)
|
||||
output = (listing.stdout or "") + (listing.stderr or "")
|
||||
except (OSError, subprocess.SubprocessError) as failure:
|
||||
listing, output = None, str(failure)
|
||||
|
||||
if listing is None or listing.returncode != 0:
|
||||
# "No devices available" is the normal no-reader machine; anything
|
||||
# else is a real problem worth surfacing.
|
||||
if "no devices" not in output.lower():
|
||||
first = next((line for line in output.splitlines() if line.strip()), "")
|
||||
error = f"fprintd did not answer: {first}"
|
||||
else:
|
||||
reader = True
|
||||
# "Fingerprints for user gib on FocalTech ... (press):" carries the
|
||||
# reader product name; " - #0: right-index-finger" the enrollment.
|
||||
match = re.search(r"^Fingerprints for user \S+ on (.*) \(\w*\):$",
|
||||
output, re.MULTILINE)
|
||||
name = match.group(1) if match else ""
|
||||
enrolled = re.findall(r"^ *- #\d+: (.+)$", output, re.MULTILINE)
|
||||
|
||||
feature = unlock_feature_enabled()
|
||||
return {
|
||||
"reader": reader,
|
||||
"readerName": name,
|
||||
"enrolled": enrolled,
|
||||
# Two names for one fact, on purpose: `pamEnabled` is what this helper
|
||||
# has always called it, `unlockFeatureEnabled` is what it is.
|
||||
"pamEnabled": feature,
|
||||
"unlockFeatureEnabled": feature,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
# ── the privileged switch ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def set_unlock(state: str) -> int:
|
||||
if state == "on":
|
||||
verb = "enable-feature"
|
||||
reason = ("Turning on fingerprint login: telling PAM (via authselect) to "
|
||||
"ask the fingerprint reader when unlocking")
|
||||
elif state == "off":
|
||||
verb = "disable-feature"
|
||||
reason = ("Turning off fingerprint login: telling PAM (via authselect) to "
|
||||
"stop asking the fingerprint reader")
|
||||
else:
|
||||
print("panama-fingerprint set-unlock takes on|off", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
escalate = os.path.join(PANAMA_PATH, "bin", "panama-sudo")
|
||||
prefix = ([escalate, "--reason", reason, "--"]
|
||||
if os.access(escalate, os.X_OK) else ["sudo"])
|
||||
return subprocess.run(prefix + ["authselect", verb, FEATURE], check=False).returncode
|
||||
|
||||
|
||||
# ── talking to fprintd ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def emit(**fields) -> None:
|
||||
"""One JSON object, one line, flushed. The page reads these as they arrive."""
|
||||
print(json.dumps(fields, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def log_call(method: str, *arguments) -> None:
|
||||
"""Record a call the fixture stood in for, so a contract can pin the order."""
|
||||
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
|
||||
if not path:
|
||||
return
|
||||
fi
|
||||
try:
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps({"method": method, "arguments": list(arguments)},
|
||||
separators=(",", ":")) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# "Fingerprints for user gib on FocalTech ... (press):" carries the reader
|
||||
# product name; " - #0: right-index-finger" lines carry the enrollment.
|
||||
local name enrolled pam
|
||||
name="$(sed -n 's/^Fingerprints for user [^ ]* on \(.*\) (\w*):$/\1/p' <<<"$listing" | head -1)"
|
||||
enrolled="$(sed -n 's/^ *- #[0-9]*: //p' <<<"$listing" | jq -Rn '[inputs]')"
|
||||
pam=false
|
||||
authselect current 2>/dev/null | grep -q 'with-fingerprint' && pam=true
|
||||
|
||||
emit true "$name" "$enrolled" "$pam" ""
|
||||
}
|
||||
def fixture() -> dict | None:
|
||||
"""The canned fprintd, or None when there is a real bus to talk to.
|
||||
|
||||
cmd_set_unlock() {
|
||||
local verb reason
|
||||
case "$1" in
|
||||
on) verb=enable-feature
|
||||
reason="Turning on fingerprint login: telling PAM (via authselect) to ask the fingerprint reader when unlocking" ;;
|
||||
off) verb=disable-feature
|
||||
reason="Turning off fingerprint login: telling PAM (via authselect) to stop asking the fingerprint reader" ;;
|
||||
*) echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1 ;;
|
||||
esac
|
||||
{"enrollStages": 5,
|
||||
"results": ["enroll-stage-passed", "enroll-retry-scan-too-short", ...],
|
||||
"error": ""} <- non-empty stands in for a refused claim
|
||||
"""
|
||||
path = os.environ.get(FIXTURE_ENV)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError) as failure:
|
||||
raise BoundaryError(f"The fingerprint fixture could not be read: {failure}")
|
||||
|
||||
local sudo_cmd=(sudo)
|
||||
[[ -x "$PANAMA_PATH/bin/panama-sudo" ]] && sudo_cmd=(
|
||||
"$PANAMA_PATH/bin/panama-sudo" --reason "$reason" --
|
||||
)
|
||||
"${sudo_cmd[@]}" authselect "$verb" with-fingerprint
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
status) cmd_status ;;
|
||||
set-unlock) [[ -n "${2:-}" ]] || { echo 'panama-fingerprint set-unlock takes on|off' >&2; exit 1; }
|
||||
cmd_set_unlock "$2" ;;
|
||||
*) echo 'usage: panama-fingerprint status | set-unlock on|off' >&2; exit 1 ;;
|
||||
esac
|
||||
def bus():
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
||||
except Exception as failure: # noqa: BLE001 - no bus is a legitimate state
|
||||
raise BoundaryError("The fingerprint service is not answering.") from failure
|
||||
|
||||
|
||||
def call(path: str, interface: str, method: str, parameters=None, reply=None):
|
||||
Gio, GLib, connection = bus()
|
||||
try:
|
||||
result = connection.call_sync(
|
||||
FPRINT, path, interface, method, parameters,
|
||||
GLib.VariantType(reply) if reply else None,
|
||||
Gio.DBusCallFlags.NONE, 30000, None)
|
||||
except Exception as failure: # noqa: BLE001
|
||||
raise BoundaryError(_clean(str(failure))) from failure
|
||||
return result.unpack() if result is not None else None
|
||||
|
||||
|
||||
def _clean(message: str) -> str:
|
||||
"""The useful sentence out of a D-Bus error, without the type prefix."""
|
||||
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip()
|
||||
if "no devices" in trimmed.lower():
|
||||
return "No fingerprint reader is connected."
|
||||
if "permission denied" in trimmed.lower() or "not authorized" in trimmed.lower():
|
||||
return "That was not authorized."
|
||||
if "already in use" in trimmed.lower() or "claimed" in trimmed.lower():
|
||||
return "The fingerprint reader is busy with something else."
|
||||
return trimmed.splitlines()[0][:200] if trimmed else "The fingerprint reader failed."
|
||||
|
||||
|
||||
def default_device() -> str:
|
||||
return call(MANAGER_PATH, MANAGER_INTERFACE, "GetDefaultDevice", None, "(o)")[0]
|
||||
|
||||
|
||||
def enroll_stages(device: str) -> int:
|
||||
Gio, GLib, connection = bus()
|
||||
try:
|
||||
result = connection.call_sync(
|
||||
FPRINT, device, "org.freedesktop.DBus.Properties", "Get",
|
||||
GLib.Variant("(ss)", (DEVICE_INTERFACE, "num-enroll-stages")),
|
||||
GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 10000, None)
|
||||
return max(1, int(result.unpack()[0]))
|
||||
except Exception: # noqa: BLE001 - a device that will not say is not fatal
|
||||
# Readers overwhelmingly want five touches, and a counter that is wrong
|
||||
# is better than a page with no counter at all.
|
||||
return 5
|
||||
|
||||
|
||||
# ── enroll ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# fprintd says "enroll-stage-passed" for a touch that counted and
|
||||
# "enroll-completed" when there are no more to take. Everything else beginning
|
||||
# "enroll-retry" or naming a placement problem is a touch to do again, and the
|
||||
# terminal failures arrive with done=true.
|
||||
STAGE_PASSED = "enroll-stage-passed"
|
||||
COMPLETED = "enroll-completed"
|
||||
|
||||
# The results that end an enrollment badly. The device says so itself over the
|
||||
# wire (the signal's `done` flag), so this list is only what the canned fprintd
|
||||
# has to recognize on its own.
|
||||
TERMINAL_FAILURES = (
|
||||
"enroll-failed", "enroll-data-full", "enroll-disconnected",
|
||||
"enroll-duplicate", "enroll-unknown-error",
|
||||
)
|
||||
|
||||
|
||||
def enroll(finger: str) -> int:
|
||||
if not FINGER.fullmatch(finger or ""):
|
||||
raise BoundaryError("That is not a finger fprintd knows.")
|
||||
|
||||
canned = fixture()
|
||||
if canned is not None:
|
||||
return enroll_replay(finger, canned)
|
||||
|
||||
device = default_device()
|
||||
total = enroll_stages(device)
|
||||
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
|
||||
|
||||
Gio, GLib, connection = bus()
|
||||
call(device, DEVICE_INTERFACE, "Claim",
|
||||
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
|
||||
|
||||
loop = GLib.MainLoop()
|
||||
state = {"done": 0, "result": "", "error": "", "ok": False, "seen": time.monotonic()}
|
||||
|
||||
def on_status(_connection, _sender, _path, _interface, _signal, parameters):
|
||||
result, finished = parameters.unpack()
|
||||
state["seen"] = time.monotonic()
|
||||
state["result"] = result
|
||||
if result == STAGE_PASSED:
|
||||
state["done"] = min(state["done"] + 1, total)
|
||||
if finished:
|
||||
state["ok"] = result == COMPLETED
|
||||
if not state["ok"]:
|
||||
state["error"] = describe_result(result)
|
||||
loop.quit()
|
||||
return
|
||||
emit(ok=True, stage="scanning", done=state["done"], total=total,
|
||||
result=result, error="")
|
||||
|
||||
subscription = connection.signal_subscribe(
|
||||
None, DEVICE_INTERFACE, "EnrollStatus", device, None,
|
||||
Gio.DBusSignalFlags.NONE, on_status)
|
||||
|
||||
# A cancelled enrollment is a terminated process -- the page drops the
|
||||
# Process and Quickshell sends a signal. The device must still be released,
|
||||
# or the next attempt finds the reader busy with a session that is gone.
|
||||
def cancelled(_data=None):
|
||||
state["error"] = ""
|
||||
state["result"] = "cancelled"
|
||||
loop.quit()
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, cancelled, None)
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, cancelled, None)
|
||||
|
||||
def watchdog(_data=None):
|
||||
if time.monotonic() - state["seen"] > IDLE_TIMEOUT_SECONDS:
|
||||
state["error"] = "The reader stopped answering."
|
||||
loop.quit()
|
||||
return GLib.SOURCE_REMOVE
|
||||
return GLib.SOURCE_CONTINUE
|
||||
|
||||
GLib.timeout_add_seconds(5, watchdog, None)
|
||||
|
||||
try:
|
||||
call(device, DEVICE_INTERFACE, "EnrollStart", GLib.Variant("(s)", (finger,)))
|
||||
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
|
||||
loop.run()
|
||||
finally:
|
||||
connection.signal_unsubscribe(subscription)
|
||||
# Both are best-effort: the interesting failure already happened, and a
|
||||
# reader left claimed is worse than a second error nobody can act on.
|
||||
for method in ("EnrollStop", "Release"):
|
||||
try:
|
||||
call(device, DEVICE_INTERFACE, method)
|
||||
except BoundaryError:
|
||||
pass
|
||||
|
||||
return finish(state, total)
|
||||
|
||||
|
||||
def finish(state: dict, total: int) -> int:
|
||||
if state["ok"]:
|
||||
emit(ok=True, stage="done", done=total, total=total,
|
||||
result=COMPLETED, error="")
|
||||
return 0
|
||||
stage = "cancelled" if state["result"] == "cancelled" else "failed"
|
||||
emit(ok=False, stage=stage, done=state["done"], total=total,
|
||||
result=state["result"], error=state["error"])
|
||||
return 1
|
||||
|
||||
|
||||
def describe_result(result: str) -> str:
|
||||
"""fprintd's terminal results, in words someone can act on."""
|
||||
return {
|
||||
"enroll-failed": "That finger could not be read. Try enrolling it again.",
|
||||
"enroll-data-full": "The reader has no room for another fingerprint.",
|
||||
"enroll-disconnected": "The fingerprint reader was disconnected.",
|
||||
"enroll-duplicate": "That finger is already enrolled.",
|
||||
"enroll-unknown-error": "The fingerprint reader failed.",
|
||||
}.get(result, "Enrolling that finger did not finish.")
|
||||
|
||||
|
||||
def enroll_replay(finger: str, canned: dict) -> int:
|
||||
"""The same stream, from a file. No bus, no reader, no waiting."""
|
||||
total = max(1, int(canned.get("enrollStages", 5)))
|
||||
emit(ok=True, stage="claiming", done=0, total=total, result="", error="")
|
||||
|
||||
log_call("Claim", os.environ.get("USER", ""))
|
||||
refusal = str(canned.get("error") or "")
|
||||
if refusal:
|
||||
emit(ok=False, stage="failed", done=0, total=total, result="", error=refusal)
|
||||
return 1
|
||||
|
||||
log_call("EnrollStart", finger)
|
||||
emit(ok=True, stage="scanning", done=0, total=total, result="", error="")
|
||||
|
||||
state = {"done": 0, "result": "", "error": "", "ok": False}
|
||||
for result in canned.get("results", []):
|
||||
state["result"] = result
|
||||
if result == STAGE_PASSED:
|
||||
state["done"] = min(state["done"] + 1, total)
|
||||
if result == COMPLETED or result in TERMINAL_FAILURES:
|
||||
state["ok"] = result == COMPLETED
|
||||
if not state["ok"]:
|
||||
state["error"] = describe_result(result)
|
||||
break
|
||||
emit(ok=True, stage="scanning", done=state["done"], total=total,
|
||||
result=result, error="")
|
||||
|
||||
log_call("EnrollStop")
|
||||
log_call("Release")
|
||||
return finish(state, total)
|
||||
|
||||
|
||||
# ── remove ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def remove(finger: str | None) -> dict:
|
||||
"""Delete one enrolled finger, or all of them, and report the fresh state."""
|
||||
if finger is not None and not FINGER.fullmatch(finger):
|
||||
raise BoundaryError("That is not a finger fprintd knows.")
|
||||
|
||||
canned = fixture()
|
||||
if canned is not None:
|
||||
log_call("Claim", os.environ.get("USER", ""))
|
||||
if finger is None:
|
||||
log_call("DeleteEnrolledFingers2")
|
||||
else:
|
||||
log_call("DeleteEnrolledFinger", finger)
|
||||
log_call("Release")
|
||||
return {**status(), "error": str(canned.get("error") or "")}
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
device = default_device()
|
||||
call(device, DEVICE_INTERFACE, "Claim",
|
||||
GLib.Variant("(s)", (os.environ.get("USER", ""),)))
|
||||
try:
|
||||
if finger is None:
|
||||
# DeleteEnrolledFingers2 works on the claimed user; its predecessor
|
||||
# took a name and is deprecated for exactly the confusion that
|
||||
# invited -- deleting someone else's prints by typo.
|
||||
call(device, DEVICE_INTERFACE, "DeleteEnrolledFingers2")
|
||||
else:
|
||||
call(device, DEVICE_INTERFACE, "DeleteEnrolledFinger",
|
||||
GLib.Variant("(s)", (finger,)))
|
||||
finally:
|
||||
try:
|
||||
call(device, DEVICE_INTERFACE, "Release")
|
||||
except BoundaryError:
|
||||
pass
|
||||
|
||||
return status()
|
||||
|
||||
|
||||
# ── entry ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
verb = arguments[0] if arguments else ""
|
||||
|
||||
try:
|
||||
if verb == "status" and len(arguments) == 1:
|
||||
print(json.dumps(status(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if verb == "set-unlock" and len(arguments) == 2:
|
||||
return set_unlock(arguments[1])
|
||||
|
||||
if verb == "enroll" and len(arguments) == 2:
|
||||
return enroll(arguments[1])
|
||||
|
||||
if verb == "remove" and len(arguments) == 2:
|
||||
print(json.dumps(remove(arguments[1]), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if verb == "remove-all" and len(arguments) == 1:
|
||||
print(json.dumps(remove(None), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
except BoundaryError as failure:
|
||||
# Enrollment streams, so its failure has to arrive in the same shape as
|
||||
# its progress; the others answer with the state plus the message, so a
|
||||
# page never has to ask twice to find out what happened.
|
||||
if verb == "enroll":
|
||||
emit(ok=False, stage="failed", done=0, total=0, result="",
|
||||
error=str(failure))
|
||||
else:
|
||||
print(json.dumps({**status(), "error": str(failure)},
|
||||
separators=(",", ":")))
|
||||
return 1
|
||||
|
||||
print("usage: panama-fingerprint status | set-unlock on|off | enroll FINGER | "
|
||||
"remove FINGER | remove-all", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
|
||||
@@ -13,14 +13,17 @@ accountsservice over D-Bus from inside this process. It is never an argument:
|
||||
argv is world-readable through /proc, so a password -- or even its hash --
|
||||
passed that way is published to every process on the machine.
|
||||
|
||||
panama-accounts snapshot
|
||||
panama-accounts set-real-name USER NAME
|
||||
panama-accounts set-icon USER PATH [X Y SIZE]
|
||||
panama-accounts set-account-type USER standard|administrator
|
||||
panama-accounts set-automatic-login USER true|false
|
||||
panama-accounts set-password USER (new password on stdin)
|
||||
panama-accounts create-user USERNAME REALNAME standard|administrator
|
||||
panama-accounts delete-user USERNAME [keep-files|remove-files]
|
||||
panama-users snapshot
|
||||
panama-users stock-avatars
|
||||
panama-users set-real-name USER NAME
|
||||
panama-users set-icon USER PATH [X Y SIZE] (PATH "" clears the picture)
|
||||
panama-users set-account-type USER standard|administrator
|
||||
panama-users set-automatic-login USER true|false
|
||||
panama-users set-locked USER true|false
|
||||
panama-users set-password USER (new password on stdin)
|
||||
panama-users reset-password USER (no password material at all)
|
||||
panama-users create-user USERNAME REALNAME standard|administrator
|
||||
panama-users delete-user USERNAME keep|remove
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,6 +42,16 @@ USER_INTERFACE = "org.freedesktop.Accounts.User"
|
||||
# Account types as accountsservice numbers them.
|
||||
STANDARD, ADMINISTRATOR = 0, 1
|
||||
|
||||
# Password modes, likewise. 1 is "the account has no usable password and must
|
||||
# choose one at the next sign-in" -- which is why resetting a password here
|
||||
# involves no password at all, not even one this process saw for a moment.
|
||||
PASSWORD_MODE_SET_AT_LOGIN = 1
|
||||
|
||||
# Where a distribution keeps the pictures its login screen offers. Fedora ships
|
||||
# fifteen; a machine with none is normal and yields an empty list.
|
||||
STOCK_AVATAR_DIR = "/usr/share/pixmaps/faces"
|
||||
STOCK_AVATAR_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".svg")
|
||||
|
||||
USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
||||
|
||||
|
||||
@@ -137,6 +150,40 @@ def snapshot() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def stock_avatars() -> dict:
|
||||
"""The pictures the distribution ships, as {name, path} pairs.
|
||||
|
||||
Absolute paths, because the caller draws them straight from disk and then
|
||||
hands the same path back to set-icon. Sorted by name so the gallery does
|
||||
not reshuffle itself between openings.
|
||||
"""
|
||||
entries = []
|
||||
try:
|
||||
names = os.listdir(STOCK_AVATAR_DIR)
|
||||
except OSError:
|
||||
# No such directory is an ordinary machine, not a failure worth a row.
|
||||
names = []
|
||||
|
||||
for name in names:
|
||||
if not name.lower().endswith(STOCK_AVATAR_SUFFIXES):
|
||||
continue
|
||||
path = os.path.join(STOCK_AVATAR_DIR, name)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
entries.append({"name": _avatar_label(name), "path": path})
|
||||
|
||||
entries.sort(key=lambda entry: entry["name"].lower())
|
||||
return {"avatars": entries, "error": ""}
|
||||
|
||||
|
||||
def _avatar_label(filename: str) -> str:
|
||||
""""coffee2.jpg" -> "Coffee 2". A file name is not a caption, but it is the
|
||||
only thing these pictures carry, so it is tidied rather than invented."""
|
||||
stem = os.path.splitext(filename)[0]
|
||||
words = re.sub(r"(\d+)$", r" \1", stem.replace("-", " ").replace("_", " ")).strip()
|
||||
return words[:1].upper() + words[1:]
|
||||
|
||||
|
||||
def set_real_name(username: str, name: str) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
@@ -195,6 +242,14 @@ def crop_square(path: str, x: int, y: int, size: int) -> str:
|
||||
def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
# An empty path is how accountsservice is told to forget the picture: the
|
||||
# same call, with nothing in it. There is no separate "clear" method, and
|
||||
# inventing a verb for it here would only hide that.
|
||||
if path == "":
|
||||
call(user_path(username), USER_INTERFACE, "SetIconFile",
|
||||
GLib.Variant("(s)", ("",)))
|
||||
return
|
||||
|
||||
if not os.path.isfile(path):
|
||||
raise BoundaryError("That picture no longer exists.")
|
||||
|
||||
@@ -221,7 +276,21 @@ def set_account_type(username: str, kind: str) -> None:
|
||||
|
||||
if kind not in ("standard", "administrator"):
|
||||
raise BoundaryError("That is not an account type.")
|
||||
call(user_path(username), USER_INTERFACE, "SetAccountType",
|
||||
path = user_path(username)
|
||||
|
||||
# The same reason deleting the last administrator is refused: a machine
|
||||
# whose only administrator has just been demoted cannot be administered,
|
||||
# and the demotion itself is the last thing that needed authorization.
|
||||
if kind == "standard":
|
||||
state = snapshot()
|
||||
target = next((user for user in state["users"]
|
||||
if user["userName"] == username), None)
|
||||
if target is not None and target["administrator"] \
|
||||
and state["administratorCount"] <= 1:
|
||||
raise BoundaryError(
|
||||
"That is the only administrator; the machine would have none.")
|
||||
|
||||
call(path, USER_INTERFACE, "SetAccountType",
|
||||
GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,)))
|
||||
|
||||
|
||||
@@ -232,6 +301,29 @@ def set_automatic_login(username: str, enabled: bool) -> None:
|
||||
GLib.Variant("(b)", (enabled,)))
|
||||
|
||||
|
||||
def set_locked(username: str, locked: bool) -> None:
|
||||
"""Lock or unlock an account. A locked account cannot sign in at all, which
|
||||
is what someone is looking at when a user row says nothing works for them."""
|
||||
from gi.repository import GLib
|
||||
|
||||
call(user_path(username), USER_INTERFACE, "SetLocked",
|
||||
GLib.Variant("(b)", (locked,)))
|
||||
|
||||
|
||||
def reset_password(username: str) -> None:
|
||||
"""Require a new password at the next sign-in.
|
||||
|
||||
Deliberately not "set a password for them": no password is chosen, typed,
|
||||
hashed, or transmitted. accountsservice is told the account's password mode
|
||||
is "set at login", and the login screen collects the new one from the person
|
||||
who is going to use it.
|
||||
"""
|
||||
from gi.repository import GLib
|
||||
|
||||
call(user_path(username), USER_INTERFACE, "SetPasswordMode",
|
||||
GLib.Variant("(i)", (PASSWORD_MODE_SET_AT_LOGIN,)))
|
||||
|
||||
|
||||
def set_password(username: str) -> None:
|
||||
"""Set a new password, read from stdin and never named on a command line."""
|
||||
secret = sys.stdin.buffer.read()
|
||||
@@ -269,10 +361,17 @@ def create_user(username: str, real_name: str, kind: str) -> None:
|
||||
"(o)")
|
||||
|
||||
|
||||
# What to do with the home directory, spelled either way. "keep"/"remove" is
|
||||
# what the page says out loud; the longer pair is what this helper has always
|
||||
# taken, and callers older than the page still pass it.
|
||||
KEEP_FILES = ("keep", "keep-files")
|
||||
REMOVE_FILES = ("remove", "remove-files")
|
||||
|
||||
|
||||
def delete_user(username: str, files: str) -> None:
|
||||
from gi.repository import GLib
|
||||
|
||||
if files not in ("keep-files", "remove-files"):
|
||||
if files not in KEEP_FILES + REMOVE_FILES:
|
||||
raise BoundaryError("Say whether to keep or remove the home directory.")
|
||||
if username == (os.environ.get("USER") or ""):
|
||||
raise BoundaryError("You cannot delete the account you are signed in to.")
|
||||
@@ -285,7 +384,7 @@ def delete_user(username: str, files: str) -> None:
|
||||
raise BoundaryError("That is the only administrator; the machine would have none.")
|
||||
|
||||
call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser",
|
||||
GLib.Variant("(xb)", (target["uid"], files == "remove-files")))
|
||||
GLib.Variant("(xb)", (target["uid"], files in REMOVE_FILES)))
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
@@ -294,6 +393,12 @@ def main(arguments: list[str]) -> int:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
# Its own shape rather than a snapshot: this asks the filesystem what
|
||||
# pictures exist, which has nothing to do with who has an account.
|
||||
if arguments == ["stock-avatars"]:
|
||||
print(json.dumps(stock_avatars(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 3 and arguments[0] == "set-real-name":
|
||||
set_real_name(arguments[1], arguments[2])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-icon":
|
||||
@@ -308,19 +413,24 @@ def main(arguments: list[str]) -> int:
|
||||
set_account_type(arguments[1], arguments[2])
|
||||
elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
|
||||
set_automatic_login(arguments[1], arguments[2] == "true")
|
||||
elif len(arguments) == 3 and arguments[0] == "set-locked":
|
||||
set_locked(arguments[1], arguments[2] == "true")
|
||||
elif len(arguments) == 2 and arguments[0] == "set-password":
|
||||
set_password(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "reset-password":
|
||||
reset_password(arguments[1])
|
||||
elif len(arguments) == 4 and arguments[0] == "create-user":
|
||||
create_user(arguments[1], arguments[2], arguments[3])
|
||||
elif len(arguments) == 3 and arguments[0] == "delete-user":
|
||||
delete_user(arguments[1], arguments[2])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-accounts snapshot | set-real-name USER NAME | "
|
||||
"Usage: panama-users snapshot | stock-avatars | set-real-name USER NAME | "
|
||||
"set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | "
|
||||
"set-automatic-login USER true|false | set-password USER | "
|
||||
"set-automatic-login USER true|false | set-locked USER true|false | "
|
||||
"set-password USER | reset-password USER | "
|
||||
"create-user USERNAME REALNAME standard|administrator | "
|
||||
"delete-user USERNAME keep-files|remove-files")
|
||||
"delete-user USERNAME keep|remove")
|
||||
except BoundaryError as error:
|
||||
# Answers with the fresh state plus the message, so a page never has to
|
||||
# ask twice to find out what happened.
|
||||
|
||||
@@ -2,12 +2,22 @@ pragma Singleton
|
||||
|
||||
// The fingerprint reader, for the Users page.
|
||||
//
|
||||
// Two facts, owned by two different systems: fprintd holds the enrolled
|
||||
// prints (GNOME's Users panel owns the enrollment dialog and Panama hands off
|
||||
// to it), and authselect decides whether PAM asks the reader at unlock. Both
|
||||
// come through scripts/panama-fingerprint, and the one privileged change --
|
||||
// flipping authselect's with-fingerprint feature -- prompts through polkit
|
||||
// with a stated reason, like everything else on that page.
|
||||
// Two facts, owned by two different systems: fprintd holds the enrolled prints,
|
||||
// and authselect decides whether PAM asks the reader at unlock. Both come
|
||||
// through scripts/panama-fingerprint, and the one privileged change -- flipping
|
||||
// authselect's with-fingerprint feature -- prompts through polkit with a stated
|
||||
// reason, like everything else on that page.
|
||||
//
|
||||
// The two facts are kept apart deliberately. `unlockFeatureEnabled` is a
|
||||
// property of the PAM configuration and is reported whether or not a reader
|
||||
// exists, because the state worth seeing most is the one where the feature is
|
||||
// on and the reader is gone: nothing works, nothing says why, and the switch
|
||||
// that would fix it used to be hidden behind the missing hardware. Hence
|
||||
// `cardVisible`.
|
||||
//
|
||||
// Enrollment happens here now rather than in GNOME's Users panel. The helper
|
||||
// drives fprintd's own Claim/EnrollStart cycle and prints one line of JSON per
|
||||
// touch, which is what `enrollStage of enrollTotal` counts.
|
||||
//
|
||||
// Read when the page opens rather than at shell startup: probing fprintd
|
||||
// bus-activates the daemon, and most sessions never open this page.
|
||||
@@ -23,55 +33,134 @@ Singleton {
|
||||
|
||||
property bool readerPresent: false
|
||||
property string readerName: ""
|
||||
property var enrolled: []
|
||||
property bool pamEnabled: false
|
||||
// The fingers fprintd currently holds a print for, by fprintd's own names.
|
||||
property var fingers: []
|
||||
property bool unlockFeatureEnabled: false
|
||||
property bool scanned: false
|
||||
property bool busy: false
|
||||
property string lastError: ""
|
||||
|
||||
// Guards read the Process objects; this is only for the page to bind to.
|
||||
readonly property bool busy: apply.running || change.running
|
||||
|
||||
// The card is worth drawing when there is a reader OR when PAM has been
|
||||
// told to use one. The second half is the stuck state: no reader, nothing
|
||||
// enrolled, and a feature switched on that quietly slows every unlock down.
|
||||
readonly property bool cardVisible: root.readerPresent || root.unlockFeatureEnabled
|
||||
|
||||
// ── What a finger is called ──────────────────────────────────────────────
|
||||
//
|
||||
// fprintd's vocabulary lives here and only here, so nothing can disagree
|
||||
// about what a finger is named or how it reads.
|
||||
|
||||
readonly property var allFingers: [
|
||||
"right-index-finger", "right-middle-finger", "right-ring-finger",
|
||||
"right-little-finger", "right-thumb",
|
||||
"left-index-finger", "left-middle-finger", "left-ring-finger",
|
||||
"left-little-finger", "left-thumb"
|
||||
]
|
||||
|
||||
// The ones still worth offering: enrolling a finger twice replaces the
|
||||
// print rather than adding one, which is not what "Add a fingerprint" says.
|
||||
readonly property var availableFingers:
|
||||
root.allFingers.filter(finger => root.fingers.indexOf(finger) === -1)
|
||||
|
||||
// "right-index-finger" -> "Right index finger". Presentation lives here
|
||||
// rather than in the page, the way PowerProfiles.label does, so nothing
|
||||
// can disagree about what a finger is called.
|
||||
// rather than in the page, the way PowerProfiles.label does.
|
||||
function fingerLabel(finger: string): string {
|
||||
const words = String(finger).split("-").join(" ");
|
||||
return words.slice(0, 1).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
// ── Enrollment ───────────────────────────────────────────────────────────
|
||||
|
||||
property bool enrolling: false
|
||||
// Touches taken, of touches the reader wants. Both zero until the device
|
||||
// has said how many it needs.
|
||||
property int enrollStage: 0
|
||||
property int enrollTotal: 0
|
||||
// The finger being enrolled, and fprintd's last word about the last touch
|
||||
// ("enroll-stage-passed", "enroll-retry-scan-too-short", ...). The page
|
||||
// turns the retries into "Try again, a little slower".
|
||||
property string enrollFinger: ""
|
||||
property string enrollResult: ""
|
||||
// claiming | scanning | done | failed | cancelled
|
||||
property string enrollPhase: ""
|
||||
|
||||
function refresh(): void {
|
||||
if (!query.running)
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function setUnlockEnabled(on: bool): void {
|
||||
if (root.busy)
|
||||
if (apply.running)
|
||||
return;
|
||||
root.busy = true;
|
||||
root.lastError = "";
|
||||
apply.command = [root.helperPath, "set-unlock", on ? "on" : "off"];
|
||||
apply.running = true;
|
||||
}
|
||||
|
||||
function startEnroll(finger: string): void {
|
||||
if (enroll.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
root.enrollFinger = finger;
|
||||
root.enrollStage = 0;
|
||||
root.enrollTotal = 0;
|
||||
root.enrollResult = "";
|
||||
root.enrollPhase = "claiming";
|
||||
root.enrolling = true;
|
||||
enroll.command = [root.helperPath, "enroll", finger];
|
||||
enroll.running = true;
|
||||
}
|
||||
|
||||
// Stopping the process is the cancellation: the helper catches the signal,
|
||||
// stops the enrollment and releases the reader, which is the part that
|
||||
// matters -- a claimed device belonging to a dead process refuses the next
|
||||
// attempt.
|
||||
function cancelEnroll(): void {
|
||||
if (enroll.running)
|
||||
enroll.running = false;
|
||||
}
|
||||
|
||||
function removeFinger(finger: string): void {
|
||||
if (change.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
change.command = [root.helperPath, "remove", finger];
|
||||
change.running = true;
|
||||
}
|
||||
|
||||
function removeAll(): void {
|
||||
if (change.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
change.command = [root.helperPath, "remove-all"];
|
||||
change.running = true;
|
||||
}
|
||||
|
||||
// Every verb that reports state answers in the same shape, so there is one
|
||||
// place that reads it.
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.readerPresent = parsed.reader === true;
|
||||
root.readerName = String(parsed.readerName ?? "");
|
||||
root.fingers = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
|
||||
root.unlockFeatureEnabled = parsed.unlockFeatureEnabled === true;
|
||||
if (String(parsed.error ?? "") !== "")
|
||||
root.lastError = String(parsed.error);
|
||||
} catch (error) {
|
||||
root.readerPresent = false;
|
||||
root.lastError = "Could not read the fingerprint helper's output.";
|
||||
console.warn("Fingerprint: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: query
|
||||
command: [root.helperPath, "status"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.readerPresent = parsed.reader === true;
|
||||
root.readerName = String(parsed.readerName ?? "");
|
||||
root.enrolled = Array.isArray(parsed.enrolled) ? parsed.enrolled : [];
|
||||
root.pamEnabled = parsed.pamEnabled === true;
|
||||
if (String(parsed.error ?? "") !== "")
|
||||
root.lastError = String(parsed.error);
|
||||
} catch (error) {
|
||||
root.readerPresent = false;
|
||||
root.lastError = "Could not read the fingerprint helper's output.";
|
||||
console.warn("Fingerprint: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
}
|
||||
|
||||
Process {
|
||||
@@ -87,8 +176,42 @@ Singleton {
|
||||
}
|
||||
// Re-read rather than assuming: authselect may refuse, and the
|
||||
// prompt may have been dismissed.
|
||||
onExited: root.refresh()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: change
|
||||
// Removing answers with the fresh state, so the list updates from the
|
||||
// removal itself and never has to ask again.
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
}
|
||||
|
||||
Process {
|
||||
id: enroll
|
||||
stdout: SplitParser {
|
||||
// One JSON object per touch. A reader takes five or so, seconds
|
||||
// apart, so this is a trickle rather than a stream to drain.
|
||||
onRead: line => {
|
||||
try {
|
||||
const update = JSON.parse(line);
|
||||
root.enrollPhase = String(update.stage ?? root.enrollPhase);
|
||||
root.enrollTotal = Number(update.total ?? root.enrollTotal);
|
||||
root.enrollStage = Number(update.done ?? root.enrollStage);
|
||||
root.enrollResult = String(update.result ?? "");
|
||||
if (update.ok === false)
|
||||
root.lastError = String(update.error ?? "That finger could not be enrolled.");
|
||||
} catch (error) {
|
||||
// A line that is not JSON is not worth abandoning an
|
||||
// enrollment over; the exit status is what decides.
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
root.busy = false;
|
||||
root.enrolling = false;
|
||||
// Cancelled by stopping the process: the helper had no chance to
|
||||
// say anything, and there is nothing to report.
|
||||
if (root.enrollPhase !== "done" && root.enrollPhase !== "failed")
|
||||
root.enrollPhase = "cancelled";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,19 @@ pragma Singleton
|
||||
// The daemon already runs in this session -- gvfs activates it, and accounts
|
||||
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
|
||||
// are D-Bus objects anything may read and modify. So listing, per-service
|
||||
// toggles, and removal all happen here, natively.
|
||||
// toggles, removal, and adding a password account all happen here, natively.
|
||||
//
|
||||
// Signing in is the exception, and only for OAuth providers. The daemon's
|
||||
// AddAccount takes credentials as an argument rather than obtaining them, and
|
||||
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
|
||||
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
|
||||
// panel and the user comes straight back here.
|
||||
// Signing in is the exception, and only for OAuth providers. GOA's AddAccount
|
||||
// takes credentials as an argument rather than obtaining them, which is exactly
|
||||
// what a Nextcloud or IMAP form can supply; what it cannot supply is a Google
|
||||
// token, because the code that runs that exchange lives in libgoa-backend,
|
||||
// which Fedora ships without a GIR binding. So that one step is handed to
|
||||
// GNOME's panel and the user comes straight back here.
|
||||
//
|
||||
// `available` means GOA answered the last time it was asked, and nothing else.
|
||||
// It used to mean "lastError is empty", so a toggle GOA refused turned the
|
||||
// whole page into "Online Accounts is not available" and hid the four working
|
||||
// accounts behind it. A write that failed is a row; it is not the page.
|
||||
//
|
||||
// Read on demand and after every change: accounts are added and removed by
|
||||
// people, not by the system, so there is nothing to poll for.
|
||||
@@ -29,68 +35,141 @@ Singleton {
|
||||
// services: [{key,label,enabled}] }]
|
||||
property var accounts: []
|
||||
property bool scanned: false
|
||||
property bool busy: false
|
||||
property string lastError: ""
|
||||
|
||||
// Whether GOA answered the last snapshot. True to begin with because
|
||||
// nothing has said otherwise yet; `scanned` is what says whether anything
|
||||
// has been asked at all.
|
||||
property bool available: true
|
||||
|
||||
// Kept apart on purpose. The first is why the page might be empty; the
|
||||
// second is why one thing someone just did did not happen. Only the first
|
||||
// has any business deciding whether the page works.
|
||||
property string snapshotError: ""
|
||||
property string writeError: ""
|
||||
|
||||
readonly property string lastError:
|
||||
root.writeError !== "" ? root.writeError : root.snapshotError
|
||||
|
||||
// Guards read the Process objects; this is only for the page to bind to,
|
||||
// so rows can go quiet while a change is in flight.
|
||||
readonly property bool busy: list.running || write.running || add.running
|
||||
|
||||
// Accounts whose stored credentials have stopped working -- an expired
|
||||
// token, a changed password. GOA knows, and nothing outside its own panel
|
||||
// ever says so, which is how an account quietly stops syncing for weeks.
|
||||
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
|
||||
|
||||
readonly property bool available: root.lastError === ""
|
||||
|
||||
// Cheap enough for every page open: one short-lived process reading D-Bus
|
||||
// objects that are already in memory.
|
||||
function refresh(): void {
|
||||
if (!list.running)
|
||||
list.running = true;
|
||||
}
|
||||
|
||||
function absorb(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
|
||||
root.snapshotError = String(parsed.error ?? "");
|
||||
root.available = root.snapshotError === "";
|
||||
} catch (error) {
|
||||
root.accounts = [];
|
||||
root.snapshotError = "Could not read the accounts helper's output.";
|
||||
root.available = false;
|
||||
console.warn("OnlineAccounts: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
|
||||
// Enabling a service clears GOA's "disabled" flag; the helper owns that
|
||||
// inversion so the UI can speak in terms of what is on.
|
||||
function setService(path: string, service: string, enabled: bool): void {
|
||||
if (root.busy)
|
||||
if (write.running)
|
||||
return;
|
||||
root.busy = true;
|
||||
root.writeError = "";
|
||||
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
|
||||
write.running = true;
|
||||
}
|
||||
|
||||
function remove(path: string): void {
|
||||
if (root.busy)
|
||||
if (write.running)
|
||||
return;
|
||||
root.busy = true;
|
||||
root.writeError = "";
|
||||
write.command = [root.helperPath, "remove", path];
|
||||
write.running = true;
|
||||
}
|
||||
|
||||
// ── Adding a password account ────────────────────────────────────────────
|
||||
//
|
||||
// The password goes to the helper's stdin and nowhere else: never an
|
||||
// argument, because argv is readable by every process on this machine.
|
||||
// It is written from onStarted, because a process has no stdin to write to
|
||||
// until it is running -- the same pattern UserAccounts uses for a new
|
||||
// password, and HomeAssistantConfig for its token.
|
||||
property string pendingPassword: ""
|
||||
|
||||
function addNextcloud(server: string, user: string, password: string): void {
|
||||
root.beginAdd(["add-nextcloud", server, user], password);
|
||||
}
|
||||
|
||||
function addImap(email: string, imapHost: string, smtpHost: string,
|
||||
user: string, password: string): void {
|
||||
root.beginAdd(["add-imap", email, imapHost, smtpHost, user], password);
|
||||
}
|
||||
|
||||
function beginAdd(arguments: var, password: string): void {
|
||||
if (add.running)
|
||||
return;
|
||||
root.writeError = "";
|
||||
root.pendingPassword = password;
|
||||
add.command = [root.helperPath].concat(arguments);
|
||||
add.stdinEnabled = true;
|
||||
add.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: list
|
||||
command: [root.helperPath, "list"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.accounts = [];
|
||||
root.lastError = "Could not read the accounts helper's output.";
|
||||
console.warn("OnlineAccounts: could not parse helper output:", error);
|
||||
}
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
command: [root.helperPath, "snapshot"]
|
||||
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||
}
|
||||
|
||||
Process {
|
||||
id: write
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
|
||||
}
|
||||
// Re-read rather than assuming the write landed: GOA may refuse, and a
|
||||
// toggle that sprang back is the honest outcome.
|
||||
onExited: {
|
||||
root.busy = false;
|
||||
root.refresh();
|
||||
onExited: root.refresh()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: add
|
||||
stdinEnabled: true
|
||||
onStarted: {
|
||||
add.write(root.pendingPassword + "\n");
|
||||
// Held for as long as it takes to hand over, and no longer.
|
||||
root.pendingPassword = "";
|
||||
add.stdinEnabled = false;
|
||||
}
|
||||
// The helper answers with the fresh account list plus whatever went
|
||||
// wrong, so a successful add lands on the page without a second read.
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : root.accounts;
|
||||
// An add that failed says why here. It is a write error:
|
||||
// GOA answered, so the page is still working.
|
||||
root.writeError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.writeError = "Could not read the accounts helper's output.";
|
||||
}
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim();
|
||||
}
|
||||
onExited: root.pendingPassword = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,15 @@ Singleton {
|
||||
{ label: "Add a user", detail: "Create another account on this machine", page: "users" },
|
||||
{ label: "Automatic login", detail: "Sign in without typing a password", page: "users" },
|
||||
{ label: "Administrator", detail: "Which accounts can manage this machine", page: "users" },
|
||||
// Users owns fingerprint enrollment now rather than handing it to
|
||||
// GNOME's panel, and it owns the other-account verbs it used to bury
|
||||
// inside an expanded row. Each of those is something people come
|
||||
// looking for by name, so each gets a name to be found by.
|
||||
{ label: "Fingerprint", detail: "Unlock and authorize with the reader on this machine", page: "users" },
|
||||
{ label: "Enroll a fingerprint", detail: "Record a finger, one touch at a time", page: "users" },
|
||||
{ label: "Delete a user", detail: "Remove an account, keeping or destroying its files", page: "users" },
|
||||
{ label: "Account type", detail: "Whether an account may administer this machine", page: "users" },
|
||||
{ label: "Reset a password", detail: "They set a new one at their next sign-in", page: "users" },
|
||||
{ label: "Printers", detail: "Add a printer and see what is queued", page: "printers" },
|
||||
{ label: "Print queue", detail: "What is waiting to print, and cancelling it", page: "printers" },
|
||||
{ label: "Add a printer", detail: "Find a printer on the network or enter its address", page: "printers" },
|
||||
@@ -170,6 +179,14 @@ Singleton {
|
||||
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
|
||||
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
|
||||
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
|
||||
// Adding an account is the thing people search for, and they search for
|
||||
// it by the name of the service. Nextcloud and mail are added on the
|
||||
// page itself; Google still goes through the provider's own sign-in,
|
||||
// which is a result worth having rather than a dead end.
|
||||
{ label: "Nextcloud", detail: "Add a Nextcloud server for files, calendar, and contacts", page: "accounts" },
|
||||
{ label: "Google account", detail: "Sign in to Google for mail, calendar, and contacts", page: "accounts" },
|
||||
{ label: "Add a mail account", detail: "Connect a mailbox by its IMAP and SMTP servers", page: "accounts" },
|
||||
{ label: "Remove an account", detail: "Sign out and take an online account off this machine", page: "accounts" },
|
||||
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
|
||||
{ label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" },
|
||||
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
|
||||
|
||||
@@ -25,6 +25,12 @@ Singleton {
|
||||
property bool scanned: false
|
||||
property string lastError: ""
|
||||
|
||||
// The pictures the distribution ships, as [{name, path}]. Read once and
|
||||
// kept: it is a directory listing that does not change while a session is
|
||||
// running, and the gallery it fills is opened and closed repeatedly.
|
||||
property var stockAvatars: []
|
||||
property bool stockAvatarsLoaded: false
|
||||
|
||||
// Guards read the Process objects rather than a derived "busy" binding. A
|
||||
// binding hands back its cached value inside the handler that changes it,
|
||||
// which silently turns a refresh after a successful change into a no-op --
|
||||
@@ -115,7 +121,27 @@ Singleton {
|
||||
String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]);
|
||||
}
|
||||
|
||||
function setAccountType(userName: string, kind: string): void {
|
||||
// Clearing the picture is the same call with nothing in it -- there is no
|
||||
// separate method for it in accountsservice, and there is none here either.
|
||||
// No user name: this is the hero card's own avatar, and an account you are
|
||||
// not signed in to has no avatar surface to remove it from.
|
||||
function removeIcon(): void {
|
||||
root.settingIcon = true;
|
||||
root.run(["set-icon", root.currentUser, ""]);
|
||||
}
|
||||
|
||||
// Called when the gallery is about to be shown, not at startup: most
|
||||
// sessions never open it, and it is a directory listing either way.
|
||||
function loadStockAvatars(): void {
|
||||
if (root.stockAvatarsLoaded || stock.running)
|
||||
return;
|
||||
stock.running = true;
|
||||
}
|
||||
|
||||
// Any account, including one that is not signed in. The helper keeps the
|
||||
// last-administrator refusal, so a page that forgets the guard still
|
||||
// cannot leave the machine unadministrable.
|
||||
function setAccountTypeFor(userName: string, kind: string): void {
|
||||
root.run(["set-account-type", userName, kind]);
|
||||
}
|
||||
|
||||
@@ -123,12 +149,28 @@ Singleton {
|
||||
root.run(["set-automatic-login", userName, enabled ? "true" : "false"]);
|
||||
}
|
||||
|
||||
// Locked accounts cannot sign in at all. Unlocking is the only half of this
|
||||
// the page offers, because locking someone out is not a settings gesture.
|
||||
function setLocked(userName: string, locked: bool): void {
|
||||
root.run(["set-locked", userName, locked ? "true" : "false"]);
|
||||
}
|
||||
|
||||
// No password of any kind is involved: accountsservice is told the account
|
||||
// must choose one at the next sign-in, and the login screen collects it
|
||||
// from the person who will use it.
|
||||
function resetPassword(userName: string): void {
|
||||
root.run(["reset-password", userName]);
|
||||
}
|
||||
|
||||
function createUser(userName: string, realName: string, kind: string): void {
|
||||
root.run(["create-user", userName, realName, kind]);
|
||||
}
|
||||
|
||||
function deleteUser(userName: string, removeFiles: bool): void {
|
||||
root.run(["delete-user", userName, removeFiles ? "remove-files" : "keep-files"]);
|
||||
// keepFiles, not removeFiles: the page asks "Keep the files" or "Remove
|
||||
// everything", and a service that inverted the sentence on its way to the
|
||||
// helper is how the destructive answer gets chosen by accident.
|
||||
function deleteUser(userName: string, keepFiles: bool): void {
|
||||
root.run(["delete-user", userName, keepFiles ? "keep" : "remove"]);
|
||||
}
|
||||
|
||||
// The password goes to the helper's stdin and nowhere else: never an
|
||||
@@ -177,6 +219,25 @@ Singleton {
|
||||
onExited: root.pendingPassword = ""
|
||||
}
|
||||
|
||||
Process {
|
||||
id: stock
|
||||
command: [root.helperPath, "stock-avatars"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.stockAvatars = Array.isArray(parsed.avatars) ? parsed.avatars : [];
|
||||
} catch (error) {
|
||||
// A machine with no gallery is normal; an empty one reads
|
||||
// the same to the page, and nothing else here depends on it.
|
||||
root.stockAvatars = [];
|
||||
console.warn("Accounts: could not read the stock avatars:", error);
|
||||
}
|
||||
root.stockAvatarsLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mutation
|
||||
// The helper answers with the fresh state, so the page updates from the
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Online Accounts in Settings.
|
||||
# @vicinae.keywords ["settings", "online accounts"]
|
||||
# @vicinae.keywords ["settings", "online accounts", "nextcloud", "google account", "add a mail account", "remove an account"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accounts
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Users in Settings.
|
||||
# @vicinae.keywords ["settings", "user account", "profile picture", "change password", "add a user", "automatic login", "administrator"]
|
||||
# @vicinae.keywords ["settings", "user account", "profile picture", "change password", "add a user", "automatic login", "administrator", "fingerprint", "enroll a fingerprint", "delete a user", "account type", "reset a password"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page users
|
||||
|
||||
Reference in New Issue
Block a user