#!/usr/bin/env python3 """Software updates, from every source this machine actually uses. Three of them, and they fail independently, so they are counted and applied separately rather than blended into one number: packages (dnf), applications (flatpak), and firmware (fwupd). Checking costs about nine seconds of network and metadata work, which is too long to spend every time a page opens. So `snapshot` is instant -- it reads the last result plus the things that are free to compute -- and `check` is the scan that refreshes it. The page shows when it last checked, the way every mature 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 panama-updates changelog dnf|flatpak|firmware panama-updates set-auto-flatpak true|false panama-updates set-auto-dnf true|false """ from __future__ import annotations import datetime import json import os import re import shutil import subprocess import sys import time from pathlib import Path # 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 # bugfix or enhancement, and calling it security would cry wolf. SECURITY_SEVERITIES = "critical,important,moderate,low" class BoundaryError(RuntimeError): """A user-visible failure.""" def run(command: list[str], timeout: float = 180.0) -> subprocess.CompletedProcess: try: return subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) except subprocess.TimeoutExpired as error: raise BoundaryError(f"{command[0]} did not finish in time.") from error except OSError as error: raise BoundaryError(f"{command[0]} is not available.") from error def cache_path() -> Path: base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "panama" base.mkdir(parents=True, exist_ok=True) return base / "updates.json" def read_cache() -> dict: try: return json.loads(cache_path().read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} def write_cache(payload: dict) -> None: # Written atomically: a page reading this while it is half-written would # report zero updates, which is the one wrong answer that looks fine. target = cache_path() temporary = target.with_suffix(".tmp") try: temporary.write_text(json.dumps(payload), encoding="utf-8") temporary.replace(target) except OSError: pass def kernel_state() -> dict: """Whether a reboot would change the kernel you are running. This is the honest version of "restart required". Comparing the running release against the newest installed one is exact, needs no plugin, and takes no time -- and a machine that has already rebooted since the update correctly reports nothing pending. """ running = os.uname().release newest = running result = run(["rpm", "-q", "kernel", "--qf", "%{VERSION}-%{RELEASE}.%{ARCH}\\n"], timeout=30) if result.returncode == 0: installed = [line.strip() for line in result.stdout.splitlines() if line.strip()] if installed: # rpm lists oldest first for equal names. newest = installed[-1] return { "running": running, "newestInstalled": newest, "rebootNeeded": newest != running, } 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} result = run(["dnf5", "check-upgrade", "--json"], timeout=180) packages = [] # dnf5 exits 100 when upgrades exist, 0 when none do. Both are success. if result.returncode in (0, 100): try: payload = json.loads(result.stdout or "{}") for entry in payload.get("upgrades", []): packages.append({ "name": str(entry.get("name", "")), "version": str(entry.get("evr", "")), "repository": str(entry.get("repository", "")), }) except json.JSONDecodeError: pass security = 0 advisory = run(["dnf5", "check-upgrade", f"--advisory-severities={SECURITY_SEVERITIES}", "--json"], timeout=180) if advisory.returncode in (0, 100): try: security = len(json.loads(advisory.stdout or "{}").get("upgrades", [])) 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"]) 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,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 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"

