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:
@@ -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)"
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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=(",", ":")))
|
||||
|
||||
Reference in New Issue
Block a user