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

After

Width:  |  Height:  |  Size: 62 KiB

+143
View File
@@ -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()
+115
View File
@@ -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}"
}