From 4cbab01ae2115bef8fad2a506e98962f99eb236b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 20 Aug 2026 11:30:31 -0400 Subject: [PATCH] Show what has actually been installed Automatic updates leave no other trace. The Flatpak that sat here as "1 update available" installed itself at 00:14 this morning and nothing on the machine would have said so. Both sources are asked in their own machine-readable form and merged on time, so the answer reads as one history rather than two lists to interleave by eye. Two parsing traps worth recording next to the code. flatpak's --json prints timestamps as "Aug 20 08:07:46" with no year in them, so the year is inferred and a date that would land in the future is read as last year's. And dnf5's start_time is epoch UTC while its own history table prints that same value as though it were local -- checked against rpm, and the local rendering here is the correct one. The contract asserts entries are newest first, that none is dated in the future, and that both sources parse; it was verified to fail by breaking the year inference so every flatpak entry landed tomorrow. Loaded on demand rather than with the page, because it reads both full transaction logs. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../modules/settings/UpdatesPage.qml | 41 +++++++++ config/dot/quickshell/scripts/panama-updates | 83 +++++++++++++++++++ config/dot/quickshell/services/Updates.qml | 54 ++++++++++++ tests/quickshell/updates-contract.sh | 38 +++++++++ 4 files changed, 216 insertions(+) diff --git a/config/dot/quickshell/modules/settings/UpdatesPage.qml b/config/dot/quickshell/modules/settings/UpdatesPage.qml index 3501712..509b0cd 100644 --- a/config/dot/quickshell/modules/settings/UpdatesPage.qml +++ b/config/dot/quickshell/modules/settings/UpdatesPage.qml @@ -266,4 +266,45 @@ SettingsPage { divider: false } } + + // Worth showing because automatic updates leave no other trace. Something + // that installed itself overnight is invisible until you look here. + SettingsCard { + title: "Recently installed" + subtitle: Updates.historyLoaded + ? "Packages and applications, newest first, from both sources at once." + : "Asks dnf and flatpak for their transaction logs." + + ActionRow { + visible: !Updates.historyLoaded + label: "Show what has been installed" + detail: "Not loaded with the page: it reads the whole history from both sources" + action: Updates.loadingHistory ? "Reading…" : "Show" + enabled: !Updates.loadingHistory + divider: false + onTriggered: Updates.loadHistory() + } + + Repeater { + model: Updates.history + + delegate: TextRow { + required property var modelData + required property int index + width: parent.width + label: String(modelData.summary ?? "") + detail: Updates.agoText(Number(modelData.at ?? 0)) + value: Updates.describeHistory(modelData) + divider: index < Updates.history.length - 1 + } + } + + TextRow { + visible: Updates.historyLoaded && Updates.history.length === 0 + label: "Nothing recorded" + detail: "Neither dnf nor flatpak has a transaction history here." + value: "" + divider: false + } + } } diff --git a/config/dot/quickshell/scripts/panama-updates b/config/dot/quickshell/scripts/panama-updates index 76295db..dfd2cae 100755 --- a/config/dot/quickshell/scripts/panama-updates +++ b/config/dot/quickshell/scripts/panama-updates @@ -20,6 +20,7 @@ updater does, instead of pretending the number is live. from __future__ import annotations +import datetime import json import os import re @@ -291,8 +292,90 @@ def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str: 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 diff --git a/config/dot/quickshell/services/Updates.qml b/config/dot/quickshell/services/Updates.qml index 76c1eea..044aa86 100644 --- a/config/dot/quickshell/services/Updates.qml +++ b/config/dot/quickshell/services/Updates.qml @@ -48,6 +48,40 @@ Singleton { readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true readonly property bool everChecked: root.checkedAt > 0 + // What has actually been installed, newest first, from both sources at + // once. Loaded on demand rather than with the snapshot: it asks dnf and + // flatpak for their whole transaction log, which is not worth doing every + // time the page opens. + property var history: [] + property bool historyLoaded: false + readonly property bool loadingHistory: historyProcess.running + + function loadHistory(): void { + if (historyProcess.running) + return; + historyProcess.command = [root.helperPath, "history"]; + historyProcess.running = true; + } + + function absorbHistory(text: string): void { + try { + const parsed = JSON.parse(text); + root.history = Array.isArray(parsed.entries) ? parsed.entries : []; + } catch (error) { + console.warn("Updates: could not parse history:", error); + root.history = []; + } + root.historyLoaded = true; + } + + // "3 packages" reads better than a raw count next to a command line. + function describeHistory(entry: var): string { + const count = Number(entry.count ?? 0); + if (entry.source === "flatpak") + return "Flatpak"; + return count === 1 ? "1 package" : count + " packages"; + } + // A count nobody has verified is not a count. Saying "up to date" on the // strength of a check that never ran is the one wrong answer that looks // reassuring. @@ -59,6 +93,21 @@ Singleton { return root.total + " update" + (root.total === 1 ? "" : "s") + " available"; } + // The same shape as lastCheckedText, for an arbitrary moment. Kept beside it + // so the two never drift into describing time differently on one page. + function agoText(epochSeconds: int): string { + if (!(epochSeconds > 0)) + return "at an unknown time"; + const seconds = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds); + if (seconds < 90) + return "just now"; + if (seconds < 3600) + return Math.floor(seconds / 60) + " minutes ago"; + if (seconds < 172800) + return Math.floor(seconds / 3600) + " hours ago"; + return Math.floor(seconds / 86400) + " days ago"; + } + function lastCheckedText(): string { if (!root.everChecked) return "Never checked"; @@ -166,4 +215,9 @@ Singleton { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } } + + Process { + id: historyProcess + stdout: StdioCollector { onStreamFinished: root.absorbHistory(this.text) } + } } diff --git a/tests/quickshell/updates-contract.sh b/tests/quickshell/updates-contract.sh index 7edc2d5..65c4311 100755 --- a/tests/quickshell/updates-contract.sh +++ b/tests/quickshell/updates-contract.sh @@ -100,6 +100,44 @@ jq -e '.kernel.rebootNeeded == (.kernel.running != .kernel.newestInstalled)' <<< [[ -n "$("$helper" bogus 2>/dev/null | jq -r '.error // ""')" ]] \ || fail 'an unknown command was accepted' + +# ── Update history ────────────────────────────────────────────────────────── +# +# Worth showing because automatic updates leave no other trace: something that +# installed itself overnight is invisible until somebody looks here. +# +# Both sources are asked in their own machine-readable form. flatpak's is the +# awkward one -- it prints a JSON array whose timestamps are "Aug 20 08:07:46", +# with no year -- so the year is inferred, and a date that would land in the +# future is read as last year's. dnf's start_time is epoch UTC; its own table +# prints that value as though it were local, which it is not. + +history="$("$helper" history)" || fail 'history failed' +printf '%s' "$history" | python3 -c " +import json, sys, time +payload = json.load(sys.stdin) +entries = payload['entries'] +if not entries: + raise SystemExit(0) # a machine with no transactions is legitimate + +now = int(time.time()) +previous = None +for entry in entries: + if entry['source'] not in ('dnf', 'flatpak'): + raise SystemExit(f\"unknown source {entry['source']}\") + at = int(entry['at']) + if at and at > now + 86400: + raise SystemExit(f\"{entry['summary']} is dated in the future, so the year was read wrong\") + if at and previous is not None and at > previous: + raise SystemExit('entries are not newest first') + if at: + previous = at + if not str(entry['summary']).strip(): + raise SystemExit('an entry has nothing to say it did') +if not any(e['source'] == 'flatpak' for e in entries) and not any(e['source'] == 'dnf' for e in entries): + raise SystemExit('neither source produced anything, so nothing was parsed') +" || fail 'the update history is not usable' + printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \ "$(jq -r '.dnf.count // 0' <<<"$state")" \ "$(jq -r '.flatpak.count // 0' <<<"$state")" \