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:
Gabriel Brown
2026-08-19 17:25:11 -04:00
parent 89cf0f8c29
commit 8f0fe23377
16 changed files with 996 additions and 6 deletions
@@ -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
+58 -2
View File
@@ -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}
+312
View File
@@ -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;
+161
View File
@@ -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
View File
@@ -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