diff --git a/linux-updater/README.md b/linux-updater/README.md new file mode 100644 index 00000000..319f58af --- /dev/null +++ b/linux-updater/README.md @@ -0,0 +1,295 @@ +# 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 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). + 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 + 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. +- **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 + quick actions (`/up`), full log in a terminal pager, an opt-in activity + graph of pending-update counts across recent checks. + +## 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`) + 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 | 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, 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 +`backends/` is open for contributions. + +## Requirements + +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. +- 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. +- Optional, only when the matching extra source is enabled: `npm`, + `cargo-install-update` (from cargo-update), `pip`, `gem`, `snap`, + `brew`. + +## 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 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 +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. | +| `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. | +| `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 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 +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 +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, 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; 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 + 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`, + `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. +- **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: 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). +- **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, 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 +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..7876ab55 --- /dev/null +++ b/linux-updater/backends/apt.luau @@ -0,0 +1,182 @@ +--!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 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 = "cache", 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. + -- 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 = {} + 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. 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) + if #ignored == 0 then + 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 + .. "; 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 + + -- "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]] + + -- 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 = { + { + 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..75702e82 --- /dev/null +++ b/linux-updater/backends/dnf.luau @@ -0,0 +1,158 @@ +--!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 + + -- "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 + 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 + + -- 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 new file mode 100644 index 00000000..4b1d8336 --- /dev/null +++ b/linux-updater/backends/packagekit.luau @@ -0,0 +1,112 @@ +--!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. 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) + -- 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, 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, 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 + + -- 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..cb6201ee --- /dev/null +++ b/linux-updater/backends/pacman.luau @@ -0,0 +1,213 @@ +--!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, +-- 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) + 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. 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 + -- 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. 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) + 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 + + -- "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. + 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..b50543d7 --- /dev/null +++ b/linux-updater/backends/xbps.luau @@ -0,0 +1,111 @@ +--!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 + + -- 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 + 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..be0b080d --- /dev/null +++ b/linux-updater/backends/zypper.luau @@ -0,0 +1,125 @@ +--!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 + + -- 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 + 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 + + -- "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 +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/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/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/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/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/launcher.luau b/linux-updater/launcher.luau new file mode 100644 index 00000000..b146a686 --- /dev/null +++ b/linux-updater/launcher.luau @@ -0,0 +1,116 @@ +--!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 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) + end + local prefix = PACKAGE_URLS[sourceKey] or PACKAGE_URLS.flatpak + return prefix .. 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 _, 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 + 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..de9bfe06 --- /dev/null +++ b/linux-updater/panel.luau @@ -0,0 +1,1266 @@ +--!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. +-- +-- "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" + +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 activityHoverIndex = nil -- activity graph point currently under the pointer + +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 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 PACKAGE_URLS[sourceKey] ~= nil then + openUrl(PACKAGE_URLS[sourceKey] .. 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 + 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" + 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 + -- 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", 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" +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 }, + } + 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 + 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 + -- 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 + -- 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 + 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 + +-- ── 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() + 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 + +-- 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 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(kind == "cache" and "rollback_missing" or "rollback_missing_repo"), + onClick = function() end, + })) + else + 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 + 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 nativeSource(item.source) 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. 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({ + 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 }), + } + + -- 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 + + -- 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 activity ~= nil or history ~= nil then + table.insert(children, ui.separator({})) + 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 + 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 = {} + -- 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, + 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, + })) + local terminalMode = noctalia.getConfig("update_mode") == "terminal" + table.insert(footer, ui.button({ + key = "update" .. (hasUpdates and "" or "-off"), + 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 (the log is tee'd from the + -- terminal too). + 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 + activityHoverIndex = 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..a774362f --- /dev/null +++ b/linux-updater/plugin.toml @@ -0,0 +1,268 @@ +id = "umedbazarov/linux-updater" +name = "Linux Updater" +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", "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 ────────────────────────────────────────────────────────────────── + +[[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 + +# ── 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" +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 + +[[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" +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 00000000..c0e9f12c Binary files /dev/null and b/linux-updater/screenshots/history.png differ diff --git a/linux-updater/screenshots/panel.png b/linux-updater/screenshots/panel.png new file mode 100644 index 00000000..4aae4888 Binary files /dev/null and b/linux-updater/screenshots/panel.png differ diff --git a/linux-updater/service.luau b/linux-updater/service.luau new file mode 100644 index 00000000..020d248d --- /dev/null +++ b/linux-updater/service.luau @@ -0,0 +1,2271 @@ +--!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, extras, downloadSizeMiB, rebootRecommended, +-- newsUnread, newsLatestTitle, err, checkedAt, +-- ignoredCount, ignoredPending, ignoredDynamic, +-- ignoredConfig, history, activity, lastUpdateAt, +-- logTail, logPath, runExit, +-- progress = { done, total } } +-- requests "linup_request" = { nonce, action, pkg } +-- -- 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 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" +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" + +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 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 + +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 resumeProbed = false -- resumeRunIfActive's log probe has answered +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 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 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} + +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, +}) + +-- 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 + 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 + +-- 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 +-- 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 validIgnoreName(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 validIgnoreName(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 + +-- 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 validIgnoreName(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 = {} + 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 + -- 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 + 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 +-- 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 + 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 + 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 + end + return n, items +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, + phase = phase, + step = step, + total = total, + 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, + 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, + sourceRollback = SOURCE_ROLLBACK, + nativeItemRollback = backend.rollbackItemCommand ~= nil, + history = historyForState(), + activity = activityHistory, + lastUpdateAt = activityLastUpdateAt, + runKind = runKind, + runMode = runMode, + 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: backend → AUR → Flatpak → extras → size → reboot ───── + +local checkFlatpak +local checkExtras +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 + +-- 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 = {} } + checkExtras(ignored) + 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. + -- 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=$? +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 }' | 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 + 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) .. ")") + sources.flatpak = { n = 0, items = {}, failed = true } + checkExtras(ignored) + return + end + local n, items = parseTabLines(result.stdout, ignored) + sources.flatpak = { n = n, items = items } + checkExtras(ignored) + end, CHECK_TIMEOUT_MS) + if not started then + noctalia.log("linux-updater: could not run the flatpak check") + sources.flatpak = { n = 0, items = {}, failed = true } + checkExtras(ignored) + 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() + -- 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 + noctalia.log("linux-updater: AUR check timed out") + sources.aur = { n = 0, items = {}, helper = helperShown, failed = true } + 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)) + 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 = helperShown } + checkFlatpak(ignored) + end, CHECK_TIMEOUT_MS) + if not started then + noctalia.log("linux-updater: could not run the AUR check") + sources.aur = { n = 0, items = {}, helper = helperShown, failed = true } + 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 = {} } } + extraSources = {} + 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 + +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 = pendingTotal() + step = "" + 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 })) + 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. 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" .. 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]] .. 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(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 + +-- 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(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 + +-- 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, mode = runMode, failHint = runFailHintKey, 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" + 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 +end + +local function clearRunMeta() + local path = runMetaPath() + if path ~= nil then + noctalia.runAsync("rm -f -- " .. shellQuote(path)) + end +end + +local function beginRun(kind, expectTotal, mode) + phase = "running" + errMsg = nil + step = "" + runKind = kind or "update" + runMode = mode or "background" + runFailHintKey = nil + 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. + -- 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 + .. "; { export LC_ALL=C; " .. 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", 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 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) + 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 nativeSource(item.source) 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 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) + 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 + 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 + addIgnoreName(item.name) + 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() + phase = "clean" + publish() + startCheck() -- verify: phase becomes checking, then clean/ready + else + runPackages = nil + clearRunMeta() + phase = "error" + -- 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 +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. 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 + return + 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(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 + -- 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 + 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 + 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 + 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() + 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 + 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 + +-- 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 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 then + target = item + break + end + end + 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 nativeSource(item.source) 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 + -- 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) + 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 nativeSource(item.source) 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 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 + end + runRollback(specs, items) +end + +-- 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) + 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) + if run == nil then + return + end + local specs = {} + for _, item in ipairs(run.packages) do + 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 + if #specs == 0 then + probeResult = { at = run.at, pkgs = {} } + publish() + return + end + local cmd = "set -- " .. specArgs(specs) .. "\n" .. probeBody + 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 validIgnoreName(name) then + return + end + addIgnoreName(name) + 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) + 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 = pendingTotal() + 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 + -- 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() + 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 = {} } + extraSources = {} + 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_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 +-- 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() + 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 + 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 + -- 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 + 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/tests/run.sh b/linux-updater/tests/run.sh new file mode 100755 index 00000000..c1d5da83 --- /dev/null +++ b/linux-updater/tests/run.sh @@ -0,0 +1,280 @@ +#!/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") +' + +# ── 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() { + 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/thumbnail.webp b/linux-updater/thumbnail.webp new file mode 100644 index 00000000..8038df58 Binary files /dev/null and b/linux-updater/thumbnail.webp differ diff --git a/linux-updater/translations/en.json b/linux-updater/translations/en.json new file mode 100644 index 00000000..9d98e447 --- /dev/null +++ b/linux-updater/translations/en.json @@ -0,0 +1,301 @@ +{ + "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", + "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", + "other": "{count} packages ignored" + }, + "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_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": "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…", + "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", + "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" + }, + "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": { + "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" + }, + "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)" + }, + "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", + "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 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.", + "label": "Custom update command" + } + }, + "size_gib": "≈ {value} GiB to download", + "size_mib": "≈ {value} MiB to download", + "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…", + "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…", + "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. 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. 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", + "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", + "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()