479 lines
18 KiB
QML
479 lines
18 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
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: HomeAssistant.phase === "ready"
|
|
width: homeBadge.implicitWidth + 14
|
|
height: 17
|
|
radius: Theme.pillRadius
|
|
color: Theme.alpha(Theme.ok, 0.1)
|
|
border.width: 1
|
|
border.color: Theme.alpha(Theme.ok, 0.3)
|
|
|
|
Text {
|
|
id: homeBadge
|
|
|
|
anchors.centerIn: parent
|
|
text: "CONNECTED"
|
|
color: Theme.ok
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
|
font.weight: Font.DemiBold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: KdeConnect.pairedCount > 0
|
|
width: phoneBadge.implicitWidth + 14
|
|
height: 17
|
|
radius: Theme.pillRadius
|
|
color: Theme.alpha(Theme.ok, 0.1)
|
|
border.width: 1
|
|
border.color: Theme.alpha(Theme.ok, 0.3)
|
|
|
|
Text {
|
|
id: phoneBadge
|
|
|
|
anchors.centerIn: parent
|
|
text: "PAIRED"
|
|
color: Theme.ok
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
|
font.weight: Font.DemiBold
|
|
font.letterSpacing: 0.5
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|