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:
co-authored by
nightwatch75
parent
990c777059
commit
f6983da41a
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -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()
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user