diff --git a/arch-updater/README.md b/arch-updater/README.md new file mode 100644 index 0000000..e47453e --- /dev/null +++ b/arch-updater/README.md @@ -0,0 +1,140 @@ +# Arch Updater + +Check pacman, AUR and Flatpak for updates from the bar, with an estimated +download size and an Arch news heads-up before you upgrade. Runs the update +in a terminal window. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `yuuto/arch-updater` | +| Entries | Bar widget: `widget`; panel: `panel`; service: `service`; launcher: `launcher` | +| Launcher Prefix | `/arch` | + +## Requirements + +- `pacman-contrib` on `PATH` (for `checkupdates`), required. +- `pacman`, `sh`, `sudo`, `awk`, `sed`, `test` and `uname`, required — base + tools from any standard Arch install, used to run and parse the pacman + check, build the download size estimate, check the running kernel, and + run the plain-pacman upgrade. +- `yay` or `paru` on `PATH`, optional, for the AUR check and update. + Auto-detected by default, see the **AUR helper** setting. +- `flatpak`, optional, for the Flatpak check and update. +- `xdg-open`, optional, to open a package page or the Arch news page. +- A terminal emulator for the update run: Noctalia's own detection + (`$TERMINAL`, then `ghostty`, `kitty`, `alacritty`, `wezterm`, `foot`, + `konsole`, `gnome-terminal`, `ptyxis`, `xterm`), or the one named in the + **Terminal** setting. + +Everything except `pacman-contrib`, `pacman`, `sh`, `sudo`, `awk`, `sed`, +`test` and `uname` is optional. A missing optional tool is skipped, not +treated as an error. + +## Usage + +Add the `widget` bar widget from Noctalia's widget picker. Left click opens +the panel, right click checks for updates now, middle click opens the +widget's own settings. You can also open the panel directly or bind it in +your compositor: + +```sh +noctalia msg panel-toggle yuuto/arch-updater:panel +``` + +The panel groups pending packages by source (Pacman, AUR, Flatpak). Click a +source row to expand it into its packages. Each package row has a copy +button (name and versions) and an open button (its page on archlinux.org, +the AUR, or Flathub). **Check Updates** queries all sources, **Update** opens +a terminal running the upgrade, **Dismiss** keeps the numbers but returns the +bar glyph to its resting colour. + +Type `/arch` in the launcher for quick actions (check, update, open news), or +`/arch ` to fuzzy-search the packages from the last check. Activating a +result opens that package's page. + +## Extras + +Not in the v4 plugin: + +- **Arch Linux news.** The news feed is checked periodically. An unread post + is flagged in the panel and the bar tooltip, with a button that opens the + news page and marks it read. Arch news is where manual-intervention steps + get announced, so it's worth seeing before a big upgrade. +- **Reboot recommendation.** Detected from whether the running kernel's + `/usr/lib/modules/` directory still exists, so it works for any + kernel flavour (`linux`, `linux-lts`, `linux-zen`, `linux-cachyos`, ...) + without naming one. Shown in the panel and the bar tooltip, and colours the + bar glyph independently of the pending count. +- **Estimated download size** for the pending pacman packages (`pacman -Si`), + shown before you commit to **Update**. +- **An ignore list that's actually enforced.** v4 only let you rewrite the + raw check/update commands. Here, packages in **Ignore packages** are + filtered out of both the count and the update run (`--ignore`), on top of + `pacman.conf`'s own `IgnorePkg`. +- **Auto-detected AUR helper**, with `yay`/`paru`/a custom command/off as + explicit overrides. +- **A generic package-page link** (`archlinux.org/packages`, + `aur.archlinux.org`, `flathub.org`) instead of hardcoded per-repo mirror + URLs, so it stays correct across Arch-based distros. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `aur_helper` | `select` | `auto` | Which AUR helper to use: auto-detect (yay, then paru), `yay`, `paru`, a custom command, or off. | +| `aur_check_cmd` | `string` | *(empty)* | Custom AUR check command, only used when `aur_helper` is `custom`. Must print `name oldver -> newver` per line. | +| `flatpak_enabled` | `bool` | `true` | Also check and update Flatpak. Skipped automatically when `flatpak` isn't installed. | +| `ignore_packages` | `string_list` | *(empty)* | Package names excluded from the count and passed as `--ignore` on update. | +| `auto_check_hours` | `int` | `0` | Check automatically every N hours. `0` never checks on its own. | +| `notify_on_updates` | `bool` | `true` | Send a desktop notification when a check finds packages to upgrade. | +| `show_download_size` | `bool` | `true` | Show the estimated pacman download size (`pacman -Si`) in the panel. | +| `check_arch_news` | `bool` | `true` | Check the Arch Linux news feed and flag unread posts. | +| `check_reboot_needed` | `bool` | `true` | Flag when the running kernel is no longer installed on disk. | +| `terminal` | `string` | *(empty)* | Terminal command for the update run, e.g. `kitty`. Empty uses Noctalia's detection. | +| `assume_yes` | `bool` | `false` | Pass `--noconfirm` / `-y` so package managers do not ask for confirmation. | +| `update_cmd` | `string` | *(empty)* | Full override for the update command. Empty builds it from the settings above. | +| `glyph` | `glyph` | `package` | The glyph shown for the widget on the bar. | +| `show_count` | `bool` | `true` | Show the pending-update count next to the bar glyph. | +| `hide_on_empty` | `bool` | `false` | Hide the widget entirely when there is nothing to show. | + +## IPC + +```sh +noctalia msg plugin yuuto/arch-updater:service all check +noctalia msg plugin yuuto/arch-updater:service all update +noctalia msg plugin yuuto/arch-updater:service all dismiss +``` + +## Notes + +- **Commands spawned.** `checkupdates`; the configured AUR helper's `-Qua` + (or your custom command); `flatpak list` / `flatpak remote-ls --updates`; + `pacman -Si` for the download size (piped through `awk` to sum it); a local + `test -d` against `uname -r` for the reboot check; and, for the update, + your terminal running `sh` to launch `sudo pacman`/the AUR helper, + optionally `flatpak update` (filtered through `awk` when packages are + ignored, using `sed` to combine the Flatpak listings during the check). No + upgrade command runs outside the terminal. +- **Network.** `checkupdates`, the AUR helper and the Flatpak check contact + mirrors, the AUR RPC, or a Flatpak remote, same as the corresponding + upgrade would. The Arch news check fetches `archlinux.org/feeds/news/` on + its own schedule, independent of a manual check. +- **Privileges.** The plugin never elevates anything itself. Plain pacman + updates run via `sudo pacman -Syu` in the terminal, where `sudo` prompts + normally. An AUR helper handles its own privilege escalation as usual. +- **Files.** One small file in the plugin's data directory tracks the last + Arch news post you've read, so unread counts survive a restart. Nothing + else is written; `pacman.conf` is never modified. +- **Sizes are pacman-only.** AUR and Flatpak downloads aren't sized. Most AUR + packages build from source, where a download size wouldn't mean much. + +## Credits + +Ported from the v4 QML "Arch Updater" plugin (MIT), rebuilt for v5's Luau +plugin API with a different feature set. See **Extras** above. + +## License + +MIT. diff --git a/arch-updater/launcher.luau b/arch-updater/launcher.luau new file mode 100644 index 0000000..5867c96 --- /dev/null +++ b/arch-updater/launcher.luau @@ -0,0 +1,103 @@ +--!nonstrict +-- arch-updater launcher provider, under the `/arch` 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 "arch_state" the engine publishes. Activating a package opens its +-- page, same as the panel's "open" button on a package row. + +local STATE_KEY = "arch_state" +local REQUEST_KEY = "arch_request" + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function request(action) + local prev = noctalia.state.get(REQUEST_KEY) + local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1 + noctalia.state.set(REQUEST_KEY, { nonce = nonce, action = action }) +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function openUrl(url) + if noctalia.commandExists("xdg-open") then + noctalia.runAsync("xdg-open " .. shellQuote(url) .. " >/dev/null 2>&1") + end +end + +local SOURCE_GLYPH = { pacman = "package", aur = "box", flatpak = "app-window" } + +local function packageUrl(sourceKey, name) + if sourceKey == "pacman" then + return "https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name) + elseif sourceKey == "aur" then + return "https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name) + end + return "https://flathub.org/apps/" .. noctalia.string.urlEncode(name) +end + +local function commandResults() + return { + { id = "cmd-check", title = tr("action_check"), subtitle = tr("launcher.check_subtitle"), glyph = "refresh" }, + { id = "cmd-update", title = tr("action_update"), subtitle = tr("launcher.update_subtitle"), glyph = "download" }, + { id = "cmd-news", title = tr("action_open_news"), subtitle = tr("launcher.news_subtitle"), glyph = "news" }, + } +end + +local function packageResults(query) + local state = noctalia.state.get(STATE_KEY) + if type(state) ~= "table" then + return {} + end + + local results = {} + local sourceList = { { key = "pacman", entry = state.pacman }, { key = "aur", entry = state.aur }, { key = "flatpak", entry = state.flatpak } } + for _, source in ipairs(sourceList) do + local items = type(source.entry) == "table" and source.entry.items or nil + if type(items) == "table" then + for _, item in ipairs(items) do + local score = noctalia.fuzzyScore(query, item.name) + if score ~= nil then + local subtitle = (item.from ~= nil and item.from ~= "" and item.to ~= nil and item.to ~= "") + and (item.from .. " → " .. item.to) + or tr("source." .. source.key) + table.insert(results, { + id = "pkg-" .. source.key .. "-" .. item.name, + title = item.name, + subtitle = subtitle, + glyph = SOURCE_GLYPH[source.key] or "package", + score = score, + }) + end + end + end + end + return results +end + +function onQuery(query) + if query == "" then + launcher.setResults(query, commandResults()) + return + end + launcher.setResults(query, packageResults(query)) +end + +function onActivate(id) + if id == "cmd-check" then + request("check") + elseif id == "cmd-update" then + request("update") + elseif id == "cmd-news" then + request("open_news") + else + local sourceKey, name = id:match("^pkg%-([a-z]+)%-(.+)$") + if sourceKey ~= nil and name ~= nil then + openUrl(packageUrl(sourceKey, name)) + end + end +end diff --git a/arch-updater/panel.luau b/arch-updater/panel.luau new file mode 100644 index 0000000..bdf70d6 --- /dev/null +++ b/arch-updater/panel.luau @@ -0,0 +1,430 @@ +--!nonstrict +-- arch-updater update panel. Pure renderer over the shared state: the engine +-- (service.luau) publishes "arch_state" and performs the "arch_request" +-- actions this panel emits, so closing the panel never interrupts a check or +-- a run in progress. +-- +-- "Check Updates" only queries pacman, the AUR helper and (optionally) +-- Flatpak. Only then do "Update" (open a terminal and run the upgrade) and +-- "Dismiss" (keep the numbers, quiet the bar) light up. + +local STATE_KEY = "arch_state" +local REQUEST_KEY = "arch_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 render + +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 not noctalia.commandExists("xdg-open") then + noctalia.notifyError(tr("title"), tr("err_no_xdg_open")) + return + end + noctalia.runAsync("xdg-open " .. shellQuote(url) .. " >/dev/null 2>&1") +end + +-- Opens a generic Arch package search instead of a per-repo mirror URL, so +-- it stays correct across Arch-based distros. +local function openPackage(sourceKey, name) + if sourceKey == "pacman" then + openUrl("https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name)) + elseif sourceKey == "aur" then + openUrl("https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name)) + elseif sourceKey == "flatpak" then + openUrl("https://flathub.org/apps/" .. noctalia.string.urlEncode(name)) + end +end + +local function detailFor(item) + local from = item.from ~= nil and item.from or "" + local to = item.to ~= nil and item.to or "" + if to == "" then + return item.name + end + if from == "" then + return item.name .. " → " .. to + end + return item.name .. " " .. from .. " → " .. to +end + +local function phaseOf() + return snapshot ~= nil and snapshot.phase or "idle" +end + +local function totalOf() + return snapshot ~= nil and tonumber(snapshot.total) or 0 +end + +local function busy() + local phase = phaseOf() + return phase == "checking" or phase == "running" +end + +-- Only "checking" blocks a new check: "running" still allows one, since it +-- doubles as a manual unstick if the update's process-poll heuristic ever +-- misses the terminal finishing. +local function checking() + return phaseOf() == "checking" +end + +local function headline() + local phase = phaseOf() + if phase == "missing" then + return 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 + 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 + return tr("source." .. key) +end + +-- pacman, then the AUR helper, then Flatpak. Only sources with pending +-- packages, biggest first. +local function orderedSources() + if snapshot == nil then + return {} + end + local candidates = { + { key = "pacman", entry = snapshot.pacman }, + { key = "aur", entry = snapshot.aur }, + { key = "flatpak", entry = snapshot.flatpak }, + } + local pending = {} + for _, candidate in ipairs(candidates) do + if type(candidate.entry) == "table" and (candidate.entry.n or 0) > 0 then + table.insert(pending, candidate) + end + end + table.sort(pending, function(a, b) + return a.entry.n > b.entry.n + end) + return pending +end + +local function cleanSources() + if snapshot == nil then + return {} + end + local names = {} + local candidates = { { key = "pacman", entry = snapshot.pacman }, { key = "flatpak", entry = snapshot.flatpak } } + if snapshot.aur ~= nil and (snapshot.aur.n or 0) == 0 and noctalia.getConfig("aur_helper") ~= "off" then + table.insert(candidates, { key = "aur", entry = snapshot.aur }) + end + for _, candidate in ipairs(candidates) do + if type(candidate.entry) == "table" and (candidate.entry.n or 0) == 0 then + table.insert(names, sourceLabel(candidate.key, candidate.entry)) + end + end + return names +end + +local function packageRow(sourceKey, index, item) + local key = "pkg-" .. sourceKey .. "-" .. index + local children = { + ui.label({ text = item.name, fontSize = 11, color = "on_surface", flexGrow = 1, maxLines = 1 }), + } + local from = item.from ~= nil and item.from or "" + local to = item.to ~= nil and item.to or "" + if to ~= "" then + if from ~= "" then + table.insert(children, ui.label({ + text = from, fontSize = 11, color = "on_surface_variant", maxWidth = VERSION_WIDTH, maxLines = 1, + })) + end + table.insert(children, ui.label({ text = "→", fontSize = 11, color = "on_surface_variant" })) + table.insert(children, ui.label({ + text = to, fontSize = 11, color = "primary", fontWeight = "semibold", maxWidth = VERSION_WIDTH, maxLines = 1, + })) + end + table.insert(children, ui.button({ + glyph = "copy", variant = "ghost", controlSize = "sm", 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", 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.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 + +-- 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 + + 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 = {} + + local rows = sourceRows() + if #rows > 0 then + table.insert(children, ui.scroll({ key = "sources", flexGrow = 1, gap = 6 }, 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 + + for _, line in ipairs(extras()) do + table.insert(children, line) + end + + 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 + + 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 = tonumber(snapshot.ignoredCount) or 0 + 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" or phase == "running") + + 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" .. (checking() and "-off" or ""), + glyph = "refresh", + variant = "ghost", + enabled = not checking() and phase ~= "missing", + tooltip = tr("tip_check"), + onClick = function() + request("check") + end, + }), + ui.button({ + glyph = "close", variant = "ghost", tooltip = tr("tip_close"), + onClick = function() + panel.close() + end, + }), + }), + ui.label({ text = text, color = color, maxLines = 2 }), + } + + for _, node in ipairs(body()) do + table.insert(children, node) + end + + local 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 + + if phase ~= "missing" then + table.insert(children, ui.row({ gap = 8, align = "center" }, { + ui.button({ + key = "check" .. (checking() and "-off" or ""), + glyph = "refresh", text = tr("action_check"), variant = "ghost", enabled = not checking(), flexGrow = 1, + onClick = function() + request("check") + end, + }), + ui.button({ + key = "dismiss" .. (hasUpdates and "" or "-off"), + text = tr("action_dismiss"), variant = "ghost", enabled = hasUpdates and snapshot.dismissed ~= true, + onClick = function() + request("dismiss") + end, + }), + ui.button({ + key = "update" .. (hasUpdates and not busy() and "" or "-off"), + glyph = "download", text = tr("action_update"), variant = "primary", enabled = hasUpdates and not busy(), + tooltip = tr("tip_update"), + onClick = function() + request("update") + end, + }), + })) + 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 = "" + 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 + snapshot = value + render() +end) diff --git a/arch-updater/plugin.toml b/arch-updater/plugin.toml new file mode 100644 index 0000000..c21e7d0 --- /dev/null +++ b/arch-updater/plugin.toml @@ -0,0 +1,157 @@ +id = "yuuto/arch-updater" +name = "Arch Updater" +version = "1.0.0" +plugin_api = 9 +author = "yuuto" +license = "MIT" +icon = "package" +description = "Check pacman, AUR and Flatpak updates, get an Arch news and reboot heads-up, then upgrade from a terminal." +dependencies = ["pacman-contrib", "awk", "flatpak", "pacman", "paru", "sed", "sh", "sudo", "test", "uname", "xdg-open", "yay"] +tags = ["arch", "bar", "panel", "launcher", "system", "utility"] + +# ── General ────────────────────────────────────────────────────────────────── + +[[setting]] +key = "aur_helper" +type = "select" +label_key = "settings.aur_helper.label" +description_key = "settings.aur_helper.description" +default = "auto" +options = [ + { value = "auto", label_key = "settings.aur_helper.options.auto" }, + { value = "yay", label_key = "settings.aur_helper.options.yay" }, + { value = "paru", label_key = "settings.aur_helper.options.paru" }, + { value = "custom", label_key = "settings.aur_helper.options.custom" }, + { value = "off", label_key = "settings.aur_helper.options.off" }, +] + +[[setting]] +key = "aur_check_cmd" +type = "string" +label_key = "settings.aur_check_cmd.label" +description_key = "settings.aur_check_cmd.description" +default = "" +visible_when = { key = "aur_helper", values = ["custom"] } + +[[setting]] +key = "flatpak_enabled" +type = "bool" +label_key = "settings.flatpak_enabled.label" +description_key = "settings.flatpak_enabled.description" +default = true + +[[setting]] +key = "ignore_packages" +type = "string_list" +label_key = "settings.ignore_packages.label" +description_key = "settings.ignore_packages.description" +default = [] + +[[setting]] +key = "auto_check_hours" +type = "int" +label_key = "settings.auto_check_hours.label" +description_key = "settings.auto_check_hours.description" +default = 0 +min = 0 +max = 168 + +[[setting]] +key = "notify_on_updates" +type = "bool" +label_key = "settings.notify_on_updates.label" +description_key = "settings.notify_on_updates.description" +default = true + +# ── Extras (not in the v4 plugin) ─────────────────────────────────────────── + +[[setting]] +key = "show_download_size" +type = "bool" +label_key = "settings.show_download_size.label" +description_key = "settings.show_download_size.description" +default = true + +[[setting]] +key = "check_arch_news" +type = "bool" +label_key = "settings.check_arch_news.label" +description_key = "settings.check_arch_news.description" +default = true + +[[setting]] +key = "check_reboot_needed" +type = "bool" +label_key = "settings.check_reboot_needed.label" +description_key = "settings.check_reboot_needed.description" +default = true + +# ── Update run ─────────────────────────────────────────────────────────────── + +[[setting]] +key = "terminal" +type = "string" +label_key = "settings.terminal.label" +description_key = "settings.terminal.description" +default = "" + +[[setting]] +key = "assume_yes" +type = "bool" +label_key = "settings.assume_yes.label" +description_key = "settings.assume_yes.description" +default = false +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 = "arch" +glyph = "package" +include_in_global_search = false diff --git a/arch-updater/service.luau b/arch-updater/service.luau new file mode 100644 index 0000000..60e0a85 --- /dev/null +++ b/arch-updater/service.luau @@ -0,0 +1,747 @@ +--!nonstrict +-- arch-updater singleton engine. Checks pacman, the AUR helper and Flatpak, +-- publishes the result as shared state, and runs the update in a terminal. +-- +-- state "arch_state" = { nonce, phase, step, total, pacman, aur, +-- flatpak, downloadSizeMiB, rebootRecommended, +-- newsUnread, newsLatestTitle, dismissed, err, +-- checkedAt, ignoredCount } +-- requests "arch_request" = { nonce, action } -- check|update|dismiss +-- +-- Checking runs pacman, then the AUR helper, then Flatpak, then (if pacman +-- has pending packages) a `pacman -Si` pass for the download size. +-- +-- Update never runs in the background. runUpdate() opens a terminal so +-- pacman/the AUR helper can prompt and sudo can ask for a password, then +-- polls for the process to end and re-checks automatically. + +local STATE_KEY = "arch_state" +local REQUEST_KEY = "arch_request" +local NEWS_FILE = "news_state.json" +local NEWS_URL = "https://archlinux.org/feeds/news/" +local NEWS_PAGE = "https://archlinux.org/news/" + +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 -- reboot check: local filesystem only +local NEWS_RECHECK_HOURS = 6 +local RUN_POLL_SECONDS = 2 +local RUN_GRACE_SECONDS = 12 +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 dismissed = false +local errMsg = nil +local checkedAt = "" +local stateNonce = 0 +local lastRequestNonce = 0 + +local runTicks = 0 +local runSeen = false +local runPollTicks = 0 +local updateProcessName = "pacman" +local sinceCheck = 0 +local startupTicks = 0 +local sinceNewsCheck = 0 +local newsStateLoaded = false + +local startCheck +local checkNews + +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 + +-- 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 ignoreList() + 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 name:match("^[a-zA-Z0-9._+-]+$") ~= nil then + table.insert(names, name) + elseif name ~= "" then + noctalia.log("arch-updater: ignoring invalid package name '" .. name .. "'") + end + end + return names +end + +local function ignoreSet() + local set = {} + for _, name in ipairs(ignoreList()) do + set[name] = true + end + return set +end + +-- ── Parsing ────────────────────────────────────────────────────────────────── + +-- One "name oldver -> newver" line per package: checkupdates' format, which +-- yay -Qua and paru -Qua also use. +local function parseVersionLines(output, ignored) + 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 and not ignored[parts[1]] then + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = parts[1], from = parts[2], to = parts[4] }) + end + end + end + return n, items +end + +-- Flatpak has no "name oldver -> newver" line, so the query joins installed +-- and pending by application id (tab-separated name/from/to). +local function parseTabLines(output, ignored) + local items = {} + local n = 0 + for line in (output or ""):gmatch("[^\n]+") do + local fields = {} + for field in (line .. "\t"):gmatch("([^\t]*)\t") do + table.insert(fields, field) + end + local name = fields[1] or "" + if name ~= "" and not ignored[name] then + n += 1 + if #items < MAX_LISTED then + table.insert(items, { name = name, from = fields[2] or "", to = fields[3] or "" }) + end + end + end + return n, items +end + +-- ── AUR helper resolution ──────────────────────────────────────────────────── + +-- auto tries yay then paru. An explicit choice is trusted as-is and reported +-- missing instead of falling back to another helper. +local function 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 noctalia.commandExists("yay") then + return "yay" + end + if noctalia.commandExists("paru") then + return "paru" + end + return nil +end + +local function aurCheckCommand(helper) + if helper == "custom" then + local raw = trim(cfg("aur_check_cmd")) + return raw ~= "" and raw or nil + end + -- No 2>/dev/null and no formatting pipe here: stderr is inspected below + -- to tell a real failure from the "-Qua" family's usual no-updates exit + -- code, and the raw output already matches checkupdates' own format. + return helper .. " -Qua" +end + +-- ── Publishing ─────────────────────────────────────────────────────────────── + +local function publish() + stateNonce += 1 + noctalia.state.set(STATE_KEY, { + nonce = stateNonce, + phase = phase, + step = step, + total = total, + pacman = sources.pacman, + aur = sources.aur, + flatpak = sources.flatpak, + downloadSizeMiB = downloadSizeMiB, + rebootRecommended = rebootRecommended, + newsUnread = newsUnread, + newsLatestTitle = newsLatestTitle, + dismissed = dismissed, + err = errMsg, + checkedAt = checkedAt, + ignoredCount = #ignoreList(), + flatpakEnabled = cfg("flatpak_enabled") == true, + }) +end + +-- ── Checking pipeline: pacman → AUR → Flatpak → size → reboot → done ──────── + +local checkFlatpak +local checkSize +local checkReboot +local finishCheck + +local function failCheck(message) + phase = "error" + errMsg = message + publish() +end + +checkReboot = function() + if cfg("check_reboot_needed") ~= true then + rebootRecommended = false + finishCheck() + return + end + -- A kernel upgrade replaces the whole /usr/lib/modules/ tree. + -- Once the running kernel's own directory is gone, a reboot is what + -- switches to the new one. Works for any kernel flavour since it checks + -- against `uname -r` directly, without naming one. + local started = noctalia.runAsync( + [[test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing]], + 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 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 cmd = "LC_ALL=C pacman -Si " .. table.concat(names, " ") .. [[ 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 }']] + local started = noctalia.runAsync(cmd, function(result) + local value = tonumber(trim(result.stdout or "")) + downloadSizeMiB = (not result.timedOut and result.exitCode == 0 and value ~= nil) and value or nil + checkReboot() + end, SIZE_TIMEOUT_MS) + if not started then + downloadSizeMiB = nil + checkReboot() + end +end + +checkFlatpak = function(ignored) + if cfg("flatpak_enabled") ~= true or not noctalia.commandExists("flatpak") then + sources.flatpak = { n = 0, items = {} } + checkSize() + return + end + step = tr("source.flatpak") + publish() + -- Flatpak tracks commits, so a version string often doesn't move across + -- an update. Short commits stand in when it doesn't, joined by + -- application id in one awk pass. + -- Each call's own output and exit code are captured before piping into + -- awk, so a real flatpak failure (e.g. no remote, network down) fails + -- the whole command instead of awk quietly succeeding on empty input. + local cmd = [[ +listOut=$(flatpak list --columns=application,version,active 2>/dev/null); listCode=$? +updOut=$(flatpak remote-ls --updates --columns=application,version,commit 2>/dev/null); updCode=$? +if [ "$listCode" -ne 0 ] || [ "$updCode" -ne 0 ]; then + exit 1 +fi +{ printf '%s\n' "$listOut" | sed 's/^/L /' + printf '%s\n' "$updOut" | sed 's/^/R /' +} | awk -F'\t' ' +{ tag=substr($1,1,1); app=substr($1,3) } +tag=="L" { v[app]=$2; c[app]=$3 } +tag=="R" { from=v[app]; to=$2 + if (from=="" || to=="" || from==to) { from=substr(c[app],1,7); to=substr($3,1,7) } + print app"\t"from"\t"to }']] + local started = noctalia.runAsync(cmd, function(result) + if result.timedOut then + sources.flatpak = { n = 0, items = {} } + checkSize() + return + end + if result.exitCode ~= 0 then + noctalia.log("arch-updater: flatpak check failed (exit " .. tostring(result.exitCode) .. ")") + failCheck(tr("err_flatpak_failed")) + return + end + local n, items = parseTabLines(result.stdout, ignored) + sources.flatpak = { n = n, items = items } + checkSize() + end, CHECK_TIMEOUT_MS) + if not started then + sources.flatpak = { n = 0, items = {} } + checkSize() + end +end + +local function checkAur(ignored) + local helper = 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 = aurCheckCommand(helper) + if cmd == nil then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + return + end + step = helper == "custom" and tr("source.aur") or tr("source.aur_named", { helper = helper }) + publish() + local started = noctalia.runAsync(cmd, function(result) + if result.timedOut then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + return + end + -- "-Qua" (like plain pacman -Qu) exits non-zero for "nothing to + -- upgrade" too, so exit code alone can't tell that apart from a real + -- failure. A real failure prints something to stderr; "no updates" + -- doesn't. + if result.exitCode ~= 0 and trim(result.stderr or "") ~= "" then + noctalia.log("arch-updater: " .. helper .. " -Qua failed: " .. trim(result.stderr)) + failCheck(tr("err_aur_failed")) + return + end + local n, items = parseVersionLines(result.stdout, ignored) + sources.aur = { n = n, items = items, helper = helper == "custom" and "" or helper } + checkFlatpak(ignored) + end, CHECK_TIMEOUT_MS) + if not started then + sources.aur = { n = 0, items = {}, helper = "" } + checkFlatpak(ignored) + end +end + +startCheck = function() + if phase == "checking" then + return + end + if not noctalia.commandExists("checkupdates") then + phase = "missing" + errMsg = tr("err_no_checkupdates") + publish() + return + end + + phase = "checking" + errMsg = nil + sources = { pacman = { n = 0, items = {} }, aur = { n = 0, items = {}, helper = "" }, flatpak = { n = 0, items = {} } } + downloadSizeMiB = nil + step = tr("source.pacman") + publish() + + local ignored = ignoreSet() + -- checkupdates exits 2 for "no updates" (not an error), 1 for a real + -- failure (mirror/db sync, etc). Normalize the former to 0 so only a + -- genuine failure reaches result.exitCode below. + local cmd = [[checkupdates 2>/dev/null; code=$?; if [ "$code" -eq 2 ]; then exit 0; fi; exit "$code"]] + local started = noctalia.runAsync(cmd, function(result) + if result.timedOut then + failCheck(tr("err_check_timeout")) + return + end + if result.exitCode ~= 0 then + noctalia.log("arch-updater: checkupdates failed (exit " .. tostring(result.exitCode) .. ")") + failCheck(tr("err_check_failed")) + return + end + local n, items = parseVersionLines(result.stdout, ignored) + sources.pacman = { n = n, items = items } + checkAur(ignored) + end, CHECK_TIMEOUT_MS) + + if not started then + failCheck(tr("err_spawn")) + end +end + +finishCheck = function() + total = sources.pacman.n + sources.aur.n + sources.flatpak.n + step = "" + phase = total > 0 and "ready" or "clean" + dismissed = false + checkedAt = noctalia.formatTime("%H:%M") + sinceCheck = 0 + publish() + if total > 0 and cfg("notify_on_updates") == true then + noctalia.notify(tr("title"), noctalia.trp("notify_updates", total, { count = total })) + end +end + +-- ── Arch Linux news ────────────────────────────────────────────────────────── + +local function newsStatePath() + local dir, err = noctalia.pluginDataDir() + if dir == nil then + noctalia.log("arch-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 cfg("check_arch_news") ~= true then + return + end + loadNewsState() + local ok = noctalia.http({ url = 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) + publish() + end + end) + if not ok then + noctalia.log("arch-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 + noctalia.runAsync("xdg-open " .. shellQuote(NEWS_PAGE) .. " >/dev/null 2>&1") +end + +-- ── Updating ───────────────────────────────────────────────────────────────── + +-- 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 + +-- Builds the update command from the AUR helper, ignore list and Flatpak +-- settings. "update_cmd" overrides it outright, for cases the built default +-- doesn't cover. +local function buildUpdateCommand() + local override = trim(cfg("update_cmd")) + if override ~= "" then + updateProcessName = "pacman" + return override + end + + local ignored = ignoreList() + local ignoreFlag = #ignored > 0 and (" --ignore " .. table.concat(ignored, ",")) or "" + local yesFlag = cfg("assume_yes") == true and " --noconfirm" or "" + + local helper = resolveAurHelper() + local parts = {} + if helper ~= nil and helper ~= "custom" and noctalia.commandExists(helper) then + table.insert(parts, helper .. " -Syu" .. yesFlag .. ignoreFlag) + updateProcessName = helper + else + table.insert(parts, "sudo pacman -Syu" .. yesFlag .. ignoreFlag) + updateProcessName = "pacman" + end + + if cfg("flatpak_enabled") == true and noctalia.commandExists("flatpak") then + local flatpakYes = cfg("assume_yes") == true and " -y" or "" + if #ignored > 0 then + -- Same ignore list as pacman/AUR: filter ignored refs out of the + -- pending Flatpak list before updating, so an app hidden from the + -- panel's count can't still slip in through a bare `flatpak update`. + local skipList = shellQuote(table.concat(ignored, "\n")) + table.insert( + parts, + "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]] + .. flatpakYes + .. [[ $flatpak_refs; fi]] + ) + else + table.insert(parts, "flatpak update" .. flatpakYes) + end + end + + table.insert(parts, "echo; echo " .. shellQuote(tr("run.press_key")) .. "; read -n 1") + return table.concat(parts, "; ") +end + +local function runUpdate() + if phase == "running" or phase == "checking" then + return + end + if not noctalia.commandExists("checkupdates") then + phase = "missing" + errMsg = tr("err_no_checkupdates") + publish() + return + end + if not launchTerminal(buildUpdateCommand()) then + phase = "error" + errMsg = tr("err_no_terminal") + publish() + noctalia.notifyError(tr("title"), tr("err_no_terminal")) + return + end + phase = "running" + errMsg = nil + step = "" + runTicks = 0 + runPollTicks = 0 + runSeen = false + publish() +end + +-- Polls for the update process. Seeing it then losing it means the run +-- ended, and triggers a re-check. Never seeing it within the grace period +-- re-checks anyway instead of sitting in "running" forever. +local function pollRun() + runPollTicks += 1 + if runPollTicks < RUN_POLL_SECONDS then + return + end + runPollTicks = 0 + noctalia.processMatches(function(matched) + if phase ~= "running" then + return + end + if matched then + runSeen = true + return + end + if runSeen or runTicks >= RUN_GRACE_SECONDS then + startCheck() + end + end, updateProcessName) +end + +-- ── Requests, lifecycle ────────────────────────────────────────────────────── + +local function handle(action) + if action == "check" then + -- Also works as a manual unstick: if the process-poll heuristic in + -- pollRun() ever misses the update finishing, this forces a re-check + -- instead of leaving the UI stuck on "running". + startCheck() + elseif action == "update" then + runUpdate() + elseif action == "dismiss" then + if not dismissed then + dismissed = true + publish() + end + 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) +end) + +-- Scriptable control: +-- noctalia msg plugin yuuto/arch-updater:service all check +-- noctalia msg plugin yuuto/arch-updater:service all update +-- noctalia msg plugin yuuto/arch-updater:service all dismiss +function onIpc(event, _payload) + handle(event) +end + +function update() + if phase == "running" then + runTicks += 1 + pollRun() + elseif phase ~= "checking" then + local hours = tonumber(cfg("auto_check_hours")) or 0 + if hours > 0 then + if phase == "idle" then + startupTicks += 1 + if startupTicks >= AUTO_CHECK_DELAY then + startCheck() + end + else + sinceCheck += 1 + if sinceCheck >= hours * 3600 then + startCheck() + end + end + end + end + + if cfg("check_arch_news") == true then + sinceNewsCheck += 1 + if sinceNewsCheck >= NEWS_RECHECK_HOURS * 3600 or sinceNewsCheck == AUTO_CHECK_DELAY then + sinceNewsCheck = 0 + checkNews() + end + end +end + +noctalia.setUpdateInterval(1000) +if not noctalia.commandExists("checkupdates") then + phase = "missing" + errMsg = tr("err_no_checkupdates") +end +publish() diff --git a/arch-updater/thumbnail.webp b/arch-updater/thumbnail.webp new file mode 100644 index 0000000..1444c67 Binary files /dev/null and b/arch-updater/thumbnail.webp differ diff --git a/arch-updater/translations/de.json b/arch-updater/translations/de.json new file mode 100644 index 0000000..4376e21 --- /dev/null +++ b/arch-updater/translations/de.json @@ -0,0 +1,141 @@ +{ + "title": "Arch Updater", + "action_check": "Updates prüfen", + "action_dismiss": "Verwerfen", + "action_update": "Aktualisieren", + "action_open_news": "News öffnen", + "status_idle": "Noch nicht geprüft", + "status_checking": "Prüfe auf Updates…", + "status_checking_step": "Prüfe {step}…", + "status_running": "Update läuft in einem Terminal…", + "status_clean": "System ist aktuell", + "status_error": "Prüfung fehlgeschlagen", + "status_missing": "checkupdates fehlt, pacman-contrib installieren", + "status_ready": { + "one": "1 Paket zu aktualisieren", + "other": "{count} Pakete zu aktualisieren" + }, + "err_no_checkupdates": "checkupdates nicht gefunden, pacman-contrib installieren und PATH prüfen", + "err_check_timeout": "Zeitüberschreitung beim Prüfen auf Updates", + "err_spawn": "checkupdates konnte nicht gestartet werden", + "err_no_terminal": "Kein Terminal-Emulator gefunden, in den Plugin-Einstellungen festlegen", + "err_no_xdg_open": "xdg-open nicht gefunden, Paketseite kann nicht geöffnet werden", + "source": { + "pacman": "Pacman", + "aur": "AUR", + "aur_named": "AUR ({helper})", + "flatpak": "Flatpak" + }, + "notify_updates": { + "one": "1 Paket zu aktualisieren", + "other": "{count} Pakete zu aktualisieren" + }, + "run": { + "press_key": "Beliebige Taste zum Schließen drücken" + }, + "more_packages": "+{count} weitere", + "hover_hint": "Zeigt beim Überfahren eines Pakets die vollständigen Versionen", + "up_to_date": "Aktuell: {sources}", + "caption_checked": "geprüft {time}", + "caption_ignored": { + "one": "1 Paket ignoriert", + "other": "{count} Pakete ignoriert" + }, + "size_mib": "≈ {value} MiB Downloadgröße", + "size_gib": "≈ {value} GiB Downloadgröße", + "reboot_recommended": "Neustart empfohlen, der laufende Kernel ist nicht mehr installiert", + "news_unread": { + "one": "1 ungelesener Arch-News-Beitrag: „{title}“", + "other": "{count} ungelesene Arch-News-Beiträge, zuletzt: „{title}“" + }, + "tip_check": "Jetzt auf Updates prüfen", + "tip_close": "Schließen", + "tip_update": "Update in einem Terminal-Fenster ausführen", + "tip_copy": "Name und Versionen kopieren", + "tip_open_page": "Paketseite öffnen", + "tooltip_status": "Status", + "tooltip_pending": "Ausstehend", + "tooltip_checked": "Geprüft", + "tooltip_reboot_key": "Neustart", + "tooltip_reboot_value": "empfohlen", + "tooltip_news": "Arch News", + "tooltip_news_value": { + "one": "1 ungelesen", + "other": "{count} ungelesen" + }, + "tooltip_hints": "Klick: Panel · rechts: jetzt prüfen", + "launcher": { + "check_subtitle": "Pacman, AUR und Flatpak auf Updates prüfen", + "update_subtitle": "Terminal öffnen und Update ausführen", + "news_subtitle": "Arch-Linux-News-Seite öffnen" + }, + "settings": { + "aur_helper": { + "label": "AUR-Helfer", + "description": "Welcher AUR-Helfer AUR-Pakete prüft und aktualisiert. „Automatisch erkennen“ versucht zuerst yay, dann paru. „Eigener Befehl“ verwendet den unten angegebenen Prüfbefehl.", + "options": { + "auto": "Automatisch erkennen (yay, dann paru)", + "yay": "yay", + "paru": "paru", + "custom": "Eigener Befehl", + "off": "Aus, nur Pacman" + } + }, + "aur_check_cmd": { + "label": "Eigener AUR-Prüfbefehl", + "description": "Wird nur verwendet, wenn oben „Eigener Befehl“ gewählt ist. Muss pro Paket eine Zeile im Format 'name oldver -> newver' ausgeben, wie 'yay -Qua' es tut." + }, + "flatpak_enabled": { + "label": "Flatpak einbeziehen", + "description": "Prüft zusätzlich (und führt beim Aktualisieren) 'flatpak update' aus. Wird automatisch übersprungen, wenn Flatpak nicht installiert ist." + }, + "ignore_packages": { + "label": "Pakete ignorieren", + "description": "Paketnamen, die aus der Zählung ausgeschlossen und beim Update zusätzlich als --ignore übergeben werden, ergänzend zu pacman.confs eigenem IgnorePkg." + }, + "auto_check_hours": { + "label": "Intervall für automatische Prüfung (Stunden)", + "description": "Prüft automatisch alle N Stunden auf Updates. 0 (Standard) prüft nie von selbst." + }, + "notify_on_updates": { + "label": "Bei gefundenen Updates benachrichtigen", + "description": "Sendet eine Desktop-Benachrichtigung, wenn eine Prüfung aktualisierbare Pakete findet." + }, + "show_download_size": { + "label": "Downloadgröße anzeigen", + "description": "Schätzt die gesamte Downloadgröße der ausstehenden Pacman-Pakete mit 'pacman -Si'. AUR- und Flatpak-Größen sind nicht enthalten." + }, + "check_arch_news": { + "label": "Arch-Linux-News prüfen", + "description": "Ruft den Arch-Linux-News-Feed ab und markiert ungelesene Beiträge im Panel." + }, + "check_reboot_needed": { + "label": "Auf nötigen Neustart prüfen", + "description": "Prüft, ob die Dateien des aktuell laufenden Kernels noch vorhanden sind. Fehlen sie, wurde ein neuerer Kernel installiert, und erst ein Neustart wechselt darauf." + }, + "terminal": { + "label": "Terminal", + "description": "Terminal-Befehl für den Update-Lauf, z. B. kitty oder ghostty. Leer (Standard) verwendet Noctalias eigene Terminal-Erkennung ($TERMINAL, dann die gängigen Emulatoren)." + }, + "assume_yes": { + "label": "Automatisch mit Ja bestätigen", + "description": "Übergibt --noconfirm / -y, damit Paketmanager nicht nach Bestätigung fragen. Aus bedeutet, du bestätigst jedes Paket im Terminal-Fenster selbst." + }, + "update_cmd": { + "label": "Eigener Update-Befehl", + "description": "Vollständige Überschreibung des im Terminal ausgeführten Befehls beim Aktualisieren. Leer (Standard) baut ihn aus AUR-Helfer, Ignorierliste, Flatpak und „Automatisch mit Ja bestätigen“ oben zusammen." + }, + "glyph": { + "label": "Bar-Symbol", + "description": "Das für das Widget in der Bar angezeigte Symbol." + }, + "show_count": { + "label": "Anzahl der Updates anzeigen", + "description": "Zeigt die Anzahl ausstehender Updates neben dem Bar-Symbol." + }, + "hide_on_empty": { + "label": "Ausblenden, wenn nichts anliegt", + "description": "Blendet das Widget vollständig aus, wenn keine Updates ausstehen und kein Neustart empfohlen wird. Aus (Standard) zeigt das Symbol immer." + } + } +} diff --git a/arch-updater/translations/en.json b/arch-updater/translations/en.json new file mode 100644 index 0000000..29a592b --- /dev/null +++ b/arch-updater/translations/en.json @@ -0,0 +1,144 @@ +{ + "title": "Arch Updater", + "action_check": "Check Updates", + "action_dismiss": "Dismiss", + "action_update": "Update", + "action_open_news": "Open news", + "status_idle": "Not checked yet", + "status_checking": "Checking for updates…", + "status_checking_step": "Checking {step}…", + "status_running": "Update running in a terminal…", + "status_clean": "System is up to date", + "status_error": "Update check failed", + "status_missing": "checkupdates not found, install pacman-contrib", + "status_ready": { + "one": "1 package to upgrade", + "other": "{count} packages to upgrade" + }, + "err_no_checkupdates": "checkupdates not found, install pacman-contrib and check your PATH", + "err_check_timeout": "Timed out while checking for updates", + "err_check_failed": "checkupdates failed, see the system log for details", + "err_aur_failed": "AUR check failed, see the system log for details", + "err_flatpak_failed": "Flatpak check failed, see the system log for details", + "err_spawn": "Could not run checkupdates", + "err_no_terminal": "No terminal emulator found, set one in the plugin settings", + "err_no_xdg_open": "xdg-open not found, cannot open the package page", + "source": { + "pacman": "Pacman", + "aur": "AUR", + "aur_named": "AUR ({helper})", + "flatpak": "Flatpak" + }, + "notify_updates": { + "one": "1 package to upgrade", + "other": "{count} packages to upgrade" + }, + "run": { + "press_key": "Press any key to close" + }, + "more_packages": "+{count} more", + "hover_hint": "Hover a package for its full versions", + "up_to_date": "Up to date: {sources}", + "caption_checked": "checked {time}", + "caption_ignored": { + "one": "1 package ignored", + "other": "{count} packages ignored" + }, + "size_mib": "≈ {value} MiB to download", + "size_gib": "≈ {value} GiB to download", + "reboot_recommended": "Reboot recommended, the running kernel is no longer installed", + "news_unread": { + "one": "1 unread Arch news post: \"{title}\"", + "other": "{count} unread Arch news posts, latest: \"{title}\"" + }, + "tip_check": "Check for updates now", + "tip_close": "Close", + "tip_update": "Run the update in a terminal window", + "tip_copy": "Copy name and versions", + "tip_open_page": "Open package page", + "tooltip_status": "Status", + "tooltip_pending": "Pending", + "tooltip_checked": "Checked", + "tooltip_reboot_key": "Reboot", + "tooltip_reboot_value": "recommended", + "tooltip_news": "Arch news", + "tooltip_news_value": { + "one": "1 unread", + "other": "{count} unread" + }, + "tooltip_hints": "click: panel · right: check now", + "launcher": { + "check_subtitle": "Check pacman, AUR and Flatpak for updates", + "update_subtitle": "Open a terminal and run the update", + "news_subtitle": "Open the Arch Linux news page" + }, + "settings": { + "aur_helper": { + "label": "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.", + "options": { + "auto": "Auto-detect (yay, then paru)", + "yay": "yay", + "paru": "paru", + "custom": "Custom command", + "off": "Off, pacman only" + } + }, + "aur_check_cmd": { + "label": "Custom AUR check command", + "description": "Only used when the AUR helper above is 'Custom command'. Must print one 'name oldver -> newver' line per package, like 'yay -Qua' does." + }, + "flatpak_enabled": { + "label": "Include Flatpak", + "description": "Also check (and, on Update, run) 'flatpak update'. Ignored automatically when flatpak is not installed." + }, + "ignore_packages": { + "label": "Ignore packages", + "description": "Package names to leave out of the count and pass as --ignore to the update itself, in addition to pacman.conf's own IgnorePkg." + }, + "auto_check_hours": { + "label": "Auto-check interval (hours)", + "description": "Check for updates automatically every N hours. 0 (default) never checks on its own." + }, + "notify_on_updates": { + "label": "Notify when updates are found", + "description": "Send a desktop notification when a check finds packages to upgrade." + }, + "show_download_size": { + "label": "Show download size", + "description": "Estimate the total download size for the pending pacman packages with 'pacman -Si'. AUR and Flatpak sizes are not included." + }, + "check_arch_news": { + "label": "Check Arch Linux news", + "description": "Fetch the Arch Linux news feed and flag unread posts in the panel." + }, + "check_reboot_needed": { + "label": "Check if a reboot is 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." + }, + "terminal": { + "label": "Terminal", + "description": "Terminal command used for the update run, e.g. kitty or ghostty. Empty (default) uses Noctalia's terminal detection ($TERMINAL, then the common emulators)." + }, + "assume_yes": { + "label": "Answer yes automatically", + "description": "Pass --noconfirm / -y so package managers do not ask for confirmation. Off means you confirm each one in the terminal window." + }, + "update_cmd": { + "label": "Custom update command", + "description": "Full override for the command run in the terminal on Update. Empty (default) builds it from the AUR helper, ignore list, Flatpak and 'Answer yes' settings above." + }, + "glyph": { + "label": "Bar glyph", + "description": "The glyph shown for the widget on the bar." + }, + "show_count": { + "label": "Show the update count", + "description": "Show the number of pending updates next to the bar glyph." + }, + "hide_on_empty": { + "label": "Hide when there is nothing to show", + "description": "Hide the widget entirely when there are no pending updates and no reboot recommendation. Off (default) always shows the glyph." + } + } +} diff --git a/arch-updater/widget.luau b/arch-updater/widget.luau new file mode 100644 index 0000000..a1a0302 --- /dev/null +++ b/arch-updater/widget.luau @@ -0,0 +1,153 @@ +--!nonstrict +-- arch-updater bar widget: pending-update badge and panel toggle. +-- +-- Pure renderer over the "arch_state" the engine (service.luau) publishes. +-- Every bar showing the widget agrees on the count without running any +-- command itself. Actions go back as "arch_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 = "yuuto/arch-updater:panel" +local REQUEST_KEY = "arch_request" +local STATE_KEY = "arch_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.dismissed ~= true + 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 + table.insert(parts, tr("source.pacman") .. " " .. 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 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 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()