diff --git a/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml new file mode 100644 index 0000000..43d7594 --- /dev/null +++ b/config/dot/quickshell/modules/settings/OnlineAccountsPage.qml @@ -0,0 +1,122 @@ +// Online Accounts. +// +// GNOME's panel of the same name, except for one step. The accounts are GNOME +// Online Accounts objects on D-Bus, the daemon already runs here, and listing, +// per-service toggles and removal are all done natively on this page. +// +// Signing in to an OAuth provider is handed to GNOME's panel, because the +// credentials are OAuth tokens and the code that obtains them ships without a +// scriptable binding. That is stated on the page rather than hidden behind a +// button that looks native, because a hand-off the user does not expect reads +// as a bug. +// +// Accounts needing attention are surfaced first. GOA knows when a token has +// expired and nothing outside its own panel says so, which is how an account +// quietly stops syncing for weeks. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Online Accounts" + lede: OnlineAccounts.attentionCount > 0 + ? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1 + ? " account needs you to sign in again." + : " accounts need you to sign in again.") + : "Accounts your mail, calendar, contacts, and files come from." + + Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh() + + SettingsCard { + visible: !OnlineAccounts.available + title: "Accounts unavailable" + subtitle: OnlineAccounts.lastError + } + + SettingsCard { + visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0 + title: "No accounts yet" + subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in." + + ActionRow { + label: "Add an account" + detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos" + action: "Add account" + divider: false + onTriggered: SystemSettings.openGnomePanel("online-accounts") + } + } + + Repeater { + model: OnlineAccounts.accounts + + SettingsCard { + id: accountCard + required property var modelData + + title: accountCard.modelData.identity || accountCard.modelData.providerName + subtitle: accountCard.modelData.needsAttention + ? accountCard.modelData.providerName + " ยท sign-in expired, so this account has stopped syncing" + : accountCard.modelData.providerName + + // Only when it is true, because it is the one thing on this page + // that needs acting on. + ActionRow { + visible: accountCard.modelData.needsAttention + label: "Sign in again" + detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts" + action: "Sign in" + onTriggered: SystemSettings.openGnomePanel("online-accounts") + } + + Repeater { + model: accountCard.modelData.services + + SettingRow { + id: serviceRow + required property var modelData + required property int index + + label: serviceRow.modelData.label + detail: serviceRow.modelData.enabled + ? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account" + : "Hidden from applications" + controlWidth: 48 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: serviceRow.modelData.enabled + onToggled: value => OnlineAccounts.setService( + accountCard.modelData.path, serviceRow.modelData.key, value) + } + } + } + + ActionRow { + label: "Remove this account" + detail: "Signs out and removes it from every application that was using it" + action: "Remove" + divider: false + onTriggered: OnlineAccounts.remove(accountCard.modelData.path) + } + } + } + + SettingsCard { + visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0 + title: "Add another account" + subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here." + + ActionRow { + label: "Add an account" + detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos" + action: "Add account" + divider: false + onTriggered: SystemSettings.openGnomePanel("online-accounts") + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 28c3be6..f1ae534 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -102,6 +102,7 @@ Rectangle { case "mouse": return mousePage; case "privacy": return privacyPage; case "region": return regionPage; + case "accounts": return onlineAccountsPage; case "accessibility": return accessibilityPage; case "power": return powerPage; case "datetime": return dateTimePage; @@ -159,6 +160,7 @@ Rectangle { Component { id: mousePage; MousePage {} } Component { id: privacyPage; PrivacyPage {} } Component { id: regionPage; RegionPage {} } + Component { id: onlineAccountsPage; OnlineAccountsPage {} } Component { id: servicesPage; ServicesPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 997df08..3e5b700 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -35,6 +35,7 @@ Rectangle { { page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" }, { page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" }, { page: "region", label: "Region & Language", icon: "\u{F0AC2}" }, + { page: "accounts", label: "Online Accounts", icon: "\u{F0004}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 99bbf5c..d0d1c94 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -51,3 +51,4 @@ TextEntryRow 1.0 TextEntryRow.qml PrivacyPage 1.0 PrivacyPage.qml RegionPage 1.0 RegionPage.qml SearchPicker 1.0 SearchPicker.qml +OnlineAccountsPage 1.0 OnlineAccountsPage.qml diff --git a/config/dot/quickshell/scripts/panama-accounts b/config/dot/quickshell/scripts/panama-accounts new file mode 100755 index 0000000..9a3be6e --- /dev/null +++ b/config/dot/quickshell/scripts/panama-accounts @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +"""Online accounts, through GNOME Online Accounts. + +GOA is a daemon plus a D-Bus API, and it is already running in this session -- +gvfs activates it, and the four accounts on this machine work without +gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts +themselves are ordinary D-Bus objects that anything may read and modify. + +So everything except the initial sign-in is available to us: listing accounts, +turning individual services on and off, and removing an account. That is the +whole Online Accounts panel apart from one OAuth handshake. + +Adding an account is the part that splits. The daemon's AddAccount takes +credentials as an ARGUMENT -- it stores them, it does not obtain them. For +password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a +password, which a settings app can reasonably collect. For Google it is an OAuth +token, and the code that runs that exchange lives in libgoa-backend, which +Fedora ships without a GIR binding -- reachable from C only. Reimplementing it +would mean our own Google client credentials. So Google sign-in is handed to +GNOME's panel, and only the sign-in. + +Usage: + panama-accounts list + panama-accounts set + panama-accounts remove +""" + +import json +import sys + +# Every service GOA models. The account object carries one interface per service +# it supports, so presence of the interface is what "this account can do mail" +# means -- there is no capability list to read. +SERVICES = [ + ("mail", "Mail", "get_mail", "mail_disabled"), + ("calendar", "Calendar", "get_calendar", "calendar_disabled"), + ("contacts", "Contacts", "get_contacts", "contacts_disabled"), + ("files", "Files", "get_files", "files_disabled"), + ("photos", "Photos", "get_photos", "photos_disabled"), + ("music", "Music", "get_music", "music_disabled"), + ("chat", "Chat", "get_chat", "chat_disabled"), +] + + +def load_client(): + import gi + + gi.require_version("Goa", "1.0") + from gi.repository import Goa + + return Goa.Client.new_sync(None) + + +def describe(obj): + account = obj.get_account() + services = [] + for key, label, getter, disabled_prop in SERVICES: + if getattr(obj, getter)() is None: + continue + services.append({ + "key": key, + "label": label, + "enabled": not getattr(account.props, disabled_prop), + }) + + return { + "path": obj.get_object_path(), + "provider": account.props.provider_type, + "providerName": account.props.provider_name, + # PresentationIdentity is the human one (an email address); Identity is + # the internal handle and is not always readable. + "identity": account.props.presentation_identity or account.props.identity, + # GOA raises this when stored credentials stop working -- an expired + # token, a changed password. It is the one piece of state a user must + # act on, and nothing else surfaces it. + "needsAttention": bool(account.props.attention_needed), + "services": services, + } + + +def find(client, path): + for obj in client.get_accounts(): + if obj.get_object_path() == path: + return obj + return None + + +def main(): + action = sys.argv[1] if len(sys.argv) > 1 else "list" + + try: + client = load_client() + except Exception as error: # noqa: BLE001 - any failure here means "no GOA" + # A machine without GOA is a legitimate state, not a crash. + print(json.dumps({ + "accounts": [], + "error": f"GNOME Online Accounts is not available: {error}", + })) + return 0 + + if action == "list": + print(json.dumps({ + "accounts": [describe(obj) for obj in client.get_accounts()], + "error": "", + })) + return 0 + + if action == "set": + if len(sys.argv) != 5: + print("usage: panama-accounts set ", file=sys.stderr) + return 2 + path, service, value = sys.argv[2], sys.argv[3], sys.argv[4] + obj = find(client, path) + if obj is None: + print(f"panama-accounts: no account at {path}", file=sys.stderr) + return 1 + match = next((s for s in SERVICES if s[0] == service), None) + if match is None: + print(f"panama-accounts: unknown service {service!r}", file=sys.stderr) + return 2 + if getattr(obj, match[2])() is None: + print(f"panama-accounts: this account does not support {service}", file=sys.stderr) + return 1 + # The property is "disabled", so enabling a service clears it. + setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes")) + return 0 + + if action == "remove": + if len(sys.argv) != 3: + print("usage: panama-accounts remove ", file=sys.stderr) + return 2 + obj = find(client, sys.argv[2]) + if obj is None: + print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr) + return 1 + obj.get_account().call_remove_sync(None) + return 0 + + print("usage: panama-accounts [list|set|remove]", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/dot/quickshell/services/OnlineAccounts.qml b/config/dot/quickshell/services/OnlineAccounts.qml new file mode 100644 index 0000000..cb4757e --- /dev/null +++ b/config/dot/quickshell/services/OnlineAccounts.qml @@ -0,0 +1,95 @@ +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, and removal all happen here, natively. +// +// Signing in is the exception, and only for OAuth providers. The daemon's +// AddAccount takes credentials as an argument rather than obtaining them, and +// the code that runs Google's OAuth 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. +// +// 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, identity, needsAttention, services: [{key,label,enabled}] }] + property var accounts: [] + property bool scanned: false + property bool busy: false + property string lastError: "" + + // 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 + + readonly property bool available: root.lastError === "" + + function refresh(): void { + if (!list.running) + list.running = 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 (root.busy) + return; + root.busy = true; + write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"]; + write.running = true; + } + + function remove(path: string): void { + if (root.busy) + return; + root.busy = true; + write.command = [root.helperPath, "remove", path]; + write.running = true; + } + + Process { + id: list + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : []; + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.accounts = []; + root.lastError = "Could not read the accounts helper's output."; + console.warn("OnlineAccounts: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: write + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = 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.busy = false; + root.refresh(); + } + } +} diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index b785047..6a102ed 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true;