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 "storage": return storagePage;
|
||||
case "snapshots": return snapshotsPage;
|
||||
case "updates": return updatesPage;
|
||||
case "users": return usersPage;
|
||||
case "sharing": return sharingPage;
|
||||
case "printers": return printersPage;
|
||||
@@ -165,6 +166,7 @@ Rectangle {
|
||||
Component { id: applicationsPage; ApplicationsPage {} }
|
||||
Component { id: storagePage; StoragePage {} }
|
||||
Component { id: snapshotsPage; SnapshotsPage {} }
|
||||
Component { id: updatesPage; UpdatesPage {} }
|
||||
Component { id: usersPage; UsersPage {} }
|
||||
Component { id: sharingPage; SharingPage {} }
|
||||
Component { id: printersPage; PrintersPage {} }
|
||||
|
||||
@@ -42,6 +42,7 @@ Rectangle {
|
||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||
{ page: "updates", label: "Software Update", icon: "\u{F06B0}" },
|
||||
{ page: "storage", label: "Storage", icon: "\u{F02CA}" },
|
||||
{ page: "snapshots", label: "Snapshots", icon: "\u{F0954}" },
|
||||
{ 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
|
||||
DateTimePage 1.0 DateTimePage.qml
|
||||
AccessibilityPage 1.0 AccessibilityPage.qml
|
||||
UpdatesPage 1.0 UpdatesPage.qml
|
||||
UsersPage 1.0 UsersPage.qml
|
||||
WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
WallpaperControls 1.0 WallpaperControls.qml
|
||||
|
||||
@@ -13,6 +13,7 @@ import re
|
||||
import secrets
|
||||
import signal
|
||||
import shutil
|
||||
import time
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -115,7 +116,7 @@ CHECK_ORDER = (
|
||||
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
|
||||
"input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
|
||||
"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",
|
||||
)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
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 valid_link(name: str, relative_source: Path) -> bool:
|
||||
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),
|
||||
"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),
|
||||
"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:
|
||||
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: "Network name", detail: "The name other machines see", 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: "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" },
|
||||
|
||||
@@ -92,7 +92,7 @@ Singleton {
|
||||
}
|
||||
|
||||
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";
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user