// Software updates. // // Three sources that fail independently -- packages, applications, firmware -- // so each is counted and applied on its own. Blending them into one number // would hide the case that matters: a flatpak mirror being down says nothing // about whether a security fix is waiting. // // Applying packages takes a snapshot first, named after what is about to // happen, so the Snapshots page shows "before 32 package updates" rather than a // timestamp. That is the thing neither macOS nor Windows does cleanly, and it // is nearly free here. // // A package row can say what it changes. "1 update available" tells you nothing // about whether to install it now or after the meeting; the changelog does, and // it is fetched per package on request rather than for a list nobody read. // // The automatic switches sit in their own card. The one that downloads packages // used to live at the bottom of Firmware, which is where somebody put it once // and nobody could find it since. import Quickshell import QtQuick import qs.config import qs.services SettingsPage { id: root objectName: "updates" title: "Software Update" lede: root.headline // Rows are shown a screenful at a time. A dnf update is routinely thirty // packages, and a page that opens on thirty rows is a page nobody reads. readonly property int previewCount: 6 property bool showAllPackages: false // "dnf:name" or "flatpak:id" -- the one row whose changelog is open. property string expandedPackage: "" Component.onCompleted: { if (!Updates.scanned) Updates.refresh(); // A first visit with nothing cached should not show a confident "up to // date" it has no basis for, so it goes and finds out. if (!Updates.everChecked && !Updates.checking) Updates.check(); } function joined(parts: var): string { return parts.filter(part => String(part ?? "") !== "").join(" · "); } readonly property string headline: { if (Updates.checking) return "Checking every source…"; // "nothing security-critical" is a measurement, not a default. When the // advisory query did not answer, the page says so instead of saying the // reassuring thing it would otherwise have said. const security = Updates.securityCount > 0 ? Updates.securityCount + " carrying a security advisory" : (Updates.total > 0 ? (Updates.securityKnown ? "nothing security-critical" : "security advisories could not be read") : ""); return root.joined([ Updates.summary(), security, Updates.lastCheckedText().replace("Checked", "checked") ]) + "."; } readonly property var packages: Updates.dnf?.packages ?? [] readonly property var shownPackages: root.showAllPackages ? root.packages : root.packages.slice(0, root.previewCount) readonly property var applications: Updates.flatpak?.applications ?? [] readonly property var firmwareDevices: Updates.firmware?.devices ?? [] function toggleChangelog(source: string, name: string): void { const key = source + ":" + name; const opening = root.expandedPackage !== key; root.expandedPackage = opening ? key : ""; // The fetch starts here, on the press, not as a side effect of the // text rendering -- see Updates.changelogFor's comment for the // binding loop that taught us the difference. if (opening) Updates.requestChangelog(source, name); } // Pure read: Updates keeps the answer for the life of the shell, and // null means the fetch is still out. function changelogText(source: string, name: string): string { const record = Updates.changelogFor(source, name); if (record === null || record === undefined) return "Reading the changelog…"; if (String(record.text ?? "") !== "") return String(record.text); return String(record.error ?? "") !== "" ? String(record.error) : "No changelog published for this update."; } // ── What just happened ─────────────────────────────────────────────────── ErrorRow { label: "Updates need attention" message: Updates.lastError } TextRow { visible: Updates.lastApplied !== null label: "Updated" detail: Updates.lastApplied ? Updates.sourceLabel(String(Updates.lastApplied.source ?? "")) + (String(Updates.lastApplied.restorePoint ?? "") !== "" ? " · a snapshot was taken first, number " + String(Updates.lastApplied.restorePoint) : "") : "" value: "" divider: false } SettingsCard { ActionRow { label: "Check for updates" detail: "Refreshes package metadata, application remotes, and firmware. Takes a few seconds." action: Updates.checking ? "Checking…" : "Check now" enabled: !Updates.busy divider: Updates.rebootNeeded onTriggered: Updates.check() } // The honest version of "restart required": the running kernel is not // the newest installed one, so a reboot would change which kernel runs. TextRow { visible: Updates.rebootNeeded label: "Restart to finish" detail: "A newer kernel is installed than the one running. " + String(Updates.kernel?.running ?? "") + " → " + String(Updates.kernel?.newestInstalled ?? "") value: "Restart needed" divider: false } } // ── System packages ────────────────────────────────────────────────────── SettingsCard { title: "System packages" // The failed case comes before the empty one: dnf reporting nothing and // dnf being unable to report are the same empty list, and only one of // them means there is nothing waiting. subtitle: Updates.sourceError("dnf") !== "" ? Updates.sourceError("dnf") : Updates.dnf?.available === false ? "dnf is not available on this machine." : (root.packages.length === 0 ? "Nothing waiting." : root.joined([ root.packages.length + " ready to install", Updates.securityCount > 0 ? Updates.securityCount + " carrying an advisory" : "", Updates.sourceDownloadSize("dnf") === "" ? "" : Updates.sourceDownloadSize("dnf") + " to download" ])) Repeater { id: packageRows model: root.shownPackages Column { id: packageRow required property var modelData required property int index readonly property string name: String(packageRow.modelData.name ?? "") readonly property bool open: root.expandedPackage === "dnf:" + packageRow.name width: parent.width ActionRow { width: parent.width label: packageRow.name detail: root.joined([ String(packageRow.modelData.version ?? ""), String(packageRow.modelData.repository ?? "") === "" ? "" : "from " + packageRow.modelData.repository ]) action: packageRow.open ? "Hide" : "Changelog" divider: !packageRow.open && packageRow.index < packageRows.count - 1 onTriggered: root.toggleChangelog("dnf", packageRow.name) } Text { width: parent.width visible: packageRow.open text: root.changelogText("dnf", packageRow.name) color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap lineHeight: 1.3 topPadding: 2 bottomPadding: 14 } } } ActionRow { visible: root.packages.length > root.previewCount label: root.showAllPackages ? "Showing all " + root.packages.length + " packages" : (root.packages.length - root.previewCount) + " more waiting" detail: root.showAllPackages ? "" : "Each one can say what it changes" action: root.showAllPackages ? "Show fewer" : "Show all" onTriggered: { root.expandedPackage = ""; root.showAllPackages = !root.showAllPackages; } } ActionRow { visible: root.packages.length > 0 label: "Install package updates" detail: "Asks for your password, and takes a snapshot first — undo lives in System › Snapshots" action: Updates.applying ? "Working…" : "Install updates" enabled: !Updates.busy divider: false onTriggered: Updates.apply("dnf") } TextRow { visible: root.packages.length === 0 && Updates.everChecked label: "Packages are current" detail: "Nothing from the system repositories is waiting" value: "" divider: false } } // ── Automatic ──────────────────────────────────────────────────────────── // // Both switches, in the one card about the same idea. The package one used // to sit at the bottom of Firmware. SettingsCard { title: "Automatic" SwitchRow { visible: Updates.automatic?.dnfAutomaticAvailable === true label: "Download package updates automatically" detail: "Fetches them in the background each morning so installing is quick. It does not install them: a machine that updates packages unattended can reboot into a kernel nobody chose." checked: Updates.automatic?.dnfAutomaticEnabled === true enabled: !Updates.busy onToggled: value => Updates.setAutomaticDnf(value) } // Offered only when the machinery exists. When it does not, this says // so rather than showing a switch that could not do anything -- // installing software is not a settings action. TextRow { visible: Updates.automatic?.dnfAutomaticAvailable === false label: "Download package updates automatically" detail: "Not set up. dnf-automatic is not installed, and Settings does not install software." value: "Off" } SwitchRow { label: "Update applications automatically" detail: "Once a day, in the background. Applications are not a security boundary the way packages are, so this is safe to leave on; packages still ask." checked: Updates.automatic?.flatpakEnabled === true enabled: !Updates.busy && Updates.automatic?.flatpakAvailable === true divider: false onToggled: value => Updates.setAutomaticFlatpak(value) } } // ── Applications & firmware ────────────────────────────────────────────── SettingsCard { title: "Applications & firmware" // A check that failed is not a machine that is current. "Current" is // only ever said about a list that was actually read. NotMeasuredRow { visible: Updates.sourceError("flatpak") !== "" label: "Flatpak" value: "Not checked" because: Updates.sourceError("flatpak") divider: true } TextRow { visible: Updates.sourceError("flatpak") === "" && Updates.flatpak?.available === false label: "Flatpak" detail: "Flatpak is not installed" value: "Unavailable" } TextRow { visible: Updates.sourceError("flatpak") === "" && Updates.flatpak?.available !== false && root.applications.length === 0 label: "Flatpak" detail: "Everything current — each application gets its own row when an update appears" value: "Current" } // Per application, because they update independently and one of them // being 400 MB is a reason to do the other three now and that one // later. Repeater { id: applicationRows model: root.applications Column { id: applicationRow required property var modelData required property int index readonly property string appId: String(applicationRow.modelData.id ?? "") readonly property bool open: root.expandedPackage === "flatpak:" + applicationRow.appId width: parent.width ActionRow { width: parent.width label: applicationRow.appId detail: root.joined([ String(applicationRow.modelData.version ?? ""), "Flatpak", Updates.formatBytes(Number(applicationRow.modelData.downloadBytes ?? 0)) ]) action: Updates.applying ? "Working…" : "Update" enabled: !Updates.busy divider: false onTriggered: Updates.applyFlatpakApp(applicationRow.appId) } ActionRow { width: parent.width label: "What changed" detail: applicationRow.open ? "" : "Release notes, when the application publishes them" action: applicationRow.open ? "Hide" : "Changelog" divider: !applicationRow.open onTriggered: root.toggleChangelog("flatpak", applicationRow.appId) } Text { width: parent.width visible: applicationRow.open text: root.changelogText("flatpak", applicationRow.appId) color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap lineHeight: 1.3 topPadding: 2 bottomPadding: 14 } } } ActionRow { visible: root.applications.length > 1 label: "Update every application" detail: "Needs no password: these are installed for your account" action: Updates.applying ? "Working…" : "Update all" enabled: !Updates.busy onTriggered: Updates.apply("flatpak") } NotMeasuredRow { visible: Updates.sourceError("firmware") !== "" label: "Firmware" value: "Not checked" because: Updates.sourceError("firmware") divider: false } TextRow { visible: Updates.sourceError("firmware") === "" && Updates.firmware?.available === false label: "Firmware" detail: "Firmware updating is not available on this machine" value: "Unavailable" divider: false } TextRow { visible: Updates.sourceError("firmware") === "" && Updates.firmware?.available !== false && root.firmwareDevices.length === 0 label: "Firmware" detail: "No firmware updates are offered for this hardware" value: "Current" divider: false } Repeater { model: root.firmwareDevices TextRow { required property var modelData label: String(modelData.name ?? "") detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "") + (modelData.needsReboot ? " · installs on restart" : "") value: "" } } ActionRow { visible: root.firmwareDevices.length > 0 label: "Install firmware" detail: "Some devices only finish updating after a restart" action: Updates.applying ? "Working…" : "Install" enabled: !Updates.busy divider: false onTriggered: Updates.apply("firmware") } } // 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 TextRow { required property var modelData required property int index 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 } } }