Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5562323eb8 | ||
|
|
52d896b054 | ||
|
|
e4409ed6aa | ||
|
|
f0321432b4 |
@@ -16,3 +16,7 @@ __pycache__/
|
|||||||
|
|
||||||
# Generated from the colour scheme; machine state, not configuration.
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
/config/dot/kitty/current-theme.conf
|
/config/dot/kitty/current-theme.conf
|
||||||
|
|
||||||
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
|
/config/dot/gtk-3.0/settings.ini
|
||||||
|
/config/dot/gtk-4.0/settings.ini
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
# GENERATED FILE -- edit settings.ini.template instead.
|
||||||
|
#
|
||||||
|
# The theme name and dark preference below follow Panama's colour
|
||||||
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
|
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||||
|
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||||
|
# has to change with the scheme rather than being pinned to dark.
|
||||||
|
#
|
||||||
[Settings]
|
[Settings]
|
||||||
# These values must match `gsettings get org.gnome.desktop.interface ...`.
|
# These values must match `gsettings get org.gnome.desktop.interface ...`.
|
||||||
#
|
#
|
||||||
@@ -6,12 +15,12 @@
|
|||||||
# this file becomes authoritative for GTK3 — which is why it previously named
|
# this file becomes authoritative for GTK3 — which is why it previously named
|
||||||
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
||||||
# appearing broken.
|
# appearing broken.
|
||||||
gtk-theme-name=adw-gtk3-dark
|
gtk-theme-name=@GTK_THEME@
|
||||||
gtk-icon-theme-name=Adwaita
|
gtk-icon-theme-name=Adwaita
|
||||||
gtk-font-name=Adwaita Sans 11
|
gtk-font-name=Adwaita Sans 11
|
||||||
gtk-cursor-theme-name=oreo_blue_cursors
|
gtk-cursor-theme-name=oreo_blue_cursors
|
||||||
gtk-cursor-theme-size=24
|
gtk-cursor-theme-size=24
|
||||||
gtk-application-prefer-dark-theme=1
|
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||||
|
|
||||||
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
||||||
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Settings]
|
|
||||||
# libadwaita apps normally take their dark preference from the
|
|
||||||
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
|
||||||
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
|
||||||
# window before the portal answers.
|
|
||||||
gtk-application-prefer-dark-theme=1
|
|
||||||
gtk-theme-name=adw-gtk3-dark
|
|
||||||
gtk-icon-theme-name=Adwaita
|
|
||||||
gtk-font-name=Adwaita Sans 11
|
|
||||||
gtk-cursor-theme-name=oreo_blue_cursors
|
|
||||||
gtk-cursor-theme-size=24
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# GENERATED FILE -- edit settings.ini.template instead.
|
||||||
|
#
|
||||||
|
# The theme name and dark preference below follow Panama's colour
|
||||||
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
|
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||||
|
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||||
|
# has to change with the scheme rather than being pinned to dark.
|
||||||
|
#
|
||||||
|
[Settings]
|
||||||
|
# libadwaita apps normally take their dark preference from the
|
||||||
|
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
||||||
|
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
||||||
|
# window before the portal answers.
|
||||||
|
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||||
|
gtk-theme-name=@GTK_THEME@
|
||||||
|
gtk-icon-theme-name=Adwaita
|
||||||
|
gtk-font-name=Adwaita Sans 11
|
||||||
|
gtk-cursor-theme-name=oreo_blue_cursors
|
||||||
|
gtk-cursor-theme-size=24
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,12 @@ SettingsPage {
|
|||||||
? "Screen lock, device access, and a machine whose security settings all check out."
|
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||||
: "Screen lock, which applications can see you, and how this machine is protected."
|
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||||
|
|
||||||
Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh()
|
Component.onCompleted: {
|
||||||
|
if (!DeviceSecurity.scanned)
|
||||||
|
DeviceSecurity.refresh();
|
||||||
|
if (!Keyring.scanned)
|
||||||
|
Keyring.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Screen lock"
|
title: "Screen lock"
|
||||||
@@ -33,6 +38,64 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "lockOnSleep"; divider: false }
|
ToggleRow { setting: "lockOnSleep"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The login keyring, which nothing else surfaces.
|
||||||
|
//
|
||||||
|
// It is unlocked at sign-in by PAM, so this card normally just confirms
|
||||||
|
// that. It earns its place on the rare occasion it is not: a locked keyring
|
||||||
|
// breaks saved passwords everywhere at once, and does it without ever
|
||||||
|
// saying the word "keyring" -- you get a mail account that will not
|
||||||
|
// authenticate and a git push that cannot find its key.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Keyring.scanned
|
||||||
|
title: "Saved passwords"
|
||||||
|
subtitle: !Keyring.available
|
||||||
|
? "No secret service is answering, so saved passwords are unavailable."
|
||||||
|
: Keyring.locked
|
||||||
|
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
|
||||||
|
: "The login keyring is unlocked, as it is after every normal sign-in."
|
||||||
|
|
||||||
|
// Two rows rather than one with a conditional button: a locked keyring
|
||||||
|
// needs an action, an unlocked one is a statement of fact, and ActionRow
|
||||||
|
// and TextRow already say exactly those two things.
|
||||||
|
ActionRow {
|
||||||
|
visible: Keyring.available && Keyring.locked
|
||||||
|
label: "Login keyring"
|
||||||
|
detail: "Unlock to restore access to stored passwords and keys"
|
||||||
|
action: Keyring.unlocking ? "Waiting…" : "Unlock"
|
||||||
|
enabled: !Keyring.unlocking
|
||||||
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||||
|
onTriggered: Keyring.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: !(Keyring.available && Keyring.locked)
|
||||||
|
label: "Login keyring"
|
||||||
|
detail: Keyring.available
|
||||||
|
? "Unlocked at sign-in by PAM, the same way GNOME does it"
|
||||||
|
: "No secret service is answering on this session"
|
||||||
|
value: Keyring.available ? "Unlocked" : "Unavailable"
|
||||||
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only shown when it is true, because it is a diagnostic rather than a
|
||||||
|
// setting: it means the daemon holding your secrets is not the one PAM
|
||||||
|
// started, so whatever unlocked it will not survive a restart.
|
||||||
|
SettingRow {
|
||||||
|
visible: Keyring.replacementDaemon
|
||||||
|
label: "Keyring service"
|
||||||
|
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
|
||||||
|
value: "Replaced"
|
||||||
|
divider: Keyring.lastError !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: Keyring.lastError !== ""
|
||||||
|
label: "Keyring problem"
|
||||||
|
detail: Keyring.lastError
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Camera & microphone"
|
title: "Camera & microphone"
|
||||||
subtitle: PrivacyState.anyActive
|
subtitle: PrivacyState.anyActive
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ Rectangle {
|
|||||||
case "mouse": return mousePage;
|
case "mouse": return mousePage;
|
||||||
case "privacy": return privacyPage;
|
case "privacy": return privacyPage;
|
||||||
case "region": return regionPage;
|
case "region": return regionPage;
|
||||||
|
case "accounts": return onlineAccountsPage;
|
||||||
case "accessibility": return accessibilityPage;
|
case "accessibility": return accessibilityPage;
|
||||||
case "power": return powerPage;
|
case "power": return powerPage;
|
||||||
case "datetime": return dateTimePage;
|
case "datetime": return dateTimePage;
|
||||||
@@ -159,6 +160,7 @@ Rectangle {
|
|||||||
Component { id: mousePage; MousePage {} }
|
Component { id: mousePage; MousePage {} }
|
||||||
Component { id: privacyPage; PrivacyPage {} }
|
Component { id: privacyPage; PrivacyPage {} }
|
||||||
Component { id: regionPage; RegionPage {} }
|
Component { id: regionPage; RegionPage {} }
|
||||||
|
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||||
Component { id: servicesPage; ServicesPage {} }
|
Component { id: servicesPage; ServicesPage {} }
|
||||||
Component { id: aboutPage; AboutPage {} }
|
Component { id: aboutPage; AboutPage {} }
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ Rectangle {
|
|||||||
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
|
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
|
||||||
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
|
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
|
||||||
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
|
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
|
||||||
|
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
|
||||||
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
||||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||||
|
|||||||
@@ -51,3 +51,4 @@ TextEntryRow 1.0 TextEntryRow.qml
|
|||||||
PrivacyPage 1.0 PrivacyPage.qml
|
PrivacyPage 1.0 PrivacyPage.qml
|
||||||
RegionPage 1.0 RegionPage.qml
|
RegionPage 1.0 RegionPage.qml
|
||||||
SearchPicker 1.0 SearchPicker.qml
|
SearchPicker 1.0 SearchPicker.qml
|
||||||
|
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||||
|
|||||||
+145
@@ -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 <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,
|
||||||
|
# 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 <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())
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""The login keyring's lock state, and a way to unlock it.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
|
||||||
|
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
|
||||||
|
and a bare Hyprland session does not, is anywhere to SEE that it failed.
|
||||||
|
|
||||||
|
It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
|
||||||
|
service_method_open_session, seen once here), and when it does, D-Bus activates
|
||||||
|
a replacement. That replacement never received the login password, so the login
|
||||||
|
keyring comes back LOCKED in the middle of a session that unlocked it correctly
|
||||||
|
at login. Everything that stores a secret then starts failing in ways that do
|
||||||
|
not mention keyrings at all: a mail client that will not authenticate, a git
|
||||||
|
push that cannot find its key, an integration that reports "not configured".
|
||||||
|
|
||||||
|
So this reports the state plainly and offers the one action that fixes it.
|
||||||
|
|
||||||
|
Unlocking prompts
|
||||||
|
-----------------
|
||||||
|
`unlock` asks the Secret Service to unlock, which raises the gcr password
|
||||||
|
dialog. That is deliberate: the password is not ours to store or handle, and it
|
||||||
|
never passes through this script. The dialog is the same one GNOME shows.
|
||||||
|
|
||||||
|
Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
|
||||||
|
is not hung, it is waiting for a dialog nobody is looking at. That is worth
|
||||||
|
knowing before debugging one for an hour.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
panama-keyring status -> {"available", "locked", "collections", "daemon"}
|
||||||
|
panama-keyring unlock -> raises the password prompt; prints the new state
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def daemon_origin():
|
||||||
|
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||||
|
|
||||||
|
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||||
|
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||||
|
outside the app slice, so the cgroup tells the two apart.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as handle:
|
||||||
|
cmdline = handle.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if "gnome-keyring-daemon" not in cmdline:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cgroup", "r") as handle:
|
||||||
|
cgroup = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return "unknown"
|
||||||
|
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||||
|
return "dbus"
|
||||||
|
return "pam"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
def load_service():
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Secret", "1")
|
||||||
|
from gi.repository import Secret
|
||||||
|
|
||||||
|
return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)
|
||||||
|
|
||||||
|
|
||||||
|
def report(service, Secret):
|
||||||
|
collections = [
|
||||||
|
{"label": c.get_label(), "locked": c.get_locked()}
|
||||||
|
for c in service.get_collections()
|
||||||
|
]
|
||||||
|
# The login keyring is the one that matters; the others are per-application
|
||||||
|
# stores that manage their own unlocking.
|
||||||
|
login = next((c for c in collections if c["label"] == "Login"), None)
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"locked": bool(login["locked"]) if login else False,
|
||||||
|
"hasLogin": login is not None,
|
||||||
|
"collections": collections,
|
||||||
|
"daemon": daemon_origin(),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||||
|
if action not in ("status", "unlock"):
|
||||||
|
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
Secret, service = load_service()
|
||||||
|
except Exception as error: # noqa: BLE001 - any failure here is "no keyring"
|
||||||
|
# No Secret Service at all is a legitimate state, not a crash: report it
|
||||||
|
# so the UI can say so instead of showing an empty card.
|
||||||
|
print(json.dumps({
|
||||||
|
"available": False, "locked": False, "hasLogin": False,
|
||||||
|
"collections": [], "daemon": daemon_origin(),
|
||||||
|
"error": f"The secret service is not answering: {error}",
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "unlock":
|
||||||
|
login = next(
|
||||||
|
(c for c in service.get_collections() if c.get_label() == "Login"), None)
|
||||||
|
if login is not None and login.get_locked():
|
||||||
|
try:
|
||||||
|
# Blocks until the dialog is answered or dismissed.
|
||||||
|
service.unlock_sync([login], None)
|
||||||
|
except Exception as error: # noqa: BLE001
|
||||||
|
state = report(service, Secret)
|
||||||
|
state["error"] = f"The keyring was not unlocked: {error}"
|
||||||
|
print(json.dumps(state))
|
||||||
|
return 0
|
||||||
|
# The collection object caches its state; re-read it.
|
||||||
|
Secret, service = load_service()
|
||||||
|
|
||||||
|
print(json.dumps(report(service, Secret)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
# Terminals are the notable exception -- they predate the standard and carry
|
# Terminals are the notable exception -- they predate the standard and carry
|
||||||
# their own palettes. kitty is handled here.
|
# their own palettes. kitty is handled here.
|
||||||
#
|
#
|
||||||
|
# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
|
||||||
|
# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
|
||||||
|
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
|
||||||
|
# scheme in light mode, so it is generated from a template here instead.
|
||||||
|
#
|
||||||
# panama-theme-apps dark|light
|
# panama-theme-apps dark|light
|
||||||
#
|
#
|
||||||
# kitty gets it twice: the generated include file so terminals opened later
|
# kitty gets it twice: the generated include file so terminals opened later
|
||||||
@@ -26,6 +31,36 @@ case "$scheme" in
|
|||||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ── GTK ──────────────────────────────────────────────────────────────────────
|
||||||
|
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
|
||||||
|
# naming a theme that does not exist makes GTK fall back to its light default --
|
||||||
|
# which made dark mode silently produce light windows.
|
||||||
|
if [[ "$scheme" == "light" ]]; then
|
||||||
|
gtk_theme="adw-gtk3"
|
||||||
|
prefer_dark=0
|
||||||
|
else
|
||||||
|
gtk_theme="adw-gtk3-dark"
|
||||||
|
prefer_dark=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
status_gtk="skipped"
|
||||||
|
for gtk_version in 3.0 4.0; do
|
||||||
|
gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
|
||||||
|
template="$gtk_dir/settings.ini.template"
|
||||||
|
[[ -r "$template" ]] || continue
|
||||||
|
|
||||||
|
# Written atomically: a GTK application starting mid-write would otherwise
|
||||||
|
# read a truncated file and fall back to defaults.
|
||||||
|
if sed -e "s/@GTK_THEME@/$gtk_theme/" -e "s/@PREFER_DARK@/$prefer_dark/" "$template" \
|
||||||
|
>"$gtk_dir/settings.ini.tmp" 2>/dev/null \
|
||||||
|
&& mv "$gtk_dir/settings.ini.tmp" "$gtk_dir/settings.ini" 2>/dev/null; then
|
||||||
|
status_gtk="written"
|
||||||
|
else
|
||||||
|
rm -f "$gtk_dir/settings.ini.tmp"
|
||||||
|
status_gtk="failed"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
||||||
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||||
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||||
|
|||||||
@@ -78,9 +78,21 @@ Singleton {
|
|||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
|
|
||||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||||
// Adwaita's light and dark are the same theme; only the preference and
|
|
||||||
// the -dark suffix differ, so applications that honour either agree.
|
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||||
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
|
// broken while light mode looked fine:
|
||||||
|
//
|
||||||
|
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
|
||||||
|
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
|
||||||
|
// not exist makes GTK fall back to its built-in default, which is
|
||||||
|
// LIGHT. So asking for light accidentally worked, asking for dark
|
||||||
|
// silently produced light, and applications that take their cue from
|
||||||
|
// the GTK theme rather than the portal -- Chromium and Electron, when
|
||||||
|
// built against GTK -- stayed light no matter what the portal said.
|
||||||
|
//
|
||||||
|
// gtk-theme-contract asserts these names are actually installed,
|
||||||
|
// because the failure mode is silent in exactly this way.
|
||||||
|
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The login keyring's lock state.
|
||||||
|
//
|
||||||
|
// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is
|
||||||
|
// under GNOME. What a bare Hyprland session lacks is anywhere to see when that
|
||||||
|
// has stopped being true.
|
||||||
|
//
|
||||||
|
// It stops being true rarely but expensively: gnome-keyring-daemon can crash,
|
||||||
|
// D-Bus activates a replacement, and the replacement never received the login
|
||||||
|
// password -- so the keyring is locked in the middle of a session that unlocked
|
||||||
|
// it correctly. Nothing announces this. What the user sees instead is a mail
|
||||||
|
// account that will not authenticate, a git push that cannot find its key, or
|
||||||
|
// an integration reporting "not configured", none of which mention keyrings.
|
||||||
|
//
|
||||||
|
// Checked on demand and after an unlock, not polled: the state changes only
|
||||||
|
// when a daemon dies or a password is entered.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring"
|
||||||
|
|
||||||
|
property bool available: false
|
||||||
|
property bool locked: false
|
||||||
|
property bool scanned: false
|
||||||
|
property bool unlocking: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// "pam" when the daemon that holds the keyring is the one PAM started at
|
||||||
|
// login, "dbus" when it is a D-Bus-activated replacement -- which is the
|
||||||
|
// signature of the crash case, and worth showing, because a dbus daemon
|
||||||
|
// that is currently unlocked was unlocked by hand and will not survive.
|
||||||
|
property string daemon: ""
|
||||||
|
|
||||||
|
readonly property bool replacementDaemon: root.daemon === "dbus"
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raises the standard password dialog. The password never passes through
|
||||||
|
// Panama -- the Secret Service prompts, the same way it does under GNOME.
|
||||||
|
function unlock(): void {
|
||||||
|
if (root.unlocking)
|
||||||
|
return;
|
||||||
|
root.unlocking = true;
|
||||||
|
unlockProcess.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absorb(text: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
root.available = parsed.available === true;
|
||||||
|
root.locked = parsed.locked === true;
|
||||||
|
root.daemon = String(parsed.daemon ?? "");
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.available = false;
|
||||||
|
root.lastError = "Could not read the keyring helper's output.";
|
||||||
|
console.warn("Keyring: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath, "status"]
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: unlockProcess
|
||||||
|
command: [root.helperPath, "unlock"]
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
|
onExited: root.unlocking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
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";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
@@ -70,6 +70,34 @@ for dir in "${dirs[@]}"; do
|
|||||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
|
||||||
|
# template rather than layered. Without this, a fresh checkout has a template
|
||||||
|
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
|
||||||
|
# panama-theme-apps rewrites both files on every scheme change after this.
|
||||||
|
for gtk_version in 3.0 4.0; do
|
||||||
|
gtk_template="$PANAMA_DOT/gtk-$gtk_version/settings.ini.template"
|
||||||
|
gtk_settings="$PANAMA_DOT/gtk-$gtk_version/settings.ini"
|
||||||
|
[ -r "$gtk_template" ] || continue
|
||||||
|
if [ -e "$gtk_settings" ]; then
|
||||||
|
log "Keeping existing GTK settings at $gtk_settings"
|
||||||
|
else
|
||||||
|
gtk_scheme="dark"
|
||||||
|
gtk_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
if [ -r "$gtk_prefs" ]; then
|
||||||
|
gtk_stored="$(jq -r '.colorScheme // "dark"' "$gtk_prefs" 2>/dev/null || echo dark)"
|
||||||
|
[ "$gtk_stored" = "light" ] && gtk_scheme="light"
|
||||||
|
fi
|
||||||
|
if [ "$gtk_scheme" = "light" ]; then
|
||||||
|
gtk_name="adw-gtk3"; gtk_dark=0
|
||||||
|
else
|
||||||
|
gtk_name="adw-gtk3-dark"; gtk_dark=1
|
||||||
|
fi
|
||||||
|
sed -e "s/@GTK_THEME@/$gtk_name/" -e "s/@PREFER_DARK@/$gtk_dark/" \
|
||||||
|
"$gtk_template" > "$gtk_settings"
|
||||||
|
log "Generated GTK $gtk_version settings ($gtk_name) → $gtk_settings"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
||||||
# from the desktop colour scheme rather than committed -- it is machine state.
|
# from the desktop colour scheme rather than committed -- it is machine state.
|
||||||
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
||||||
|
|||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Every GTK theme name Panama sets must be a theme that is actually installed.
|
||||||
|
#
|
||||||
|
# This exists because of a bug that was invisible for weeks. ColorScheme set
|
||||||
|
# gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is
|
||||||
|
# installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are -- and GTK
|
||||||
|
# responds to an unknown theme name by silently falling back to its built-in
|
||||||
|
# default, which is LIGHT.
|
||||||
|
#
|
||||||
|
# So light mode appeared to work, dark mode produced light windows, and nothing
|
||||||
|
# anywhere reported an error. Applications that take their cue from the GTK
|
||||||
|
# theme rather than the portal -- Chromium and Electron among them -- were stuck
|
||||||
|
# light with no way to diagnose it from inside the application.
|
||||||
|
#
|
||||||
|
# The failure is silent by construction, so it needs a test rather than a
|
||||||
|
# comment. Checks the compositor-facing setting and the generated GTK config
|
||||||
|
# agree, and that both name something real.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
color_scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||||
|
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'gtk theme contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
theme_installed() {
|
||||||
|
local name="$1" dir
|
||||||
|
for dir in /usr/share/themes "$HOME/.themes" "$HOME/.local/share/themes"; do
|
||||||
|
[[ -d "$dir/$name/gtk-3.0" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── The names ColorScheme sets must exist ────────────────────────────────────
|
||||||
|
names="$(grep -oE 'root\.dark \? "[a-zA-Z0-9-]+" : "[a-zA-Z0-9-]+"' "$color_scheme" \
|
||||||
|
| grep -oE '"[a-zA-Z0-9-]+"' | tr -d '"' | grep -E '^adw-|^Adwaita' | sort -u)"
|
||||||
|
[[ -n "$names" ]] || fail 'could not find the GTK theme names in ColorScheme.qml -- this contract is not reading it correctly'
|
||||||
|
|
||||||
|
while read -r name; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
theme_installed "$name" \
|
||||||
|
|| fail "ColorScheme sets gtk-theme to \"$name\", which is not installed. GTK falls back to its light default when a theme is missing, so this produces light windows in dark mode with no error anywhere."
|
||||||
|
done <<<"$names"
|
||||||
|
|
||||||
|
# ── The generated GTK config must agree, in both directions ──────────────────
|
||||||
|
# Generated into a fixture rather than the live config, so running this cannot
|
||||||
|
# retheme the desktop it is running on.
|
||||||
|
fixture="$(mktemp -d /tmp/panama-gtk-theme.XXXXXX)"
|
||||||
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
|
|
||||||
|
for version in 3.0 4.0; do
|
||||||
|
mkdir -p "$fixture/gtk-$version"
|
||||||
|
cp "$repo_dir/config/dot/gtk-$version/settings.ini.template" "$fixture/gtk-$version/" \
|
||||||
|
|| fail "gtk-$version has no settings.ini.template -- the generated file would never be produced"
|
||||||
|
done
|
||||||
|
|
||||||
|
for scheme in dark light; do
|
||||||
|
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
|
||||||
|
|
||||||
|
for version in 3.0 4.0; do
|
||||||
|
generated="$fixture/gtk-$version/settings.ini"
|
||||||
|
[[ -r "$generated" ]] || fail "gtk-$version settings.ini was not generated for $scheme"
|
||||||
|
|
||||||
|
grep -q '@GTK_THEME@\|@PREFER_DARK@' "$generated" \
|
||||||
|
&& fail "gtk-$version settings.ini still contains an unsubstituted placeholder for $scheme"
|
||||||
|
|
||||||
|
theme="$(sed -n 's/^gtk-theme-name=//p' "$generated")"
|
||||||
|
prefer="$(sed -n 's/^gtk-application-prefer-dark-theme=//p' "$generated")"
|
||||||
|
|
||||||
|
theme_installed "$theme" \
|
||||||
|
|| fail "gtk-$version settings.ini names \"$theme\" for $scheme, which is not installed"
|
||||||
|
|
||||||
|
if [[ "$scheme" == "dark" ]]; then
|
||||||
|
[[ "$prefer" == "1" ]] || fail "gtk-$version asks for prefer-dark=$prefer in dark mode"
|
||||||
|
[[ "$theme" == *dark* ]] || fail "gtk-$version uses \"$theme\" in dark mode, which is not a dark theme"
|
||||||
|
else
|
||||||
|
[[ "$prefer" == "0" ]] || fail "gtk-$version asks for prefer-dark=$prefer in light mode"
|
||||||
|
[[ "$theme" != *dark* ]] || fail "gtk-$version uses \"$theme\" in light mode, which is a dark theme"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'gtk theme contract: PASS\n'
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# panama-keyring reports the login keyring's state, and the Settings page reads
|
||||||
|
# nothing but its JSON.
|
||||||
|
#
|
||||||
|
# The state that matters is LOCKED, and it is also the one that cannot be
|
||||||
|
# rehearsed on a real desktop: locking the login keyring breaks every saved
|
||||||
|
# password on the machine and can only be undone by typing the password into a
|
||||||
|
# dialog. So the secret service is stubbed here instead. Nothing touches the
|
||||||
|
# real keyring -- this contract is safe to run on the daily driver, which is the
|
||||||
|
# entire reason it is written this way.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'keyring helper contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)"
|
||||||
|
trap 'rm -rf "$stub_dir"' EXIT
|
||||||
|
|
||||||
|
# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides
|
||||||
|
# what the fake service reports, so one stub covers every case.
|
||||||
|
mkdir -p "$stub_dir/gi/repository"
|
||||||
|
cat >"$stub_dir/gi/__init__.py" <<'STUB'
|
||||||
|
def require_version(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
STUB
|
||||||
|
cat >"$stub_dir/gi/repository/__init__.py" <<'STUB'
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class _Collection:
|
||||||
|
def __init__(self, label, locked):
|
||||||
|
self._label = label
|
||||||
|
self._locked = locked
|
||||||
|
|
||||||
|
def get_label(self):
|
||||||
|
return self._label
|
||||||
|
|
||||||
|
def get_locked(self):
|
||||||
|
return self._locked
|
||||||
|
|
||||||
|
|
||||||
|
class _Service:
|
||||||
|
def get_collections(self):
|
||||||
|
mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked")
|
||||||
|
if mode == "nologin":
|
||||||
|
return [_Collection("Some App", False)]
|
||||||
|
return [_Collection("Login", mode == "locked"), _Collection("", False)]
|
||||||
|
|
||||||
|
|
||||||
|
class _ServiceFactory:
|
||||||
|
@staticmethod
|
||||||
|
def get_sync(_flags, _cancellable):
|
||||||
|
if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable":
|
||||||
|
raise RuntimeError("no secret service")
|
||||||
|
return _Service()
|
||||||
|
|
||||||
|
# unlock_sync is what the `unlock` action calls; record that it was reached.
|
||||||
|
@staticmethod
|
||||||
|
def _noop(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Secret:
|
||||||
|
class ServiceFlags:
|
||||||
|
LOAD_COLLECTIONS = 1
|
||||||
|
|
||||||
|
Service = _ServiceFactory
|
||||||
|
STUB
|
||||||
|
|
||||||
|
run() {
|
||||||
|
PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Unlocked: the normal state after any sign-in ─────────────────────────────
|
||||||
|
out="$(run unlocked)"
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out"
|
||||||
|
jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \
|
||||||
|
|| fail "an unlocked login keyring was misreported: $out"
|
||||||
|
|
||||||
|
# ── Locked: the state the whole card exists for ──────────────────────────────
|
||||||
|
out="$(run locked)"
|
||||||
|
jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a locked login keyring was not reported as locked: $out"
|
||||||
|
|
||||||
|
# ── No secret service at all is a state, not a crash ─────────────────────────
|
||||||
|
out="$(run unavailable)"
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$out" \
|
||||||
|
|| fail "a missing secret service produced no JSON, so the page would show nothing: $out"
|
||||||
|
jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a missing secret service must be reported with a reason: $out"
|
||||||
|
|
||||||
|
# ── No login keyring: not locked, because there is nothing to lock ───────────
|
||||||
|
out="$(run nologin)"
|
||||||
|
jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a machine with no login keyring must not report itself locked: $out"
|
||||||
|
|
||||||
|
# ── The daemon origin is reported, since it is the crash diagnostic ──────────
|
||||||
|
jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \
|
||||||
|
|| fail "the daemon origin must be one of pam/dbus/none/unknown"
|
||||||
|
|
||||||
|
printf 'keyring helper contract: PASS\n'
|
||||||
@@ -149,8 +149,23 @@ sleep 0.3
|
|||||||
qs_for_harness ipc call settings-system-test applyJson \
|
qs_for_harness ipc call settings-system-test applyJson \
|
||||||
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
||||||
|
|
||||||
|
# 10 seconds, not 4. The write path verifies each option by reading it back off
|
||||||
|
# the compositor and retries a refused batch, so a busy machine legitimately
|
||||||
|
# takes longer than a quick apply -- and this contract runs in a suite alongside
|
||||||
|
# other tests driving the same compositor. Failing at 4 seconds reported a
|
||||||
|
# product bug ("did not reach the compositor") for what was queueing.
|
||||||
|
# Re-issued periodically, because this contract and the LIVE shell both write to
|
||||||
|
# the same compositor. When the running Panama re-applies its own preferences --
|
||||||
|
# which it does on any store change -- it overwrites the values this test just
|
||||||
|
# set, and the read-back below then sees Panama's shipped defaults with the
|
||||||
|
# writer reporting no error at all. That combination is the signature: a
|
||||||
|
# rejected write leaves an error, a clobbered one does not.
|
||||||
typed=false
|
typed=false
|
||||||
for _ in $(seq 1 40); do
|
for attempt in $(seq 1 100); do
|
||||||
|
if (( attempt % 30 == 0 )); then
|
||||||
|
qs_for_harness ipc call settings-system-test applyJson \
|
||||||
|
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
||||||
|
fi
|
||||||
if [[ "$(read_option decoration:rounding)" == "7" \
|
if [[ "$(read_option decoration:rounding)" == "7" \
|
||||||
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
|
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
|
||||||
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
|
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
|
||||||
@@ -161,10 +176,14 @@ for _ in $(seq 1 40); do
|
|||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
if [[ "$typed" != true ]]; then
|
if [[ "$typed" != true ]]; then
|
||||||
|
# Report what the writer thinks as well as what the compositor holds. Those
|
||||||
|
# two disagreeing is a rejected write; both showing defaults is a write that
|
||||||
|
# never happened, and the messages should not look identical.
|
||||||
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
|
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
|
||||||
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
|
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
|
||||||
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
|
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
|
||||||
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
|
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
|
||||||
|
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verification must recognise those shapes as success, not report them rejected.
|
# Verification must recognise those shapes as success, not report them rejected.
|
||||||
|
|||||||
Reference in New Issue
Block a user