382 lines
14 KiB
QML
382 lines
14 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
|
|
|
|
// How much there is to fetch, when every pending item was priced. The
|
|
// helper omits the figure for a source it could only partly price, and a
|
|
// partial total presented as the whole download understates it -- which is
|
|
// the direction that surprises somebody on a metered connection.
|
|
readonly property int downloadBytes: Number(root.dnf?.downloadBytes ?? 0)
|
|
+ Number(root.flatpak?.downloadBytes ?? 0)
|
|
+ Number(root.firmware?.downloadBytes ?? 0)
|
|
|
|
readonly property string downloadSize: root.formatBytes(root.downloadBytes)
|
|
|
|
// Decimal units, matching the Storage page and About's disk row, and the
|
|
// way both dnf and flatpak report sizes themselves.
|
|
function formatBytes(bytes: int): string {
|
|
const value = Number(bytes ?? 0);
|
|
if (!(value > 0))
|
|
return "";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let scaled = value;
|
|
let index = 0;
|
|
while (scaled >= 1000 && index < units.length - 1) {
|
|
scaled /= 1000;
|
|
index += 1;
|
|
}
|
|
return (scaled < 10 && index > 1 ? scaled.toFixed(1) : Math.round(scaled))
|
|
+ " " + units[index];
|
|
}
|
|
|
|
function sourceDownloadSize(source: string): string {
|
|
const record = source === "dnf" ? root.dnf
|
|
: source === "flatpak" ? root.flatpak
|
|
: source === "firmware" ? root.firmware : null;
|
|
return root.formatBytes(Number(record?.downloadBytes ?? 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;
|
|
}
|
|
|
|
// One application rather than all of them. The ID is checked against the
|
|
// last scan by the helper, so a page that has gone stale cannot ask for
|
|
// something that is not actually waiting.
|
|
function applyFlatpakApp(id: string): void {
|
|
if (applyProcess.running || !id)
|
|
return;
|
|
root.lastError = "";
|
|
root.lastApplied = null;
|
|
applyProcess.command = [root.helperPath, "apply", "flatpak", id];
|
|
applyProcess.running = true;
|
|
}
|
|
|
|
// ── Changelogs, fetched once per item ───────────────────────────────────
|
|
//
|
|
// Asking dnf what changed costs a metadata load, so an expander that
|
|
// re-asked every time it opened would spend seconds re-learning the same
|
|
// answer. Records are keyed "source/name" and kept for the life of the
|
|
// shell; the update they describe cannot change while it is pending.
|
|
|
|
property var changelogs: ({})
|
|
|
|
// Bumped whenever a record lands, and read at the top of changelogFor() so
|
|
// a binding built on that call has something to invalidate. A bare
|
|
// function call captures no dependency and every reader would go stale --
|
|
// the same reason DesktopPreferences.get() reads its revision.
|
|
property int changelogRevision: 0
|
|
|
|
// [{source, name}] waiting their turn. One at a time rather than one
|
|
// process per expander: each fetch loads repository metadata, and running
|
|
// several concurrently would multiply that work for no benefit -- nobody
|
|
// reads two changelogs at once.
|
|
property var changelogQueue: []
|
|
|
|
readonly property bool loadingChangelog: changelogProcess.running
|
|
|
|
// Returns the record for an item, or null while one is being fetched.
|
|
// Starting the fetch is a side effect on purpose: the page asks for a
|
|
// changelog by rendering one, and there is nothing else to ask.
|
|
function changelogFor(source: string, name: string): var {
|
|
root.changelogRevision;
|
|
const key = source + "/" + name;
|
|
const known = root.changelogs[key];
|
|
if (known !== undefined)
|
|
return known;
|
|
if (!source || !name)
|
|
return null;
|
|
if (changelogProcess.key === key
|
|
|| root.changelogQueue.some(entry => entry.source + "/" + entry.name === key))
|
|
return null;
|
|
root.changelogQueue = root.changelogQueue.concat([{ source: source, name: name }]);
|
|
root.pumpChangelogs();
|
|
return null;
|
|
}
|
|
|
|
function pumpChangelogs(): void {
|
|
if (changelogProcess.running || root.changelogQueue.length === 0)
|
|
return;
|
|
const next = root.changelogQueue[0];
|
|
root.changelogQueue = root.changelogQueue.slice(1);
|
|
changelogProcess.key = next.source + "/" + next.name;
|
|
changelogProcess.outputText = "";
|
|
changelogProcess.exited = false;
|
|
changelogProcess.streamFinished = false;
|
|
changelogProcess.command = [root.helperPath, "changelog", next.source, next.name];
|
|
changelogProcess.running = true;
|
|
}
|
|
|
|
function settleChangelog(): void {
|
|
if (!changelogProcess.exited || !changelogProcess.streamFinished
|
|
|| changelogProcess.key === "")
|
|
return;
|
|
root.absorbChangelog(changelogProcess.key, changelogProcess.outputText);
|
|
changelogProcess.key = "";
|
|
root.pumpChangelogs();
|
|
}
|
|
|
|
function absorbChangelog(key: string, text: string): void {
|
|
let record = { kind: "none", text: "", error: "The changelog could not be read." };
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
record = {
|
|
kind: String(parsed.kind ?? "none"),
|
|
text: String(parsed.text ?? ""),
|
|
error: String(parsed.error ?? "")
|
|
};
|
|
} catch (error) {
|
|
console.warn("Updates: could not parse changelog output:", error);
|
|
}
|
|
const next = Object.assign({}, root.changelogs);
|
|
next[key] = record;
|
|
root.changelogs = next;
|
|
root.changelogRevision += 1;
|
|
}
|
|
|
|
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) }
|
|
}
|
|
|
|
Process {
|
|
id: changelogProcess
|
|
|
|
// Which record the output belongs to. Held on the process rather than
|
|
// read back from the payload so a reply that failed to name itself
|
|
// still lands under the key that was asked for, instead of silently
|
|
// going nowhere and leaving the expander spinning forever.
|
|
property string key: ""
|
|
|
|
// Exit and stream-close arrive in either order. Settling on both --
|
|
// the same pair Health.qml waits on -- is what stops a reply being
|
|
// filed under an already-cleared key, which would leave the expander
|
|
// waiting on an answer that had in fact already arrived.
|
|
property string outputText: ""
|
|
property bool exited: false
|
|
property bool streamFinished: false
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
changelogProcess.outputText = this.text;
|
|
changelogProcess.streamFinished = true;
|
|
root.settleChangelog();
|
|
}
|
|
}
|
|
onExited: (exitCode, exitStatus) => {
|
|
changelogProcess.exited = true;
|
|
root.settleChangelog();
|
|
}
|
|
}
|
|
}
|