Compare commits
4
Commits
607acb0a2d
...
5562323eb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5562323eb8 | ||
|
|
52d896b054 | ||
|
|
e4409ed6aa | ||
|
|
f0321432b4 |
@@ -16,3 +16,7 @@ __pycache__/
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
/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]
|
||||
# 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
|
||||
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
||||
# appearing broken.
|
||||
gtk-theme-name=adw-gtk3-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
|
||||
gtk-application-prefer-dark-theme=1
|
||||
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||
|
||||
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
||||
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, 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 {
|
||||
title: "Screen lock"
|
||||
@@ -33,6 +38,64 @@ SettingsPage {
|
||||
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 {
|
||||
title: "Camera & microphone"
|
||||
subtitle: PrivacyState.anyActive
|
||||
|
||||
@@ -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 {} }
|
||||
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
# 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
|
||||
#
|
||||
# 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 ;;
|
||||
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"
|
||||
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||
|
||||
@@ -78,9 +78,21 @@ Singleton {
|
||||
root.lastError = "";
|
||||
|
||||
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.
|
||||
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
|
||||
|
||||
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||
// 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 = [
|
||||
["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 {
|
||||
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;
|
||||
|
||||
@@ -70,6 +70,34 @@ for dir in "${dirs[@]}"; do
|
||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||
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
|
||||
# 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
|
||||
|
||||
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 \
|
||||
'{"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
|
||||
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" \
|
||||
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
|
||||
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
|
||||
@@ -161,10 +176,14 @@ for _ in $(seq 1 40); do
|
||||
sleep 0.1
|
||||
done
|
||||
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), \
|
||||
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
|
||||
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
|
||||
|
||||
# Verification must recognise those shapes as success, not report them rejected.
|
||||
|
||||
Reference in New Issue
Block a user