Files
community-plugins/arch-updater/service.luau
T

754 lines
25 KiB
Luau

--!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 newsDirty = 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)
newsDirty = true
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
if newsDirty then
newsDirty = false
publish()
end
end
noctalia.setUpdateInterval(1000)
if not noctalia.commandExists("checkupdates") then
phase = "missing"
errMsg = tr("err_no_checkupdates")
end
publish()