Four accounts distinguished only by a line of small grey text are four rows that have to be read rather than recognised. GOA already knows what each one is. It hands back a serialised GThemedIcon -- ". GThemedIcon goa-account-google goa-account goa …" -- a preference-ordered fallback chain. The helper passes the whole chain on rather than resolving it, because which of those names exists is a property of the icon theme in use and not something a python script talking to D-Bus should be deciding. The page walks the chain and takes the first name the active theme actually has. Both simpler readings were wrong and looked right: taking the first name blindly assumes it resolves, and taking the last as a fallback assumes the most generic name is the most likely to exist. On Adwaita the tails of these very chains -- "mail", "goa-symbolic" -- do not exist at all, so a miss would have drawn nothing. Checked by asking Quickshell.iconPath directly, which returns empty for a name the theme lacks; the fallback is avatar-default-symbolic, which is present. SettingsCard grew an optional icon for this. It is empty by default and the header lays out exactly as before when unset, so no other card moves. Last-sync is not here because there is nothing to show: GOA exposes no sync-related property at all, on any of these accounts. Better to say so than to invent a timestamp from when the page last refreshed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
171 lines
6.3 KiB
Python
Executable File
171 lines
6.3 KiB
Python
Executable File
#!/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 <object-path> <service> <true|false>
|
|
panama-accounts remove <object-path>
|
|
"""
|
|
|
|
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,
|
|
# GOA hands back a serialised GThemedIcon: ". GThemedIcon name1 name2 …",
|
|
# a preference-ordered fallback chain. Passed on as that list rather than
|
|
# resolved here, because which of those names exists is a property of the
|
|
# icon theme in use, which this has no business deciding.
|
|
"providerIcons": themed_icon_names(account.props.provider_icon),
|
|
# 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 themed_icon_names(icon) -> list[str]:
|
|
"""The icon names out of a serialised GThemedIcon, best first.
|
|
|
|
The string form is ". GThemedIcon mail-unread-symbolic mail-symbolic mail",
|
|
where the leading "." and the type name are structure rather than content.
|
|
Anything that is not that shape yields nothing, so a caller gets an empty
|
|
list rather than a name that will never resolve.
|
|
"""
|
|
if icon is None:
|
|
return []
|
|
try:
|
|
text = icon.to_string()
|
|
except Exception:
|
|
text = str(icon)
|
|
parts = str(text or "").split()
|
|
if len(parts) < 3 or parts[0] != "." or parts[1] != "GThemedIcon":
|
|
return []
|
|
return [name for name in parts[2:] if name]
|
|
|
|
|
|
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 <path> <service> <true|false>", 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 <path>", 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())
|