Files
community-plugins/topgrade-wrapper/service.luau
T

702 lines
25 KiB
Luau

--!nonstrict
-- topgrade-wrapper — singleton engine: counts pending updates and starts runs.
--
-- Runs once regardless of how many bars show the widget. The widget and the
-- panel are pure renderers wired through the plugin's shared state:
-- engine publishes "topgrade_state" = { nonce, phase, step, total, counts,
-- uncounted, dismissed, err,
-- checkedAt, configPath, excluded }
-- UI entries send "topgrade_request" = { nonce, action } -- check|update|dismiss
--
-- Counting is two-staged, because topgrade has no "how many packages?" mode of
-- its own. First `topgrade --dry-run` reports the steps it would actually run,
-- so the user's own topgrade.toml (plus this plugin's --disable overrides)
-- decides what is counted; every "Dry running: <cmd>" line it prints is then
-- matched against the COUNTERS table to learn which package managers are in
-- play. Each matched manager gets one read-only query listing what it has
-- pending, run one at a time so a slow mirror never stalls the others'
-- timeouts. Steps with no counter are reported by name instead of being folded
-- into the total: a wrong number is worse than an honest "not counted".
--
-- The queries list packages rather than count them, so a `counts` entry carries
-- both `n` and the `items` behind it ({ name, from, to }) and the panel can
-- expand a row into them without asking a mirror twice.
--
-- Upgrades never run in the background. runUpdate() opens a terminal window so
-- package managers can prompt and sudo can ask for the password on the tty.
-- While one runs, processMatches() polls for the topgrade process and re-counts
-- as soon as it is gone, so the bar clears itself.
local STATE_KEY = "topgrade_state"
local REQUEST_KEY = "topgrade_request"
local DRY_TIMEOUT_MS = 45000 -- topgrade --dry-run: PATH probes, no network
local COUNT_TIMEOUT_MS = 60000 -- per manager; several of these hit the network
local RUN_POLL_SECONDS = 3 -- how often a running topgrade is polled for
local RUN_GRACE_SECONDS = 20 -- how long to wait for the process to show up
local AUTO_CHECK_DELAY = 10 -- ticks before the startup check, when enabled
-- Packages per manager kept for the panel's expandable list. Every entry is
-- republished on each state update, so this bounds a pathological case (hundreds
-- of outdated site-packages) without touching the reported count.
local MAX_LISTED = 200
-- One entry per package manager we can ask what it has pending.
-- signals — Lua patterns matched against the "Dry running:" commands of a
-- step; any match means this manager is part of the run.
-- requires — binary that must exist for the query to work.
-- cmd — read-only query printing ONE PACKAGE PER LINE as
-- `name<TAB>installed<TAB>available`. Either version may be empty
-- when the tool does not report it.
--
-- The count is simply how many lines came back, so the number on the bar and the
-- list the panel expands are the same answer to the same question, asked once:
-- no second round trip to a mirror, and no way for the two to disagree.
--
-- Homebrew and npm give names only: `brew outdated --quiet` and
-- `npm -g outdated --parseable` do not carry a usable pair.
--
-- `pip` deliberately matches only the pip-review / pipupgrade steps: topgrade's
-- own `pip3` step upgrades pip itself, and counting every outdated site-package
-- against it would badly overstate the run.
local COUNTERS = {
{
key = "pacman",
requires = "checkupdates",
-- An AUR helper counts too: it upgrades the repositories as well as the
-- AUR, and it does not always name pacman on its command line (yay is
-- invoked as `yay --pacman pacman -Syu`, paru plainly as `paru -Syu`).
-- Matching only "pacman" would leave repository updates uncounted on a
-- paru system, so every helper that carries -Syu fires this counter and
-- the AUR-only counters below add their own share on top.
signals = { "pacman[^\n]*%-Sy+u", "yay[^\n]*%-Sy+u", "paru[^\n]*%-Sy+u" },
cmd = [[checkupdates 2>/dev/null | awk '{print $1"\t"$2"\t"$4}']],
},
{
key = "aur_yay",
requires = "yay",
signals = { "yay[^\n]*%-Sy+u" },
cmd = [[yay -Qua 2>/dev/null | awk '{print $1"\t"$2"\t"$4}']],
},
{
key = "aur_paru",
requires = "paru",
signals = { "paru[^\n]*%-Sy+u" },
cmd = [[paru -Qua 2>/dev/null | awk '{print $1"\t"$2"\t"$4}']],
},
{
key = "apt",
requires = "apt-get",
signals = { "apt%-get[^\n]*upgrade", "apt[^\n]*full%-upgrade", "apt[^\n]*dist%-upgrade", "nala[^\n]*upgrade" },
cmd = [[apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | awk '/^Inst /{gsub(/[][]/,"",$3); gsub(/[()]/,"",$4); print $2"\t"$3"\t"$4}']],
},
{
key = "dnf",
requires = "dnf",
signals = { "dnf[^\n]*upgrade" },
cmd = [[dnf -q check-update 2>/dev/null | awk 'NF==3{print $1"\t\t"$2}']],
},
{
key = "zypper",
requires = "zypper",
signals = { "zypper" },
cmd = [[zypper --quiet --non-interactive list-updates 2>/dev/null | awk -F'|' '/^v/{gsub(/^ +| +$/,"",$3); gsub(/^ +| +$/,"",$4); gsub(/^ +| +$/,"",$5); print $3"\t"$4"\t"$5}']],
},
{
key = "flatpak",
requires = "flatpak",
signals = { "flatpak[^\n]*update" },
-- Two queries joined in one pass: `list` for what is installed (whose
-- commit column is `active`, not `commit`) and `remote-ls --updates` for
-- what is pending. Flatpak tracks commits, so an app's version string
-- often does not move across an update — verified: floorp goes 12.16.3 →
-- 12.16.3 — and an arrow between two equal versions would claim a change
-- the numbers deny. So the version pair is used when it really differs
-- and short commits stand in when it does not. The tag is a letter and a
-- space rather than a tab: POSIX sed has no \t in a replacement.
cmd = [[
{ flatpak list --columns=application,version,active 2>/dev/null | sed 's/^/L /'
flatpak remote-ls --updates --columns=application,version,commit 2>/dev/null | 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 }'
]],
},
{
key = "snap",
requires = "snap",
signals = { "snap[^\n]*refresh" },
cmd = [[snap refresh --list 2>/dev/null | tail -n +2 | awk '{print $1"\t\t"$2}']],
},
{
key = "brew",
requires = "brew",
signals = { "brew[^\n]*upgrade" },
cmd = "brew outdated --quiet 2>/dev/null",
},
{
key = "cargo",
-- The cargo-update subcommand, not cargo itself: without it the query
-- would answer 0 and read as "up to date".
requires = "cargo-install-update",
signals = { "install%-update" },
cmd = [[cargo install-update --list 2>/dev/null | awk '$NF=="Yes"{print $1"\t"$2"\t"$3}']],
},
{
key = "npm",
requires = "npm",
signals = { "npm[^\n]*update", "npm[^\n]*upgrade" },
cmd = "npm -g outdated --parseable 2>/dev/null | awk -F: '{print $2}'",
},
{
key = "gem",
requires = "gem",
signals = { "gem[^\n]*update" },
cmd = [[gem outdated 2>/dev/null | awk '{gsub(/[()]/,"",$2); gsub(/[()]/,"",$4); print $1"\t"$2"\t"$4}']],
},
{
key = "pip",
requires = "pip",
signals = { "pip%-review", "pipupgrade" },
-- --format=freeze is rejected outright with --outdated; the default
-- table carries the installed and latest versions side by side, past a
-- two-line header.
cmd = [[pip list --outdated 2>/dev/null | awk 'NR>2{print $1"\t"$2"\t"$3}']],
},
}
-- Where topgrade looks for its configuration, in its own order of preference.
-- Detected for display only: with no override the plugin passes no --config at
-- all and lets topgrade resolve the file itself.
local CONFIG_CANDIDATES = {
"$XDG_CONFIG_HOME/topgrade.toml",
"$XDG_CONFIG_HOME/topgrade/topgrade.toml",
"~/.config/topgrade.toml",
"~/.config/topgrade/topgrade.toml",
}
local phase = "idle" -- idle|checking|clean|ready|running|error|missing
local step = "" -- manager being counted (phase == "checking")
local counts = {} -- array of { key, n, items }, every manager actually queried
local uncounted = {} -- step names topgrade would run that we cannot count
local total = 0
local dismissed = false -- "Dismiss" pressed; counts kept, bar goes quiet
local errMsg = nil
local checkedAt = ""
local stateNonce = 0
local lastRequestNonce = 0
local queue = {} -- counters still to run this check
local planTotal = 0 -- counters this check started with, for the progress bar
local runTicks = 0 -- seconds since the terminal was launched
local runSeen = false -- the topgrade process was observed at least once
local runPollTicks = 0
local sinceCheck = 0 -- seconds since the last completed check
local startupTicks = 0
local commandSig = nil -- signature of the settings that shape the command line
local startCheck
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
-- Which exclusions apply, per the exclude_mode setting: either topgrade's own
-- configuration alone ("config" — the plugin adds nothing to the command line),
-- or that plus the plugin's own list ("override").
--
-- Step ids reach the command line, so accept only topgrade's own id shape
-- (lowercase, digits, underscore). Anything else is dropped with a log line
-- rather than quoted, so a typo can never smuggle in shell syntax.
local function excludedSteps()
if cfg("exclude_mode") ~= "override" then
return {}
end
local raw = cfg("exclude_steps")
if type(raw) ~= "table" then
return {}
end
local steps = {}
for _, entry in ipairs(raw) do
local id = trim(tostring(entry)):lower()
if id:match("^[a-z0-9_]+$") ~= nil then
table.insert(steps, id)
elseif id ~= "" then
noctalia.log("topgrade-wrapper: ignoring invalid step id '" .. id .. "'")
end
end
return steps
end
local function configOverride()
local path = trim(cfg("topgrade_config"))
if path == "" then
return nil
end
return noctalia.expandPath(path)
end
-- The topgrade config actually in force: the override when set, else the first
-- candidate that exists. nil when topgrade is running on its defaults.
local function detectedConfig()
local override = configOverride()
if override ~= nil then
return override
end
local xdg = noctalia.getenv("XDG_CONFIG_HOME")
for _, candidate in ipairs(CONFIG_CANDIDATES) do
local path = candidate
if path:find("$XDG_CONFIG_HOME", 1, true) ~= nil then
if xdg == nil or xdg == "" then
continue
end
path = path:gsub("%$XDG_CONFIG_HOME", (xdg:gsub("%%", "%%%%")))
end
path = noctalia.expandPath(path)
if noctalia.fileExists(path) then
return path
end
end
return nil
end
-- Settings that change what topgrade would do; a check result is stale once
-- any of them moves, so onConfigChanged can tell a real change from a cosmetic
-- one (a glyph edit must not throw away a fresh count).
local function commandSignature()
return tostring(configOverride()) .. "\0" .. table.concat(excludedSteps(), ",")
end
local function buildCommand(dry)
local parts = { "topgrade" }
local override = configOverride()
if override ~= nil then
table.insert(parts, "--config " .. shellQuote(override))
end
-- One flag per step: repeating --disable keeps clap from swallowing the
-- flags that follow, which a space-separated list would.
for _, id in ipairs(excludedSteps()) do
table.insert(parts, "--disable " .. id)
end
if dry then
-- --no-self-update keeps the count from doing a release check it would
-- never act on in dry mode.
table.insert(parts, "--dry-run --no-self-update")
else
if cfg("assume_yes") == true then
table.insert(parts, "--yes")
end
if cfg("sudo_loop") == true then
table.insert(parts, "--sudoloop")
end
if cfg("keep_terminal_open") == true then
table.insert(parts, "--keep")
end
end
return table.concat(parts, " ")
end
local function publish()
stateNonce += 1
local config = detectedConfig()
noctalia.state.set(STATE_KEY, {
nonce = stateNonce,
phase = phase,
step = step,
progress = planTotal > 0 and (planTotal - #queue) / planTotal or 0,
total = total,
counts = counts,
uncounted = uncounted,
dismissed = dismissed,
err = errMsg,
checkedAt = checkedAt,
configPath = config,
excluded = #excludedSteps(),
})
end
-- ── Counting ────────────────────────────────────────────────────────────────
-- `topgrade --dry-run` prints one header per step it would run,
-- ―― HH:MM:SS - <Step name> ――
-- followed by its "Dry running: <cmd>" lines (a step can have none). The
-- trailing "Summary" header repeats the names with their outcome, so parsing
-- stops there. The header is matched loosely — timestamp, " - ", name, bar —
-- so a cosmetic change to topgrade's rule characters cannot break the parse.
local function parseDryRun(stdout)
local steps = {}
local current = nil
for line in stdout:gmatch("[^\n]+") do
local cmd = line:match("^Dry running:%s*(.+)$")
if cmd ~= nil then
if current == nil then
current = { name = tr("step_unnamed"), cmds = {} }
table.insert(steps, current)
end
table.insert(current.cmds, cmd)
else
local name = line:match("%d%d:%d%d:%d%d%s*%-%s*(.-)%s*\u{2015}")
if name ~= nil then
if name == "Summary" then
break
end
current = { name = name, cmds = {} }
table.insert(steps, current)
end
end
end
return steps
end
local function signalsMatch(counter, blob)
for _, pattern in ipairs(counter.signals) do
if blob:find(pattern) ~= nil then
return true
end
end
return false
end
-- Turns parsed steps into the ordered list of counters to run, plus everything
-- that will be missing from the total. A manager shared by several steps —
-- Flatpak's user and system steps, say — is queried once.
--
-- What gets reported as not counted depends on how much of a step is covered:
-- * nothing answered → the step's own name, which means more to the reader
-- than the name of a tool they do not have;
-- * partly answered → the managers that could not answer. Without this an
-- Arch box with an AUR helper but no pacman-contrib
-- would show the AUR total as if it were the whole
-- system update, with nothing to hint at the gap.
local function planCounters(steps)
local plan = {}
local seen = {} -- counter keys already queued
local absent = {} -- counter keys already reported as unavailable
local skipped = {}
for _, entry in ipairs(steps) do
local blob = table.concat(entry.cmds, "\n")
local matched = false
local unavailable = {}
for _, counter in ipairs(COUNTERS) do
if signalsMatch(counter, blob) then
if seen[counter.key] then
matched = true
elseif noctalia.commandExists(counter.requires) then
seen[counter.key] = true
table.insert(plan, counter)
matched = true
else
table.insert(unavailable, counter)
end
end
end
if not matched then
table.insert(skipped, entry.name)
else
for _, counter in ipairs(unavailable) do
if not absent[counter.key] then
absent[counter.key] = true
table.insert(skipped, tr("count." .. counter.key))
end
end
end
end
return plan, skipped
end
local function finishCheck()
total = 0
for _, entry in ipairs(counts) do
total += entry.n
end
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
local function failCheck(message)
queue = {}
step = ""
phase = "error"
errMsg = message
publish()
end
local pumpQueue
pumpQueue = function()
if #queue == 0 then
finishCheck()
return
end
local counter = table.remove(queue, 1)
step = tr("count." .. counter.key)
publish()
local started = noctalia.runAsync("export LC_ALL=C; " .. counter.cmd, function(result)
-- A query that timed out or reported an error is recorded as unknown
-- rather than zero: saying "up to date" because a mirror was down would
-- be a lie. Note that most of these commands end in a filter, so their
-- exit status is the filter's — this catches the queries that speak for
-- themselves (flatpak, brew), not every possible failure.
if result.timedOut or result.exitCode ~= 0 then
table.insert(uncounted, tr("count." .. counter.key))
pumpQueue()
return
end
-- One package per line, tab-separated. The full count is kept even when
-- the stored list is capped, so the total never quietly shrinks to what
-- the panel can show.
local items = {}
local n = 0
for line in (result.stdout or ""):gmatch("[^\n]+") do
-- Trailing empty fields matter (a tool that reports no versions
-- yields "name\t\t"), so append a separator and take every field.
local fields = {}
for field in (line .. "\t"):gmatch("([^\t]*)\t") do
table.insert(fields, trim(field))
end
local name = fields[1] or ""
if 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
table.insert(counts, { key = counter.key, n = n, items = items })
pumpQueue()
end, COUNT_TIMEOUT_MS)
if not started then
table.insert(uncounted, tr("count." .. counter.key))
pumpQueue()
end
end
startCheck = function()
if phase == "checking" then
return
end
if not noctalia.commandExists("topgrade") then
phase = "missing"
errMsg = tr("err_no_topgrade")
publish()
return
end
phase = "checking"
errMsg = nil
counts = {}
uncounted = {}
queue = {}
planTotal = 0
total = 0
step = tr("step_planning")
publish()
local started = noctalia.runAsync("export LC_ALL=C; " .. buildCommand(true), function(result)
if result.timedOut then
failCheck(tr("err_dry_timeout"))
return
end
-- A bad --disable id is the likely cause here, and topgrade names it on
-- stderr; surfacing its first line beats a generic failure.
if result.exitCode ~= 0 then
local detail = trim((result.stderr or ""):match("[^\n]+") or "")
failCheck(detail ~= "" and detail or tr("err_dry_failed"))
return
end
local plan, skipped = planCounters(parseDryRun(result.stdout or ""))
uncounted = skipped
queue = plan
planTotal = #plan
if #queue == 0 then
finishCheck()
return
end
pumpQueue()
end, DRY_TIMEOUT_MS)
if not started then
failCheck(tr("err_spawn"))
end
end
-- ── Running ─────────────────────────────────────────────────────────────────
-- Noctalia's own terminal discovery ($TERMINAL, then the usual emulators) is
-- used unless a terminal is configured. A configured one is wired up the same
-- way the host does it: `<term> -e sh -lc <cmd>`, with the separator the few
-- GTK terminals need instead of -e.
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
local function runUpdate()
if phase == "running" or phase == "checking" then
return
end
if not noctalia.commandExists("topgrade") then
phase = "missing"
errMsg = tr("err_no_topgrade")
publish()
return
end
if not launchTerminal(buildCommand(false)) 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 topgrade process while a run is in flight. Seeing it and then
-- losing it means the run ended, which is the cue to re-count. Never seeing it
-- within the grace period means the terminal died on startup (or the run was
-- over instantly), so re-count anyway rather than sit 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, "topgrade")
end
-- ── Requests, lifecycle ─────────────────────────────────────────────────────
local function handle(action)
if action == "check" then
-- Counting halfway through an upgrade would publish a number that is
-- already wrong; the run's own completion poll re-checks anyway.
if phase ~= "running" then
startCheck()
end
elseif action == "update" then
runUpdate()
elseif action == "dismiss" then
if not dismissed then
dismissed = true
publish()
end
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 nightwatch75/topgrade-wrapper:service all check
-- noctalia msg plugin nightwatch75/topgrade-wrapper:service all update
function onIpc(event, _payload)
handle(event)
end
function onConfigChanged()
local sig = commandSignature()
if sig ~= commandSig then
commandSig = sig
-- The count described a different topgrade invocation; drop it instead
-- of showing a number the new settings would not produce.
if phase == "ready" or phase == "clean" or phase == "error" then
phase = "idle"
counts = {}
uncounted = {}
total = 0
checkedAt = ""
errMsg = nil
end
end
publish()
end
function update()
if phase == "running" then
runTicks += 1
pollRun()
return
end
if phase == "checking" then
return
end
local hours = tonumber(cfg("auto_check_hours")) or 0
if hours <= 0 then
return
end
if phase == "idle" then
-- Staggered so enabling the setting does not fire a check into a
-- still-starting session.
startupTicks += 1
if startupTicks >= AUTO_CHECK_DELAY then
startCheck()
end
return
end
sinceCheck += 1
if sinceCheck >= hours * 3600 then
startCheck()
end
end
noctalia.setUpdateInterval(1000)
commandSig = commandSignature()
if not noctalia.commandExists("topgrade") then
phase = "missing"
errMsg = tr("err_no_topgrade")
end
publish()