Files
Panama/config/dot/quickshell/modules/settings/UsersPage.qml
T

872 lines
37 KiB
QML

// Your account, and anyone else who signs in to this machine.
//
// The layout puts your own account first because that is what someone opens
// this page for, and the avatar leads because it is the thing that shows up
// elsewhere in the desktop -- the Control Center draws it, and the lock screen
// and login screen read the same file.
//
// 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
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "users"
title: "Users"
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 "";
if (root.newPassword.length < 6)
return "Use at least six characters.";
if (root.confirmPassword !== "" && root.newPassword !== root.confirmPassword)
return "The two entries do not match.";
return "";
}
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: {
if (!UserAccounts.scanned)
UserAccounts.refresh();
// Probing fprintd bus-activates it, so it waits for the page rather
// than costing every shell launch.
if (!Fingerprint.scanned)
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.
ErrorRow {
label: "That change did not go through"
message: UserAccounts.lastError
}
// ── You ──────────────────────────────────────────────────────────────────
// Chosen but not yet framed. While this is set the cropper replaces the
// account card, because framing is a decision to finish, not a setting to
// leave half-made.
property string pendingPicture: ""
SettingsCard {
visible: root.pendingPicture !== ""
title: "Frame the picture"
AvatarCropper {
width: parent.width
source: root.pendingPicture
onCropped: (x, y, size) => {
UserAccounts.setIconCropped(String(root.me?.userName ?? ""),
root.pendingPicture, x, y, size);
root.pendingPicture = "";
}
onCancelled: root.pendingPicture = ""
}
}
SettingsCard {
visible: root.me !== null && root.pendingPicture === ""
Row {
width: parent.width
spacing: 20
Item {
width: 96
height: 96
ClippingRectangle {
anchors.fill: parent
radius: width / 2
color: Theme.alpha(Theme.fg, 0.08)
Image {
anchors.fill: parent
source: UserAccounts.avatarUrl
visible: UserAccounts.avatarUrl !== ""
fillMode: Image.PreserveAspectCrop
// accountsservice replaces the file in place, so the
// path never changes. cache:false is not enough on its
// own -- an unchanged source is never re-read at all --
// which is why avatarUrl carries a revision fragment.
cache: false
asynchronous: true
sourceSize.width: 192
sourceSize.height: 192
}
Text {
anchors.centerIn: parent
visible: UserAccounts.avatarUrl === ""
text: UserAccounts.displayName(root.me).slice(0, 1).toUpperCase()
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 38
font.weight: Font.DemiBold
}
}
}
Column {
width: parent.width - 116
spacing: 4
anchors.verticalCenter: parent.verticalCenter
Text {
text: UserAccounts.displayName(root.me)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
text: String(root.me?.userName ?? "") + " · "
+ (root.me?.administrator ? "Administrator" : "Standard account")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Item { width: 1; height: 6 }
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;
}
}
ConfirmAction {
id: removePicture
visible: UserAccounts.avatarUrl !== ""
actionId: "remove-avatar"
armText: "Remove…"
confirmText: "Remove it"
enabled: !UserAccounts.busy
onConfirmed: {
root.pictureOpen = false;
UserAccounts.removeIcon();
}
}
}
// The consequence, said before the confirming press: this
// deletes the file accountsservice keeps, so the picture is
// gone from the lock screen and every other account surface,
// not merely from this page.
Text {
width: parent.width
visible: removePicture.armed
text: "The picture is deleted from your account — the lock screen and everywhere else showing it fall back to your initial."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
}
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 && root.pendingPicture === ""
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)
}
TextRow {
label: "Username"
detail: "Fixed when the account was created, because files and permissions are keyed to it"
value: String(root.me?.userName ?? "")
}
SegmentRow {
label: "Account type"
detail: root.me?.administrator && UserAccounts.administratorCount <= 1
? "The last administrator cannot step down"
: "Administrators can install software and manage other accounts"
options: [
{ value: "standard", label: "Standard" },
{ value: "administrator", label: "Administrator" }
]
value: root.me?.administrator ? "administrator" : "standard"
enabled: !UserAccounts.busy
&& !(root.me?.administrator && UserAccounts.administratorCount <= 1)
onSelected: value => UserAccounts.setAccountType(String(root.me?.userName ?? ""), value)
}
ActionRow {
label: "Password"
detail: root.openPanel === "password"
? "Changing it asks for authorization first"
: "Change the password used to sign in and to unlock the screen"
action: root.openPanel === "password" ? "Cancel" : "Change…"
enabled: !UserAccounts.busy
divider: root.openPanel === "password"
onTriggered: {
const wasOpen = root.openPanel === "password";
root.closePanels();
if (!wasOpen)
root.openPanel = "password";
}
}
Column {
width: parent.width
visible: root.openPanel === "password"
SecretFieldRow {
id: newPasswordField
width: parent.width
label: "New password"
detail: "At least six characters"
enabled: !UserAccounts.busy
onChanged: value => root.newPassword = value
}
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
}
ActionRow {
width: parent.width
label: "Set this password"
detail: "You will be asked to authorize the change"
action: "Set password"
enabled: root.passwordReady && !UserAccounts.busy
divider: false
onTriggered: {
UserAccounts.setPassword(String(root.me?.userName ?? ""), root.newPassword);
root.closePanels();
}
}
}
SwitchRow {
label: "Automatic login"
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
onToggled: value => UserAccounts.setAutomaticLogin(String(root.me?.userName ?? ""), value)
}
}
// ── Fingerprint ──────────────────────────────────────────────────────────
//
// 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 {
// `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.readerPresent && Fingerprint.readerName !== ""
? Fingerprint.readerName
: ""
// 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
StatusBadge {
anchors.verticalCenter: parent.verticalCenter
text: "No reader"
tone: Theme.warn
}
SettingsButton {
text: "Turn off"
enabled: !Fingerprint.busy
onClicked: Fingerprint.setUnlockEnabled(false)
}
}
}
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)
}
SectionLabel { text: "Enrolled fingers" }
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()
}
}
ErrorRow {
label: "The reader had something to say"
message: Fingerprint.lastError
}
}
// ── Everyone else ────────────────────────────────────────────────────────
SettingsCard {
title: "Other accounts"
subtitle: UserAccounts.others.length === 0
? "Only your account exists on this machine."
: UserAccounts.others.length + " other account"
+ (UserAccounts.others.length === 1 ? "" : "s")
Repeater {
model: UserAccounts.others
delegate: Column {
id: otherBlock
required property var modelData
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
// The real path, from accountsservice, rather than the home root
// joined to the account name. Every sentence below used to
// construct that guess, the armed confirmation for deleting the
// files among them — so a home that had been moved was named
// confidently and wrongly exactly where being wrong costs the
// most. When no path is reported, the sentences name the
// directory in words rather than inventing one.
readonly property string homePath:
UserAccounts.homeDirectory(otherBlock.modelData)
readonly property bool homeKnown: otherBlock.homePath !== ""
readonly property string homePhrase: otherBlock.homeKnown
? otherBlock.homePath : "their home directory"
readonly property string homeSubject: otherBlock.homeKnown
? otherBlock.homePath : "Their home directory"
SettingRow {
width: otherBlock.width
label: UserAccounts.displayName(otherBlock.modelData)
detail: otherBlock.userName + " · "
+ (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";
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: otherBlock.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Column {
width: otherBlock.width
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 " + otherBlock.homePhrase
options: [
{
value: "keep",
label: "Keep the files",
detail: otherBlock.homeSubject
+ " 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 — " + otherBlock.homePhrase
+ " and everything in it is deleted along with the account.";
return "Their files stay in " + otherBlock.homePhrase
+ ", 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 }
}
}
ActionRow {
label: "Add an account"
detail: "Creating an account asks for authorization first"
action: root.openPanel === "newUser" ? "Cancel" : "Add…"
enabled: !UserAccounts.busy
divider: root.openPanel === "newUser"
onTriggered: {
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"
LiveFieldRow {
width: parent.width
label: "Full name"
placeholder: "Their name"
detail: "Shown on the login screen"
text: root.newRealName
enabled: !UserAccounts.busy
onEdited: value => root.newRealName = value
}
LiveFieldRow {
width: parent.width
label: "Username"
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
enabled: !UserAccounts.busy
onEdited: value => root.newUserName = value
}
SegmentRow {
width: parent.width
label: "Account type"
detail: "Standard accounts cannot install software or manage other accounts"
options: [
{ value: "standard", label: "Standard" },
{ value: "administrator", label: "Administrator" }
]
value: root.newUserIsAdministrator ? "administrator" : "standard"
enabled: !UserAccounts.busy
onSelected: value => root.newUserIsAdministrator = value === "administrator"
}
ActionRow {
width: parent.width
label: "Create the account"
detail: "They set their own password the first time they sign in"
action: "Create"
// 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,
root.newUserIsAdministrator ? "administrator" : "standard");
root.closePanels();
}
}
}
}
// Picking a picture goes through the desktop portal, which is the same
// chooser every other application gets and needs no privilege of its own.
AvatarPicker {
id: avatarPicker
onPicked: path => root.pendingPicture = path
}
}