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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -267,6 +267,29 @@ done
|
||||
# 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
|
||||
# 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"
|
||||
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
|
||||
|
||||
@@ -114,7 +114,7 @@ run_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() {
|
||||
local snapshot="$1"
|
||||
@@ -123,8 +123,8 @@ assert_schema_and_redaction() {
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and ([.checks[].id] | length == 26)
|
||||
and ([.checks[].id] | unique | length == 26)
|
||||
and ([.checks[].id] | length == 27)
|
||||
and ([.checks[].id] | unique | length == 27)
|
||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
|
||||
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
|
||||
[[ "$(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