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
224 lines
7.8 KiB
QML
224 lines
7.8 KiB
QML
pragma Singleton
|
|
|
|
// Software updates, from the three sources this machine actually uses.
|
|
//
|
|
// They are counted and applied separately because they fail separately: a
|
|
// flatpak mirror being down says nothing about whether a kernel security fix is
|
|
// waiting. Blending them into one number would hide exactly the case that
|
|
// matters.
|
|
//
|
|
// Checking costs about nine seconds. So the page opens on the last result and
|
|
// says when that was, the way every mature updater does, and refreshes in the
|
|
// background rather than making someone watch a spinner to learn there is
|
|
// nothing to do.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-updates"
|
|
|
|
property var dnf: ({})
|
|
property var flatpak: ({})
|
|
property var firmware: ({})
|
|
property var kernel: ({})
|
|
property var automatic: ({})
|
|
property int checkedAt: 0
|
|
property bool scanned: false
|
|
property string lastError: ""
|
|
|
|
// Set after a successful apply, so the page can say what was done and
|
|
// whether a restore point was taken.
|
|
property var lastApplied: null
|
|
|
|
// Guards read the Process objects directly; a derived binding is stale
|
|
// inside the handler that changes it. See DefaultApps.qml.
|
|
readonly property bool checking: checkProcess.running
|
|
readonly property bool applying: applyProcess.running
|
|
readonly property bool busy: root.checking || root.applying
|
|
|
|
readonly property int total: Number(root.dnf?.count ?? 0)
|
|
+ Number(root.flatpak?.count ?? 0)
|
|
+ Number(root.firmware?.count ?? 0)
|
|
|
|
readonly property int securityCount: Number(root.dnf?.securityCount ?? 0)
|
|
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.
|
|
function summary(): string {
|
|
if (!root.everChecked)
|
|
return "Not checked yet";
|
|
if (root.total === 0)
|
|
return "Up to date";
|
|
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";
|
|
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - root.checkedAt);
|
|
if (seconds < 90)
|
|
return "Checked just now";
|
|
if (seconds < 3600)
|
|
return "Checked " + Math.floor(seconds / 60) + " minutes ago";
|
|
if (seconds < 172800)
|
|
return "Checked " + Math.floor(seconds / 3600) + " hours ago";
|
|
return "Checked " + Math.floor(seconds / 86400) + " days ago";
|
|
}
|
|
|
|
function sourceLabel(source: string): string {
|
|
switch (source) {
|
|
case "dnf": return "System packages";
|
|
case "flatpak": return "Applications";
|
|
case "firmware": return "Firmware";
|
|
default: return source;
|
|
}
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (query.running)
|
|
return;
|
|
query.command = [root.helperPath, "snapshot"];
|
|
query.running = true;
|
|
}
|
|
|
|
// The slow one, on request.
|
|
function check(): void {
|
|
if (checkProcess.running)
|
|
return;
|
|
root.lastError = "";
|
|
checkProcess.command = [root.helperPath, "check"];
|
|
checkProcess.running = true;
|
|
}
|
|
|
|
function apply(source: string): void {
|
|
if (applyProcess.running)
|
|
return;
|
|
root.lastError = "";
|
|
root.lastApplied = null;
|
|
applyProcess.command = [root.helperPath, "apply", source];
|
|
applyProcess.running = true;
|
|
}
|
|
|
|
function setAutomaticDnf(enabled: bool): void {
|
|
if (applyProcess.running)
|
|
return;
|
|
root.lastError = "";
|
|
applyProcess.command = [root.helperPath, "set-auto-dnf", enabled ? "true" : "false"];
|
|
applyProcess.running = true;
|
|
}
|
|
|
|
function setAutomaticFlatpak(enabled: bool): void {
|
|
if (applyProcess.running)
|
|
return;
|
|
root.lastError = "";
|
|
applyProcess.command = [root.helperPath, "set-auto-flatpak", enabled ? "true" : "false"];
|
|
applyProcess.running = true;
|
|
}
|
|
|
|
function absorb(text: string): void {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
root.dnf = parsed.dnf ?? ({});
|
|
root.flatpak = parsed.flatpak ?? ({});
|
|
root.firmware = parsed.firmware ?? ({});
|
|
root.kernel = parsed.kernel ?? ({});
|
|
root.automatic = parsed.automatic ?? ({});
|
|
root.checkedAt = Number(parsed.checkedAt ?? 0);
|
|
root.lastError = String(parsed.error ?? "");
|
|
if (parsed.applied)
|
|
root.lastApplied = parsed.applied;
|
|
} catch (error) {
|
|
root.lastError = "Could not read the update helper's answer.";
|
|
console.warn("Updates: could not parse helper output:", error);
|
|
}
|
|
root.scanned = true;
|
|
}
|
|
|
|
Component.onCompleted: root.refresh()
|
|
|
|
Process {
|
|
id: query
|
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: checkProcess
|
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: applyProcess
|
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
|
stderr: StdioCollector {
|
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: historyProcess
|
|
stdout: StdioCollector { onStreamFinished: root.absorbHistory(this.text) }
|
|
}
|
|
}
|