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")" \