diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 0dfcd41..1522a7c 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -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 {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index ee8fb7b..c11930a 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -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}" }, diff --git a/config/dot/quickshell/modules/settings/UpdatesPage.qml b/config/dot/quickshell/modules/settings/UpdatesPage.qml new file mode 100644 index 0000000..d584dc6 --- /dev/null +++ b/config/dot/quickshell/modules/settings/UpdatesPage.qml @@ -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 + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 0311d81..b0cd7d9 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 0d022b6..8510efb 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -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} diff --git a/config/dot/quickshell/scripts/panama-updates b/config/dot/quickshell/scripts/panama-updates new file mode 100755 index 0000000..91093e7 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-updates @@ -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:])) diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 0fac03d..50ba46b 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 1870169..31cc9a6 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -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; diff --git a/config/dot/quickshell/services/Updates.qml b/config/dot/quickshell/services/Updates.qml new file mode 100644 index 0000000..8b7055e --- /dev/null +++ b/config/dot/quickshell/services/Updates.qml @@ -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() + } + } +} diff --git a/config/local/share/systemd/user/panama-flatpak-update.service b/config/local/share/systemd/user/panama-flatpak-update.service new file mode 100644 index 0000000..883b312 --- /dev/null +++ b/config/local/share/systemd/user/panama-flatpak-update.service @@ -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 diff --git a/config/local/share/systemd/user/panama-flatpak-update.timer b/config/local/share/systemd/user/panama-flatpak-update.timer new file mode 100644 index 0000000..9e37dad --- /dev/null +++ b/config/local/share/systemd/user/panama-flatpak-update.timer @@ -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 diff --git a/config/local/share/vicinae/scripts/settings-updates.sh b/config/local/share/vicinae/scripts/settings-updates.sh new file mode 100755 index 0000000..c5c1dd7 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-updates.sh @@ -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 diff --git a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md index 9ce0c0c..2757392 100644 --- a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md +++ b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md @@ -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 diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 70ef5c3..132376b 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -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 diff --git a/tests/quickshell/panama-doctor-contract.sh b/tests/quickshell/panama-doctor-contract.sh index 9ab117c..79f4090 100755 --- a/tests/quickshell/panama-doctor-contract.sh +++ b/tests/quickshell/panama-doctor-contract.sh @@ -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" ]] \ diff --git a/tests/quickshell/updates-contract.sh b/tests/quickshell/updates-contract.sh new file mode 100755 index 0000000..7edc2d5 --- /dev/null +++ b/tests/quickshell/updates-contract.sh @@ -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")"