Files

468 lines
18 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}
}