Merge remote-tracking branch 'origin/main' into feat/panama-health

# Conflicts:
#	config/dot/quickshell/modules/settings/ServicesPage.qml
#	config/dot/quickshell/modules/settings/SettingsShell.qml
#	config/dot/quickshell/modules/settings/SettingsSidebar.qml
This commit is contained in:
Gabriel Brown
2026-08-18 11:23:07 -04:00
87 changed files with 4557 additions and 184 deletions
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# What this machine is, as JSON: {label, value} pairs in display order.
#
# GNOME's About panel answers "what am I running on" in one screen, and
# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
# uptime, package counts, shell, resolution, processor, memory, swap, disk and
# locale. Rows are ordered roughly the way fastfetch presents them: what the
# system is, then what is installed on it, then the hardware underneath.
#
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
# the vitals readout, and naming them again here would be a second source of
# truth that could disagree with the first. The page joins the two.
#
# Anything unreadable is omitted rather than reported as "Unknown". These are
# facts about hardware, and a row saying "Processor: Unknown" is noise where
# simply not having the row is not.
set -uo pipefail
facts=()
emit() {
[[ -n "${2:-}" ]] || return 0
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
}
# ── System ───────────────────────────────────────────────────────────────────
if [[ -r /etc/os-release ]]; then
# Sourced in a subshell so the variables cannot leak into this script.
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
emit "Operating system" "$os"
fi
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
# string"). Those are worse than nothing, so they are filtered out.
dmi() {
local value
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
case "$value" in
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
esac
printf '%s' "$value"
}
vendor="$(dmi sys_vendor)"
product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then
emit "Model" "$vendor $product"
else
emit "Model" "${product:-$vendor}"
fi
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)"
# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the
# leading "up " needs removing.
emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')"
# ── Installed ────────────────────────────────────────────────────────────────
# Counted rather than listed. rpm -qa on a full workstation is a few thousand
# lines and takes a moment, which is part of why this whole helper runs on
# demand rather than at startup.
packages=""
if command -v rpm >/dev/null 2>&1; then
rpm_count="$(rpm -qa 2>/dev/null | wc -l)"
[[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm"
fi
if command -v flatpak >/dev/null 2>&1; then
flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)"
if [[ "$flatpak_count" -gt 0 ]]; then
[[ -n "$packages" ]] && packages="$packages, "
packages="$packages$flatpak_count flatpak"
fi
fi
emit "Packages" "$packages"
# $SHELL is the login shell, which is the one worth reporting -- the shell this
# script happens to run under is an implementation detail of the caller.
if [[ -n "${SHELL:-}" ]]; then
shell_name="$(basename "$SHELL")"
shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
emit "Shell" "${shell_name}${shell_version:+ $shell_version}"
fi
emit "Locale" "${LANG:-}"
case "${XDG_SESSION_TYPE:-}" in
wayland) emit "Windowing system" "Wayland" ;;
x11) emit "Windowing system" "X11" ;;
esac
# ── Hardware ─────────────────────────────────────────────────────────────────
# The focused monitor's actual mode, including the fractional scale, since a
# 4500x3000 panel at 1.5 presents very differently from one at 1.
if command -v hyprctl >/dev/null 2>&1; then
emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \
| jq -r 'map(select(.focused)) + . | .[0]
| select(. != null)
| "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)"
fi
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
threads="$(nproc 2>/dev/null)"
if [[ -n "$cpu" ]]; then
# The marketing name usually already says "6-Core", so the thread count is
# the part that adds information.
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
emit "Processor" "$cpu"
fi
# Reported as the kernel sees it, which is a little under the sticker figure
# because firmware and integrated graphics reserve some before Linux starts.
#
# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure
# is stale before it finishes drawing. The Home page's vitals readout is where
# live numbers belong.
emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
emit "Swap" "$swap"
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
+145
View File
@@ -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())
@@ -57,6 +57,39 @@ has_accessible_bus() {
return 1
}
# The I2C bus that carries DDC/CI for one connector.
#
# There are two, and picking the wrong one finds no monitor at all:
#
# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a
# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and
# is the one ddcutil talks to.
#
# The connector's `ddc` symlink points at the classic I2C line used for EDID
# on HDMI and DVI. On a DisplayPort connector it still exists and still
# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5,
# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10
# immediately.
#
# So prefer the AUX child and fall back to the symlink. Note the readlink: the
# entries under /sys/class/drm are symlinks, and `find` does not follow the path
# it is given, so searching the unresolved path silently finds nothing.
bus_for_connector() {
local path="$1" real aux ddc
real="$(readlink -f "$path")"
aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)"
if [[ -n "$aux" ]]; then
printf '%s' "${aux#i2c-}"
return 0
fi
ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1
[[ -n "$ddc" ]] || return 1
ddc="$(basename "$ddc")"
printf '%s' "${ddc#i2c-}"
}
cmd_list() {
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
@@ -69,8 +102,7 @@ cmd_list() {
connector="$(basename "$path")"
connector="${connector#card*-}"
bus="$(basename "$(readlink -f "$path/ddc")")"
bus="${bus#i2c-}"
bus="$(bus_for_connector "$path")"
[[ "$bus" =~ ^[0-9]+$ ]] || continue
# A monitor that does not implement 0x10 is not an error; it simply
+138
View File
@@ -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())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# System locale, via localectl.
#
# panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set <locale>
#
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
# already know, which defeats the point of a picker, so codes are resolved
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
# The code stays visible as the row's detail, because it is what actually gets
# written and someone choosing between two Spanish variants needs to see it.
#
# The join happens in a single jq pass. Doing it per locale meant 327 jq
# invocations, which took long enough to be visible when opening the page.
#
# Setting the locale is a privileged operation: localectl goes through polkit,
# which prompts. It also only takes effect for programs started afterwards, so
# the caller is responsible for saying a sign-out is needed -- this script does
# not pretend the running session changed.
set -uo pipefail
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
cmd_get() {
localectl status 2>/dev/null \
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]'
}
cmd_list() {
local locales
locales="$(localectl list-locales 2>/dev/null)" || locales=""
if [[ -z "$locales" ]]; then
printf '[]\n'
return 0
fi
# Without iso-codes installed the codes are still perfectly usable; they
# just do not get friendly names. That is a degraded list, not a failure.
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
jq -Rn --rawfile raw /dev/stdin \
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
<<<"$locales"
return 0
fi
jq -Rn \
--slurpfile languages "$ISO_LANG" \
--slurpfile countries "$ISO_COUNTRY" \
--rawfile raw /dev/stdin '
# alpha_2 -> name, for both databases. Languages without a two-letter
# code cannot appear in a locale name, so they are simply absent.
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
| [ $raw
| split("\n")[]
| select(length > 0)
| . as $value
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
# not part of the human name.
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
| ($lang[$parts[0]] // $parts[0]) as $language
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
| {
value: $value,
label: (if $region then "\($language) (\($region))" else $language end),
detail: $value
}
]
| sort_by(.label)
' <<<"$locales"
}
cmd_set() {
local locale="${1:-}"
# Constrained rather than passed through: this reaches a privileged
# command, and the set of legal locale names is narrow and well known.
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2
}
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 2
}
localectl set-locale "LANG=$locale"
}
case "${1:-list}" in
list) cmd_list ;;
get) cmd_get ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
esac
+141 -6
View File
@@ -2,6 +2,8 @@
set -u
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
strict_delivery() {
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
}
@@ -67,18 +69,151 @@ adjust_microphone() {
show_volume "$target" microphone
}
brightness_percent() {
local output="$1" percent
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 1
printf '%s\n' "$percent"
}
brightness_error() {
local detail="$1" label="External brightness unavailable"
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
label="Brightness needs permission"
fi
show_message dialog-warning-symbolic "$label" || true
if command -v notify-send >/dev/null 2>&1; then
notify-send --app-name=Panama --icon=display-brightness-symbolic \
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
fi
}
discover_ddc_bus() {
local helper="$1" cache_file="$2" list_json focused selected error bus connector
command -v jq >/dev/null 2>&1 || {
brightness_error "jq is required to discover DDC/CI displays."
return 1
}
list_json="$("$helper" list 2>/dev/null)" || {
brightness_error "The external brightness helper could not inspect connected displays."
return 1
}
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
brightness_error "The external brightness helper returned invalid display information."
return 1
fi
error="$(jq -r '.error // empty' <<<"$list_json")"
if [[ -n $error ]]; then
brightness_error "$error"
return 1
fi
focused="$(hyprctl -j monitors 2>/dev/null \
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
| head -n1)"
selected="$(jq -r --arg connector "$focused" '
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
| [.bus, .connector]
| @tsv
' <<<"$list_json")"
IFS=$'\t' read -r bus connector <<<"$selected"
if [[ ! $bus =~ ^[0-9]+$ ]]; then
brightness_error "No connected monitor exposes DDC/CI brightness control."
return 1
fi
umask 077
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
printf '%s\n' "$bus"
}
adjust_ddc_brightness() {
local action="$1" step="$2"
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
local bus="" connector="" current target lock_fd
[[ -x $helper ]] || {
brightness_error "The external brightness helper is not installed."
return 0
}
mkdir -p "$runtime_dir" || return 0
chmod 700 "$runtime_dir" 2>/dev/null || true
exec {lock_fd}>"$lock_file" || return 0
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
flock -w 2 "$lock_fd" || return 0
if [[ -r $cache_file ]]; then
IFS=$'\t' read -r bus connector <"$cache_file" || true
[[ $bus =~ ^[0-9]+$ ]] || bus=""
fi
if [[ -n $bus ]]; then
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
if [[ ! $current =~ ^[0-9]+$ ]]; then
: >"$cache_file"
bus=""
fi
fi
if [[ -z $bus ]]; then
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
fi
if [[ ! $current =~ ^[0-9]+$ ]]; then
brightness_error "The selected monitor stopped responding over DDC/CI."
return 0
fi
if [[ $action == up ]]; then
target=$(( current + step ))
else
target=$(( current - step ))
fi
(( target > 100 )) && target=100
(( target < 0 )) && target=0
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
brightness_error "The selected monitor did not accept the brightness change."
return 0
fi
show_progress brightness "$target" "${target}%"
}
adjust_brightness() {
local action="${1:-}" step="${2:-5}" output percent
[[ $step =~ ^[0-9]+$ ]] || {
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
return 2
}
case "$action" in
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
up|down) ;;
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
esac
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
[[ $percent =~ ^[0-9]+$ ]] || return 0
show_progress brightness "$percent" "${percent}%"
# Laptop panels expose a kernel backlight class and remain the fastest,
# most reliable path. Desktops fall through to DDC/CI monitor control.
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
if percent="$(brightness_percent "$output")"; then
if [[ $action == up ]]; then
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
else
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
fi
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
percent="$(brightness_percent "$output")" || return 0
show_progress brightness "$percent" "${percent}%"
return
fi
adjust_ddc_brightness "$action" "$step"
}
media_action() {
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# System power profile, via the PowerProfiles D-Bus API.
#
# GNOME's Power panel offers Balanced / Performance / Power Saver; this is the
# same daemon behind it. On Fedora 44 the implementation is tuned-ppd rather
# than power-profiles-daemon, but it serves the same net.hadess.PowerProfiles
# interface, which is why this talks to the interface rather than to either
# binary -- powerprofilesctl is not even installed here.
#
# Setting a profile needs no privileges: the daemon accepts a property write
# from the active session user.
#
# Usage:
# panama-power-profile list -> {"profiles":[...],"active":"...","degraded":"..."}
# panama-power-profile set <name>
#
# PerformanceDegraded is reported because it is the one thing that makes the
# choice a lie: a thermally throttled laptop reports "performance" while
# behaving otherwise, and GNOME surfaces exactly this. It is an empty string
# when nothing is wrong.
set -uo pipefail
readonly BUS_NAME=net.hadess.PowerProfiles
readonly OBJECT=/net/hadess/PowerProfiles
emit_error() {
printf '{"profiles":[],"active":"","degraded":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v busctl >/dev/null 2>&1 || emit_error 'busctl is not available'
property() {
busctl get-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" "$1" 2>/dev/null
}
cmd_list() {
# A machine with no power-profiles daemon is a normal state -- plenty of
# desktops have none -- so it is reported rather than treated as a failure.
busctl status "$BUS_NAME" >/dev/null 2>&1 \
|| emit_error 'No power profile service is running. GNOME uses power-profiles-daemon; Fedora ships tuned-ppd.'
local active degraded profiles
active="$(property ActiveProfile | sed 's/^s //; s/"//g')"
degraded="$(property PerformanceDegraded | sed 's/^s //; s/"//g')"
# Profiles is an array of dicts, which busctl renders flat:
# v aa{sv} 3 2 "Profile" s "power-saver" "Driver" s "tuned" 2 "Profile" ...
# so each profile is the string following its own "Profile" marker. Matching
# the marker matters: "Driver" values sit in the same stream, and on this
# machine the driver is called "tuned", which a looser pattern happily
# reports as a fourth profile that does not exist.
profiles="$(property Profiles \
| grep -oE '"Profile" s "[a-z-]+"' \
| sed 's/.*s "//; s/"$//' \
| awk '!seen[$0]++')"
[[ -n "$profiles" ]] || emit_error 'The power profile service reported no profiles.'
jq -cn \
--arg active "$active" \
--arg degraded "$degraded" \
--argjson profiles "$(printf '%s\n' "$profiles" | jq -Rn '[inputs | select(length > 0)]')" \
'{profiles: $profiles, active: $active, degraded: $degraded, error: ""}'
}
cmd_set() {
local profile="${1:-}"
# Constrained rather than passed through: this reaches a system service.
[[ "$profile" =~ ^[a-z-]+$ ]] || {
printf 'panama-power-profile: refusing a profile name with unexpected characters\n' >&2
return 2
}
busctl set-property "$BUS_NAME" "$OBJECT" "$BUS_NAME" ActiveProfile s "$profile" 2>&1 >/dev/null \
| head -2 >&2
return 0
}
case "${1:-list}" in
list) cmd_list ;;
set) shift; cmd_set "${1:-}" ;;
*) printf 'usage: panama-power-profile [list|set <profile>]\n' >&2; exit 2 ;;
esac
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Device security facts, as JSON.
#
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
# disk encryption, SELinux mode and the firewall are set in firmware, at install
# time, or by system policy -- none of them is a desktop preference, and a
# settings app that offered to toggle them would either fail or do something
# far-reaching from a switch that looks like any other.
#
# What it is for is answering "is this machine set up the way I think it is",
# which is the question GNOME's Device Security panel exists to answer and which
# otherwise needs five commands and root.
#
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
# the UI can highlight what deserves attention without hard-coding the meaning
# of each string. Anything that cannot be determined reports "Unknown" with
# ok:false rather than guessing, because a security readout that quietly reports
# "fine" when it failed to look is worse than no readout.
set -uo pipefail
fact() {
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
'{label: $label, value: $value, ok: $ok, detail: $detail}'
}
facts=()
# ── Secure Boot ──────────────────────────────────────────────────────────────
if command -v mokutil >/dev/null 2>&1; then
case "$(mokutil --sb-state 2>/dev/null)" in
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
esac
elif [[ -d /sys/firmware/efi ]]; then
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
else
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
fi
# ── TPM ──────────────────────────────────────────────────────────────────────
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
if [[ -n "$tpm_major" ]]; then
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
elif [[ -e /sys/class/tpm/tpm0 ]]; then
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
else
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
fi
# ── Disk encryption ──────────────────────────────────────────────────────────
# Counts LUKS mappings rather than naming them: which volume is encrypted is
# more detail than this readout needs, and device names are not meaningful here.
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
if (( crypt_count > 0 )); then
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
else
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
fi
# ── SELinux ──────────────────────────────────────────────────────────────────
if command -v getenforce >/dev/null 2>&1; then
case "$(getenforce 2>/dev/null)" in
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
esac
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
else
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
fi
fi
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
@@ -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,84 @@ case "$scheme" in
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
esac
# ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an
# open session changes now instead of at next launch -- tmux applies a
# source-file to every attached client immediately.
tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
status_tmux="skipped"
if [[ -r "$tmux_theme" ]]; then
if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
&& mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
status_tmux="written"
# Only if a server is actually running; `tmux source-file` would
# otherwise start one just to theme it.
if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
&& status_tmux="applied to running sessions"
fi
else
rm -f "$tmux_dir/current-theme.conf.tmp"
status_tmux="failed"
fi
fi
# ── btop ─────────────────────────────────────────────────────────────────────
# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
# rewrites the whole file on exit -- so the config is not symlinked into Panama
# and not replaced wholesale here; just this one value is edited, and btop keeps
# it on the next write.
#
# btop reads its theme once at startup, so a running instance keeps the old
# colours until it is restarted. That is acceptable for a monitor you open when
# you want it, and forcing a restart would kill a process the user is watching.
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
btop_theme="tokyonight-moon"
[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
status_btop="skipped"
if [[ -w "$btop_conf" ]]; then
if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
status_btop="written"
else
status_btop="failed"
fi
fi
# ── 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"
@@ -54,4 +137,13 @@ if [[ -r "$theme_file" ]]; then
fi
fi
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
# Every target reports what actually happened. A helper that says only
# "kitty: applied" while silently skipping three other applications is how a
# half-applied theme goes unnoticed.
jq -cn \
--arg scheme "$scheme" \
--arg kitty "$status_kitty" \
--arg gtk "$status_gtk" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera.
#
# GNOME's Wi-Fi panel has this and it is the single most-used thing in it.
# The payload is the de-facto WIFI: URI that Android and iOS both scan:
#
# WIFI:T:WPA;S:<ssid>;P:<passphrase>;H:<hidden>;;
#
# HANDLING THE PASSPHRASE
#
# This image contains the network password in machine-readable form. Anyone who
# can read the file can read the password, so:
#
# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it
# never reaches disk and disappears at logout -- not /tmp, which is shared;
# * it is created with umask 077;
# * the passphrase is never printed, never passed as an argument (argv is
# world-readable via /proc), and never appears in an error message.
#
# It is piped to qrencode on stdin for that last reason.
#
# Usage:
# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]}
# panama-wifi-qr qr <name> -> {"path":"/run/user/…/….png"}
set -uo pipefail
emit_error() {
printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
exit 0
}
command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available'
command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn'
cmd_list() {
local rows=() name ssid psk
while IFS= read -r name; do
[[ -n "$name" ]] || continue
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || ssid="$name"
# Only networks whose passphrase this user can actually read are
# shareable. An enterprise network has no passphrase to share at all,
# and a QR code for one would simply not work.
psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)"
rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \
--argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \
'{name: $name, ssid: $ssid, shareable: $shareable}')")
done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \
| awk -F: '$2 == "802-11-wireless" { print $1 }')
if [[ ${#rows[@]} -eq 0 ]]; then
printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n'
return 0
fi
printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
}
# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID
# containing a semicolon would otherwise terminate the field early and produce a
# QR code for a different network entirely.
#
# Trailing newlines are stripped as well. nmcli terminates every value with one,
# and left in place it lands INSIDE the payload -- the code still decodes here,
# but a newline in the middle of a WIFI: URI is not something every phone's
# scanner tolerates, and the failure would look like "the QR code just does not
# work on my phone".
escape_field() {
sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \
| tr -d '\n'
}
cmd_qr() {
local name="${1:-}"
[[ -n "$name" ]] || emit_error 'no network named'
local ssid hidden psk_file payload_file out_dir out_file
ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)"
[[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"."
hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)"
[[ "$hidden" == "yes" ]] && hidden=true || hidden=false
out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama"
umask 077
mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory'
chmod 700 "$out_dir" 2>/dev/null || true
# Named after the connection, hashed, so repeated shares reuse one file
# instead of accumulating images of the password.
out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png"
# Built in a file rather than a variable that could be echoed, and piped to
# qrencode on stdin so the passphrase never appears in argv.
payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file'
trap 'rm -f "$payload_file"' RETURN
{
printf 'WIFI:T:WPA;S:'
printf '%s' "$ssid" | escape_field
printf ';P:'
nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field
printf ';H:%s;;' "$hidden"
} >"$payload_file"
if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then
emit_error "Could not generate a QR code for \"$name\"."
fi
chmod 600 "$out_file" 2>/dev/null || true
jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}'
}
case "${1:-list}" in
list) cmd_list ;;
qr) shift; cmd_qr "${1:-}" ;;
*) printf 'usage: panama-wifi-qr [list|qr <name>]\n' >&2; exit 2 ;;
esac