Automatic updates leave no other trace. The Flatpak that sat here as "1 update available" installed itself at 00:14 this morning and nothing on the machine would have said so. Both sources are asked in their own machine-readable form and merged on time, so the answer reads as one history rather than two lists to interleave by eye. Two parsing traps worth recording next to the code. flatpak's --json prints timestamps as "Aug 20 08:07:46" with no year in them, so the year is inferred and a date that would land in the future is read as last year's. And dnf5's start_time is epoch UTC while its own history table prints that same value as though it were local -- checked against rpm, and the local rendering here is the correct one. The contract asserts entries are newest first, that none is dated in the future, and that both sources parse; it was verified to fail by breaking the year inference so every flatpak entry landed tomorrow. Loaded on demand rather than with the page, because it reads both full transaction logs. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
311 lines
12 KiB
QML
311 lines
12 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.
|
|
|
|
import Quickshell
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
objectName: "updates"
|
|
title: "Software Update"
|
|
lede: "Packages, applications, and firmware, each from the place it actually comes from."
|
|
|
|
property string expandedSource: ""
|
|
|
|
Component.onCompleted: {
|
|
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();
|
|
}
|
|
|
|
TextRow {
|
|
visible: Updates.lastError !== ""
|
|
label: "Updates need attention"
|
|
detail: Updates.lastError
|
|
value: ""
|
|
divider: false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── The headline ─────────────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
Column {
|
|
width: parent.width
|
|
spacing: 10
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
|
|
Text {
|
|
text: Updates.checking ? "Checking…" : Updates.summary()
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeTitle
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: Updates.securityCount > 0
|
|
text: Updates.securityCount + " carry a security advisory"
|
|
color: Theme.warn
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: Updates.lastCheckedText()
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// ── One card per source ──────────────────────────────────────────────────
|
|
|
|
SettingsCard {
|
|
title: "System packages"
|
|
subtitle: Updates.dnf?.available === false
|
|
? "dnf is not available on this machine."
|
|
: (Number(Updates.dnf?.count ?? 0) === 0
|
|
? "Nothing waiting."
|
|
: Updates.dnf.count + " package"
|
|
+ (Updates.dnf.count === 1 ? "" : "s") + " ready to install"
|
|
+ (Updates.securityCount > 0
|
|
? ", " + Updates.securityCount + " carrying an advisory" : ""))
|
|
|
|
ActionRow {
|
|
visible: Number(Updates.dnf?.count ?? 0) > 0
|
|
label: "Install package updates"
|
|
detail: "Asks for your password, and takes a snapshot first so this can be undone"
|
|
action: Updates.applying ? "Working…" : "Install"
|
|
enabled: !Updates.busy
|
|
onTriggered: Updates.apply("dnf")
|
|
}
|
|
|
|
ActionRow {
|
|
visible: Number(Updates.dnf?.count ?? 0) > 0
|
|
label: "What would change"
|
|
detail: root.expandedSource === "dnf"
|
|
? "Every package that would be replaced"
|
|
: Updates.dnf.count + " packages"
|
|
action: root.expandedSource === "dnf" ? "Hide" : "Show"
|
|
divider: root.expandedSource === "dnf"
|
|
onTriggered: root.expandedSource = root.expandedSource === "dnf" ? "" : "dnf"
|
|
}
|
|
|
|
Repeater {
|
|
model: root.expandedSource === "dnf" ? (Updates.dnf?.packages ?? []) : []
|
|
|
|
delegate: TextRow {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent.width
|
|
label: String(modelData.name ?? "")
|
|
detail: String(modelData.repository ?? "")
|
|
value: String(modelData.version ?? "")
|
|
divider: index < (Updates.dnf?.packages ?? []).length - 1
|
|
}
|
|
}
|
|
|
|
TextRow {
|
|
visible: Number(Updates.dnf?.count ?? 0) === 0 && Updates.everChecked
|
|
label: "Packages are current"
|
|
detail: "Nothing from the system repositories is waiting"
|
|
value: ""
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Applications"
|
|
subtitle: Updates.flatpak?.available === false
|
|
? "Flatpak is not installed."
|
|
: (Number(Updates.flatpak?.count ?? 0) === 0
|
|
? "Nothing waiting."
|
|
: Updates.flatpak.count + " application"
|
|
+ (Updates.flatpak.count === 1 ? "" : "s") + " ready to update")
|
|
|
|
Repeater {
|
|
model: Updates.flatpak?.applications ?? []
|
|
|
|
delegate: TextRow {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent.width
|
|
label: String(modelData.id ?? "")
|
|
detail: "Flatpak"
|
|
value: String(modelData.version ?? "")
|
|
divider: true
|
|
}
|
|
}
|
|
|
|
ActionRow {
|
|
visible: Number(Updates.flatpak?.count ?? 0) > 0
|
|
label: "Update applications"
|
|
detail: "Needs no password: these are installed for your account"
|
|
action: Updates.applying ? "Working…" : "Update"
|
|
enabled: !Updates.busy
|
|
onTriggered: Updates.apply("flatpak")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Firmware"
|
|
subtitle: Updates.firmware?.available === false
|
|
? "Firmware updating is not available on this machine."
|
|
: (Number(Updates.firmware?.count ?? 0) === 0
|
|
? "No firmware updates are offered for this hardware."
|
|
: Updates.firmware.count + " device"
|
|
+ (Updates.firmware.count === 1 ? "" : "s") + " have firmware available")
|
|
|
|
Repeater {
|
|
model: Updates.firmware?.devices ?? []
|
|
|
|
delegate: TextRow {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent.width
|
|
label: String(modelData.name ?? "")
|
|
detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "")
|
|
+ (modelData.needsReboot ? " · installs on restart" : "")
|
|
value: ""
|
|
divider: true
|
|
}
|
|
}
|
|
|
|
ActionRow {
|
|
visible: Number(Updates.firmware?.count ?? 0) > 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")
|
|
}
|
|
|
|
// 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.
|
|
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
|
|
divider: false
|
|
onToggled: value => Updates.setAutomaticDnf(value)
|
|
}
|
|
|
|
TextRow {
|
|
visible: Updates.automatic?.dnfAutomaticAvailable === false
|
|
label: "Automatic package updates"
|
|
detail: "Not set up. dnf-automatic is not installed, and Settings does not install software."
|
|
value: "Off"
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
delegate: TextRow {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent.width
|
|
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
|
|
}
|
|
}
|
|
}
|