--!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: " 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 -- `nameinstalledavailable`. 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", "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 - ―― -- followed by its "Dry running: " 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(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(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: ` -e sh -lc `, 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()