Files

447 lines
17 KiB
QML

// 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, removal and password-provider sign-in are all done
// natively on this page.
//
// The one hand-off left is OAuth. Google's and Microsoft's tokens are obtained
// by code inside libgoa-backend, which Fedora ships without a GIR binding, so
// no amount of D-Bus gets at it. That is stated on the page rather than hidden
// behind a button that looks native, because a hand-off nobody expects reads as
// a bug -- and it goes through openProviderDialog() below, which is the only
// place in this file that names a GNOME panel.
//
// What Panama itself signs in to leads, because those two integrations are the
// ones this desktop actually uses. GOA's accounts are for the applications.
//
// A failed write is a row. The page used to replace itself with "Accounts
// unavailable" whenever `lastError` was set, so one refused toggle hid every
// account on the machine.
import QtQuick
import Quickshell
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "accounts"
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.")
: "What this desktop signs in to — Panama's own integrations first."
// "" | "nextcloud" | "imap"
property string openForm: ""
property string expandedAccount: ""
property string confirmingRemoval: ""
property string ncServer: ""
property string ncUser: ""
property string ncPassword: ""
property string imapEmail: ""
property string imapHost: ""
property string smtpHost: ""
property string imapUser: ""
property string imapPassword: ""
readonly property bool nextcloudReady: /^https?:\/\/.+/.test(root.ncServer.trim())
&& root.ncUser.trim() !== "" && root.ncPassword !== ""
readonly property bool imapReady: /^[^@\s]+@[^@\s]+$/.test(root.imapEmail.trim())
&& root.imapHost.trim() !== "" && root.smtpHost.trim() !== ""
&& root.imapUser.trim() !== "" && root.imapPassword !== ""
readonly property string homeSummary: {
if (!HomeAssistantConfig.configured)
return "Not set up";
if (HomeAssistant.phase === "ready") {
const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length;
const host = root.hostOf(HomeAssistantConfig.url);
return named > 0
? host + " · " + named + " room" + (named === 1 ? "" : "s")
: host + " · " + HomeAssistant.discoveredCount + " lights";
}
if (HomeAssistant.phase === "degraded")
return root.hostOf(HomeAssistantConfig.url) + " · answering slowly";
if (HomeAssistant.phase === "loading")
return "Asking " + root.hostOf(HomeAssistantConfig.url) + "…";
return root.hostOf(HomeAssistantConfig.url) + " · not answering";
}
readonly property string phoneName: KdeConnect.preferredPhone
? String(KdeConnect.preferredPhone.name)
: "Phone"
readonly property string phoneSummary: {
if (!KdeConnect.available)
return "Not set up";
if (!KdeConnect.preferredPhone)
return "KDE Connect · no phone paired yet";
const battery = KdeConnect.phoneBattery;
const where = KdeConnect.phoneReachable ? "nearby on Wi-Fi" : "not nearby";
return battery
? "KDE Connect · " + where + " · battery " + battery.charge + "%"
: "KDE Connect · " + where;
}
// Just the host, because a full URL in a subtitle is mostly scheme and
// trailing slash.
function hostOf(url: string): string {
const text = String(url ?? "").trim();
if (text === "")
return "Home Assistant";
return text.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
}
// The single hand-off. Both the OAuth add row and a needs-attention row
// arrive here, so this file names a GNOME panel exactly once -- see
// gnome-handoff-contract, which allows this one and nothing else.
function openProviderDialog(): void {
SystemSettings.openGnomePanel("online-accounts");
}
function closeForms(): void {
root.openForm = "";
root.ncServer = "";
root.ncUser = "";
root.ncPassword = "";
nextcloudPassword.clear();
root.imapEmail = "";
root.imapHost = "";
root.smtpHost = "";
root.imapUser = "";
root.imapPassword = "";
imapPasswordField.clear();
}
// Every open, not only the first: accounts are added and removed by people
// -- sometimes in the very dialog this page hands them to -- so coming back
// to this page is exactly when the list is most likely to be stale.
Component.onCompleted: {
OnlineAccounts.refresh();
// The phone row reads a service that is only refreshed when the
// Control Center's phone shelf opens, which this page cannot assume
// has happened. Home Assistant refreshes itself.
KdeConnect.refresh();
}
// Two errors, kept apart because they mean different things. A refused
// write is about the one thing somebody just did; a listing that failed is
// why the card below might be empty. Neither takes the page away.
TextRow {
visible: OnlineAccounts.writeError !== ""
label: "That did not go through"
detail: OnlineAccounts.writeError
value: ""
divider: false
}
TextRow {
visible: OnlineAccounts.snapshotError !== ""
label: "The account list could not be read"
detail: OnlineAccounts.snapshotError
value: ""
divider: false
}
// ── What Panama itself uses ──────────────────────────────────────────────
SettingsCard {
title: "This desktop"
subtitle: "The two services Panama signs in to on its own behalf."
SettingRow {
// The same glyph the My Home page and its Control Center tile use.
icon: "\u{F0335}"
label: "Home Assistant"
detail: root.homeSummary
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
StatusBadge {
anchors.verticalCenter: parent.verticalCenter
visible: HomeAssistant.phase === "ready"
text: "Connected"
tone: Theme.ok
}
SettingsButton {
text: HomeAssistantConfig.configured ? "Open Home" : "Set up"
onClicked: ShellState.openSettings("my-home")
}
}
}
SettingRow {
// md-cellphone
icon: "\u{F011C}"
label: root.phoneName
detail: root.phoneSummary
controlWidth: 210
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
StatusBadge {
anchors.verticalCenter: parent.verticalCenter
visible: KdeConnect.pairedCount > 0
text: "Paired"
tone: Theme.ok
}
SettingsButton {
text: KdeConnect.pairedCount > 0 ? "Open Phone" : "Set up"
onClicked: ShellState.openSettings("phone")
}
}
}
}
// ── Everything the applications use ──────────────────────────────────────
SettingsCard {
title: "Accounts"
subtitle: "Signed in through GNOME Online Accounts, which is what mail, calendar, contacts and file managers read."
TextRow {
visible: !OnlineAccounts.scanned
label: "Reading the account list…"
detail: ""
value: ""
divider: false
}
TextRow {
visible: OnlineAccounts.scanned && OnlineAccounts.accounts.length === 0
label: "No accounts yet"
detail: "Adding one lets mail, calendar, contacts and file managers share a single sign-in"
value: ""
divider: false
}
Repeater {
model: OnlineAccounts.accounts
delegate: OnlineAccountRow {
id: accountRow
required property var modelData
required property int index
account: accountRow.modelData
busy: OnlineAccounts.busy
// Said here rather than in the row, next to the hand-off it
// runs: GOA knows the token expired and nothing outside its own
// panel says so, which is how an account quietly stops syncing
// for weeks.
signInAction: "Sign in again"
expanded: root.expandedAccount === String(accountRow.modelData.path ?? "")
confirmingRemoval: root.confirmingRemoval === String(accountRow.modelData.path ?? "")
divider: accountRow.index < OnlineAccounts.accounts.length - 1
onActivated: {
const path = String(accountRow.modelData.path ?? "");
root.expandedAccount = accountRow.expanded ? "" : path;
root.confirmingRemoval = "";
}
onServiceToggled: (key, enabled) => OnlineAccounts.setService(
String(accountRow.modelData.path ?? ""), key, enabled)
onRemoveArmed: root.confirmingRemoval = String(accountRow.modelData.path ?? "")
onRemoveCancelled: root.confirmingRemoval = ""
onRemoveConfirmed: {
root.confirmingRemoval = "";
root.expandedAccount = "";
OnlineAccounts.remove(String(accountRow.modelData.path ?? ""));
}
onSignInAgainRequested: root.openProviderDialog()
}
}
ActionRow {
label: "Check again"
detail: "Reads the list back from GOA, for when an account changed somewhere else"
action: OnlineAccounts.busy ? "Checking…" : "Refresh"
enabled: !OnlineAccounts.busy
divider: false
onTriggered: OnlineAccounts.refresh()
}
}
// ── Adding one ───────────────────────────────────────────────────────────
SettingsCard {
title: "Add an account"
ActionRow {
label: "Nextcloud"
detail: "Server, username, password — signed in right here"
action: root.openForm === "nextcloud" ? "Cancel" : "Add…"
enabled: !OnlineAccounts.busy
divider: root.openForm === "nextcloud"
onTriggered: {
const wasOpen = root.openForm === "nextcloud";
root.closeForms();
if (!wasOpen)
root.openForm = "nextcloud";
}
}
Column {
width: parent.width
visible: root.openForm === "nextcloud"
LiveFieldRow {
width: parent.width
label: "Server"
detail: "The address you open Nextcloud at, scheme and all"
placeholder: "https://cloud.example.org"
text: root.ncServer
enabled: !OnlineAccounts.busy
onEdited: value => root.ncServer = value
}
LiveFieldRow {
width: parent.width
label: "Username"
placeholder: "you"
text: root.ncUser
enabled: !OnlineAccounts.busy
onEdited: value => root.ncUser = value
}
SecretFieldRow {
id: nextcloudPassword
width: parent.width
label: "Password"
detail: "Your password, or an app password — GOA signs in with Basic auth, so either works, and Nextcloud's Security settings issue an app password per device. It is handed over on stdin and never becomes a command-line argument."
placeholder: "Password or app password"
enabled: !OnlineAccounts.busy
onChanged: value => root.ncPassword = value
}
ActionRow {
width: parent.width
label: "Sign in to Nextcloud"
detail: root.nextcloudReady
? "Files, calendar and contacts arrive with the account"
: "A server address, a username and an app password are needed first"
action: OnlineAccounts.busy ? "Signing in…" : "Sign in"
enabled: root.nextcloudReady && !OnlineAccounts.busy
divider: false
onTriggered: {
OnlineAccounts.addNextcloud(root.ncServer.trim(), root.ncUser.trim(),
root.ncPassword);
root.closeForms();
}
}
}
ActionRow {
label: "Mail (IMAP & SMTP)"
detail: "Server details and password — native form"
action: root.openForm === "imap" ? "Cancel" : "Add…"
enabled: !OnlineAccounts.busy
divider: root.openForm === "imap"
onTriggered: {
const wasOpen = root.openForm === "imap";
root.closeForms();
if (!wasOpen)
root.openForm = "imap";
}
}
Column {
width: parent.width
visible: root.openForm === "imap"
LiveFieldRow {
width: parent.width
label: "Email address"
placeholder: "[email protected]"
text: root.imapEmail
enabled: !OnlineAccounts.busy
onEdited: value => root.imapEmail = value
}
LiveFieldRow {
width: parent.width
label: "Incoming server"
detail: "IMAP"
placeholder: "imap.example.com"
text: root.imapHost
enabled: !OnlineAccounts.busy
onEdited: value => root.imapHost = value
}
LiveFieldRow {
width: parent.width
label: "Outgoing server"
detail: "SMTP"
placeholder: "smtp.example.com"
text: root.smtpHost
enabled: !OnlineAccounts.busy
onEdited: value => root.smtpHost = value
}
LiveFieldRow {
width: parent.width
label: "Username"
detail: "Often the address again, sometimes not"
placeholder: "[email protected]"
text: root.imapUser
enabled: !OnlineAccounts.busy
onEdited: value => root.imapUser = value
}
SecretFieldRow {
id: imapPasswordField
width: parent.width
label: "Password"
detail: "Handed over on stdin, never as a command-line argument"
enabled: !OnlineAccounts.busy
onChanged: value => root.imapPassword = value
}
ActionRow {
width: parent.width
label: "Add this mail account"
detail: root.imapReady
? "GOA stores it and mail clients pick it up from there"
: "An address, both servers, a username and a password are needed first"
action: OnlineAccounts.busy ? "Adding…" : "Add account"
enabled: root.imapReady && !OnlineAccounts.busy
divider: false
onTriggered: {
OnlineAccounts.addImap(root.imapEmail.trim(), root.imapHost.trim(),
root.smtpHost.trim(), root.imapUser.trim(),
root.imapPassword);
root.closeForms();
}
}
}
ActionRow {
label: "Google or Microsoft"
detail: "OAuth needs the provider's own browser sign-in, which only GOA's dialog can run — the one thing this page hands off"
action: "Open GNOME dialog…"
divider: false
onTriggered: root.openProviderDialog()
}
}
}