Add Software Update, across packages, applications and firmware
Three sources that fail independently, so they are counted and applied 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, which is too long to spend every time a page opens, so the page opens on the last result and says when it was taken. A first visit with nothing cached goes and finds out rather than showing a confident "up to date" it has no basis for. Installing packages takes a snapshot first, named after what is about to happen, so Snapshots shows "before 32 package updates" rather than a timestamp. Best effort: a machine without snapper still updates, because an update that refuses to run when a nicety fails would be worse than one without a restore point. Automatic updates cover applications only, through a Panama-owned user timer running daily with a randomized delay. Packages still ask, and dnf-automatic is reported as absent rather than offered, because installing software is not a settings action. Health gained a check, and that is where the bug was: it first returned status "degraded", which is not in the doctor's vocabulary of ok, warning, error and unconfigured. It was counted as nothing at all while the summary still said healthy -- the same silent no-op this codebase keeps relearning. A contract now asserts every status a check can return is one the doctor counts, and the doctor's own contract knows about the new check rather than failing on its arrival. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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
|
||||
|
||||
// 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";
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user