Files
Panama/config/dot/quickshell/services/UserAccounts.qml
T
Gabriel Brown 1aa1324083 Lead Appearance with light and dark, and stop pages listing whole datasets
Appearance was six cards deep and Light/Dark was the third of them, below the
wallpaper grid and the entire lock screen -- so the control reached most often
was the last one you got to. It is five tabs now, Theme first. The mock showed
four; the page turned out to have eleven cards, so Titlebars and Windows became
Windows, and Clock and vitals became Shell, rather than pretending four would
hold them.

Region, Date & Time and Displays each rendered a complete dataset as rows: every
installed locale, the whole tz database, every mode the monitor advertises. The
chooser was never the problem -- SearchPicker already existed and worked. It was
simply rendered always-expanded, so the one line saying what is currently set sat
under hundreds that were not. PickerRow collapses each behind its current value
and closes again once something is picked.

The avatar never appeared to change because accountsservice writes every picture
to the same path, leaving the URL byte-identical while Qt served its cached
image. cache:false was already set and could not have helped: an unchanged source
is never re-read at all. avatarUrl now carries a revision fragment, bumped only
when a write actually succeeds. Pictures are cropped before they are set, in the
picture's own pixel coordinates so the result does not depend on the size it
happened to be displayed at, and written out at 512x512 through GdkPixbuf --
already a dependency here, so nothing new is required.

Snapshots listed nothing. The timeline and its Delete buttons existed the whole
time, behind a row labelled "Browse...", a word that promises a file browser. The
three most recent points are shown inline now, with the rest one press away.

qmldir-registration-contract exists because an unregistered component is not a
quiet problem: Quickshell fails the entire configuration on it, so the settings
window dies and the bar and dock go with it. That happened twice while writing
this, both times on a machine somebody was using. It is pure file inspection, so
it runs before a change ever reaches the running shell.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:41 -04:00

190 lines
7.2 KiB
QML

pragma Singleton
// User accounts, through accountsservice -- the daemon GNOME's Users panel
// drives, so what this changes is what every other login surface reads.
//
// Every change is authorized by polkit, which prompts through the agent this
// session already runs. A refused prompt is a normal outcome, not an error to
// apologize for, and it comes back as a plain sentence.
//
// No password ever crosses this file. Setting one writes it to the helper's
// stdin, which is the only path that keeps it off a command line.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-users"
property var users: []
property string currentUser: ""
property int administratorCount: 0
property bool scanned: false
property string lastError: ""
// 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 --
// the write lands and the page never notices. See DefaultApps.qml.
readonly property bool busy: query.running || mutation.running || passwordWrite.running
readonly property var me: {
for (const user of root.users) {
if (user.userName === root.currentUser)
return user;
}
return root.users.length > 0 ? root.users[0] : null;
}
readonly property var others: root.users.filter(user => user.userName !== root.currentUser)
// Bumped once a picture has actually been set, and only then.
//
// accountsservice writes every avatar to the same path, so choosing a new
// picture leaves iconFile byte-identical and the URL never changes. Qt keys
// its image cache on that URL, so the old picture stayed on screen and the
// page looked broken while the write had in fact succeeded. The fragment
// moves the cache key without changing the file the URL resolves to.
property int iconRevision: 0
// Set while a picture is being written, so the revision is bumped for a
// genuine change rather than on every refresh that happens to pass through.
property bool settingIcon: false
// The avatar, as a URL the shell can draw, or "" when none is set.
readonly property string avatarUrl: root.me && String(root.me.iconFile ?? "") !== ""
? "file://" + root.me.iconFile + "#v" + root.iconRevision
: ""
function displayName(user: var): string {
const real = String(user?.realName ?? "").trim();
return real !== "" ? real : String(user?.userName ?? "");
}
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.users = Array.isArray(parsed.users) ? parsed.users : [];
root.currentUser = String(parsed.currentUser ?? "");
root.administratorCount = Number(parsed.administratorCount ?? 0);
root.lastError = String(parsed.error ?? "");
if (root.settingIcon) {
root.settingIcon = false;
if (root.lastError === "")
root.iconRevision += 1;
}
} catch (error) {
root.lastError = "Could not read the account service's answer.";
console.warn("Accounts: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
function setRealName(userName: string, name: string): void {
root.run(["set-real-name", userName, name]);
}
function setIcon(userName: string, path: string): void {
root.settingIcon = true;
root.run(["set-icon", userName, path]);
}
// The same, from a square chosen in the picture's own pixels.
function setIconCropped(userName: string, path: string,
x: int, y: int, size: int): void {
root.settingIcon = true;
root.run(["set-icon", userName, path,
String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]);
}
function setAccountType(userName: string, kind: string): void {
root.run(["set-account-type", userName, kind]);
}
function setAutomaticLogin(userName: string, enabled: bool): void {
root.run(["set-automatic-login", userName, enabled ? "true" : "false"]);
}
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"]);
}
// 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 rather than here, because a process has no
// stdin to write to until it is actually running. The same pattern
// HomeAssistantConfig uses for its token.
property string pendingPassword: ""
function setPassword(userName: string, password: string): void {
if (passwordWrite.running)
return;
root.lastError = "";
root.pendingPassword = password;
passwordWrite.command = [root.helperPath, "set-password", userName];
passwordWrite.running = true;
}
// Self-initializing: the Control Center draws the avatar too, and a
// singleton is constructed on first use, so whichever surface asks first
// gets a populated service without having to know to ask.
Component.onCompleted: root.refresh()
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: passwordWrite
stdinEnabled: true
onStarted: {
passwordWrite.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
passwordWrite.stdinEnabled = false;
}
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.pendingPassword = ""
}
Process {
id: mutation
// The helper answers with the fresh state, so the page updates from the
// mutation itself and never has to ask again.
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
}