251 lines
9.9 KiB
QML
251 lines
9.9 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 ?? "");
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|