Add Software Update, across packages, applications and firmware
Three sources that fail independently, so they are counted and applied 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, which is too long to spend every time a page opens, so the page opens on the last result and says when it was taken. A first visit with nothing cached goes and finds out rather than showing a confident "up to date" it has no basis for. Installing packages takes a snapshot first, named after what is about to happen, so Snapshots shows "before 32 package updates" rather than a timestamp. Best effort: a machine without snapper still updates, because an update that refuses to run when a nicety fails would be worse than one without a restore point. Automatic updates cover applications only, through a Panama-owned user timer running daily with a randomized delay. Packages still ask, and dnf-automatic is reported as absent rather than offered, because installing software is not a settings action. Health gained a check, and that is where the bug was: it first returned status "degraded", which is not in the doctor's vocabulary of ok, warning, error and unconfigured. It was counted as nothing at all while the summary still said healthy -- the same silent no-op this codebase keeps relearning. A contract now asserts every status a check can return is one the doctor counts, and the doctor's own contract knows about the new check rather than failing on its arrival. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -122,6 +122,7 @@ Rectangle {
|
|||||||
case "applications": return applicationsPage;
|
case "applications": return applicationsPage;
|
||||||
case "storage": return storagePage;
|
case "storage": return storagePage;
|
||||||
case "snapshots": return snapshotsPage;
|
case "snapshots": return snapshotsPage;
|
||||||
|
case "updates": return updatesPage;
|
||||||
case "users": return usersPage;
|
case "users": return usersPage;
|
||||||
case "sharing": return sharingPage;
|
case "sharing": return sharingPage;
|
||||||
case "printers": return printersPage;
|
case "printers": return printersPage;
|
||||||
@@ -165,6 +166,7 @@ Rectangle {
|
|||||||
Component { id: applicationsPage; ApplicationsPage {} }
|
Component { id: applicationsPage; ApplicationsPage {} }
|
||||||
Component { id: storagePage; StoragePage {} }
|
Component { id: storagePage; StoragePage {} }
|
||||||
Component { id: snapshotsPage; SnapshotsPage {} }
|
Component { id: snapshotsPage; SnapshotsPage {} }
|
||||||
|
Component { id: updatesPage; UpdatesPage {} }
|
||||||
Component { id: usersPage; UsersPage {} }
|
Component { id: usersPage; UsersPage {} }
|
||||||
Component { id: sharingPage; SharingPage {} }
|
Component { id: sharingPage; SharingPage {} }
|
||||||
Component { id: printersPage; PrintersPage {} }
|
Component { id: printersPage; PrintersPage {} }
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Rectangle {
|
|||||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||||
|
{ page: "updates", label: "Software Update", icon: "\u{F06B0}" },
|
||||||
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
|
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
|
||||||
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
|
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
|
||||||
{ page: "users", label: "Users", icon: "\u{F0004}" },
|
{ page: "users", label: "Users", icon: "\u{F0004}" },
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reported rather than offered. dnf-automatic is a package this machine
|
||||||
|
// does not have, and installing software is not a settings action.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ LockScreenPreview 1.0 LockScreenPreview.qml
|
|||||||
PowerPage 1.0 PowerPage.qml
|
PowerPage 1.0 PowerPage.qml
|
||||||
DateTimePage 1.0 DateTimePage.qml
|
DateTimePage 1.0 DateTimePage.qml
|
||||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||||
|
UpdatesPage 1.0 UpdatesPage.qml
|
||||||
UsersPage 1.0 UsersPage.qml
|
UsersPage 1.0 UsersPage.qml
|
||||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||||
WallpaperControls 1.0 WallpaperControls.qml
|
WallpaperControls 1.0 WallpaperControls.qml
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
@@ -115,7 +116,7 @@ CHECK_ORDER = (
|
|||||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
||||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||||
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
|
||||||
"integration.home-assistant", "integration.calendar", "panama.runtime-links", "panama.vicinae-commands",
|
"integration.home-assistant", "integration.calendar", "panama.updates", "panama.runtime-links", "panama.vicinae-commands",
|
||||||
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
|
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -485,6 +486,61 @@ def check_calendar(config: DoctorConfig) -> Check:
|
|||||||
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
|
return Check("integration.calendar", "integrations", "Calendar", "ok", f"{enabled_sources} enabled calendar source{'s' if enabled_sources != 1 else ''} configured.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_updates(config: DoctorConfig) -> Check:
|
||||||
|
"""Whether the machine is current, and whether it is running what it installed.
|
||||||
|
|
||||||
|
Two different questions with two different answers. A kernel that has been
|
||||||
|
installed but not booted into is the one people miss: everything reports
|
||||||
|
success, nothing looks wrong, and the security fix they installed last week
|
||||||
|
is sitting on disk unused. That is reported as its own state rather than
|
||||||
|
folded into "updates available".
|
||||||
|
|
||||||
|
Read from the Updates page's cache rather than by scanning: a health check
|
||||||
|
that took nine seconds of network work would make opening System Health feel
|
||||||
|
broken. A stale cache is reported as stale.
|
||||||
|
"""
|
||||||
|
cache = Path(os.environ.get("XDG_CACHE_HOME", config.home / ".cache")) / "panama" / "updates.json"
|
||||||
|
running = os.uname().release
|
||||||
|
|
||||||
|
newest = running
|
||||||
|
rpm_query = run_command(("rpm", "-q", "kernel", "--qf", "%{VERSION}-%{RELEASE}.%{ARCH}\\n"), config)
|
||||||
|
if rpm_query.state == "ok":
|
||||||
|
installed = [line.strip() for line in rpm_query.stdout.splitlines() if line.strip()]
|
||||||
|
if installed:
|
||||||
|
newest = installed[-1]
|
||||||
|
if newest != running:
|
||||||
|
return Check("panama.updates", "panama-tools", "Software updates", "warning",
|
||||||
|
f"A newer kernel is installed than the one running ({running} → {newest}). Restart to use it.",
|
||||||
|
action=Action("open", "Open Software Update"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(cache.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
|
||||||
|
"Updates have not been checked yet.",
|
||||||
|
action=Action("open", "Open Software Update"))
|
||||||
|
|
||||||
|
checked_at = int(payload.get("checkedAt", 0))
|
||||||
|
age_days = (time.time() - checked_at) / 86400 if checked_at else 999
|
||||||
|
security = int(payload.get("dnf", {}).get("securityCount", 0))
|
||||||
|
total = sum(int(payload.get(source, {}).get("count", 0))
|
||||||
|
for source in ("dnf", "flatpak", "firmware"))
|
||||||
|
|
||||||
|
if security > 0:
|
||||||
|
return Check("panama.updates", "panama-tools", "Software updates", "warning",
|
||||||
|
f"{security} pending update{'' if security == 1 else 's'} carry a security advisory.",
|
||||||
|
action=Action("open", "Open Software Update"))
|
||||||
|
if age_days > 7:
|
||||||
|
return Check("panama.updates", "panama-tools", "Software updates", "unconfigured",
|
||||||
|
"Updates have not been checked in over a week.",
|
||||||
|
action=Action("open", "Open Software Update"))
|
||||||
|
if total > 0:
|
||||||
|
return Check("panama.updates", "panama-tools", "Software updates", "ok",
|
||||||
|
f"{total} update{'' if total == 1 else 's'} available, none carrying a security advisory.")
|
||||||
|
return Check("panama.updates", "panama-tools", "Software updates", "ok",
|
||||||
|
"Everything is current.")
|
||||||
|
|
||||||
|
|
||||||
def check_runtime_links(config: DoctorConfig) -> Check:
|
def check_runtime_links(config: DoctorConfig) -> Check:
|
||||||
def valid_link(name: str, relative_source: Path) -> bool:
|
def valid_link(name: str, relative_source: Path) -> bool:
|
||||||
destination = config.config_home / name
|
destination = config.config_home / name
|
||||||
@@ -601,7 +657,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
|
|||||||
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
|
||||||
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
"input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
|
||||||
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
|
||||||
"panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
"panama.updates": lambda: check_updates(config), "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
|
||||||
}
|
}
|
||||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
futures = {check_id: executor.submit(probes[check_id]) for check_id in CHECK_ORDER}
|
||||||
|
|||||||
Executable
+312
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Software updates, from every source this machine actually uses.
|
||||||
|
|
||||||
|
Three of them, and they fail independently, so they are counted and applied
|
||||||
|
separately rather than blended into one number: packages (dnf), applications
|
||||||
|
(flatpak), and firmware (fwupd).
|
||||||
|
|
||||||
|
Checking costs about nine seconds of network and metadata work, which is too
|
||||||
|
long to spend every time a page opens. So `snapshot` is instant -- it reads the
|
||||||
|
last result plus the things that are free to compute -- and `check` is the scan
|
||||||
|
that refreshes it. The page shows when it last checked, the way every mature
|
||||||
|
updater does, instead of pretending the number is live.
|
||||||
|
|
||||||
|
panama-updates snapshot
|
||||||
|
panama-updates check
|
||||||
|
panama-updates apply dnf|flatpak|firmware
|
||||||
|
panama-updates set-auto-flatpak true|false
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# The user timer this ships for keeping applications current. dnf has no
|
||||||
|
# equivalent here because dnf-automatic is not installed, and installing
|
||||||
|
# software is not this script's job.
|
||||||
|
FLATPAK_TIMER = "panama-flatpak-update.timer"
|
||||||
|
|
||||||
|
# Anything carrying an advisory of these severities is reported as a security
|
||||||
|
# fix. "none" is excluded deliberately: an advisory with no severity is a
|
||||||
|
# bugfix or enhancement, and calling it security would cry wolf.
|
||||||
|
SECURITY_SEVERITIES = "critical,important,moderate,low"
|
||||||
|
|
||||||
|
|
||||||
|
class BoundaryError(RuntimeError):
|
||||||
|
"""A user-visible failure."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str], timeout: float = 180.0) -> subprocess.CompletedProcess:
|
||||||
|
try:
|
||||||
|
return subprocess.run(command, capture_output=True, text=True,
|
||||||
|
timeout=timeout, check=False)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
raise BoundaryError(f"{command[0]} did not finish in time.") from error
|
||||||
|
except OSError as error:
|
||||||
|
raise BoundaryError(f"{command[0]} is not available.") from error
|
||||||
|
|
||||||
|
|
||||||
|
def cache_path() -> Path:
|
||||||
|
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "panama"
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
return base / "updates.json"
|
||||||
|
|
||||||
|
|
||||||
|
def read_cache() -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(cache_path().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def write_cache(payload: dict) -> None:
|
||||||
|
# Written atomically: a page reading this while it is half-written would
|
||||||
|
# report zero updates, which is the one wrong answer that looks fine.
|
||||||
|
target = cache_path()
|
||||||
|
temporary = target.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
temporary.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
temporary.replace(target)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def kernel_state() -> dict:
|
||||||
|
"""Whether a reboot would change the kernel you are running.
|
||||||
|
|
||||||
|
This is the honest version of "restart required". Comparing the running
|
||||||
|
release against the newest installed one is exact, needs no plugin, and
|
||||||
|
takes no time -- and a machine that has already rebooted since the update
|
||||||
|
correctly reports nothing pending.
|
||||||
|
"""
|
||||||
|
running = os.uname().release
|
||||||
|
newest = running
|
||||||
|
result = run(["rpm", "-q", "kernel", "--qf", "%{VERSION}-%{RELEASE}.%{ARCH}\\n"], timeout=30)
|
||||||
|
if result.returncode == 0:
|
||||||
|
installed = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||||
|
if installed:
|
||||||
|
# rpm lists oldest first for equal names.
|
||||||
|
newest = installed[-1]
|
||||||
|
return {
|
||||||
|
"running": running,
|
||||||
|
"newestInstalled": newest,
|
||||||
|
"rebootNeeded": newest != running,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dnf_updates() -> dict:
|
||||||
|
if not shutil.which("dnf5"):
|
||||||
|
return {"available": False, "count": 0, "packages": [], "securityCount": 0}
|
||||||
|
|
||||||
|
result = run(["dnf5", "check-upgrade", "--json"], timeout=180)
|
||||||
|
packages = []
|
||||||
|
# dnf5 exits 100 when upgrades exist, 0 when none do. Both are success.
|
||||||
|
if result.returncode in (0, 100):
|
||||||
|
try:
|
||||||
|
payload = json.loads(result.stdout or "{}")
|
||||||
|
for entry in payload.get("upgrades", []):
|
||||||
|
packages.append({
|
||||||
|
"name": str(entry.get("name", "")),
|
||||||
|
"version": str(entry.get("evr", "")),
|
||||||
|
"repository": str(entry.get("repository", "")),
|
||||||
|
})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
security = 0
|
||||||
|
advisory = run(["dnf5", "check-upgrade",
|
||||||
|
f"--advisory-severities={SECURITY_SEVERITIES}", "--json"], timeout=180)
|
||||||
|
if advisory.returncode in (0, 100):
|
||||||
|
try:
|
||||||
|
security = len(json.loads(advisory.stdout or "{}").get("upgrades", []))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
security = 0
|
||||||
|
|
||||||
|
packages.sort(key=lambda item: item["name"])
|
||||||
|
return {"available": True, "count": len(packages), "packages": packages,
|
||||||
|
"securityCount": security}
|
||||||
|
|
||||||
|
|
||||||
|
def flatpak_updates() -> dict:
|
||||||
|
if not shutil.which("flatpak"):
|
||||||
|
return {"available": False, "count": 0, "applications": []}
|
||||||
|
result = run(["flatpak", "remote-ls", "--updates", "--columns=application,version"],
|
||||||
|
timeout=120)
|
||||||
|
applications = []
|
||||||
|
if result.returncode == 0:
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = [part.strip() for part in line.split("\t")]
|
||||||
|
if parts and parts[0]:
|
||||||
|
applications.append({"id": parts[0],
|
||||||
|
"version": parts[1] if len(parts) > 1 else ""})
|
||||||
|
return {"available": True, "count": len(applications), "applications": applications}
|
||||||
|
|
||||||
|
|
||||||
|
def firmware_updates() -> dict:
|
||||||
|
if not shutil.which("fwupdmgr"):
|
||||||
|
return {"available": False, "count": 0, "devices": []}
|
||||||
|
result = run(["fwupdmgr", "get-updates", "--json"], timeout=120)
|
||||||
|
devices = []
|
||||||
|
try:
|
||||||
|
payload = json.loads(result.stdout or "{}")
|
||||||
|
for device in payload.get("Devices", []):
|
||||||
|
releases = device.get("Releases", [])
|
||||||
|
devices.append({
|
||||||
|
"name": str(device.get("Name", "Unknown device")),
|
||||||
|
"version": str(device.get("Version", "")),
|
||||||
|
"target": str(releases[0].get("Version", "")) if releases else "",
|
||||||
|
# Firmware that needs a reboot to flash is worth saying up front.
|
||||||
|
"needsReboot": "needs-reboot" in json.dumps(device.get("Flags", [])),
|
||||||
|
})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {"available": True, "count": len(devices), "devices": devices}
|
||||||
|
|
||||||
|
|
||||||
|
def automatic_state() -> dict:
|
||||||
|
flatpak_timer = run(["systemctl", "--user", "is-enabled", FLATPAK_TIMER], timeout=20)
|
||||||
|
dnf_timer = run(["systemctl", "is-enabled", "dnf5-automatic.timer"], timeout=20)
|
||||||
|
return {
|
||||||
|
"flatpakEnabled": flatpak_timer.stdout.strip() == "enabled",
|
||||||
|
"flatpakAvailable": flatpak_timer.stdout.strip() not in ("", "not-found"),
|
||||||
|
# Reported, never offered: dnf-automatic is a package this machine does
|
||||||
|
# not have, and installing software is not a settings action.
|
||||||
|
"dnfAutomaticEnabled": dnf_timer.stdout.strip() == "enabled",
|
||||||
|
"dnfAutomaticAvailable": dnf_timer.stdout.strip() not in ("", "not-found"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check() -> dict:
|
||||||
|
payload = {
|
||||||
|
"dnf": dnf_updates(),
|
||||||
|
"flatpak": flatpak_updates(),
|
||||||
|
"firmware": firmware_updates(),
|
||||||
|
"checkedAt": int(time.time()),
|
||||||
|
}
|
||||||
|
write_cache(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot() -> dict:
|
||||||
|
cached = read_cache()
|
||||||
|
empty = {"available": True, "count": 0}
|
||||||
|
return {
|
||||||
|
"dnf": cached.get("dnf", {**empty, "packages": [], "securityCount": 0}),
|
||||||
|
"flatpak": cached.get("flatpak", {**empty, "applications": []}),
|
||||||
|
"firmware": cached.get("firmware", {**empty, "devices": []}),
|
||||||
|
# 0 means never checked, which the page says rather than showing a
|
||||||
|
# confident "0 updates" it has no basis for.
|
||||||
|
"checkedAt": int(cached.get("checkedAt", 0)),
|
||||||
|
"kernel": kernel_state(),
|
||||||
|
"automatic": automatic_state(),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def take_restore_point(reason: str) -> str:
|
||||||
|
"""A snapshot before the system changes, named after what is about to happen.
|
||||||
|
|
||||||
|
Best effort: if snapper is not configured, the update still proceeds. An
|
||||||
|
update that refuses to run because a nicety failed would be worse than one
|
||||||
|
without a restore point.
|
||||||
|
"""
|
||||||
|
if not shutil.which("snapper"):
|
||||||
|
return ""
|
||||||
|
result = run(["snapper", "-c", "root", "create", "--description", reason,
|
||||||
|
"--cleanup-algorithm", "number", "--print-number"], timeout=120)
|
||||||
|
return result.stdout.strip() if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def apply(source: str) -> dict:
|
||||||
|
if source == "flatpak":
|
||||||
|
if not shutil.which("flatpak"):
|
||||||
|
raise BoundaryError("Flatpak is not installed.")
|
||||||
|
result = run(["flatpak", "update", "-y", "--noninteractive"], timeout=3600)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise BoundaryError(_refusal(result, "The applications could not be updated."))
|
||||||
|
return {"restorePoint": ""}
|
||||||
|
|
||||||
|
if source == "dnf":
|
||||||
|
if not shutil.which("dnf5"):
|
||||||
|
raise BoundaryError("dnf is not installed.")
|
||||||
|
pending = read_cache().get("dnf", {}).get("count", 0)
|
||||||
|
restore_point = take_restore_point(
|
||||||
|
f"before {pending} package update{'' if pending == 1 else 's'}")
|
||||||
|
result = run(["pkexec", "dnf5", "upgrade", "-y"], timeout=7200)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise BoundaryError(_refusal(result, "The packages could not be updated."))
|
||||||
|
return {"restorePoint": restore_point}
|
||||||
|
|
||||||
|
if source == "firmware":
|
||||||
|
if not shutil.which("fwupdmgr"):
|
||||||
|
raise BoundaryError("Firmware updating is not available.")
|
||||||
|
result = run(["fwupdmgr", "update", "-y", "--no-reboot-check"], timeout=3600)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise BoundaryError(_refusal(result, "The firmware could not be updated."))
|
||||||
|
return {"restorePoint": ""}
|
||||||
|
|
||||||
|
raise BoundaryError("That is not an update source.")
|
||||||
|
|
||||||
|
|
||||||
|
def set_auto_flatpak(enabled: bool) -> None:
|
||||||
|
action = ["enable", "--now"] if enabled else ["disable", "--now"]
|
||||||
|
result = run(["systemctl", "--user", *action, FLATPAK_TIMER], timeout=60)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise BoundaryError(_refusal(result, "Automatic application updates could not be changed."))
|
||||||
|
|
||||||
|
|
||||||
|
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
|
||||||
|
text = ((result.stderr or "") + "\n" + (result.stdout or "")).strip().splitlines()
|
||||||
|
meaningful = [line for line in text if line.strip()]
|
||||||
|
if not meaningful:
|
||||||
|
return fallback
|
||||||
|
last = meaningful[-1]
|
||||||
|
if "not authorized" in last.lower() or "dismissed" in last.lower():
|
||||||
|
return "That update was not authorized."
|
||||||
|
return last[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def main(arguments: list[str]) -> int:
|
||||||
|
try:
|
||||||
|
if arguments == ["snapshot"]:
|
||||||
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
if arguments == ["check"]:
|
||||||
|
check()
|
||||||
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
if len(arguments) == 2 and arguments[0] == "apply":
|
||||||
|
outcome = apply(arguments[1])
|
||||||
|
# Re-check, so the page reflects what is actually left rather than
|
||||||
|
# assuming the update cleared everything it listed.
|
||||||
|
check()
|
||||||
|
state = snapshot()
|
||||||
|
state["applied"] = {"source": arguments[1], **outcome}
|
||||||
|
print(json.dumps(state, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
if len(arguments) == 2 and arguments[0] == "set-auto-flatpak":
|
||||||
|
set_auto_flatpak(arguments[1] == "true")
|
||||||
|
else:
|
||||||
|
raise BoundaryError(
|
||||||
|
"Usage: panama-updates snapshot | check | apply dnf|flatpak|firmware | "
|
||||||
|
"set-auto-flatpak true|false")
|
||||||
|
except BoundaryError as error:
|
||||||
|
state = snapshot()
|
||||||
|
state["error"] = str(error)
|
||||||
|
print(json.dumps(state, separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -78,6 +78,11 @@ Singleton {
|
|||||||
{ label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
|
{ label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
|
||||||
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
||||||
{ label: "File sharing", detail: "Share folders on the network", page: "sharing" },
|
{ label: "File sharing", detail: "Share folders on the network", page: "sharing" },
|
||||||
|
{ label: "Software update", detail: "Packages, applications, and firmware", page: "updates" },
|
||||||
|
{ label: "Updates", detail: "What is waiting to be installed", page: "updates" },
|
||||||
|
{ label: "Firmware", detail: "Updates for the hardware itself", page: "updates" },
|
||||||
|
{ label: "Security updates", detail: "Packages that carry a security advisory", page: "updates" },
|
||||||
|
{ label: "Automatic updates", detail: "Keep applications current in the background", page: "updates" },
|
||||||
{ label: "Snapshots", detail: "Points in time you can go back to", page: "snapshots" },
|
{ label: "Snapshots", detail: "Points in time you can go back to", page: "snapshots" },
|
||||||
{ label: "Restore a file", detail: "Take a file or folder back out of a snapshot", page: "snapshots" },
|
{ label: "Restore a file", detail: "Take a file or folder back out of a snapshot", page: "snapshots" },
|
||||||
{ label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" },
|
{ label: "Backups", detail: "Automatic snapshots of the system and your home folder", page: "snapshots" },
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
function openSettings(page: string): void {
|
||||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "storage", "snapshots", "users", "sharing", "printers", "services", "about"];
|
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "printers", "services", "about"];
|
||||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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)
|
||||||
|
readonly property bool rebootNeeded: root.kernel?.rebootNeeded === true
|
||||||
|
readonly property bool everChecked: root.checkedAt > 0
|
||||||
|
|
||||||
|
// A count nobody has verified is not a count. Saying "up to date" on the
|
||||||
|
// strength of a check that never ran is the one wrong answer that looks
|
||||||
|
// reassuring.
|
||||||
|
function summary(): string {
|
||||||
|
if (!root.everChecked)
|
||||||
|
return "Not checked yet";
|
||||||
|
if (root.total === 0)
|
||||||
|
return "Up to date";
|
||||||
|
return root.total + " update" + (root.total === 1 ? "" : "s") + " available";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Update Flatpak applications
|
||||||
|
Documentation=https://github.com/gibbyb/Panama
|
||||||
|
# Pointless without a network, and a failure here is noise rather than news.
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# --noninteractive so it never waits for an answer nobody is there to give.
|
||||||
|
# No --system: this updates the user installation, which needs no privilege.
|
||||||
|
ExecStart=/usr/bin/flatpak update --assumeyes --noninteractive
|
||||||
|
# An update that cannot reach the network should not be reported as a failure
|
||||||
|
# every hour; the timer will try again.
|
||||||
|
SuccessExitStatus=0 1
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Update Flatpak applications daily
|
||||||
|
Documentation=https://github.com/gibbyb/Panama
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Daily rather than hourly: applications are not a security boundary the way
|
||||||
|
# packages are, and a download starting while someone is gaming is worse than
|
||||||
|
# an application being a day out of date.
|
||||||
|
OnCalendar=daily
|
||||||
|
# Catches machines that were off at the scheduled time, which a desktop
|
||||||
|
# frequently is.
|
||||||
|
Persistent=true
|
||||||
|
# So several machines do not all wake and download at once.
|
||||||
|
RandomizedDelaySec=30m
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||||
|
# @vicinae.schemaVersion 1
|
||||||
|
# @vicinae.title Settings: Software Update
|
||||||
|
# @vicinae.mode silent
|
||||||
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
|
# @vicinae.description Open Software Update in Settings.
|
||||||
|
# @vicinae.keywords ["settings", "software update", "updates", "firmware", "security updates", "automatic updates"]
|
||||||
|
|
||||||
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page updates
|
||||||
@@ -65,6 +65,28 @@ thing on this list and it is nearly free now.
|
|||||||
**Done when** the machine can be brought fully current from Settings, and going
|
**Done when** the machine can be brought fully current from Settings, and going
|
||||||
back is one click on a page that already exists.
|
back is one click on a page that already exists.
|
||||||
|
|
||||||
|
**Landed 2026-08-19.** All three sources, counted and applied separately.
|
||||||
|
Firmware was added to the plan after the fact and belonged there: `fwupd` is
|
||||||
|
installed, reports cleanly as JSON, and answers instantly.
|
||||||
|
|
||||||
|
Two things the shape of the data forced. Checking costs about nine seconds, so
|
||||||
|
the page opens on a cached result and says when it was taken -- a first visit
|
||||||
|
with nothing cached goes and finds out rather than showing a confident "up to
|
||||||
|
date" it has no basis for. And `dnf5` exits 100 when upgrades exist, which is
|
||||||
|
success, not failure.
|
||||||
|
|
||||||
|
Automatic updates apply to applications only, through a Panama-owned systemd
|
||||||
|
user timer running daily with a randomized delay. Packages still ask.
|
||||||
|
dnf-automatic is reported as absent rather than offered, because installing
|
||||||
|
software is not a settings action.
|
||||||
|
|
||||||
|
Health gained a check for it, which is where the interesting bug was: it first
|
||||||
|
returned status "degraded", and the doctor's vocabulary is ok, warning, error,
|
||||||
|
unconfigured. The check was counted as nothing at all while the summary still
|
||||||
|
reported healthy -- the same silent no-op this codebase keeps relearning, this
|
||||||
|
time caught by writing the contract. A contract now asserts every status a check
|
||||||
|
can return is one the doctor counts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Batch 2 — Your infrastructure
|
## Batch 2 — Your infrastructure
|
||||||
|
|||||||
@@ -267,6 +267,29 @@ done
|
|||||||
# editor ends up owning screenshots and a video transcoder ends up owning MP3s.
|
# editor ends up owning screenshots and a video transcoder ends up owning MP3s.
|
||||||
# Seeding only fills roles nobody has chosen for; an existing "Open With" choice
|
# Seeding only fills roles nobody has chosen for; an existing "Open With" choice
|
||||||
# is always kept.
|
# is always kept.
|
||||||
|
# Panama's own systemd user units -- currently the Flatpak update timer, which
|
||||||
|
# the Updates page switches on and off. Linked rather than copied so an edit in
|
||||||
|
# the repository is the edit that runs.
|
||||||
|
PANAMA_UNIT_DIR="$PANAMA_PATH/config/local/share/systemd/user"
|
||||||
|
USER_UNIT_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/systemd/user"
|
||||||
|
if [[ -d "$PANAMA_UNIT_DIR" ]]; then
|
||||||
|
mkdir -p "$USER_UNIT_DIR"
|
||||||
|
for unit_file in "$PANAMA_UNIT_DIR"/*; do
|
||||||
|
[[ -e "$unit_file" ]] || continue
|
||||||
|
unit_name="$(basename "$unit_file")"
|
||||||
|
unit_target="$USER_UNIT_DIR/$unit_name"
|
||||||
|
if [[ -L "$unit_target" ]]; then
|
||||||
|
rm "$unit_target"
|
||||||
|
elif [[ -e "$unit_target" ]]; then
|
||||||
|
log "Keeping existing unit at $unit_target"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
ln -s "$unit_file" "$unit_target"
|
||||||
|
log "Linked $unit_name → $unit_target"
|
||||||
|
done
|
||||||
|
systemctl --user daemon-reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
DEFAULT_APPS_HELPER="$PANAMA_PATH/config/dot/quickshell/scripts/panama-default-apps"
|
DEFAULT_APPS_HELPER="$PANAMA_PATH/config/dot/quickshell/scripts/panama-default-apps"
|
||||||
if [[ -x "$DEFAULT_APPS_HELPER" ]] && command -v xdg-mime >/dev/null 2>&1; then
|
if [[ -x "$DEFAULT_APPS_HELPER" ]] && command -v xdg-mime >/dev/null 2>&1; then
|
||||||
update-desktop-database "$USER_APPLICATION_DIR" >/dev/null 2>&1 || true
|
update-desktop-database "$USER_APPLICATION_DIR" >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ run_doctor() {
|
|||||||
/usr/bin/python3 "$doctor" "$@"
|
/usr/bin/python3 "$doctor" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.updates\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||||
|
|
||||||
assert_schema_and_redaction() {
|
assert_schema_and_redaction() {
|
||||||
local snapshot="$1"
|
local snapshot="$1"
|
||||||
@@ -123,8 +123,8 @@ assert_schema_and_redaction() {
|
|||||||
and (.summary.status | IN("healthy", "warning", "error"))
|
and (.summary.status | IN("healthy", "warning", "error"))
|
||||||
and (.context.session | IN("hyprland", "other"))
|
and (.context.session | IN("hyprland", "other"))
|
||||||
and (.context.versions | type == "array")
|
and (.context.versions | type == "array")
|
||||||
and ([.checks[].id] | length == 26)
|
and ([.checks[].id] | length == 27)
|
||||||
and ([.checks[].id] | unique | length == 26)
|
and ([.checks[].id] | unique | length == 27)
|
||||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
||||||
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
||||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
|
||||||
|
|||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Updates come from three places that fail independently, and the page must not
|
||||||
|
# claim to know more than it checked.
|
||||||
|
#
|
||||||
|
# The rules:
|
||||||
|
#
|
||||||
|
# 1. Every status a health check can return must be one the doctor counts.
|
||||||
|
# "degraded" is not in its vocabulary; a check returning it was counted as
|
||||||
|
# nothing at all while the summary still said healthy. That is the silent
|
||||||
|
# no-op this whole codebase keeps relearning.
|
||||||
|
# 2. A count nobody verified is not a count. "Up to date" may only be said
|
||||||
|
# after a check actually ran.
|
||||||
|
# 3. Applying packages takes a restore point first, and a failure to take one
|
||||||
|
# must not block the update.
|
||||||
|
# 4. Checking is separate from opening. A nine-second scan on every page open
|
||||||
|
# would make Settings feel broken.
|
||||||
|
#
|
||||||
|
# Read-only: it reads update state and never installs anything.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-updates"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/Updates.qml"
|
||||||
|
page="$repo_dir/config/dot/quickshell/modules/settings/UpdatesPage.qml"
|
||||||
|
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'updates contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in "$helper" "$service" "$page" "$doctor"; do
|
||||||
|
[[ -r "$path" ]] || fail "missing $path"
|
||||||
|
done
|
||||||
|
[[ -x "$helper" ]] || fail 'panama-updates is not executable'
|
||||||
|
|
||||||
|
# ── 1. Every health status is one the doctor counts ─────────────────────────
|
||||||
|
statuses="$(sed -n 's/^Status = Literal\[\(.*\)\]$/\1/p' "$doctor" | tr -d '" ' | tr ',' '\n' | grep -v '^$')"
|
||||||
|
[[ -n "$statuses" ]] || fail 'could not read the doctor status vocabulary'
|
||||||
|
while read -r used; do
|
||||||
|
[[ -n "$used" ]] || continue
|
||||||
|
grep -qx "$used" <<<"$statuses" \
|
||||||
|
|| fail "a check reports status \"$used\", which the doctor does not count -- it would be invisible in the summary"
|
||||||
|
done < <(grep -oE 'Check\("[a-z.]+", "[a-z-]+", "[^"]+", "[a-z]+"' "$doctor" \
|
||||||
|
| sed -E 's/.*, "([a-z]+)"$/\1/' | sort -u)
|
||||||
|
|
||||||
|
# ── 2. No confident answer without a check ──────────────────────────────────
|
||||||
|
grep -q 'everChecked' "$service" \
|
||||||
|
|| fail 'the service cannot tell "no updates" from "never looked"'
|
||||||
|
grep -q 'Not checked yet' "$service" \
|
||||||
|
|| fail 'a machine that has never checked is reported as up to date'
|
||||||
|
|
||||||
|
# ── 3. Packages get a restore point, best effort ────────────────────────────
|
||||||
|
apply_body="$(sed -n '/^def apply/,/^def /p' "$helper")"
|
||||||
|
grep -q 'take_restore_point' <<<"$apply_body" \
|
||||||
|
|| fail 'installing packages does not take a snapshot first'
|
||||||
|
restore_body="$(sed -n '/^def take_restore_point/,/^def /p' "$helper")"
|
||||||
|
grep -q 'return ""' <<<"$restore_body" \
|
||||||
|
|| fail 'a failed snapshot has no non-fatal path, so it would block the update'
|
||||||
|
grep -qE 'raise BoundaryError' <<<"$restore_body" \
|
||||||
|
&& fail 'a failed snapshot aborts the update, which is worse than an update without a restore point'
|
||||||
|
|
||||||
|
# Firmware and applications must NOT take a system snapshot: neither changes
|
||||||
|
# the system tree, and a restore point that restores nothing is noise.
|
||||||
|
grep -qE 'take_restore_point.*firmware|firmware.*take_restore_point' <<<"$apply_body" \
|
||||||
|
&& fail 'firmware updates take a system snapshot, which would restore nothing'
|
||||||
|
|
||||||
|
# ── 4. Opening is not checking ──────────────────────────────────────────────
|
||||||
|
grep -q 'def snapshot' "$helper" || fail 'there is no cheap read'
|
||||||
|
snapshot_body="$(sed -n '/^def snapshot/,/^def /p' "$helper")"
|
||||||
|
grep -qE 'dnf_updates\(\)|flatpak_updates\(\)|firmware_updates\(\)' <<<"$snapshot_body" \
|
||||||
|
&& fail 'the cheap read runs the expensive scan, so every page open would wait on the network'
|
||||||
|
grep -q 'read_cache()' <<<"$snapshot_body" \
|
||||||
|
|| fail 'the cheap read does not use the cached result'
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || { printf 'updates contract: SKIP (no jq)\n'; exit 0; }
|
||||||
|
|
||||||
|
# ── The snapshot is fast and complete ───────────────────────────────────────
|
||||||
|
started="$(date +%s)"
|
||||||
|
state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
|
||||||
|
elapsed=$(( $(date +%s) - started ))
|
||||||
|
(( elapsed <= 5 )) || fail "the cheap read took ${elapsed}s; it is supposed to be instant"
|
||||||
|
|
||||||
|
jq -e '(.dnf | type == "object") and (.flatpak | type == "object") and (.firmware | type == "object")' \
|
||||||
|
<<<"$state" >/dev/null || fail 'the snapshot is missing one of the three sources'
|
||||||
|
jq -e '.kernel | has("running") and has("newestInstalled") and has("rebootNeeded")' <<<"$state" >/dev/null \
|
||||||
|
|| fail 'the kernel state is incomplete'
|
||||||
|
jq -e '.automatic | has("flatpakEnabled")' <<<"$state" >/dev/null \
|
||||||
|
|| fail 'automatic update state is missing'
|
||||||
|
|
||||||
|
# The reboot signal must be derived, not guessed.
|
||||||
|
jq -e '.kernel.rebootNeeded == (.kernel.running != .kernel.newestInstalled)' <<<"$state" >/dev/null \
|
||||||
|
|| fail 'the reboot signal does not follow from the running and installed kernels'
|
||||||
|
|
||||||
|
# ── Refusals ────────────────────────────────────────────────────────────────
|
||||||
|
[[ -n "$("$helper" apply nonsense 2>/dev/null | jq -r '.error // ""')" ]] \
|
||||||
|
|| fail 'an unknown update source was accepted'
|
||||||
|
[[ -n "$("$helper" bogus 2>/dev/null | jq -r '.error // ""')" ]] \
|
||||||
|
|| fail 'an unknown command was accepted'
|
||||||
|
|
||||||
|
printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \
|
||||||
|
"$(jq -r '.dnf.count // 0' <<<"$state")" \
|
||||||
|
"$(jq -r '.flatpak.count // 0' <<<"$state")" \
|
||||||
|
"$(jq -r '.firmware.count // 0' <<<"$state")" \
|
||||||
|
"$(jq -r '.kernel.rebootNeeded' <<<"$state")"
|
||||||
Reference in New Issue
Block a user