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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
["<"] = "<",
|
||||
[">"] = ">",
|
||||
["""] = '"',
|
||||
["'"] = "'",
|
||||
["'"] = "'",
|
||||
["&"] = "&",
|
||||
}
|
||||
|
||||
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 |
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user