From f6983da41aeffeb5908001b835be4011821e5913 Mon Sep 17 00:00:00 2001 From: nightwatch75 <30896720+nightwatch75@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:15:03 +0200 Subject: [PATCH] Add nightwatch75/topgrade-wrapper 0.0.2 (#114) A bar plugin for topgrade: the glyph carries the number of packages waiting, and the panel counts them and starts the upgrade in a terminal window. topgrade has no "how many packages?" mode, so counting is two-staged. `topgrade --dry-run --no-self-update` reports the steps topgrade would actually run, which is how the user's own topgrade.toml decides what gets counted; each "Dry running:" command is then matched against a table of 13 package managers, and every match is asked once, read-only, to list what it has pending. The count is the length of that list, so the bar number and the list a panel row expands into are the same answer and expanding costs no second trip to a mirror. Steps nothing can count are named rather than folded into the total, and a partly covered step names the manager that could not answer, so an Arch box with an AUR helper but no pacman-contrib never reads its AUR total as the whole system update. The upgrade never runs in the background: it opens a terminal so package managers can prompt and sudo can ask on the tty. The plugin writes no files and never rewrites topgrade's configuration. Co-authored-by: nightwatch75 --- topgrade-wrapper/README.md | 228 ++++++++ topgrade-wrapper/panel.luau | 433 +++++++++++++++ topgrade-wrapper/plugin.toml | 142 +++++ topgrade-wrapper/service.luau | 701 +++++++++++++++++++++++++ topgrade-wrapper/thumbnail.webp | Bin 0 -> 63856 bytes topgrade-wrapper/topgrade-wrapper.luau | 143 +++++ topgrade-wrapper/translations/en.json | 115 ++++ 7 files changed, 1762 insertions(+) create mode 100644 topgrade-wrapper/README.md create mode 100644 topgrade-wrapper/panel.luau create mode 100644 topgrade-wrapper/plugin.toml create mode 100644 topgrade-wrapper/service.luau create mode 100644 topgrade-wrapper/thumbnail.webp create mode 100644 topgrade-wrapper/topgrade-wrapper.luau create mode 100644 topgrade-wrapper/translations/en.json diff --git a/topgrade-wrapper/README.md b/topgrade-wrapper/README.md new file mode 100644 index 0000000..e1d87ab --- /dev/null +++ b/topgrade-wrapper/README.md @@ -0,0 +1,228 @@ +# Topgrade Wrapper + +A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin that drives +[topgrade](https://github.com/topgrade-rs/topgrade), the "upgrade everything" +tool. The bar glyph shows how many packages are waiting, and the panel checks +for updates and starts the run in a terminal window — so you get the pending +count at a glance without giving up the interactive upgrade. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `nightwatch75/topgrade-wrapper` | +| Entries | Bar widget: `topgrade-wrapper`; panel: `panel`; service: `service` | + +## Requirements + +Noctalia with plugin API 9 or newer (the panel wires its callbacks as closures), +and `topgrade` on `PATH`. A terminal emulator is needed for the update run: +the plugin uses 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 else is optional and affects only the count, never the upgrade. + +### Count coverage + +A manager is counted when the tool that can answer "how many updates?" is +installed. Nothing here is required: an absent tool costs you a number, not a +feature. + +| Counted | Needs | +| --- | --- | +| Arch repositories | `checkupdates` (from `pacman-contrib`) | +| AUR | `yay` or `paru` | +| Debian/Ubuntu | `apt-get` | +| Fedora/RHEL | `dnf` | +| openSUSE | `zypper` | +| Flatpak, Snap, Homebrew | `flatpak`, `snap`, `brew` | +| Cargo, npm, RubyGems, pip | `cargo-install-update` (from `cargo-update`), `npm`, `gem`, `pip` | + +Anything topgrade would run but this list does not cover — Void's `xbps`, +Gentoo's `emerge`, Alpine's `apk`, Nix, VS Code extensions, container images, +and so on — is named in the panel under *Not counted*, and left out of the +total. A step that is only partly covered names the manager that could not +answer instead: on Arch with an AUR helper but no `pacman-contrib`, the panel +counts the AUR and lists **Pacman** as not counted, so the total is never +mistaken for the whole system update. + +## Usage + +Add the `topgrade-wrapper` widget from Noctalia's widget picker, then click it to +open the panel. You can also open the panel directly or bind it in your +compositor: + +```sh +noctalia msg panel-toggle nightwatch75/topgrade-wrapper:panel +``` + +| Action | Effect | +|-----------------------------------|-------------------------------------------------------------| +| Left click (bar glyph) | Open/close the panel | +| Right click (bar glyph) | Check for updates now | +| **Check Updates** (panel) | Count what topgrade would upgrade | +| Click a manager row (panel) | Expand or collapse the packages behind its number | +| Hover a package (panel) | Show its full `installed → available` versions below the list | +| **Update** (panel) | Run topgrade in a terminal window | +| **Dismiss** (panel) | Keep the numbers but return the bar glyph to its resting colour | +| ↻ refresh (panel header) | Same as **Check Updates** | + +The glyph turns to the accent colour with the pending count next to it once a +check finds something, stays neutral while everything is up to date or after +**Dismiss**, and turns red when `topgrade` is missing or a check failed. Its +tooltip carries the status, the per-manager breakdown, and the time of the last +check. Middle click is not used: every bar widget carries a built-in binding for +it that opens the widget's own settings. + +### Checking + +topgrade has no "how many packages?" mode, so the check runs in two stages: + +1. `topgrade --dry-run --no-self-update` reports the steps topgrade *would* run. + Your own topgrade configuration decides that list, which is exactly what the + count needs to reflect, and the **Excluded steps** setting is layered on top + of it. +2. Every package manager named in that output is asked once, with a read-only + query, to *list* what it has pending — `checkupdates`, `flatpak remote-ls + --updates`, and so on. The queries run one at a time. + +The panel then shows one row per manager that has updates, the total in the +headline, and two captions: *Up to date* for the managers that answered zero, +and *Not counted* for the steps that ran but that no query covers (VS Code +extensions or container images, say). A manager whose query times out or reports +an error is moved to *Not counted* rather than shown as zero; a query that simply +comes back empty is taken at its word. + +**Click a manager row** to expand it into the packages behind its number, and +click again to fold it. The number *is* the length of that list — the queries list +rather than count, so the two can never disagree and expanding a row costs no +second trip to a mirror. + +Each package shows its name and, where the manager reports them, `installed → +available` with the incoming version in the accent colour. Arch git-snapshot +versions run long, so the pair is elided to fit the row; **hover a package** and +the line under the list spells it out in full. (Noctalia's plugin UI has no +tooltip for a plain row — only buttons take one — so the detail line is where the +untruncated text goes. It stays visible while any list is open, hovered or not, +because a line that appeared on hover would resize the list under your pointer.) + +Flatpak is a special case: it tracks commits, so an app's version string often +does not move across an update. When it does, the pair is shown as usual; when it +does not, the short commits stand in (`187a4c5 → 7a8c453`) rather than an arrow +between two identical numbers. Homebrew and npm report names only. + +Turn **Show package versions** off to get plain name-only rows. The hover line +stays exactly as it is with them on, so the versions remain one hover away — the +setting decides how much each row carries at rest, not whether the detail is +available. + +Very long lists are trimmed for display, with a `+N more` line so the rows never +quietly contradict the count. A re-check folds every row back. + +Checks only happen when you ask for one, unless you set an **Auto-check +interval**. + +### Updating + +**Update** opens a terminal window running `topgrade`. Nothing is upgraded in the +background: package managers keep their prompts, and `sudo` asks for your +password on the terminal's tty. The window closes when the run ends unless you +enable **Keep the terminal open**. + +While the run is in flight the panel says so, and the plugin watches for the +`topgrade` process; as soon as it is gone the counts are refreshed automatically, +so the bar clears itself without another click. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `topgrade_config` | `file` | *(empty)* | Alternative topgrade configuration, passed as `--config`. Empty lets topgrade resolve its own file. | +| `exclude_mode` | `select` | `config` | Where skipped steps come from: `topgrade configuration` (its `disable` list alone) or `Override with the list below`. | +| `exclude_steps` | `string_list` | *(empty)* | topgrade step ids to skip, passed as `--disable ` (e.g. `flatpak`, `cargo`, `containers`). Only shown, and only applied, in override mode. Run `topgrade --help` for the full list. | +| `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_versions` | `bool` | `true` | Show `installed → available` beside each package in an expanded row. Off lists names only; hovering still shows the full pair under the list either way. | +| `terminal` | `string` | *(empty)* | Terminal command for the update run, e.g. `kitty`. Empty uses Noctalia's detection. | +| `assume_yes` | `bool` | `false` | Pass `--yes` so package managers do not ask for confirmation. | +| `sudo_loop` | `bool` | `false` | Pass `--sudoloop`, so the password is asked once and the sudo timestamp is refreshed for the whole run. | +| `keep_terminal_open` | `bool` | `false` | Pass `--keep` so the window waits for a key press instead of closing. | +| `glyph` | `glyph` | `package` | The glyph shown for the widget on the bar. | +| `show_count` | `bool` | `true` | Show the pending-update count next to the glyph. | + +### Excluding steps + +By default the plugin adds nothing of its own: what topgrade skips is whatever +the `disable` list in your `topgrade.toml` says, and the count follows. Switch +**Excluded steps source** to *Override with the list below* to reveal the +**Excluded steps** list and have its ids passed as `--disable ` on every +command line, check and run alike. Your configuration file is never rewritten — +and because `--disable` only ever adds, this layers on top of the config's own +exclusions rather than replacing them; it cannot re-enable a step your +`topgrade.toml` disables. + +Switching the mode, or editing the list, invalidates the last count: it +described a different invocation. + +Ids are validated before they reach the command line, and only `[a-z0-9_]` is +accepted; anything else is dropped with a line in the Noctalia log. An id +topgrade does not know makes the check fail with topgrade's own message +("invalid value … for `--disable`") in the panel, which tells you what to fix. + +## IPC + +The service accepts the same three actions as the panel buttons, so a check or a +run can be bound to a key or driven from a script: + +```sh +noctalia msg plugin nightwatch75/topgrade-wrapper:service all check +noctalia msg plugin nightwatch75/topgrade-wrapper:service all update +noctalia msg plugin nightwatch75/topgrade-wrapper:service all dismiss +``` + +## Notes + +- **Commands spawned.** `topgrade --dry-run --no-self-update` for the step list; + one read-only listing query per detected manager (`checkupdates`, `yay -Qua`, + `paru -Qua`, `apt-get -s upgrade`, `dnf check-update`, `zypper list-updates`, + `flatpak list` + `flatpak remote-ls --updates`, `snap refresh --list`, + `brew outdated`, `cargo install-update --list`, `npm -g outdated`, + `gem outdated`, `pip list --outdated`, each piped through `awk`/`sed` to one + package per line); + and, for the run, your terminal with `topgrade` inside it. Nothing else, and no + upgrade command is ever run outside the terminal window. +- **Network.** Several count queries contact package mirrors, the AUR RPC, or a + Flatpak remote, exactly as the corresponding upgrade would. They are read-only + and only run when a check runs. +- **Privileges.** The plugin never elevates anything itself. topgrade escalates + per step with its own `sudo_command`, which prompts on the terminal's tty. + Setting `sudo_command = "pkexec"` in your `topgrade.toml` routes that prompt + through Noctalia's polkit agent instead, as a graphical dialog. +- **Files.** The plugin writes nothing: no cache, no state file, and your + `topgrade.toml` is never modified — step exclusions are command-line + overrides. +- **Counts are per manager, not per step.** A count is only ever as good as the + query behind it, so managers without one are named instead of estimated. The + total is the sum of the rows shown, nothing more. +- **Settings that change the command line** (the configuration file and the + excluded steps) invalidate the last result, since it described a different + run; cosmetic edits such as the glyph leave it alone. + +## Install + +Install **Topgrade Wrapper** from Noctalia's plugin store (*Settings → +Plugins*), then add the widget to a bar from *Settings → Bar*. Plugin options +live in *Settings → Plugins*. + +For local development, add your working copy as a path source instead +(`.luau` edits hot-reload): + +```sh +noctalia msg plugins source add dev path /path/to/plugins +noctalia msg plugins enable nightwatch75/topgrade-wrapper +``` + +## License + +MIT. diff --git a/topgrade-wrapper/panel.luau b/topgrade-wrapper/panel.luau new file mode 100644 index 0000000..52d6afa --- /dev/null +++ b/topgrade-wrapper/panel.luau @@ -0,0 +1,433 @@ +--!nonstrict +-- topgrade-wrapper — update panel. A pure renderer over the shared state: the +-- engine (service.luau) publishes "topgrade_state" and performs the +-- "topgrade_request" actions this panel emits, so closing the panel never +-- interrupts a check or a run in progress. +-- +-- The flow is deliberately two-step. "Check Updates" only queries, and its +-- result is a per-manager breakdown plus, when something could not be counted, +-- the names of the steps it left out. Only then do "Update" (open a terminal +-- and run topgrade) and "Dismiss" (keep the numbers, quiet the bar) light up. + +local STATE_KEY = "topgrade_state" +local REQUEST_KEY = "topgrade_request" + +local snapshot = nil +local expanded = {} -- manager 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 manager is expanded this render + +local render + +local function tr(key, args) + return noctalia.tr(key, args) +end + +-- The nonce is monotonic across writers (the panel and every widget instance): +-- each seeds from the last request already in the shared state. +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 versionsShown() + return noctalia.getConfig("show_versions") ~= false +end + +-- The full text for the detail line: everything the row may have had to elide. +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 + +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 + +-- Version pair width. Arch git-snapshot versions run past 25 characters, so the +-- pair is capped and elided here and the hover line below the list carries the +-- full text: no plugin UI primitive can put a tooltip on a plain row. +local VERSION_WIDTH = 82 + +-- A single package inside an expanded manager: name, then — unless the versions +-- are switched off — `installed → available` with the incoming version in the +-- accent colour so the eye lands on what changes. +-- +-- The hover callback is a closure over this row's own package, which is why it +-- needs no lookup table and no key argument: it already holds the text it will +-- show. It fills the detail line below the list, the only place a long version +-- pair fits unelided (a tooltip is not an option — the UI offers that on +-- ui.button alone), and every row feeds it whether or not it shows the pair. +local function packageRow(managerKey, index, item) + local key = "pkg-" .. managerKey .. "-" .. index + local children = { + ui.label({ text = item.name, fontSize = 11, color = "on_surface", flexGrow = 1, maxLines = 1 }), + } + -- Versions off narrows the row to the name, but the hover line below the list + -- keeps working exactly as it does with them on: the setting is about how + -- much every row carries at rest, not about giving up the detail. + local from = item.from ~= nil and item.from or "" + local to = item.to ~= nil and item.to or "" + if versionsShown() and 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 + -- Only the leave of the row that is still the hovered one clears the line, so + -- an enter that beats its predecessor's leave is not undone by it. + 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 + +-- One row per manager that answered, biggest first so the row that matters is +-- at the top. Managers that answered zero are kept out of the list and summed +-- up in the footer caption instead. +-- +-- A row with names behind it is a click target that expands into them. The +-- expanded set is panel-local on purpose: which rows you opened is a property of +-- looking at the list, not of the check, so it is neither published nor kept +-- across a re-check. +local function countRows() + local rows = {} + if snapshot == nil or type(snapshot.counts) ~= "table" then + return rows + end + local pending = {} + for _, entry in ipairs(snapshot.counts) do + if (tonumber(entry.n) or 0) > 0 then + table.insert(pending, entry) + end + end + table.sort(pending, function(a, b) + if a.n == b.n then + return tostring(a.key) < tostring(b.key) + end + return a.n > b.n + end) + + for _, entry in ipairs(pending) do + local names = type(entry.items) == "table" and entry.items or {} + local open = expanded[entry.key] == true and #names > 0 + listOpen = listOpen or open + local header = { gap = 8, align = "center", key = "count-" .. entry.key .. (open and "-open" or "") } + if #names > 0 then + local managerKey = entry.key + header.onClick = function() + expanded[managerKey] = not expanded[managerKey] + -- Folding removes the rows the detail line was describing. + 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 = tr("count." .. entry.key), color = "on_surface", flexGrow = 1 }), + ui.label({ text = tostring(entry.n), color = "primary", fontWeight = "bold" }), + })) + + if open then + for index, item in ipairs(names) do + table.insert(rows, packageRow(entry.key, index, item)) + end + -- The engine caps the stored list; say so rather than let the rows + -- silently disagree with the count beside the manager. + if entry.n > #names then + table.insert(rows, ui.row({ key = "more-" .. entry.key, paddingH = 18 }, { + ui.label({ + text = tr("more_packages", { count = entry.n - #names }), + fontSize = 11, + color = "on_surface_variant", + }), + })) + end + end + end + return rows +end + +-- Managers that answered "0" — worth showing, because "checked and up to date" +-- and "never checked" must not look the same. +local function cleanNames() + local names = {} + if snapshot == nil or type(snapshot.counts) ~= "table" then + return names + end + for _, entry in ipairs(snapshot.counts) do + if (tonumber(entry.n) or 0) == 0 then + table.insert(names, tr("count." .. entry.key)) + end + end + return names +end + +local function body() + local phase = phaseOf() + local children = {} + + if phase == "checking" then + table.insert(children, ui.progress({ key = "check-progress", progress = tonumber(snapshot.progress) or 0 })) + end + + local rows = countRows() + if #rows > 0 then + table.insert(children, ui.scroll({ key = "counts", flexGrow = 1, gap = 6 }, rows)) + else + table.insert(children, ui.spacer({ key = "filler", flexGrow = 1 })) + end + + -- The detail line is rendered for as long as any list is open, hovered or + -- not: a line that appeared on hover would resize the list under the pointer + -- and flicker the row straight back out from under it. + 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 + + -- Everything topgrade would run but nothing could count: listed by step + -- name, never folded into the total. + if snapshot ~= nil and type(snapshot.uncounted) == "table" and #snapshot.uncounted > 0 then + table.insert(children, ui.label({ + text = tr("uncounted", { steps = table.concat(snapshot.uncounted, ", ") }), + fontSize = 11, + color = "on_surface_variant", + maxLines = 3, + })) + end + + local clean = cleanNames() + if #clean > 0 then + table.insert(children, ui.label({ + text = tr("up_to_date", { steps = table.concat(clean, ", ") }), + fontSize = 11, + color = "on_surface_variant", + maxLines = 2, + })) + end + + return children +end + +-- Shortens $HOME to ~ for display. The prefix is pattern-escaped: a home +-- directory holding a dash or a dot would otherwise be read as a pattern. +local function tildify(path) + local home = noctalia.getenv("HOME") + if home == nil or home == "" then + return path + end + local escaped = home:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + return (path:gsub("^" .. escaped, "~")) +end + +-- Config file and exclusion count: the two things that decide what the number +-- above actually covers. +local function configCaption() + if snapshot == nil then + return nil + end + local parts = {} + local path = snapshot.configPath + if path ~= nil and path ~= "" then + table.insert(parts, tr("caption_config", { path = tildify(path) })) + else + table.insert(parts, tr("caption_config_default")) + end + local excluded = tonumber(snapshot.excluded) or 0 + if excluded > 0 then + table.insert(parts, noctalia.trp("caption_excluded", excluded, {})) + end + if snapshot.checkedAt ~= nil and snapshot.checkedAt ~= "" then + table.insert(parts, tr("caption_checked", { time = snapshot.checkedAt })) + end + return table.concat(parts, " · ") +end + +render = function() + -- Recomputed from the tree that is about to be produced. + 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" .. (busy() and "-off" or ""), + glyph = "refresh", + variant = "ghost", + enabled = not busy() 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 = configCaption() + if caption ~= nil then + table.insert(children, ui.separator({})) + table.insert(children, ui.label({ text = caption, fontSize = 11, color = "on_surface_variant", maxLines = 3 })) + end + + if phase ~= "missing" then + table.insert(children, ui.row({ gap = 8, align = "center" }, { + ui.button({ + key = "check" .. (busy() and "-off" or ""), + glyph = "refresh", + text = tr("action_check"), + variant = "ghost", + enabled = not busy(), + 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 + -- A new check invalidates the rows the old one produced, so start it folded. + if value.phase == "checking" and (snapshot == nil or snapshot.phase ~= "checking") then + expanded = {} + hoverKey = nil + hoverText = "" + end + snapshot = value + render() +end) diff --git a/topgrade-wrapper/plugin.toml b/topgrade-wrapper/plugin.toml new file mode 100644 index 0000000..0401416 --- /dev/null +++ b/topgrade-wrapper/plugin.toml @@ -0,0 +1,142 @@ +# Topgrade Wrapper — drive topgrade (https://github.com/topgrade-rs/topgrade) +# from the bar. The bar glyph shows how many packages are waiting; the panel +# runs the check and starts the upgrade. +# +# The check is a `topgrade --dry-run` (which reports the steps topgrade would +# run, honoring the user's own topgrade.toml) followed by one read-only query per +# package manager it found, each listing what that manager has pending — so the +# number on the bar and the list a panel row expands into are the same answer. +# The upgrade itself is never run in the background: it opens a terminal window, +# so package managers can prompt and sudo can ask for the password on the tty. + +id = "nightwatch75/topgrade-wrapper" +name = "Topgrade Wrapper" +version = "0.0.2" +plugin_api = 9 +author = "nightwatch75" +license = "MIT" +dependencies = ["topgrade"] +tags = ["bar", "panel", "service", "system", "utility"] +icon = "package" +description = "Check how many packages topgrade would upgrade, then run the upgrade in a terminal window." + +# Plugin-level settings: shared by the engine (service.luau, which builds every +# topgrade command line) and the panel (which shows the active config). + +# Empty = let topgrade resolve its own configuration file, which is what the +# count is meant to reflect. Set this only to point at an alternative file. +[[setting]] +key = "topgrade_config" +type = "file" +label_key = "settings.topgrade_config.label" +description_key = "settings.topgrade_config.description" + +# Where step exclusions come from: topgrade's own configuration alone, or that +# plus the list below. In "override" the ids are passed as `--disable ` on +# the command line — the config file itself is never rewritten. +[[setting]] +key = "exclude_mode" +type = "select" +label_key = "settings.exclude_mode.label" +description_key = "settings.exclude_mode.description" +default = "config" +options = [ + { value = "config", label_key = "settings.exclude_mode.options.config" }, + { value = "override", label_key = "settings.exclude_mode.options.override" }, +] + +[[setting]] +key = "exclude_steps" +type = "string_list" +label_key = "settings.exclude_steps.label" +description_key = "settings.exclude_steps.description" +default = [] +visible_when = { key = "exclude_mode", values = ["override"] } + +[[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 + +# Off makes an expanded manager a plain list of names. The hover line under the +# list still spells out the versions of whatever row the pointer is on, so this +# only decides how much each row carries at rest. +[[setting]] +key = "show_versions" +type = "bool" +label_key = "settings.show_versions.label" +description_key = "settings.show_versions.description" +default = true + +# Empty = Noctalia's own terminal discovery ($TERMINAL, then the usual +# emulators). Set a command to force one, e.g. "kitty" or "ghostty". +[[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 + +[[setting]] +key = "sudo_loop" +type = "bool" +label_key = "settings.sudo_loop.label" +description_key = "settings.sudo_loop.description" +default = false +advanced = true + +[[setting]] +key = "keep_terminal_open" +type = "bool" +label_key = "settings.keep_terminal_open.label" +description_key = "settings.keep_terminal_open.description" +default = false +advanced = true + +[[service]] +id = "service" +entry = "service.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 400 +height = 500 +placement = "attached" +open_near_click = true + +[[widget]] +id = "topgrade-wrapper" +entry = "topgrade-wrapper.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 diff --git a/topgrade-wrapper/service.luau b/topgrade-wrapper/service.luau new file mode 100644 index 0000000..6b071e8 --- /dev/null +++ b/topgrade-wrapper/service.luau @@ -0,0 +1,701 @@ +--!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() diff --git a/topgrade-wrapper/thumbnail.webp b/topgrade-wrapper/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..7768b443f1b68a61abb5d724d7f391c08d39b494 GIT binary patch literal 63856 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 literal 0 HcmV?d00001 diff --git a/topgrade-wrapper/topgrade-wrapper.luau b/topgrade-wrapper/topgrade-wrapper.luau new file mode 100644 index 0000000..3103dc3 --- /dev/null +++ b/topgrade-wrapper/topgrade-wrapper.luau @@ -0,0 +1,143 @@ +--!nonstrict +-- topgrade-wrapper — bar widget: pending-update badge and panel toggle. +-- +-- A pure renderer over the shared state the engine (service.luau) publishes on +-- "topgrade_state"; the count is therefore already there when the panel has +-- never been opened, and every bar showing the widget agrees. Actions are sent +-- back as "topgrade_request" entries rather than run here, so one engine owns +-- the topgrade process no matter how many widget instances exist. +-- +-- Click mapping: +-- Left click — open/close the panel +-- Right click — check for updates now +-- +-- There is deliberately no middle-click handler: every bar widget carries a +-- built-in `middle` binding that opens its own settings, and a user binding wins +-- over a script callback, so one here would be dead code. Claiming the gesture +-- back needs [widget.actions], which is plugin_api 14. + +local PANEL_ID = "nightwatch75/topgrade-wrapper:panel" +local REQUEST_KEY = "topgrade_request" +local STATE_KEY = "topgrade_state" + +local snapshot = nil + +local function tr(key, args) + return noctalia.tr(key, args) +end + +-- The nonce is monotonic across writers (every widget instance and the panel): +-- each seeds from the last request already in the shared state. +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 + +-- "pacman 5 · Flatpak 1" — the per-manager breakdown, non-zero entries only. +local function breakdown() + if snapshot == nil or type(snapshot.counts) ~= "table" then + return "" + end + local parts = {} + for _, entry in ipairs(snapshot.counts) do + if entry.n > 0 then + table.insert(parts, tr("count." .. entry.key) .. " " .. entry.n) + end + 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 + +-- Updates are "pending" only while they are worth interrupting the user for: +-- a dismissed result stays visible in the panel but takes the bar back to its +-- resting colour. +local function pending() + return snapshot ~= nil + and snapshot.phase == "ready" + and snapshot.dismissed ~= true + and (snapshot.total or 0) > 0 +end + +local function render() + barWidget.setGlyph(noctalia.getConfig("glyph")) + + local phase = snapshot ~= nil and snapshot.phase or "idle" + if phase == "missing" or phase == "error" then + barWidget.setGlyphColor("error") + elseif phase == "checking" or phase == "running" then + barWidget.setGlyphColor("secondary") + 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 + + -- A semantic row label, not the plugin name: the tooltip hangs off this + -- plugin's own glyph, so restating "Topgrade Wrapper" only costs width. + 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 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 and count 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() diff --git a/topgrade-wrapper/translations/en.json b/topgrade-wrapper/translations/en.json new file mode 100644 index 0000000..a8d649a --- /dev/null +++ b/topgrade-wrapper/translations/en.json @@ -0,0 +1,115 @@ +{ + "action_check": "Check Updates", + "action_dismiss": "Dismiss", + "action_update": "Update", + "caption_checked": "checked {time}", + "caption_config": "config {path}", + "caption_config_default": "topgrade defaults", + "caption_excluded": { + "one": "1 step excluded", + "other": "{count} steps excluded" + }, + "count": { + "apt": "APT", + "aur_paru": "AUR (paru)", + "aur_yay": "AUR (yay)", + "brew": "Homebrew", + "cargo": "Cargo", + "dnf": "DNF", + "flatpak": "Flatpak", + "gem": "RubyGems", + "npm": "npm", + "pacman": "Pacman", + "pip": "pip", + "snap": "Snap", + "zypper": "Zypper" + }, + "err_dry_failed": "topgrade could not list its steps", + "err_dry_timeout": "Timed out while listing the topgrade steps", + "err_no_terminal": "No terminal emulator found — set one in the plugin settings", + "err_no_topgrade": "topgrade not found — install it and check your PATH", + "err_spawn": "Could not run topgrade", + "hover_hint": "Hover a package for its full versions", + "more_packages": "+{count} more", + "notify_updates": { + "one": "1 package to upgrade", + "other": "{count} packages to upgrade" + }, + "settings": { + "assume_yes": { + "description": "Pass --yes so package managers do not ask for confirmation. Off means you confirm each one in the terminal window.", + "label": "Answer yes automatically" + }, + "auto_check_hours": { + "description": "Check for updates automatically every N hours. 0 (default) never checks on its own — nothing runs until you ask for it.", + "label": "Auto-check interval (hours)" + }, + "exclude_mode": { + "description": "Where skipped steps come from. 'topgrade configuration' uses only the disable list in your topgrade.toml. 'Override with the list below' also passes the steps you list as --disable; note that --disable can only add exclusions, never re-enable a step your topgrade.toml already disables.", + "label": "Excluded steps source", + "options": { + "config": "topgrade configuration", + "override": "Override with the list below" + } + }, + "exclude_steps": { + "description": "topgrade step ids to skip, e.g. flatpak, cargo, containers. Passed on the command line; the config file is never modified. Run 'topgrade --help' for the full list of ids.", + "label": "Excluded steps" + }, + "glyph": { + "description": "The glyph shown for the topgrade widget on the bar.", + "label": "Bar glyph" + }, + "keep_terminal_open": { + "description": "Pass --keep so the terminal window waits for a key press instead of closing when the run ends.", + "label": "Keep the terminal open" + }, + "notify_on_updates": { + "description": "Send a desktop notification when a check finds packages to upgrade.", + "label": "Notify when updates are found" + }, + "show_count": { + "description": "Show the number of pending updates next to the bar glyph.", + "label": "Show the update count" + }, + "show_versions": { + "description": "Show `installed → available` beside each package when a manager row is expanded. Off lists names only; hovering a package still shows its full versions under the list either way.", + "label": "Show package versions" + }, + "sudo_loop": { + "description": "Pass --sudoloop so topgrade refreshes the sudo timestamp and asks for your password once. Leaves a sudo session open for the whole run.", + "label": "Keep sudo alive" + }, + "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).", + "label": "Terminal" + }, + "topgrade_config": { + "description": "Alternative topgrade configuration file, passed as --config. Empty (default) lets topgrade find its own, which is what the update count reflects.", + "label": "topgrade configuration" + } + }, + "status_checking": "Checking for updates…", + "status_checking_step": "Checking {step}…", + "status_clean": "Everything is up to date", + "status_error": "Update check failed", + "status_idle": "Not checked yet", + "status_missing": "topgrade is not installed", + "status_ready": { + "one": "1 package to upgrade", + "other": "{count} packages to upgrade" + }, + "status_running": "topgrade is running in a terminal…", + "step_planning": "topgrade steps", + "step_unnamed": "topgrade", + "tip_check": "Check for updates now", + "tip_close": "Close", + "tip_update": "Run topgrade in a terminal window", + "title": "Topgrade Wrapper", + "tooltip_checked": "Checked", + "tooltip_hints": "click: panel · right: check now", + "tooltip_pending": "Pending", + "tooltip_status": "Status", + "uncounted": "Not counted: {steps}", + "up_to_date": "Up to date: {steps}" +}