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,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()
|
||||
Reference in New Issue
Block a user