468 lines
18 KiB
QML
468 lines
18 KiB
QML
// 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
|
||
}
|
||
}
|
||
}
|