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 0000000..7768b44 Binary files /dev/null and b/topgrade-wrapper/thumbnail.webp differ 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}" +}