feat: add arch-updater plugin (#121)

* feat: add arch-updater plugin

* fix(arch-updater): declare shelled-out commands, close Flatpak ignore gap, surface real check failures

Address PR review: plugin.toml only listed pacman-contrib despite invoking
awk, sed, test, uname, pacman, sh, sudo, flatpak, xdg-open, yay and paru;
the Flatpak ignore list was enforced in the panel but not in the actual
`flatpak update`; and checkupdates/AUR/Flatpak checks piped through
awk/sed in a way that swallowed real command failures as an empty/clean
result instead of an error.

* fix(arch-updater): fix Luau long-string that broke service.luau parsing

The Flatpak ignore-filter awk snippet used a [[ ]] long bracket string,
but its own content ("skip[arr[i]] = 1") contains a literal ]] that
closed the string early, corrupting everything after it and failing
the whole file's parse (not just the Flatpak path). Switched to a
[=[ ]=] long bracket, which isn't terminated by a bare ]].
This commit is contained in:
Yuuto
2026-07-27 23:23:15 -04:00
committed by GitHub
parent d9d2217841
commit 3878d83c33
9 changed files with 2015 additions and 0 deletions
+140
View File
@@ -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 <text>` 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/<version>` 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.
+103
View File
@@ -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
+430
View File
@@ -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)
+157
View File
@@ -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
+747
View File
@@ -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/<version> 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 = {
["&lt;"] = "<",
["&gt;"] = ">",
["&quot;"] = '"',
["&#39;"] = "'",
["&apos;"] = "'",
["&amp;"] = "&",
}
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("<item>(.-)</item>") do
local title = block:match("<title>(.-)</title>")
local link = block:match("<link>(.-)</link>")
local guid = block:match("<guid[^>]*>(.-)</guid>")
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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+141
View File
@@ -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."
}
}
}
+144
View File
@@ -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."
}
}
}
+153
View File
@@ -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()