pragma Singleton // Online accounts, via GNOME Online Accounts. // // The daemon already runs in this session -- gvfs activates it, and accounts // work without gnome-shell anywhere. Only the panel was GNOME's; the accounts // are D-Bus objects anything may read and modify. So listing, per-service // toggles, removal, and adding a password account all happen here, natively. // // Signing in is the exception, and only for OAuth providers. GOA's AddAccount // takes credentials as an argument rather than obtaining them, which is exactly // what a Nextcloud or IMAP form can supply; what it cannot supply is a Google // token, because the code that runs that exchange lives in libgoa-backend, // which Fedora ships without a GIR binding. So that one step is handed to // GNOME's panel and the user comes straight back here. // // `available` means GOA answered the last time it was asked, and nothing else. // It used to mean "lastError is empty", so a toggle GOA refused turned the // whole page into "Online Accounts is not available" and hid the four working // accounts behind it. A write that failed is a row; it is not the page. // // Read on demand and after every change: accounts are added and removed by // people, not by the system, so there is nothing to poll for. import Quickshell import Quickshell.Io import QtQuick Singleton { id: root readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts" // [{ path, provider, providerName, providerIcons, identity, needsAttention, // services: [{key,label,enabled}] }] property var accounts: [] property bool scanned: false // Whether GOA answered the last snapshot. True to begin with because // nothing has said otherwise yet; `scanned` is what says whether anything // has been asked at all. property bool available: true // Kept apart on purpose. The first is why the page might be empty; the // second is why one thing someone just did did not happen. Only the first // has any business deciding whether the page works. property string snapshotError: "" property string writeError: "" readonly property string lastError: root.writeError !== "" ? root.writeError : root.snapshotError // Guards read the Process objects; this is only for the page to bind to, // so rows can go quiet while a change is in flight. readonly property bool busy: list.running || write.running || add.running // Accounts whose stored credentials have stopped working -- an expired // token, a changed password. GOA knows, and nothing outside its own panel // ever says so, which is how an account quietly stops syncing for weeks. readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length // Cheap enough for every page open: one short-lived process reading D-Bus // objects that are already in memory. function refresh(): void { if (!list.running) list.running = true; } function absorb(text: string): void { try { const parsed = JSON.parse(text); root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : []; root.snapshotError = String(parsed.error ?? ""); root.available = root.snapshotError === ""; } catch (error) { root.accounts = []; root.snapshotError = "Could not read the accounts helper's output."; root.available = false; console.warn("OnlineAccounts: could not parse helper output:", error); } root.scanned = true; } // Enabling a service clears GOA's "disabled" flag; the helper owns that // inversion so the UI can speak in terms of what is on. function setService(path: string, service: string, enabled: bool): void { if (write.running) return; root.writeError = ""; write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"]; write.running = true; } function remove(path: string): void { if (write.running) return; root.writeError = ""; write.command = [root.helperPath, "remove", path]; write.running = true; } // ── Adding a password account ──────────────────────────────────────────── // // 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, because a process has no stdin to write to // until it is running -- the same pattern UserAccounts uses for a new // password, and HomeAssistantConfig for its token. property string pendingPassword: "" function addNextcloud(server: string, user: string, password: string): void { root.beginAdd(["add-nextcloud", server, user], password); } function addImap(email: string, imapHost: string, smtpHost: string, user: string, password: string): void { root.beginAdd(["add-imap", email, imapHost, smtpHost, user], password); } function beginAdd(arguments: var, password: string): void { if (add.running) return; root.writeError = ""; root.pendingPassword = password; add.command = [root.helperPath].concat(arguments); add.stdinEnabled = true; add.running = true; } Process { id: list command: [root.helperPath, "snapshot"] stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } } Process { id: write stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim(); } // Re-read rather than assuming the write landed: GOA may refuse, and a // toggle that sprang back is the honest outcome. onExited: root.refresh() } Process { id: add stdinEnabled: true onStarted: { add.write(root.pendingPassword + "\n"); // Held for as long as it takes to hand over, and no longer. root.pendingPassword = ""; add.stdinEnabled = false; } // The helper answers with the fresh account list plus whatever went // wrong, so a successful add lands on the page without a second read. stdout: StdioCollector { onStreamFinished: { try { const parsed = JSON.parse(this.text); root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : root.accounts; // An add that failed says why here. It is a write error: // GOA answered, so the page is still working. root.writeError = String(parsed.error ?? ""); } catch (error) { root.writeError = "Could not read the accounts helper's output."; } } } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.writeError = this.text.trim(); } onExited: root.pendingPassword = "" } }