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) // Whether the advisory query answered at all. It only ever shrinks the // alarm -- the page says "nothing security-critical" when the count is // zero -- so a failed query returning zero would be reassurance nobody // measured. readonly property bool securityKnown: root.dnf?.securityKnown !== false readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true // The last time EVERY source answered. The helper carries this forward // rather than restamping it on a partial failure, so it never describes a // check that did not complete. readonly property bool everChecked: root.checkedAt > 0 // ── What is not known, and why ────────────────────────────────────────── // // The three sources fail independently, so they carry their own errors. // A source that could not answer has an UNKNOWN count, which is not a count // of zero -- and the whole distance between those two is the distance // between "Up to date" and a lie. `total` sums what was actually counted, // so it is only the whole truth when nothing failed. readonly property var failedSources: { const failed = []; for (const source of ["dnf", "flatpak", "firmware"]) { const record = source === "dnf" ? root.dnf : source === "flatpak" ? root.flatpak : root.firmware; if (String(record?.error ?? "") !== "") failed.push(source); } return failed; } readonly property bool anySourceFailed: root.failedSources.length > 0 function sourceError(source: string): string { const record = source === "dnf" ? root.dnf : source === "flatpak" ? root.flatpak : source === "firmware" ? root.firmware : null; return String(record?.error ?? ""); } // "System packages", or "System packages and Firmware" -- named so the // headline can say which source it could not reach rather than leaving // someone to work it out from three cards. function describeFailedSources(): string { const names = root.failedSources.map(source => root.sourceLabel(source)); if (names.length <= 1) return names.join(""); return names.slice(0, -1).join(", ") + " and " + names[names.length - 1]; } // 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 -- or one that failed -- is the one // wrong answer that looks reassuring. // // A source that could not answer is checked FIRST and by name. "Up to date" // is a claim about all three, and it cannot be made while one of them is // unknown, however many the other two counted. function summary(): string { if (root.anySourceFailed) { const which = root.describeFailedSources(); return root.total > 0 ? root.total + " update" + (root.total === 1 ? "" : "s") + " found, but " + which + " could not be checked" : which + " could not be checked"; } 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. // Reading and requesting are two functions on purpose. The first version // started the fetch as a side effect of being rendered, and mutating the // queue from inside a binding evaluation is a binding loop: the queue // write bumps a property the binding reads, which re-evaluates the // binding, forty-odd times per page open. Bindings call changelogFor // (pure); the expand action calls requestChangelog (imperative). function changelogFor(source: string, name: string): var { root.changelogRevision; const known = root.changelogs[source + "/" + name]; return known !== undefined ? known : null; } function requestChangelog(source: string, name: string): void { if (!source || !name) return; const key = source + "/" + name; if (root.changelogs[key] !== undefined || changelogProcess.key === key || root.changelogQueue.some(entry => entry.source + "/" + entry.name === key)) return; root.changelogQueue = root.changelogQueue.concat([{ source: source, name: name }]); root.pumpChangelogs(); } 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(); } } }