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:
Executable
+127
@@ -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
@@ -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
|
||||
|
||||
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())
|
||||
Executable
+98
@@ -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
|
||||
@@ -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
@@ -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
@@ -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}'
|
||||
|
||||
Executable
+121
@@ -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
|
||||
Reference in New Issue
Block a user