Files

262 lines
10 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: ""
// 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 --
// 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 ?? "");
}
// The home directory accountsservice reports for an account, which is not
// reliably "/home/" + userName: a home can be moved, or live on another
// mount entirely. Empty when the helper reported none -- and empty is the
// caller's cue to name the directory in words rather than to construct a
// path, because a guessed path inside the confirmation for an irreversible
// deletion is the app being most confident about the one thing it does not
// actually know.
function homeDirectory(user: var): string {
return String(user?.homeDirectory ?? "").trim();
}
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))]);
}
// 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]);
}
function setAutomaticLogin(userName: string, enabled: bool): void {
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]);
}
// 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
// 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: 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
// 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()
}
}
}