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() } } }