From 8f50bb81b6ae5babc748635abc0b25e1ce67aca4 Mon Sep 17 00:00:00 2001 From: UmedjonBA Date: Mon, 17 Aug 2026 11:17:30 +0300 Subject: [PATCH 1/5] feat: add linux-updater plugin Multi-distro system updater grown out of arch-updater: background updates with a live log tail and progress, an update-history strip with rollback, panel-managed ignore lists, and one-click fixes for missing system setup. The engine is distro-neutral; per-manager backends (pacman, dnf, apt, zypper, xbps, PackageKit as the generic fallback) declare capabilities the UI honors, and the right backend is picked from /etc/os-release. Command layers of the non-Arch backends are verified in containers against recorded fixtures; the plugin is fully exercised on Arch. Co-Authored-By: Claude Fable 5 --- linux-updater/README.md | 207 ++ linux-updater/backends/apt.luau | 140 ++ linux-updater/backends/dnf.luau | 127 ++ linux-updater/backends/packagekit.luau | 91 + linux-updater/backends/pacman.luau | 202 ++ linux-updater/backends/xbps.luau | 108 ++ linux-updater/backends/zypper.luau | 115 ++ linux-updater/launcher.luau | 103 + linux-updater/panel.luau | 937 +++++++++ linux-updater/plugin.toml | 196 ++ .../polkit/49-linux-updater-apt.rules | 15 + .../polkit/49-linux-updater-dnf.rules | 16 + .../polkit/49-linux-updater-pacman.rules | 25 + .../polkit/49-linux-updater-xbps.rules | 15 + .../polkit/49-linux-updater-zypper.rules | 14 + linux-updater/screenshots/history.png | Bin 0 -> 16349 bytes linux-updater/screenshots/panel.png | Bin 0 -> 22437 bytes linux-updater/service.luau | 1674 +++++++++++++++++ linux-updater/thumbnail.webp | Bin 0 -> 25400 bytes linux-updater/translations/en.json | 221 +++ linux-updater/widget.luau | 159 ++ 21 files changed, 4365 insertions(+) create mode 100644 linux-updater/README.md create mode 100644 linux-updater/backends/apt.luau create mode 100644 linux-updater/backends/dnf.luau create mode 100644 linux-updater/backends/packagekit.luau create mode 100644 linux-updater/backends/pacman.luau create mode 100644 linux-updater/backends/xbps.luau create mode 100644 linux-updater/backends/zypper.luau create mode 100644 linux-updater/launcher.luau create mode 100644 linux-updater/panel.luau create mode 100644 linux-updater/plugin.toml create mode 100644 linux-updater/polkit/49-linux-updater-apt.rules create mode 100644 linux-updater/polkit/49-linux-updater-dnf.rules create mode 100644 linux-updater/polkit/49-linux-updater-pacman.rules create mode 100644 linux-updater/polkit/49-linux-updater-xbps.rules create mode 100644 linux-updater/polkit/49-linux-updater-zypper.rules create mode 100644 linux-updater/screenshots/history.png create mode 100644 linux-updater/screenshots/panel.png create mode 100644 linux-updater/service.luau create mode 100644 linux-updater/thumbnail.webp create mode 100644 linux-updater/translations/en.json create mode 100644 linux-updater/widget.luau diff --git a/linux-updater/README.md b/linux-updater/README.md new file mode 100644 index 00000000..9416f22d --- /dev/null +++ b/linux-updater/README.md @@ -0,0 +1,207 @@ +# Linux Updater + +Check and install system updates from the bar on any major Linux +distribution. One click runs the whole upgrade in the background: polkit +asks for your password, the panel shows a live log tail and a progress bar, +finished runs land on a history strip — with rollback where the +distribution supports it. The right package-manager backend is picked +automatically from `/etc/os-release`. + +![Pending updates](screenshots/panel.png) +![Update history strip](screenshots/history.png) + +## Features + +- **Background updates.** The run is spawned detached (it survives a shell + restart), fully non-interactive, logged to a file the panel tails live + with a progress bar; the bar widget shows a percentage. On success: + notification and an automatic re-check. A failed run keeps its log on + screen and offers an interactive **Retry in terminal** fallback. +- **Update history with rollback.** Every finished run becomes a segment on + the history strip (hover for date and size, click for the package list). + On Arch, single packages or whole runs roll back from the package cache — + dependencies from the same run travel along, and pacman refuses anything + that would break other packages. On Fedora a whole run is undone with + `dnf history undo`. A second click confirms every rollback. +- **Ignore management.** Every package row has an ignore button; ignored + packages live in an expandable section with restore buttons. The system's + own mechanisms (`IgnorePkg`, `apt-mark hold`) are detected and shown with + a tag explaining where they are managed. The plugin list is honored + during updates (`--ignore`/`--exclude`/hold/lock per backend). +- **One polkit password per run.** pkexec normally re-authenticates every + package-manager call; the panel offers to install a narrow + keep-authorization rule (one confirmed click) so a single password covers + the whole run. +- **Self-fixing setup.** When something the plugin relies on is missing or + off (the polkit rule, apt's list-refresh timers), the panel says so and + offers a one-click, one-confirmation fix. Nothing is ever changed + silently. +- **Extras.** Download-size estimate and Arch news (pacman backend), AUR + via paru/yay, Flatpak on every backend, reboot recommendation with the + best available method per distribution, desktop notifications, launcher + quick actions (`/up`), full log in a terminal pager. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `umedbazarov/linux-updater` | +| Entries | Bar widget: `widget`; panel: `panel`; service: `service`; launcher: `launcher` | +| Launcher Prefix | `/up` | + +## Backends and capabilities + +| | pacman (Arch, Manjaro, …) | dnf (Fedora) | apt (Debian, Ubuntu, Mint, …) | zypper (openSUSE) | xbps (Void) | PackageKit (fallback) | +| --- | --- | --- | --- | --- | --- | --- | +| Check without root | ✓ | ✓ | ✓ (via apt timers) | ✓ (via autorefresh) | ✓ | ✓ | +| Background update | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Old→new versions in the list | ✓ | ✓ | ✓ | ✓ | new only | new only | +| Download size estimate | ✓ | — | — | — | — | — | +| Rollback | per package / per run, from the package cache | whole run, `dnf history undo` | — | — (use snapper) | — | — | +| System ignore shown | `IgnorePkg` | — | `apt-mark hold` | — | — | — | +| Plugin ignore honored on update | `--ignore` | `--exclude` | hold for the run | lock for the run | hold for the run | display only | +| Distribution news | Arch news feed | — | — | — | — | — | +| AUR layer | ✓ (paru/yay) | — | — | — | — | — | +| Reboot detection | kernel modules | `needs-restarting` | `/var/run/reboot-required` | `zypper needs-rebooting` | kernel modules | kernel modules | + +Flatpak checking and updating works on every backend. NixOS is not +supported by design (see +[nix-monitor](https://noctalia.dev/plugins/avivbintangaringga/nix-monitor) +instead); Gentoo has no backend yet — the backend interface in +`backends/` is open for contributions. + +## Requirements + +- The distribution's own package manager, on `PATH`: `pacman` + + `pacman-contrib` (Arch family), `dnf` (Fedora), `apt-get` + (Debian family), `zypper` (openSUSE), `xbps-install` (Void), or `pkcon` + (PackageKit) as the generic fallback. Only the one matching your + distribution is needed; the panel says what is missing. +- `pkexec` (polkit) with an authentication agent — Noctalia's built-in + agent works out of the box. Not needed for the PackageKit backend, which + uses its own polkit policies. +- `sh`, `awk`, `sed`, `tail`, `test`, `uname` — base tools on any install. +- Optional: `paru`/`yay` (AUR, Arch family), `flatpak`, `xdg-open`, + `sudo` + a terminal emulator for the **Retry in terminal** fallback. + +## Usage + +Add the `widget` bar widget from Noctalia's widget picker. Left click opens +the panel, right click checks for updates now. You can also open the panel +directly: + +```sh +noctalia msg panel-toggle umedbazarov/linux-updater:panel +``` + +The panel lists pending packages by source (system manager, AUR, Flatpak). +Each package row has an ignore button, a copy button and an open button. +**Update** starts the background run: pkexec raises the polkit dialog, +everything else is non-interactive; the package list gives way to a live +log tail with a progress bar, the bar widget shows a percentage, and the +run survives a shell restart. When it ends you get a notification and an +automatic re-check; a failed run keeps its log on screen and offers +**Retry in terminal**. + +The strip at the bottom is the update history: one segment per run, hover +for the date, click for the run's package list. Where the backend supports +rollback (see the matrix), packages or whole runs can be rolled back from +there — a second click confirms, and the package manager refuses any +transaction that would break dependencies. + +If the system needs a one-time setup step (polkit keep-authorization rule +so one password covers a run; apt timers for fresh package lists), the +panel says so and offers to fix it with one confirmed click. Nothing is +ever changed silently. + +Type `/up` in the launcher for quick actions or `/up ` to +fuzzy-search pending packages. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `backend` | `select` | `auto` | Package-manager backend; auto-detected from `/etc/os-release`. | +| `aur_helper` | `select` | `auto` | AUR helper (Arch family only): auto/yay/paru/custom/off. | +| `aur_check_cmd` | `string` | *(empty)* | Custom AUR check command when `aur_helper` is `custom`. | +| `flatpak_enabled` | `bool` | `true` | Also check and update Flatpak. | +| `ignore_packages` | `string_list` | *(empty)* | Packages excluded from the count and skipped on update (see matrix for the mechanism per backend). | +| `auto_check_hours` | `int` | `0` | Check automatically every N hours; 0 never. | +| `notify_on_updates` | `bool` | `true` | Desktop notification when updates are found. | +| `show_download_size` | `bool` | `true` | Show the download estimate where the backend supports it. | +| `check_arch_news` | `bool` | `true` | Arch news feed (pacman backend only). | +| `check_reboot_needed` | `bool` | `true` | Flag when a reboot is recommended. | +| `rollback_auto_ignore` | `bool` | `false` | After a rollback, add the rolled-back packages to the plugin ignore list. | +| `hide_setup_hints` | `bool` | `false` | Hide the one-time setup suggestions. | +| `hide_polkit_hint` | `bool` | `false` | Hide the polkit keep-authorization rule suggestion. | +| `log_lines` | `int` | `14` | Log lines shown during a run (6–30). | +| `terminal` | `string` | *(empty)* | Terminal for the fallback; empty uses Noctalia's detection. | +| `update_cmd` | `string` | *(empty)* | Full override for the background update command. | + +## IPC + +```sh +noctalia msg plugin umedbazarov/linux-updater:service all check +noctalia msg plugin umedbazarov/linux-updater:service all update +noctalia msg plugin umedbazarov/linux-updater:service all update_terminal +noctalia msg plugin umedbazarov/linux-updater:service all dismiss +noctalia msg plugin umedbazarov/linux-updater:service all ignore:NAME +noctalia msg plugin umedbazarov/linux-updater:service all unignore:NAME +``` + +## Notes + +- **Commands spawned.** Per backend, listed in `backends/*.luau` (each file + documents its own commands): the distribution's check command + unprivileged; the update through `pkexec ` (or PackageKit's own + polkit path), detached, logged to `/update.log` and followed with + `tail`; `flatpak list/remote-ls/update`; `pactree`/`rpm`/`apt-mark`/ + `zypper locks`/`xbps-pkgdb` where the matrix says so. +- **Privileges.** Escalation only through polkit, only for package-manager + binaries; the optional keep-authorization rules (shipped in `polkit/`, + installable from the panel with one confirmed click) are scoped to those + binaries for active local sessions. `pacman.conf`, apt or zypper + configuration files are never edited. +- **Files written.** Only in the plugin data directory: `update.log`, + `runs.json` (history), `ignore.json`, `news_state.json`, `run_meta.json`, + a staged polkit rule and its install marker — plus + `/etc/polkit-1/rules.d/49-linux-updater-.rules` when you explicitly + click the install button. +- **Network.** Whatever the corresponding manual check/upgrade would + contact, plus the Arch news feed (pacman backend, every 6 h). +## Testing status + +Honest coverage, so expectations are set right: + +- **Arch (pacman backend): fully exercised on a real system** — background + updates including AUR builds, per-package rollback and roll-forward from + the cache, ignore management, the polkit rule install, history, resume + after a shell restart. +- **dnf / apt / zypper / xbps / PackageKit: command layers verified in + containers** on real package managers — including the full + `upgrade → dnf history undo` cycle on Fedora and apt's hold semantics — + and every parser runs against recorded real-output fixtures in CI-able + tests. +- **Not yet verified by anyone:** live polkit dialogs and the full UI on + non-Arch distributions (containers cannot reproduce a polkit session), + the dnf4 output branch (fixtures cover dnf5), Debian-specific deviations + from Ubuntu. Treat non-Arch backends as **beta** — the capability matrix + above is enforced in code, so the worst case is a missing feature, not a + broken system. + +**I would be genuinely glad to see this tested on other package managers +and distributions — Fedora, Ubuntu/Debian/Mint, openSUSE, Void, anything +with PackageKit. Feedback and bug reports on GitHub are very welcome: +please open an issue in +[community-plugins](https://github.com/noctalia-dev/community-plugins/issues) +with `[linux-updater]` in the title, and mention your distribution and the +backend the panel shows.** + +## Credits + +Grown out of [arch-updater](https://github.com/noctalia-dev/community-plugins/tree/main/arch-updater) +(yuuto, MIT), generalized to a backend architecture. + +## License + +MIT. diff --git a/linux-updater/backends/apt.luau b/linux-updater/backends/apt.luau new file mode 100644 index 00000000..43d42ff3 --- /dev/null +++ b/linux-updater/backends/apt.luau @@ -0,0 +1,140 @@ +--!nonstrict +-- apt backend: native Debian-family support (Debian, Ubuntu, Mint, Pop!_OS, +-- Zorin, MX). Two constraints shape it: +-- +-- 1. `apt-get update` needs root, so the unprivileged check reads the index +-- lists the system's own apt-daily timers keep fresh (a self-check nags +-- when those timers are off). The background run refreshes the lists +-- itself, under its own escalation, right before upgrading. +-- 2. apt has no per-run --exclude, so the plugin ignore list is applied by +-- holding the packages for the duration of the transaction +-- (apt-mark hold ... / unhold ...), the same tool a user would reach for. +-- +-- debconf prompts need no DEBIAN_FRONTEND here: with no controlling tty it +-- falls back to its noninteractive frontend on its own, and the detached +-- runner never has one. Rollback is off: /var/cache/apt/archives is +-- routinely empty (Ubuntu cleans it) and old versions leave the repos. + +return function(env) + local MAX_LISTED = env.MAX_LISTED + + local backend = { + id = "apt", + caps = { bgUpdate = true, size = false, rollback = false, news = false, aur = false }, + checkTool = "apt-get", + missingToolKey = "err_no_apt", + nativeLabelKey = "source.system", + escalateProgram = "/usr/bin/apt-get", + -- One "Unpacking name (version) ..." line per package; "Setting up" + -- would double-count the same set. + progressPattern = "^Unpacking ", + news = nil, + polkitRuleName = "49-linux-updater-apt.rules", + systemIgnoreLabelKey = "ignored_tag_hold", + } + + backend.polkitRule = [=[/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per apt call). Authentication is kept for ~5 minutes, + * like sudo's timestamp. Scope: only pkexec launching apt-get or apt-mark, + * only for an active local session of a sudo/wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-apt.rules + */ +polkit.addRule(function(action, subject) { + var prog = action.lookup("program"); + if (action.id == "org.freedesktop.policykit.exec" && + (prog == "/usr/bin/apt-get" || prog == "/usr/bin/apt-mark") && + subject.active && subject.local && + (subject.isInGroup("sudo") || subject.isInGroup("wheel"))) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); +]=] + + -- `apt list --upgradable` needs no root and reports both versions: + -- name/suite newver arch [upgradable from: oldver] + -- Phased updates Ubuntu holds back are absent from this list — that is + -- deliberate on their side, not a parsing gap. Held packages carry + -- "[upgradable from: ...]" too and are filtered by the parser below via + -- the system hold list appended after a separator line. + backend.checkCommand = [[apt list --upgradable 2>/dev/null; echo '::HOLDS'; apt-mark showhold 2>/dev/null]] + + function backend.parseCheck(output, ignored, ignoredOut) + local items = {} + local n = 0 + local holds = {} + local inHolds = false + for line in (output or ""):gmatch("[^\n]+") do + if line == "::HOLDS" then + inHolds = true + elseif inHolds then + holds[line] = true + end + end + for line in (output or ""):gmatch("[^\n]+") do + if line == "::HOLDS" then + break + end + local name, to, from = line:match("^([^/%s]+)/%S+%s+(%S+)%s+%S+%s+%[upgradable from: ([^%]]+)%]") + if name ~= nil then + if holds[name] then + table.insert(ignoredOut, { name = name, from = from, to = to, source = "system" }) + elseif ignored[name] then + table.insert(ignoredOut, { name = name, from = from, to = to, source = "plugin" }) + else + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = from, to = to }) + end + end + end + end + return n, items + end + + local UPGRADE_FLAGS = " -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade" + + -- Two pkexec calls (update, then upgrade) instead of one pkexec'd shell: + -- the polkit rule can then stay scoped to apt-get/apt-mark, and with + -- keep-authorization one password still covers the run. + function backend.buildBackgroundCommand(ignored) + local upgrade = "pkexec apt-get -qq update; pkexec apt-get" .. UPGRADE_FLAGS + if #ignored == 0 then + return upgrade + end + local names = table.concat(ignored, " ") + return "pkexec apt-get -qq update; pkexec apt-mark hold " .. names + .. "; " .. "pkexec apt-get" .. UPGRADE_FLAGS + .. "; s=$?; pkexec apt-mark unhold " .. names .. "; exit $s" + end + + function backend.buildTerminalCommand(ignored) + local upgrade = "sudo apt-get update && sudo apt-get full-upgrade" + if #ignored == 0 then + return upgrade + end + local names = table.concat(ignored, " ") + return "sudo apt-get update && sudo apt-mark hold " .. names + .. " && sudo apt-get full-upgrade; s=$?; sudo apt-mark unhold " .. names .. "; exit $s" + end + + -- `apt-get -s` omits the "Need to get" line in simulate mode, so a + -- cheap size estimate is not available; capability off. + backend.sizeCommand = nil + + backend.rebootCommand = [[test -f /var/run/reboot-required && echo missing || echo present]] + + -- The unprivileged check depends on the apt-daily timers keeping the + -- package lists fresh; offer to enable them when they are off. + backend.selfChecks = { + { + id = "apt_timers", + testCommand = [[systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1 && echo ok || echo fail]], + hintKey = "selfcheck_apt_timers", + actionKey = "selfcheck_fix", + tipKey = "selfcheck_apt_timers_tip", + fixCommand = "pkexec systemctl enable --now apt-daily.timer apt-daily-upgrade.timer", + }, + } + + return backend +end diff --git a/linux-updater/backends/dnf.luau b/linux-updater/backends/dnf.luau new file mode 100644 index 00000000..7a289e73 --- /dev/null +++ b/linux-updater/backends/dnf.luau @@ -0,0 +1,127 @@ +--!nonstrict +-- dnf backend: native Fedora-family support. Chosen over the generic +-- PackageKit backend because dnf brings two things PackageKit cannot: +-- old-version info for the check list (via rpm) and a real transaction +-- rollback (`dnf history undo`), which is safer than any file-cache +-- approach — dnf reverses the exact transaction, dependencies included. +-- +-- caps.rollback = "native": the engine records the dnf transaction id +-- after a successful run (txIdCommand/parseTxId) and undoes whole runs +-- with rollbackNativeCommand; per-package rollback is not offered. + +return function(env) + local MAX_LISTED = env.MAX_LISTED + + local backend = { + id = "dnf", + caps = { bgUpdate = true, size = false, rollback = "native", news = false, aur = false }, + checkTool = "dnf", + missingToolKey = "err_no_dnf", + nativeLabelKey = "source.system", + escalateProgram = "/usr/bin/dnf", + -- dnf5: "[ 3/12] Upgrading name-..." (per package; the paired + -- "Removing" of the old version is deliberately not counted). + -- dnf4: " Upgrading : name-...". The summary header + -- ("Upgrading:" / " Upgrading: 5 packages") matches neither branch. + progressPattern = "^\\[ *[0-9]+/[0-9]+\\] (Upgrading|Installing|Downgrading|Reinstalling) |^ +(Upgrading|Installing|Downgrading|Reinstalling) +: ", + news = nil, + polkitRuleName = "49-linux-updater-dnf.rules", + } + + backend.polkitRule = [=[/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per dnf transaction). Authentication is kept for + * ~5 minutes, like sudo's timestamp. Scope: only pkexec launching dnf, + * only for an active local session of a wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-dnf.rules + */ +polkit.addRule(function(action, subject) { + var prog = action.lookup("program"); + if (action.id == "org.freedesktop.policykit.exec" && + (prog == "/usr/bin/dnf" || prog == "/usr/bin/dnf5" || + prog == "/usr/bin/dnf-3") && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); +]=] + + -- `dnf check-update` exits 100 when updates exist, 0 when none, 1 on a + -- real failure — and prints only the NEW version. The old one comes + -- from rpm, joined in the shell so Lua parses a small "name|old|new" + -- list instead of the whole rpm database. + backend.checkCommand = [[out=$(LC_ALL=C dnf -q check-update 2>/dev/null); code=$? +if [ "$code" -eq 0 ]; then exit 0; fi +if [ "$code" -ne 100 ]; then exit "$code"; fi +printf '%s\n' "$out" | awk 'NF>=3 && $1 ~ /\./ && $2 ~ /^[0-9]/ {print $1, $2}' | while read -r na ver; do + name=${na%.*} + old=$(rpm -q --qf '%{EVR}\n' "$name" 2>/dev/null | head -n 1) + printf '%s|%s|%s\n' "$name" "$old" "$ver" +done +exit 0]] + + function backend.parseCheck(output, ignored, ignoredOut) + local items = {} + local n = 0 + for line in (output or ""):gmatch("[^\n]+") do + local name, from, to = line:match("^([^|]+)|([^|]*)|(.+)$") + if name ~= nil and name ~= "" then + if ignored[name] then + table.insert(ignoredOut, { name = name, from = from, to = to, source = "plugin" }) + else + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = from, to = to }) + end + end + end + end + return n, items + end + + local function excludeFlags(ignored) + local flags = "" + for _, name in ipairs(ignored) do + flags = flags .. " --exclude=" .. name + end + return flags + end + + function backend.buildBackgroundCommand(ignored) + return "pkexec dnf -y --refresh upgrade" .. excludeFlags(ignored) + end + + function backend.buildTerminalCommand(ignored) + return "sudo dnf --refresh upgrade" .. excludeFlags(ignored) + end + + backend.sizeCommand = nil + + -- needs-restarting (dnf-utils) knows about services and libraries, not + -- just the kernel; fall back to the kernel-modules check without it. + backend.rebootCommand = [[if command -v needs-restarting >/dev/null 2>&1; then + needs-restarting -r >/dev/null 2>&1; [ "$?" -eq 1 ] && echo missing || echo present +else + test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing +fi]] + + -- Newest transaction id, queried right after a successful run; the + -- history strip's whole-run rollback undoes exactly that transaction. + backend.txIdCommand = "LC_ALL=C dnf history list 2>/dev/null | head -n 6" + + function backend.parseTxId(output) + for line in (output or ""):gmatch("[^\n]+") do + local id = line:match("^%s*(%d+)%s") + if id ~= nil then + return tonumber(id) + end + end + return nil + end + + function backend.rollbackNativeCommand(txId) + return "pkexec dnf -y history undo " .. tostring(txId) + end + + return backend +end diff --git a/linux-updater/backends/packagekit.luau b/linux-updater/backends/packagekit.luau new file mode 100644 index 00000000..fe44d067 --- /dev/null +++ b/linux-updater/backends/packagekit.luau @@ -0,0 +1,91 @@ +--!nonstrict +-- PackageKit backend: the generic path for distros whose native manager +-- PackageKit abstracts (Fedora/dnf, Debian/Ubuntu/apt, openSUSE/zypper). +-- `pkcon` output is identical across them (verified against fixtures/ from +-- Fedora 41 and Ubuntu 24.04 containers), so one parser covers all three +-- families. Escalation is PackageKit's own polkit policies — no pkexec and +-- no keep-authorization rule of ours; on most desktop distros an active +-- session updates with one prompt or none. +-- +-- Capability notes: no rollback (PackageKit has no transaction undo), no +-- distro news feed, no AUR-like layer, no download-size estimate (pkcon +-- does not expose it cheaply). The check output has no old version either, +-- so items carry from="" — the UI already tolerates that. + +return function(env) + local MAX_LISTED = env.MAX_LISTED + + local backend = { + id = "packagekit", + caps = { bgUpdate = true, size = false, rollback = false, news = false, aur = false }, + checkTool = "pkcon", + missingToolKey = "err_no_pkcon", + nativeLabelKey = "source.system", + -- "Package:" lines appear once per package in the download phase and + -- once in the install phase, so the counter runs at up to 2x; the + -- engine clamps the bar at 100%, which makes this harmless. + progressPattern = "^Package:", + news = nil, + polkitRuleName = nil, + polkitRule = nil, + } + + -- pkcon exits 5 when there is nothing to report; normalize to 0 so only + -- a genuine failure reaches the engine. + backend.checkCommand = [[pkcon -p get-updates 2>/dev/null; code=$?; if [ "$code" -eq 5 ]; then exit 0; fi; exit "$code"]] + + -- Result lines look like: + -- Security curl-8.9.1-4.fc41.x86_64 (updates) + -- Bug fix libcurl4t64-8.5.0-2ubuntu10.11.amd64 (ubuntu-noble-updates-main) + -- The name/version split is at the first "-" boundary, which + -- holds for both rpm (epoch "1:" starts with a digit) and deb versions. + local SEVERITIES = { + ["Security"] = true, ["Bug fix"] = true, ["Enhancement"] = true, + ["Normal"] = true, ["Important"] = true, ["Critical"] = true, + ["Low"] = true, ["Trivial"] = true, ["Blocked"] = true, + } + + function backend.parseCheck(output, ignored, ignoredOut) + local items = {} + local n = 0 + for line in (output or ""):gmatch("[^\n]+") do + local severity, rest = line:match("^(%a[%a ]-)%s%s+(%S.*)$") + if severity ~= nil and SEVERITIES[severity] then + local pkgid = rest:match("^(%S+)") + -- strip the trailing ".arch" (underscore included: x86_64) + local nameVer = pkgid:gsub("%.[%w_]+$", "") + local name, version = nameVer:match("^(.-)%-(%d.*)$") + if name ~= nil and name ~= "" then + if ignored[name] then + table.insert(ignoredOut, { name = name, from = "", to = version, source = "plugin" }) + else + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = "", to = version }) + end + end + end + end + end + return n, items + end + + -- PackageKit cannot exclude packages per transaction, so the plugin + -- ignore list stays a display-side filter here; a "hold" equivalent + -- would need the native manager and belongs to a native backend. + function backend.buildBackgroundCommand(_ignored) + return "pkcon -y --plain refresh && pkcon -y --plain update" + end + + function backend.buildTerminalCommand(_ignored) + return "pkcon refresh && pkcon update" + end + + backend.sizeCommand = nil + + -- Kernel-flavour agnostic and works on every systemd-era distro; native + -- backends can refine (needs-restarting, /var/run/reboot-required). + backend.rebootCommand = [[test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing]] + + return backend +end diff --git a/linux-updater/backends/pacman.luau b/linux-updater/backends/pacman.luau new file mode 100644 index 00000000..e0ae959e --- /dev/null +++ b/linux-updater/backends/pacman.luau @@ -0,0 +1,202 @@ +--!nonstrict +-- pacman backend: everything Arch-specific the engine needs, for Arch and +-- its derivatives (Manjaro, EndeavourOS, ... — anything with ID_LIKE=arch). +-- Constructed with the engine's helper environment so parsers and command +-- builders share its utilities. +-- +-- Backend contract (what the engine relies on): +-- id, caps { bgUpdate, size, rollback, news, aur }, checkTool, +-- missingToolKey, nativeLabelKey, escalateProgram, progressPattern, +-- news { url, page } | nil, polkitRuleName, polkitRule, +-- checkCommand, parseCheck(stdout, ignoredSet, ignoredOut) -> n, items, +-- resolveAurHelper(), aurCheckCommand(helper), +-- buildBackgroundCommand(ignoreList), buildTerminalCommand(ignoreList), +-- sizeCommand(quotedNames) | nil, rebootCommand, +-- findPkgSh, rollbackInstall, depsListCommand(name), reverseDepsCountSh + +return function(env) + local trim = env.trim + local shellQuote = env.shellQuote + local cfg = env.cfg + local commandExists = env.commandExists + local MAX_LISTED = env.MAX_LISTED + + local backend = { + id = "pacman", + caps = { bgUpdate = true, size = true, rollback = "cache", news = true, aur = true }, + checkTool = "checkupdates", + missingToolKey = "err_no_checkupdates", + nativeLabelKey = "source.pacman", + escalateProgram = "/usr/bin/pacman", + -- Lines pacman prints once per package while applying a transaction; + -- the engine greps the run log with this to drive the progress bar. + progressPattern = "^(upgrading|installing|reinstalling|downgrading) ", + news = { + url = "https://archlinux.org/feeds/news/", + page = "https://archlinux.org/news/", + }, + polkitRuleName = "49-linux-updater-pacman.rules", + systemIgnoreLabelKey = "ignored_tag_system", + systemIgnoreTipKey = "ignored_tip_system", + } + + -- Without this rule pkexec re-authenticates every pacman transaction of + -- a run (db sync + each install batch); with it one password covers the + -- whole run. Kept in sync with polkit/49-linux-updater-pacman.rules. + backend.polkitRule = [=[/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per pacman transaction). Authentication is kept for + * ~5 minutes, like sudo's timestamp. Scope: only pkexec launching + * /usr/bin/pacman, only for an active local session of a wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-pacman.rules + */ +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.policykit.exec" && + action.lookup("program") == "/usr/bin/pacman" && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); +]=] + + -- checkupdates exits 2 for "no updates" (not an error), 1 for a real + -- failure. Normalize the former to 0 so only a genuine failure reaches + -- the engine. + backend.checkCommand = [[checkupdates 2>/dev/null; code=$?; if [ "$code" -eq 2 ]; then exit 0; fi; exit "$code"]] + + -- One "name oldver -> newver" line per package: checkupdates' format, + -- which yay -Qua and paru -Qua also use. A trailing "[ignored]" is + -- pacman.conf's IgnorePkg: the run skips those, so they go to + -- ignoredOut instead of the pending count. + function backend.parseCheck(output, ignored, ignoredOut) + local items = {} + local n = 0 + for line in (output or ""):gmatch("[^\n]+") do + local parts = {} + for token in line:gmatch("%S+") do + table.insert(parts, token) + end + if #parts >= 4 then + local name, from, to = parts[1], parts[2], parts[4] + if parts[#parts] == "[ignored]" then + table.insert(ignoredOut, { name = name, from = from, to = to, source = "system" }) + elseif ignored[name] then + table.insert(ignoredOut, { name = name, from = from, to = to, source = "plugin" }) + else + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = from, to = to }) + end + end + end + end + return n, items + end + + -- auto tries yay then paru. An explicit choice is trusted as-is and + -- reported missing instead of falling back to another helper. + function backend.resolveAurHelper() + local choice = cfg("aur_helper") + if choice == "off" then + return nil + end + if choice == "custom" then + return "custom" + end + if choice == "yay" or choice == "paru" then + return choice + end + if commandExists("yay") then + return "yay" + end + if commandExists("paru") then + return "paru" + end + return nil + end + + function backend.aurCheckCommand(helper) + if helper == "custom" then + local raw = trim(cfg("aur_check_cmd")) + return raw ~= "" and raw or nil + end + -- stderr is inspected by the engine to tell a real failure from the + -- "-Qua" family's usual no-updates exit code. + return helper .. " -Qua" + end + + local function ignoreFlag(ignored) + return #ignored > 0 and (" --ignore " .. table.concat(ignored, ",")) or "" + end + + -- The core upgrade command, fully non-interactive. pkexec raises the + -- polkit password dialog; --noconfirm answers every remaining question + -- with its default. AUR helpers run as the user and escalate through + -- pkexec themselves, so no terminal and no sudo tty are ever needed. + function backend.buildBackgroundCommand(ignored) + local quietFlags = " --noconfirm --noprogressbar --color never" + local helper = backend.resolveAurHelper() + if helper ~= nil and helper ~= "custom" and commandExists(helper) then + local autoAnswers = helper == "paru" and " --skipreview" + or " --answerdiff None --answerclean None --answeredit None" + return helper .. " -Syu --sudo pkexec" .. autoAnswers .. quietFlags .. ignoreFlag(ignored) + end + return "pkexec pacman -Syu" .. quietFlags .. ignoreFlag(ignored) + end + + -- Interactive variant for the terminal fallback: no --noconfirm, + -- prompts and the PKGBUILD review work as usual. + function backend.buildTerminalCommand(ignored) + local helper = backend.resolveAurHelper() + if helper ~= nil and helper ~= "custom" and commandExists(helper) then + return helper .. " -Syu" .. ignoreFlag(ignored) + end + return "sudo pacman -Syu" .. ignoreFlag(ignored) + end + + -- Summed download size of the pending repo packages, in MiB on stdout. + function backend.sizeCommand(quotedNames) + return "LC_ALL=C pacman -Si " .. table.concat(quotedNames, " ") .. [[ 2>/dev/null | awk ' +/^Name/ { name=$3 } +/^Download Size/ && !(name in done) { + done[name]=1 + v=$4; u=$5 + gsub(",", ".", v) + if (u == "GiB") v = v * 1024 + else if (u == "KiB") v = v / 1024 + else if (u == "B") v = v / 1024 / 1024 + sum += v +} +END { printf "%.2f", sum }']] + end + + -- A kernel upgrade replaces the whole /usr/lib/modules/ tree; + -- once the running kernel's own directory is gone, a reboot switches to + -- the new one. Kernel-flavour agnostic. + backend.rebootCommand = [[test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing]] + + -- sh helper: prints the cached package file for "name version", trying + -- the pacman cache first, then paru's and yay's build caches. + backend.findPkgSh = [[find_pkg() { +for f in /var/cache/pacman/pkg/"$1"-"$2"-*.pkg.tar.zst /var/cache/pacman/pkg/"$1"-"$2"-*.pkg.tar.xz \ + "$HOME"/.cache/paru/clone/*/"$1"-"$2"-*.pkg.tar.zst "$HOME"/.cache/paru/clone/*/"$1"-"$2"-*.pkg.tar.xz \ + "$HOME"/.cache/yay/*/"$1"-"$2"-*.pkg.tar.zst "$HOME"/.cache/yay/*/"$1"-"$2"-*.pkg.tar.xz; do + if [ -f "$f" ]; then printf '%s\n' "$f"; return 0; fi +done +return 1 +}]] + + backend.rollbackInstall = "pkexec pacman -U --noconfirm --noprogressbar --color never" + + -- Recursive dependency list of an installed package, one name per line + -- (pactree ships with checkupdates in pacman-contrib). + function backend.depsListCommand(name) + return "pactree -l " .. shellQuote(name) .. " 2>/dev/null" + end + + -- Direct reverse dependencies count for "$1"; used by the run probe. + backend.reverseDepsCountSh = [[n=$(pactree -rd1 "$1" 2>/dev/null | wc -l) + [ "$n" -gt 0 ] && n=$((n - 1))]] + + return backend +end diff --git a/linux-updater/backends/xbps.luau b/linux-updater/backends/xbps.luau new file mode 100644 index 00000000..f7ba28e1 --- /dev/null +++ b/linux-updater/backends/xbps.luau @@ -0,0 +1,108 @@ +--!nonstrict +-- xbps backend: native Void Linux support. Void runs runit, not systemd — +-- no systemd units in any command here. The unprivileged check is a +-- dry-run against the synced repodata (`xbps-install -un`); the run syncs +-- itself (-S) under its own escalation. The plugin ignore list is applied +-- with xbps holds for the duration of the transaction (xbps-pkgdb -m +-- hold/unhold). Rollback is off in this first version: installing a +-- specific cached version needs --repository juggling that wants its own +-- testing round. + +return function(env) + local MAX_LISTED = env.MAX_LISTED + + local backend = { + id = "xbps", + caps = { bgUpdate = true, size = false, rollback = false, news = false, aur = false }, + checkTool = "xbps-install", + missingToolKey = "err_no_xbps", + nativeLabelKey = "source.system", + escalateProgram = "/usr/bin/xbps-install", + -- One "name-version: unpacking ..." line per package during the + -- transaction (verified against the Void container fixture). + progressPattern = "^[^ ]+: unpacking ", + news = nil, + polkitRuleName = "49-linux-updater-xbps.rules", + } + + backend.polkitRule = [=[/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per xbps call). Authentication is kept for ~5 minutes. + * Scope: only pkexec launching xbps-install or xbps-pkgdb, only for an + * active local session of a wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-xbps.rules + */ +polkit.addRule(function(action, subject) { + var prog = action.lookup("program"); + if (action.id == "org.freedesktop.policykit.exec" && + (prog == "/usr/bin/xbps-install" || prog == "/usr/bin/xbps-pkgdb") && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); +]=] + + -- Dry-run lines: "name-1.2_3 update x86_64 ". + -- Exit 0 with output when updates exist; exit 17 (or 0 with no lines) + -- when there is nothing to do. + backend.checkCommand = [[xbps-install -un 2>/dev/null; code=$?; if [ "$code" -eq 17 ]; then exit 0; fi; exit "$code"]] + + function backend.parseCheck(output, ignored, ignoredOut) + local items = {} + local n = 0 + for line in (output or ""):gmatch("[^\n]+") do + local pkgver, action = line:match("^(%S+)%s+(%S+)") + if pkgver ~= nil and (action == "update" or action == "install") then + local name, version = pkgver:match("^(.+)%-([^%-]+)$") + if name ~= nil and name ~= "" then + if ignored[name] then + table.insert(ignoredOut, { name = name, from = "", to = version, source = "plugin" }) + else + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = "", to = version }) + end + end + end + end + end + return n, items + end + + function backend.buildBackgroundCommand(ignored) + local upgrade = "pkexec xbps-install -Suy" + if #ignored == 0 then + return upgrade + end + local holdCmds = {} + local unholdCmds = {} + for _, name in ipairs(ignored) do + table.insert(holdCmds, "pkexec xbps-pkgdb -m hold " .. name) + table.insert(unholdCmds, "pkexec xbps-pkgdb -m unhold " .. name) + end + return table.concat(holdCmds, "; ") .. "; " .. upgrade + .. "; s=$?; " .. table.concat(unholdCmds, "; ") .. "; exit $s" + end + + function backend.buildTerminalCommand(ignored) + local upgrade = "sudo xbps-install -Su" + if #ignored == 0 then + return upgrade + end + local holdCmds = {} + local unholdCmds = {} + for _, name in ipairs(ignored) do + table.insert(holdCmds, "sudo xbps-pkgdb -m hold " .. name) + table.insert(unholdCmds, "sudo xbps-pkgdb -m unhold " .. name) + end + return table.concat(holdCmds, "; ") .. "; " .. upgrade + .. "; s=$?; " .. table.concat(unholdCmds, "; ") .. "; exit $s" + end + + backend.sizeCommand = nil + + -- runit, no systemd tooling: the kernel-modules check works everywhere. + backend.rebootCommand = [[test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing]] + + return backend +end diff --git a/linux-updater/backends/zypper.luau b/linux-updater/backends/zypper.luau new file mode 100644 index 00000000..a1453d9f --- /dev/null +++ b/linux-updater/backends/zypper.luau @@ -0,0 +1,115 @@ +--!nonstrict +-- zypper backend: native openSUSE support. Tumbleweed's correct upgrade is +-- `dup` (full distribution upgrade), Leap's is `up` — decided once from +-- os-release. The unprivileged check reads the repo metadata the system's +-- own refresh services keep current (zypper's autorefresh / PackageKit), +-- like the apt backend does; the run refreshes under its own escalation. +-- +-- The plugin ignore list is applied with package locks for the duration of +-- the transaction (zypper addlock/removelock). Rollback is off: the right +-- tool on openSUSE is snapper (btrfs snapshots, installed by default), and +-- zypper's package cache is disabled out of the box (keeppackages=0). + +return function(env) + local MAX_LISTED = env.MAX_LISTED + local trim = env.trim + local osRelease = env.osRelease or "" + + local tumbleweed = osRelease:find("tumbleweed", 1, true) ~= nil + or osRelease:find("Tumbleweed", 1, true) ~= nil + local upgradeVerb = tumbleweed and "dup" or "up" + + local backend = { + id = "zypper", + caps = { bgUpdate = true, size = false, rollback = false, news = false, aur = false }, + checkTool = "zypper", + missingToolKey = "err_no_zypper", + nativeLabelKey = "source.system", + escalateProgram = "/usr/bin/zypper", + -- One "Installing: name-version ..." line per package during the + -- commit phase (both up and dup). + progressPattern = "^Installing: ", + news = nil, + polkitRuleName = "49-linux-updater-zypper.rules", + systemIgnoreLabelKey = "ignored_tag_lock", + } + + backend.polkitRule = [=[/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per zypper call). Authentication is kept for + * ~5 minutes, like sudo's timestamp. Scope: only pkexec launching zypper, + * only for an active local session of a wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-zypper.rules + */ +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.policykit.exec" && + action.lookup("program") == "/usr/bin/zypper" && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); +]=] + + -- Table output, one row per update: + -- v | Repository | Name | Current Version | Available Version | Arch + backend.checkCommand = [[LC_ALL=C zypper --non-interactive -q list-updates 2>/dev/null]] + + function backend.parseCheck(output, ignored, ignoredOut) + local items = {} + local n = 0 + for line in (output or ""):gmatch("[^\n]+") do + if line:match("^v%s*|") ~= nil then + local fields = {} + for field in line:gmatch("|([^|]*)") do + table.insert(fields, trim(field)) + end + local name, from, to = fields[2], fields[3], fields[4] + if name ~= nil and name ~= "" then + if ignored[name] then + table.insert(ignoredOut, { name = name, from = from, to = to, source = "plugin" }) + else + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = from, to = to }) + end + end + end + end + end + return n, items + end + + function backend.buildBackgroundCommand(ignored) + local upgrade = "pkexec zypper --non-interactive " .. upgradeVerb + if #ignored == 0 then + return upgrade + end + local names = table.concat(ignored, " ") + return "pkexec zypper --non-interactive addlock " .. names + .. "; " .. upgrade + .. "; s=$?; pkexec zypper --non-interactive removelock " .. names .. "; exit $s" + end + + function backend.buildTerminalCommand(ignored) + local upgrade = "sudo zypper " .. upgradeVerb + if #ignored == 0 then + return upgrade + end + local names = table.concat(ignored, " ") + return "sudo zypper addlock " .. names .. " && " .. upgrade + .. "; s=$?; sudo zypper removelock " .. names .. "; exit $s" + end + + backend.sizeCommand = nil + + -- zypper needs-rebooting: exit 102 = reboot needed, 0 = not needed; + -- anything else (old zypper) falls back to the kernel-modules check. + backend.rebootCommand = [[zypper needs-rebooting >/dev/null 2>&1 +code=$? +if [ "$code" -eq 102 ]; then echo missing +elif [ "$code" -eq 0 ]; then echo present +else test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing +fi]] + + return backend +end diff --git a/linux-updater/launcher.luau b/linux-updater/launcher.luau new file mode 100644 index 00000000..0df4b5ba --- /dev/null +++ b/linux-updater/launcher.luau @@ -0,0 +1,103 @@ +--!nonstrict +-- linux-updater launcher provider, under the `/up` prefix. +-- +-- Empty query shows the three quick actions. Any other text is fuzzy-matched +-- against the pending packages from the last check, read straight from the +-- shared "linup_state" the engine publishes. Activating a package opens its +-- page, same as the panel's "open" button on a package row. + +local STATE_KEY = "linup_state" +local REQUEST_KEY = "linup_request" + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function request(action) + local prev = noctalia.state.get(REQUEST_KEY) + local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1 + noctalia.state.set(REQUEST_KEY, { nonce = nonce, action = action }) +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function openUrl(url) + if noctalia.commandExists("xdg-open") then + noctalia.runAsync("xdg-open " .. shellQuote(url) .. " >/dev/null 2>&1") + end +end + +local SOURCE_GLYPH = { pacman = "package", aur = "box", flatpak = "app-window" } + +local function packageUrl(sourceKey, name) + if sourceKey == "pacman" then + return "https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name) + elseif sourceKey == "aur" then + return "https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name) + end + return "https://flathub.org/apps/" .. noctalia.string.urlEncode(name) +end + +local function commandResults() + return { + { id = "cmd-check", title = tr("action_check"), subtitle = tr("launcher.check_subtitle"), glyph = "refresh" }, + { id = "cmd-update", title = tr("action_update"), subtitle = tr("launcher.update_subtitle"), glyph = "download" }, + { id = "cmd-news", title = tr("action_open_news"), subtitle = tr("launcher.news_subtitle"), glyph = "news" }, + } +end + +local function packageResults(query) + local state = noctalia.state.get(STATE_KEY) + if type(state) ~= "table" then + return {} + end + + local results = {} + local sourceList = { { key = "pacman", entry = state.pacman }, { key = "aur", entry = state.aur }, { key = "flatpak", entry = state.flatpak } } + for _, source in ipairs(sourceList) do + local items = type(source.entry) == "table" and source.entry.items or nil + if type(items) == "table" then + for _, item in ipairs(items) do + local score = noctalia.fuzzyScore(query, item.name) + if score ~= nil then + local subtitle = (item.from ~= nil and item.from ~= "" and item.to ~= nil and item.to ~= "") + and (item.from .. " → " .. item.to) + or tr("source." .. source.key) + table.insert(results, { + id = "pkg-" .. source.key .. "-" .. item.name, + title = item.name, + subtitle = subtitle, + glyph = SOURCE_GLYPH[source.key] or "package", + score = score, + }) + end + end + end + end + return results +end + +function onQuery(query) + if query == "" then + launcher.setResults(query, commandResults()) + return + end + launcher.setResults(query, packageResults(query)) +end + +function onActivate(id) + if id == "cmd-check" then + request("check") + elseif id == "cmd-update" then + request("update") + elseif id == "cmd-news" then + request("open_news") + else + local sourceKey, name = id:match("^pkg%-([a-z]+)%-(.+)$") + if sourceKey ~= nil and name ~= nil then + openUrl(packageUrl(sourceKey, name)) + end + end +end diff --git a/linux-updater/panel.luau b/linux-updater/panel.luau new file mode 100644 index 00000000..3fd438c3 --- /dev/null +++ b/linux-updater/panel.luau @@ -0,0 +1,937 @@ +--!nonstrict +-- linux-updater update panel. Pure renderer over the shared state: the engine +-- (service.luau) publishes "linup_state" and performs the "linup_request" +-- actions this panel emits, so closing the panel never interrupts a check or +-- a run in progress. +-- +-- One click on "Update" starts the background run: pkexec raises the polkit +-- password dialog, everything else is non-interactive. While it runs, the +-- package list gives way to a live tail of the update log plus a progress +-- bar. A failed run keeps its log on screen and offers a terminal fallback +-- where prompts work normally. + +local STATE_KEY = "linup_state" +local REQUEST_KEY = "linup_request" + +local snapshot = nil +local expanded = {} -- source key -> the package list is open +local hoverKey = nil -- package row currently under the pointer +local hoverText = "" -- what the detail line shows +local listOpen = false -- at least one source is expanded this render +local openedRunAt = nil -- history run whose package list replaces the sources +local armedKey = nil -- rollback button waiting for its confirming second click + +local render + +local function tr(key, args) + return noctalia.tr(key, args) +end + +-- extra: a package name string, or a table merged into the request payload +-- (pkg/version/at for the rollback family). +local function request(action, extra) + local prev = noctalia.state.get(REQUEST_KEY) + local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1 + local payload = { nonce = nonce, action = action } + if type(extra) == "table" then + for key, value in pairs(extra) do + payload[key] = value + end + elseif extra ~= nil then + payload.pkg = extra + end + noctalia.state.set(REQUEST_KEY, payload) +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function openUrl(url) + if not noctalia.commandExists("xdg-open") then + noctalia.notifyError(tr("title"), tr("err_no_xdg_open")) + return + end + noctalia.runAsync("xdg-open " .. shellQuote(url) .. " >/dev/null 2>&1") +end + +-- Opens a generic Arch package search instead of a per-repo mirror URL, so +-- it stays correct across Arch-based distros. +local function openPackage(sourceKey, name) + if sourceKey == "pacman" then + openUrl("https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name)) + elseif sourceKey == "aur" then + openUrl("https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name)) + elseif sourceKey == "flatpak" then + openUrl("https://flathub.org/apps/" .. noctalia.string.urlEncode(name)) + end +end + +local function detailFor(item) + local from = item.from ~= nil and item.from or "" + local to = item.to ~= nil and item.to or "" + if to == "" then + return item.name + end + if from == "" then + return item.name .. " → " .. to + end + return item.name .. " " .. from .. " → " .. to +end + +local function phaseOf() + return snapshot ~= nil and snapshot.phase or "idle" +end + +local function totalOf() + return snapshot ~= nil and tonumber(snapshot.total) or 0 +end + +local function busy() + local phase = phaseOf() + return phase == "checking" or phase == "running" +end + +local function checking() + return phaseOf() == "checking" +end + +local function runFailed() + return phaseOf() == "error" and snapshot ~= nil and tonumber(snapshot.runExit) ~= nil and tonumber(snapshot.runExit) ~= 0 +end + +local function logLines() + if snapshot == nil or type(snapshot.logTail) ~= "table" then + return {} + end + return snapshot.logTail +end + +local function headline() + local phase = phaseOf() + if phase == "missing" then + -- The missing-tool message comes from the backend (checkupdates, + -- pkcon, ...), so prefer it over the static fallback. + return (snapshot ~= nil and snapshot.err) or tr("status_missing"), "error" + elseif phase == "error" then + return (snapshot ~= nil and snapshot.err) or tr("status_error"), "error" + elseif phase == "checking" then + local step = snapshot.step + if step ~= nil and step ~= "" then + return tr("status_checking_step", { step = step }), "secondary" + end + return tr("status_checking"), "secondary" + elseif phase == "running" then + if snapshot ~= nil and snapshot.runKind == "rollback" then + return tr("status_rolling_back"), "secondary" + end + return tr("status_running"), "secondary" + elseif phase == "clean" then + return tr("status_clean"), "on_surface" + elseif phase == "ready" then + return noctalia.trp("status_ready", totalOf(), {}), "primary" + end + return tr("status_idle"), "on_surface_variant" +end + +local VERSION_WIDTH = 78 + +local function sourceLabel(key, entry) + if key == "aur" and type(entry.helper) == "string" and entry.helper ~= "" then + return tr("source.aur_named", { helper = entry.helper }) + end + -- The native slot is still keyed "pacman" internally; its display name + -- comes from the backend (Pacman, System, ...). + if key == "pacman" and snapshot ~= nil and type(snapshot.nativeLabel) == "string" then + return tr(snapshot.nativeLabel) + end + return tr("source." .. key) +end + +local SOURCE_GLYPHS = { pacman = "package", aur = "cloud", flatpak = "app-window" } + +local function sourceGlyph(key) + return SOURCE_GLYPHS[key] or "package" +end + +-- pacman, then the AUR helper, then Flatpak. Only sources with pending +-- packages, biggest first. +local function orderedSources() + if snapshot == nil then + return {} + end + local candidates = { + { key = "pacman", entry = snapshot.pacman }, + { key = "aur", entry = snapshot.aur }, + { key = "flatpak", entry = snapshot.flatpak }, + } + local pending = {} + for _, candidate in ipairs(candidates) do + if type(candidate.entry) == "table" and (candidate.entry.n or 0) > 0 then + table.insert(pending, candidate) + end + end + table.sort(pending, function(a, b) + return a.entry.n > b.entry.n + end) + return pending +end + +-- Sources that are installed, enabled and have nothing pending. Flatpak is +-- skipped entirely when it is not on the system, so the panel doesn't brag +-- about a tool that isn't there. +local function cleanSources() + if snapshot == nil then + return {} + end + local names = {} + local candidates = { { key = "pacman", entry = snapshot.pacman } } + if snapshot.flatpakEnabled == true then + table.insert(candidates, { key = "flatpak", entry = snapshot.flatpak }) + end + if snapshot.aur ~= nil and (snapshot.aur.n or 0) == 0 and noctalia.getConfig("aur_helper") ~= "off" then + table.insert(candidates, { key = "aur", entry = snapshot.aur }) + end + for _, candidate in ipairs(candidates) do + if type(candidate.entry) == "table" and (candidate.entry.n or 0) == 0 then + table.insert(names, sourceLabel(candidate.key, candidate.entry)) + end + end + return names +end + +local function packageRow(sourceKey, index, item) + local key = "pkg-" .. sourceKey .. "-" .. index + local children = { + ui.label({ text = item.name, fontSize = 11, color = "on_surface", flexGrow = 1, maxLines = 1 }), + } + local from = item.from ~= nil and item.from or "" + local to = item.to ~= nil and item.to or "" + if to ~= "" then + if from ~= "" then + table.insert(children, ui.label({ + text = from, fontSize = 11, color = "on_surface_variant", maxWidth = VERSION_WIDTH, maxLines = 1, + })) + end + table.insert(children, ui.label({ text = "→", fontSize = 11, color = "on_surface_variant" })) + table.insert(children, ui.label({ + text = to, fontSize = 11, color = "primary", fontWeight = "semibold", maxWidth = VERSION_WIDTH, maxLines = 1, + })) + end + table.insert(children, ui.button({ + glyph = "eye-off", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, + tooltip = tr("tip_ignore"), + onClick = function() + request("ignore", item.name) + end, + })) + table.insert(children, ui.button({ + glyph = "copy", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, + tooltip = tr("tip_copy"), + onClick = function() + noctalia.copyToClipboard(detailFor(item), "text/plain") + end, + })) + table.insert(children, ui.button({ + glyph = "external-link", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, + tooltip = tr("tip_open_page"), + onClick = function() + openPackage(sourceKey, item.name) + end, + })) + return ui.row({ + key = key, + paddingH = 18, + gap = 4, + align = "center", + onHover = function(state) + if state == "true" then + hoverKey = key + hoverText = detailFor(item) + elseif hoverKey == key then + hoverKey = nil + hoverText = "" + else + return + end + render() + end, + }, children) +end + +local function sourceRows() + local rows = {} + for _, source in ipairs(orderedSources()) do + local names = type(source.entry.items) == "table" and source.entry.items or {} + local open = expanded[source.key] == true and #names > 0 + listOpen = listOpen or open + local header = { gap = 8, align = "center", key = "src-" .. source.key .. (open and "-open" or "") } + if #names > 0 then + local sourceKey = source.key + header.onClick = function() + expanded[sourceKey] = not expanded[sourceKey] + hoverKey = nil + hoverText = "" + render() + end + end + table.insert(rows, ui.row(header, { + ui.glyph({ name = #names == 0 and "point" or (open and "chevron-down" or "chevron-right"), size = 12, color = "on_surface_variant" }), + ui.glyph({ name = sourceGlyph(source.key), size = 13, color = "on_surface_variant" }), + ui.label({ text = sourceLabel(source.key, source.entry), color = "on_surface", flexGrow = 1 }), + ui.label({ text = tostring(source.entry.n), color = "primary", fontWeight = "bold" }), + })) + + if open then + for index, item in ipairs(names) do + table.insert(rows, packageRow(source.key, index, item)) + end + if source.entry.n > #names then + table.insert(rows, ui.row({ key = "more-" .. source.key, paddingH = 18 }, { + ui.label({ text = tr("more_packages", { count = source.entry.n - #names }), fontSize = 11, color = "on_surface_variant" }), + })) + end + end + end + return rows +end + +-- ── Ignored packages ───────────────────────────────────────────────────────── + +-- One display list: packages the last check saw as ignored (with versions), +-- then panel-managed and settings-based ignores with nothing pending. +local function ignoredEntries() + if snapshot == nil then + return {} + end + local entries = {} + local seen = {} + local pending = type(snapshot.ignoredPending) == "table" and snapshot.ignoredPending or {} + for _, item in ipairs(pending) do + if type(item) == "table" and item.name ~= nil and not seen[item.name] then + seen[item.name] = true + table.insert(entries, item) + end + end + local dynamic = type(snapshot.ignoredDynamic) == "table" and snapshot.ignoredDynamic or {} + for _, name in ipairs(dynamic) do + if not seen[name] then + seen[name] = true + table.insert(entries, { name = name, source = "plugin" }) + end + end + local config = type(snapshot.ignoredConfig) == "table" and snapshot.ignoredConfig or {} + for _, name in ipairs(config) do + if not seen[name] then + seen[name] = true + table.insert(entries, { name = name, source = "settings" }) + end + end + return entries +end + +-- Only panel-managed ignores can be lifted from here. +local function isRemovable(name) + if snapshot == nil or type(snapshot.ignoredDynamic) ~= "table" then + return false + end + for _, entry in ipairs(snapshot.ignoredDynamic) do + if entry == name then + return true + end + end + return false +end + +local function ignoredRow(index, item) + local children = { + ui.label({ text = item.name, fontSize = 11, color = "on_surface_variant", flexGrow = 1, maxLines = 1 }), + } + if item.to ~= nil and item.to ~= "" then + table.insert(children, ui.label({ + text = item.to, fontSize = 11, color = "on_surface_variant", maxWidth = VERSION_WIDTH, maxLines = 1, + })) + end + if item.source ~= "system" and isRemovable(item.name) then + table.insert(children, ui.button({ + glyph = "eye", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, + tooltip = tr("tip_unignore"), + onClick = function() + request("unignore", item.name) + end, + })) + elseif item.source == "system" then + -- The system-side ignore mechanism belongs to root (IgnorePkg, + -- apt-mark hold, zypper locks); the tag only explains where it is. + table.insert(children, ui.button({ + text = tr((snapshot ~= nil and snapshot.systemIgnoreLabel) or "ignored_tag_system"), + variant = "ghost", + controlSize = "sm", + tooltip = tr((snapshot ~= nil and snapshot.systemIgnoreTip) or "ignored_tip_system"), + onClick = function() end, + })) + else + table.insert(children, ui.button({ + text = tr("ignored_tag_settings"), variant = "ghost", controlSize = "sm", + tooltip = tr("ignored_tip_settings"), + onClick = function() + noctalia.runAsync("noctalia msg settings-open-plugin umedbazarov/linux-updater >/dev/null 2>&1") + end, + })) + end + return ui.row({ key = "ign-" .. index, paddingH = 18, gap = 6, align = "center" }, children) +end + +local function ignoredRows() + local entries = ignoredEntries() + if #entries == 0 then + return {} + end + local open = expanded["ignored"] == true + local rows = {} + table.insert(rows, ui.row({ + key = "src-ignored" .. (open and "-open" or ""), + gap = 8, + align = "center", + onClick = function() + expanded["ignored"] = not expanded["ignored"] + render() + end, + }, { + ui.glyph({ name = open and "chevron-down" or "chevron-right", size = 12, color = "on_surface_variant" }), + ui.glyph({ name = "eye-off", size = 13, color = "on_surface_variant" }), + ui.label({ text = tr("ignored_title"), color = "on_surface_variant", flexGrow = 1 }), + ui.label({ text = tostring(#entries), color = "on_surface_variant", fontWeight = "bold" }), + })) + if open then + for index, item in ipairs(entries) do + table.insert(rows, ignoredRow(index, item)) + end + end + return rows +end + +-- ── Run history and rollback ───────────────────────────────────────────────── + +local function historyRuns() + if snapshot == nil or type(snapshot.history) ~= "table" then + return {} + end + return snapshot.history -- newest first +end + +local function findHistoryRun(at) + for _, run in ipairs(historyRuns()) do + if tonumber(run.at) == tonumber(at) then + return run + end + end + return nil +end + +local function describeRun(run) + local text = os.date("%d.%m %H:%M", tonumber(run.at) or 0) + .. " · " .. noctalia.trp("run_packages", run.n or 0, { count = run.n or 0 }) + if run.rollback == true then + text = text .. " · " .. tr("history_rollback_tag") + end + return text +end + +-- The footer strip: one equal-width segment per recorded run, oldest on the +-- left. Hover describes the run, click opens its package list. +local function historySection() + local runs = historyRuns() + if #runs == 0 then + return nil + end + local segments = {} + for i = #runs, 1, -1 do + local run = runs[i] + table.insert(segments, ui.button({ + key = "run-" .. tostring(run.at), + -- primary: painted in the theme's accent color, so the strip is + -- visible at rest (ghost would only show on hover) + variant = "primary", + flexGrow = 1, + height = 10, + tooltip = describeRun(run) .. "\n" .. tr("tip_history_segment"), + onClick = function() + openedRunAt = run.at + armedKey = nil + request("probe_run", { at = run.at }) + render() + end, + })) + end + local header = { + ui.label({ text = tr("history_title"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant", flexGrow = 1 }), + } + if snapshot ~= nil and snapshot.checkedAt ~= nil and snapshot.checkedAt ~= "" then + table.insert(header, ui.label({ + text = tr("caption_checked", { time = snapshot.checkedAt }), + fontSize = 10, + color = "on_surface_variant", + })) + end + return ui.column({ key = "history", gap = 3 }, { + ui.row({ key = "history-head", gap = 6, align = "center" }, header), + ui.row({ key = "history-segments", gap = 2 }, segments), + }) +end + +local function rollbackKind() + if snapshot ~= nil and type(snapshot.caps) == "table" then + return snapshot.caps.rollback + end + return false +end + +-- Per-package rollback exists only for the cache kind; native backends +-- (dnf history undo) reverse whole transactions. +local function rollbackAvailable() + return rollbackKind() == "cache" +end + +local function probeFor(run) + if snapshot ~= nil and type(snapshot.probe) == "table" and tonumber(snapshot.probe.at) == tonumber(run.at) then + return snapshot.probe.pkgs + end + return nil +end + +-- Two-click rollback: the first click arms the button (it turns into a +-- confirm label), the second sends the request. Anything else re-renders +-- the armed state away. +local function rollbackButton(key, tooltip, onConfirm) + if armedKey == key then + return ui.button({ + key = key .. "-armed", + text = tr("rollback_confirm"), variant = "primary", controlSize = "sm", + tooltip = tooltip, + onClick = function() + armedKey = nil + onConfirm() + end, + }) + end + return ui.button({ + key = key, + glyph = "undo", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, + tooltip = tooltip, + onClick = function() + armedKey = key + render() + end, + }) +end + +local function rollbackRow(run, index, item, probePkgs) + local key = "rb-" .. tostring(run.at) .. "-" .. index + local info = probePkgs ~= nil and probePkgs[item.name] or nil + local children = {} + + if item.source == "flatpak" or not rollbackAvailable() then + table.insert(children, ui.glyph({ + name = item.source == "flatpak" and "app-window" or "point", + size = 12, + color = "on_surface_variant", + })) + elseif info ~= nil and info.cache ~= true then + table.insert(children, ui.button({ + key = key .. "-miss", + glyph = "undo", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, + enabled = false, + tooltip = tr("rollback_missing"), + onClick = function() end, + })) + else + local tooltip = tr("tip_rollback", { version = tostring(item.from or "") }) + if info ~= nil and (info.req or 0) > 0 then + tooltip = tooltip .. "\n" .. noctalia.trp("rollback_required_by", info.req, { count = info.req }) + end + table.insert(children, rollbackButton(key, tooltip, function() + request("rollback", { pkg = item.name, version = item.from, at = run.at }) + end)) + end + + table.insert(children, ui.label({ text = item.name, fontSize = 11, color = "on_surface", flexGrow = 1, maxLines = 1 })) + if item.from ~= nil and item.from ~= "" then + table.insert(children, ui.label({ + text = item.from, fontSize = 11, color = "on_surface_variant", maxWidth = VERSION_WIDTH, maxLines = 1, + })) + end + table.insert(children, ui.label({ text = "→", fontSize = 11, color = "on_surface_variant" })) + table.insert(children, ui.label({ + text = tostring(item.to or ""), fontSize = 11, color = "primary", maxWidth = VERSION_WIDTH, maxLines = 1, + })) + return ui.row({ key = key .. "-row", gap = 6, align = "center" }, children) +end + +-- The opened run: header with a back button and a whole-run rollback, then +-- one row per package showing the run's from -> to and a rollback control. +local function runViewRows() + local run = findHistoryRun(openedRunAt) + if run == nil then + openedRunAt = nil + return nil + end + local probePkgs = probeFor(run) + local rows = {} + local header = { + ui.button({ + glyph = "chevron-left", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, + tooltip = tr("action_back"), + onClick = function() + openedRunAt = nil + armedKey = nil + render() + end, + }), + ui.label({ text = describeRun(run), fontSize = 12, fontWeight = "bold", color = "on_surface", flexGrow = 1, maxLines = 1 }), + } + local rollable = 0 + for _, item in ipairs(run.packages or {}) do + if item.source ~= "flatpak" then + rollable += 1 + end + end + local runRollbackPossible = rollbackAvailable() + or (rollbackKind() == "native" and run.txId ~= nil) + if rollable > 0 and run.rollback ~= true and runRollbackPossible then + table.insert(header, rollbackButton("rb-all-" .. tostring(run.at), tr("tip_rollback_run"), function() + request("rollback_run", { at = run.at }) + end)) + end + table.insert(rows, ui.row({ key = "run-head", gap = 6, align = "center" }, header)) + for index, item in ipairs(run.packages or {}) do + table.insert(rows, rollbackRow(run, index, item, probePkgs)) + end + return rows +end + +-- Live tail of the update log, with a progress bar while packages are being +-- processed. Shown during a run and kept on screen after a failed one. +local function logSection() + local children = {} + + local header = { + ui.label({ text = tr("log_title"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant", flexGrow = 1 }), + } + local done = snapshot ~= nil and type(snapshot.progress) == "table" and tonumber(snapshot.progress.done) or 0 + local expect = snapshot ~= nil and type(snapshot.progress) == "table" and tonumber(snapshot.progress.total) or 0 + if phaseOf() == "running" and expect > 0 then + table.insert(header, ui.label({ + text = tostring(math.min(done, expect)) .. " / " .. tostring(expect), + fontSize = 11, + color = "on_surface_variant", + })) + end + table.insert(children, ui.row({ key = "log-head", gap = 6, align = "center" }, header)) + + if phaseOf() == "running" and expect > 0 then + table.insert(children, ui.progress({ key = "log-progress", progress = math.min(done / expect, 1) })) + end + + local lines = logLines() + local rows = {} + if #lines == 0 then + table.insert(rows, ui.label({ + key = "log-empty", text = tr("log_waiting"), fontSize = 10, color = "on_surface_variant", + })) + else + for index, line in ipairs(lines) do + table.insert(rows, ui.label({ + key = "log-" .. index, + text = line, + fontSize = 10, + color = "on_surface_variant", + maxLines = 1, + })) + end + end + table.insert(children, ui.column({ key = "log-lines", gap = 2, flexGrow = 1 }, rows)) + + if snapshot ~= nil and type(snapshot.logPath) == "string" and snapshot.logPath ~= "" then + table.insert(children, ui.row({ key = "log-actions", gap = 6 }, { + ui.button({ + text = tr("action_open_log"), variant = "ghost", controlSize = "sm", + -- Opened by the service in a terminal pager: xdg-open on a + -- text file dies silently when the default handler is a + -- terminal editor. + onClick = function() + request("open_log") + end, + }), + })) + end + + return ui.column({ key = "log", gap = 6, flexGrow = 1 }, children) +end + +-- Download size, reboot recommendation and Arch news. Each is its own line, +-- so turning one off in settings just removes that line. +local function extras() + local lines = {} + if snapshot == nil then + return lines + end + + if type(snapshot.downloadSizeMiB) == "number" then + local size = snapshot.downloadSizeMiB + local text = size >= 1024 and tr("size_gib", { value = string.format("%.2f", size / 1024) }) + or tr("size_mib", { value = string.format("%.1f", size) }) + table.insert(lines, ui.row({ key = "size", gap = 6, align = "center" }, { + ui.glyph({ name = "download", size = 13, color = "on_surface_variant" }), + ui.label({ text = text, fontSize = 12, color = "on_surface_variant" }), + })) + end + + if snapshot.rebootRecommended == true then + table.insert(lines, ui.row({ key = "reboot", gap = 6, align = "center" }, { + ui.glyph({ name = "alert-triangle", size = 13, color = "warning" }), + ui.label({ text = tr("reboot_recommended"), fontSize = 12, color = "warning", flexGrow = 1 }), + })) + end + + -- Offered until the polkit keep-authorization rule is installed. + if snapshot.polkitRule == false and phaseOf() ~= "running" and noctalia.getConfig("hide_polkit_hint") ~= true then + table.insert(lines, ui.row({ key = "polkit", gap = 6, align = "center" }, { + ui.glyph({ name = "shield", size = 13, color = "on_surface_variant" }), + ui.label({ + text = tr("polkit_hint"), + fontSize = 12, + color = "on_surface_variant", + flexGrow = 1, + maxLines = 2, + }), + ui.button({ + text = tr("action_polkit_install"), variant = "ghost", controlSize = "sm", + tooltip = tr("tip_polkit_install"), + onClick = function() + request("polkit_install") + end, + }), + })) + end + + -- Backend self-checks: the plugin says what the system is missing and + -- offers to fix it with one confirmed click. + if type(snapshot.selfChecks) == "table" and phaseOf() ~= "running" + and noctalia.getConfig("hide_setup_hints") ~= true then + for index, check in ipairs(snapshot.selfChecks) do + table.insert(lines, ui.row({ key = "selfcheck-" .. tostring(check.id or index), gap = 6, align = "center" }, { + ui.glyph({ name = "alert-triangle", size = 13, color = "warning" }), + ui.label({ + text = tr(check.hintKey or "selfcheck_generic"), + fontSize = 12, + color = "on_surface_variant", + flexGrow = 1, + maxLines = 2, + }), + ui.button({ + text = tr(check.actionKey or "selfcheck_fix"), variant = "ghost", controlSize = "sm", + tooltip = check.tipKey ~= nil and tr(check.tipKey) or nil, + onClick = function() + request("self_fix", { id = check.id }) + end, + }), + })) + end + end + + if (snapshot.newsUnread or 0) > 0 then + table.insert(lines, ui.row({ key = "news", gap = 6, align = "center" }, { + ui.glyph({ name = "news", size = 13, color = "on_surface" }), + ui.label({ + text = noctalia.trp("news_unread", snapshot.newsUnread, { title = snapshot.newsLatestTitle or "" }), + fontSize = 12, + color = "on_surface", + flexGrow = 1, + maxLines = 2, + }), + ui.button({ + text = tr("action_open_news"), variant = "ghost", controlSize = "sm", + onClick = function() + request("open_news") + end, + }), + })) + end + + return lines +end + +local function body() + local children = {} + + -- The middle of the panel: the live log while updating (and after a + -- failure), an opened history run, or the package list. + if phaseOf() == "running" or (runFailed() and #logLines() > 0) then + table.insert(children, logSection()) + else + local runRows = openedRunAt ~= nil and runViewRows() or nil + if runRows ~= nil then + table.insert(children, ui.scroll({ key = "run-view", flexGrow = 1, gap = 4 }, runRows)) + return children + end + local rows = sourceRows() + for _, row in ipairs(ignoredRows()) do + table.insert(rows, row) + end + if #rows > 0 then + table.insert(children, ui.scroll({ key = "sources", flexGrow = 1, gap = 3 }, rows)) + else + table.insert(children, ui.spacer({ key = "filler", flexGrow = 1 })) + end + + if listOpen then + table.insert(children, ui.label({ + key = "hover-detail", + text = hoverText ~= "" and hoverText or tr("hover_hint"), + fontSize = 11, + color = hoverText ~= "" and "on_surface" or "on_surface_variant", + maxLines = 1, + })) + end + end + + for _, line in ipairs(extras()) do + table.insert(children, line) + end + + if not busy() then + local clean = cleanSources() + if #clean > 0 then + table.insert(children, ui.label({ + text = tr("up_to_date", { sources = table.concat(clean, ", ") }), + fontSize = 11, + color = "on_surface_variant", + maxLines = 2, + })) + end + end + + return children +end + +local function footerCaption() + if snapshot == nil or snapshot.checkedAt == nil or snapshot.checkedAt == "" then + return nil + end + local parts = { tr("caption_checked", { time = snapshot.checkedAt }) } + local ignored = #ignoredEntries() + if ignored > 0 then + table.insert(parts, noctalia.trp("caption_ignored", ignored, {})) + end + return table.concat(parts, " · ") +end + +render = function() + listOpen = false + + local text, color = headline() + local phase = phaseOf() + local hasUpdates = totalOf() > 0 and phase == "ready" + + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.button({ + key = "header-check" .. (busy() and "-off" or ""), + glyph = "refresh", + variant = "ghost", + enabled = not busy() and phase ~= "missing", + tooltip = tr("tip_check"), + onClick = function() + request("check") + end, + }), + ui.button({ + glyph = "close", variant = "ghost", tooltip = tr("tip_close"), + onClick = function() + panel.close() + end, + }), + }), + ui.label({ text = text, color = color, maxLines = 2 }), + } + + for _, node in ipairs(body()) do + table.insert(children, node) + end + + local history = not busy() and historySection() or nil + if history ~= nil then + table.insert(children, ui.separator({})) + table.insert(children, history) + else + local caption = footerCaption() + if caption ~= nil then + table.insert(children, ui.separator({})) + table.insert(children, ui.label({ text = caption, fontSize = 11, color = "on_surface_variant", maxLines = 2 })) + end + end + + if phase ~= "missing" then + local footer = {} + if runFailed() then + table.insert(footer, ui.button({ + key = "retry-terminal", + glyph = "terminal", text = tr("action_run_terminal"), variant = "ghost", flexGrow = 1, + tooltip = tr("tip_run_terminal"), + onClick = function() + request("update_terminal") + end, + })) + end + table.insert(footer, ui.button({ + key = "dismiss" .. (hasUpdates and "" or "-off"), + text = tr("action_dismiss"), variant = "ghost", enabled = hasUpdates, + onClick = function() + request("dismiss") + panel.close() + end, + })) + table.insert(footer, ui.button({ + key = "update" .. (hasUpdates and "" or "-off"), + glyph = "download", text = tr("action_update"), variant = "primary", enabled = hasUpdates, + tooltip = tr("tip_update"), + onClick = function() + -- The panel stays open: the log section takes over so the + -- run can be watched live. + request("update") + end, + })) + table.insert(children, ui.row({ gap = 8, align = "center", justify = "end" }, footer)) + end + + panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, children)) +end + +function onOpen(_context) + snapshot = noctalia.state.get(STATE_KEY) + expanded = {} + hoverKey = nil + hoverText = "" + openedRunAt = nil + armedKey = nil + render() +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) ~= "table" then + return + end + if value.phase == "checking" and (snapshot == nil or snapshot.phase ~= "checking") then + expanded = {} + hoverKey = nil + hoverText = "" + end + if value.phase == "running" and (snapshot == nil or snapshot.phase ~= "running") then + openedRunAt = nil + armedKey = nil + end + snapshot = value + render() +end) diff --git a/linux-updater/plugin.toml b/linux-updater/plugin.toml new file mode 100644 index 00000000..23bdb70c --- /dev/null +++ b/linux-updater/plugin.toml @@ -0,0 +1,196 @@ +id = "umedbazarov/linux-updater" +name = "Linux Updater" +version = "0.6.0" +plugin_api = 9 +author = "umedbazarov" +license = "MIT" +icon = "package" +description = "Check and install system updates in the background on any major distro: live log, update history, rollback." +dependencies = ["sh", "awk", "sed", "tail", "test", "uname", "pkexec", "pacman-contrib", "pacman", "dnf", "apt-get", "zypper", "xbps-install", "pkcon", "paru", "yay", "flatpak", "sudo", "xdg-open"] +tags = ["arch", "debian", "fedora", "opensuse", "void", "bar", "panel", "launcher", "system", "utility"] + +# ── General ────────────────────────────────────────────────────────────────── + +[[setting]] +key = "backend" +type = "select" +label_key = "settings.backend.label" +description_key = "settings.backend.description" +default = "auto" +options = [ + { value = "auto", label_key = "settings.backend.options.auto" }, + { value = "pacman", label_key = "settings.backend.options.pacman" }, + { value = "dnf", label_key = "settings.backend.options.dnf" }, + { value = "apt", label_key = "settings.backend.options.apt" }, + { value = "zypper", label_key = "settings.backend.options.zypper" }, + { value = "xbps", label_key = "settings.backend.options.xbps" }, + { value = "packagekit", label_key = "settings.backend.options.packagekit" }, +] + +[[setting]] +key = "aur_helper" +type = "select" +label_key = "settings.aur_helper.label" +description_key = "settings.aur_helper.description" +default = "auto" +options = [ + { value = "auto", label_key = "settings.aur_helper.options.auto" }, + { value = "yay", label_key = "settings.aur_helper.options.yay" }, + { value = "paru", label_key = "settings.aur_helper.options.paru" }, + { value = "custom", label_key = "settings.aur_helper.options.custom" }, + { value = "off", label_key = "settings.aur_helper.options.off" }, +] + +[[setting]] +key = "aur_check_cmd" +type = "string" +label_key = "settings.aur_check_cmd.label" +description_key = "settings.aur_check_cmd.description" +default = "" +visible_when = { key = "aur_helper", values = ["custom"] } + +[[setting]] +key = "flatpak_enabled" +type = "bool" +label_key = "settings.flatpak_enabled.label" +description_key = "settings.flatpak_enabled.description" +default = true + +[[setting]] +key = "ignore_packages" +type = "string_list" +label_key = "settings.ignore_packages.label" +description_key = "settings.ignore_packages.description" +default = [] + +[[setting]] +key = "auto_check_hours" +type = "int" +label_key = "settings.auto_check_hours.label" +description_key = "settings.auto_check_hours.description" +default = 0 +min = 0 +max = 168 + +[[setting]] +key = "notify_on_updates" +type = "bool" +label_key = "settings.notify_on_updates.label" +description_key = "settings.notify_on_updates.description" +default = true + +# ── Extras ─────────────────────────────────────────────────────────────────── + +[[setting]] +key = "show_download_size" +type = "bool" +label_key = "settings.show_download_size.label" +description_key = "settings.show_download_size.description" +default = true + +[[setting]] +key = "check_arch_news" +type = "bool" +label_key = "settings.check_arch_news.label" +description_key = "settings.check_arch_news.description" +default = true + +[[setting]] +key = "check_reboot_needed" +type = "bool" +label_key = "settings.check_reboot_needed.label" +description_key = "settings.check_reboot_needed.description" +default = true + +# ── Update run ─────────────────────────────────────────────────────────────── + +[[setting]] +key = "rollback_auto_ignore" +type = "bool" +label_key = "settings.rollback_auto_ignore.label" +description_key = "settings.rollback_auto_ignore.description" +default = false + +[[setting]] +key = "hide_setup_hints" +type = "bool" +label_key = "settings.hide_setup_hints.label" +description_key = "settings.hide_setup_hints.description" +default = false + +[[setting]] +key = "hide_polkit_hint" +type = "bool" +label_key = "settings.hide_polkit_hint.label" +description_key = "settings.hide_polkit_hint.description" +default = false + +[[setting]] +key = "log_lines" +type = "int" +label_key = "settings.log_lines.label" +description_key = "settings.log_lines.description" +default = 14 +min = 6 +max = 30 + +[[setting]] +key = "terminal" +type = "string" +label_key = "settings.terminal.label" +description_key = "settings.terminal.description" +default = "" +advanced = true + +[[setting]] +key = "update_cmd" +type = "string" +label_key = "settings.update_cmd.label" +description_key = "settings.update_cmd.description" +default = "" +advanced = true + +[[service]] +id = "service" +entry = "service.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 420 +height = 540 +placement = "attached" +position = "auto" +open_near_click = true + +[[widget]] +id = "widget" +entry = "widget.luau" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "package" + + [[widget.setting]] + key = "show_count" + type = "bool" + label_key = "settings.show_count.label" + description_key = "settings.show_count.description" + default = true + + [[widget.setting]] + key = "hide_on_empty" + type = "bool" + label_key = "settings.hide_on_empty.label" + description_key = "settings.hide_on_empty.description" + default = false + +[[launcher_provider]] +id = "launcher" +entry = "launcher.luau" +prefix = "up" +glyph = "package" +include_in_global_search = false diff --git a/linux-updater/polkit/49-linux-updater-apt.rules b/linux-updater/polkit/49-linux-updater-apt.rules new file mode 100644 index 00000000..71626877 --- /dev/null +++ b/linux-updater/polkit/49-linux-updater-apt.rules @@ -0,0 +1,15 @@ +/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per apt call). Authentication is kept for ~5 minutes, + * like sudo's timestamp. Scope: only pkexec launching apt-get or apt-mark, + * only for an active local session of a sudo/wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-apt.rules + */ +polkit.addRule(function(action, subject) { + var prog = action.lookup("program"); + if (action.id == "org.freedesktop.policykit.exec" && + (prog == "/usr/bin/apt-get" || prog == "/usr/bin/apt-mark") && + subject.active && subject.local && + (subject.isInGroup("sudo") || subject.isInGroup("wheel"))) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); diff --git a/linux-updater/polkit/49-linux-updater-dnf.rules b/linux-updater/polkit/49-linux-updater-dnf.rules new file mode 100644 index 00000000..fe8837c1 --- /dev/null +++ b/linux-updater/polkit/49-linux-updater-dnf.rules @@ -0,0 +1,16 @@ +/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per dnf transaction). Authentication is kept for + * ~5 minutes, like sudo's timestamp. Scope: only pkexec launching dnf, + * only for an active local session of a wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-dnf.rules + */ +polkit.addRule(function(action, subject) { + var prog = action.lookup("program"); + if (action.id == "org.freedesktop.policykit.exec" && + (prog == "/usr/bin/dnf" || prog == "/usr/bin/dnf5" || + prog == "/usr/bin/dnf-3") && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); diff --git a/linux-updater/polkit/49-linux-updater-pacman.rules b/linux-updater/polkit/49-linux-updater-pacman.rules new file mode 100644 index 00000000..69e6c018 --- /dev/null +++ b/linux-updater/polkit/49-linux-updater-pacman.rules @@ -0,0 +1,25 @@ +/* One polkit password per update run instead of one per pacman transaction. + * + * paru runs `pkexec pacman ...` separately for the database sync and for + * every install transaction, and plain pkexec re-authenticates each time. + * AUTH_ADMIN_KEEP caches a successful authentication for ~5 minutes (same + * idea as sudo's timestamp), so one password covers the whole run. A build + * that takes longer than 5 minutes between transactions may still prompt + * again. + * + * Scope: only pkexec launching /usr/bin/pacman, only for an active local + * session of a wheel member. + * + * Install (root required): + * sudo install -Dm644 polkit/49-linux-updater-pacman.rules /etc/polkit-1/rules.d/49-linux-updater-pacman.rules + * Remove: + * sudo rm /etc/polkit-1/rules.d/49-linux-updater-pacman.rules + */ +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.policykit.exec" && + action.lookup("program") == "/usr/bin/pacman" && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); diff --git a/linux-updater/polkit/49-linux-updater-xbps.rules b/linux-updater/polkit/49-linux-updater-xbps.rules new file mode 100644 index 00000000..d0a849f1 --- /dev/null +++ b/linux-updater/polkit/49-linux-updater-xbps.rules @@ -0,0 +1,15 @@ +/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per xbps call). Authentication is kept for ~5 minutes. + * Scope: only pkexec launching xbps-install or xbps-pkgdb, only for an + * active local session of a wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-xbps.rules + */ +polkit.addRule(function(action, subject) { + var prog = action.lookup("program"); + if (action.id == "org.freedesktop.policykit.exec" && + (prog == "/usr/bin/xbps-install" || prog == "/usr/bin/xbps-pkgdb") && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); diff --git a/linux-updater/polkit/49-linux-updater-zypper.rules b/linux-updater/polkit/49-linux-updater-zypper.rules new file mode 100644 index 00000000..63f86f33 --- /dev/null +++ b/linux-updater/polkit/49-linux-updater-zypper.rules @@ -0,0 +1,14 @@ +/* Installed by the linux-updater Noctalia plugin (one password per update + * run instead of one per zypper call). Authentication is kept for + * ~5 minutes, like sudo's timestamp. Scope: only pkexec launching zypper, + * only for an active local session of a wheel member. + * Remove: sudo rm /etc/polkit-1/rules.d/49-linux-updater-zypper.rules + */ +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.policykit.exec" && + action.lookup("program") == "/usr/bin/zypper" && + subject.active && subject.local && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_ADMIN_KEEP; + } +}); diff --git a/linux-updater/screenshots/history.png b/linux-updater/screenshots/history.png new file mode 100644 index 0000000000000000000000000000000000000000..c0e9f12c990c4b6799b50fb628b2d0c13754f959 GIT binary patch literal 16349 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 literal 0 HcmV?d00001 diff --git a/linux-updater/screenshots/panel.png b/linux-updater/screenshots/panel.png new file mode 100644 index 0000000000000000000000000000000000000000..4aae488814bef7334bcbd4ee605f66f4a068c8c5 GIT binary patch literal 22437 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 F{{ZJ3&|Clj literal 0 HcmV?d00001 diff --git a/linux-updater/service.luau b/linux-updater/service.luau new file mode 100644 index 00000000..a6bd5c58 --- /dev/null +++ b/linux-updater/service.luau @@ -0,0 +1,1674 @@ +--!nonstrict +-- linux-updater singleton engine. Checks pacman, the AUR helper and Flatpak, +-- publishes the result as shared state, and runs the update in the +-- BACKGROUND: one click, pkexec (polkit) asks for the password, the run is +-- fully non-interactive (--noconfirm and friends) and its log streams into +-- the panel. +-- +-- state "linup_state" = { nonce, phase, step, total, pacman, aur, +-- flatpak, downloadSizeMiB, rebootRecommended, +-- newsUnread, newsLatestTitle, err, checkedAt, +-- ignoredCount, ignoredPending, ignoredDynamic, +-- ignoredConfig, logTail, logPath, runExit, +-- progress = { done, total } } +-- requests "linup_request" = { nonce, action, pkg } +-- -- check|update|update_terminal|dismiss|open_news +-- -- |ignore|unignore (pkg = package name) +-- +-- The update run is spawned fully detached (double-fork + setsid), writing to +-- /update.log; it survives a shell restart. The engine polls +-- the log every RUN_POLL_SECONDS with `tail` and finishes when the runner's +-- "::EXIT " marker appears. If a run cannot proceed non-interactively +-- it exits non-zero without touching the system, and the panel offers a +-- terminal fallback ("update_terminal") where prompts work normally. + +local STATE_KEY = "linup_state" +local REQUEST_KEY = "linup_request" +local NEWS_FILE = "news_state.json" +local IGNORE_FILE = "ignore.json" +local RUNS_FILE = "runs.json" +local RUN_META_FILE = "run_meta.json" +local MAX_RUNS = 15 -- update runs kept for the history strip / rollback +local MAX_RUN_PACKAGES = 100 -- per-run package list cap (storage and state) +local LOG_FILE = "update.log" + +local CHECK_TIMEOUT_MS = 45000 -- pacman/AUR/flatpak checks: each may sync a mirror +local SIZE_TIMEOUT_MS = 20000 -- pacman -Si: local db, no mirror sync +local FAST_TIMEOUT_MS = 5000 -- log tail / reboot check: local filesystem only +local NEWS_RECHECK_HOURS = 6 +local RUN_POLL_SECONDS = 2 +local RUN_STALE_LIMIT_S = 1800 -- no log growth for this long = the run is stuck +local RUN_RESUME_MAX_AGE_S = 6 * 3600 -- older unfinished logs are not resumed +local AUTO_CHECK_DELAY = 10 -- ticks before an enabled auto-check's first run +local MAX_LISTED = 300 -- packages kept per source for the panel's expandable list + +local phase = "idle" -- idle|checking|clean|ready|running|error|missing +local step = "" -- source label being checked (phase == "checking") +local total = 0 +local sources = { pacman = { n = 0, items = {} }, aur = { n = 0, items = {}, helper = "" }, flatpak = { n = 0, items = {} } } +local downloadSizeMiB = nil +local rebootRecommended = false +local newsUnread = 0 +local newsLatestTitle = nil +local newsItems = {} +local newsLastSeenGuid = nil +local errMsg = nil +local checkedAt = "" +local stateNonce = 0 +local lastRequestNonce = 0 + +local runPollTicks = 0 +local runExit = nil -- exit code of the last background run, nil while unknown +local runDone = 0 -- progress: package lines seen in the log so far +local runTotal = 0 -- progress: pending count when the run started +local runStaleS = 0 -- seconds without log growth during a run +local logTail = {} -- last log lines for the panel +local lastTailText = "" +local sinceCheck = 0 +local startupTicks = 0 +local sinceNewsCheck = 0 +local newsStateLoaded = false +local newsDirty = false +local ignoredPending = {} -- ignored packages that had a pending update in the last check +local dynamicIgnore = nil -- panel-managed ignore list, persisted in IGNORE_FILE +local polkitRuleInstalled = nil -- nil until the async check ran, then boolean +local polkitInstallBusy = false +local selfCheckState = nil -- failing backend self-checks, for the panel's hint rows +local runsHistory = nil -- lazy-loaded array of {at, rollback, packages}, newest last +local runKind = "update" -- update|rollback: what the current background run does +local runPackages = nil -- packages of the current run, for the history entry +local probeResult = nil -- cache/required-by info for one run, {at, pkgs} + +local startCheck +local checkNews +local finishRun + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function trim(value) + return noctalia.string.trim(value or "") +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +-- The package-manager backends: every distro-specific command, parser and +-- capability lives there; the engine only orchestrates. +local BACKEND_FACTORIES = { + pacman = require("./backends/pacman.luau"), + packagekit = require("./backends/packagekit.luau"), + dnf = require("./backends/dnf.luau"), + apt = require("./backends/apt.luau"), + zypper = require("./backends/zypper.luau"), + xbps = require("./backends/xbps.luau"), +} + +-- Backend choice: the "backend" setting wins; "auto" resolves from +-- /etc/os-release (ID, then ID_LIKE — Manjaro and friends carry +-- ID_LIKE=arch), then falls back by installed tooling: a native backend's +-- manager first, PackageKit as the generic answer for everything else. +local OS_RELEASE = noctalia.readFile("/etc/os-release") or "" + +local function resolveBackendId() + local choice = cfg("backend") + if type(choice) == "string" and BACKEND_FACTORIES[choice] ~= nil then + return choice + end + local osRelease = OS_RELEASE + local id = osRelease:match('\nID="?([%w%-%._]+)') or osRelease:match('^ID="?([%w%-%._]+)') or "" + local idLike = osRelease:match('ID_LIKE="?([%w%-%._ ]+)') or "" + local haystack = " " .. id .. " " .. idLike .. " " + if haystack:find(" arch ", 1, true) ~= nil then + return "pacman" + end + if (haystack:find("fedora", 1, true) ~= nil or haystack:find("rhel", 1, true) ~= nil) + and noctalia.commandExists("dnf") then + return "dnf" + end + if (haystack:find("debian", 1, true) ~= nil or haystack:find("ubuntu", 1, true) ~= nil) + and noctalia.commandExists("apt-get") then + return "apt" + end + if haystack:find("suse", 1, true) ~= nil and noctalia.commandExists("zypper") then + return "zypper" + end + if haystack:find("void", 1, true) ~= nil and noctalia.commandExists("xbps-install") then + return "xbps" + end + -- Unrecognized distro: fall back by whichever native tool exists, + -- PackageKit as the generic answer for everything else. + for _, candidate in ipairs({ + { tool = "pacman", id = "pacman" }, + { tool = "dnf", id = "dnf" }, + { tool = "apt-get", id = "apt" }, + { tool = "zypper", id = "zypper" }, + { tool = "xbps-install", id = "xbps" }, + }) do + if noctalia.commandExists(candidate.tool) then + return candidate.id + end + end + return "packagekit" +end + +local backend = BACKEND_FACTORIES[resolveBackendId()]({ + trim = trim, + shellQuote = shellQuote, + cfg = cfg, + MAX_LISTED = MAX_LISTED, + commandExists = noctalia.commandExists, + osRelease = OS_RELEASE, +}) +-- nil when the backend needs no keep-authorization rule (e.g. PackageKit +-- brings its own polkit policies) — the whole rule machinery is off then. +local POLKIT_RULE_PATH = backend.polkitRuleName ~= nil + and ("/etc/polkit-1/rules.d/" .. backend.polkitRuleName) + or nil + +local function logPath() + local dir, err = noctalia.pluginDataDir() + if dir == nil then + noctalia.log("linux-updater: cannot resolve plugin data dir: " .. tostring(err)) + return nil + end + return dir .. "/" .. LOG_FILE +end + +-- Package names reach the command line (--ignore, pacman -Si), so only +-- pacman's own name grammar is accepted. Anything else is dropped with a log +-- line instead of being quoted. +local function validName(name) + return name:match("^[a-zA-Z0-9._+-]+$") ~= nil +end + +-- Two plugin-side ignore sources: the "ignore_packages" setting and a +-- panel-managed list in IGNORE_FILE (the panel cannot write settings). +-- pacman.conf's IgnorePkg arrives from the checkers as "[ignored]" lines +-- and is displayed only, never managed here. +local function ignorePath() + local dir, err = noctalia.pluginDataDir() + if dir == nil then + noctalia.log("linux-updater: cannot resolve plugin data dir: " .. tostring(err)) + return nil + end + return dir .. "/" .. IGNORE_FILE +end + +local function loadDynamicIgnore() + if dynamicIgnore ~= nil then + return + end + dynamicIgnore = {} + local path = ignorePath() + local encoded = path ~= nil and noctalia.readFile(path) or nil + local ok, decoded = pcall(function() + return encoded ~= nil and noctalia.json.decode(encoded) or nil + end) + if ok and type(decoded) == "table" and type(decoded.packages) == "table" then + for _, entry in ipairs(decoded.packages) do + local name = trim(tostring(entry)) + if validName(name) then + table.insert(dynamicIgnore, name) + end + end + end +end + +local function saveDynamicIgnore() + local path = ignorePath() + if path == nil then + return + end + local encoded = noctalia.json.encode({ packages = dynamicIgnore }) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +local function configIgnoreList() + local raw = cfg("ignore_packages") + if type(raw) ~= "table" then + return {} + end + local names = {} + for _, entry in ipairs(raw) do + local name = trim(tostring(entry)) + if validName(name) then + table.insert(names, name) + elseif name ~= "" then + noctalia.log("linux-updater: ignoring invalid package name '" .. name .. "'") + end + end + return names +end + +local function ignoreList() + loadDynamicIgnore() + local names = {} + local seen = {} + for _, name in ipairs(configIgnoreList()) do + if not seen[name] then + seen[name] = true + table.insert(names, name) + end + end + for _, name in ipairs(dynamicIgnore) do + if not seen[name] then + seen[name] = true + table.insert(names, name) + end + end + return names +end + +local function ignoreSet() + local set = {} + for _, name in ipairs(ignoreList()) do + set[name] = true + end + return set +end + +-- Appends to the panel-managed list without touching the current snapshot; +-- used in bulk after a rollback when rollback_auto_ignore is on. +local function addIgnoreName(name) + name = trim(tostring(name or "")) + if not validName(name) then + return + end + loadDynamicIgnore() + for _, existing in ipairs(ignoreList()) do + if existing == name then + return + end + end + table.insert(dynamicIgnore, name) + saveDynamicIgnore() +end + +-- ── Run history ────────────────────────────────────────────────────────────── + +-- One entry per finished background run: when it ran, whether it was a +-- rollback, and the "name from -> to" list it applied. This is what the +-- panel's history strip shows and what rollback resolves versions from. +local function runsPath() + local dir = noctalia.pluginDataDir() + return dir ~= nil and (dir .. "/" .. RUNS_FILE) or nil +end + +local function loadRuns() + if runsHistory ~= nil then + return + end + runsHistory = {} + local path = runsPath() + local encoded = path ~= nil and noctalia.readFile(path) or nil + local ok, decoded = pcall(function() + return encoded ~= nil and noctalia.json.decode(encoded) or nil + end) + if ok and type(decoded) == "table" and type(decoded.runs) == "table" then + for _, entry in ipairs(decoded.runs) do + if type(entry) == "table" and tonumber(entry.at) ~= nil and type(entry.packages) == "table" then + table.insert(runsHistory, entry) + end + end + end +end + +local function saveRuns() + local path = runsPath() + if path == nil then + return + end + local encoded = noctalia.json.encode({ runs = runsHistory }) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +local historyState = nil -- cached newest-first copy for publish() + +local function historyForState() + if historyState == nil then + loadRuns() + historyState = {} + for i = #runsHistory, 1, -1 do + local entry = runsHistory[i] + table.insert(historyState, { + at = entry.at, + rollback = entry.rollback == true, + n = #entry.packages, + packages = entry.packages, + txId = entry.txId, + }) + end + end + return historyState +end + +local function recordRun(kind, packages, txId) + if type(packages) ~= "table" or #packages == 0 then + return + end + loadRuns() + table.insert(runsHistory, { at = os.time(), rollback = kind == "rollback", packages = packages, txId = txId }) + while #runsHistory > MAX_RUNS do + table.remove(runsHistory, 1) + end + historyState = nil + saveRuns() +end + +local function findRun(at) + loadRuns() + for _, entry in ipairs(runsHistory) do + if tonumber(entry.at) == tonumber(at) then + return entry + end + end + return nil +end + +-- Snapshot of everything the update run is about to apply, taken at launch: +-- the run itself is non-interactive, so its target set is exactly the +-- pending list. Flatpak entries are kept for display but can't be rolled +-- back through the pacman cache. +local function collectPendingPackages() + local packages = {} + for _, source in ipairs({ { key = "pacman", entry = sources.pacman }, { key = "aur", entry = sources.aur }, { key = "flatpak", entry = sources.flatpak } }) do + for _, item in ipairs(source.entry.items or {}) do + if #packages < MAX_RUN_PACKAGES then + table.insert(packages, { name = item.name, from = item.from, to = item.to, source = source.key }) + end + end + end + return packages +end + +-- ── Parsing ────────────────────────────────────────────────────────────────── + +-- Flatpak has no "name oldver -> newver" line, so the query joins installed +-- and pending by application id (tab-separated name/from/to). +local function parseTabLines(output, ignored) + local items = {} + local n = 0 + for line in (output or ""):gmatch("[^\n]+") do + local fields = {} + for field in (line .. "\t"):gmatch("([^\t]*)\t") do + table.insert(fields, field) + end + local name = fields[1] or "" + if name ~= "" then + if ignored[name] then + table.insert(ignoredPending, { name = name, from = fields[2] or "", to = fields[3] or "", source = "plugin" }) + else + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = fields[2] or "", to = fields[3] or "" }) + end + end + end + end + return n, items +end + +-- ── Publishing ─────────────────────────────────────────────────────────────── + +local function publish() + stateNonce += 1 + noctalia.state.set(STATE_KEY, { + nonce = stateNonce, + phase = phase, + step = step, + total = total, + pacman = sources.pacman, + aur = sources.aur, + flatpak = sources.flatpak, + downloadSizeMiB = downloadSizeMiB, + rebootRecommended = rebootRecommended, + newsUnread = newsUnread, + newsLatestTitle = newsLatestTitle, + err = errMsg, + checkedAt = checkedAt, + backend = backend.id, + caps = backend.caps, + nativeLabel = backend.nativeLabelKey, + systemIgnoreLabel = backend.systemIgnoreLabelKey, + systemIgnoreTip = backend.systemIgnoreTipKey, + selfChecks = selfCheckState, + ignoredCount = #ignoreList(), + ignoredPending = ignoredPending, + ignoredDynamic = dynamicIgnore or {}, + ignoredConfig = configIgnoreList(), + polkitRule = polkitRuleInstalled, + history = historyForState(), + runKind = runKind, + probe = probeResult, + flatpakEnabled = cfg("flatpak_enabled") == true and noctalia.commandExists("flatpak"), + logTail = logTail, + logPath = logPath(), + runExit = runExit, + progress = { done = runDone, total = runTotal }, + }) +end + +-- ── Checking pipeline: pacman → AUR → Flatpak → size → reboot → done ──────── + +local checkFlatpak +local checkSize +local checkReboot +local finishCheck + +local function failCheck(message) + phase = "error" + errMsg = message + publish() +end + +checkReboot = function() + if cfg("check_reboot_needed") ~= true then + rebootRecommended = false + finishCheck() + return + end + local started = noctalia.runAsync( + backend.rebootCommand, + function(result) + rebootRecommended = trim(result.stdout or "") == "missing" + finishCheck() + end, + FAST_TIMEOUT_MS + ) + if not started then + rebootRecommended = false + finishCheck() + end +end + +checkSize = function() + if cfg("show_download_size") ~= true or backend.caps.size ~= true or sources.pacman.n == 0 then + downloadSizeMiB = nil + checkReboot() + return + end + local names = {} + for _, item in ipairs(sources.pacman.items) do + table.insert(names, shellQuote(item.name)) + end + if #names == 0 then + -- More pending than MAX_LISTED kept a name for, so the size would + -- under-count. Left unknown instead of wrong. + downloadSizeMiB = nil + checkReboot() + return + end + local started = noctalia.runAsync(backend.sizeCommand(names), function(result) + local value = tonumber(trim(result.stdout or "")) + downloadSizeMiB = (not result.timedOut and result.exitCode == 0 and value ~= nil) and value or nil + checkReboot() + end, SIZE_TIMEOUT_MS) + if not started then + downloadSizeMiB = nil + checkReboot() + end +end + +checkFlatpak = function(ignored) + if cfg("flatpak_enabled") ~= true or not noctalia.commandExists("flatpak") then + sources.flatpak = { n = 0, items = {} } + checkSize() + return + end + step = tr("source.flatpak") + publish() + -- Flatpak tracks commits, so a version string often doesn't move across + -- an update. Short commits stand in when it doesn't, joined by + -- application id in one awk pass. + -- Each call's own output and exit code are captured before piping into + -- awk, so a real flatpak failure (e.g. no remote, network down) fails + -- the whole command instead of awk quietly succeeding on empty input. + local cmd = [[ +listOut=$(flatpak list --columns=application,version,active 2>/dev/null); listCode=$? +updOut=$(flatpak remote-ls --updates --columns=application,version,commit 2>/dev/null); updCode=$? +if [ "$listCode" -ne 0 ] || [ "$updCode" -ne 0 ]; then + exit 1 +fi +{ printf '%s\n' "$listOut" | sed 's/^/L /' + printf '%s\n' "$updOut" | sed 's/^/R /' +} | awk -F'\t' ' +{ tag=substr($1,1,1); app=substr($1,3) } +tag=="L" { v[app]=$2; c[app]=$3 } +tag=="R" { from=v[app]; to=$2 + if (from=="" || to=="" || from==to) { from=substr(c[app],1,7); to=substr($3,1,7) } + print app"\t"from"\t"to }']] + local started = noctalia.runAsync(cmd, function(result) + if result.timedOut then + sources.flatpak = { n = 0, items = {} } + checkSize() + return + end + if result.exitCode ~= 0 then + noctalia.log("linux-updater: flatpak check failed (exit " .. tostring(result.exitCode) .. ")") + failCheck(tr("err_flatpak_failed")) + return + end + local n, items = parseTabLines(result.stdout, ignored) + sources.flatpak = { n = n, items = items } + checkSize() + end, CHECK_TIMEOUT_MS) + if not started then + sources.flatpak = { n = 0, items = {} } + checkSize() + end +end + +local function checkAur(ignored) + if backend.caps.aur ~= true then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + return + end + local helper = backend.resolveAurHelper() + if helper == nil then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + return + end + if helper ~= "custom" and not noctalia.commandExists(helper) then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + return + end + local cmd = backend.aurCheckCommand(helper) + if cmd == nil then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + return + end + step = helper == "custom" and tr("source.aur") or tr("source.aur_named", { helper = helper }) + publish() + local started = noctalia.runAsync(cmd, function(result) + if result.timedOut then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + return + end + -- "-Qua" (like plain pacman -Qu) exits non-zero for "nothing to + -- upgrade" too, so exit code alone can't tell that apart from a real + -- failure. A real failure prints something to stderr; "no updates" + -- doesn't. + if result.exitCode ~= 0 and trim(result.stderr or "") ~= "" then + noctalia.log("linux-updater: AUR check failed: " .. trim(result.stderr)) + failCheck(tr("err_aur_failed")) + return + end + local n, items = backend.parseCheck(result.stdout, ignored, ignoredPending) + sources.aur = { n = n, items = items, helper = helper == "custom" and "" or helper } + checkFlatpak(ignored) + end, CHECK_TIMEOUT_MS) + if not started then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + end +end + +startCheck = function() + if phase == "checking" or phase == "running" then + return + end + if not noctalia.commandExists(backend.checkTool) then + phase = "missing" + errMsg = tr(backend.missingToolKey) + publish() + return + end + + phase = "checking" + errMsg = nil + sources = { pacman = { n = 0, items = {} }, aur = { n = 0, items = {}, helper = "" }, flatpak = { n = 0, items = {} } } + downloadSizeMiB = nil + ignoredPending = {} + step = tr("source.pacman") + publish() + + local ignored = ignoreSet() + local started = noctalia.runAsync(backend.checkCommand, function(result) + if result.timedOut then + failCheck(tr("err_check_timeout")) + return + end + if result.exitCode ~= 0 then + noctalia.log("linux-updater: " .. backend.checkTool .. " failed (exit " .. tostring(result.exitCode) .. ")") + failCheck(tr("err_check_failed")) + return + end + local n, items = backend.parseCheck(result.stdout, ignored, ignoredPending) + sources.pacman = { n = n, items = items } + checkAur(ignored) + end, CHECK_TIMEOUT_MS) + + if not started then + failCheck(tr("err_spawn")) + end +end + +finishCheck = function() + total = sources.pacman.n + sources.aur.n + sources.flatpak.n + step = "" + phase = total > 0 and "ready" or "clean" + checkedAt = noctalia.formatTime("%H:%M") + sinceCheck = 0 + publish() + if total > 0 and cfg("notify_on_updates") == true then + noctalia.notify(tr("title"), noctalia.trp("notify_updates", total, { count = total })) + end +end + +-- ── Arch Linux news ────────────────────────────────────────────────────────── + +local function newsStatePath() + local dir, err = noctalia.pluginDataDir() + if dir == nil then + noctalia.log("linux-updater: cannot resolve plugin data dir: " .. tostring(err)) + return nil + end + return dir .. "/" .. NEWS_FILE +end + +local function loadNewsState() + if newsStateLoaded then + return + end + newsStateLoaded = true + local path = newsStatePath() + local encoded = path ~= nil and noctalia.readFile(path) or nil + local ok, decoded = pcall(function() + return encoded ~= nil and noctalia.json.decode(encoded) or nil + end) + if ok and type(decoded) == "table" and type(decoded.lastSeenGuid) == "string" then + newsLastSeenGuid = decoded.lastSeenGuid + end +end + +local function saveNewsState() + local path = newsStatePath() + if path == nil then + return + end + local encoded = noctalia.json.encode({ lastSeenGuid = newsLastSeenGuid }) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +local HTML_ENTITIES = { + ["<"] = "<", + [">"] = ">", + ["""] = '"', + ["'"] = "'", + ["'"] = "'", + ["&"] = "&", +} + +local function unescapeHtml(text) + return (text:gsub("&#?%w+;", HTML_ENTITIES)) +end + +-- Plain RSS 2.0, so a few gmatch patterns are enough, no XML library needed. +-- Wrapped in pcall: a feed change degrades to no news data, not a crash. +local function parseNewsFeed(xml) + local items = {} + for block in xml:gmatch("(.-)") do + local title = block:match("(.-)") + local link = block:match("(.-)") + local guid = block:match("]*>(.-)") + if title ~= nil and link ~= nil then + table.insert(items, { + title = unescapeHtml(trim(title)), + link = trim(link), + guid = guid ~= nil and trim(guid) or trim(link), + }) + end + end + return items +end + +local function applyNewsItems(items) + newsItems = items + if #items == 0 then + newsUnread = 0 + newsLatestTitle = nil + return + end + newsLatestTitle = items[1].title + if newsLastSeenGuid == nil then + -- First run: today's news is the baseline, not a backlog to alert on. + newsLastSeenGuid = items[1].guid + saveNewsState() + newsUnread = 0 + return + end + local unread = 0 + for _, item in ipairs(items) do + if item.guid == newsLastSeenGuid then + break + end + unread += 1 + end + newsUnread = unread +end + +checkNews = function() + if backend.caps.news ~= true or backend.news == nil or cfg("check_arch_news") ~= true then + return + end + loadNewsState() + local ok = noctalia.http({ url = backend.news.url }, function(res) + if not res.ok or res.body == nil or res.body == "" then + return + end + local parsed, items = pcall(parseNewsFeed, res.body) + if parsed and type(items) == "table" then + applyNewsItems(items) + newsDirty = true + end + end) + if not ok then + noctalia.log("linux-updater: could not start the Arch news request") + end +end + +-- Opens the news page and marks everything fetched so far as read. +local function openNews() + if #newsItems > 0 then + newsLastSeenGuid = newsItems[1].guid + saveNewsState() + newsUnread = 0 + publish() + end + if backend.news ~= nil then + noctalia.runAsync("xdg-open " .. shellQuote(backend.news.page) .. " >/dev/null 2>&1") + end +end + +-- ── Polkit rule ────────────────────────────────────────────────────────────── +-- The keep-authorization rule text lives in the backend (embedded because +-- the plugin directory is not exposed to Lua at this api level). + +-- /etc/polkit-1/rules.d is 750 root:polkitd on Arch, so an unprivileged +-- `test -f` cannot see the rule. Three answers: file visible (yes), +-- directory readable without the file (no), directory opaque — then a +-- marker written on successful install is the best available memory. Worst +-- case is a missing hint, never a broken update. +local function polkitMarkerPath() + local dir = noctalia.pluginDataDir() + return dir ~= nil and (dir .. "/polkit_rule_installed") or nil +end + +local function checkPolkitRule() + if POLKIT_RULE_PATH == nil then + return + end + local cmd = "if [ -f " .. POLKIT_RULE_PATH .. " ]; then echo yes;" + .. " elif [ -r /etc/polkit-1/rules.d ]; then echo no;" + .. " else echo opaque; fi" + noctalia.runAsync(cmd, function(result) + local answer = trim(result.stdout or "") + if answer == "yes" then + polkitRuleInstalled = true + elseif answer == "no" then + polkitRuleInstalled = false + local marker = polkitMarkerPath() + if marker ~= nil then + noctalia.runAsync("rm -f " .. shellQuote(marker)) + end + elseif answer == "opaque" then + local marker = polkitMarkerPath() + polkitRuleInstalled = marker ~= nil and noctalia.readFile(marker) ~= nil + else + return + end + publish() + end, FAST_TIMEOUT_MS) +end + +-- The rule text is staged in the data dir because pkexec needs a file +-- path; polkit picks up rules.d changes on the fly. +local function installPolkitRule() + if POLKIT_RULE_PATH == nil or backend.polkitRule == nil then + return + end + if polkitInstallBusy or not noctalia.commandExists("pkexec") then + return + end + local dir = noctalia.pluginDataDir() + if dir == nil then + return + end + local staged = dir .. "/" .. backend.polkitRuleName + noctalia.writeFile(staged, backend.polkitRule) + polkitInstallBusy = true + local cmd = "pkexec install -Dm644 -o root -g root " .. shellQuote(staged) .. " " .. POLKIT_RULE_PATH + local started = noctalia.runAsync(cmd, function(result) + polkitInstallBusy = false + if result.exitCode == 0 then + -- Recorded in the marker because rules.d is opaque to the user. + local marker = polkitMarkerPath() + if marker ~= nil then + noctalia.writeFile(marker, "1") + end + polkitRuleInstalled = true + publish() + noctalia.notify(tr("title"), tr("notify_polkit_ok")) + elseif result.exitCode ~= 126 then + -- 126 = the polkit dialog was dismissed; anything else failed. + noctalia.notifyError(tr("title"), tr("err_polkit_install")) + checkPolkitRule() + end + end, 120000) + if not started then + polkitInstallBusy = false + end +end + +-- ── Updating (background) ──────────────────────────────────────────────────── + +-- The core upgrade command, fully non-interactive. pkexec raises the polkit +-- password dialog; --noconfirm answers every remaining question with its +-- default. AUR helpers run as the user and escalate through pkexec themselves +-- (--sudo pkexec), so no terminal and no sudo tty are ever needed. +-- Flatpak is cross-distro, so its update part belongs to the engine, not a +-- backend. Same ignore list as the native manager: filter ignored refs out +-- of the pending Flatpak list before updating, so an app hidden from the +-- panel's count can't still slip in through a bare `flatpak update`. +local function buildFlatpakPart(ignored) + if cfg("flatpak_enabled") ~= true or not noctalia.commandExists("flatpak") then + return nil + end + if #ignored == 0 then + return "flatpak update -y --noninteractive" + end + local skipList = shellQuote(table.concat(ignored, "\n")) + return "{ flatpak_refs=$(flatpak remote-ls --updates --columns=application 2>/dev/null | awk -v ignore=" + .. skipList + .. [=[ 'BEGIN { n = split(ignore, arr, "\n"); for (i = 1; i <= n; i++) skip[arr[i]] = 1 } !($0 in skip)'); ]=] + .. [[if [ -n "$flatpak_refs" ]; then flatpak update -y --noninteractive $flatpak_refs; fi; }]] +end + +local function buildBackgroundCommand() + local override = trim(cfg("update_cmd")) + if override ~= "" then + return override + end + local ignored = ignoreList() + local parts = { backend.buildBackgroundCommand(ignored) } + local flatpakPart = buildFlatpakPart(ignored) + if flatpakPart ~= nil then + table.insert(parts, flatpakPart) + end + return table.concat(parts, " && ") +end + +-- Interactive variant for the terminal fallback: no --noconfirm, prompts and +-- the PKGBUILD review work as usual. Output is tee'd into the same log so the +-- engine still sees the "::EXIT" marker and re-checks when the run ends. +local function buildTerminalCommand() + local ignored = ignoreList() + local parts = { backend.buildTerminalCommand(ignored) } + if cfg("flatpak_enabled") == true and noctalia.commandExists("flatpak") then + table.insert(parts, "flatpak update") + end + return table.concat(parts, " && ") +end + +-- Uses Noctalia's terminal discovery ($TERMINAL, then the usual emulators) +-- unless a terminal is configured. +local function launchTerminal(cmd) + local term = trim(cfg("terminal")) + if term == "" then + return noctalia.runInTerminal(cmd) + end + local first = term:match("^%S+") or term + local bin = first:match("([^/]+)$") or first + local separator = (bin == "gnome-terminal" or bin == "kgx" or bin == "ptyxis") and "--" or "-e" + return noctalia.runAsync(term .. " " .. separator .. " sh -lc " .. shellQuote(cmd)) +end + +-- "Open full log": xdg-open is unreliable for plain text (text/plain often +-- maps to a terminal editor like vim, which cannot start without a tty and +-- dies silently), so the log opens in a terminal pager instead. +local function openLog() + local path = logPath() + if path == nil then + return + end + if not launchTerminal("less +G -- " .. shellQuote(path)) then + noctalia.notifyError(tr("title"), tr("err_no_terminal")) + end +end + +-- The current run's kind, package list and expected total live in memory, +-- which a hot reload or shell restart wipes; persisting them next to the +-- log lets a resumed engine keep the progress percent and still record the +-- run in the history when it finishes. +local function runMetaPath() + local dir = noctalia.pluginDataDir() + return dir ~= nil and (dir .. "/" .. RUN_META_FILE) or nil +end + +local function saveRunMeta() + local path = runMetaPath() + if path == nil then + return + end + local encoded = noctalia.json.encode({ kind = runKind, total = runTotal, packages = runPackages }) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +local function loadRunMeta() + local path = runMetaPath() + local encoded = path ~= nil and noctalia.readFile(path) or nil + local ok, decoded = pcall(function() + return encoded ~= nil and noctalia.json.decode(encoded) or nil + end) + if ok and type(decoded) == "table" then + runKind = decoded.kind == "rollback" and "rollback" or "update" + runTotal = tonumber(decoded.total) or 0 + runPackages = type(decoded.packages) == "table" and decoded.packages or nil + end +end + +local function clearRunMeta() + local path = runMetaPath() + if path ~= nil then + noctalia.runAsync("rm -f -- " .. shellQuote(path)) + end +end + +local function beginRun(kind, expectTotal) + phase = "running" + errMsg = nil + step = "" + runKind = kind or "update" + runExit = nil + runDone = 0 + runTotal = expectTotal or total + runStaleS = 0 + runPollTicks = 0 + logTail = {} + lastTailText = "" + publish() +end + +local function runUpdate() + if phase == "running" or phase == "checking" then + return + end + if not noctalia.commandExists(backend.checkTool) then + phase = "missing" + errMsg = tr(backend.missingToolKey) + publish() + return + end + -- Only backends escalating through pkexec need it; PackageKit brings + -- its own polkit path. + if backend.polkitRuleName ~= nil and not noctalia.commandExists("pkexec") then + phase = "error" + errMsg = tr("err_no_pkexec") + publish() + noctalia.notifyError(tr("title"), tr("err_no_pkexec")) + return + end + local path = logPath() + if path == nil then + phase = "error" + errMsg = tr("err_spawn") + publish() + return + end + + -- "::START " lets a restarted engine tell a live run from a stale + -- log; "::EXIT " is the completion marker the poller waits for. + local quoted = shellQuote(path) + local script = "printf '::START %s\\n' \"$(date +%s)\" > " .. quoted + .. "; { " .. buildBackgroundCommand() .. " ; } >> " .. quoted .. " 2>&1" + .. "; printf '::EXIT %s\\n' \"$?\" >> " .. quoted + + if not noctalia.runAsync(script) then + phase = "error" + errMsg = tr("err_spawn") + publish() + return + end + runPackages = collectPendingPackages() + beginRun("update") + saveRunMeta() +end + +local function runUpdateTerminal() + if phase == "running" or phase == "checking" then + return + end + local path = logPath() + if path == nil then + return + end + local quoted = shellQuote(path) + local wrapped = "printf '::START %s\\n' \"$(date +%s)\" > " .. quoted + .. "; { " .. buildTerminalCommand() .. "; printf '::EXIT %s\\n' \"$?\" ; } 2>&1 | tee -a " .. quoted + .. "; echo; echo " .. shellQuote(tr("run.press_key")) .. "; read -n 1" + if not launchTerminal(wrapped) then + phase = "error" + errMsg = tr("err_no_terminal") + publish() + noctalia.notifyError(tr("title"), tr("err_no_terminal")) + return + end + runPackages = collectPendingPackages() + beginRun("update") + saveRunMeta() +end + +finishRun = function(code) + runExit = code + runStaleS = 0 + if code == 0 then + -- Native-rollback backends stamp the run with the package manager's + -- own transaction id, fetched right after the run finishes; the + -- history entry is then written asynchronously. + if runKind == "update" and backend.txIdCommand ~= nil and type(runPackages) == "table" then + local kind, packages = runKind, runPackages + local started = noctalia.runAsync(backend.txIdCommand, function(result) + recordRun(kind, packages, backend.parseTxId(result.stdout)) + publish() + end, FAST_TIMEOUT_MS) + if not started then + recordRun(kind, packages) + end + else + recordRun(runKind, runPackages) + end + if runKind == "rollback" then + noctalia.notify(tr("title"), tr("notify_rollback_ok")) + if cfg("rollback_auto_ignore") == true and type(runPackages) == "table" then + for _, item in ipairs(runPackages) do + addIgnoreName(item.name) + end + end + else + noctalia.notify(tr("title"), tr("notify_run_ok")) + end + runPackages = nil + clearRunMeta() + phase = "clean" + publish() + startCheck() -- verify: phase becomes checking, then clean/ready + else + runPackages = nil + clearRunMeta() + phase = "error" + errMsg = tr("err_run_failed", { code = tostring(code) }) + publish() + noctalia.notifyError(tr("title"), errMsg) + end +end + +local function stripAnsi(line) + return (line:gsub("\27%[[%d;]*[A-Za-z]", ""):gsub("\r", "")) +end + +-- One `tail` per poll: the last screenful for display plus a full-file +-- progress count, so the bar doesn't reset when early lines scroll out of +-- the tail window. +local function pollRunLog() + local path = logPath() + if path == nil then + return + end + local keep = math.max(6, math.min(30, tonumber(cfg("log_lines")) or 14)) + local quoted = shellQuote(path) + local cmd = "tail -n " .. tostring(keep + 8) .. " " .. quoted .. " 2>/dev/null" + .. "; printf '::COUNT %s\\n' \"$(grep -cE " .. shellQuote(backend.progressPattern) .. " " .. quoted .. " 2>/dev/null)\"" + local started = noctalia.runAsync(cmd, function(result) + if phase ~= "running" then + return + end + local text = result.stdout or "" + if text == lastTailText then + runStaleS += RUN_POLL_SECONDS + if runStaleS >= RUN_STALE_LIMIT_S then + finishRun(-1) + end + return + end + lastTailText = text + runStaleS = 0 + + local lines = {} + local exitCode = nil + for line in text:gmatch("[^\n]+") do + local exitMatch = line:match("^::EXIT (%-?%d+)") + local countMatch = line:match("^::COUNT (%d+)") + if exitMatch ~= nil then + exitCode = tonumber(exitMatch) + elseif countMatch ~= nil then + runDone = tonumber(countMatch) or runDone + elseif line:match("^::START ") == nil then + line = stripAnsi(line) + if trim(line) ~= "" then + table.insert(lines, line) + end + end + end + local tail = {} + for i = math.max(1, #lines - keep + 1), #lines do + table.insert(tail, lines[i]) + end + logTail = tail + + if exitCode ~= nil then + finishRun(exitCode) + else + publish() + end + end, FAST_TIMEOUT_MS) + if not started then + noctalia.log("linux-updater: could not poll the update log") + end +end + +-- A fresh engine (login, shell restart) re-attaches to an unfinished run: +-- the detached updater survives Noctalia, so an update.log with a recent +-- ::START and no ::EXIT means it is still going. +local function resumeRunIfActive() + local path = logPath() + if path == nil then + return + end + local quoted = shellQuote(path) + local cmd = "head -n 1 " .. quoted .. " 2>/dev/null; tail -n 3 " .. quoted .. " 2>/dev/null" + local started = noctalia.runAsync(cmd, function(result) + if phase ~= "idle" then + return + end + local text = result.stdout or "" + local startedAt = tonumber(text:match("::START (%d+)")) + if startedAt == nil then + return + end + if text:match("::EXIT %-?%d+") ~= nil then + return + end + if os.time() - startedAt > RUN_RESUME_MAX_AGE_S then + return + end + loadRunMeta() + beginRun(runKind, runTotal > 0 and runTotal or nil) + end, FAST_TIMEOUT_MS) + if not started then + noctalia.log("linux-updater: could not inspect the update log") + end +end + +-- ── Rollback ───────────────────────────────────────────────────────────────── + +-- Rollback = `pkexec pacman -U` on the old package files still present in +-- the pacman cache (or the AUR helper's build cache). pacman installs the +-- chosen version directly, no need to step through intermediate upgrades. +-- --nodeps is never passed: a downgrade that would break another package's +-- versioned dependency (sonames included) makes pacman refuse the whole +-- transaction before anything changes. + +local function versionOk(version) + return version:match("^[%w:._+~-]+$") ~= nil +end + +local function specArgs(specs) + local args = {} + for _, spec in ipairs(specs) do + table.insert(args, shellQuote(spec.name) .. " " .. shellQuote(spec.version)) + end + return table.concat(args, " ") +end + +local function runRollback(specs, items) + if backend.caps.rollback ~= "cache" then + return + end + if phase == "running" or phase == "checking" or #specs == 0 then + return + end + if not noctalia.commandExists("pkexec") then + phase = "error" + errMsg = tr("err_no_pkexec") + publish() + noctalia.notifyError(tr("title"), tr("err_no_pkexec")) + return + end + local path = logPath() + if path == nil then + return + end + local body = "set -- " .. specArgs(specs) .. "\n" .. backend.findPkgSh .. [[ + +files="" +missing="" +while [ "$#" -ge 2 ]; do + if f=$(find_pkg "$1" "$2"); then files="$files $f"; else missing="$missing $1"; fi + shift 2 +done +if [ -n "$missing" ]; then echo "missing in cache:$missing"; exit 3; fi +]] .. backend.rollbackInstall .. " $files" + local quoted = shellQuote(path) + local script = "printf '::START %s\\n' \"$(date +%s)\" > " .. quoted + .. "; { " .. body .. "\n} >> " .. quoted .. " 2>&1" + .. "; printf '::EXIT %s\\n' \"$?\" >> " .. quoted + if not noctalia.runAsync(script) then + phase = "error" + errMsg = tr("err_spawn") + publish() + return + end + runPackages = items + beginRun("rollback", #specs) + saveRunMeta() +end + +-- Reverses one run entry: rolling back a package means going from its +-- current `to` version back to `from`. +local function reversedItem(item) + return { name = item.name, from = item.to, to = item.from, source = item.source } +end + +-- Per-package rollback. The package's dependencies that were updated in the +-- same run ride along in the same transaction (pactree intersected with the +-- run's package list), so a program and its libraries move back together. +local function startRollback(payload) + if backend.caps.rollback ~= "cache" or type(payload) ~= "table" then + return + end + local run = findRun(payload.at) + if run == nil then + return + end + local name = trim(tostring(payload.pkg or "")) + local target = nil + for _, item in ipairs(run.packages) do + if item.name == name and item.source ~= "flatpak" then + target = item + break + end + end + if target == nil or not validName(name) or not versionOk(tostring(target.from or "")) then + return + end + local runMates = {} + for _, item in ipairs(run.packages) do + if item.source ~= "flatpak" and item.name ~= name and validName(item.name) and versionOk(tostring(item.from or "")) then + runMates[item.name] = item + end + end + local finish = function(depNames) + local specs = { { name = name, version = target.from } } + local items = { reversedItem(target) } + for _, dep in ipairs(depNames) do + local mate = runMates[dep] + if mate ~= nil then + table.insert(specs, { name = mate.name, version = mate.from }) + table.insert(items, reversedItem(mate)) + runMates[dep] = nil + end + end + runRollback(specs, items) + end + local started = noctalia.runAsync(backend.depsListCommand(name), function(result) + local deps = {} + if not result.timedOut and result.exitCode == 0 then + for line in (result.stdout or ""):gmatch("[^\n]+") do + table.insert(deps, trim(line)) + end + end + finish(deps) + end, SIZE_TIMEOUT_MS) + if not started then + finish({}) + end +end + +-- Native whole-run rollback: the package manager reverses its own recorded +-- transaction (e.g. `dnf history undo `), dependencies included. Runs +-- through the same detached runner and log as everything else. +local function runNativeRollback(run) + if run.txId == nil or backend.rollbackNativeCommand == nil then + return + end + if phase == "running" or phase == "checking" then + return + end + local path = logPath() + if path == nil then + return + end + local items = {} + for _, item in ipairs(run.packages) do + if item.source ~= "flatpak" then + table.insert(items, reversedItem(item)) + end + end + local quoted = shellQuote(path) + local body = backend.rollbackNativeCommand(run.txId) + local script = "printf '::START %s\\n' \"$(date +%s)\" > " .. quoted + .. "; { " .. body .. " ; } >> " .. quoted .. " 2>&1" + .. "; printf '::EXIT %s\\n' \"$?\" >> " .. quoted + if not noctalia.runAsync(script) then + phase = "error" + errMsg = tr("err_spawn") + publish() + return + end + runPackages = items + beginRun("rollback", #items) + saveRunMeta() +end + +local function startRollbackRun(payload) + local run = findRun(type(payload) == "table" and payload.at or payload) + if run == nil then + return + end + if backend.caps.rollback == "native" then + runNativeRollback(run) + return + end + local specs = {} + local items = {} + for _, item in ipairs(run.packages) do + if item.source ~= "flatpak" and validName(item.name) and versionOk(tostring(item.from or "")) then + table.insert(specs, { name = item.name, version = item.from }) + table.insert(items, reversedItem(item)) + end + end + runRollback(specs, items) +end + +-- Cache availability and reverse-dependency counts for one run, fetched +-- when the panel opens that run's package list: greys out packages whose +-- old file is gone and shows how many installed packages require each one. +local function probeRun(payload) + if backend.caps.rollback ~= "cache" then + return + end + local run = findRun(type(payload) == "table" and payload.at or payload) + if run == nil then + return + end + local specs = {} + for _, item in ipairs(run.packages) do + if item.source ~= "flatpak" and validName(item.name) and versionOk(tostring(item.from or "")) then + table.insert(specs, { name = item.name, version = item.from }) + end + end + if #specs == 0 then + probeResult = { at = run.at, pkgs = {} } + publish() + return + end + local cmd = "set -- " .. specArgs(specs) .. "\n" .. backend.findPkgSh .. [[ + +while [ "$#" -ge 2 ]; do + if find_pkg "$1" "$2" >/dev/null; then c=ok; else c=miss; fi + ]] .. backend.reverseDepsCountSh .. [[ + + printf '%s|%s|%s\n' "$1" "$c" "$n" + shift 2 +done]] + noctalia.runAsync(cmd, function(result) + if result.timedOut then + return + end + local pkgs = {} + for line in (result.stdout or ""):gmatch("[^\n]+") do + local pkgName, cache, req = line:match("^(.-)|(%a+)|(%d+)$") + if pkgName ~= nil then + pkgs[pkgName] = { cache = cache == "ok", req = tonumber(req) or 0 } + end + end + probeResult = { at = run.at, pkgs = pkgs } + publish() + end, SIZE_TIMEOUT_MS) +end + +-- ── Backend self-checks ────────────────────────────────────────────────────── + +-- "The plugin tells you and fixes it": each backend can declare environment +-- checks {id, testCommand (echo ok/fail), hintKey, actionKey, tipKey, +-- fixCommand}. Failing ones become panel hint rows with a fix button; the +-- fix runs one user-confirmed escalation, then the check re-runs. Nothing +-- is ever fixed silently. +local function runSelfChecks() + if type(backend.selfChecks) ~= "table" then + return + end + for _, check in ipairs(backend.selfChecks) do + noctalia.runAsync(check.testCommand, function(result) + local ok = trim(result.stdout or "") == "ok" + local kept = {} + for _, entry in ipairs(selfCheckState or {}) do + if entry.id ~= check.id then + table.insert(kept, entry) + end + end + if not ok then + table.insert(kept, { + id = check.id, + hintKey = check.hintKey, + actionKey = check.actionKey, + tipKey = check.tipKey, + }) + end + selfCheckState = kept + publish() + end, FAST_TIMEOUT_MS) + end +end + +local function fixSelfCheck(payload) + local id = type(payload) == "table" and payload.id or payload + for _, check in ipairs(backend.selfChecks or {}) do + if check.id == id and check.fixCommand ~= nil then + noctalia.runAsync(check.fixCommand, function(_result) + runSelfChecks() + end, 120000) + return + end + end +end + +-- ── Requests, lifecycle ────────────────────────────────────────────────────── + +-- Persists in IGNORE_FILE and takes effect immediately by moving the +-- package out of the current snapshot; n is only decremented when the +-- package was actually listed (entries beyond MAX_LISTED have no name). +local function payloadPkg(payload) + return trim(tostring((type(payload) == "table" and payload.pkg or payload) or "")) +end + +local function addIgnore(payload) + local name = payloadPkg(payload) + if not validName(name) then + return + end + addIgnoreName(name) + for _, entry in ipairs({ sources.pacman, sources.aur, sources.flatpak }) do + for index, item in ipairs(entry.items or {}) do + if item.name == name then + table.remove(entry.items, index) + entry.n = math.max(0, (entry.n or 0) - 1) + table.insert(ignoredPending, { name = item.name, from = item.from, to = item.to, source = "plugin" }) + break + end + end + end + total = sources.pacman.n + sources.aur.n + sources.flatpak.n + if phase == "ready" and total == 0 then + phase = "clean" + end + publish() +end + +-- Only the panel-managed list is editable here; settings entries and +-- pacman.conf's IgnorePkg are managed where they live. +local function removeIgnore(payload) + local name = payloadPkg(payload) + if name == "" then + return + end + loadDynamicIgnore() + local found = false + for index, existing in ipairs(dynamicIgnore) do + if existing == name then + table.remove(dynamicIgnore, index) + found = true + break + end + end + if not found then + return + end + saveDynamicIgnore() + -- A recheck brings the package's pending update (if any) back. + if phase ~= "running" and phase ~= "checking" then + startCheck() + else + publish() + end +end + +local function handle(action, payload) + if action == "check" then + startCheck() + elseif action == "update" then + runUpdate() + elseif action == "update_terminal" then + runUpdateTerminal() + elseif action == "ignore" then + addIgnore(payload) + elseif action == "unignore" then + removeIgnore(payload) + elseif action == "rollback" then + startRollback(payload) + elseif action == "rollback_run" then + startRollbackRun(payload) + elseif action == "probe_run" then + probeRun(payload) + elseif action == "open_log" then + openLog() + elseif action == "polkit_install" then + installPolkitRule() + elseif action == "self_fix" then + fixSelfCheck(payload) + elseif action == "dismiss" then + sources.pacman = { n = 0, items = {} } + sources.aur = { n = 0, items = {}, helper = sources.aur.helper } + sources.flatpak = { n = 0, items = {} } + total = 0 + downloadSizeMiB = nil + runExit = nil + logTail = {} + phase = "clean" + publish() + elseif action == "open_news" then + openNews() + end +end + +noctalia.state.watch(REQUEST_KEY, function(value) + if type(value) ~= "table" then + return + end + local nonce = tonumber(value.nonce) or 0 + if nonce <= lastRequestNonce then + return + end + lastRequestNonce = nonce + handle(value.action, value) +end) + +-- Scriptable control: +-- noctalia msg plugin umedbazarov/linux-updater:service all check +-- noctalia msg plugin umedbazarov/linux-updater:service all update +-- noctalia msg plugin umedbazarov/linux-updater:service all update_terminal +-- noctalia msg plugin umedbazarov/linux-updater:service all dismiss +-- noctalia msg plugin umedbazarov/linux-updater:service all ignore:NAME +-- noctalia msg plugin umedbazarov/linux-updater:service all unignore:NAME +function onIpc(event, payload) + local action, arg = tostring(event):match("^(%w+):(.+)$") + if action ~= nil then + handle(action, arg) + else + handle(event, payload) + end +end + +function update() + if phase == "running" then + runPollTicks += 1 + if runPollTicks >= RUN_POLL_SECONDS then + runPollTicks = 0 + pollRunLog() + end + elseif phase ~= "checking" then + local hours = tonumber(cfg("auto_check_hours")) or 0 + if hours > 0 then + if phase == "idle" then + startupTicks += 1 + if startupTicks >= AUTO_CHECK_DELAY then + startCheck() + end + else + sinceCheck += 1 + if sinceCheck >= hours * 3600 then + startCheck() + end + end + end + end + + if cfg("check_arch_news") == true then + sinceNewsCheck += 1 + -- Fires once at startup (== AUTO_CHECK_DELAY), then every + -- NEWS_RECHECK_HOURS. Resetting back to AUTO_CHECK_DELAY (not 0) + -- keeps the "== AUTO_CHECK_DELAY" branch from re-firing every 10 + -- seconds. + if sinceNewsCheck == AUTO_CHECK_DELAY or sinceNewsCheck >= NEWS_RECHECK_HOURS * 3600 + AUTO_CHECK_DELAY then + if sinceNewsCheck > AUTO_CHECK_DELAY then + sinceNewsCheck = AUTO_CHECK_DELAY + end + checkNews() + end + end + + if newsDirty then + newsDirty = false + publish() + end +end + +noctalia.setUpdateInterval(1000) +if not noctalia.commandExists(backend.checkTool) then + phase = "missing" + errMsg = tr(backend.missingToolKey) +end +publish() +resumeRunIfActive() +checkPolkitRule() +runSelfChecks() diff --git a/linux-updater/thumbnail.webp b/linux-updater/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..e4f6a662b48b541e533a45acd9715c39fc085105 GIT binary patch literal 25400 zcmagFb8u$O`ZavVwr$&XGO=w zcdu)$b#-^`ts?#H8?z|@peZ4$q@l!3O#b)2!2l!|gtiY1A5@S9J6fESq!4+mCV&_W zyt&O6X!2>Vt}tomqGc4=3GxO~TYvS=^lF%7z|JPrC{+4o@l3XHnC64|DkQfnwz~=p zTqYXuU%9h8C3+)l?LF~7e&YJf{>1vscR;!Tt^p7H4S^h2!{^EGyI;LZLNop|{<}cu z>&dUfr^D^^XCft}-$Yyff4n99*MP&&rADv zsu#oc*^6D^m)s}V+v*F^4AGxnBcUN+IxzC<=H24+5cvFY(orJHRb}KcFhG_!A5Gx1jm6f4u_nKHT4!K6@{PfM1|M>aXCh z-go!cn-AbYFA(_r-U7V6I(+5=+IQw(hW`Zyj);*WJ+FP^I)bEb4^QzBgv z6c{f}8s?XB^~Cpm+tH|14Ef{gyiX!ve@SP4-@^}S%=)MDzxy%4GJsqQu`9CP{q4iv z@^}1NC05tGQkojLy#GD+vja$Lmkv#?9x)^@05NZMSh#4GnlOo#?e;U@&breDlMY9r z9rsVR5`5ulljhkS)vk=;?43SLtN58^@tpI2(2$s>`JZq6i$?7u3HT{4@f?klm%+de zy~ih0bi3itu(*=*t7p2_pZ^<0S7?V($je|g=Jm-_6L8N31w1Ml4awuEJZjSqcGrJ! zO*g$zJWY-#`~O8ior85u3d)hULXNw>;o!p?OC73}<@2j9_v)t$S0=YfKabSAo!ml)N?*?>?TUkruudZy?nv5<>$@ZbK9rF-STgcoK4aZ|R{UZjC31`~QWu3)x(wK+Gz?0AlSV2U`SL zcDux9fP%IaW)_QHZGs;;mSd#{WBD%%2jkn8y)P5Vef5=i6akA2SGi1n+S~UC9Bt{m zATRRw6mj2pi@3M8BStSbD6S$!jsY3J4@{?Bjf?-%u;}Lz7?wAVxID$bsTdl0xdhQY zoG%WRDqhAQK#+H$p(r^JNF^6>Wa;Rjjb`nQgDL<>zXdt+S=i=ixeqldDtYm@{KYZv4tm8J|tl9!!uLjfDb1 zaJ>Y?K%VdXzxIdBVYZS?zp0-9!VKNtLFyni@(Ar;RdqGKiXf4oxbzqzaR1F$6R9=Z z7b~lOX>S+}uDO1ym2m0mL?p>WlY>B#D}Kr^FW>vkY4|8i*4GMnQO+!*6`m)hw+$SD z8x&f*2B#OyV@MA`KKxKw78Sz1EkDfU5(!Xtw++4&eH@gP{YTE}6X%1H|CV=+H0}$Q zQ&p;J10I=&9;@j}ii7Nb) z+@*4zw#m%(qePWQzAMmw{t)T0?gG7nAE`YQ8SKI(aiHy7{Ri!CW#;ui3;!O9gRPs* zsmnKXy9e2>5VNb{P_cPkSg3#d)-}?}S2(a{-yuRD+{gO_Cfj}Mlx zoOJ%jhRSeXC|xxYcKSx(Wh_NEwk#-3z$Gu3R`JPu`LsQ%To&=3$!|qQHLB+}_kCut z{}tE_n|*yP%YWI5N+35)ynvo_&$PcxC4gi`4b-!pRB!ssOrX6(gH*#3BUMw&9$JAMbF!N5^6t*^q+p>WqX(Ry#7tG}FqQB^9ctH&4@cSUD z!f3>NQQBDFQTZb}9MU9ZEGpraHh!s@{yH~_&Ho2c)0GiMQ+~YHZ7fH{Ssu{NX+6Ir zcM96N5KI#eH2=Sp5WbF0>_V`1;^Zv(@hN8UFV=N-EbU#oTP-$>{vL+f-V2>UVVT2D zU`l!a-uy%BX#Zwz2`w!(2yuShhWO=i1_ElvU9C_BNqB&Km|oE8_=uJ4$P8QZCovt9 zu~C&Nr%Bex`@u(=?R-{n!LWF4i#8u+e07fANt4!S4w;tiL0?`xhtSUT;Qz{B9nkU5 zhHod?GXe(bcdtoH@BP)K*o@?DqqMGfsWCEP12fr{Ts+XsxT!RppUjwZ_V zfC_T&t7)DZgXNG#mbF9%Sa^JRZ-p>%hcEhf3gu6*#=7;$wViMg_dH zMnObn75||~DX1f2<+bLEr$-z~B+pdok8h1KZKfZ$fOQq7fPMlFSPd5)N5%{STwgt1J+54fH}7FrLS;v4*( z+aNJf+;ys<#VjNn`2WJUSV%;SYE0BS+BR&z+$(eyU2oRImV%yzYtktH=F_<3^l$2z zU-S0;7YkMrMzj8U4|5V9VsUjfe>Af=mvTo7#Od-&vxlYpaFCV@^^a6$c@!BVhA8I7 zm%!iA2)lIuJEKLC8UMANk^=3BkTx+cow^HMq+zCu_i--okPAh$Het7ePyq{nS7))T z2cO*)q^N&;#(ynrs$4GfTiPqyFfb2_w$yZ^qJQ+ORWS@jgIuEPb!oT%N0tA#r4KaV z3%EbinibTDwL?=<00028DqaDA>ui`?0gqO!FI*mJMYNg)kv>8=Qs@Xdg}dFl(@%Gy ziw>av?Ax{fs`FP@{^J(#|Ml={;8DKmMbLNcOBoM5u`wI~;=Nz0M628seVnTQer7;y zU`giiaCY%v*9$koSZ*(+Y-fN^vipWggIkS&0a^+pUQYH~-Qdtue&rl$l6N*>A7n;& zI*5>o4fLc`5QU=M)=XWWK~l><{QA{%8=^BKC(khy=Uc@qUFL?PK)slX?ZCSQeh$SO z&jZs1r`W}N*Pl2hUVKU;w!!1a?i_Ehd#G??LJ%IRANyV(x#c%sl)4x{4rHtyDM1OW zW#f~4s}LC`op!Gz4q~%>J&2I>b#fe&pLIGBWm`C`y3A!;_U0{;a!xb6K&bjcN zJoZ}D)WvlRb8#*Y<)V7sVp|xS(Z(uoKtVcd_6SuO7ix!0EPwots>iF|7{7@+Igq>7 z^~96L%iy?PnTX&a>+e0rCPnJO?tgwP>!jzVe$X-3$6k@oc4|NsmhD(W46diZ9m6`- z7n5QzDb{fn2w=y>7wA z=O&yQ9Q37EWcn!SzDzb~9QfsSEqiW*ifMvyjjpXi#?uhpi-+xiK7cCz7S~xwaOV+S z`RR>IdOVWm&##fx%s8yLyqSoSxegDur%uhm!;*g?;HK{c)Jx+zd2S94nI;99? zpf{s^Bl#cM(0m}@$#pd$P9P#&*_6eIY58Q;lynhb&k?~ZE`k*AV5R(U_Iqpeb*_Ak zZ)ecV&ZL6eKx6v#DS|-o1m!K($gE9g%8s}B&blY(Sp=TmE0L*hRF+`{*M z*uIPkzK3*Ig>7LqhlAgZ+a0sUX@f?LcLee+RhjkdjbT2)Go~{ zj&HOR_`Jx2BY)sO|V%6b`Vq4K#(lQJ_aSLXcBPfV^x(3Y)wc?z)0e$m!8Gy)?Y;pFY~T zDGad0zhB-LM5;YoJ5nyl>9dgE@0IZen?g57#zTohPJ2Z6!2-6M%0w^m_Rj*?C!ZuiFIDf4(v= zHrQgFUO#%S7XFh~$lkdj^vlDzfQ{%DF4P_ED@{vH&r-9~Cd3%ZQdh(Z^WwL^d63>P z5K$4&fB07Nb#)c+UgW!1Fo=PSMmQ!4Rhv@@VGJ1>QON}W-5+@U$|(SV%@vRw0W{bS zIs^XNB-rQfGD)-N1gugXI;IbR;0W+*(_xhdEQ#3q8aa7%`!Me1W?v~{)nn*fylQct%S-gpb$p*%QP@(!TB^}*+53{7}^u1WkPf0TpRMwHPd}iH_ci4$^N(4xY^tsi8I~h^CZ>QlFnZx;D`2g zARo#_K?<$*jAF@sF^fS~dM^yLR4^wHwW3;O>Nd+?Zg_LK;185=v8Rkv60tmNq zcoQ`~Uo1N_SI4tg1-3OF7;Y7_FDp4WxWEO_w|lq=NGtA!FOsi!d0l8G6c_vK;VNx@~nn2UHxzuzc<&y6}P6V-|u<$ zk^qsu0Fw3#LPA+`RVHUGIM=S^uve+2q+^{_&| zFpLe8H}1%0jrV2BWBfg_%l1wgMi#VuZb;dlqKdIG)n6O*hFktk*MG)IQ@y;s)hQr+ zDFE8-X^~^V`cq0M2HQ|Jd>c=*`Ka1Hc!R{|&dXiHF&*kQSkMR4o8s6|Q06sT0TNm{ z@bj1wLzJ&45sWqIVp~asd5c9VO%w|ZO<9!AV5}$%b?)3Yp|;`cC;#@1M#0kf$yftN zz;V;4acb3OEdDRF0tAaS&RIrNjia_>FrG6e9$r@S800-W2D_O-21U+L6%L!1ZwFPLfym&EF z1HCZnapx-79dLG6DZ6MhTIl+aEMsn1mIIu)q&kuVTO001eO!Mr{R*fR`Shhr5hycZ zHB$Q@!be(-jBQU9Q|D0-J+fy~r(L>IR>>~w7+Bog>$=+*9NrI38KbAr8NbwNMPQZh z_@B9As%y=NUa z7e~F&4afy;Q{F*V3Ug29BRdM_us#)b`9x2P)EvJ4x3^i#MGKn9{_Sy-Cz*hrv9}#D)x_eXo}LkI!|R^2X?v!M zt;pqi-q{Rf5og(k`>*_rx)wbvB+88F#`;ucP@}Y)Xjt?=%kX|Hib=yQI91*JH!xPy zA+;wewhSZZv%2e$q~b5pOPZPAgnNajqVrdFGclFT(Gnci!WguEbW1ana4o%sqfL8a zEo-H3-SAJm*zBB+Zi$}!Lc?F3$+=gOLyp12MBfD;14AaO^6utlybX(|f8s7UAm0}9 zNIX2m&5^m7)gLFJwi}zUt1!`y)Xj*Ms;eOt>uiC}9ae#Nj+|FfWur>qf~yn^|UpRPAQ5o_r*(rGb- zfvR3z^H^G~GjLqaEQ4JbX+jZ`#*x&NcubTiaze1AeOxXn7tanQrLdLOX4tX#Kj`ZVAJCtPJrYE$%@nI(5mm1i#m? zp0Q-qmv@E9n`}#7DIw3-64XPg^Af zcljAw_OG;w1Dk#&!BDoU86S{1TGvxW_|zfu<$mEjm0*wFCluD81=jsy-x)12%%dL?bhvyXFl(pf+a>f+DWw7S@?9>lKs=4QQc~F z;u+{Hc5Mz;$i^zKPI1TdwR&uKlV~71h3U4)Gp=SiBjVjO!~kN|=0377`f1Atuea`g zE@zi`&~wug+FEA8ePUucppkLu!C!_|dF+FdR3jBEaU41@SVI?C4ga8KDV+*u&DW$w z6@bj@+=Fr=Fm>xvN(JgSytuh#Yh4uzmy8Vi6=vb0gM59(Lj6!@HH@uEYp@~4?$_B~ z7)wtc*5qE}H$U~6ca_>PTWi<4su)R@8V-1fCggpk)`x-RK1S# z3u73=(tj$>P1m7*fF8y1eJW{bHz~8uV0FOSEMA=x z2rg%&hehZrY-me>Nqx$cp|{Am9j^GN0GXYr)V)hmx=EE~h--$#)8!6$kBGmdjxs&E z`Ps*<$tmes<9Z{R(>3MN1K!U15iZ`JjE&7Pc`7HE@Lmzf#g^4`>~atjX;LgqIU0r; zmPuwJ==pG0oK%y_`l+j%Gek|}kn8EyHB-ODpDh<5AjE%n-X_D3kJsooh~*N|xGsi0 zyARfRJmMu1Skj>6Ca?E&*t^Oal88#$GIC`WqP1Azx%Iq}0hrZb;VI zo_@JxhKCfb@_e`a1Bj+Q;Q%1W5t1ZkU1-&PSU!IPWJhHgNe#HzrXt@xT8}D89cVL) zKQ}~%);XmbvV*}x#6g0Q0?F_VO-@GfQqfe~QUJt#Wpf&`)Fx#1c;8h{_kjH|-@? zR-iS~+M*umVu?1Qs{?%>q&|ig)3y^DliT^X{hOl?1m|?bOq^efTRwKTf}5cs?pBJ` zVE{VScT|2m?^u`=rke8!VcpJa!+7mk8@P!MbEO93>%GdLVuhr;V$bO=aYr;W=9HN< zZOaGGBN2$LiSMj4>!JBC71kZB%I`JI5V6 z`b0l7Jn@}O7bN=9dA%$au2uzTMXcW=FKUs{h;%1)KKG1})mPc4+c~=l zYPrOVha{wT4c-_8bl3^g2X9}=)0=y7dyDNuxB{EqHlC~R=?yUyWLimzEN+xORyE~l z9*xL}A2jBAc6mLqKBsnxGaGaB8tZ(Z6x3{!6YllWm~Bi^qmteI&}_l=G+0Pb8g&2@ z^?CljltH?4IQ6Ej*&B%!>O7-4hERsS1~2drfbCz)>xOXS#tE@Te+*;CZzo9y`Ij>w!qq2Hs&%4wQEz$HvZAH2Fcw!08avx=wYQ%He}W)& z$!_-P#{1mQ_MH>tptPA-*|FBgIpKc9k0^XQfr-rXI%d}_fd^J3!!TDQ7Wd0Hl^PKl zwTnYY^X$r;nlpS+@T2p{{26scLFS+p;O;|cqJ=DNz^IJyb;3Lx1RX^7wKBhvH!+p{ z?p(E5!sFe6`QF@5e%~QMCldu_p?%8q?kNrVTwdJSzrq`sC3Ugr1J)R)KHxRArBGaZ zICyfm(`dHp7^#po6a+7iN=R``#N!ulWKxt6xfD;-ey;>+iAh7-JvnXbzoOXco%VI5 zE1>&y`{iIl`(){^Xf7(d7(!6*`S@wN7-vfNcfVavh1?czqAi`_48|@$-jrR(Dv0=G zvBg5JsVfFln&=J5Z1h?s+m}2b{f>x$=_W&ElExsV|2p>b8n*EF3ib@1c@2N(7W)d0 zd4{^QQN~sRQ7c6Qkr{H=<}YTjw{n3JKe$D8MRr(_X&H~M!Ix-Ls^P-o#W)d{awPnn4<6L+x@J3kk0U13UL~AR;9l`I9%Qyn9>MN1paJID!-kAvgo07G7Cz$2qw)E zDnl}x9IBq-r{m81?!@x6)%iqan_&!>Wak%Ax@zLpLi=vW@WUVF#|>0TLyJ8-P^?8k zRSs8jK!R+u5D~29`O0|1Cz-Wheacayz4EC~@)Rcyf5 zeNLEVgciI171?F(xa@gML^>;dmJklw)m0m<01lbT4+U~~W!y!@hBI@WI4lTq_yoHl z40ja&#kn@Tro6QEFm{4+N_*el+Li*!UhfNjItc5fJc&6Bl&|{$un7DvHih3{NHRG+ zw=s)EVFnpCqW1a3#^HB=4-ynpZ=4ihS*Oqbn1 z$?a+;9oh^eQ^XH`ExJoAm)hz;)O?xIK38*#*cb@tT!?4JaCmhMfp0m*AA^Jwj2+zX zj-tsD?1z{4e_%OW2Eg*bB91u_5J_9p2Zt!crDNJlEtZ7~mmO(Wb21hg(WBRjVMvXUl?qq7QZ zIEuw(iCS{M@2S2Mo8Wm+Da}$Ts5#4)E}^cK`f(sq`DP6V)eop zqT|v9n^OL*3dwx8S&44M5yo;Vu6GLSTCtjq>$${86zk*|&gFU$siFqP=Q#nl&*aDr zwV{zNVHYU>in%sv8XV-6`kBKE2>&j3D{nX>qYgou|t4Cz>WUFl( zieF+%gf{`_N!ww7=xd@=Mx_J~Nw`<-JQS4iwsJoN(*Rpr^q_>y&3J5bU>sxzZJdIC zH*d&Eiky4iz@`$lpJ1#kcK~V|D#pBes)u+HOc;+{-eU{HNLLo6kgMK3C1ufs+D(er z4J&r%a4s8znu|)uAnQ5{gy%12Q1N|?E6N1+WDO$*0>}3;ErG?#5ofsr2YO`5bHv{s zUU~|O4^6j6#OhveogZ;el9~(rJ_V_+WfV$JCr8|)@y}E!G50HS?k?wNb< zl=l7pW)fWT+k@Y`C3T(~^ylNX4~>mXrauQ_y9!zu4LKngEsX>CdoRV%qX-Q{(CPQS z^M2`3@q_Z=D^p1U1BUQc3`lf(zi0}bCt|}~eh7X(HP0*j#KKi%CIzHM^d&KKEh*1D zys+o-@PC!5OFh9MC>?RXF(R3(B)>#htHm{qt-RxwhLmV$d)X6xw@AL+SjFsOqOO+D zb+ErRdzFvF@UD50c!X_BnBM6F&--TV@L6|g9#AqC|2keat$MBHxh%T;Ol{ySBuB*0 z^LJ*yW>>VLJ?;`skk^&5bS~m-1Af#PK~cOpOn+0oZh0fhyZ~O;rv|as_nLXQo7Zoa z!{|tZWeh3SO(9QJPm|g_DxVp1%R9u$ycruK(`%NG$#x?i=3$gXpp9-xXs+_yA zt|B^uQ<7rtDt>63={J4#?e{;mODu3(pGHiaQiD?_vo_$zH_x!GE%(2~JL~51Ne?*9 z5-sKgUn=r~HJmpb2m9GDoy@_?ghOh+gT#tU+OYwkDH(!G69le^kfa~$RXzx)rxxRZ z4wIVn^GyxHk0B=oyeea9nLHC2&JPGfZ!t{T-QaO52 z4jweyeSt^am-tG;7<2vxSf-f%rDZBeogJdfFppPMS=rJ{nX^x9cACJP%Nw_JVU&aW z!P=pa1m{K}z&TLZeWv3IpeyTGZEDAzO2aoym9x(;&xFjMMVxFjHW}BY6H7 zmdu;*&WEke`h>U)-ZL(~r|$%NS{gO)3sz&rLpB0kWV|%G!=0t`%v{=Vi4Q8DZuHpq zhH3Y0Lxm=;m(_5tr=$w a^^ox>UMu<`0ctT{aC+VZ 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 literal 0 HcmV?d00001 diff --git a/linux-updater/translations/en.json b/linux-updater/translations/en.json new file mode 100644 index 00000000..1bf3d081 --- /dev/null +++ b/linux-updater/translations/en.json @@ -0,0 +1,221 @@ +{ + "action_back": "Back", + "action_check": "Check Updates", + "action_dismiss": "Dismiss", + "action_open_log": "Open full log", + "action_open_news": "Open news", + "action_polkit_install": "Ask once", + "action_run_terminal": "Retry in terminal", + "action_update": "Update", + "caption_checked": "checked {time}", + "caption_ignored": { + "one": "1 package ignored", + "other": "{count} packages ignored" + }, + "err_aur_failed": "AUR check failed, see the system log for details", + "err_check_failed": "The update check failed, see the system log for details", + "err_check_timeout": "Timed out while checking for updates", + "err_flatpak_failed": "Flatpak check failed, see the system log for details", + "err_no_apt": "apt-get not found, this backend needs the Debian package manager", + "err_no_checkupdates": "checkupdates not found, install pacman-contrib and check your PATH", + "err_no_dnf": "dnf not found, this backend needs the Fedora package manager", + "err_no_pkcon": "pkcon not found, install PackageKit to check and update on this distribution", + "err_no_pkexec": "pkexec not found, install polkit to update in the background", + "err_no_terminal": "No terminal emulator found, set one in the plugin settings", + "err_no_xbps": "xbps-install not found, this backend needs the Void package manager", + "err_no_xdg_open": "xdg-open not found, cannot open the package page", + "err_no_zypper": "zypper not found, this backend needs the openSUSE package manager", + "err_polkit_install": "Could not install the polkit rule, see the system log for details", + "err_run_failed": "Update failed (exit {code}), check the log or retry in a terminal", + "err_spawn": "Could not run the update check", + "history_rollback_tag": "rollback", + "history_title": "Update history", + "hover_hint": "Hover a package for its full versions", + "ignored_tag_hold": "apt hold", + "ignored_tag_lock": "zypper lock", + "ignored_tag_settings": "settings", + "ignored_tag_system": "pacman.conf", + "ignored_tip_hold": "Held with apt-mark; run 'sudo apt-mark unhold ' to lift it", + "ignored_tip_lock": "Locked in zypper; run 'sudo zypper removelock ' to lift it", + "ignored_tip_settings": "Ignored via the plugin settings, remove it there. Click to open them.", + "ignored_tip_system": "Ignored by IgnorePkg in /etc/pacman.conf, edit that file (as root) to lift it", + "ignored_title": "Ignored", + "launcher": { + "check_subtitle": "Check for pending updates", + "news_subtitle": "Open the distribution news page", + "update_subtitle": "Update in the background (polkit password prompt)" + }, + "log_title": "Update log", + "log_waiting": "Waiting for the polkit password prompt and the first output…", + "more_packages": "+{count} more", + "news_unread": { + "one": "1 unread Arch news post: \"{title}\"", + "other": "{count} unread Arch news posts, latest: \"{title}\"" + }, + "notify_polkit_ok": "Polkit rule installed: one password per update run from now on", + "notify_rollback_ok": "Rollback finished successfully", + "notify_run_ok": "Update finished successfully", + "notify_updates": { + "one": "1 package to upgrade", + "other": "{count} packages to upgrade" + }, + "polkit_hint": "Polkit asks for a password several times per update", + "reboot_recommended": "Reboot recommended, the running kernel is no longer installed", + "rollback_confirm": "Sure?", + "rollback_missing": "The old version is no longer in the package cache", + "rollback_required_by": { + "one": "Careful: 1 installed package requires it", + "other": "Careful: {count} installed packages require it" + }, + "run": { + "press_key": "Press any key to close" + }, + "run_packages": { + "one": "1 package", + "other": "{count} packages" + }, + "selfcheck_apt_timers": "The apt timers that keep package lists fresh are disabled", + "selfcheck_apt_timers_tip": "Enable apt-daily.timer and apt-daily-upgrade.timer (one password prompt). Without them the unprivileged update check reads stale lists.", + "selfcheck_fix": "Fix", + "selfcheck_generic": "The system needs a one-time setup step", + "settings": { + "aur_check_cmd": { + "description": "Only used when the AUR helper above is 'Custom command'. Must print one 'name oldver -> newver' line per package, like 'yay -Qua' does.", + "label": "Custom AUR check command" + }, + "aur_helper": { + "description": "Which AUR helper checks and upgrades AUR packages. 'Auto-detect' tries yay, then paru. 'Custom command' lets you supply your own check command below.", + "label": "AUR helper", + "options": { + "auto": "Auto-detect (yay, then paru)", + "custom": "Custom command", + "off": "Off, pacman only", + "paru": "paru", + "yay": "yay" + } + }, + "auto_check_hours": { + "description": "Check for updates automatically every N hours. 0 (default) never checks on its own.", + "label": "Auto-check interval (hours)" + }, + "backend": { + "description": "Which package-manager backend drives checks and updates. Auto detects the distribution from /etc/os-release: Arch and derivatives get the native pacman backend, everything else falls back to PackageKit.", + "label": "Backend", + "options": { + "apt": "apt (Debian family)", + "auto": "Auto-detect (recommended)", + "dnf": "dnf (Fedora family)", + "packagekit": "PackageKit (generic)", + "pacman": "pacman (Arch family)", + "xbps": "xbps (Void)", + "zypper": "zypper (openSUSE)" + } + }, + "check_arch_news": { + "description": "Fetch the Arch Linux news feed and flag unread posts in the panel (pacman backend only).", + "label": "Check Arch Linux news" + }, + "check_reboot_needed": { + "description": "Detect whether the currently running kernel's files are still on disk. When they are gone, a newer kernel was installed and a reboot is what switches you to it.", + "label": "Check if a reboot is needed" + }, + "flatpak_enabled": { + "description": "Also check (and, on Update, run) 'flatpak update'. Ignored automatically when flatpak is not installed.", + "label": "Include Flatpak" + }, + "glyph": { + "description": "The glyph shown for the widget on the bar.", + "label": "Bar glyph" + }, + "hide_on_empty": { + "description": "Hide the widget entirely when there are no pending updates and no reboot recommendation. Off (default) always shows the glyph.", + "label": "Hide when there is nothing to show" + }, + "hide_polkit_hint": { + "description": "Hide the panel line offering to install the polkit keep-authorization rule (one password per update run).", + "label": "Hide the polkit rule suggestion" + }, + "hide_setup_hints": { + "description": "Hide the panel lines where the plugin offers to fix a missing piece of system setup (timers, services).", + "label": "Hide setup suggestions" + }, + "ignore_packages": { + "description": "Package names to leave out of the count and skip during updates, in addition to the system's own ignore/hold mechanism.", + "label": "Ignore packages" + }, + "log_lines": { + "description": "How many of the latest update-log lines the panel shows while an update is running.", + "label": "Log lines in the panel" + }, + "notify_on_updates": { + "description": "Send a desktop notification when a check finds packages to upgrade.", + "label": "Notify when updates are found" + }, + "rollback_auto_ignore": { + "description": "After a successful rollback, add the rolled-back packages to the plugin's ignore list so the next check does not immediately offer them again.", + "label": "Ignore packages after a rollback" + }, + "show_count": { + "description": "Show the number of pending updates next to the bar glyph.", + "label": "Show the update count" + }, + "show_download_size": { + "description": "Estimate the total download size for pending updates where the backend supports it. AUR and Flatpak sizes are not included.", + "label": "Show download size" + }, + "terminal": { + "description": "Terminal command used only for the 'Retry in terminal' fallback, e.g. kitty or ghostty. Empty (default) uses Noctalia's terminal detection ($TERMINAL, then the common emulators).", + "label": "Terminal (fallback)" + }, + "update_cmd": { + "description": "Full override for the background update command. Empty (default) builds it from the backend and the settings above.", + "label": "Custom update command" + } + }, + "size_gib": "≈ {value} GiB to download", + "size_mib": "≈ {value} MiB to download", + "source": { + "aur": "AUR", + "aur_named": "AUR ({helper})", + "flatpak": "Flatpak", + "pacman": "Pacman", + "system": "System" + }, + "status_checking": "Checking for updates…", + "status_checking_step": "Checking {step}…", + "status_clean": "System is up to date", + "status_error": "Update check failed", + "status_idle": "Not checked yet", + "status_missing": "Package manager tooling not found", + "status_ready": { + "one": "1 package to upgrade", + "other": "{count} packages to upgrade" + }, + "status_rolling_back": "Rolling back in the background…", + "status_running": "Updating in the background…", + "tip_check": "Check for updates now", + "tip_close": "Close", + "tip_copy": "Copy name and versions", + "tip_history_segment": "Click to see this run's packages", + "tip_ignore": "Ignore this package: hide it from the count and skip it on update", + "tip_open_page": "Open package page", + "tip_polkit_install": "Install a polkit rule (asks for your password once): afterwards one password covers a whole update run, kept for ~5 minutes like sudo", + "tip_rollback": "Roll back to {version}, together with its dependencies from this run. pacman refuses the whole transaction if it would break other packages.", + "tip_rollback_run": "Downgrade every package of this run in one transaction", + "tip_run_terminal": "Open a terminal window where prompts work normally", + "tip_unignore": "Stop ignoring this package", + "tip_update": "Update in the background: polkit asks for your password, everything else is automatic", + "title": "Linux Updater", + "tooltip_checked": "Checked", + "tooltip_hints": "click: panel · right: check now", + "tooltip_news": "Arch news", + "tooltip_news_value": { + "one": "1 unread", + "other": "{count} unread" + }, + "tooltip_pending": "Pending", + "tooltip_reboot_key": "Reboot", + "tooltip_reboot_value": "recommended", + "tooltip_status": "Status", + "up_to_date": "Up to date: {sources}" +} diff --git a/linux-updater/widget.luau b/linux-updater/widget.luau new file mode 100644 index 00000000..2f9f77f7 --- /dev/null +++ b/linux-updater/widget.luau @@ -0,0 +1,159 @@ +--!nonstrict +-- linux-updater bar widget: pending-update badge and panel toggle. +-- +-- Pure renderer over the "linup_state" the engine (service.luau) publishes. +-- Every bar showing the widget agrees on the count without running any +-- command itself. Actions go back as "linup_request" entries, so one engine +-- owns the checks and the update run no matter how many widgets exist. +-- +-- Click mapping: +-- Left click: open/close the panel +-- Right click: check for updates now +-- +-- No middle-click handler: the host already binds it to the widget's own +-- settings, so a callback here would be dead code. + +local PANEL_ID = "umedbazarov/linux-updater:panel" +local REQUEST_KEY = "linup_request" +local STATE_KEY = "linup_state" + +local snapshot = nil + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function request(action) + local prev = noctalia.state.get(REQUEST_KEY) + local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1 + noctalia.state.set(REQUEST_KEY, { nonce = nonce, action = action }) +end + +local function pending() + return snapshot ~= nil + and snapshot.phase == "ready" + and (snapshot.total or 0) > 0 +end + +-- "Pacman 12 · AUR (yay) 3 · Flatpak 1": non-zero sources only. +local function breakdown() + if snapshot == nil then + return "" + end + local parts = {} + local pacman = snapshot.pacman + if type(pacman) == "table" and (pacman.n or 0) > 0 then + local labelKey = type(snapshot.nativeLabel) == "string" and snapshot.nativeLabel or "source.pacman" + table.insert(parts, tr(labelKey) .. " " .. pacman.n) + end + local aur = snapshot.aur + if type(aur) == "table" and (aur.n or 0) > 0 then + local label = (aur.helper ~= nil and aur.helper ~= "") and tr("source.aur_named", { helper = aur.helper }) or tr("source.aur") + table.insert(parts, label .. " " .. aur.n) + end + local flatpak = snapshot.flatpak + if type(flatpak) == "table" and (flatpak.n or 0) > 0 then + table.insert(parts, tr("source.flatpak") .. " " .. flatpak.n) + end + return table.concat(parts, " · ") +end + +local function statusLabel() + if snapshot == nil then + return tr("status_idle") + end + local phase = snapshot.phase + if phase == "missing" then + return snapshot.err or tr("status_missing") + elseif phase == "checking" then + local current = snapshot.step + if current ~= nil and current ~= "" then + return tr("status_checking_step", { step = current }) + end + return tr("status_checking") + elseif phase == "running" then + return tr("status_running") + elseif phase == "error" then + return snapshot.err or tr("status_error") + elseif phase == "clean" then + return tr("status_clean") + elseif phase == "ready" then + return noctalia.trp("status_ready", snapshot.total or 0, {}) + end + return tr("status_idle") +end + +local function render() + barWidget.setGlyph(noctalia.getConfig("glyph")) + + local phase = snapshot ~= nil and snapshot.phase or "idle" + local reboot = snapshot ~= nil and snapshot.rebootRecommended == true + if phase == "missing" or phase == "error" then + barWidget.setGlyphColor("error") + elseif phase == "checking" or phase == "running" then + barWidget.setGlyphColor("secondary") + elseif reboot then + barWidget.setGlyphColor("warning") + elseif pending() then + barWidget.setGlyphColor("primary") + else + barWidget.setGlyphColor("on_surface") + end + + if phase == "running" and snapshot ~= nil and type(snapshot.progress) == "table" + and (tonumber(snapshot.progress.total) or 0) > 0 then + -- Live progress replaces the count while the background run is going. + local done = tonumber(snapshot.progress.done) or 0 + local expect = tonumber(snapshot.progress.total) or 1 + barWidget.setText(tostring(math.min(math.floor(done / expect * 100), 100)) .. "%") + elseif pending() and noctalia.getConfig("show_count") == true then + barWidget.setText(tostring(snapshot.total)) + else + barWidget.setText("") + end + + local empty = snapshot == nil or (snapshot.total or 0) == 0 + barWidget.setVisible(not (noctalia.getConfig("hide_on_empty") == true and empty and not reboot)) + + local rows = { { key = tr("tooltip_status"), value = statusLabel() } } + local detail = breakdown() + if detail ~= "" then + table.insert(rows, { key = tr("tooltip_pending"), value = detail }) + end + if reboot then + table.insert(rows, { key = tr("tooltip_reboot_key"), value = tr("tooltip_reboot_value") }) + end + if snapshot ~= nil and (snapshot.newsUnread or 0) > 0 then + table.insert(rows, { key = tr("tooltip_news"), value = noctalia.trp("tooltip_news_value", snapshot.newsUnread, {}) }) + end + if snapshot ~= nil and snapshot.checkedAt ~= nil and snapshot.checkedAt ~= "" then + table.insert(rows, { key = tr("tooltip_checked"), value = snapshot.checkedAt }) + end + table.insert(rows, { key = "", value = tr("tooltip_hints") }) + barWidget.setTooltip(rows) +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +-- Periodic re-render keeps the glyph/visibility in sync with widget-setting +-- edits, which do not move the engine's state. +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + request("check") +end + +noctalia.setUpdateInterval(1000) +snapshot = noctalia.state.get(STATE_KEY) +render() From 3947b17f382b8fa9f94e417ec209813548694c0e Mon Sep 17 00:00:00 2001 From: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:29:04 +0300 Subject: [PATCH 2/5] linux-updater: address review; update mode, activity graph, fixes Review items: - Gate apt/zypper/xbps upgrades on successful refresh and hold/lock setup (&&, fail closed) while keeping the cleanup unconditional; regression-tested in tests/run.sh. - Declare every spawned external command in plugin.toml dependencies and document them under README Requirements. - Ship the parser fixtures (fixtures/) and the test harness (tests/run.sh) the Testing status section refers to, and state their CI status precisely. Also, ported from the arch-updater plugin: - update_mode setting: background (default) or terminal-window updates sharing the same log/progress/history; terminal runs are exempt from the stale-log timeout, and their history entries are verified against installed versions so declined packages are not recorded. - Opt-in activity graph of pending-update counts across recent checks. - Fixes: startup race between run-resume and the auto-check, version constraints stripped from rollback dependency lists, flatpak ignore filtering and progress counting in both modes, no terminal retry offered after a failed rollback, polkit hint hidden in terminal mode. Co-Authored-By: Claude Fable 5 --- linux-updater/README.md | 79 +++-- linux-updater/backends/apt.luau | 19 +- linux-updater/backends/dnf.luau | 7 + linux-updater/backends/pacman.luau | 9 + linux-updater/backends/xbps.luau | 7 +- linux-updater/backends/zypper.luau | 12 +- .../fixtures/apt/ubuntu2404-backend-check.txt | 6 + .../apt/ubuntu2404-list-upgradable.txt | 3 + .../fixtures/apt/ubuntu2404-simulate.txt | 3 + .../apt/ubuntu2404-upgrade-output.txt | 24 ++ .../fixtures/dnf/fedora41-backend-check.txt | 5 + .../fixtures/dnf/fedora41-check-update.txt | 5 + .../fixtures/dnf/fedora41-history-list.txt | 5 + .../dnf/fedora41-installed-versions.txt | 2 + .../fixtures/dnf/fedora41-upgrade-output.txt | 42 +++ .../packagekit/fedora41-get-updates-plain.txt | 11 + .../packagekit/fedora41-get-updates.txt | 13 + .../packagekit/fedora41-update-download.txt | 26 ++ .../packagekit/ubuntu2404-get-updates.txt | 14 + .../packagekit/ubuntu2404-update-download.txt | 30 ++ linux-updater/fixtures/xbps/void-check.txt | 1 + .../fixtures/xbps/void-upgrade-output.txt | 34 ++ .../fixtures/zypper/leap156-list-updates.txt | 3 + .../fixtures/zypper/leap156-up-output.txt | 9 + .../fixtures/zypper/tw-list-updates.txt | 3 + linux-updater/panel.luau | 232 +++++++++++++- linux-updater/plugin.toml | 32 +- linux-updater/service.luau | 294 +++++++++++++++--- linux-updater/tests/run.sh | 175 +++++++++++ linux-updater/translations/en.json | 53 +++- 30 files changed, 1060 insertions(+), 98 deletions(-) create mode 100644 linux-updater/fixtures/apt/ubuntu2404-backend-check.txt create mode 100644 linux-updater/fixtures/apt/ubuntu2404-list-upgradable.txt create mode 100644 linux-updater/fixtures/apt/ubuntu2404-simulate.txt create mode 100644 linux-updater/fixtures/apt/ubuntu2404-upgrade-output.txt create mode 100644 linux-updater/fixtures/dnf/fedora41-backend-check.txt create mode 100644 linux-updater/fixtures/dnf/fedora41-check-update.txt create mode 100644 linux-updater/fixtures/dnf/fedora41-history-list.txt create mode 100644 linux-updater/fixtures/dnf/fedora41-installed-versions.txt create mode 100644 linux-updater/fixtures/dnf/fedora41-upgrade-output.txt create mode 100644 linux-updater/fixtures/packagekit/fedora41-get-updates-plain.txt create mode 100644 linux-updater/fixtures/packagekit/fedora41-get-updates.txt create mode 100644 linux-updater/fixtures/packagekit/fedora41-update-download.txt create mode 100644 linux-updater/fixtures/packagekit/ubuntu2404-get-updates.txt create mode 100644 linux-updater/fixtures/packagekit/ubuntu2404-update-download.txt create mode 100644 linux-updater/fixtures/xbps/void-check.txt create mode 100644 linux-updater/fixtures/xbps/void-upgrade-output.txt create mode 100644 linux-updater/fixtures/zypper/leap156-list-updates.txt create mode 100644 linux-updater/fixtures/zypper/leap156-up-output.txt create mode 100644 linux-updater/fixtures/zypper/tw-list-updates.txt create mode 100755 linux-updater/tests/run.sh diff --git a/linux-updater/README.md b/linux-updater/README.md index 9416f22d..7fc840ac 100644 --- a/linux-updater/README.md +++ b/linux-updater/README.md @@ -12,11 +12,16 @@ automatically from `/etc/os-release`. ## Features -- **Background updates.** The run is spawned detached (it survives a shell - restart), fully non-interactive, logged to a file the panel tails live - with a progress bar; the bar widget shows a percentage. On success: - notification and an automatic re-check. A failed run keeps its log on - screen and offers an interactive **Retry in terminal** fallback. +- **Background or terminal updates.** By default the run is spawned + detached (it survives a shell restart), fully non-interactive, logged to + a file the panel tails live with a progress bar; the bar widget shows a + percentage. On success: notification and an automatic re-check. A failed + run keeps its log on screen and offers an interactive **Retry in + terminal** fallback. The `update_mode` setting can instead open every + update in a terminal window, where prompts work as usual — the log, the + progress bar and the history keep working (the terminal output is tee'd + into the same log, and the history entry is verified against the + installed versions, so declined packages are not recorded). - **Update history with rollback.** Every finished run becomes a segment on the history strip (hover for date and size, click for the package list). On Arch, single packages or whole runs roll back from the package cache — @@ -39,7 +44,8 @@ automatically from `/etc/os-release`. - **Extras.** Download-size estimate and Arch news (pacman backend), AUR via paru/yay, Flatpak on every backend, reboot recommendation with the best available method per distribution, desktop notifications, launcher - quick actions (`/up`), full log in a terminal pager. + quick actions (`/up`), full log in a terminal pager, an opt-in activity + graph of pending-update counts across recent checks. ## Plugin @@ -72,17 +78,28 @@ instead); Gentoo has no backend yet — the backend interface in ## Requirements -- The distribution's own package manager, on `PATH`: `pacman` + - `pacman-contrib` (Arch family), `dnf` (Fedora), `apt-get` - (Debian family), `zypper` (openSUSE), `xbps-install` (Void), or `pkcon` - (PackageKit) as the generic fallback. Only the one matching your - distribution is needed; the panel says what is missing. +The `dependencies` list in `plugin.toml` names every external command the +plugin can spawn across all backends; only the subset below matters on any +one system. + +- The distribution's own package-manager tooling, on `PATH` — one family + is enough, and the panel says what is missing: + - Arch family: `pacman`, `checkupdates` + `pactree` (both from + `pacman-contrib`); + - Fedora family: `dnf`, `rpm`; + - Debian family: `apt`, `apt-get`, `apt-mark`, `dpkg-query`, and + `systemctl` for the apt-timers self-check; + - openSUSE: `zypper`, `rpm`; + - Void: `xbps-install`, `xbps-pkgdb`; + - anything else: `pkcon` (PackageKit). - `pkexec` (polkit) with an authentication agent — Noctalia's built-in agent works out of the box. Not needed for the PackageKit backend, which uses its own polkit policies. -- `sh`, `awk`, `sed`, `tail`, `test`, `uname` — base tools on any install. -- Optional: `paru`/`yay` (AUR, Arch family), `flatpak`, `xdg-open`, - `sudo` + a terminal emulator for the **Retry in terminal** fallback. +- POSIX base tools, present on any install: `sh`, `awk`, `date`, `grep`, + `head`, `install`, `rm`, `sed`, `tail`, `tee`, `test`, `uname`, `wc`. +- Optional: `paru`/`yay` (AUR, Arch family), `flatpak`, `xdg-open` (open + package pages), `less` (full-log pager), `sudo` + a terminal emulator + for terminal-mode updates and the **Retry in terminal** fallback. ## Usage @@ -96,12 +113,13 @@ noctalia msg panel-toggle umedbazarov/linux-updater:panel The panel lists pending packages by source (system manager, AUR, Flatpak). Each package row has an ignore button, a copy button and an open button. -**Update** starts the background run: pkexec raises the polkit dialog, -everything else is non-interactive; the package list gives way to a live -log tail with a progress bar, the bar widget shows a percentage, and the -run survives a shell restart. When it ends you get a notification and an -automatic re-check; a failed run keeps its log on screen and offers -**Retry in terminal**. +**Update** starts the run per the `update_mode` setting — in the +background (default: pkexec raises the polkit dialog, everything else is +non-interactive, the run survives a shell restart) or in a terminal window +where prompts work as usual. Either way the package list gives way to a +live log tail with a progress bar and the bar widget shows a percentage. +When it ends you get a notification and an automatic re-check; a failed +background run keeps its log on screen and offers **Retry in terminal**. The strip at the bottom is the update history: one segment per run, hover for the date, click for the run's package list. Where the backend supports @@ -131,11 +149,14 @@ fuzzy-search pending packages. | `show_download_size` | `bool` | `true` | Show the download estimate where the backend supports it. | | `check_arch_news` | `bool` | `true` | Arch news feed (pacman backend only). | | `check_reboot_needed` | `bool` | `true` | Flag when a reboot is recommended. | +| `show_activity_graph` | `bool` | `false` | Record and graph pending-update counts across recent checks. | +| `activity_history_length` | `int` | `10` | Checks kept for the activity graph (3–30). | +| `update_mode` | `select` | `background` | How Update runs: non-interactive background run or a terminal window. | | `rollback_auto_ignore` | `bool` | `false` | After a rollback, add the rolled-back packages to the plugin ignore list. | | `hide_setup_hints` | `bool` | `false` | Hide the one-time setup suggestions. | | `hide_polkit_hint` | `bool` | `false` | Hide the polkit keep-authorization rule suggestion. | | `log_lines` | `int` | `14` | Log lines shown during a run (6–30). | -| `terminal` | `string` | *(empty)* | Terminal for the fallback; empty uses Noctalia's detection. | +| `terminal` | `string` | *(empty)* | Terminal for terminal-mode updates and the fallback; empty uses Noctalia's detection. | | `update_cmd` | `string` | *(empty)* | Full override for the background update command. | ## IPC @@ -143,6 +164,7 @@ fuzzy-search pending packages. ```sh noctalia msg plugin umedbazarov/linux-updater:service all check noctalia msg plugin umedbazarov/linux-updater:service all update +noctalia msg plugin umedbazarov/linux-updater:service all update_background noctalia msg plugin umedbazarov/linux-updater:service all update_terminal noctalia msg plugin umedbazarov/linux-updater:service all dismiss noctalia msg plugin umedbazarov/linux-updater:service all ignore:NAME @@ -154,9 +176,11 @@ noctalia msg plugin umedbazarov/linux-updater:service all unignore:NAME - **Commands spawned.** Per backend, listed in `backends/*.luau` (each file documents its own commands): the distribution's check command unprivileged; the update through `pkexec ` (or PackageKit's own - polkit path), detached, logged to `/update.log` and followed with - `tail`; `flatpak list/remote-ls/update`; `pactree`/`rpm`/`apt-mark`/ - `zypper locks`/`xbps-pkgdb` where the matrix says so. + polkit path, or `sudo` in terminal mode), detached, logged to + `/update.log` and followed with `tail`; + `flatpak list/remote-ls/update`; `pactree`/`rpm`/`dpkg-query`/`apt-mark`/ + `zypper locks`/`xbps-pkgdb` where the matrix says so. The full command + list is declared in `dependencies` in `plugin.toml`. - **Privileges.** Escalation only through polkit, only for package-manager binaries; the optional keep-authorization rules (shipped in `polkit/`, installable from the panel with one confirmed click) are scoped to those @@ -164,6 +188,7 @@ noctalia msg plugin umedbazarov/linux-updater:service all unignore:NAME configuration files are never edited. - **Files written.** Only in the plugin data directory: `update.log`, `runs.json` (history), `ignore.json`, `news_state.json`, `run_meta.json`, + `activity_state.json` (only when the activity graph is on), a staged polkit rule and its install marker — plus `/etc/polkit-1/rules.d/49-linux-updater-.rules` when you explicitly click the install button. @@ -180,8 +205,10 @@ Honest coverage, so expectations are set right: - **dnf / apt / zypper / xbps / PackageKit: command layers verified in containers** on real package managers — including the full `upgrade → dnf history undo` cycle on Fedora and apt's hold semantics — - and every parser runs against recorded real-output fixtures in CI-able - tests. + and every parser runs against recorded real-output fixtures: the + fixtures live in `fixtures/`, the test harness is `tests/run.sh` (needs + the `luau` CLI; not wired into this repository's CI, which validates + manifests only). - **Not yet verified by anyone:** live polkit dialogs and the full UI on non-Arch distributions (containers cannot reproduce a polkit session), the dnf4 output branch (fixtures cover dnf5), Debian-specific deviations diff --git a/linux-updater/backends/apt.luau b/linux-updater/backends/apt.luau index 43d42ff3..217e53bf 100644 --- a/linux-updater/backends/apt.luau +++ b/linux-updater/backends/apt.luau @@ -95,15 +95,17 @@ polkit.addRule(function(action, subject) { -- Two pkexec calls (update, then upgrade) instead of one pkexec'd shell: -- the polkit rule can then stay scoped to apt-get/apt-mark, and with - -- keep-authorization one password still covers the run. + -- keep-authorization one password still covers the run. The upgrade is + -- gated (&&) on the refresh and the holds: if placing a hold fails, the + -- upgrade must not run and update packages the user ignored. The unhold + -- cleanup stays unconditional. function backend.buildBackgroundCommand(ignored) - local upgrade = "pkexec apt-get -qq update; pkexec apt-get" .. UPGRADE_FLAGS if #ignored == 0 then - return upgrade + return "pkexec apt-get -qq update && pkexec apt-get" .. UPGRADE_FLAGS end local names = table.concat(ignored, " ") - return "pkexec apt-get -qq update; pkexec apt-mark hold " .. names - .. "; " .. "pkexec apt-get" .. UPGRADE_FLAGS + return "pkexec apt-get -qq update && pkexec apt-mark hold " .. names + .. " && pkexec apt-get" .. UPGRADE_FLAGS .. "; s=$?; pkexec apt-mark unhold " .. names .. "; exit $s" end @@ -121,6 +123,13 @@ polkit.addRule(function(action, subject) { -- cheap size estimate is not available; capability off. backend.sizeCommand = nil + -- "name version" per line for the given (quoted) names; the engine uses + -- it after an interactive terminal run to keep only the packages whose + -- installed version actually moved. + function backend.installedVersionsCommand(quotedNames) + return "dpkg-query -W -f '${Package} ${Version}\\n' " .. table.concat(quotedNames, " ") .. " 2>/dev/null" + end + backend.rebootCommand = [[test -f /var/run/reboot-required && echo missing || echo present]] -- The unprivileged check depends on the apt-daily timers keeping the diff --git a/linux-updater/backends/dnf.luau b/linux-updater/backends/dnf.luau index 7a289e73..c8a4ce82 100644 --- a/linux-updater/backends/dnf.luau +++ b/linux-updater/backends/dnf.luau @@ -97,6 +97,13 @@ exit 0]] backend.sizeCommand = nil + -- "name version" per line for the given (quoted) names; the engine uses + -- it after an interactive terminal run to keep only the packages whose + -- installed version actually moved. %{EVR} matches the check's rpm side. + function backend.installedVersionsCommand(quotedNames) + return "rpm -q --qf '%{NAME} %{EVR}\\n' " .. table.concat(quotedNames, " ") .. " 2>/dev/null" + end + -- needs-restarting (dnf-utils) knows about services and libraries, not -- just the kernel; fall back to the kernel-modules check without it. backend.rebootCommand = [[if command -v needs-restarting >/dev/null 2>&1; then diff --git a/linux-updater/backends/pacman.luau b/linux-updater/backends/pacman.luau index e0ae959e..b43181db 100644 --- a/linux-updater/backends/pacman.luau +++ b/linux-updater/backends/pacman.luau @@ -12,6 +12,8 @@ -- resolveAurHelper(), aurCheckCommand(helper), -- buildBackgroundCommand(ignoreList), buildTerminalCommand(ignoreList), -- sizeCommand(quotedNames) | nil, rebootCommand, +-- installedVersionsCommand(quotedNames) | nil ("name version" per line, +-- used to verify what an interactive terminal run actually applied), -- findPkgSh, rollbackInstall, depsListCommand(name), reverseDepsCountSh return function(env) @@ -170,6 +172,13 @@ polkit.addRule(function(action, subject) { END { printf "%.2f", sum }']] end + -- "name version" per line for the given (quoted) names; the engine uses + -- it after an interactive terminal run to keep only the packages whose + -- installed version actually moved (the user may decline some). + function backend.installedVersionsCommand(quotedNames) + return "LC_ALL=C pacman -Q " .. table.concat(quotedNames, " ") .. " 2>/dev/null" + end + -- A kernel upgrade replaces the whole /usr/lib/modules/ tree; -- once the running kernel's own directory is gone, a reboot switches to -- the new one. Kernel-flavour agnostic. diff --git a/linux-updater/backends/xbps.luau b/linux-updater/backends/xbps.luau index f7ba28e1..b50543d7 100644 --- a/linux-updater/backends/xbps.luau +++ b/linux-updater/backends/xbps.luau @@ -69,6 +69,9 @@ polkit.addRule(function(action, subject) { return n, items end + -- The upgrade is gated (&&) on every hold: if placing one fails, the + -- upgrade must not run and update packages the user ignored. The unhold + -- cleanup stays unconditional. function backend.buildBackgroundCommand(ignored) local upgrade = "pkexec xbps-install -Suy" if #ignored == 0 then @@ -80,7 +83,7 @@ polkit.addRule(function(action, subject) { table.insert(holdCmds, "pkexec xbps-pkgdb -m hold " .. name) table.insert(unholdCmds, "pkexec xbps-pkgdb -m unhold " .. name) end - return table.concat(holdCmds, "; ") .. "; " .. upgrade + return table.concat(holdCmds, " && ") .. " && " .. upgrade .. "; s=$?; " .. table.concat(unholdCmds, "; ") .. "; exit $s" end @@ -95,7 +98,7 @@ polkit.addRule(function(action, subject) { table.insert(holdCmds, "sudo xbps-pkgdb -m hold " .. name) table.insert(unholdCmds, "sudo xbps-pkgdb -m unhold " .. name) end - return table.concat(holdCmds, "; ") .. "; " .. upgrade + return table.concat(holdCmds, " && ") .. " && " .. upgrade .. "; s=$?; " .. table.concat(unholdCmds, "; ") .. "; exit $s" end diff --git a/linux-updater/backends/zypper.luau b/linux-updater/backends/zypper.luau index a1453d9f..be0b080d 100644 --- a/linux-updater/backends/zypper.luau +++ b/linux-updater/backends/zypper.luau @@ -79,6 +79,9 @@ polkit.addRule(function(action, subject) { return n, items end + -- The upgrade is gated (&&) on the locks: if placing a lock fails, the + -- upgrade must not run and update packages the user ignored. The + -- removelock cleanup stays unconditional. function backend.buildBackgroundCommand(ignored) local upgrade = "pkexec zypper --non-interactive " .. upgradeVerb if #ignored == 0 then @@ -86,7 +89,7 @@ polkit.addRule(function(action, subject) { end local names = table.concat(ignored, " ") return "pkexec zypper --non-interactive addlock " .. names - .. "; " .. upgrade + .. " && " .. upgrade .. "; s=$?; pkexec zypper --non-interactive removelock " .. names .. "; exit $s" end @@ -102,6 +105,13 @@ polkit.addRule(function(action, subject) { backend.sizeCommand = nil + -- "name version" per line for the given (quoted) names; the engine uses + -- it after an interactive terminal run to keep only the packages whose + -- installed version actually moved. rpm is always present on openSUSE. + function backend.installedVersionsCommand(quotedNames) + return "rpm -q --qf '%{NAME} %{EVR}\\n' " .. table.concat(quotedNames, " ") .. " 2>/dev/null" + end + -- zypper needs-rebooting: exit 102 = reboot needed, 0 = not needed; -- anything else (old zypper) falls back to the kernel-modules check. backend.rebootCommand = [[zypper needs-rebooting >/dev/null 2>&1 diff --git a/linux-updater/fixtures/apt/ubuntu2404-backend-check.txt b/linux-updater/fixtures/apt/ubuntu2404-backend-check.txt new file mode 100644 index 00000000..0a9a67db --- /dev/null +++ b/linux-updater/fixtures/apt/ubuntu2404-backend-check.txt @@ -0,0 +1,6 @@ +Listing... +curl/noble-updates,noble-security 8.5.0-2ubuntu10.11 amd64 [upgradable from: 8.5.0-2ubuntu10] +libcurl4t64/noble-updates,noble-security 8.5.0-2ubuntu10.11 amd64 [upgradable from: 8.5.0-2ubuntu10] +libsystemd0/noble-updates,noble-security 255.4-1ubuntu8.17 amd64 [upgradable from: 255.4-1ubuntu8.16] +libudev1/noble-updates,noble-security 255.4-1ubuntu8.17 amd64 [upgradable from: 255.4-1ubuntu8.16] +::HOLDS diff --git a/linux-updater/fixtures/apt/ubuntu2404-list-upgradable.txt b/linux-updater/fixtures/apt/ubuntu2404-list-upgradable.txt new file mode 100644 index 00000000..db580a36 --- /dev/null +++ b/linux-updater/fixtures/apt/ubuntu2404-list-upgradable.txt @@ -0,0 +1,3 @@ +Listing... +curl/noble-updates,noble-security 8.5.0-2ubuntu10.11 amd64 [upgradable from: 8.5.0-2ubuntu10] +libcurl4t64/noble-updates,noble-security 8.5.0-2ubuntu10.11 amd64 [upgradable from: 8.5.0-2ubuntu10] diff --git a/linux-updater/fixtures/apt/ubuntu2404-simulate.txt b/linux-updater/fixtures/apt/ubuntu2404-simulate.txt new file mode 100644 index 00000000..a0d12a79 --- /dev/null +++ b/linux-updater/fixtures/apt/ubuntu2404-simulate.txt @@ -0,0 +1,3 @@ +2 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. +Inst curl [8.5.0-2ubuntu10] (8.5.0-2ubuntu10.11 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64]) [] +Inst libcurl4t64 [8.5.0-2ubuntu10] (8.5.0-2ubuntu10.11 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64]) diff --git a/linux-updater/fixtures/apt/ubuntu2404-upgrade-output.txt b/linux-updater/fixtures/apt/ubuntu2404-upgrade-output.txt new file mode 100644 index 00000000..86747444 --- /dev/null +++ b/linux-updater/fixtures/apt/ubuntu2404-upgrade-output.txt @@ -0,0 +1,24 @@ +Reading package lists... +Building dependency tree... +Reading state information... +Calculating upgrade... +The following packages have been kept back: + curl libcurl4t64 +The following packages will be upgraded: + libsystemd0 libudev1 +2 upgraded, 0 newly installed, 0 to remove and 2 not upgraded. +Need to get 609 kB of archives. +After this operation, 2048 B of additional disk space will be used. +Get:1 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 libsystemd0 amd64 255.4-1ubuntu8.17 [432 kB] +Get:2 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 libudev1 amd64 255.4-1ubuntu8.17 [178 kB] +debconf: delaying package configuration, since apt-utils is not installed +Fetched 609 kB in 0s (1528 kB/s) +(Reading database ... (Reading database ... 5% (Reading database ... 10% (Reading database ... 15% (Reading database ... 20% (Reading database ... 25% (Reading database ... 30% (Reading database ... 35% (Reading database ... 40% (Reading database ... 45% (Reading database ... 50% (Reading database ... 55% (Reading database ... 60% (Reading database ... 65% (Reading database ... 70% (Reading database ... 75% (Reading database ... 80% (Reading database ... 85% (Reading database ... 90% (Reading database ... 95% (Reading database ... 100% (Reading database ... 4989 files and directories currently installed.) +Preparing to unpack .../libsystemd0_255.4-1ubuntu8.17_amd64.deb ... +Unpacking libsystemd0:amd64 (255.4-1ubuntu8.17) over (255.4-1ubuntu8.16) ... +Setting up libsystemd0:amd64 (255.4-1ubuntu8.17) ... +(Reading database ... (Reading database ... 5% (Reading database ... 10% (Reading database ... 15% (Reading database ... 20% (Reading database ... 25% (Reading database ... 30% (Reading database ... 35% (Reading database ... 40% (Reading database ... 45% (Reading database ... 50% (Reading database ... 55% (Reading database ... 60% (Reading database ... 65% (Reading database ... 70% (Reading database ... 75% (Reading database ... 80% (Reading database ... 85% (Reading database ... 90% (Reading database ... 95% (Reading database ... 100% (Reading database ... 4989 files and directories currently installed.) +Preparing to unpack .../libudev1_255.4-1ubuntu8.17_amd64.deb ... +Unpacking libudev1:amd64 (255.4-1ubuntu8.17) over (255.4-1ubuntu8.16) ... +Setting up libudev1:amd64 (255.4-1ubuntu8.17) ... +Processing triggers for libc-bin (2.39-0ubuntu8.8) ... diff --git a/linux-updater/fixtures/dnf/fedora41-backend-check.txt b/linux-updater/fixtures/dnf/fedora41-backend-check.txt new file mode 100644 index 00000000..f9c69af7 --- /dev/null +++ b/linux-updater/fixtures/dnf/fedora41-backend-check.txt @@ -0,0 +1,5 @@ +curl|8.9.1-2.fc41|8.9.1-4.fc41 +libcurl|8.9.1-2.fc41|8.9.1-4.fc41 +openssl-libs|1:3.2.2-9.fc41|1:3.2.6-2.fc41 +vim-data|2:9.1.737-1.fc41|2:9.1.1914-1.fc41 +vim-minimal|2:9.1.737-1.fc41|2:9.1.1914-1.fc41 diff --git a/linux-updater/fixtures/dnf/fedora41-check-update.txt b/linux-updater/fixtures/dnf/fedora41-check-update.txt new file mode 100644 index 00000000..baeb4b73 --- /dev/null +++ b/linux-updater/fixtures/dnf/fedora41-check-update.txt @@ -0,0 +1,5 @@ +curl.x86_64 8.9.1-4.fc41 updates +libcurl.x86_64 8.9.1-4.fc41 updates +openssl-libs.x86_64 1:3.2.6-2.fc41 updates +vim-data.noarch 2:9.1.1914-1.fc41 updates +vim-minimal.x86_64 2:9.1.1914-1.fc41 updates diff --git a/linux-updater/fixtures/dnf/fedora41-history-list.txt b/linux-updater/fixtures/dnf/fedora41-history-list.txt new file mode 100644 index 00000000..f83225e6 --- /dev/null +++ b/linux-updater/fixtures/dnf/fedora41-history-list.txt @@ -0,0 +1,5 @@ +ID Command line Date and time Action(s) Altered + 4 dnf -y upgrade 2026-08-17 02:14:07 10 + 3 dnf -y -q distro-sync --disablerepo=up 2026-08-17 02:14:03 10 + 2 dnf -y -q install dnf-utils 2026-08-17 02:13:56 80 + 1 dnf5 --config /builddir/result/image/b 2025-12-15 07:47:23 124 diff --git a/linux-updater/fixtures/dnf/fedora41-installed-versions.txt b/linux-updater/fixtures/dnf/fedora41-installed-versions.txt new file mode 100644 index 00000000..a999bcf5 --- /dev/null +++ b/linux-updater/fixtures/dnf/fedora41-installed-versions.txt @@ -0,0 +1,2 @@ +curl-8.9.1-2.fc41.x86_64 +bash-5.2.32-1.fc41.x86_64 diff --git a/linux-updater/fixtures/dnf/fedora41-upgrade-output.txt b/linux-updater/fixtures/dnf/fedora41-upgrade-output.txt new file mode 100644 index 00000000..72b6b907 --- /dev/null +++ b/linux-updater/fixtures/dnf/fedora41-upgrade-output.txt @@ -0,0 +1,42 @@ +Updating and loading repositories: +Repositories loaded. +Package Arch Version Repository Size +Upgrading: + curl x86_64 8.9.1-4.fc41 updates 796.2 KiB + replacing curl x86_64 8.9.1-2.fc41 fedora 796.2 KiB + libcurl x86_64 8.9.1-4.fc41 updates 822.1 KiB + replacing libcurl x86_64 8.9.1-2.fc41 fedora 818.1 KiB + openssl-libs x86_64 1:3.2.6-2.fc41 updates 7.8 MiB + replacing openssl-libs x86_64 1:3.2.2-9.fc41 fedora 7.8 MiB + vim-data noarch 2:9.1.1914-1.fc41 updates 10.2 KiB + replacing vim-data noarch 2:9.1.737-1.fc41 fedora 17.8 KiB + vim-minimal x86_64 2:9.1.1914-1.fc41 updates 1.7 MiB + replacing vim-minimal x86_64 2:9.1.737-1.fc41 fedora 1.7 MiB + +Transaction Summary: + Upgrading: 5 packages + Replacing: 5 packages + +Total size of inbound packages is 4 MiB. Need to download 4 MiB. +After this operation, 65 KiB extra will be used (install 11 MiB, remove 11 MiB). +[1/5] libcurl-0:8.9.1-4.fc41.x86_64 100% | 646.5 KiB/s | 360.1 KiB | 00m01s +[2/5] curl-0:8.9.1-4.fc41.x86_64 100% | 552.8 KiB/s | 311.8 KiB | 00m01s +[3/5] vim-data-2:9.1.1914-1.fc41.noarch 100% | 97.4 KiB/s | 17.4 KiB | 00m00s +[4/5] openssl-libs-1:3.2.6-2.fc41.x86_6 100% | 2.6 MiB/s | 2.3 MiB | 00m01s +[5/5] vim-minimal-2:9.1.1914-1.fc41.x86 100% | 2.4 MiB/s | 846.4 KiB | 00m00s +-------------------------------------------------------------------------------- +[5/5] Total 100% | 2.7 MiB/s | 3.8 MiB | 00m01s +Running transaction +[ 1/12] Verify package files 100% | 217.0 B/s | 5.0 B | 00m00s +[ 2/12] Prepare transaction 100% | 312.0 B/s | 10.0 B | 00m00s +[ 3/12] Upgrading vim-data-2:9.1.1914-1 100% | 5.5 MiB/s | 11.3 KiB | 00m00s +[ 4/12] Upgrading openssl-libs-1:3.2.6- 100% | 217.7 MiB/s | 7.8 MiB | 00m00s +[ 5/12] Upgrading libcurl-0:8.9.1-4.fc4 100% | 160.8 MiB/s | 823.2 KiB | 00m00s +[ 6/12] Upgrading curl-0:8.9.1-4.fc41.x 100% | 260.0 MiB/s | 798.6 KiB | 00m00s +[ 7/12] Upgrading vim-minimal-2:9.1.191 100% | 172.0 MiB/s | 1.7 MiB | 00m00s +[ 8/12] Removing curl-0:8.9.1-2.fc41.x8 100% | 16.6 KiB/s | 17.0 B | 00m00s +[ 9/12] Removing libcurl-0:8.9.1-2.fc41 100% | 6.8 KiB/s | 7.0 B | 00m00s +[10/12] Removing vim-minimal-2:9.1.737- 100% | 15.6 KiB/s | 16.0 B | 00m00s +[11/12] Removing vim-data-2:9.1.737-1.f 100% | 0.0 B/s | 7.0 B | 00m00s +[12/12] Removing openssl-libs-1:3.2.2-9 100% | 1.5 KiB/s | 39.0 B | 00m00s +Complete! diff --git a/linux-updater/fixtures/packagekit/fedora41-get-updates-plain.txt b/linux-updater/fixtures/packagekit/fedora41-get-updates-plain.txt new file mode 100644 index 00000000..a20413b7 --- /dev/null +++ b/linux-updater/fixtures/packagekit/fedora41-get-updates-plain.txt @@ -0,0 +1,11 @@ +Transaction: Getting updates +Status: Waiting in queue +Status: Starting +Percentage: 100 +Status: Finished +Results: +Security curl-8.9.1-4.fc41.x86_64 (updates) +Security libcurl-8.9.1-4.fc41.x86_64 (updates) +Security openssl-libs-1:3.2.6-2.fc41.x86_64 (updates) +Bug fix vim-data-2:9.1.1914-1.fc41.noarch (updates) +Bug fix vim-minimal-2:9.1.1914-1.fc41.x86_64 (updates) diff --git a/linux-updater/fixtures/packagekit/fedora41-get-updates.txt b/linux-updater/fixtures/packagekit/fedora41-get-updates.txt new file mode 100644 index 00000000..2e6136da --- /dev/null +++ b/linux-updater/fixtures/packagekit/fedora41-get-updates.txt @@ -0,0 +1,13 @@ +Transaction: Getting updates +Status: Waiting in queue +Status: Starting +Status: Querying +Status: Loading cache +Percentage: 100 +Status: Finished +Results: +Security curl-8.9.1-4.fc41.x86_64 (updates) +Security libcurl-8.9.1-4.fc41.x86_64 (updates) +Security openssl-libs-1:3.2.6-2.fc41.x86_64 (updates) +Bug fix vim-data-2:9.1.1914-1.fc41.noarch (updates) +Bug fix vim-minimal-2:9.1.1914-1.fc41.x86_64 (updates) diff --git a/linux-updater/fixtures/packagekit/fedora41-update-download.txt b/linux-updater/fixtures/packagekit/fedora41-update-download.txt new file mode 100644 index 00000000..efc93e87 --- /dev/null +++ b/linux-updater/fixtures/packagekit/fedora41-update-download.txt @@ -0,0 +1,26 @@ +Transaction: Getting updates +Status: Waiting in queue +Status: Starting +Percentage: 100 +Status: Finished +Transaction: Updating packages +Status: Waiting in queue +Status: Starting +Status: Querying +Percentage: 10 +Status: Downloading packages +Package: vim-minimal-2:9.1.1914-1.fc41.x86_64 +Package: vim-data-2:9.1.1914-1.fc41.noarch +Package: openssl-libs-1:3.2.6-2.fc41.x86_64 +Package: libcurl-8.9.1-4.fc41.x86_64 +Package: curl-8.9.1-4.fc41.x86_64 +Status: Requesting data +Status: Testing changes +Percentage: 100 +Status: Finished +Results: +Downloaded curl-8.9.1-4.fc41.x86_64 (updates) +Downloaded libcurl-8.9.1-4.fc41.x86_64 (updates) +Downloaded openssl-libs-1:3.2.6-2.fc41.x86_64 (updates) +Downloaded vim-data-2:9.1.1914-1.fc41.noarch (updates) +Downloaded vim-minimal-2:9.1.1914-1.fc41.x86_64 (updates) diff --git a/linux-updater/fixtures/packagekit/ubuntu2404-get-updates.txt b/linux-updater/fixtures/packagekit/ubuntu2404-get-updates.txt new file mode 100644 index 00000000..e4cb52f1 --- /dev/null +++ b/linux-updater/fixtures/packagekit/ubuntu2404-get-updates.txt @@ -0,0 +1,14 @@ +Transaction: Getting updates +Status: Waiting in queue +Status: Starting +Status: Loading cache +Percentage: 0 +Percentage: 100 +Status: Querying +Status: Loading cache +Percentage: 10 +Percentage: 100 +Status: Finished +Results: +Bug fix curl-8.5.0-2ubuntu10.11.amd64 (ubuntu-noble-updates-main) +Bug fix libcurl4t64-8.5.0-2ubuntu10.11.amd64 (ubuntu-noble-updates-main) diff --git a/linux-updater/fixtures/packagekit/ubuntu2404-update-download.txt b/linux-updater/fixtures/packagekit/ubuntu2404-update-download.txt new file mode 100644 index 00000000..f5780b41 --- /dev/null +++ b/linux-updater/fixtures/packagekit/ubuntu2404-update-download.txt @@ -0,0 +1,30 @@ +Transaction: Getting updates +Status: Waiting in queue +Status: Starting +Status: Loading cache +Percentage: 0 +Percentage: 50 +Percentage: 100 +Status: Querying +Status: Loading cache +Percentage: 10 +Percentage: 100 +Status: Finished +Transaction: Updating packages +Status: Waiting in queue +Status: Starting +Status: Loading cache +Percentage: 100 +Percentage: 100 +Status: Querying +Status: Running +Status: Loading cache +Status: Downloading packages +Percentage: 0 +Package: curl-8.5.0-2ubuntu10.11.amd64 +Package: libcurl4t64-8.5.0-2ubuntu10.11.amd64 +Status: Running +Status: Finished +Results: +Downloaded curl-8.5.0-2ubuntu10.11.amd64 (ubuntu-noble-updates-main) +Downloaded libcurl4t64-8.5.0-2ubuntu10.11.amd64 (ubuntu-noble-updates-main) diff --git a/linux-updater/fixtures/xbps/void-check.txt b/linux-updater/fixtures/xbps/void-check.txt new file mode 100644 index 00000000..ce779bdd --- /dev/null +++ b/linux-updater/fixtures/xbps/void-check.txt @@ -0,0 +1 @@ +libarchive-3.8.9_1 update x86_64 https://repo-default.voidlinux.org/current 817232 380316 diff --git a/linux-updater/fixtures/xbps/void-upgrade-output.txt b/linux-updater/fixtures/xbps/void-upgrade-output.txt new file mode 100644 index 00000000..9656a05b --- /dev/null +++ b/linux-updater/fixtures/xbps/void-upgrade-output.txt @@ -0,0 +1,34 @@ +[*] Updating repository `https://repo-default.voidlinux.org/current/x86_64-repodata' ... +1 package will be downloaded: + + libarchive-3.8.9_1 +1 package will be updated: + + libarchive (3.8.8_1 -> 3.8.9_1) + +Size to download: 372KB +Size required on disk: 798KB +Space available on disk: 639GB + + +[*] Downloading packages +libarchive-3.8.9_1.x86_64.xbps.sig2: [512B 100%] 35MB/s ETA: 00m00s +libarchive-3.8.9_1.x86_64.xbps.sig2: 512B [avg rate: 35MB/s] +libarchive-3.8.9_1.x86_64.xbps: [371KB 1%] 57MB/s ETA: 00m00s +libarchive-3.8.9_1.x86_64.xbps: 371KB [avg rate: 5334MB/s] +libarchive-3.8.9_1: verifying RSA signature... + +[*] Collecting package files +libarchive-3.8.9_1: collecting files... +libarchive-3.8.8_1: collecting files... + +[*] Unpacking packages +libarchive-3.8.8_1: updating to 3.8.9_1 ... +libarchive-3.8.9_1: unpacking ... +libarchive-3.8.9_1: removed obsolete entry: ./usr/lib/libarchive.so.13.8.8 + +[*] Configuring unpacked packages +libarchive-3.8.9_1: configuring ... +libarchive-3.8.9_1: updated successfully. + +1 downloaded, 0 installed, 1 updated, 1 configured, 0 removed, 0 on hold. diff --git a/linux-updater/fixtures/zypper/leap156-list-updates.txt b/linux-updater/fixtures/zypper/leap156-list-updates.txt new file mode 100644 index 00000000..f841f980 --- /dev/null +++ b/linux-updater/fixtures/zypper/leap156-list-updates.txt @@ -0,0 +1,3 @@ +Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC. + + diff --git a/linux-updater/fixtures/zypper/leap156-up-output.txt b/linux-updater/fixtures/zypper/leap156-up-output.txt new file mode 100644 index 00000000..8117b7aa --- /dev/null +++ b/linux-updater/fixtures/zypper/leap156-up-output.txt @@ -0,0 +1,9 @@ +Loading repository data... +Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC. + + Warning: Repository metadata expired: Check if 'autorefresh' is turned on (zypper lr), otherwise + manually refresh the repository (zypper ref). If this does not solve the issue, it could be that + you are using a broken mirror or the server has actually discontinued to support the repository. + +Reading installed packages... +Nothing to do. diff --git a/linux-updater/fixtures/zypper/tw-list-updates.txt b/linux-updater/fixtures/zypper/tw-list-updates.txt new file mode 100644 index 00000000..b1c6e0de --- /dev/null +++ b/linux-updater/fixtures/zypper/tw-list-updates.txt @@ -0,0 +1,3 @@ +S | Repository | Name | Current Version | Available Version | Arch +---+----------------------------+--------------------+-----------------+-------------------+------- +v | openSUSE-Tumbleweed-Update | openSUSE-build-key | 1.0-68.1 | 1.0-69.1 | x86_64 diff --git a/linux-updater/panel.luau b/linux-updater/panel.luau index 3fd438c3..f7be65a6 100644 --- a/linux-updater/panel.luau +++ b/linux-updater/panel.luau @@ -4,11 +4,12 @@ -- actions this panel emits, so closing the panel never interrupts a check or -- a run in progress. -- --- One click on "Update" starts the background run: pkexec raises the polkit --- password dialog, everything else is non-interactive. While it runs, the --- package list gives way to a live tail of the update log plus a progress --- bar. A failed run keeps its log on screen and offers a terminal fallback --- where prompts work normally. +-- "Update" follows the update_mode setting: a non-interactive background +-- run (default — pkexec raises the polkit password dialog, everything else +-- is automatic) or a terminal window where prompts work as usual. Either +-- way the package list gives way to a live tail of the update log plus a +-- progress bar while it runs. A failed background run keeps its log on +-- screen and offers a terminal retry where prompts work normally. local STATE_KEY = "linup_state" local REQUEST_KEY = "linup_request" @@ -20,6 +21,7 @@ local hoverText = "" -- what the detail line shows local listOpen = false -- at least one source is expanded this render local openedRunAt = nil -- history run whose package list replaces the sources local armedKey = nil -- rollback button waiting for its confirming second click +local activityHoverIndex = nil -- activity graph point currently under the pointer local render @@ -125,6 +127,9 @@ local function headline() if snapshot ~= nil and snapshot.runKind == "rollback" then return tr("status_rolling_back"), "secondary" end + if snapshot ~= nil and snapshot.runMode == "terminal" then + return tr("status_running_terminal"), "secondary" + end return tr("status_running"), "secondary" elseif phase == "clean" then return tr("status_clean"), "on_surface" @@ -411,6 +416,186 @@ local function ignoredRows() return rows end +-- ── Activity graph (opt-in) ────────────────────────────────────────────────── + +-- Normalizes the pending-count history to 0..1 for ui.graph, relative to the +-- min/max in the window (not a fixed scale, since pending counts vary wildly +-- between systems). A flat window (min == max, e.g. every check so far found +-- the same count) centers the line at 0.5 instead of pinning it to the top +-- edge, where it would be indistinguishable from the box border. +local function activityValues(history) + local minN, maxN = tonumber(history[1].n) or 0, tonumber(history[1].n) or 0 + for _, entry in ipairs(history) do + local n = tonumber(entry.n) or 0 + if n < minN then + minN = n + end + if n > maxN then + maxN = n + end + end + local range = maxN - minN + local values = {} + for _, entry in ipairs(history) do + local n = tonumber(entry.n) or 0 + table.insert(values, range > 0 and (n - minN) / range or 0.5) + end + return values +end + +local ACTIVITY_GRAPH_SUBDIVISIONS = 8 + +local function upsampleLinear(values, subdivisions) + if #values < 2 or subdivisions <= 1 then + return values + end + local out = {} + for i = 1, #values - 1 do + local a, b = values[i], values[i + 1] + for s = 0, subdivisions - 1 do + table.insert(out, a + (b - a) * (s / subdivisions)) + end + end + table.insert(out, values[#values]) + return out +end + +local function padGraphLookbehind(values) + if #values == 0 then + return values + end + local out = { values[1], values[1] } + for _, v in ipairs(values) do + table.insert(out, v) + end + return out +end + +-- "2 hours ago", "3 days ago", etc. nil for entries that never recorded a +-- timestamp. +local function relativeTime(at) + local t = tonumber(at) + if t == nil then + return nil + end + local diff = os.time() - t + if diff < 60 then + return tr("activity.just_now") + elseif diff < 3600 then + local m = math.floor(diff / 60) + return noctalia.trp("activity.minutes_ago", m, { count = m }) + elseif diff < 86400 then + local h = math.floor(diff / 3600) + return noctalia.trp("activity.hours_ago", h, { count = h }) + else + local d = math.floor(diff / 86400) + return noctalia.trp("activity.days_ago", d, { count = d }) + end +end + +-- "Updated · 2 hours ago" for the check that verified an update run, else +-- "3 pending updates · 2 hours ago". Shared by the hover tooltip and the +-- top-right caption so both describe a point the same way. +local function describeEntry(entry) + local n = tonumber(entry.n) or 0 + local text = entry.afterUpdate == true and tr("activity.updated") + or noctalia.trp("activity.pending_at", n, { count = n }) + local when = relativeTime(entry.at) + if when ~= nil then + text = text .. " · " .. when + end + return text +end + +-- ui.graph takes no pointer props of its own and the framework has no +-- overlay control, so per-point hover cannot live on the line itself: it is +-- a row of hit targets right under it, drawn as an axis of small dots (the +-- hovered one lights up in the accent color) so the interactivity is +-- visible without the ghost-button hover flash. +-- +-- Real points sit at (k-1)/(#history-1) of the width (see +-- padGraphLookbehind), i.e. #history-1 equal gaps, not #history equal slots +-- - so this builds one equal-width segment per gap rather than per entry, +-- ending each segment (and its dot) exactly on the point at its right edge. +-- That leaves entry 1 (at the left edge, with no gap before it) without its +-- own hover zone, but keeps every other spike landing right at the end of +-- its segment instead of drifting toward the start of an oversized one. +local function activityHoverRow(history) + local segments = {} + for i = 2, #history do + local hovered = activityHoverIndex == i + segments[i - 1] = ui.row({ + key = "activity-hit-" .. i .. (hovered and "-on" or ""), + flexGrow = 1, + height = 12, + align = "center", + justify = "end", + onHover = function(state) + if state == "true" then + activityHoverIndex = i + elseif activityHoverIndex == i then + activityHoverIndex = nil + else + return + end + render() + end, + }, { + ui.box({ + width = hovered and 7 or 5, + height = hovered and 7 or 5, + radius = 4, + fill = hovered and "primary" or "outline/0.6", + }), + }) + end + return ui.row({ key = "activity-hits", gap = 1 }, segments) +end + +-- A small trend graph of pending-update counts across recent checks, plus +-- when the last update ran (or, while hovering a point, that point's own +-- description). Off entirely when show_activity_graph is off, and hidden +-- until there is enough history to draw a line. +local function activitySection() + if snapshot == nil or noctalia.getConfig("show_activity_graph") ~= true then + return nil + end + local history = type(snapshot.activity) == "table" and snapshot.activity or {} + if #history < 2 then + return nil + end + + local hoveredEntry = activityHoverIndex ~= nil and history[activityHoverIndex] or nil + local caption + if hoveredEntry ~= nil then + caption = describeEntry(hoveredEntry) + else + local lastUpdateAt = tonumber(snapshot.lastUpdateAt) + if lastUpdateAt == nil then + caption = tr("activity.never_updated") + else + local days = math.floor((os.time() - lastUpdateAt) / 86400) + caption = days <= 0 and tr("activity.updated_today") + or noctalia.trp("activity.updated_days_ago", days, { count = days }) + end + end + + return ui.column({ key = "activity", gap = 4 }, { + ui.row({ justify = "space_between", align = "center" }, { + ui.label({ text = tr("activity.title"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" }), + ui.label({ text = caption, fontSize = 10, color = "on_surface_variant" }), + }), + ui.graph({ + values = padGraphLookbehind(upsampleLinear(activityValues(history), ACTIVITY_GRAPH_SUBDIVISIONS)), + color = "primary", + fillOpacity = 0.15, + lineWidth = 2, + height = 36, + }), + activityHoverRow(history), + }) +end + -- ── Run history and rollback ───────────────────────────────────────────────── local function historyRuns() @@ -694,8 +879,15 @@ local function extras() })) end - -- Offered until the polkit keep-authorization rule is installed. - if snapshot.polkitRule == false and phaseOf() ~= "running" and noctalia.getConfig("hide_polkit_hint") ~= true then + -- Offered until the polkit keep-authorization rule is installed. Only in + -- background mode: a terminal run goes through sudo, so the repeated + -- polkit prompts the rule solves never happen there. + if + snapshot.polkitRule == false + and noctalia.getConfig("update_mode") ~= "terminal" + and phaseOf() ~= "running" + and noctalia.getConfig("hide_polkit_hint") ~= true + then table.insert(lines, ui.row({ key = "polkit", gap = 6, align = "center" }, { ui.glyph({ name = "shield", size = 13, color = "on_surface_variant" }), ui.label({ @@ -861,10 +1053,18 @@ render = function() table.insert(children, node) end + -- Both footers hide while a check or run is on screen, so neither ever + -- competes with the live log for space. + local activity = not busy() and activitySection() or nil local history = not busy() and historySection() or nil - if history ~= nil then + if activity ~= nil or history ~= nil then table.insert(children, ui.separator({})) - table.insert(children, history) + if activity ~= nil then + table.insert(children, activity) + end + if history ~= nil then + table.insert(children, history) + end else local caption = footerCaption() if caption ~= nil then @@ -875,7 +1075,9 @@ render = function() if phase ~= "missing" then local footer = {} - if runFailed() then + -- Not offered after a failed rollback: re-running the *update* + -- command in a terminal is not a retry of the rollback. + if runFailed() and (snapshot == nil or snapshot.runKind ~= "rollback") then table.insert(footer, ui.button({ key = "retry-terminal", glyph = "terminal", text = tr("action_run_terminal"), variant = "ghost", flexGrow = 1, @@ -893,13 +1095,16 @@ render = function() panel.close() end, })) + local terminalMode = noctalia.getConfig("update_mode") == "terminal" table.insert(footer, ui.button({ key = "update" .. (hasUpdates and "" or "-off"), - glyph = "download", text = tr("action_update"), variant = "primary", enabled = hasUpdates, - tooltip = tr("tip_update"), + glyph = terminalMode and "terminal" or "download", + text = tr("action_update"), variant = "primary", enabled = hasUpdates, + tooltip = terminalMode and tr("tip_update_terminal") or tr("tip_update"), onClick = function() -- The panel stays open: the log section takes over so the - -- run can be watched live. + -- run can be watched live (the log is tee'd from the + -- terminal too). request("update") end, })) @@ -916,6 +1121,7 @@ function onOpen(_context) hoverText = "" openedRunAt = nil armedKey = nil + activityHoverIndex = nil render() end diff --git a/linux-updater/plugin.toml b/linux-updater/plugin.toml index 23bdb70c..98995917 100644 --- a/linux-updater/plugin.toml +++ b/linux-updater/plugin.toml @@ -1,12 +1,12 @@ id = "umedbazarov/linux-updater" name = "Linux Updater" -version = "0.6.0" +version = "0.7.0" plugin_api = 9 author = "umedbazarov" license = "MIT" icon = "package" description = "Check and install system updates in the background on any major distro: live log, update history, rollback." -dependencies = ["sh", "awk", "sed", "tail", "test", "uname", "pkexec", "pacman-contrib", "pacman", "dnf", "apt-get", "zypper", "xbps-install", "pkcon", "paru", "yay", "flatpak", "sudo", "xdg-open"] +dependencies = ["apt", "apt-get", "apt-mark", "awk", "date", "dnf", "dpkg-query", "flatpak", "grep", "head", "install", "less", "pacman", "pacman-contrib", "paru", "pkcon", "pkexec", "rm", "rpm", "sed", "sh", "sudo", "systemctl", "tail", "tee", "test", "uname", "wc", "xbps-install", "xbps-pkgdb", "xdg-open", "yay", "zypper"] tags = ["arch", "debian", "fedora", "opensuse", "void", "bar", "panel", "launcher", "system", "utility"] # ── General ────────────────────────────────────────────────────────────────── @@ -102,8 +102,36 @@ label_key = "settings.check_reboot_needed.label" description_key = "settings.check_reboot_needed.description" default = true +[[setting]] +key = "show_activity_graph" +type = "bool" +label_key = "settings.show_activity_graph.label" +description_key = "settings.show_activity_graph.description" +default = false + +[[setting]] +key = "activity_history_length" +type = "int" +label_key = "settings.activity_history_length.label" +description_key = "settings.activity_history_length.description" +default = 10 +min = 3 +max = 30 +visible_when = { key = "show_activity_graph", values = ["true"] } + # ── Update run ─────────────────────────────────────────────────────────────── +[[setting]] +key = "update_mode" +type = "select" +label_key = "settings.update_mode.label" +description_key = "settings.update_mode.description" +default = "background" +options = [ + { value = "background", label_key = "settings.update_mode.options.background" }, + { value = "terminal", label_key = "settings.update_mode.options.terminal" }, +] + [[setting]] key = "rollback_auto_ignore" type = "bool" diff --git a/linux-updater/service.luau b/linux-updater/service.luau index a6bd5c58..25a44be1 100644 --- a/linux-updater/service.luau +++ b/linux-updater/service.luau @@ -9,18 +9,27 @@ -- flatpak, downloadSizeMiB, rebootRecommended, -- newsUnread, newsLatestTitle, err, checkedAt, -- ignoredCount, ignoredPending, ignoredDynamic, --- ignoredConfig, logTail, logPath, runExit, +-- ignoredConfig, history, activity, lastUpdateAt, +-- logTail, logPath, runExit, -- progress = { done, total } } -- requests "linup_request" = { nonce, action, pkg } --- -- check|update|update_terminal|dismiss|open_news +-- -- check|update|update_background|update_terminal +-- -- |dismiss|open_news -- -- |ignore|unignore (pkg = package name) -- +-- "update" follows the update_mode setting: "background" (default) runs +-- non-interactively as described above; "terminal" opens a terminal window +-- where prompts (and the PKGBUILD review on Arch) work as usual. Both write +-- to the same log, so the panel's live tail, the progress bar and the +-- history strip work in either mode. +-- -- The update run is spawned fully detached (double-fork + setsid), writing to -- /update.log; it survives a shell restart. The engine polls -- the log every RUN_POLL_SECONDS with `tail` and finishes when the runner's --- "::EXIT " marker appears. If a run cannot proceed non-interactively --- it exits non-zero without touching the system, and the panel offers a --- terminal fallback ("update_terminal") where prompts work normally. +-- "::EXIT " marker appears. If a background run cannot proceed +-- non-interactively it exits non-zero without touching the system, and the +-- panel offers a terminal retry ("update_terminal") where prompts work +-- normally. local STATE_KEY = "linup_state" local REQUEST_KEY = "linup_request" @@ -28,6 +37,7 @@ local NEWS_FILE = "news_state.json" local IGNORE_FILE = "ignore.json" local RUNS_FILE = "runs.json" local RUN_META_FILE = "run_meta.json" +local ACTIVITY_FILE = "activity_state.json" -- pending-count trend per check, for the panel's graph local MAX_RUNS = 15 -- update runs kept for the history strip / rollback local MAX_RUN_PACKAGES = 100 -- per-run package list cap (storage and state) local LOG_FILE = "update.log" @@ -37,7 +47,7 @@ local SIZE_TIMEOUT_MS = 20000 -- pacman -Si: local db, no mirror sync local FAST_TIMEOUT_MS = 5000 -- log tail / reboot check: local filesystem only local NEWS_RECHECK_HOURS = 6 local RUN_POLL_SECONDS = 2 -local RUN_STALE_LIMIT_S = 1800 -- no log growth for this long = the run is stuck +local RUN_STALE_LIMIT_S = 1800 -- no log growth for this long = the background run is stuck local RUN_RESUME_MAX_AGE_S = 6 * 3600 -- older unfinished logs are not resumed local AUTO_CHECK_DELAY = 10 -- ticks before an enabled auto-check's first run local MAX_LISTED = 300 -- packages kept per source for the panel's expandable list @@ -66,6 +76,7 @@ local logTail = {} -- last log lines for the panel local lastTailText = "" local sinceCheck = 0 local startupTicks = 0 +local resumeProbed = false -- resumeRunIfActive's log probe has answered local sinceNewsCheck = 0 local newsStateLoaded = false local newsDirty = false @@ -75,7 +86,12 @@ local polkitRuleInstalled = nil -- nil until the async check ran, then boolean local polkitInstallBusy = false local selfCheckState = nil -- failing backend self-checks, for the panel's hint rows local runsHistory = nil -- lazy-loaded array of {at, rollback, packages}, newest last -local runKind = "update" -- update|rollback: what the current background run does +local activityHistory = {} -- activity graph: { n, at, afterUpdate } per check, oldest first +local activityLastUpdateAt = nil -- os.time() of the last update run that finished +local activityLoaded = false +local checkIsPostUpdate = false -- next finished check followed an update run +local runKind = "update" -- update|rollback: what the current run does +local runMode = "background" -- background|terminal: how the current run was launched local runPackages = nil -- packages of the current run, for the history entry local probeResult = nil -- cache/required-by info for one run, {at, pkgs} @@ -392,6 +408,88 @@ local function collectPendingPackages() return packages end +-- ── Activity history (the panel's trend graph, opt-in) ─────────────────────── + +-- One { n, at, afterUpdate } entry per finished check: how many updates were +-- pending at that moment. The panel draws these as a small trend graph. +-- Distinct from the run history above: this tracks *checks* (including the +-- stretches when nothing was updated), not applied runs. +local function activityStatePath() + local dir = noctalia.pluginDataDir() + return dir ~= nil and (dir .. "/" .. ACTIVITY_FILE) or nil +end + +local function loadActivityState() + if activityLoaded then + return + end + activityLoaded = true + local path = activityStatePath() + local encoded = path ~= nil and noctalia.readFile(path) or nil + local ok, decoded = pcall(function() + return encoded ~= nil and noctalia.json.decode(encoded) or nil + end) + if ok and type(decoded) == "table" then + if type(decoded.history) == "table" then + local entries = {} + for _, entry in ipairs(decoded.history) do + if type(entry) == "table" then + table.insert(entries, { + n = tonumber(entry.n) or 0, + at = tonumber(entry.at), + afterUpdate = entry.afterUpdate == true, + }) + end + end + activityHistory = entries + end + if type(decoded.lastUpdateAt) == "number" then + activityLastUpdateAt = decoded.lastUpdateAt + end + end +end + +local function saveActivityState() + local path = activityStatePath() + if path == nil then + return + end + local encoded = noctalia.json.encode({ history = activityHistory, lastUpdateAt = activityLastUpdateAt }) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +-- Appends the current total to the activity history, trimmed to the +-- configured length. A no-op when the graph is turned off, so disabling it +-- also stops collecting data, not just hides it. +local function recordCheck() + local wasPostUpdate = checkIsPostUpdate + checkIsPostUpdate = false + if cfg("show_activity_graph") ~= true then + return + end + loadActivityState() + table.insert(activityHistory, { n = total, at = os.time(), afterUpdate = wasPostUpdate }) + local maxLen = math.max(3, math.min(30, tonumber(cfg("activity_history_length")) or 10)) + while #activityHistory > maxLen do + table.remove(activityHistory, 1) + end + saveActivityState() +end + +-- Marks the check that follows as the one that verifies an update run, so its +-- history entry can say "Updated" instead of just a pending count. +local function recordUpdateRun() + checkIsPostUpdate = true + if cfg("show_activity_graph") ~= true then + return + end + loadActivityState() + activityLastUpdateAt = os.time() + saveActivityState() +end + -- ── Parsing ────────────────────────────────────────────────────────────────── -- Flatpak has no "name oldver -> newver" line, so the query joins installed @@ -422,6 +520,9 @@ end -- ── Publishing ─────────────────────────────────────────────────────────────── local function publish() + if cfg("show_activity_graph") == true then + loadActivityState() -- no-op after the first call; keeps the graph populated right after a reload + end stateNonce += 1 noctalia.state.set(STATE_KEY, { nonce = stateNonce, @@ -449,7 +550,10 @@ local function publish() ignoredConfig = configIgnoreList(), polkitRule = polkitRuleInstalled, history = historyForState(), + activity = activityHistory, + lastUpdateAt = activityLastUpdateAt, runKind = runKind, + runMode = runMode, probe = probeResult, flatpakEnabled = cfg("flatpak_enabled") == true and noctalia.commandExists("flatpak"), logTail = logTail, @@ -665,6 +769,7 @@ finishCheck = function() phase = total > 0 and "ready" or "clean" checkedAt = noctalia.formatTime("%H:%M") sinceCheck = 0 + recordCheck() publish() if total > 0 and cfg("notify_on_updates") == true then noctalia.notify(tr("title"), noctalia.trp("notify_updates", total, { count = total })) @@ -885,21 +990,23 @@ end -- default. AUR helpers run as the user and escalate through pkexec themselves -- (--sudo pkexec), so no terminal and no sudo tty are ever needed. -- Flatpak is cross-distro, so its update part belongs to the engine, not a --- backend. Same ignore list as the native manager: filter ignored refs out --- of the pending Flatpak list before updating, so an app hidden from the --- panel's count can't still slip in through a bare `flatpak update`. -local function buildFlatpakPart(ignored) +-- backend. Shared by both modes. Same ignore list as the native manager: +-- filter ignored refs out of the pending Flatpak list before updating, so +-- an app hidden from the panel's count can't still slip in through a bare +-- `flatpak update`. `flags` carries the mode's own switches +-- (non-interactive for the background run). +local function buildFlatpakPart(ignored, flags) if cfg("flatpak_enabled") ~= true or not noctalia.commandExists("flatpak") then return nil end if #ignored == 0 then - return "flatpak update -y --noninteractive" + return "flatpak update" .. flags end local skipList = shellQuote(table.concat(ignored, "\n")) return "{ flatpak_refs=$(flatpak remote-ls --updates --columns=application 2>/dev/null | awk -v ignore=" .. skipList .. [=[ 'BEGIN { n = split(ignore, arr, "\n"); for (i = 1; i <= n; i++) skip[arr[i]] = 1 } !($0 in skip)'); ]=] - .. [[if [ -n "$flatpak_refs" ]; then flatpak update -y --noninteractive $flatpak_refs; fi; }]] + .. [[if [ -n "$flatpak_refs" ]; then flatpak update]] .. flags .. [[ $flatpak_refs; fi; }]] end local function buildBackgroundCommand() @@ -909,21 +1016,23 @@ local function buildBackgroundCommand() end local ignored = ignoreList() local parts = { backend.buildBackgroundCommand(ignored) } - local flatpakPart = buildFlatpakPart(ignored) + local flatpakPart = buildFlatpakPart(ignored, " -y --noninteractive") if flatpakPart ~= nil then table.insert(parts, flatpakPart) end return table.concat(parts, " && ") end --- Interactive variant for the terminal fallback: no --noconfirm, prompts and --- the PKGBUILD review work as usual. Output is tee'd into the same log so the --- engine still sees the "::EXIT" marker and re-checks when the run ends. +-- Interactive variant, used when update_mode is "terminal" and as the retry +-- after a failed background run: no --noconfirm, prompts work as usual. +-- Output is tee'd into the same log so the engine still sees the "::EXIT" +-- marker and re-checks when the run ends. local function buildTerminalCommand() local ignored = ignoreList() local parts = { backend.buildTerminalCommand(ignored) } - if cfg("flatpak_enabled") == true and noctalia.commandExists("flatpak") then - table.insert(parts, "flatpak update") + local flatpakPart = buildFlatpakPart(ignored, "") + if flatpakPart ~= nil then + table.insert(parts, flatpakPart) end return table.concat(parts, " && ") end @@ -968,7 +1077,7 @@ local function saveRunMeta() if path == nil then return end - local encoded = noctalia.json.encode({ kind = runKind, total = runTotal, packages = runPackages }) + local encoded = noctalia.json.encode({ kind = runKind, mode = runMode, total = runTotal, packages = runPackages }) if encoded ~= nil then noctalia.writeFile(path, encoded) end @@ -982,6 +1091,7 @@ local function loadRunMeta() end) if ok and type(decoded) == "table" then runKind = decoded.kind == "rollback" and "rollback" or "update" + runMode = decoded.mode == "terminal" and "terminal" or "background" runTotal = tonumber(decoded.total) or 0 runPackages = type(decoded.packages) == "table" and decoded.packages or nil end @@ -994,11 +1104,12 @@ local function clearRunMeta() end end -local function beginRun(kind, expectTotal) +local function beginRun(kind, expectTotal, mode) phase = "running" errMsg = nil step = "" runKind = kind or "update" + runMode = mode or "background" runExit = nil runDone = 0 runTotal = expectTotal or total @@ -1074,30 +1185,74 @@ local function runUpdateTerminal() return end runPackages = collectPendingPackages() - beginRun("update") + beginRun("update", nil, "terminal") saveRunMeta() end +-- The history entry trusts the pending-list snapshot taken at launch, but an +-- interactive terminal run lets the user decline packages along the way. +-- Before recording, the backend's installed-versions query weeds out entries +-- whose installed version never left `from`: they were not actually updated, +-- so a rollback segment offering to "undo" them would lie. Flatpak entries +-- and entries without a known old version are kept as-is (display only). +-- Any failure to verify records the full snapshot, like before. +local function verifyAndRecordRun(kind, packages, txId) + local function fallback() + recordRun(kind, packages, txId) + publish() + end + if type(packages) ~= "table" or #packages == 0 then + return + end + if backend.installedVersionsCommand == nil then + fallback() + return + end + local names = {} + for _, item in ipairs(packages) do + if item.source ~= "flatpak" and tostring(item.from or "") ~= "" and validName(tostring(item.name or "")) then + table.insert(names, shellQuote(item.name)) + end + end + if #names == 0 then + fallback() + return + end + local started = noctalia.runAsync(backend.installedVersionsCommand(names), function(result) + if result.timedOut then + fallback() + return + end + local installed = {} + for line in (result.stdout or ""):gmatch("[^\n]+") do + local name, version = line:match("^(%S+)%s+(%S+)$") + if name ~= nil then + installed[name] = version + end + end + local applied = {} + for _, item in ipairs(packages) do + if item.source == "flatpak" or tostring(item.from or "") == "" then + table.insert(applied, item) + elseif installed[item.name] ~= nil and installed[item.name] ~= item.from then + table.insert(applied, item) + end + end + recordRun(kind, applied, txId) + publish() + end, SIZE_TIMEOUT_MS) + if not started then + fallback() + end +end + finishRun = function(code) runExit = code runStaleS = 0 if code == 0 then - -- Native-rollback backends stamp the run with the package manager's - -- own transaction id, fetched right after the run finishes; the - -- history entry is then written asynchronously. - if runKind == "update" and backend.txIdCommand ~= nil and type(runPackages) == "table" then - local kind, packages = runKind, runPackages - local started = noctalia.runAsync(backend.txIdCommand, function(result) - recordRun(kind, packages, backend.parseTxId(result.stdout)) - publish() - end, FAST_TIMEOUT_MS) - if not started then - recordRun(kind, packages) - end - else - recordRun(runKind, runPackages) - end if runKind == "rollback" then + -- One atomic transaction: exit 0 means it all applied. + recordRun(runKind, runPackages) noctalia.notify(tr("title"), tr("notify_rollback_ok")) if cfg("rollback_auto_ignore") == true and type(runPackages) == "table" then for _, item in ipairs(runPackages) do @@ -1105,7 +1260,23 @@ finishRun = function(code) end end else + -- Native-rollback backends stamp the run with the package + -- manager's own transaction id, fetched right after the run + -- finishes; the history entry is then written asynchronously, + -- verified against the installed versions where the backend can. + local kind, packages = runKind, runPackages + if backend.txIdCommand ~= nil and type(packages) == "table" then + local started = noctalia.runAsync(backend.txIdCommand, function(result) + verifyAndRecordRun(kind, packages, backend.parseTxId(result.stdout)) + end, FAST_TIMEOUT_MS) + if not started then + verifyAndRecordRun(kind, packages) + end + else + verifyAndRecordRun(kind, packages) + end noctalia.notify(tr("title"), tr("notify_run_ok")) + recordUpdateRun() -- rollbacks don't count as "updated" end runPackages = nil clearRunMeta() @@ -1128,7 +1299,9 @@ end -- One `tail` per poll: the last screenful for display plus a full-file -- progress count, so the bar doesn't reset when early lines scroll out of --- the tail window. +-- the tail window. The count matches the backend's package lines and +-- Flatpak's per-ref "Updating app/..." lines, so a run with pending +-- Flatpak updates can still reach 100%. local function pollRunLog() local path = logPath() if path == nil then @@ -1136,17 +1309,24 @@ local function pollRunLog() end local keep = math.max(6, math.min(30, tonumber(cfg("log_lines")) or 14)) local quoted = shellQuote(path) + local countPattern = backend.progressPattern .. "|^(Updating|Installing) (app|runtime)/" local cmd = "tail -n " .. tostring(keep + 8) .. " " .. quoted .. " 2>/dev/null" - .. "; printf '::COUNT %s\\n' \"$(grep -cE " .. shellQuote(backend.progressPattern) .. " " .. quoted .. " 2>/dev/null)\"" + .. "; printf '::COUNT %s\\n' \"$(grep -cE " .. shellQuote(countPattern) .. " " .. quoted .. " 2>/dev/null)\"" local started = noctalia.runAsync(cmd, function(result) if phase ~= "running" then return end local text = result.stdout or "" if text == lastTailText then - runStaleS += RUN_POLL_SECONDS - if runStaleS >= RUN_STALE_LIMIT_S then - finishRun(-1) + -- Staleness only means "stuck" for non-interactive runs. In a + -- terminal the user may sit on a prompt for any amount of time; + -- killing the tracking there would also offer a retry that + -- clobbers the log under the live process. + if runMode ~= "terminal" then + runStaleS += RUN_POLL_SECONDS + if runStaleS >= RUN_STALE_LIMIT_S then + finishRun(-1) + end end return end @@ -1192,11 +1372,13 @@ end local function resumeRunIfActive() local path = logPath() if path == nil then + resumeProbed = true return end local quoted = shellQuote(path) local cmd = "head -n 1 " .. quoted .. " 2>/dev/null; tail -n 3 " .. quoted .. " 2>/dev/null" local started = noctalia.runAsync(cmd, function(result) + resumeProbed = true if phase ~= "idle" then return end @@ -1212,9 +1394,10 @@ local function resumeRunIfActive() return end loadRunMeta() - beginRun(runKind, runTotal > 0 and runTotal or nil) + beginRun(runKind, runTotal > 0 and runTotal or nil, runMode) end, FAST_TIMEOUT_MS) if not started then + resumeProbed = true noctalia.log("linux-updater: could not inspect the update log") end end @@ -1334,7 +1517,14 @@ local function startRollback(payload) local deps = {} if not result.timedOut and result.exitCode == 0 then for line in (result.stdout or ""):gmatch("[^\n]+") do - table.insert(deps, trim(line)) + -- Dependency lists may carry raw depend strings: version + -- pins ("libelf=0.196", "linux-api-headers>=4.10") and + -- soname provides included. Strip the constraint so the + -- name can match the run's package list. + local dep = trim(line):match("^([^<>=]+)") + if dep ~= nil and dep ~= "" then + table.insert(deps, dep) + end end end finish(deps) @@ -1557,6 +1747,13 @@ local function handle(action, payload) if action == "check" then startCheck() elseif action == "update" then + -- Follows the update_mode setting; background is the default. + if cfg("update_mode") == "terminal" then + runUpdateTerminal() + else + runUpdate() + end + elseif action == "update_background" then runUpdate() elseif action == "update_terminal" then runUpdateTerminal() @@ -1606,6 +1803,7 @@ end) -- Scriptable control: -- noctalia msg plugin umedbazarov/linux-updater:service all check -- noctalia msg plugin umedbazarov/linux-updater:service all update +-- noctalia msg plugin umedbazarov/linux-updater:service all update_background -- noctalia msg plugin umedbazarov/linux-updater:service all update_terminal -- noctalia msg plugin umedbazarov/linux-updater:service all dismiss -- noctalia msg plugin umedbazarov/linux-updater:service all ignore:NAME @@ -1630,7 +1828,13 @@ function update() local hours = tonumber(cfg("auto_check_hours")) or 0 if hours > 0 then if phase == "idle" then - startupTicks += 1 + -- The startup auto-check waits for resumeRunIfActive's log + -- probe: if a detached run is still going, starting a check + -- first would flip phase to "checking" and the probe's + -- callback would decline to re-attach. + if resumeProbed then + startupTicks += 1 + end if startupTicks >= AUTO_CHECK_DELAY then startCheck() end diff --git a/linux-updater/tests/run.sh b/linux-updater/tests/run.sh new file mode 100755 index 00000000..bea42752 --- /dev/null +++ b/linux-updater/tests/run.sh @@ -0,0 +1,175 @@ +#!/bin/sh +# Layer-1 parser tests: run every backend's parseCheck against the recorded +# fixtures with the `luau` CLI (pacman -S luau). The CLI has no file IO, so +# each case is assembled into one temporary chunk: fixture text as a long +# string + the backend source wrapped into a factory + assertions. +# +# Usage: tests/run.sh (from the plugin root or anywhere) + +set -u +cd "$(dirname "$0")/.." || exit 1 + +command -v luau >/dev/null 2>&1 || { echo "SKIP: luau CLI not installed (pacman -S luau)"; exit 0; } + +pass=0 +fail=0 + +# run_case +run_case() { + backend=$1 + fixture=$2 + asserts=$3 + tmp=$(mktemp --suffix=.luau) + { + echo "local FIXTURE = [=======[" + cat "$fixture" + echo "]=======]" + echo "local factory = (function()" + cat "$backend" + echo "end)()" + cat << 'HARNESS' +local env = { + trim = function(s) return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) end, + shellQuote = function(s) return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end, + cfg = function(_) return nil end, + commandExists = function(_) return false end, + MAX_LISTED = 300, + osRelease = "", +} +local backend = factory(env) +local function parse(ignoredNames) + local ignored = {} + for _, n in ipairs(ignoredNames or {}) do ignored[n] = true end + local out = {} + local n, items = backend.parseCheck(FIXTURE, ignored, out) + return n, items, out +end +local function fatal(msg) + print("FAIL: " .. msg) + error(msg, 0) +end +local function eq(got, want, what) + if got ~= want then + fatal(what .. ": got " .. tostring(got) .. ", want " .. tostring(want)) + end +end +HARNESS + echo "$asserts" + echo 'print("OK")' + } > "$tmp" + out=$(luau "$tmp" 2>&1) + if [ "$(printf '%s' "$out" | tail -n 1)" = "OK" ]; then + pass=$((pass + 1)) + echo "ok $(basename "$backend" .luau) / $(basename "$fixture")" + else + fail=$((fail + 1)) + echo "FAIL $(basename "$backend" .luau) / $(basename "$fixture")" + printf '%s\n' "$out" | sed 's/^/ /' + fi + rm -f "$tmp" +} + +# ── pacman: checkupdates format, [ignored] marker, plugin-ignore routing ───── +cat > /tmp/linup-test-pacman.txt << 'EOF' +zip 3.0-13 -> 3.0-14 +libical 4.0.4-1 -> 4.0.5-1 +assistant 6.5-1 -> 6.5.0-3 [ignored] +EOF +run_case backends/pacman.luau /tmp/linup-test-pacman.txt ' +local n, items, ign = parse({"libical"}) +eq(n, 1, "pacman count") +eq(items[1].name, "zip", "pacman name") +eq(items[1].from, "3.0-13", "pacman from") +eq(items[1].to, "3.0-14", "pacman to") +eq(#ign, 2, "pacman ignored entries") +eq(ign[1].source, "plugin", "plugin ignore routed (fixture order)") +eq(ign[2].source, "system", "IgnorePkg routed as system") +' + +# ── packagekit: identical format on Fedora and Ubuntu ──────────────────────── +run_case backends/packagekit.luau fixtures/packagekit/fedora41-get-updates.txt ' +local n, items = parse({}) +eq(n, 5, "pk fedora count") +eq(items[1].name, "curl", "pk fedora name") +eq(items[1].to, "8.9.1-4.fc41", "pk fedora version") +eq(items[3].name, "openssl-libs", "pk epoch name split") +eq(items[3].to, "1:3.2.6-2.fc41", "pk epoch kept in version") +' +run_case backends/packagekit.luau fixtures/packagekit/ubuntu2404-get-updates.txt ' +local n, items = parse({}) +eq(n, 2, "pk ubuntu count") +eq(items[2].name, "libcurl4t64", "deb name with digits split") +eq(items[2].to, "8.5.0-2ubuntu10.11", "deb version") +' + +# ── dnf: name|old|new composed by the check shell ──────────────────────────── +run_case backends/dnf.luau fixtures/dnf/fedora41-backend-check.txt ' +local n, items = parse({"vim-data"}) +eq(n, 4, "dnf count minus ignored") +eq(items[1].name, "curl", "dnf name") +eq(items[1].from, "8.9.1-2.fc41", "dnf old version") +eq(items[3].from, "1:3.2.2-9.fc41", "dnf epoch old") +' + +# ── apt: list --upgradable + ::HOLDS separator ─────────────────────────────── +run_case backends/apt.luau fixtures/apt/ubuntu2404-backend-check.txt ' +local n, items, ign = parse({"libudev1"}) +eq(n, 3, "apt count minus ignored") +eq(items[1].name, "curl", "apt name") +eq(items[1].from, "8.5.0-2ubuntu10", "apt old") +eq(items[1].to, "8.5.0-2ubuntu10.11", "apt new") +eq(ign[1].name, "libudev1", "apt plugin-ignore routed") +' +cat > /tmp/linup-test-apt-hold.txt << 'EOF' +Listing... +curl/noble-updates 8.5.0-2ubuntu10.11 amd64 [upgradable from: 8.5.0-2ubuntu10] +::HOLDS +curl +EOF +run_case backends/apt.luau /tmp/linup-test-apt-hold.txt ' +local n, items, ign = parse({}) +eq(n, 0, "held package not pending") +eq(ign[1].source, "system", "apt hold routed as system") +' + +# ── zypper: table rows, header and warnings skipped ────────────────────────── +run_case backends/zypper.luau fixtures/zypper/tw-list-updates.txt ' +local n, items = parse({}) +eq(n, 1, "zypper count") +eq(items[1].name, "openSUSE-build-key", "zypper name") +eq(items[1].from, "1.0-68.1", "zypper current") +eq(items[1].to, "1.0-69.1", "zypper available") +' + +# ── xbps: pkgver split at the last dash ────────────────────────────────────── +run_case backends/xbps.luau fixtures/xbps/void-check.txt ' +local n, items = parse({}) +eq(n, 1, "xbps count") +eq(items[1].name, "libarchive", "xbps name") +eq(items[1].to, "3.8.9_1", "xbps version") +' + +# ── fail-closed ignores: the upgrade must be gated (&&) on refresh/hold/lock +# setup, never chained with ";" — a failed hold must stop the upgrade ──────── +check_gated() { + file=$1 + what=$2 + run_case "$file" /dev/null " +local cmd = backend.buildBackgroundCommand({\"pkga\", \"pkgb\"}) +local upgradeAt = cmd:find(\"$what\", 1, true) or fatal(\"$what missing from bg command\") +local prefix = cmd:sub(1, upgradeAt - 1) +if prefix:find(\";\", 1, true) ~= nil then + fatal(\"$(basename "$file" .luau): hold/refresh chained with ; before the upgrade (fail-open)\") +end +if prefix:find(\"&&\", 1, true) == nil then + fatal(\"$(basename "$file" .luau): upgrade not gated on the ignore setup\") +end +" +} +check_gated backends/apt.luau "apt-get -y" +check_gated backends/zypper.luau " up" +check_gated backends/xbps.luau "xbps-install -Suy" + +echo "----" +echo "passed: $pass, failed: $fail" +[ "$fail" -eq 0 ] diff --git a/linux-updater/translations/en.json b/linux-updater/translations/en.json index 1bf3d081..0f8f727b 100644 --- a/linux-updater/translations/en.json +++ b/linux-updater/translations/en.json @@ -7,6 +7,33 @@ "action_polkit_install": "Ask once", "action_run_terminal": "Retry in terminal", "action_update": "Update", + "activity": { + "days_ago": { + "one": "1 day ago", + "other": "{count} days ago" + }, + "hours_ago": { + "one": "1 hour ago", + "other": "{count} hours ago" + }, + "just_now": "just now", + "minutes_ago": { + "one": "1 minute ago", + "other": "{count} minutes ago" + }, + "never_updated": "Never updated", + "pending_at": { + "one": "1 pending update", + "other": "{count} pending updates" + }, + "title": "Activity", + "updated": "Updated", + "updated_days_ago": { + "one": "Updated 1 day ago", + "other": "Updated {count} days ago" + }, + "updated_today": "Updated today" + }, "caption_checked": "checked {time}", "caption_ignored": { "one": "1 package ignored", @@ -43,7 +70,7 @@ "launcher": { "check_subtitle": "Check for pending updates", "news_subtitle": "Open the distribution news page", - "update_subtitle": "Update in the background (polkit password prompt)" + "update_subtitle": "Install the pending updates (background or terminal, per the update mode setting)" }, "log_title": "Update log", "log_waiting": "Waiting for the polkit password prompt and the first output…", @@ -79,6 +106,14 @@ "selfcheck_fix": "Fix", "selfcheck_generic": "The system needs a one-time setup step", "settings": { + "activity_history_length": { + "description": "How many of the most recent checks to keep for the activity graph.", + "label": "Activity history length" + }, + "show_activity_graph": { + "description": "Track pending-update counts across recent checks and when you last updated, shown as a small graph above the update history strip. Off (default) also stops recording the history.", + "label": "Show activity graph" + }, "aur_check_cmd": { "description": "Only used when the AUR helper above is 'Custom command'. Must print one 'name oldver -> newver' line per package, like 'yay -Qua' does.", "label": "Custom AUR check command" @@ -164,8 +199,16 @@ "label": "Show download size" }, "terminal": { - "description": "Terminal command used only for the 'Retry in terminal' fallback, e.g. kitty or ghostty. Empty (default) uses Noctalia's terminal detection ($TERMINAL, then the common emulators).", - "label": "Terminal (fallback)" + "description": "Terminal command for terminal-mode updates and the 'Retry in terminal' fallback, e.g. kitty or ghostty. Empty (default) uses Noctalia's terminal detection ($TERMINAL, then the common emulators).", + "label": "Terminal" + }, + "update_mode": { + "description": "How the Update button runs the upgrade. 'In the background' (default) is fully non-interactive (--noconfirm and friends, polkit password dialog) with a live log in the panel. 'In a terminal window' opens your terminal, where prompts and the PKGBUILD review (Arch) work as usual.", + "label": "Update mode", + "options": { + "background": "In the background (non-interactive)", + "terminal": "In a terminal window" + } }, "update_cmd": { "description": "Full override for the background update command. Empty (default) builds it from the backend and the settings above.", @@ -193,18 +236,20 @@ }, "status_rolling_back": "Rolling back in the background…", "status_running": "Updating in the background…", + "status_running_terminal": "Updating in a terminal window…", "tip_check": "Check for updates now", "tip_close": "Close", "tip_copy": "Copy name and versions", "tip_history_segment": "Click to see this run's packages", "tip_ignore": "Ignore this package: hide it from the count and skip it on update", "tip_open_page": "Open package page", - "tip_polkit_install": "Install a polkit rule (asks for your password once): afterwards one password covers a whole update run, kept for ~5 minutes like sudo", + "tip_polkit_install": "Install a polkit rule (asks for your password once): afterwards one password covers a whole update run, kept for ~5 minutes like sudo. Note: the rule applies to any pkexec launch of the package-manager binaries from your active local session, not only this plugin.", "tip_rollback": "Roll back to {version}, together with its dependencies from this run. pacman refuses the whole transaction if it would break other packages.", "tip_rollback_run": "Downgrade every package of this run in one transaction", "tip_run_terminal": "Open a terminal window where prompts work normally", "tip_unignore": "Stop ignoring this package", "tip_update": "Update in the background: polkit asks for your password, everything else is automatic", + "tip_update_terminal": "Update in a terminal window: prompts work as usual", "title": "Linux Updater", "tooltip_checked": "Checked", "tooltip_hints": "click: panel · right: check now", From 213747c93e0e6bad852a00990e5a0a9b21c93625 Mon Sep 17 00:00:00 2001 From: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:32:52 +0300 Subject: [PATCH 3/5] linux-updater: regenerate thumbnail with the thumbnail generator Co-Authored-By: Claude Fable 5 --- linux-updater/thumbnail.webp | Bin 25400 -> 53678 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/linux-updater/thumbnail.webp b/linux-updater/thumbnail.webp index e4f6a662b48b541e533a45acd9715c39fc085105..8038df5869b255729e144ef139c368d116b1cbfe 100644 GIT binary patch literal 53678 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 a^^ox>UMu<`0ctT{aC+VZ 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 From 2d5f69a309e43953c529d4ac6112359ee1d931db Mon Sep 17 00:00:00 2001 From: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:07:37 +0300 Subject: [PATCH 4/5] linux-updater: force the C locale on the apt and pkcon checks Their parsers key on localized text (apt's '[upgradable from: ...]', pkcon's severity words); a non-English locale would count zero updates. Co-Authored-By: Claude Fable 5 --- linux-updater/backends/apt.luau | 4 +++- linux-updater/backends/packagekit.luau | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/linux-updater/backends/apt.luau b/linux-updater/backends/apt.luau index 217e53bf..4f3fa196 100644 --- a/linux-updater/backends/apt.luau +++ b/linux-updater/backends/apt.luau @@ -56,7 +56,9 @@ polkit.addRule(function(action, subject) { -- deliberate on their side, not a parsing gap. Held packages carry -- "[upgradable from: ...]" too and are filtered by the parser below via -- the system hold list appended after a separator line. - backend.checkCommand = [[apt list --upgradable 2>/dev/null; echo '::HOLDS'; apt-mark showhold 2>/dev/null]] + -- LC_ALL=C: apt localizes the "[upgradable from: ...]" bracket the + -- parser keys on, so a non-English locale would count zero updates. + backend.checkCommand = [[LC_ALL=C apt list --upgradable 2>/dev/null; echo '::HOLDS'; apt-mark showhold 2>/dev/null]] function backend.parseCheck(output, ignored, ignoredOut) local items = {} diff --git a/linux-updater/backends/packagekit.luau b/linux-updater/backends/packagekit.luau index fe44d067..cd4f056f 100644 --- a/linux-updater/backends/packagekit.luau +++ b/linux-updater/backends/packagekit.luau @@ -31,8 +31,9 @@ return function(env) } -- pkcon exits 5 when there is nothing to report; normalize to 0 so only - -- a genuine failure reaches the engine. - backend.checkCommand = [[pkcon -p get-updates 2>/dev/null; code=$?; if [ "$code" -eq 5 ]; then exit 0; fi; exit "$code"]] + -- a genuine failure reaches the engine. LC_ALL=C: pkcon localizes the + -- severity words the parser keys on ("Security", "Bug fix", ...). + backend.checkCommand = [[LC_ALL=C pkcon -p get-updates 2>/dev/null; code=$?; if [ "$code" -eq 5 ]; then exit 0; fi; exit "$code"]] -- Result lines look like: -- Security curl-8.9.1-4.fc41.x86_64 (updates) From 4135c394e36e2e3e6218435322fa997c02cd1b69 Mon Sep 17 00:00:00 2001 From: Umedjon Bazarov <170195993+UmedjonBA@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:02:18 +0300 Subject: [PATCH 5/5] linux-updater: extra sources, wider rollback, soft-fail checks; 1.0.0 - Opt-in extra sources beyond the system manager: global npm, cargo (via cargo-update), pip (check-only, PEP 668), RubyGems, Snap and Homebrew ride along with the check and the update run, honor the plugin ignore list, and roll back per item where their manager has a mechanism (npm/gem/cargo reinstall the recorded version, snap reverts, Flatpak pins the full commit recorded at check time). - Rollback grew two roads: apt from the archive cache (per package and per run, epoch-encoded filenames, probe greys gone entries) and dnf per-package downgrade while the old version is in a repo (cache-only repoquery probe reports the reason before the attempt). - The plugin ignore list is now honored on the PackageKit backend too (explicit pending-minus-ignored package list). - A failing secondary source (AUR mirror down, registry unreachable) no longer aborts the check: it is reported under the headline and the remaining sources are still checked. - Settings that change what a check would count invalidate the last result instead of showing stale numbers; LC_ALL=C pinned wherever output is parsed; scoped npm names accepted in the ignore list without reaching the native backend's hold path. - Version 1.0.0: first public release starts at a clean major. - Fixtures recorded from real containers (node:22, ruby:3.3, rust:1, python:3.12, ubuntu:24.04, fedora:41); the README Testing status section spells out what was verified where, and that snap/brew parsers are from documented formats only. --- linux-updater/README.md | 85 ++- linux-updater/backends/apt.luau | 37 +- linux-updater/backends/dnf.luau | 24 + linux-updater/backends/packagekit.luau | 34 +- linux-updater/backends/pacman.luau | 10 +- linux-updater/extras.luau | 290 ++++++++++ .../fixtures/extras/brew-outdated-quiet.txt | 2 + .../extras/cargo-install-update-list.txt | 5 + .../fixtures/extras/gem-outdated.txt | 3 + .../extras/npm-outdated-parseable.txt | 4 + .../fixtures/extras/pip-list-outdated.txt | 4 + .../fixtures/extras/snap-refresh-list.txt | 3 + linux-updater/launcher.luau | 19 +- linux-updater/panel.luau | 145 ++++- linux-updater/plugin.toml | 48 +- linux-updater/service.luau | 509 ++++++++++++++++-- linux-updater/tests/run.sh | 105 ++++ linux-updater/translations/en.json | 41 +- 18 files changed, 1265 insertions(+), 103 deletions(-) create mode 100644 linux-updater/extras.luau create mode 100644 linux-updater/fixtures/extras/brew-outdated-quiet.txt create mode 100644 linux-updater/fixtures/extras/cargo-install-update-list.txt create mode 100644 linux-updater/fixtures/extras/gem-outdated.txt create mode 100644 linux-updater/fixtures/extras/npm-outdated-parseable.txt create mode 100644 linux-updater/fixtures/extras/pip-list-outdated.txt create mode 100644 linux-updater/fixtures/extras/snap-refresh-list.txt diff --git a/linux-updater/README.md b/linux-updater/README.md index 7fc840ac..319f58af 100644 --- a/linux-updater/README.md +++ b/linux-updater/README.md @@ -24,10 +24,14 @@ automatically from `/etc/os-release`. installed versions, so declined packages are not recorded). - **Update history with rollback.** Every finished run becomes a segment on the history strip (hover for date and size, click for the package list). - On Arch, single packages or whole runs roll back from the package cache — - dependencies from the same run travel along, and pacman refuses anything - that would break other packages. On Fedora a whole run is undone with - `dnf history undo`. A second click confirms every rollback. + Rollback takes whatever road the distribution offers: on Arch and + Debian/Ubuntu single packages or whole runs come back from the package + cache (dependencies from the same run travel along, and the package + manager refuses anything that would break other packages), on Fedora a + whole run is undone with `dnf history undo` and single packages with + `dnf downgrade` while the old version is still in a repo. Buttons grey + out with the reason when the cached file or repo version is gone. A + second click confirms every rollback. - **Ignore management.** Every package row has an ignore button; ignored packages live in an expandable section with restore buttons. The system's own mechanisms (`IgnorePkg`, `apt-mark hold`) are detected and shown with @@ -41,6 +45,16 @@ automatically from `/etc/os-release`. off (the polkit rule, apt's list-refresh timers), the panel says so and offers a one-click, one-confirmation fix. Nothing is ever changed silently. +- **Extra sources (opt-in).** Beyond the system manager: global npm + packages, cargo-installed binaries (via cargo-update), RubyGems, Snap + and Homebrew can each be checked and updated in the same run, and pip + can be checked (check-only by design: distribution Pythons are + externally managed, PEP 668). Each is its own off-by-default toggle and + is silently skipped when its tool is absent. They work with any + backend, like Flatpak — and most of them roll back too, each through + its manager's own mechanism: npm/gem/cargo reinstall the recorded old + version, snap reverts to the locally kept previous revision, and + Flatpak apps pin the previous commit recorded at check time. - **Extras.** Download-size estimate and Arch news (pacman backend), AUR via paru/yay, Flatpak on every backend, reboot recommendation with the best available method per distribution, desktop notifications, launcher @@ -63,14 +77,25 @@ automatically from `/etc/os-release`. | Background update | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Old→new versions in the list | ✓ | ✓ | ✓ | ✓ | new only | new only | | Download size estimate | ✓ | — | — | — | — | — | -| Rollback | per package / per run, from the package cache | whole run, `dnf history undo` | — | — (use snapper) | — | — | +| Rollback | per package / per run, from the package cache | whole run (`dnf history undo`) + per package (`dnf downgrade`, while the old version is in a repo) | per package / per run, from the apt archive cache (kept on Debian, routinely cleaned on Ubuntu; gone entries are greyed out) | — (use snapper) | — | — | | System ignore shown | `IgnorePkg` | — | `apt-mark hold` | — | — | — | -| Plugin ignore honored on update | `--ignore` | `--exclude` | hold for the run | lock for the run | hold for the run | display only | +| Plugin ignore honored on update | `--ignore` | `--exclude` | hold for the run | lock for the run | hold for the run | explicit pending-minus-ignored list | | Distribution news | Arch news feed | — | — | — | — | — | | AUR layer | ✓ (paru/yay) | — | — | — | — | — | | Reboot detection | kernel modules | `needs-restarting` | `/var/run/reboot-required` | `zypper needs-rebooting` | kernel modules | kernel modules | -Flatpak checking and updating works on every backend. NixOS is not +Flatpak checking and updating works on every backend, and so do the +opt-in extra sources (npm, Cargo, pip [check-only], RubyGems, Snap, +Homebrew) — they ride along with the update run, honor the plugin ignore +list, and offer per-item rollback where their manager has a mechanism +for it: `npm -g install `, `gem install -v `, +`cargo install --version `, `snap revert`, and +`flatpak update --commit=` for Flatpak apps. pip and Homebrew +have none. A secondary source that cannot be checked (an unreachable AUR +mirror, a registry that is down) fails only itself: the remaining sources +are still checked, and the panel reports the failed one instead of +counting it as up to date. Only a failure of the system package manager's +own check stops the run. NixOS is not supported by design (see [nix-monitor](https://noctalia.dev/plugins/avivbintangaringga/nix-monitor) instead); Gentoo has no backend yet — the backend interface in @@ -100,6 +125,9 @@ one system. - Optional: `paru`/`yay` (AUR, Arch family), `flatpak`, `xdg-open` (open package pages), `less` (full-log pager), `sudo` + a terminal emulator for terminal-mode updates and the **Retry in terminal** fallback. +- Optional, only when the matching extra source is enabled: `npm`, + `cargo-install-update` (from cargo-update), `pip`, `gem`, `snap`, + `brew`. ## Usage @@ -143,6 +171,12 @@ fuzzy-search pending packages. | `aur_helper` | `select` | `auto` | AUR helper (Arch family only): auto/yay/paru/custom/off. | | `aur_check_cmd` | `string` | *(empty)* | Custom AUR check command when `aur_helper` is `custom`. | | `flatpak_enabled` | `bool` | `true` | Also check and update Flatpak. | +| `npm_enabled` | `bool` | `false` | Also check and update global npm packages. | +| `cargo_enabled` | `bool` | `false` | Also check and update cargo-installed binaries (needs cargo-update). | +| `pip_enabled` | `bool` | `false` | Also check outdated pip packages (check-only, PEP 668). | +| `gem_enabled` | `bool` | `false` | Also check and update RubyGems. | +| `snap_enabled` | `bool` | `false` | Also check and update snaps (snapd's own polkit). | +| `brew_enabled` | `bool` | `false` | Also check and update Homebrew packages. | | `ignore_packages` | `string_list` | *(empty)* | Packages excluded from the count and skipped on update (see matrix for the mechanism per backend). | | `auto_check_hours` | `int` | `0` | Check automatically every N hours; 0 never. | | `notify_on_updates` | `bool` | `true` | Desktop notification when updates are found. | @@ -159,6 +193,11 @@ fuzzy-search pending packages. | `terminal` | `string` | *(empty)* | Terminal for terminal-mode updates and the fallback; empty uses Noctalia's detection. | | `update_cmd` | `string` | *(empty)* | Full override for the background update command. | +Settings that change what a check would count (backend, AUR helper, the +Flatpak and extra-source toggles, the ignore list) invalidate the last +result: the panel returns to "Not checked yet" instead of showing numbers +the new settings would not produce. Cosmetic settings leave it alone. + ## IPC ```sh @@ -179,8 +218,12 @@ noctalia msg plugin umedbazarov/linux-updater:service all unignore:NAME polkit path, or `sudo` in terminal mode), detached, logged to `/update.log` and followed with `tail`; `flatpak list/remote-ls/update`; `pactree`/`rpm`/`dpkg-query`/`apt-mark`/ - `zypper locks`/`xbps-pkgdb` where the matrix says so. The full command - list is declared in `dependencies` in `plugin.toml`. + `zypper locks`/`xbps-pkgdb` where the matrix says so; and, per enabled + extra source, its own read-only listing (`npm -g outdated`, + `cargo install-update --list`, `pip list --outdated`, `gem outdated`, + `snap refresh --list`, `brew outdated`) plus its update command in the + run. The full command list is declared in `dependencies` in + `plugin.toml`. - **Privileges.** Escalation only through polkit, only for package-manager binaries; the optional keep-authorization rules (shipped in `polkit/`, installable from the panel with one confirmed click) are scoped to those @@ -209,12 +252,30 @@ Honest coverage, so expectations are set right: fixtures live in `fixtures/`, the test harness is `tests/run.sh` (needs the `luau` CLI; not wired into this repository's CI, which validates manifests only). +- **Rollback and ignore paths verified in containers:** the apt cache + rollback (Ubuntu 24.04: a curl+libcurl4t64 pair downgraded in one + transaction from `/var/cache/apt/archives`, epoch-encoded filenames), + the dnf per-package downgrade (Fedora 41: exact-version downgrade + succeeds while the version is in a repo; the repoquery probe answers + ok/miss so the panel can grey the button with the reason), and + PackageKit's explicit-list update (Fedora 41 with a hand-started + dbus/polkitd: `pkcon update curl` upgraded curl and left the + "ignored" package untouched). +- **Extra sources: verified in containers** — full update → rollback + cycles for npm (node:22), RubyGems (ruby:3.3) and Cargo (rust:1) with + the plugin's exact commands; pip's outdated listing (python:3.12); and + the whole Flatpak commit story on Ubuntu 24.04 (downgrade by full + commit works, a 12-char prefix is rejected by the server — which is why + the check records the full active commit). The fixtures under + `fixtures/extras/` are these containers' real outputs. **Snap and + Homebrew parsers are written from documented formats only** (snapd + needs systemd, brew a full bootstrap — neither fits a container run). - **Not yet verified by anyone:** live polkit dialogs and the full UI on non-Arch distributions (containers cannot reproduce a polkit session), the dnf4 output branch (fixtures cover dnf5), Debian-specific deviations - from Ubuntu. Treat non-Arch backends as **beta** — the capability matrix - above is enforced in code, so the worst case is a missing feature, not a - broken system. + from Ubuntu, snap/brew against live tools. Treat non-Arch backends as + **beta** — the capability matrix above is enforced in code, so the + worst case is a missing feature, not a broken system. **I would be genuinely glad to see this tested on other package managers and distributions — Fedora, Ubuntu/Debian/Mint, openSUSE, Void, anything diff --git a/linux-updater/backends/apt.luau b/linux-updater/backends/apt.luau index 4f3fa196..7876ab55 100644 --- a/linux-updater/backends/apt.luau +++ b/linux-updater/backends/apt.luau @@ -12,15 +12,18 @@ -- -- debconf prompts need no DEBIAN_FRONTEND here: with no controlling tty it -- falls back to its noninteractive frontend on its own, and the detached --- runner never has one. Rollback is off: /var/cache/apt/archives is --- routinely empty (Ubuntu cleans it) and old versions leave the repos. +-- runner never has one. Rollback is opportunistic, from the .deb files in +-- /var/cache/apt/archives: Debian keeps them by default, Ubuntu routinely +-- cleans them — the run probe greys out packages whose old file is gone, +-- so the panel never offers a rollback it cannot perform. return function(env) local MAX_LISTED = env.MAX_LISTED + local shellQuote = env.shellQuote local backend = { id = "apt", - caps = { bgUpdate = true, size = false, rollback = false, news = false, aur = false }, + caps = { bgUpdate = true, size = false, rollback = "cache", news = false, aur = false }, checkTool = "apt-get", missingToolKey = "err_no_apt", nativeLabelKey = "source.system", @@ -134,6 +137,34 @@ polkit.addRule(function(action, subject) { backend.rebootCommand = [[test -f /var/run/reboot-required && echo missing || echo present]] + -- sh helper: prints the cached .deb for "name version". The filename + -- encodes the epoch colon as %3a ("1:2.0-1" -> "1%3a2.0-1"); the arch + -- part is globbed (amd64/arm64/all). + backend.findPkgSh = [[find_pkg() { +ver=$(printf '%s' "$2" | sed 's/:/%3a/g') +for f in /var/cache/apt/archives/"$1"_"$ver"_*.deb; do + if [ -f "$f" ]; then printf '%s\n' "$f"; return 0; fi +done +return 1 +}]] + + -- apt-get resolves dependencies for local .deb paths and refuses the + -- whole transaction if the downgrade would break other packages — + -- the same semantics the pacman cache path relies on. + backend.rollbackInstall = "pkexec apt-get install -y --allow-downgrades" + .. " -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold" + + -- Recursive installed-dependency names of a package, one per line, for + -- the run-mates resolution (virtual unwrapped). + function backend.depsListCommand(name) + return "apt-cache depends --recurse --installed " .. shellQuote(name) + .. [[ 2>/dev/null | awk '/^ *(Pre)?Depends:/{gsub(/[<>]/,"",$2); print $2}']] + end + + -- Installed reverse dependencies count for "$1"; the first two rdepends + -- lines are the package name and the "Reverse Depends:" header. + backend.reverseDepsCountSh = [[n=$(apt-cache rdepends --installed "$1" 2>/dev/null | tail -n +3 | wc -l)]] + -- The unprivileged check depends on the apt-daily timers keeping the -- package lists fresh; offer to enable them when they are off. backend.selfChecks = { diff --git a/linux-updater/backends/dnf.luau b/linux-updater/backends/dnf.luau index c8a4ce82..75702e82 100644 --- a/linux-updater/backends/dnf.luau +++ b/linux-updater/backends/dnf.luau @@ -130,5 +130,29 @@ fi]] return "pkexec dnf -y history undo " .. tostring(txId) end + -- Per-package rollback: downgrade to the exact recorded version + -- (name-[epoch:]version-release). Only possible while that version is + -- still in an enabled repository — Fedora's base repo keeps the GA + -- version forever, but intermediate updates disappear. + function backend.rollbackItemCommand(item) + local name = tostring(item.name or "") + local from = tostring(item.from or "") + if name:match("^[%w._+-]+$") == nil or from:match("^[%w:._+~^-]+$") == nil then + return nil + end + return "pkexec dnf -y downgrade " .. env.shellQuote(name .. "-" .. from) + end + + -- Availability probe for "$1" (name) "$2" (version), run when a history + -- run is opened: sets c=ok when the exact old version is still in the + -- repositories, c=miss otherwise, so the panel can grey the button and + -- say why BEFORE a doomed attempt. -C keeps it on the local metadata + -- cache — no network, and an unreadable cache degrades to miss. + backend.itemProbeSh = [[if [ -n "$(dnf -q -C repoquery --qf '%{name}' "$1-$2" 2>/dev/null | head -n 1)" ]; then c=ok; else c=miss; fi]] + + -- Shown instead of the generic run-failure text when a per-package + -- rollback exits non-zero: the by-far-usual cause is worth naming. + backend.rollbackFailHintKey = "err_dnf_rollback_unavailable" + return backend end diff --git a/linux-updater/backends/packagekit.luau b/linux-updater/backends/packagekit.luau index cd4f056f..4b1d8336 100644 --- a/linux-updater/backends/packagekit.luau +++ b/linux-updater/backends/packagekit.luau @@ -71,15 +71,35 @@ return function(env) return n, items end - -- PackageKit cannot exclude packages per transaction, so the plugin - -- ignore list stays a display-side filter here; a "hold" equivalent - -- would need the native manager and belongs to a native backend. - function backend.buildBackgroundCommand(_ignored) - return "pkcon -y --plain refresh && pkcon -y --plain update" + -- PackageKit cannot exclude packages per transaction, but `pkcon + -- update` accepts explicit names — so with a plugin ignore list the + -- engine passes the pending-minus-ignored snapshot (pendingNames) and + -- the update names exactly those. nil = no filter needed (update all), + -- {} = everything pending is ignored (refresh only). Dependencies of an + -- updated package can still pull an ignored one in — the same + -- best-effort semantics as --exclude elsewhere. + backend.ignoreByExplicitList = true + + function backend.buildBackgroundCommand(_ignored, pendingNames) + local base = "pkcon -y --plain refresh" + if pendingNames == nil then + return base .. " && pkcon -y --plain update" + end + if #pendingNames == 0 then + return base + end + return base .. " && pkcon -y --plain update " .. table.concat(pendingNames, " ") end - function backend.buildTerminalCommand(_ignored) - return "pkcon refresh && pkcon update" + function backend.buildTerminalCommand(_ignored, pendingNames) + local base = "pkcon refresh" + if pendingNames == nil then + return base .. " && pkcon update" + end + if #pendingNames == 0 then + return base + end + return base .. " && pkcon update " .. table.concat(pendingNames, " ") end backend.sizeCommand = nil diff --git a/linux-updater/backends/pacman.luau b/linux-updater/backends/pacman.luau index b43181db..cb6201ee 100644 --- a/linux-updater/backends/pacman.luau +++ b/linux-updater/backends/pacman.luau @@ -63,8 +63,9 @@ polkit.addRule(function(action, subject) { -- checkupdates exits 2 for "no updates" (not an error), 1 for a real -- failure. Normalize the former to 0 so only a genuine failure reaches - -- the engine. - backend.checkCommand = [[checkupdates 2>/dev/null; code=$?; if [ "$code" -eq 2 ]; then exit 0; fi; exit "$code"]] + -- the engine. LC_ALL=C: pacman translates the "[ignored]" marker the + -- parser keys on ("[Ignoriert]" on a German system). + backend.checkCommand = [[LC_ALL=C checkupdates 2>/dev/null; code=$?; if [ "$code" -eq 2 ]; then exit 0; fi; exit "$code"]] -- One "name oldver -> newver" line per package: checkupdates' format, -- which yay -Qua and paru -Qua also use. A trailing "[ignored]" is @@ -123,8 +124,9 @@ polkit.addRule(function(action, subject) { return raw ~= "" and raw or nil end -- stderr is inspected by the engine to tell a real failure from the - -- "-Qua" family's usual no-updates exit code. - return helper .. " -Qua" + -- "-Qua" family's usual no-updates exit code. LC_ALL=C for the same + -- reason as the check: the helpers are localized too. + return "LC_ALL=C " .. helper .. " -Qua" end local function ignoreFlag(ignored) diff --git a/linux-updater/extras.luau b/linux-updater/extras.luau new file mode 100644 index 00000000..8d518acc --- /dev/null +++ b/linux-updater/extras.luau @@ -0,0 +1,290 @@ +--!nonstrict +-- Extra sources: cross-distro developer/desktop package managers that live +-- alongside any backend, the way the engine's Flatpak part does. Each one is +-- strictly opt-in (its own bool setting, default off) and silently skipped +-- when its tool is not installed. +-- +-- Contract per entry (what the engine relies on): +-- key — source id; also the `source` field on run-history items +-- tool — binary gating the source (commandExists) +-- settingKey — bool setting that enables it +-- labelKey — translation key for the panel row / checking step +-- checkCommand — read-only pending-updates query, parsed by parseCheck. +-- Spawned under LC_ALL=C: the parsers key off English words +-- and fixed column layouts. +-- parseCheck(stdout, ignoredSet, ignoredOut) -> n, items +-- — same contract as a backend's parseCheck +-- buildUpdateCommand(quotedNames | nil) | nil +-- — the upgrade part appended to the update run; nil names = +-- everything pending. nil command = check-only (pip: PEP 668 +-- makes a blind upgrade of site-packages a hazard, so its +-- count is informational). +-- rollbackKind — "version" (reinstall the recorded old version through the +-- manager itself) or "revert" (the manager restores its own +-- previous state, no version needed — snap). nil = none. +-- buildRollbackCommand(item) | nil +-- — the rollback command for one run-history item, or nil when +-- the item lacks what the command needs. The manager resolves +-- its own dependencies; there is no run-mate resolution like +-- the pacman cache path has. +-- +-- Extras are excluded from the backend's rollback transaction and from the +-- installed-versions verification; their per-item rollback (where declared) +-- runs through the same detached runner as everything else. + +return function(env) + local MAX_LISTED = env.MAX_LISTED + local trim = env.trim + + -- Names reach the command line (quoted) and the panel; anything outside + -- this shape is dropped. @ and / cover npm scoped packages. + local function usableName(name) + return name:match("^[%w@/._+-]+$") ~= nil + end + + local function newList() + return { n = 0, items = {} } + end + + local function push(list, name, from, to, ignored, ignoredOut) + if not usableName(name) then + return + end + if ignored[name] then + table.insert(ignoredOut, { name = name, from = from or "", to = to or "", source = "plugin" }) + else + list.n += 1 + if #list.items < MAX_LISTED then + table.insert(list.items, { name = name, from = from or "", to = to or "" }) + end + end + end + + -- Versions reach rollback command lines (quoted); same shape the + -- engine's own versionOk accepts. + local function usableVersion(version) + return tostring(version or ""):match("^[%w:._+~-]+$") ~= nil + end + + -- Splits "name@version" at the LAST @, so npm scoped names + -- ("@scope/pkg@1.2.3") keep their prefix. + local function splitAtVersion(blob) + local name, version = blob:match("^(.+)@([^@]*)$") + if name == nil then + return blob, "" + end + return name, version + end + + local extras = {} + + -- ── npm (global packages) ──────────────────────────────────────────────── + -- `npm -g outdated` exits 1 when anything is outdated, so the exit code is + -- normalized; parse failures then show up as an empty list, not an error. + -- Parseable lines: ::: + table.insert(extras, { + key = "npm", + tool = "npm", + settingKey = "npm_enabled", + labelKey = "source.npm", + checkCommand = [[LC_ALL=C npm -g outdated --parseable 2>/dev/null; exit 0]], + parseCheck = function(output, ignored, ignoredOut) + local list = newList() + for line in (output or ""):gmatch("[^\n]+") do + local fields = {} + for field in (line .. ":"):gmatch("([^:]*):") do + table.insert(fields, field) + end + if #fields >= 4 then + local name, wanted = splitAtVersion(fields[2]) + local _, current = splitAtVersion(fields[3]) + if current == "MISSING" then + current = "" + end + push(list, name, current, wanted, ignored, ignoredOut) + end + end + return list.n, list.items + end, + buildUpdateCommand = function(quotedNames) + return "npm -g update" .. (quotedNames ~= nil and (" " .. table.concat(quotedNames, " ")) or "") + end, + rollbackKind = "version", + -- The registry keeps every published version, so rolling back is a + -- plain install of the recorded old one. + buildRollbackCommand = function(item) + local name = tostring(item.name or "") + local from = tostring(item.from or "") + if not usableName(name) or not usableVersion(from) then + return nil + end + return "npm -g install " .. env.shellQuote(name .. "@" .. from) + end, + }) + + -- ── cargo (cargo install binaries, via cargo-update) ───────────────────── + -- Table rows: "name v1.0.0 v1.1.0 Yes"; only Yes rows are pending. + table.insert(extras, { + key = "cargo", + tool = "cargo-install-update", + settingKey = "cargo_enabled", + labelKey = "source.cargo", + checkCommand = [[LC_ALL=C cargo install-update --list 2>/dev/null]], + parseCheck = function(output, ignored, ignoredOut) + local list = newList() + for line in (output or ""):gmatch("[^\n]+") do + local name, from, to = line:match("^(%S+)%s+v?(%S+)%s+v?(%S+)%s+Yes%s*$") + if name ~= nil then + push(list, name, from, to, ignored, ignoredOut) + end + end + return list.n, list.items + end, + buildUpdateCommand = function(quotedNames) + if quotedNames ~= nil then + return "cargo install-update " .. table.concat(quotedNames, " ") + end + return "cargo install-update -a" + end, + rollbackKind = "version", + -- cargo install pins the requested version; --force replaces the + -- newer binary. This rebuilds from source, so it can take a while. + buildRollbackCommand = function(item) + local name = tostring(item.name or "") + local from = tostring(item.from or "") + if not usableName(name) or not usableVersion(from) then + return nil + end + return "cargo install --force --version " .. env.shellQuote(from) .. " " .. env.shellQuote(name) + end, + }) + + -- ── pip (check-only) ───────────────────────────────────────────────────── + -- PEP 668 marks distro Pythons externally managed, and blindly upgrading + -- site-packages breaks environments — so pip's pending list is shown but + -- never included in the update run. + table.insert(extras, { + key = "pip", + tool = "pip", + settingKey = "pip_enabled", + labelKey = "source.pip", + checkCommand = [[LC_ALL=C pip list --outdated 2>/dev/null]], + parseCheck = function(output, ignored, ignoredOut) + local list = newList() + for line in (output or ""):gmatch("[^\n]+") do + -- Two header lines: the column names and the dashes under them. + if line:match("^Package%s") == nil and line:match("^%-%-") == nil then + local name, from, to = line:match("^(%S+)%s+(%S+)%s+(%S+)") + if name ~= nil then + push(list, name, from, to, ignored, ignoredOut) + end + end + end + return list.n, list.items + end, + buildUpdateCommand = nil, + }) + + -- ── gem ────────────────────────────────────────────────────────────────── + -- Lines: "name (1.0.0 < 1.2.0)". + table.insert(extras, { + key = "gem", + tool = "gem", + settingKey = "gem_enabled", + labelKey = "source.gem", + checkCommand = [[LC_ALL=C gem outdated 2>/dev/null]], + parseCheck = function(output, ignored, ignoredOut) + local list = newList() + for line in (output or ""):gmatch("[^\n]+") do + local name, from, to = line:match("^(%S+) %((%S+) < ([^%)]+)%)") + if name ~= nil then + push(list, name, from, trim(to), ignored, ignoredOut) + end + end + return list.n, list.items + end, + buildUpdateCommand = function(quotedNames) + return "gem update" .. (quotedNames ~= nil and (" " .. table.concat(quotedNames, " ")) or "") + end, + rollbackKind = "version", + -- gem update keeps the old version installed alongside the new one, + -- so removing the new one usually suffices; the install afterwards + -- covers a cleaned-up old version and no-ops when it is still there. + buildRollbackCommand = function(item) + local name = tostring(item.name or "") + local from = tostring(item.from or "") + local to = tostring(item.to or "") + if not usableName(name) or not usableVersion(from) or not usableVersion(to) then + return nil + end + local quotedName = env.shellQuote(name) + return "gem uninstall -x -I " .. quotedName .. " -v " .. env.shellQuote(to) + .. " >/dev/null 2>&1; gem install " .. quotedName .. " -v " .. env.shellQuote(from) + end, + }) + + -- ── snap ───────────────────────────────────────────────────────────────── + -- `snap refresh --list` needs no root (snapd socket); the header row is + -- skipped, and only the new version is reported. The refresh itself + -- escalates through snapd's own polkit policies, no pkexec of ours. + table.insert(extras, { + key = "snap", + tool = "snap", + settingKey = "snap_enabled", + labelKey = "source.snap", + checkCommand = [[LC_ALL=C snap refresh --list 2>/dev/null]], + parseCheck = function(output, ignored, ignoredOut) + local list = newList() + local first = true + for line in (output or ""):gmatch("[^\n]+") do + if first then + first = false + elseif line:match("^All snaps up to date") == nil then + local name, to = line:match("^(%S+)%s+(%S+)") + if name ~= nil then + push(list, name, "", to, ignored, ignoredOut) + end + end + end + return list.n, list.items + end, + buildUpdateCommand = function(quotedNames) + return "snap refresh" .. (quotedNames ~= nil and (" " .. table.concat(quotedNames, " ")) or "") + end, + rollbackKind = "revert", + -- snapd keeps the previous revision locally; revert needs no version + -- and fails honestly when there is nothing to revert to. + buildRollbackCommand = function(item) + local name = tostring(item.name or "") + if not usableName(name) then + return nil + end + return "snap revert " .. env.shellQuote(name) + end, + }) + + -- ── brew ───────────────────────────────────────────────────────────────── + -- `--quiet` prints bare names; brew does not report a usable version pair. + table.insert(extras, { + key = "brew", + tool = "brew", + settingKey = "brew_enabled", + labelKey = "source.brew", + checkCommand = [[LC_ALL=C brew outdated --quiet 2>/dev/null]], + parseCheck = function(output, ignored, ignoredOut) + local list = newList() + for line in (output or ""):gmatch("[^\n]+") do + local name = trim(line) + if name ~= "" then + push(list, name, "", "", ignored, ignoredOut) + end + end + return list.n, list.items + end, + buildUpdateCommand = function(quotedNames) + return "brew upgrade" .. (quotedNames ~= nil and (" " .. table.concat(quotedNames, " ")) or "") + end, + }) + + return extras +end diff --git a/linux-updater/fixtures/extras/brew-outdated-quiet.txt b/linux-updater/fixtures/extras/brew-outdated-quiet.txt new file mode 100644 index 00000000..d97be7f0 --- /dev/null +++ b/linux-updater/fixtures/extras/brew-outdated-quiet.txt @@ -0,0 +1,2 @@ +wget +node diff --git a/linux-updater/fixtures/extras/cargo-install-update-list.txt b/linux-updater/fixtures/extras/cargo-install-update-list.txt new file mode 100644 index 00000000..a24cbb83 --- /dev/null +++ b/linux-updater/fixtures/extras/cargo-install-update-list.txt @@ -0,0 +1,5 @@ + Polling registry 'https://index.crates.io/'.. + +Package Installed Latest Needs update +ripgrep v14.0.3 v15.2.0 Yes +cargo-update v22.1.1 v22.1.1 No diff --git a/linux-updater/fixtures/extras/gem-outdated.txt b/linux-updater/fixtures/extras/gem-outdated.txt new file mode 100644 index 00000000..c0e6afd4 --- /dev/null +++ b/linux-updater/fixtures/extras/gem-outdated.txt @@ -0,0 +1,3 @@ +json (2.7.2 < 2.21.2) +rake (13.1.0 < 13.4.2) +rdoc (6.6.3.1 < 8.0.0) diff --git a/linux-updater/fixtures/extras/npm-outdated-parseable.txt b/linux-updater/fixtures/extras/npm-outdated-parseable.txt new file mode 100644 index 00000000..a9981cf5 --- /dev/null +++ b/linux-updater/fixtures/extras/npm-outdated-parseable.txt @@ -0,0 +1,4 @@ +/usr/local/lib/node_modules/corepack:corepack@0.35.0:corepack@0.34.6:corepack@0.35.0:global +/usr/local/lib/node_modules/npm:npm@12.0.2:npm@10.9.8:npm@12.0.2:global +/usr/local/lib/node_modules/semver:semver@7.8.5:semver@7.5.0:semver@7.8.5:global +/usr/lib:@angular/cli@18.2.10:@angular/cli@18.2.8:@angular/cli@18.2.10:global diff --git a/linux-updater/fixtures/extras/pip-list-outdated.txt b/linux-updater/fixtures/extras/pip-list-outdated.txt new file mode 100644 index 00000000..e76acab9 --- /dev/null +++ b/linux-updater/fixtures/extras/pip-list-outdated.txt @@ -0,0 +1,4 @@ +Package Version Latest Type +-------- ------- ------ ----- +pip 25.0.1 26.2.1 wheel +requests 2.31.0 2.34.2 wheel diff --git a/linux-updater/fixtures/extras/snap-refresh-list.txt b/linux-updater/fixtures/extras/snap-refresh-list.txt new file mode 100644 index 00000000..e1cdd9f4 --- /dev/null +++ b/linux-updater/fixtures/extras/snap-refresh-list.txt @@ -0,0 +1,3 @@ +Name Version Rev Size Publisher Notes +core22 20240904 1621 77MB canonical base +firefox 130.0.1-1 4848 283MB mozilla - diff --git a/linux-updater/launcher.luau b/linux-updater/launcher.luau index 0df4b5ba..b146a686 100644 --- a/linux-updater/launcher.luau +++ b/linux-updater/launcher.luau @@ -31,13 +31,23 @@ end local SOURCE_GLYPH = { pacman = "package", aur = "box", flatpak = "app-window" } +local PACKAGE_URLS = { + aur = "https://aur.archlinux.org/packages/", + flatpak = "https://flathub.org/apps/", + npm = "https://www.npmjs.com/package/", + cargo = "https://crates.io/crates/", + pip = "https://pypi.org/project/", + gem = "https://rubygems.org/gems/", + snap = "https://snapcraft.io/", + brew = "https://formulae.brew.sh/formula/", +} + local function packageUrl(sourceKey, name) if sourceKey == "pacman" then return "https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name) - elseif sourceKey == "aur" then - return "https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name) end - return "https://flathub.org/apps/" .. noctalia.string.urlEncode(name) + local prefix = PACKAGE_URLS[sourceKey] or PACKAGE_URLS.flatpak + return prefix .. noctalia.string.urlEncode(name) end local function commandResults() @@ -56,6 +66,9 @@ local function packageResults(query) local results = {} local sourceList = { { key = "pacman", entry = state.pacman }, { key = "aur", entry = state.aur }, { key = "flatpak", entry = state.flatpak } } + for _, extra in ipairs(type(state.extras) == "table" and state.extras or {}) do + table.insert(sourceList, { key = extra.key, entry = extra }) + end for _, source in ipairs(sourceList) do local items = type(source.entry) == "table" and source.entry.items or nil if type(items) == "table" then diff --git a/linux-updater/panel.luau b/linux-updater/panel.luau index f7be65a6..de9bfe06 100644 --- a/linux-updater/panel.luau +++ b/linux-updater/panel.luau @@ -59,13 +59,22 @@ end -- Opens a generic Arch package search instead of a per-repo mirror URL, so -- it stays correct across Arch-based distros. +local PACKAGE_URLS = { + aur = "https://aur.archlinux.org/packages/", + flatpak = "https://flathub.org/apps/", + npm = "https://www.npmjs.com/package/", + cargo = "https://crates.io/crates/", + pip = "https://pypi.org/project/", + gem = "https://rubygems.org/gems/", + snap = "https://snapcraft.io/", + brew = "https://formulae.brew.sh/formula/", +} + local function openPackage(sourceKey, name) if sourceKey == "pacman" then openUrl("https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name)) - elseif sourceKey == "aur" then - openUrl("https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name)) - elseif sourceKey == "flatpak" then - openUrl("https://flathub.org/apps/" .. noctalia.string.urlEncode(name)) + elseif PACKAGE_URLS[sourceKey] ~= nil then + openUrl(PACKAGE_URLS[sourceKey] .. noctalia.string.urlEncode(name)) end end @@ -150,10 +159,20 @@ local function sourceLabel(key, entry) if key == "pacman" and snapshot ~= nil and type(snapshot.nativeLabel) == "string" then return tr(snapshot.nativeLabel) end + -- Extra sources carry their own label key in the state entry. + if type(entry.label) == "string" then + return tr(entry.label) + end return tr("source." .. key) end -local SOURCE_GLYPHS = { pacman = "package", aur = "cloud", flatpak = "app-window" } +local SOURCE_GLYPHS = { pacman = "package", aur = "cloud", flatpak = "app-window", npm = "box", cargo = "box", pip = "box", gem = "box", snap = "box", brew = "box" } + +-- Sources whose packages the backend itself installed — the only ones the +-- rollback controls apply to. +local function nativeSource(source) + return source == "pacman" or source == "aur" +end local function sourceGlyph(key) return SOURCE_GLYPHS[key] or "package" @@ -170,6 +189,9 @@ local function orderedSources() { key = "aur", entry = snapshot.aur }, { key = "flatpak", entry = snapshot.flatpak }, } + for _, extra in ipairs(type(snapshot.extras) == "table" and snapshot.extras or {}) do + table.insert(candidates, { key = extra.key, entry = extra }) + end local pending = {} for _, candidate in ipairs(candidates) do if type(candidate.entry) == "table" and (candidate.entry.n or 0) > 0 then @@ -197,8 +219,33 @@ local function cleanSources() if snapshot.aur ~= nil and (snapshot.aur.n or 0) == 0 and noctalia.getConfig("aur_helper") ~= "off" then table.insert(candidates, { key = "aur", entry = snapshot.aur }) end + -- Extras appear here only when their check actually ran this round. + for _, extra in ipairs(type(snapshot.extras) == "table" and snapshot.extras or {}) do + table.insert(candidates, { key = extra.key, entry = extra }) + end for _, candidate in ipairs(candidates) do - if type(candidate.entry) == "table" and (candidate.entry.n or 0) == 0 then + -- A failed slot also has n == 0, but "up to date" is exactly what it + -- doesn't know — those go to failedSources() instead. + if type(candidate.entry) == "table" and (candidate.entry.n or 0) == 0 and candidate.entry.failed ~= true then + table.insert(names, sourceLabel(candidate.key, candidate.entry)) + end + end + return names +end + +-- Sources whose check could not complete this round (mirror down, registry +-- unreachable): the rest of the check went on without them. +local function failedSources() + if snapshot == nil then + return {} + end + local names = {} + local candidates = { + { key = "aur", entry = snapshot.aur }, + { key = "flatpak", entry = snapshot.flatpak }, + } + for _, candidate in ipairs(candidates) do + if type(candidate.entry) == "table" and candidate.entry.failed == true then table.insert(names, sourceLabel(candidate.key, candidate.entry)) end end @@ -711,27 +758,89 @@ local function rollbackButton(key, tooltip, onConfirm) }) end +-- What kind of per-item rollback a non-backend source offers ("version", +-- "revert" or "commit"), published by the engine; nil = none. +local function sourceRollbackKind(source) + if snapshot ~= nil and type(snapshot.sourceRollback) == "table" then + return snapshot.sourceRollback[source] + end + return nil +end + +-- The rollback control of one non-native item: the source's own command +-- needs the recorded old version (npm/gem/cargo), nothing at all (snap +-- revert), or the recorded Flatpak commit. Items lacking that data get the +-- plain glyph. +local function extraRollbackControl(key, run, item) + local kind = sourceRollbackKind(item.source) + local from = tostring(item.from or "") + local tooltip = nil + if kind == "revert" then + tooltip = tr("tip_rollback_revert") + elseif kind == "version" and from ~= "" then + tooltip = tr("tip_rollback_extra", { version = from }) + elseif kind == "commit" and type(item.commit) == "string" then + tooltip = tr("tip_rollback_commit") + end + if tooltip == nil then + return nil + end + return rollbackButton(key, tooltip, function() + request("rollback", { pkg = item.name, at = run.at }) + end) +end + +-- Whether this native item can offer a per-package rollback and how: +-- "cache" (the backend's package cache, run-mates ride along) or "item" +-- (the manager's own downgrade, dnf). nil = dots. +local function nativeItemKind(run) + if rollbackAvailable() then + return "cache" + end + -- The manager's downgrade only goes backwards, so a rollback entry + -- (whose "old" version is the newer one) gets no button — the way + -- forward from there is a plain Update. + if snapshot ~= nil and snapshot.nativeItemRollback == true and run.rollback ~= true then + return "item" + end + return nil +end + local function rollbackRow(run, index, item, probePkgs) local key = "rb-" .. tostring(run.at) .. "-" .. index local info = probePkgs ~= nil and probePkgs[item.name] or nil local children = {} + local kind = nativeSource(item.source) and nativeItemKind(run) or nil - if item.source == "flatpak" or not rollbackAvailable() then - table.insert(children, ui.glyph({ + if not nativeSource(item.source) then + table.insert(children, extraRollbackControl(key, run, item) or ui.glyph({ name = item.source == "flatpak" and "app-window" or "point", size = 12, color = "on_surface_variant", })) + elseif kind == nil then + table.insert(children, ui.glyph({ + name = "point", + size = 12, + color = "on_surface_variant", + })) elseif info ~= nil and info.cache ~= true then + -- The probe says the old version is gone — from the cache or from + -- the repositories; the tooltip names which. table.insert(children, ui.button({ key = key .. "-miss", glyph = "undo", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12, enabled = false, - tooltip = tr("rollback_missing"), + tooltip = tr(kind == "cache" and "rollback_missing" or "rollback_missing_repo"), onClick = function() end, })) else - local tooltip = tr("tip_rollback", { version = tostring(item.from or "") }) + local tooltip + if kind == "cache" then + tooltip = tr("tip_rollback", { version = tostring(item.from or "") }) + else + tooltip = tr("tip_rollback_item", { version = tostring(item.from or "") }) + end if info ~= nil and (info.req or 0) > 0 then tooltip = tooltip .. "\n" .. noctalia.trp("rollback_required_by", info.req, { count = info.req }) end @@ -777,7 +886,7 @@ local function runViewRows() } local rollable = 0 for _, item in ipairs(run.packages or {}) do - if item.source ~= "flatpak" then + if nativeSource(item.source) then rollable += 1 end end @@ -1049,6 +1158,20 @@ render = function() ui.label({ text = text, color = color, maxLines = 2 }), } + -- A source whose check failed is announced right under the headline — + -- at the bottom it read as fine print, and "up to date" without it + -- would be a lie. + if not busy() then + for _, name in ipairs(failedSources()) do + table.insert(children, ui.label({ + text = tr("check_failed_source", { source = name }), + fontSize = 11, + color = "error", + maxLines = 2, + })) + end + end + for _, node in ipairs(body()) do table.insert(children, node) end diff --git a/linux-updater/plugin.toml b/linux-updater/plugin.toml index 98995917..a774362f 100644 --- a/linux-updater/plugin.toml +++ b/linux-updater/plugin.toml @@ -1,12 +1,12 @@ id = "umedbazarov/linux-updater" name = "Linux Updater" -version = "0.7.0" +version = "1.0.0" plugin_api = 9 author = "umedbazarov" license = "MIT" icon = "package" description = "Check and install system updates in the background on any major distro: live log, update history, rollback." -dependencies = ["apt", "apt-get", "apt-mark", "awk", "date", "dnf", "dpkg-query", "flatpak", "grep", "head", "install", "less", "pacman", "pacman-contrib", "paru", "pkcon", "pkexec", "rm", "rpm", "sed", "sh", "sudo", "systemctl", "tail", "tee", "test", "uname", "wc", "xbps-install", "xbps-pkgdb", "xdg-open", "yay", "zypper"] +dependencies = ["apt", "apt-get", "apt-mark", "awk", "brew", "cargo-install-update", "date", "dnf", "dpkg-query", "flatpak", "gem", "grep", "head", "install", "less", "npm", "pacman", "pacman-contrib", "paru", "pip", "pkcon", "pkexec", "rm", "rpm", "sed", "sh", "snap", "sudo", "systemctl", "tail", "tee", "test", "uname", "wc", "xbps-install", "xbps-pkgdb", "xdg-open", "yay", "zypper"] tags = ["arch", "debian", "fedora", "opensuse", "void", "bar", "panel", "launcher", "system", "utility"] # ── General ────────────────────────────────────────────────────────────────── @@ -56,6 +56,50 @@ label_key = "settings.flatpak_enabled.label" description_key = "settings.flatpak_enabled.description" default = true +# ── Extra sources (opt-in, cross-distro) ───────────────────────────────────── + +[[setting]] +key = "npm_enabled" +type = "bool" +label_key = "settings.npm_enabled.label" +description_key = "settings.npm_enabled.description" +default = false + +[[setting]] +key = "cargo_enabled" +type = "bool" +label_key = "settings.cargo_enabled.label" +description_key = "settings.cargo_enabled.description" +default = false + +[[setting]] +key = "pip_enabled" +type = "bool" +label_key = "settings.pip_enabled.label" +description_key = "settings.pip_enabled.description" +default = false + +[[setting]] +key = "gem_enabled" +type = "bool" +label_key = "settings.gem_enabled.label" +description_key = "settings.gem_enabled.description" +default = false + +[[setting]] +key = "snap_enabled" +type = "bool" +label_key = "settings.snap_enabled.label" +description_key = "settings.snap_enabled.description" +default = false + +[[setting]] +key = "brew_enabled" +type = "bool" +label_key = "settings.brew_enabled.label" +description_key = "settings.brew_enabled.description" +default = false + [[setting]] key = "ignore_packages" type = "string_list" diff --git a/linux-updater/service.luau b/linux-updater/service.luau index 25a44be1..020d248d 100644 --- a/linux-updater/service.luau +++ b/linux-updater/service.luau @@ -6,7 +6,7 @@ -- the panel. -- -- state "linup_state" = { nonce, phase, step, total, pacman, aur, --- flatpak, downloadSizeMiB, rebootRecommended, +-- flatpak, extras, downloadSizeMiB, rebootRecommended, -- newsUnread, newsLatestTitle, err, checkedAt, -- ignoredCount, ignoredPending, ignoredDynamic, -- ignoredConfig, history, activity, lastUpdateAt, @@ -92,6 +92,7 @@ local activityLoaded = false local checkIsPostUpdate = false -- next finished check followed an update run local runKind = "update" -- update|rollback: what the current run does local runMode = "background" -- background|terminal: how the current run was launched +local runFailHintKey = nil -- backend-specific "why it failed" key for the current rollback local runPackages = nil -- packages of the current run, for the history entry local probeResult = nil -- cache/required-by info for one run, {at, pkgs} @@ -182,6 +183,58 @@ local backend = BACKEND_FACTORIES[resolveBackendId()]({ commandExists = noctalia.commandExists, osRelease = OS_RELEASE, }) + +-- Cross-distro extra sources (npm, cargo, pip, gem, snap, brew): opt-in +-- per-manager, checked after Flatpak and appended to the update run where +-- they support updating. All display-only in the run history — no rollback +-- and no installed-versions verification, like Flatpak. +local EXTRAS = require("./extras.luau")({ + trim = trim, + shellQuote = shellQuote, + cfg = cfg, + MAX_LISTED = MAX_LISTED, + commandExists = noctalia.commandExists, +}) + +local extraSources = {} -- key -> { n, items } for the extras of the last check + +local function enabledExtras() + local list = {} + for _, extra in ipairs(EXTRAS) do + if cfg(extra.settingKey) == true and noctalia.commandExists(extra.tool) then + table.insert(list, extra) + end + end + return list +end + +-- Sources whose packages the backend itself installed — the only ones the +-- backend's own rollback and the installed-versions verification may touch. +local function nativeSource(source) + return source == "pacman" or source == "aur" +end + +-- Per-item rollback capability of the non-backend sources, published so the +-- panel can offer the right control: "version" reinstalls the recorded old +-- version through the manager itself, "revert" restores the manager's own +-- previous state (snap), "commit" pins the previous Flatpak commit. +local SOURCE_ROLLBACK = (function() + local map = { flatpak = "commit" } + for _, extra in ipairs(EXTRAS) do + if extra.rollbackKind ~= nil then + map[extra.key] = extra.rollbackKind + end + end + return map +end)() + +local EXTRAS_BY_KEY = (function() + local map = {} + for _, extra in ipairs(EXTRAS) do + map[extra.key] = extra + end + return map +end)() -- nil when the backend needs no keep-authorization rule (e.g. PackageKit -- brings its own polkit policies) — the whole rule machinery is off then. local POLKIT_RULE_PATH = backend.polkitRuleName ~= nil @@ -204,6 +257,28 @@ local function validName(name) return name:match("^[a-zA-Z0-9._+-]+$") ~= nil end +-- The ignore list also holds extra-source names, which may be npm scoped +-- packages ("@scope/pkg") — wider than the native grammar but still shaped +-- (must start with a word character or @, no shell metacharacters). +local function validIgnoreName(name) + return name:match("^[%w@][%w@/._+-]*$") ~= nil +end + +-- Backend commands splice ignore names in unquoted (--ignore a,b, +-- apt-mark hold ...), and a scoped npm name there would make e.g. apt-mark +-- fail — which, correctly, aborts the gated upgrade. So the backend only +-- ever sees names of its own grammar; the wider ones stay with the quoted +-- consumers (flatpak filter, extras). +local function nativeIgnoreNames(ignored) + local names = {} + for _, name in ipairs(ignored) do + if validName(name) then + table.insert(names, name) + end + end + return names +end + -- Two plugin-side ignore sources: the "ignore_packages" setting and a -- panel-managed list in IGNORE_FILE (the panel cannot write settings). -- pacman.conf's IgnorePkg arrives from the checkers as "[ignored]" lines @@ -230,7 +305,7 @@ local function loadDynamicIgnore() if ok and type(decoded) == "table" and type(decoded.packages) == "table" then for _, entry in ipairs(decoded.packages) do local name = trim(tostring(entry)) - if validName(name) then + if validIgnoreName(name) then table.insert(dynamicIgnore, name) end end @@ -256,7 +331,7 @@ local function configIgnoreList() local names = {} for _, entry in ipairs(raw) do local name = trim(tostring(entry)) - if validName(name) then + if validIgnoreName(name) then table.insert(names, name) elseif name ~= "" then noctalia.log("linux-updater: ignoring invalid package name '" .. name .. "'") @@ -292,11 +367,33 @@ local function ignoreSet() return set end +-- Settings that change what a check would count. Once any of them moves, +-- the published numbers describe a check the new settings would not +-- produce, so the result is dropped (back to "not checked yet") instead of +-- shown; cosmetic settings (glyph, log lines) leave it alone. Note that a +-- backend switch additionally needs a plugin reload to take effect — the +-- stale count is still wrong to keep either way. Checked once per tick. +local checkSig = nil -- nil until the startup snapshot below + +local function checkSignature() + local parts = { + tostring(cfg("backend")), + tostring(cfg("aur_helper")), + tostring(cfg("aur_check_cmd")), + tostring(cfg("flatpak_enabled")), + table.concat(configIgnoreList(), ","), + } + for _, extra in ipairs(EXTRAS) do + table.insert(parts, tostring(cfg(extra.settingKey))) + end + return table.concat(parts, "\0") +end + -- Appends to the panel-managed list without touching the current snapshot; -- used in bulk after a rollback when rollback_auto_ignore is on. local function addIgnoreName(name) name = trim(tostring(name or "")) - if not validName(name) then + if not validIgnoreName(name) then return end loadDynamicIgnore() @@ -398,10 +495,20 @@ end -- back through the pacman cache. local function collectPendingPackages() local packages = {} - for _, source in ipairs({ { key = "pacman", entry = sources.pacman }, { key = "aur", entry = sources.aur }, { key = "flatpak", entry = sources.flatpak } }) do + local slots = { { key = "pacman", entry = sources.pacman }, { key = "aur", entry = sources.aur }, { key = "flatpak", entry = sources.flatpak } } + for _, extra in ipairs(EXTRAS) do + -- Check-only extras (pip) are not part of the run, so recording their + -- packages as applied would lie. + if extra.buildUpdateCommand ~= nil and extraSources[extra.key] ~= nil then + table.insert(slots, { key = extra.key, entry = extraSources[extra.key] }) + end + end + for _, source in ipairs(slots) do for _, item in ipairs(source.entry.items or {}) do if #packages < MAX_RUN_PACKAGES then - table.insert(packages, { name = item.name, from = item.from, to = item.to, source = source.key }) + -- commit: only Flatpak items carry one; it is what their + -- per-item rollback pins. + table.insert(packages, { name = item.name, from = item.from, to = item.to, source = source.key, commit = item.commit }) end end end @@ -493,7 +600,8 @@ end -- ── Parsing ────────────────────────────────────────────────────────────────── -- Flatpak has no "name oldver -> newver" line, so the query joins installed --- and pending by application id (tab-separated name/from/to). +-- and pending by application id (tab-separated name/from/to, plus the full +-- active commit as a fourth field — that is what a rollback pins). local function parseTabLines(output, ignored) local items = {} local n = 0 @@ -509,7 +617,11 @@ local function parseTabLines(output, ignored) else n += 1 if #items < MAX_LISTED then - table.insert(items, { name = name, from = fields[2] or "", to = fields[3] or "" }) + local item = { name = name, from = fields[2] or "", to = fields[3] or "" } + if fields[4] ~= nil and fields[4] ~= "" then + item.commit = fields[4] + end + table.insert(items, item) end end end @@ -532,6 +644,25 @@ local function publish() pacman = sources.pacman, aur = sources.aur, flatpak = sources.flatpak, + extras = (function() + -- Ordered array for the panel: only the extras of the last check, + -- each carrying its label key so the panel needs no table of its + -- own. Entries with n == 0 are kept for the "Up to date" caption. + local list = {} + for _, extra in ipairs(EXTRAS) do + local entry = extraSources[extra.key] + if entry ~= nil then + table.insert(list, { + key = extra.key, + n = entry.n, + items = entry.items, + label = extra.labelKey, + updatable = extra.buildUpdateCommand ~= nil, + }) + end + end + return list + end)(), downloadSizeMiB = downloadSizeMiB, rebootRecommended = rebootRecommended, newsUnread = newsUnread, @@ -549,6 +680,8 @@ local function publish() ignoredDynamic = dynamicIgnore or {}, ignoredConfig = configIgnoreList(), polkitRule = polkitRuleInstalled, + sourceRollback = SOURCE_ROLLBACK, + nativeItemRollback = backend.rollbackItemCommand ~= nil, history = historyForState(), activity = activityHistory, lastUpdateAt = activityLastUpdateAt, @@ -563,9 +696,10 @@ local function publish() }) end --- ── Checking pipeline: pacman → AUR → Flatpak → size → reboot → done ──────── +-- ── Checking pipeline: backend → AUR → Flatpak → extras → size → reboot ───── local checkFlatpak +local checkExtras local checkSize local checkReboot local finishCheck @@ -624,10 +758,42 @@ checkSize = function() end end +-- One extra source at a time, so a slow mirror never stacks timeouts; a +-- failing query is logged and its source left unknown for this check (not +-- zero — "up to date" because a registry was down would be a lie). +checkExtras = function(ignored) + local queue = enabledExtras() + local function pump() + local extra = table.remove(queue, 1) + if extra == nil then + checkSize() + return + end + step = tr(extra.labelKey) + publish() + local started = noctalia.runAsync(extra.checkCommand, function(result) + if result.timedOut or result.exitCode ~= 0 then + noctalia.log("linux-updater: " .. extra.key .. " check failed (exit " + .. tostring(result.exitCode) .. (result.timedOut and ", timeout" or "") .. ")") + extraSources[extra.key] = nil + else + local n, items = extra.parseCheck(result.stdout, ignored, ignoredPending) + extraSources[extra.key] = { n = n, items = items } + end + pump() + end, CHECK_TIMEOUT_MS) + if not started then + noctalia.log("linux-updater: could not run the " .. extra.key .. " check") + pump() + end + end + pump() +end + checkFlatpak = function(ignored) if cfg("flatpak_enabled") ~= true or not noctalia.commandExists("flatpak") then sources.flatpak = { n = 0, items = {} } - checkSize() + checkExtras(ignored) return end step = tr("source.flatpak") @@ -638,6 +804,10 @@ checkFlatpak = function(ignored) -- Each call's own output and exit code are captured before piping into -- awk, so a real flatpak failure (e.g. no remote, network down) fails -- the whole command instead of awk quietly succeeding on empty input. + -- The trailing loop appends each pending app's FULL active commit + -- (flatpak info -c): the `active` list column truncates to 12 chars, + -- and `update --commit=` rejects a prefix with a server 404 (verified + -- in an Ubuntu container) — only the full 64-char commit can roll back. local cmd = [[ listOut=$(flatpak list --columns=application,version,active 2>/dev/null); listCode=$? updOut=$(flatpak remote-ls --updates --columns=application,version,commit 2>/dev/null); updCode=$? @@ -651,25 +821,32 @@ fi tag=="L" { v[app]=$2; c[app]=$3 } tag=="R" { from=v[app]; to=$2 if (from=="" || to=="" || from==to) { from=substr(c[app],1,7); to=substr($3,1,7) } - print app"\t"from"\t"to }']] + print app"\t"from"\t"to }' | while IFS="$(printf '\t')" read -r app from to; do + printf '%s\t%s\t%s\t%s\n' "$app" "$from" "$to" "$(flatpak info "$app" -c 2>/dev/null)" +done]] + -- Like the AUR: a broken Flatpak check fails its own slot and the check + -- moves on to the extras. local started = noctalia.runAsync(cmd, function(result) if result.timedOut then - sources.flatpak = { n = 0, items = {} } - checkSize() + noctalia.log("linux-updater: flatpak check timed out") + sources.flatpak = { n = 0, items = {}, failed = true } + checkExtras(ignored) return end if result.exitCode ~= 0 then noctalia.log("linux-updater: flatpak check failed (exit " .. tostring(result.exitCode) .. ")") - failCheck(tr("err_flatpak_failed")) + sources.flatpak = { n = 0, items = {}, failed = true } + checkExtras(ignored) return end local n, items = parseTabLines(result.stdout, ignored) sources.flatpak = { n = n, items = items } - checkSize() + checkExtras(ignored) end, CHECK_TIMEOUT_MS) if not started then - sources.flatpak = { n = 0, items = {} } - checkSize() + noctalia.log("linux-updater: could not run the flatpak check") + sources.flatpak = { n = 0, items = {}, failed = true } + checkExtras(ignored) end end @@ -698,9 +875,15 @@ local function checkAur(ignored) end step = helper == "custom" and tr("source.aur") or tr("source.aur_named", { helper = helper }) publish() + -- An unreachable AUR (mirror down, RPC blocked) must not abort the whole + -- check: the other sources are still checkable. The slot is marked failed + -- so the panel reports it instead of claiming "up to date" — same idea as + -- the unknown extras, which a broken registry leaves out of the state. + local helperShown = helper == "custom" and "" or helper local started = noctalia.runAsync(cmd, function(result) if result.timedOut then - sources.aur = { n = 0, items = {}, helper = "" } + noctalia.log("linux-updater: AUR check timed out") + sources.aur = { n = 0, items = {}, helper = helperShown, failed = true } checkFlatpak(ignored) return end @@ -710,15 +893,17 @@ local function checkAur(ignored) -- doesn't. if result.exitCode ~= 0 and trim(result.stderr or "") ~= "" then noctalia.log("linux-updater: AUR check failed: " .. trim(result.stderr)) - failCheck(tr("err_aur_failed")) + sources.aur = { n = 0, items = {}, helper = helperShown, failed = true } + checkFlatpak(ignored) return end local n, items = backend.parseCheck(result.stdout, ignored, ignoredPending) - sources.aur = { n = n, items = items, helper = helper == "custom" and "" or helper } + sources.aur = { n = n, items = items, helper = helperShown } checkFlatpak(ignored) end, CHECK_TIMEOUT_MS) if not started then - sources.aur = { n = 0, items = {}, helper = "" } + noctalia.log("linux-updater: could not run the AUR check") + sources.aur = { n = 0, items = {}, helper = helperShown, failed = true } checkFlatpak(ignored) end end @@ -737,6 +922,7 @@ startCheck = function() phase = "checking" errMsg = nil sources = { pacman = { n = 0, items = {} }, aur = { n = 0, items = {}, helper = "" }, flatpak = { n = 0, items = {} } } + extraSources = {} downloadSizeMiB = nil ignoredPending = {} step = tr("source.pacman") @@ -763,8 +949,16 @@ startCheck = function() end end +local function pendingTotal() + local sum = sources.pacman.n + sources.aur.n + sources.flatpak.n + for _, entry in pairs(extraSources) do + sum += entry.n + end + return sum +end + finishCheck = function() - total = sources.pacman.n + sources.aur.n + sources.flatpak.n + total = pendingTotal() step = "" phase = total > 0 and "ready" or "clean" checkedAt = noctalia.formatTime("%H:%M") @@ -1009,17 +1203,76 @@ local function buildFlatpakPart(ignored, flags) .. [[if [ -n "$flatpak_refs" ]; then flatpak update]] .. flags .. [[ $flatpak_refs; fi; }]] end +-- The extras' upgrade parts, shared by both modes (none of them prompt). +-- Plain "update everything" normally; with a plugin ignore list the pending +-- snapshot minus the ignored names is passed explicitly instead — the parse +-- already routed ignored names out of items. When the snapshot is truncated +-- (n > #items) the explicit list would silently drop packages, so it falls +-- back to update-all with a log line: an ignore miss beats a lost update. +local function buildExtrasParts(ignored) + local parts = {} + for _, extra in ipairs(enabledExtras()) do + if extra.buildUpdateCommand ~= nil then + local names = nil + local entry = extraSources[extra.key] + if #ignored > 0 and entry ~= nil then + if entry.n == #entry.items then + if entry.n == 0 then + -- Everything this manager had pending (if anything) + -- is ignored; a plain update-all would grab it. + continue + end + names = {} + for _, item in ipairs(entry.items) do + table.insert(names, shellQuote(item.name)) + end + else + noctalia.log("linux-updater: " .. extra.key .. " pending list truncated, ignore list not applied") + end + end + table.insert(parts, extra.buildUpdateCommand(names)) + end + end + return parts +end + +-- For backends that cannot exclude per transaction (backend.ignoreByExplicitList, +-- PackageKit): with a non-empty ignore list the pending-minus-ignored snapshot +-- is passed as explicit (quoted) names, same policy as the extras. nil = no +-- filtering needed; {} = everything pending is ignored, skip the upgrade. +-- A truncated snapshot (n > #items) falls back to update-all with a log line: +-- an ignore miss beats a lost update. +local function nativePendingNames(ignored) + if backend.ignoreByExplicitList ~= true or #ignored == 0 then + return nil + end + if sources.pacman.n ~= #sources.pacman.items then + noctalia.log("linux-updater: pending list truncated, ignore list not applied to " .. backend.id) + return nil + end + local names = {} + for _, item in ipairs(sources.pacman.items) do + if validName(tostring(item.name or "")) then + table.insert(names, shellQuote(item.name)) + end + end + return names +end + local function buildBackgroundCommand() local override = trim(cfg("update_cmd")) if override ~= "" then return override end local ignored = ignoreList() - local parts = { backend.buildBackgroundCommand(ignored) } + local parts = { backend.buildBackgroundCommand(nativeIgnoreNames(ignored), nativePendingNames(ignored)) } local flatpakPart = buildFlatpakPart(ignored, " -y --noninteractive") if flatpakPart ~= nil then table.insert(parts, flatpakPart) end + for _, part in ipairs(buildExtrasParts(ignored)) do + table.insert(parts, part) + end return table.concat(parts, " && ") end @@ -1029,11 +1282,14 @@ end -- marker and re-checks when the run ends. local function buildTerminalCommand() local ignored = ignoreList() - local parts = { backend.buildTerminalCommand(ignored) } + local parts = { backend.buildTerminalCommand(nativeIgnoreNames(ignored), nativePendingNames(ignored)) } local flatpakPart = buildFlatpakPart(ignored, "") if flatpakPart ~= nil then table.insert(parts, flatpakPart) end + for _, part in ipairs(buildExtrasParts(ignored)) do + table.insert(parts, part) + end return table.concat(parts, " && ") end @@ -1077,7 +1333,7 @@ local function saveRunMeta() if path == nil then return end - local encoded = noctalia.json.encode({ kind = runKind, mode = runMode, total = runTotal, packages = runPackages }) + local encoded = noctalia.json.encode({ kind = runKind, mode = runMode, failHint = runFailHintKey, total = runTotal, packages = runPackages }) if encoded ~= nil then noctalia.writeFile(path, encoded) end @@ -1092,6 +1348,7 @@ local function loadRunMeta() if ok and type(decoded) == "table" then runKind = decoded.kind == "rollback" and "rollback" or "update" runMode = decoded.mode == "terminal" and "terminal" or "background" + runFailHintKey = type(decoded.failHint) == "string" and decoded.failHint or nil runTotal = tonumber(decoded.total) or 0 runPackages = type(decoded.packages) == "table" and decoded.packages or nil end @@ -1110,6 +1367,7 @@ local function beginRun(kind, expectTotal, mode) step = "" runKind = kind or "update" runMode = mode or "background" + runFailHintKey = nil runExit = nil runDone = 0 runTotal = expectTotal or total @@ -1149,9 +1407,14 @@ local function runUpdate() -- "::START " lets a restarted engine tell a live run from a stale -- log; "::EXIT " is the completion marker the poller waits for. + -- export LC_ALL=C: the progress patterns grep for English package-manager + -- lines ("upgrading ...", "Unpacking ..."), which the managers translate. + -- pkexec whitelists LC_* through its environment scrub, so the escalated + -- manager inherits it. Terminal runs deliberately keep the user's locale + -- (their window, their language) at the cost of the progress count there. local quoted = shellQuote(path) local script = "printf '::START %s\\n' \"$(date +%s)\" > " .. quoted - .. "; { " .. buildBackgroundCommand() .. " ; } >> " .. quoted .. " 2>&1" + .. "; { export LC_ALL=C; " .. buildBackgroundCommand() .. " ; } >> " .. quoted .. " 2>&1" .. "; printf '::EXIT %s\\n' \"$?\" >> " .. quoted if not noctalia.runAsync(script) then @@ -1193,9 +1456,10 @@ end -- interactive terminal run lets the user decline packages along the way. -- Before recording, the backend's installed-versions query weeds out entries -- whose installed version never left `from`: they were not actually updated, --- so a rollback segment offering to "undo" them would lie. Flatpak entries --- and entries without a known old version are kept as-is (display only). --- Any failure to verify records the full snapshot, like before. +-- so a rollback segment offering to "undo" them would lie. Flatpak and +-- extra-source entries, and entries without a known old version, are kept +-- as-is (display only). Any failure to verify records the full snapshot, +-- like before. local function verifyAndRecordRun(kind, packages, txId) local function fallback() recordRun(kind, packages, txId) @@ -1210,7 +1474,7 @@ local function verifyAndRecordRun(kind, packages, txId) end local names = {} for _, item in ipairs(packages) do - if item.source ~= "flatpak" and tostring(item.from or "") ~= "" and validName(tostring(item.name or "")) then + if nativeSource(item.source) and tostring(item.from or "") ~= "" and validName(tostring(item.name or "")) then table.insert(names, shellQuote(item.name)) end end @@ -1232,7 +1496,7 @@ local function verifyAndRecordRun(kind, packages, txId) end local applied = {} for _, item in ipairs(packages) do - if item.source == "flatpak" or tostring(item.from or "") == "" then + if not nativeSource(item.source) or tostring(item.from or "") == "" then table.insert(applied, item) elseif installed[item.name] ~= nil and installed[item.name] ~= item.from then table.insert(applied, item) @@ -1287,7 +1551,13 @@ finishRun = function(code) runPackages = nil clearRunMeta() phase = "error" - errMsg = tr("err_run_failed", { code = tostring(code) }) + -- A rollback with a backend-specific failure cause names it instead + -- of the generic exit-code line; the log stays on screen either way. + if runKind == "rollback" and runFailHintKey ~= nil then + errMsg = tr(runFailHintKey, { code = tostring(code) }) + else + errMsg = tr("err_run_failed", { code = tostring(code) }) + end publish() noctalia.notifyError(tr("title"), errMsg) end @@ -1394,7 +1664,9 @@ local function resumeRunIfActive() return end loadRunMeta() + local hint = runFailHintKey -- beginRun clears it; restore the resumed run's own beginRun(runKind, runTotal > 0 and runTotal or nil, runMode) + runFailHintKey = hint end, FAST_TIMEOUT_MS) if not started then resumeProbed = true @@ -1472,11 +1744,73 @@ local function reversedItem(item) return { name = item.name, from = item.to, to = item.from, source = item.source } end --- Per-package rollback. The package's dependencies that were updated in the --- same run ride along in the same transaction (pactree intersected with the --- run's package list), so a program and its libraries move back together. +-- Rollback of one Flatpak item: pin the full active commit recorded at +-- check time. The full 64-char checksum is required — the server rejects +-- a prefix with a 404. When the remote no longer serves that commit the +-- run fails honestly in the log, nothing changes. +local function flatpakRollbackCommand(item) + local name = trim(tostring(item.name or "")) + local commit = tostring(item.commit or "") + if name:match("^[%w._-]+$") == nil or commit:match("^%x+$") == nil or #commit ~= 64 then + return nil + end + return "flatpak update -y --noninteractive --commit=" .. commit .. " " .. shellQuote(name) +end + +-- Shared detached runner for the single-command rollbacks (extras, Flatpak +-- commit pins, dnf per-package downgrade): same log, markers and poller as +-- everything else. failHintKey, when set, replaces the generic run-failure +-- message so the panel can say WHY (e.g. "the old version left the +-- repositories") instead of just an exit code. +local function runDetachedRollback(cmd, items, failHintKey) + if phase == "running" or phase == "checking" then + return + end + local path = logPath() + if path == nil then + return + end + local quoted = shellQuote(path) + local script = "printf '::START %s\\n' \"$(date +%s)\" > " .. quoted + .. "; { export LC_ALL=C; " .. cmd .. " ; } >> " .. quoted .. " 2>&1" + .. "; printf '::EXIT %s\\n' \"$?\" >> " .. quoted + if not noctalia.runAsync(script) then + phase = "error" + errMsg = tr("err_spawn") + publish() + return + end + runPackages = items + beginRun("rollback", #items) + runFailHintKey = failHintKey + saveRunMeta() +end + +-- Per-item rollback for the non-backend sources: the manager's own command +-- (npm/gem/cargo reinstall the recorded version, snap reverts, flatpak pins +-- a commit). No run-mate dependency resolution — each manager handles its +-- own. +local function startExtraRollback(item) + local cmd + if item.source == "flatpak" then + cmd = flatpakRollbackCommand(item) + else + local extra = EXTRAS_BY_KEY[item.source] + cmd = extra ~= nil and extra.buildRollbackCommand ~= nil and extra.buildRollbackCommand(item) or nil + end + if cmd == nil then + return + end + runDetachedRollback(cmd, { reversedItem(item) }) +end + +-- Per-package rollback. Native packages go through the backend's cache path: +-- dependencies updated in the same run ride along in the same transaction +-- (pactree intersected with the run's package list), so a program and its +-- libraries move back together. Everything else routes to its source's own +-- rollback command. local function startRollback(payload) - if backend.caps.rollback ~= "cache" or type(payload) ~= "table" then + if type(payload) ~= "table" then return end local run = findRun(payload.at) @@ -1486,17 +1820,37 @@ local function startRollback(payload) local name = trim(tostring(payload.pkg or "")) local target = nil for _, item in ipairs(run.packages) do - if item.name == name and item.source ~= "flatpak" then + if item.name == name then target = item break end end - if target == nil or not validName(name) or not versionOk(tostring(target.from or "")) then + if target == nil then + return + end + if not nativeSource(target.source) then + startExtraRollback(target) + return + end + -- Native backends without a package cache can still step one package + -- back through their own manager (dnf downgrade) — with the backend's + -- failure hint, since "the old version left the repositories" is the + -- usual outcome worth naming. + if backend.caps.rollback ~= "cache" then + if backend.rollbackItemCommand ~= nil then + local cmd = backend.rollbackItemCommand(target) + if cmd ~= nil then + runDetachedRollback(cmd, { reversedItem(target) }, backend.rollbackFailHintKey) + end + end + return + end + if not validName(name) or not versionOk(tostring(target.from or "")) then return end local runMates = {} for _, item in ipairs(run.packages) do - if item.source ~= "flatpak" and item.name ~= name and validName(item.name) and versionOk(tostring(item.from or "")) then + if nativeSource(item.source) and item.name ~= name and validName(item.name) and versionOk(tostring(item.from or "")) then runMates[item.name] = item end end @@ -1550,7 +1904,7 @@ local function runNativeRollback(run) end local items = {} for _, item in ipairs(run.packages) do - if item.source ~= "flatpak" then + if nativeSource(item.source) then table.insert(items, reversedItem(item)) end end @@ -1582,7 +1936,7 @@ local function startRollbackRun(payload) local specs = {} local items = {} for _, item in ipairs(run.packages) do - if item.source ~= "flatpak" and validName(item.name) and versionOk(tostring(item.from or "")) then + if nativeSource(item.source) and validName(item.name) and versionOk(tostring(item.from or "")) then table.insert(specs, { name = item.name, version = item.from }) table.insert(items, reversedItem(item)) end @@ -1590,11 +1944,32 @@ local function startRollbackRun(payload) runRollback(specs, items) end --- Cache availability and reverse-dependency counts for one run, fetched --- when the panel opens that run's package list: greys out packages whose --- old file is gone and shows how many installed packages require each one. +-- Per-item rollback feasibility and reverse-dependency counts for one run, +-- fetched when the panel opens that run's package list: greys out packages +-- whose old version is gone — from the package cache (cache backends) or +-- from the repositories (backend.itemProbeSh, dnf) — so the reason appears +-- BEFORE a doomed attempt, and shows how many installed packages require +-- each one. local function probeRun(payload) - if backend.caps.rollback ~= "cache" then + local probeBody + if backend.caps.rollback == "cache" then + probeBody = backend.findPkgSh .. [[ + +while [ "$#" -ge 2 ]; do + if find_pkg "$1" "$2" >/dev/null; then c=ok; else c=miss; fi + ]] .. backend.reverseDepsCountSh .. [[ + + printf '%s|%s|%s\n' "$1" "$c" "$n" + shift 2 +done]] + elseif backend.itemProbeSh ~= nil then + probeBody = [[while [ "$#" -ge 2 ]; do + ]] .. backend.itemProbeSh .. [[ + + printf '%s|%s|0\n' "$1" "$c" + shift 2 +done]] + else return end local run = findRun(type(payload) == "table" and payload.at or payload) @@ -1603,7 +1978,7 @@ local function probeRun(payload) end local specs = {} for _, item in ipairs(run.packages) do - if item.source ~= "flatpak" and validName(item.name) and versionOk(tostring(item.from or "")) then + if nativeSource(item.source) and validName(item.name) and versionOk(tostring(item.from or "")) then table.insert(specs, { name = item.name, version = item.from }) end end @@ -1612,15 +1987,7 @@ local function probeRun(payload) publish() return end - local cmd = "set -- " .. specArgs(specs) .. "\n" .. backend.findPkgSh .. [[ - -while [ "$#" -ge 2 ]; do - if find_pkg "$1" "$2" >/dev/null; then c=ok; else c=miss; fi - ]] .. backend.reverseDepsCountSh .. [[ - - printf '%s|%s|%s\n' "$1" "$c" "$n" - shift 2 -done]] + local cmd = "set -- " .. specArgs(specs) .. "\n" .. probeBody noctalia.runAsync(cmd, function(result) if result.timedOut then return @@ -1694,11 +2061,15 @@ end local function addIgnore(payload) local name = payloadPkg(payload) - if not validName(name) then + if not validIgnoreName(name) then return end addIgnoreName(name) - for _, entry in ipairs({ sources.pacman, sources.aur, sources.flatpak }) do + local slots = { sources.pacman, sources.aur, sources.flatpak } + for _, entry in pairs(extraSources) do + table.insert(slots, entry) + end + for _, entry in ipairs(slots) do for index, item in ipairs(entry.items or {}) do if item.name == name then table.remove(entry.items, index) @@ -1708,7 +2079,7 @@ local function addIgnore(payload) end end end - total = sources.pacman.n + sources.aur.n + sources.flatpak.n + total = pendingTotal() if phase == "ready" and total == 0 then phase = "clean" end @@ -1777,6 +2148,7 @@ local function handle(action, payload) sources.pacman = { n = 0, items = {} } sources.aur = { n = 0, items = {}, helper = sources.aur.helper } sources.flatpak = { n = 0, items = {} } + extraSources = {} total = 0 downloadSizeMiB = nil runExit = nil @@ -1818,6 +2190,27 @@ function onIpc(event, payload) end function update() + local sig = checkSignature() + if checkSig ~= nil and sig ~= checkSig then + checkSig = sig + -- Only settled phases are dropped: a run or a check in flight + -- finishes under the settings it started with and re-publishes. + if phase == "ready" or phase == "clean" or phase == "error" then + phase = "idle" + sources = { pacman = { n = 0, items = {} }, aur = { n = 0, items = {}, helper = "" }, flatpak = { n = 0, items = {} } } + extraSources = {} + total = 0 + downloadSizeMiB = nil + ignoredPending = {} + errMsg = nil + runExit = nil + checkedAt = "" + publish() + end + elseif checkSig == nil then + checkSig = sig + end + if phase == "running" then runPollTicks += 1 if runPollTicks >= RUN_POLL_SECONDS then diff --git a/linux-updater/tests/run.sh b/linux-updater/tests/run.sh index bea42752..c1d5da83 100755 --- a/linux-updater/tests/run.sh +++ b/linux-updater/tests/run.sh @@ -149,6 +149,111 @@ eq(items[1].name, "libarchive", "xbps name") eq(items[1].to, "3.8.9_1", "xbps version") ' +# ── extra sources: one parser per manager, factory returns the whole array ── +run_extra() { + fixture=$1 + key=$2 + asserts=$3 + run_case extras.luau "$fixture" " +local byKey = {} +for _, e in ipairs(backend) do byKey[e.key] = e end +local extra = byKey[\"$key\"] or fatal(\"extra $key missing\") +local ign = {} +local n, items = extra.parseCheck(FIXTURE, {}, ign) +$asserts" +} +run_extra fixtures/extras/npm-outdated-parseable.txt npm ' +eq(n, 4, "npm count") +eq(items[1].name, "corepack", "npm name") +eq(items[1].from, "0.34.6", "npm current") +eq(items[1].to, "0.35.0", "npm wanted") +eq(items[3].name, "semver", "npm plain package") +eq(items[4].name, "@angular/cli", "npm scoped name keeps its prefix") +eq(items[4].from, "18.2.8", "npm scoped current") +eq(extra.buildRollbackCommand(items[3]), "npm -g install '\''semver@7.5.0'\''", "npm rollback reinstalls the old version") +eq(extra.buildRollbackCommand(items[4]), "npm -g install '\''@angular/cli@18.2.8'\''", "npm scoped rollback") +eq(extra.buildRollbackCommand({ name = "x", from = "" }), nil, "npm rollback needs a recorded old version") +local ign2 = {} +local n2 = extra.parseCheck(FIXTURE, { ["@angular/cli"] = true }, ign2) +eq(n2, 3, "scoped ignore drops from the count") +eq(ign2[1].name, "@angular/cli", "scoped ignore routed to the ignored section") +' +run_extra fixtures/extras/cargo-install-update-list.txt cargo ' +eq(n, 1, "cargo count (No rows, header and registry-poll line skipped)") +eq(items[1].name, "ripgrep", "cargo name") +eq(items[1].from, "14.0.3", "cargo installed, v stripped") +eq(items[1].to, "15.2.0", "cargo latest") +eq(extra.buildRollbackCommand(items[1]), "cargo install --force --version '\''14.0.3'\'' '\''ripgrep'\''", "cargo rollback pins the version") +' +run_extra fixtures/extras/pip-list-outdated.txt pip ' +eq(n, 2, "pip count (both header lines skipped)") +eq(items[2].name, "requests", "pip name") +eq(items[2].from, "2.31.0", "pip installed") +eq(items[2].to, "2.34.2", "pip latest") +eq(extra.buildUpdateCommand, nil, "pip stays check-only") +eq(extra.rollbackKind, nil, "pip has no rollback") +' +run_extra fixtures/extras/gem-outdated.txt gem ' +eq(n, 3, "gem count") +eq(items[2].name, "rake", "gem name") +eq(items[2].from, "13.1.0", "gem installed (highest of the side-by-side versions)") +eq(items[2].to, "13.4.2", "gem latest") +eq(items[3].from, "6.6.3.1", "gem four-part version") +eq( + extra.buildRollbackCommand(items[2]), + "gem uninstall -x -I '\''rake'\'' -v '\''13.4.2'\'' >/dev/null 2>&1; gem install '\''rake'\'' -v '\''13.1.0'\''", + "gem rollback removes the new version, then ensures the old one" +) +' +run_extra fixtures/extras/snap-refresh-list.txt snap ' +eq(n, 2, "snap count (header skipped)") +eq(items[2].name, "firefox", "snap name") +eq(items[2].from, "", "snap has no old version") +eq(items[2].to, "130.0.1-1", "snap new version") +eq(extra.rollbackKind, "revert", "snap rollback is a revert") +eq(extra.buildRollbackCommand(items[2]), "snap revert '\''firefox'\''", "snap revert needs no version") +' +run_extra fixtures/extras/brew-outdated-quiet.txt brew ' +eq(n, 2, "brew count") +eq(items[1].name, "wget", "brew name") +eq(items[1].to, "", "brew reports names only") +eq(extra.rollbackKind, nil, "brew has no rollback") +' + +# ── packagekit: ignore honored via an explicit pending-minus-ignored list ──── +run_case backends/packagekit.luau /dev/null ' +eq(backend.buildBackgroundCommand({}, nil), "pkcon -y --plain refresh && pkcon -y --plain update", "pk no filter = update all") +eq( + backend.buildBackgroundCommand({"x"}, {"'\''curl'\''", "'\''vim'\''"}), + "pkcon -y --plain refresh && pkcon -y --plain update '\''curl'\'' '\''vim'\''", + "pk explicit names" +) +eq(backend.buildBackgroundCommand({"x"}, {}), "pkcon -y --plain refresh", "pk everything ignored = refresh only") +eq(backend.ignoreByExplicitList, true, "pk asks the engine for the pending list") +' + +# ── apt: opportunistic cache rollback pieces ───────────────────────────────── +run_case backends/apt.luau /dev/null ' +eq(backend.caps.rollback, "cache", "apt rollback is cache-kind") +if backend.findPkgSh:find("%3a", 1, true) == nil then fatal("apt findPkgSh must encode the epoch colon as %3a") end +if backend.rollbackInstall:find("--allow-downgrades", 1, true) == nil then fatal("apt rollback install needs --allow-downgrades") end +if backend.depsListCommand("curl"):find("--recurse --installed", 1, true) == nil then fatal("apt deps list must be recursive and installed-only") end +' + +# ── dnf: per-item downgrade with reasoned failure ──────────────────────────── +run_case backends/dnf.luau /dev/null ' +eq( + backend.rollbackItemCommand({ name = "openssl-libs", from = "1:3.2.2-9.fc41" }), + "pkexec dnf -y downgrade '\''openssl-libs-1:3.2.2-9.fc41'\''", + "dnf downgrade pins the exact recorded version, epoch included" +) +eq(backend.rollbackItemCommand({ name = "curl", from = "" }), nil, "dnf item rollback needs a recorded version") +if backend.itemProbeSh:find("repoquery", 1, true) == nil or backend.itemProbeSh:find("%-C", 1) == nil then + fatal("dnf probe must be a cache-only repoquery") +end +eq(backend.rollbackFailHintKey, "err_dnf_rollback_unavailable", "dnf failure names its cause") +' + # ── fail-closed ignores: the upgrade must be gated (&&) on refresh/hold/lock # setup, never chained with ";" — a failed hold must stop the upgrade ──────── check_gated() { diff --git a/linux-updater/translations/en.json b/linux-updater/translations/en.json index 0f8f727b..9d98e447 100644 --- a/linux-updater/translations/en.json +++ b/linux-updater/translations/en.json @@ -39,10 +39,9 @@ "one": "1 package ignored", "other": "{count} packages ignored" }, - "err_aur_failed": "AUR check failed, see the system log for details", + "check_failed_source": "{source} check failed, see the system log for details", "err_check_failed": "The update check failed, see the system log for details", "err_check_timeout": "Timed out while checking for updates", - "err_flatpak_failed": "Flatpak check failed, see the system log for details", "err_no_apt": "apt-get not found, this backend needs the Debian package manager", "err_no_checkupdates": "checkupdates not found, install pacman-contrib and check your PATH", "err_no_dnf": "dnf not found, this backend needs the Fedora package manager", @@ -88,8 +87,10 @@ }, "polkit_hint": "Polkit asks for a password several times per update", "reboot_recommended": "Reboot recommended, the running kernel is no longer installed", + "err_dnf_rollback_unavailable": "Rollback failed (exit {code}): dnf could not install the old version — it has most likely left the repositories. The log below has dnf's own message.", "rollback_confirm": "Sure?", "rollback_missing": "The old version is no longer in the package cache", + "rollback_missing_repo": "The old version is no longer available in the repositories", "rollback_required_by": { "one": "Careful: 1 installed package requires it", "other": "Careful: {count} installed packages require it" @@ -133,6 +134,30 @@ "description": "Check for updates automatically every N hours. 0 (default) never checks on its own.", "label": "Auto-check interval (hours)" }, + "brew_enabled": { + "description": "Also check and update Homebrew packages ('brew outdated' / 'brew upgrade'). Skipped when brew is not installed.", + "label": "Include Homebrew" + }, + "cargo_enabled": { + "description": "Also check and update cargo-installed binaries via cargo-update ('cargo install-update'). Skipped when cargo-install-update is not installed.", + "label": "Include Cargo binaries" + }, + "gem_enabled": { + "description": "Also check and update RubyGems ('gem outdated' / 'gem update'). Skipped when gem is not installed.", + "label": "Include RubyGems" + }, + "npm_enabled": { + "description": "Also check and update global npm packages ('npm -g outdated' / 'npm -g update'). Skipped when npm is not installed; a root-owned global prefix will make the update step fail — use a user-level prefix (nvm, fnm).", + "label": "Include npm (global)" + }, + "pip_enabled": { + "description": "Also check outdated pip packages ('pip list --outdated'). Check-only: the update run never touches them, because distribution Pythons are externally managed (PEP 668) and blind upgrades break environments.", + "label": "Include pip (check only)" + }, + "snap_enabled": { + "description": "Also check and update snaps ('snap refresh --list' / 'snap refresh'). Skipped when snap is not installed; the refresh escalates through snapd's own polkit policies.", + "label": "Include Snap" + }, "backend": { "description": "Which package-manager backend drives checks and updates. Auto detects the distribution from /etc/os-release: Arch and derivatives get the native pacman backend, everything else falls back to PackageKit.", "label": "Backend", @@ -220,8 +245,14 @@ "source": { "aur": "AUR", "aur_named": "AUR ({helper})", + "brew": "Homebrew", + "cargo": "Cargo", "flatpak": "Flatpak", + "gem": "RubyGems", + "npm": "npm", "pacman": "Pacman", + "pip": "pip", + "snap": "Snap", "system": "System" }, "status_checking": "Checking for updates…", @@ -244,7 +275,11 @@ "tip_ignore": "Ignore this package: hide it from the count and skip it on update", "tip_open_page": "Open package page", "tip_polkit_install": "Install a polkit rule (asks for your password once): afterwards one password covers a whole update run, kept for ~5 minutes like sudo. Note: the rule applies to any pkexec launch of the package-manager binaries from your active local session, not only this plugin.", - "tip_rollback": "Roll back to {version}, together with its dependencies from this run. pacman refuses the whole transaction if it would break other packages.", + "tip_rollback": "Roll back to {version}, together with its dependencies from this run. The package manager refuses the whole transaction if it would break other packages.", + "tip_rollback_item": "Downgrade to {version} with the package manager. Works while the old version is still in the repositories.", + "tip_rollback_commit": "Roll back this Flatpak app to its previous commit. If the remote no longer serves it, the run fails without changing anything.", + "tip_rollback_extra": "Reinstall version {version} through this package manager; it resolves its own dependencies.", + "tip_rollback_revert": "Revert to the previous revision, which snapd keeps locally.", "tip_rollback_run": "Downgrade every package of this run in one transaction", "tip_run_terminal": "Open a terminal window where prompts work normally", "tip_unignore": "Stop ignoring this package",