Finish the wonderland: System told truthfully, in eight tabs instead of ten

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 23:31:52 -04:00
parent 9ffaf45a4d
commit be0e55214b
57 changed files with 5040 additions and 925 deletions
+42 -4
View File
@@ -3,10 +3,11 @@
# 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.
# fastfetch answers it in more detail; this covers both -- model, OS, Panama's
# own revision, firmware, Secure Boot, 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
@@ -43,6 +44,20 @@ dmi() {
printf '%s' "$value"
}
# Which Panama this is, from the checkout it is running out of. A version
# string restated in a file would be a second source of truth that goes stale
# the moment somebody forgets to bump it; the commit cannot. Absent outside a
# git checkout -- an installed copy without .git legitimately has no answer.
# The script's own directory, not a counted number of "..": this tree is
# symlinked into ~/.config/quickshell, and git discovers the checkout by
# walking up from wherever it is told to start.
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1; then
panama_rev="$(git -C "$repo_root" describe --tags --always --dirty 2>/dev/null)"
panama_age="$(git -C "$repo_root" log -1 --format=%cr 2>/dev/null)"
emit "Panama" "${panama_rev}${panama_age:+ · $panama_age}"
fi
vendor="$(dmi sys_vendor)"
product="$(dmi product_name)"
if [[ -n "$vendor" && -n "$product" ]]; then
@@ -51,6 +66,29 @@ else
emit "Model" "${product:-$vendor}"
fi
# The BIOS/UEFI version, which is the one firmware fact a person is ever asked
# for. DMI first because it is a free file read; bootctl only as the fallback,
# since it reports "n/a" on this class of machine and shelling out for that
# would be a slower way to learn nothing.
firmware="$(dmi bios_version)"
if [[ -z "$firmware" ]] && command -v bootctl >/dev/null 2>&1; then
firmware="$(bootctl status 2>/dev/null \
| awk -F': *' '/^ *Firmware:/ { print $2; exit }' \
| sed 's/ *(n\/a)$//')"
[[ "$firmware" == "n/a"* ]] && firmware=""
fi
bios_date="$(dmi bios_date)"
emit "Firmware" "${firmware:+${firmware}${bios_date:+ · $bios_date}}"
# Absent-tolerant on purpose: mokutil is not installed everywhere, and a
# machine that cannot answer "is Secure Boot on" should not claim it is off.
if command -v mokutil >/dev/null 2>&1; then
case "$(mokutil --sb-state 2>/dev/null)" in
*"SecureBoot enabled"*) emit "Secure Boot" "Enabled" ;;
*"SecureBoot disabled"*) emit "Secure Boot" "Disabled" ;;
esac
fi
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
emit "Kernel" "$(uname -r 2>/dev/null)"
+61 -11
View File
@@ -200,6 +200,10 @@ CHECK_TITLES = {
"integration.bluebubbles": "BlueBubbles",
"integration.home-assistant": "Home Assistant",
"integration.calendar": "Calendar",
# Every id in CHECK_ORDER needs an entry here: unavailable_check() looks the
# title up by id, so a missing one turned a probe that merely timed out into
# a KeyError that took the whole scan down with it.
"panama.updates": "Software updates",
"panama.runtime-links": "Panama runtime links",
"panama.vicinae-commands": "Panama commands",
"panama.selected-terminal": "Selected terminal",
@@ -294,6 +298,14 @@ def check_json(check: Check) -> dict[str, object]:
result: dict[str, object] = {"id": check.id, "group": check.group, "title": check.title, "status": check.status, "detail": check.detail}
if check.action is not None:
result["action"] = action_json(check.action)
# The exact command a repair would run, so the page can show it before
# anybody presses the button. Taken from REPAIR_COMMANDS rather than
# written out again in the UI -- a second copy is a copy that can be wrong,
# and the whole point of showing it is that it is what actually happens.
# Only the authored-command repairs have one; the three in-process repairs
# are Python, not a command line, and claiming otherwise would be a lie.
if check.id in REPAIR_COMMANDS:
result["repairCommand"] = " ".join(REPAIR_COMMANDS[check.id])
return result
@@ -659,14 +671,14 @@ def check_updates(config: DoctorConfig) -> Check:
if newest != running:
return Check("panama.updates", "panama-tools", "Software updates", "warning",
f"A newer kernel is installed than the one running ({running} → {newest}). Restart to use it.",
action=Action("open", "Open Software Update"))
action=Action("open", "Open Software Update", target="updates"))
try:
payload = json.loads(cache.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked yet.",
action=Action("open", "Open Software Update"))
action=Action("open", "Open Software Update", target="updates"))
checked_at = int(payload.get("checkedAt", 0))
age_days = (time.time() - checked_at) / 86400 if checked_at else 999
@@ -677,11 +689,11 @@ def check_updates(config: DoctorConfig) -> Check:
if security > 0:
return Check("panama.updates", "panama-tools", "Software updates", "warning",
f"{security} pending update{'' if security == 1 else 's'} carry a security advisory.",
action=Action("open", "Open Software Update"))
action=Action("open", "Open Software Update", target="updates"))
if age_days > 7:
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
"Updates have not been checked in over a week.",
action=Action("open", "Open Software Update"))
action=Action("open", "Open Software Update", target="updates"))
if total > 0:
return Check("panama.updates", "panama-tools", "Software updates", "ok",
f"{total} update{'' if total == 1 else 's'} available, none carrying a security advisory.")
@@ -799,14 +811,18 @@ def unavailable_versions() -> list[dict[str, str]]:
return [{"id": name, "version": "unavailable"} for name in ("hyprland", "quickshell", "fedora", "panama")]
def collect_checks(config: DoctorConfig) -> list[Check]:
probes: dict[str, Callable[[], Check]] = {
def probe_table(config: DoctorConfig) -> dict[str, Callable[[], Check]]:
return {
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config), "desktop.portal-stability": lambda: check_portal_stability(config), "desktop.document-portal": lambda: check_document_portal(config),
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.video-wallpaper": lambda: check_video_wallpaper(config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
"panama.updates": lambda: check_updates(config), "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
}
def collect_checks(config: DoctorConfig) -> list[Check]:
probes = probe_table(config)
with ThreadPoolExecutor(max_workers=8) as executor:
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
checks: list[Check] = []
@@ -818,11 +834,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
return checks
def snapshot(config: DoctorConfig) -> dict[str, object]:
try:
checks = collect_checks(config)
except Exception:
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
def snapshot_of(config: DoctorConfig, checks: list[Check]) -> dict[str, object]:
counts = {status: sum(check.status == status for check in checks) for status in ("ok", "warning", "error", "unconfigured")}
overall: Literal["healthy", "warning", "error"] = "error" if counts["error"] else "warning" if counts["warning"] else "healthy"
session = "hyprland" if "hyprland" in os.environ.get("XDG_CURRENT_DESKTOP", "").casefold() else "other"
@@ -833,6 +845,30 @@ def snapshot(config: DoctorConfig) -> dict[str, object]:
return {"schemaVersion": 1, "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "summary": {"status": overall, "healthy": counts["ok"], "warnings": counts["warning"], "errors": counts["error"], "unconfigured": counts["unconfigured"]}, "context": {"session": session, "versions": versions}, "checks": [check_json(check) for check in checks]}
def snapshot(config: DoctorConfig) -> dict[str, object]:
try:
checks = collect_checks(config)
except Exception:
checks = [unavailable_check(check_id) for check_id in CHECK_ORDER]
return snapshot_of(config, checks)
def single_check(check_id: str, config: DoctorConfig) -> dict[str, object]:
"""One probe, in the shape of a whole snapshot.
Re-checking a single row after a repair should not cost the other
twenty-nine probes. The reply is the same envelope a full scan produces --
same schema, same summary arithmetic, same check object -- so the caller
validates it with the code it already has, rather than growing a second
reader for a second shape that could drift from the first.
"""
try:
checks = [probe_table(config)[check_id]()]
except Exception:
checks = [unavailable_check(check_id)]
return snapshot_of(config, checks)
def repair_authored_command(check_id: str, config: DoctorConfig) -> RepairResult:
command = REPAIR_COMMANDS[check_id]
if check_id == "desktop.quickshell":
@@ -1139,8 +1175,22 @@ def main(argv: list[str]) -> int:
output.add_argument("--json", action="store_true")
output.add_argument("--summary", action="store_true")
parser.add_argument("--repair", metavar="CHECK_ID")
parser.add_argument("verb", nargs="*", metavar="check CHECK_ID",
help="check CHECK_ID -- re-run one probe and print it as a snapshot")
args = parser.parse_args(argv)
if args.verb:
# An unknown id is refused rather than answered with an empty snapshot:
# a caller that asked for a check that does not exist has a bug, and a
# valid-looking reply with no rows in it would hide it.
if len(args.verb) != 2 or args.verb[0] != "check" or args.verb[1] not in CHECK_ORDER:
parser.error("usage: panama-doctor check CHECK_ID")
if args.repair is not None or args.summary:
parser.error("check takes no other output mode")
print(json.dumps(single_check(args.verb[1], config_from_environment()),
separators=(",", ":"), sort_keys=False))
return 0
if args.repair is not None:
if args.repair not in REPAIR_IDS or args.summary:
result = RepairResult(args.repair, False, 2, "This health check has no authored repair.")
+128 -12
View File
@@ -2,9 +2,25 @@
# System locale, via localectl.
#
# panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale list -> [{value, label, detail}]
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
# panama-locale set <locale>
# panama-locale categories -> the category names, one per line
# panama-locale overrides -> {LC_TIME: "...", ...}, "" for none
# panama-locale get <category> -> the override, or "" for "match language"
# panama-locale set <category> <locale|"">
#
# The categories are the five that a person actually chooses independently of
# their language: LC_TIME, LC_NUMERIC, LC_MONETARY, LC_MEASUREMENT, LC_PAPER.
# Someone reading in English while writing dates and currency the way their
# country does is the ordinary case, not an exotic one.
#
# An empty value means "match language", which is the ABSENCE of an override
# rather than a value equal to LANG -- the two behave the same today and
# diverge the moment the language changes. localectl replaces /etc/locale.conf
# with exactly the assignments it is given, so unsetting one category means
# re-issuing all the others. That is why cmd_set_category reads the current
# file first: passing only the survivors is the only way to remove one.
#
# 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
@@ -24,11 +40,63 @@ 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
readonly LOCALE_CONF=/etc/locale.conf
readonly CATEGORIES=(LC_TIME LC_NUMERIC LC_MONETARY LC_MEASUREMENT LC_PAPER)
is_category() {
local candidate="$1" name
for name in "${CATEGORIES[@]}"; do
[[ "$name" == "$candidate" ]] && return 0
done
return 1
}
# LANG plus every category override, as VAR=value lines. /etc/locale.conf is
# what localectl writes and is world-readable, so it is read directly rather
# than scraped out of `localectl status`, whose multi-variable output wraps
# across continuation lines and has no stable machine form.
current_assignments() {
[[ -r "$LOCALE_CONF" ]] || return 0
while IFS= read -r line; do
line="${line%%#*}"
[[ "$line" =~ ^[[:space:]]*(LANG|LANGUAGE|LC_[A-Z_]+)=(.*)$ ]] || continue
local name="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}"
# locale.conf quotes values; localectl takes them bare.
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
[[ -n "$value" ]] && printf '%s=%s\n' "$name" "$value"
done <"$LOCALE_CONF"
}
cmd_get() {
localectl status 2>/dev/null \
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]'
local name="${1:-LANG}"
if [[ "$name" != "LANG" ]] && ! is_category "$name"; then
printf 'panama-locale: %s is not a category this manages\n' "$name" >&2
return 2
fi
local value
value="$(current_assignments | awk -F= -v want="$name" '$1 == want { print $2; exit }')"
# A machine with no /etc/locale.conf still has a LANG, and localectl is the
# one that knows it. Categories have no such fallback: absent there means
# absent, which is exactly "match language".
if [[ -z "$value" && "$name" == "LANG" ]]; then
value="$(localectl status 2>/dev/null \
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
| tr -d '[:space:]')"
fi
printf '%s\n' "$value"
}
# Every category override in one answer: {"LC_TIME": "de_DE.UTF-8", ...} with
# "" for the ones that match the language. Five separate `get` calls would be
# five processes for one screenful of state.
cmd_overrides() {
local assignments name
assignments="$(current_assignments)"
{ for name in "${CATEGORIES[@]}"; do
printf '%s\t%s\n' "$name" \
"$(awk -F= -v want="$name" '$1 == want { print $2; exit }' <<<"$assignments")"
done; } | jq -Rn '[inputs | split("\t") | {key: .[0], value: (.[1] // "")}] | from_entries'
}
cmd_list() {
@@ -75,10 +143,11 @@ cmd_list() {
' <<<"$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.
# A locale name that is both well formed and actually installed. Everything
# reaching localectl goes through this: it is a privileged command, and the set
# of legal locale names is narrow and well known.
installed_locale() {
local locale="$1"
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
return 2
@@ -87,12 +156,59 @@ cmd_set() {
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
return 2
}
}
cmd_set() {
local locale="${1:-}"
installed_locale "$locale" || return 2
# LANG is set on its own rather than through the rewrite path: changing the
# language must not quietly drop category overrides somebody chose, and
# localectl merges a lone LANG= assignment into the existing file.
localectl set-locale "LANG=$locale"
}
cmd_set_category() {
local name="${1:-}" locale="${2:-}"
is_category "$name" || {
printf 'panama-locale: %s is not a category this manages\n' "$name" >&2
return 2
}
local assignments=() line
while IFS= read -r line; do
[[ "${line%%=*}" == "$name" ]] && continue
assignments+=("$line")
done < <(current_assignments)
if [[ -n "$locale" ]]; then
installed_locale "$locale" || return 2
assignments+=("$name=$locale")
fi
# An empty file would leave the system with no LANG at all. Nothing here
# should be able to produce that, but refusing is cheaper than explaining.
(( ${#assignments[@]} > 0 )) || {
printf 'panama-locale: refusing to clear every locale setting\n' >&2
return 2
}
localectl set-locale "${assignments[@]}"
}
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 ;;
get) shift; cmd_get "${1:-LANG}" ;;
set)
shift
if is_category "${1:-}"; then
cmd_set_category "${1:-}" "${2:-}"
else
cmd_set "${1:-}"
fi
;;
categories) printf '%s\n' "${CATEGORIES[@]}" ;;
overrides) cmd_overrides ;;
*)
printf 'usage: panama-locale [list|categories|overrides|get [category]|set [category] <locale>]\n' >&2
exit 2
;;
esac
@@ -39,6 +39,13 @@ KEEP = 15
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
# A label somebody types, kept inside the envelope rather than in the filename.
# The name on disk stays the timestamp SNAPSHOT_RE describes: it is what
# orders the list, what prune and restore match against, and what confines a
# restore to this directory. A user-supplied filename would put all three of
# those in the caller's hands.
LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$")
class BackupError(RuntimeError):
pass
@@ -210,12 +217,27 @@ def clean_transaction_artifacts() -> None:
fsync_directory(TRANSACTION_PARENT)
# Quickshell's FileView writes atomically through QSaveFile, which stages at
# "<name>.XXXXXX" -- no leading dot, six random characters -- and renames. A
# shell killed mid-write leaves that file behind forever: nothing ever looks at
# it again, and ~/.config/panama slowly fills with half-remembered copies of
# the settings store. Panama's own writer uses the dotted prefixes above, so
# this pattern is only ever somebody else's leftover.
QSAVEFILE_RE = re.compile(r"^(settings\.json|panama-home\.json)\.[A-Za-z0-9]{6}$")
# A write in flight looks exactly like a leaked one. Only files older than this
# are swept, which is several orders of magnitude longer than a settings write
# takes and short enough that nobody accumulates them.
STALE_AFTER_SECONDS = 3600
def clean_stale_atomic_files() -> None:
locations = (
(SETTINGS.parent, (".settings.json.",)),
(HOME_STATE.parent, (".panama-home.json.",)),
(BACKUP_DIR, (".settings-",)),
)
now = time.time()
for directory, prefixes in locations:
if not directory.exists():
continue
@@ -223,8 +245,15 @@ def clean_stale_atomic_files() -> None:
fail(f"{directory} is not a safe directory.")
changed = False
for child in directory.iterdir():
if not any(child.name.startswith(prefix) for prefix in prefixes):
continue
owned = any(child.name.startswith(prefix) for prefix in prefixes)
if not owned:
if QSAVEFILE_RE.fullmatch(child.name) is None:
continue
try:
if now - child.stat().st_mtime < STALE_AFTER_SECONDS:
continue
except OSError:
continue
# Only Panama's hidden atomic-write names are eligible. A matching
# directory is unexpected and is never recursively removed.
if child.is_dir() and not child.is_symlink():
@@ -359,7 +388,23 @@ def prune_snapshots() -> None:
durable_remove(old)
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
def sanitize_label(raw: str) -> str:
"""A typed name, reduced to what can safely sit in a JSON envelope.
Collapsed rather than refused: somebody who typed two spaces or a trailing
one meant the obvious thing, and losing their snapshot over it would be
absurd. Anything still outside the charset after that is refused, because
at that point they typed something this does not mean to carry.
"""
collapsed = " ".join(raw.split())
if not collapsed:
return ""
if LABEL_RE.fullmatch(collapsed) is None:
fail("That name cannot be used.")
return collapsed
def save_snapshot(*, require_any: bool, validate: bool, label: str = "") -> Path | None:
try:
desktop_present, desktop = current_store(
SETTINGS, "The current settings file"
@@ -386,6 +431,11 @@ def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
envelope["desktop"]["data"] = desktop
if home_present:
envelope["home"]["data"] = home
# Absent rather than empty when there is no label, so a snapshot taken
# automatically before a risky action is distinguishable from one somebody
# deliberately named "".
if label:
envelope["label"] = label
destination = next_snapshot_path()
atomic_write_json(destination, envelope)
@@ -515,6 +565,37 @@ def command_save(arguments: list[str]) -> None:
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
def command_create(arguments: list[str]) -> None:
"""save, with a name on it.
Kept as its own verb rather than an optional argument to `save`: `save`
already takes live Home state as its first argument, and overloading that
position by type is how a Home payload eventually gets read as a label.
"""
label = sanitize_label(arguments[0]) if arguments else ""
if len(arguments) > 1:
write_live_home(arguments[1])
destination = save_snapshot(require_any=True, validate=True, label=label)
assert destination is not None
print(json.dumps({"saved": destination.name, "label": label},
separators=(",", ":")))
def command_delete(arguments: list[str]) -> None:
"""Remove one snapshot, named the way restore names one.
Goes through snapshot_source, which is what confines the name to this
directory -- the same gate a restore passes. A delete that resolved paths
its own way would be a second boundary to keep correct, and the weaker of
the two is the one that gets used.
"""
if not arguments:
fail("Which snapshot?")
source = snapshot_source(arguments[0])
durable_remove(source)
print(json.dumps({"deleted": source.name}, separators=(",", ":")))
def snapshot_files() -> list[Path]:
ensure_directory(BACKUP_DIR)
return sorted(
@@ -533,6 +614,7 @@ def snapshot_files() -> list[Path]:
def command_list() -> None:
output: list[dict[str, Any]] = []
for path in snapshot_files():
label = ""
try:
value = read_json(path, "A snapshot")
if is_v2_envelope(value):
@@ -540,6 +622,9 @@ def command_list() -> None:
keys = len(desktop["data"]) if desktop["present"] else 0
else:
keys = len(value)
raw_label = value.get("label")
if isinstance(raw_label, str) and LABEL_RE.fullmatch(raw_label):
label = raw_label
except BackupError:
keys = 0
raw = path.name.removeprefix("settings-").removesuffix(".json")
@@ -547,7 +632,14 @@ def command_list() -> None:
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
)
output.append({"name": path.name, "when": pretty, "keys": keys})
# Size on disk, so the page can say what fifteen snapshots actually
# cost rather than leaving it as an unbounded mystery.
try:
size = path.stat().st_size
except OSError:
size = 0
output.append({"name": path.name, "when": pretty, "keys": keys,
"bytes": size, "label": label})
print(json.dumps(output, separators=(",", ":")))
@@ -628,12 +720,17 @@ def main() -> None:
arguments = sys.argv[2:]
if command == "save":
command_save(arguments)
elif command == "create":
command_create(arguments)
elif command == "list":
command_list()
elif command == "restore":
command_restore(arguments)
elif command == "delete":
command_delete(arguments)
else:
fail("usage: panama-settings-backup [save|list|restore <name>]")
fail("usage: panama-settings-backup "
"[save|create [name]|list|restore <name>|delete <name>]")
if __name__ == "__main__":
@@ -64,6 +64,8 @@ CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
HIDDEN = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*category:\s*"([a-z-]+)"\s*\}')
def categories() -> list[tuple[str, str, list[tuple[str, str]]]]:
@@ -87,6 +89,33 @@ def categories() -> list[tuple[str, str, list[tuple[str, str]]]]:
return found
def hidden_leaves() -> list[tuple[str, str]]:
"""Leaves that are routable but draw no tab, as (id, label).
The manual is the one: reference material rather than a control surface, so
it is opened from About, a deep link, or a launcher command rather than
found by scanning a tab strip. Which makes the command below the main way
anybody reaches it, and dropping it because it has no tab would be exactly
the wrong conclusion.
They live outside `categories` because the reader above requires each
category to end `tabs: [...] }` and cross-checks every `page:` inside that
array; a hidden leaf declared in there would break both.
"""
source = read(ROUTES)
block = re.search(r"readonly property var hiddenLeaves: \[(.*?)\n \]", source, re.S)
if not block:
return []
found = HIDDEN.findall(block.group(1))
declared = len(re.findall(r'\bpage:\s*"', block.group(1)))
if len(found) != declared:
raise ParseError(
f"read {len(found)} of the {declared} hidden leaves in "
"SettingsRoutes.qml; that array no longer looks the way this "
"reader expects")
return [(page, label) for page, label, _category in found]
def pages() -> list[tuple[str, str]]:
"""The leaf pages, in sidebar order, as (id, label).
@@ -97,6 +126,7 @@ def pages() -> list[tuple[str, str]]:
leaves: list[tuple[str, str]] = []
for page, label, tabs in categories():
leaves += tabs or [(page, label)]
leaves += hidden_leaves()
ids = [page for page, _label in leaves]
duplicated = sorted({page for page in ids if ids.count(page) > 1})
if duplicated:
@@ -43,6 +43,8 @@ CATEGORY = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*icon:\s*"[^"]*",\s*tabs:\s*\[([^\]]*)\]\s*\}',
re.S)
TAB = re.compile(r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)"\s*\}')
HIDDEN = re.compile(
r'\{\s*page:\s*"([a-z-]+)",\s*label:\s*"([^"]+)",\s*category:\s*"([a-z-]+)"\s*\}')
class SchemaError(RuntimeError):
@@ -61,9 +63,11 @@ def read_titles():
if not block:
raise SchemaError("could not find the categories array in SettingsRoutes.qml")
titles = {}
labels = {}
read = 0
for page, label, tabs in CATEGORY.findall(block.group(1)):
found = TAB.findall(tabs)
labels[page] = label
read += 1 + len(found)
if found:
titles.update({tab: f"{label} {tab_label}" for tab, tab_label in found})
@@ -77,6 +81,19 @@ def read_titles():
raise SchemaError(
f"read {read} of the {declared} pages in SettingsRoutes.qml; the "
"categories array no longer looks the way this reader expects")
# Leaves that are routable but draw no tab -- the manual -- are declared
# outside the categories array, because the reader above requires each
# category to end `tabs: [...] }` and accounts for every `page:` inside it.
# They are still pages somebody lands on, so they are still named here.
hidden = re.search(r"readonly property var hiddenLeaves: \[(.*?)\n \]", text, re.S)
if hidden:
found = HIDDEN.findall(hidden.group(1))
if len(found) != len(re.findall(r'\bpage:\s*"', hidden.group(1))):
raise SchemaError(
"the hiddenLeaves array no longer looks the way this reader expects")
for page, label, category in found:
titles[page] = f"{labels.get(category, category)} {label}"
return titles
@@ -58,6 +58,17 @@ MACHINE_SPECIFIC = {
# machine where the file exists. Carried, then checked on arrival.
PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"}
# A preview is something a person reads before pressing Import. Past a screen or
# two it stops being read and starts being scrolled, so the list is capped and
# the count says how many there really are -- the import itself still applies
# every change, because the cap is about what is shown, not what is done.
CHANGE_LIMIT = 40
# Long enough for a wallpaper path or a theme name, short enough that no single
# row can push the rest off the screen. Values are rendered for display here,
# never re-parsed, so a truncated one costs nothing.
VALUE_LIMIT = 120
class BoundaryError(RuntimeError):
"""A user-visible validation or file failure."""
@@ -158,6 +169,29 @@ def fits(entry: dict, value) -> str:
return ""
def render(value) -> str:
"""One setting's value as a line of text a person can compare.
Rendered here rather than in the page because the page would have to know
the difference between a JSON setting and a scalar one to do it, and that
knowledge already lives in the schema on this side. A value with no entry
at all is "not set" rather than "null": the two look identical in JSON and
mean quite different things to somebody reading a diff.
"""
if value is None:
return "not set"
if isinstance(value, bool):
return "on" if value else "off"
if isinstance(value, (int, float)):
return f"{value:g}" if isinstance(value, float) else str(value)
if isinstance(value, str):
text = value
else:
text = json.dumps(value, separators=(",", ":"), sort_keys=True)
text = " ".join(text.split())
return text if len(text) <= VALUE_LIMIT else text[:VALUE_LIMIT - 1] + "…"
def exportable() -> tuple[dict, list[dict]]:
known = schema()
current = stored()
@@ -248,14 +282,17 @@ def plan(path: str) -> dict:
continue
apply[key] = value
changes.append({"key": key,
"from": current.get(key, None),
"to": value})
"from": render(current.get(key)),
"to": render(value)})
return {
"path": str(Path(path).expanduser()),
"exportedFrom": str(bundle.get("exportedFrom", "")),
"exportedAt": int(bundle.get("exportedAt", 0)),
"changes": changes,
"changes": changes[:CHANGE_LIMIT],
# What the list would have held uncapped, so the page can say "and 12
# more" rather than quietly showing forty of fifty-two.
"changeCount": len(changes),
"skipped": skipped,
"apply": apply,
}
+262 -23
View File
@@ -15,7 +15,10 @@ updater does, instead of pretending the number is live.
panama-updates snapshot
panama-updates check
panama-updates apply dnf|flatpak|firmware
panama-updates apply flatpak <app-id>
panama-updates changelog dnf|flatpak|firmware <name>
panama-updates set-auto-flatpak true|false
panama-updates set-auto-dnf true|false
"""
from __future__ import annotations
@@ -30,10 +33,22 @@ import sys
import time
from pathlib import Path
# The user timer this ships for keeping applications current. dnf has no
# equivalent here because dnf-automatic is not installed, and installing
# software is not this script's job.
# The two unattended-update timers this machine can have. The flatpak one is
# Panama's own user timer; the dnf one is dnf5-automatic's system timer, which
# ships with dnf5 and downloads without installing. Both are only ever enabled
# or disabled -- installing software is not this script's job, so a timer that
# is not present is reported as unavailable rather than offered.
FLATPAK_TIMER = "panama-flatpak-update.timer"
DNF_TIMER = "dnf5-automatic.timer"
# Package and application names reach a subprocess as argv, never a shell. They
# are still constrained: rpm names and flatpak application IDs both live well
# inside this, and anything outside it is not a name either source would emit.
NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$")
# How much changelog text is worth carrying into a settings expander. Beyond
# this it stops being something anybody reads and starts being a scroll.
CHANGELOG_LIMIT = 6000
# Anything carrying an advisory of these severities is reported as a security
# fix. "none" is excluded deliberately: an advisory with no severity is a
@@ -103,6 +118,30 @@ def kernel_state() -> dict:
}
def dnf_download_sizes() -> dict[str, int]:
"""Bytes to fetch per pending package, straight from the repo metadata.
`dnf5 repoquery --queryformat` is the only place dnf5 reports this as a
number rather than as a rendered "172.9 MiB". A repo it cannot reach, or a
format it renders differently one day, yields nothing at all -- the size is
a nicety, and a wrong size is worse than no size.
"""
result = run(["dnf5", "repoquery", "--upgrades",
"--queryformat", "%{name} %{downloadsize}\\n"], timeout=180)
if result.returncode != 0:
return {}
sizes: dict[str, int] = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) != 2:
continue
try:
sizes[parts[0]] = int(parts[1])
except ValueError:
continue
return sizes
def dnf_updates() -> dict:
if not shutil.which("dnf5"):
return {"available": False, "count": 0, "packages": [], "securityCount": 0}
@@ -131,24 +170,74 @@ def dnf_updates() -> dict:
except json.JSONDecodeError:
security = 0
sizes = dnf_download_sizes() if packages else {}
for package in packages:
if package["name"] in sizes:
package["bytes"] = sizes[package["name"]]
packages.sort(key=lambda item: item["name"])
return {"available": True, "count": len(packages), "packages": packages,
"securityCount": security}
source = {"available": True, "count": len(packages), "packages": packages,
"securityCount": security}
# Only when every pending package was priced. A partial total reads as the
# whole download and would understate it, which is the direction that
# surprises somebody on a metered connection.
if packages and all("bytes" in package for package in packages):
source["downloadBytes"] = sum(package["bytes"] for package in packages)
return source
def human_bytes(text: str) -> int:
"""flatpak's "36.2 MB" back into a number, or 0 when it is not one.
flatpak has no machine-readable size: its --json output omits the column
entirely, so the rendered string is the only source there is. Parsed with
the decimal units flatpak actually prints, and zero on anything else.
"""
match = re.match(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*([kMGT]?B)\s*$", text)
if not match:
return 0
scale = {"B": 1, "kB": 10 ** 3, "MB": 10 ** 6, "GB": 10 ** 9, "TB": 10 ** 12}
return int(float(match.group(1)) * scale[match.group(2)])
def flatpak_updates() -> dict:
if not shutil.which("flatpak"):
return {"available": False, "count": 0, "applications": []}
result = run(["flatpak", "remote-ls", "--updates", "--columns=application,version"],
timeout=120)
result = run(["flatpak", "remote-ls", "--updates",
"--columns=application,version,origin,download-size"], timeout=120)
applications = []
if result.returncode == 0:
for line in result.stdout.splitlines():
parts = [part.strip() for part in line.split("\t")]
if parts and parts[0]:
applications.append({"id": parts[0],
"version": parts[1] if len(parts) > 1 else ""})
return {"available": True, "count": len(applications), "applications": applications}
if not parts or not parts[0]:
continue
application = {"id": parts[0],
"version": parts[1] if len(parts) > 1 else "",
"origin": parts[2] if len(parts) > 2 else ""}
size = human_bytes(parts[3]) if len(parts) > 3 else 0
if size:
application["bytes"] = size
applications.append(application)
source = {"available": True, "count": len(applications), "applications": applications}
if applications and all("bytes" in application for application in applications):
source["downloadBytes"] = sum(application["bytes"] for application in applications)
return source
def strip_markup(text: str) -> str:
"""fwupd release notes are a small AppStream XML dialect, not prose.
Paragraphs and list items become lines; everything else is dropped. Doing
this here rather than in the page keeps the helper's answer plain text, the
same shape the dnf and flatpak paths return.
"""
if not text.strip():
return ""
text = re.sub(r"</p>|</li>", "\n", text)
text = re.sub(r"<li>", "• ", text)
text = re.sub(r"<[^>]+>", "", text)
lines = [line.strip() for line in text.splitlines()]
return "\n".join(line for line in lines if line)
def firmware_updates() -> dict:
@@ -160,13 +249,20 @@ def firmware_updates() -> dict:
payload = json.loads(result.stdout or "{}")
for device in payload.get("Devices", []):
releases = device.get("Releases", [])
devices.append({
entry = {
"name": str(device.get("Name", "Unknown device")),
"version": str(device.get("Version", "")),
"target": str(releases[0].get("Version", "")) if releases else "",
# Firmware that needs a reboot to flash is worth saying up front.
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
})
}
# fwupd already has the vendor's release notes in hand, so they are
# kept here rather than re-fetched: the changelog for firmware costs
# nothing beyond the scan that found the update.
notes = strip_markup(str(releases[0].get("Description", ""))) if releases else ""
if notes:
entry["changelog"] = notes[:CHANGELOG_LIMIT]
devices.append(entry)
except json.JSONDecodeError:
pass
return {"available": True, "count": len(devices), "devices": devices}
@@ -174,12 +270,13 @@ def firmware_updates() -> dict:
def automatic_state() -> dict:
flatpak_timer = run(["systemctl", "--user", "is-enabled", FLATPAK_TIMER], timeout=20)
dnf_timer = run(["systemctl", "is-enabled", "dnf5-automatic.timer"], timeout=20)
dnf_timer = run(["systemctl", "is-enabled", DNF_TIMER], timeout=20)
return {
"flatpakEnabled": flatpak_timer.stdout.strip() == "enabled",
"flatpakAvailable": flatpak_timer.stdout.strip() not in ("", "not-found"),
# Reported, never offered: dnf-automatic is a package this machine does
# not have, and installing software is not a settings action.
# Offered when the timer exists, reported as unavailable when it does
# not. See set_auto_dnf for what enabling it actually does -- it
# downloads, it does not install.
"dnfAutomaticEnabled": dnf_timer.stdout.strip() == "enabled",
"dnfAutomaticAvailable": dnf_timer.stdout.strip() not in ("", "not-found"),
}
@@ -226,11 +323,22 @@ def take_restore_point(reason: str) -> str:
return result.stdout.strip() if result.returncode == 0 else ""
def apply(source: str) -> dict:
def apply(source: str, target: str = "") -> dict:
if source == "flatpak":
if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed.")
result = run(["flatpak", "update", "-y", "--noninteractive"], timeout=3600)
command = ["flatpak", "update", "-y", "--noninteractive"]
if target:
# One application, by ID. Checked against the IDs the last scan
# actually found rather than passed through: this is the only verb
# that takes a name from the page, and the page is not the
# authority on what is pending.
pending = {str(entry.get("id", ""))
for entry in read_cache().get("flatpak", {}).get("applications", [])}
if target not in pending:
raise BoundaryError("That application does not have an update waiting.")
command.append(target)
result = run(command, timeout=3600)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The applications could not be updated."))
return {"restorePoint": ""}
@@ -257,6 +365,115 @@ def apply(source: str) -> dict:
raise BoundaryError("That is not an update source.")
def dnf_changelog(name: str) -> dict:
"""The best text dnf5 will actually give for one pending package.
Two sources, in the order a person cares about them. An advisory says why
the update exists and what it fixes, which is the answer when there is one.
Failing that, the rpm changelog DELTA -- `--upgrades` prints only entries
newer than what is installed, which is exactly the question being asked and
not the package's whole history.
Plenty of packages have neither. Third-party repos routinely ship with no
changelog at all, and dnf5 answers that with a header and nothing under it.
Saying so is the honest result, not a failure.
"""
advisory = run(["dnf5", "advisory", "info", "--json", "--updates",
f"--contains-pkgs={name}"], timeout=180)
if advisory.returncode == 0:
try:
entries = json.loads(advisory.stdout or "[]")
except json.JSONDecodeError:
entries = []
blocks = []
for entry in entries if isinstance(entries, list) else []:
heading = " · ".join(part for part in (
str(entry.get("Name", "")).strip(),
str(entry.get("Type", "")).strip().title(),
str(entry.get("Severity", "")).strip(),
) if part)
body = "\n".join(part for part in (
str(entry.get("Title", "")).strip(),
str(entry.get("Description", "")).strip(),
) if part)
if heading or body:
blocks.append((heading + "\n" + body).strip())
if blocks:
return {"kind": "advisory", "text": "\n\n".join(blocks)[:CHANGELOG_LIMIT]}
result = run(["dnf5", "changelog", "--upgrades", name], timeout=180)
if result.returncode == 0:
# dnf5 prints "Listing only new changelogs..." and "Changelogs for
# <nevra>" before the entries. Both are dnf talking about itself.
lines = [line for line in result.stdout.splitlines()
if not line.startswith(("Listing only ", "Changelogs for "))]
text = "\n".join(lines).strip()
if text:
return {"kind": "changelog", "text": text[:CHANGELOG_LIMIT]}
return {"kind": "none", "text": "This package publishes no changelog for the update."}
def flatpak_changelog(name: str) -> dict:
"""Whatever the remote already has cached, and nothing more.
`flatpak remote-info --log` without --cached is an ostree history walk
against the network, which is far too much work for an expander somebody
clicked. With --cached it answers from metadata already on disk, and most
remotes have nothing there -- Flathub's commit history is not part of the
summary. Absence is reported as absence.
"""
origin = ""
for entry in read_cache().get("flatpak", {}).get("applications", []):
if str(entry.get("id", "")) == name:
origin = str(entry.get("origin", ""))
break
if not origin:
listed = run(["flatpak", "list", "--app", "--columns=application,origin"], timeout=60)
for line in listed.stdout.splitlines():
parts = [part.strip() for part in line.split("\t")]
if len(parts) > 1 and parts[0] == name:
origin = parts[1]
break
if not origin or not NAME_PATTERN.match(origin):
return {"kind": "none", "text": "This application publishes no release notes."}
result = run(["flatpak", "remote-info", "--cached", "--log", origin, name], timeout=90)
if result.returncode == 0:
history = result.stdout.partition("History:")[2].strip()
if history:
return {"kind": "changelog", "text": history[:CHANGELOG_LIMIT]}
return {"kind": "none", "text": "This application publishes no release notes."}
def firmware_changelog(name: str) -> dict:
for device in read_cache().get("firmware", {}).get("devices", []):
if str(device.get("name", "")) == name:
notes = str(device.get("changelog", "")).strip()
if notes:
return {"kind": "changelog", "text": notes[:CHANGELOG_LIMIT]}
break
return {"kind": "none", "text": "This firmware update ships no release notes."}
def changelog(source: str, name: str) -> dict:
if not NAME_PATTERN.match(name):
raise BoundaryError("That is not a name this machine would have produced.")
if source == "dnf":
if not shutil.which("dnf5"):
raise BoundaryError("dnf is not installed.")
result = dnf_changelog(name)
elif source == "flatpak":
if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed.")
result = flatpak_changelog(name)
elif source == "firmware":
result = firmware_changelog(name)
else:
raise BoundaryError("That is not an update source.")
return {"source": source, "name": name, **result, "error": ""}
def set_auto_dnf(enabled: bool) -> None:
"""Enable the packaging timer, which DOWNLOADS updates but does not apply them.
@@ -269,7 +486,7 @@ def set_auto_dnf(enabled: bool) -> None:
if not state["dnfAutomaticAvailable"]:
raise BoundaryError("Automatic package updates are not installed.")
action = ["enable", "--now"] if enabled else ["disable", "--now"]
result = run(["pkexec", "systemctl", *action, "dnf5-automatic.timer"], timeout=120)
result = run(["pkexec", "systemctl", *action, DNF_TIMER], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "Automatic package updates could not be changed."))
@@ -383,13 +600,24 @@ def main(arguments: list[str]) -> int:
check()
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "apply":
outcome = apply(arguments[1])
# Changelogs answer on their own, not folded into a snapshot: this is
# the one verb whose reply is about a single package, and putting it
# inside the state blob would make every reader guess which package it
# was talking about.
if len(arguments) == 3 and arguments[0] == "changelog":
print(json.dumps(changelog(arguments[1], arguments[2]),
separators=(",", ":")))
return 0
if len(arguments) in (2, 3) and arguments[0] == "apply":
target = arguments[2] if len(arguments) == 3 else ""
if target and arguments[1] != "flatpak":
raise BoundaryError("Only applications can be updated one at a time.")
outcome = apply(arguments[1], target)
# Re-check, so the page reflects what is actually left rather than
# assuming the update cleared everything it listed.
check()
state = snapshot()
state["applied"] = {"source": arguments[1], **outcome}
state["applied"] = {"source": arguments[1], "target": target, **outcome}
print(json.dumps(state, separators=(",", ":")))
return 0
if len(arguments) == 2 and arguments[0] == "set-auto-flatpak":
@@ -398,9 +626,20 @@ def main(arguments: list[str]) -> int:
set_auto_dnf(arguments[1] == "true")
else:
raise BoundaryError(
"Usage: panama-updates snapshot | check | apply dnf|flatpak|firmware | "
"Usage: panama-updates snapshot | check | history | "
"apply dnf|flatpak|firmware [app-id] | "
"changelog dnf|flatpak|firmware NAME | "
"set-auto-flatpak true|false | set-auto-dnf true|false")
except BoundaryError as error:
# A failed changelog answers in the changelog's own shape. Returning a
# whole state blob here would hand the caller a payload with no text
# field at all, which reads as "no changelog" rather than as a refusal.
if arguments[:1] == ["changelog"]:
print(json.dumps({"source": arguments[1] if len(arguments) > 1 else "",
"name": arguments[2] if len(arguments) > 2 else "",
"kind": "none", "text": "", "error": str(error)},
separators=(",", ":")))
return 0
state = snapshot()
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))