|", "\n", text) text = re.sub(r"
  • ", "• ", 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: if not shutil.which("fwupdmgr"): return {"available": False, "count": 0, "devices": []} result = run(["fwupdmgr", "get-updates", "--json"], timeout=120) devices = [] try: payload = json.loads(result.stdout or "{}") for device in payload.get("Devices", []): releases = device.get("Releases", []) 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} def automatic_state() -> dict: flatpak_timer = run(["systemctl", "--user", "is-enabled", FLATPAK_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"), # 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"), } def check() -> dict: payload = { "dnf": dnf_updates(), "flatpak": flatpak_updates(), "firmware": firmware_updates(), "checkedAt": int(time.time()), } write_cache(payload) return payload def snapshot() -> dict: cached = read_cache() empty = {"available": True, "count": 0} return { "dnf": cached.get("dnf", {**empty, "packages": [], "securityCount": 0}), "flatpak": cached.get("flatpak", {**empty, "applications": []}), "firmware": cached.get("firmware", {**empty, "devices": []}), # 0 means never checked, which the page says rather than showing a # confident "0 updates" it has no basis for. "checkedAt": int(cached.get("checkedAt", 0)), "kernel": kernel_state(), "automatic": automatic_state(), "error": "", } def take_restore_point(reason: str) -> str: """A snapshot before the system changes, named after what is about to happen. Best effort: if snapper is not configured, the update still proceeds. An update that refuses to run because a nicety failed would be worse than one without a restore point. """ if not shutil.which("snapper"): return "" result = run(["snapper", "-c", "root", "create", "--description", reason, "--cleanup-algorithm", "number", "--print-number"], timeout=120) return result.stdout.strip() if result.returncode == 0 else "" def apply(source: str, target: str = "") -> dict: if source == "flatpak": if not shutil.which("flatpak"): raise BoundaryError("Flatpak is not installed.") 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": ""} if source == "dnf": if not shutil.which("dnf5"): raise BoundaryError("dnf is not installed.") pending = read_cache().get("dnf", {}).get("count", 0) restore_point = take_restore_point( f"before {pending} package update{'' if pending == 1 else 's'}") result = run(["pkexec", "dnf5", "upgrade", "-y"], timeout=7200) if result.returncode != 0: raise BoundaryError(_refusal(result, "The packages could not be updated.")) return {"restorePoint": restore_point} if source == "firmware": if not shutil.which("fwupdmgr"): raise BoundaryError("Firmware updating is not available.") result = run(["fwupdmgr", "update", "-y", "--no-reboot-check"], timeout=3600) if result.returncode != 0: raise BoundaryError(_refusal(result, "The firmware could not be updated.")) return {"restorePoint": ""} 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 # " 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. That is the shipped default (apply_updates = no) and it is the right one to leave alone: a machine that installs packages unattended can reboot into a kernel nobody chose. Downloading ahead of time makes the install quick when someone does choose it. """ state = automatic_state() 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, DNF_TIMER], timeout=120) if result.returncode != 0: raise BoundaryError(_refusal(result, "Automatic package updates could not be changed.")) def set_auto_flatpak(enabled: bool) -> None: action = ["enable", "--now"] if enabled else ["disable", "--now"] result = run(["systemctl", "--user", *action, FLATPAK_TIMER], timeout=60) if result.returncode != 0: raise BoundaryError(_refusal(result, "Automatic application updates could not be changed.")) def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str: text = ((result.stderr or "") + "\n" + (result.stdout or "")).strip().splitlines() meaningful = [line for line in text if line.strip()] if not meaningful: return fallback last = meaningful[-1] if "not authorized" in last.lower() or "dismissed" in last.lower(): return "That update was not authorized." return last[:200] def flatpak_time(text: str) -> int: """flatpak prints "Aug 20 08:07:46" -- a time with no year in it. Assumed to be this year, and rolled back one if that would put it in the future, which is the only reading that makes sense for a history. Anything unparseable sorts last rather than pretending to be the epoch, which would put it at the top of a newest-first list. """ if not text.strip(): return 0 now = datetime.datetime.now() for year in (now.year, now.year - 1): try: when = datetime.datetime.strptime(f"{year} {text.strip()}", "%Y %b %d %H:%M:%S") except ValueError: return 0 if when <= now + datetime.timedelta(days=1): return int(when.timestamp()) return 0 def history(limit: int = 25) -> list[dict]: """What has actually been installed, newest first. Both sources are asked in their own machine-readable form rather than by parsing their tables: dnf5 prints JSON, and flatpak takes --json. The two are merged on time so the answer reads as one history rather than as two lists a person has to interleave themselves. Automatic updates are the reason this is worth showing. Something that installs itself overnight leaves no other trace a person would notice. """ entries: list[dict] = [] dnf = run(["dnf5", "history", "list", "--json"], timeout=30.0) if dnf.returncode == 0: try: for row in json.loads(dnf.stdout or "[]"): command = str(row.get("command_line") or "").strip() entries.append({ "source": "dnf", "at": int(row.get("start_time") or 0), # The full argv is noise; what was done is the useful part. "summary": command.split("/")[-1] if command else "transaction", "count": int(row.get("altered_count") or 0), "ok": str(row.get("status") or "") == "Ok", }) except (json.JSONDecodeError, TypeError, ValueError): pass flatpak = run(["flatpak", "history", "--json"], timeout=30.0) if flatpak.returncode == 0: try: rows = json.loads(flatpak.stdout or "[]") except json.JSONDecodeError: rows = [] for row in rows if isinstance(rows, list) else []: application = str(row.get("application") or "").strip() change = str(row.get("change") or "").strip() if not application: continue # "deploy update", "deploy install", "uninstall" -- the verb is a # word inside the change rather than the whole of it. verb = next((word for word in ("update", "install", "uninstall") if word in change), "") if not verb: continue entries.append({ "source": "flatpak", "at": flatpak_time(str(row.get("time") or "")), "summary": verb + " " + application, "count": 1, "ok": True, }) entries.sort(key=lambda entry: entry["at"], reverse=True) return entries[:limit] def main(arguments: list[str]) -> int: try: if arguments == ["history"]: print(json.dumps({"entries": history(), "error": ""}, separators=(",", ":"))) return 0 if arguments == ["snapshot"]: print(json.dumps(snapshot(), separators=(",", ":"))) return 0 if arguments == ["check"]: check() print(json.dumps(snapshot(), separators=(",", ":"))) return 0 # 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], "target": target, **outcome} print(json.dumps(state, separators=(",", ":"))) return 0 if len(arguments) == 2 and arguments[0] == "set-auto-flatpak": set_auto_flatpak(arguments[1] == "true") elif len(arguments) == 2 and arguments[0] == "set-auto-dnf": set_auto_dnf(arguments[1] == "true") else: raise BoundaryError( "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=(",", ":"))) return 0 print(json.dumps(snapshot(), separators=(",", ":"))) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))