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