Compare commits

..
16 Commits
Author SHA1 Message Date
felis 3118d7f8b5 bump plugin version
Jobs to run on push to main / update-catalog (push) Canceled after 0s
Jobs to run on push to main / update-issue-templates (push) Canceled after 0s
Validate Plugin Manifests / validate (push) Canceled after 0s
2026-08-13 20:57:34 -07:00
felis 86ff7b83c2 add stage type
Jobs to run on push to main / update-catalog (push) Canceled after 0s
Jobs to run on push to main / update-issue-templates (push) Canceled after 0s
Validate Plugin Manifests / validate (push) Canceled after 0s
2026-08-13 20:55:58 -07:00
felis 114129f72e remove all other plugins in plugins.toml
Validate Plugin Manifests / validate (push) Canceled after 0s
2026-08-13 20:53:20 -07:00
felis a1c7540880 remove other plugins
Validate Plugin Manifests / validate (push) Canceled after 0s
Jobs to run on push to main / update-catalog (push) Canceled after 0s
Jobs to run on push to main / update-issue-templates (push) Canceled after 0s
2026-08-13 20:43:17 -07:00
felis 0d106e8f1e bump version
Jobs to run on push to main / update-catalog (push) Canceled after 0s
Jobs to run on push to main / update-issue-templates (push) Canceled after 0s
Validate Plugin Manifests / validate (push) Canceled after 0s
2026-08-13 20:40:54 -07:00
felis b9c7680124 add stage layout type
Jobs to run on push to main / update-catalog (push) Canceled after 0s
Jobs to run on push to main / update-issue-templates (push) Canceled after 0s
Validate Plugin Manifests / validate (push) Canceled after 0s
2026-08-13 20:36:44 -07:00
github-actions[bot] 1e08459260 chore: update plugin catalog [skip ci] 2026-08-13 18:58:22 +00:00
Spyridon SiarapisandGitHub b97a74174d Merge pull request #350 from Cleboost/feat/hotspot-control-center-shortcut
feat(hotspot): add Control Center shortcut to toggle hotspot
2026-08-13 20:58:10 +02:00
Cleboost 168861dbb3 feat(hotspot): add Control Center shortcut to toggle hotspot
Closes #349 by exposing a cc-toggle shortcut entry that mirrors service
state and sends the existing hotspot_command toggle action.
2026-08-13 11:13:20 +02:00
github-actions[bot] 3785668387 chore: update plugin catalog [skip ci] 2026-08-13 02:44:53 +00:00
Yocraft-2000andGitHub b75eb1cb33 Update Battery widget plugin (#346)
* fix: don't notify on warnings, use same default warning_threshold as noctalia

* chore: dump version
2026-08-12 22:44:40 -04:00
github-actions[bot] 335017f1e8 chore: update plugin catalog [skip ci] 2026-08-13 02:42:05 +00:00
weinguyenandGitHub 691398d9da update(procmon): fix windowed table follow-scroll & row sizing` (#340)
* update(procmon): fix windowed table follow-scroll & row sizing`

* Update plugin.toml

* Update README.md

* fix(procmon): always render list body as scroll to stop bottom clipping

0-result search switched the body from ui.scroll to a bare ui.row; when
results returned the row->scroll switch left the flexGrow viewport stuck
short, clipping bottom rows. Use ui.scroll with a shared key in both
branches so the tree keeps one stable scroll node.
2026-08-12 22:41:55 -04:00
github-actions[bot] a4ae9c8ab4 chore: update plugin catalog [skip ci] 2026-08-12 13:49:25 +00:00
weinguyenandGitHub 27458632e2 feat(plugin): OpenCode Companion beta.8 upgrade + streaming/overflow (#337)
* feat(plugin): OpenCode Companion beta.8 upgrade + streaming/overflow
fixes

Upgrade to plugin API 21 and reverse the chat so the newest message sits
at the bottom with auto-scroll:

- panel: reverse message list to oldest-first; pin the scroll to the
  bottom via stickToBottom, jump there on open and session switch via
  scrollToBottomRev, and clear the stick flag in onScroll when the user
  scrolls away so reading history doesn't yank them down.
- composer: enable submitOnEnter so Enter sends and Shift+Enter inserts
  a
  newline; update en/vi placeholder and send-tooltip strings.
- panel: render assistant replies as markdown (headings, lists, tables),
  keeping user messages as plain labels.

Fix runtime errors surfaced during use:

- panel: pin markdown width to WRAP — ui.markdown has no maxWidth prop,
  so
  a bare flexGrow left it unconstrained and single-line text overflowed.
- panel: wrap fenced code blocks in monospace labels with a maxWidth —
  noctalia's MarkdownView never wraps code, so long bash/code lines
  spilled outside the panel. Text outside code fences stays markdown.
- panel: coerce onScroll(offset, maxOffset) args with tonumber — the
  reconciler passes them as strings, raising "attempt to compare number
  <= string".
- service: handle message.part.updated / message.updated streaming via
  throttled reload; acknowledge message.part.delta without reloading
  (the
  server emits one per token, and reloading on each decodes the whole
  history and blows the async callback CPU budget in json.decode).
- service: handle session.updated / session.diff with a throttled
  session
  reload so auto-generated titles stay current in the chooser.

Verified with luac syntax checks and a standalone split_markdown test
(5 cases) and JSON validation of the translation files.

* update version to 0.2.0
2026-08-12 09:49:13 -04:00
github-actions[bot] 6fcf69f738 chore: update plugin catalog [skip ci] 2026-08-12 13:37:42 +00:00
853 changed files with 10 additions and 138068 deletions
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 weinguyen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-84
View File
@@ -1,84 +0,0 @@
# 9Router Control
Manage your [9Router](https://github.com/decolua/9router) combos right from the
Noctalia bar — no web dashboard needed.
- **List combos** — see every combo with its model chain and routing kind.
- **Reorder models** — open a combo and move models up/down to change fallback
order.
- **Create / rename / delete combos** — full CRUD from the panel.
- **Change routing kind** — fallback, round-robin, or fusion.
- **Search** — filter combos by name or model chain.
## Plugin
| Field | Value |
| ------- | --------------------------- |
| ID | `weinguyen/9router-control` |
| Widget | `widget` (bar) |
| Panel | `panel` |
| Service | `service` |
Toggle the panel from the bar widget, or with:
```sh
noctalia msg panel-toggle weinguyen/9router-control:panel
```
## Requirements
- Noctalia v5.0.0 or higher.
- A running 9Router instance (dashboard) whose REST API the plugin talks to via
`server_host:server_port`.
- The following external commands, used by the service backend:
- `curl` — password login posts to `/api/auth/login` and reads the `Set-Cookie`
header (Noctalia's HTTP binding does not expose response headers).
- `sha256sum`, `cat`, `cut`, `printf` — compute the CLI authentication token
from the `machine-id` and `auth/cli-secret` files when the dashboard has
login enabled.
- `sleep` — back-off polling while a CLI token computation is in flight.
Ordinary (login-disabled) usage needs only `curl`; the remaining commands are
required when dashboard login is enabled.
## Usage
Click the 9Router widget on the bar to open the panel.
- The **combo list** shows every combo; the search box filters by name or model
chain.
- Select a combo to **reorder** its models (move up/down) and change its routing
kind.
- Use **create / rename / delete** to manage combos without touching the web
dashboard.
If the dashboard has login enabled and no CLI token is available, the panel
shows a login screen where you enter the dashboard password; the plugin then
reuses the session cookie for subsequent requests.
## Settings
| Setting | Default | Description |
| -------------- | ------------ | ----------------------------------------------------------------------- |
| Server Host | `127.0.0.1` | Hostname of the 9Router dashboard. |
| Server Port | `20128` | Port of the 9Router dashboard. |
| Data Directory | `~/.9router` | 9Router data dir, used to compute the CLI token when login is required. |
| Language | `auto` | UI language (auto / English / Tiếng Việt). |
| Debug Logging | off | Print debug messages to the Noctalia log. |
## Notes
- The plugin talks to the local 9Router REST API (`/api/combos`). By default the
dashboard login is disabled, so plain requests work.
- When login is enabled, the plugin first tries the 9Router CLI token (computed
from the `machine-id` and `auth/cli-secret` files, sent as an `x-9r-cli-token`
header). If that isn't available, it falls back to a password login and reuses
the session cookie.
- Reordering writes the new `models` array via `PUT /api/combos/:id`.
## Development
- `widget.luau` — bar widget entry.
- `panel.luau` — chat-style panel surface.
- `service.luau` — headless API / state backend.
- `translations/` — user-facing strings.
-901
View File
@@ -1,901 +0,0 @@
-- 9Router Control — the combo management surface.
--
-- Three views:
-- • List — all combos with search + "new combo" button
-- • Detail — a single combo's model chain with reorder/edit/delete
-- • Create — a form to build a new combo
--
-- Pure subscriber of 9router.* state. User actions are dispatched as IPC
-- events to the service.
local STATE = {
connection = "9router.connection",
combos = "9router.combos",
models = "9router.models",
selected = "9router.selected_combo",
last_error = "9router.last_error",
}
local PAD = 12
local WRAP = 480
-- Confirmation / form state
local pending_delete = nil
local rename_id = nil
local rename_value = ""
local creating = false
local create_name = ""
local create_models = ""
local create_kind = "none"
local search_query = ""
local login_password = ""
local adding_model = false
local model_search = ""
local create_error = ""
local rename_error = ""
local KIND_OPTIONS = {
{ value = "none", label_key = "kind.none" },
{ value = "fallback", label_key = "kind.fallback" },
{ value = "round-robin", label_key = "kind.round_robin" },
{ value = "fusion", label_key = "kind.fusion" },
}
-- ── i18n ─────────────────────────────────────────────────────────────────────
local i18n_cache = { lang = nil, table = nil }
local function load_lang_table(lang)
if i18n_cache.lang == lang and i18n_cache.table then
return i18n_cache.table
end
local merged = {}
local function merge_file(path)
local ok, content = pcall(noctalia.readFile, path)
if not ok or type(content) ~= "string" or content == "" then return end
local ok2, data = pcall(noctalia.json.decode, content)
if not ok2 or type(data) ~= "table" then return end
local function deep(dst, src)
for k, v in pairs(src) do
if type(v) == "table" and type(dst[k]) == "table" then
deep(dst[k], v)
else
dst[k] = v
end
end
end
deep(merged, data)
end
merge_file("translations/en.json")
if lang ~= "en" then
merge_file("translations/" .. lang .. ".json")
end
i18n_cache.lang = lang
i18n_cache.table = merged
return merged
end
local function tr(key, args)
local lang = noctalia.getConfig and noctalia.getConfig("language")
if type(lang) ~= "string" or lang == "" or lang == "auto" then
return noctalia.tr(key, args)
end
local node = load_lang_table(lang)
for part in string.gmatch(key, "[^%.]+") do
if type(node) ~= "table" then node = nil break end
node = node[part]
end
if type(node) ~= "string" then
return noctalia.tr(key, args)
end
if type(args) == "table" then
node = string.gsub(node, "{(%w+)}", function(name)
local v = args[name]
if v == nil then return "{" .. name .. "}" end
return tostring(v)
end)
end
return node
end
local function kind_label(value)
for _, o in ipairs(KIND_OPTIONS) do
if o.value == value then return tr(o.label_key) end
end
return value or tr("kind.none")
end
-- ── helpers ─────────────────────────────────────────────────────────────────
local function dispatch_ipc(event, payload)
local cmd = "noctalia msg plugin 'weinguyen/9router-control:service' all " .. event
if payload ~= nil then
-- Shell-escape the payload (single-quote wrap + escape embedded quotes).
payload = tostring(payload):gsub("'", "'\\''")
cmd = cmd .. " '" .. payload .. "'"
end
noctalia.runAsync(cmd)
end
local function find_combo(combos, id)
if type(combos) ~= "table" then return nil end
for _, c in ipairs(combos) do
if type(c) == "table" and c.id == id then return c end
end
return nil
end
local function model_chain_text(models)
if type(models) ~= "table" then return "" end
local parts = {}
for _, m in ipairs(models) do
if type(m) == "string" and m ~= "" then parts[#parts + 1] = m end
end
return table.concat(parts, " → ")
end
-- 9Router combo name rule: only a-z, A-Z, 0-9, -, _ and .
local NAME_RE = "^[%w%.%-]+$"
local function valid_name(name)
return type(name) == "string" and name ~= "" and string.match(name, NAME_RE) ~= nil
end
-- Compact fingerprint of the combo list (names + model chains + kinds) so a
-- reorder/edit that keeps the count the same still triggers a re-render.
local function combos_fingerprint(combos)
if type(combos) ~= "table" then return "0" end
local parts = {}
for _, c in ipairs(combos) do
if type(c) == "table" then
parts[#parts + 1] = tostring(c.id or "") .. "|" .. tostring(c.name or "") .. "|" .. tostring(c.kind or "") .. "|" .. model_chain_text(c.models)
end
end
return table.concat(parts, "\1")
end
-- ── list view ───────────────────────────────────────────────────────────────
local function render_list(combos, conn)
local rows = {}
-- Header
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "route", color = "primary", size = 24 }),
ui.label({ text = tr("list.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
}),
ui.row({ gap = 8 }, {
ui.button({ glyph = "refresh", onClick = function()
dispatch_ipc("refresh")
end, tooltip = tr("list.refresh") }),
}),
})
rows[#rows + 1] = ui.separator({})
-- Error banner (if any)
local err = noctalia.state.get(STATE.last_error)
if type(err) == "table" and err.message then
rows[#rows + 1] = ui.row({ gap = 8, align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, {
ui.glyph({ name = "alert-circle", color = "error", size = 16 }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }),
ui.label({ text = err.detail or "", fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 }),
}),
ui.button({
glyph = "x",
variant = "ghost",
tooltip = tr("error.dismiss"),
onClick = function()
dispatch_ipc("clear_error")
end,
}),
})
end
-- New combo button
rows[#rows + 1] = ui.button({
text = tr("list.new_combo"),
glyph = "plus",
onClick = function()
creating = true
create_name = ""
create_models = ""
create_kind = "none"
render()
end,
})
-- Search box
rows[#rows + 1] = ui.input({
key = "combo_search",
value = search_query,
placeholder = tr("list.search"),
onChange = function(text)
search_query = text or ""
render()
end,
})
-- Filter combos by search query
local visible = combos
if type(combos) == "table" and search_query ~= "" then
local needle = string.lower(search_query)
visible = {}
for _, c in ipairs(combos) do
if type(c) == "table" then
local hay = string.lower(tostring(c.name or "") .. " " .. model_chain_text(c.models))
if string.find(hay, needle, 1, true) then
visible[#visible + 1] = c
end
end
end
end
-- Combo list
local list_items = {}
if type(visible) ~= "table" or #visible == 0 then
list_items[#list_items + 1] = ui.label({
text = search_query ~= "" and tr("list.no_match") or tr("list.empty"),
maxWidth = WRAP,
opacity = 0.7,
})
else
for _, c in ipairs(visible) do
if type(c) == "table" and c.id then
local combo_id = c.id
local name = c.name or c.id
local chain = model_chain_text(c.models)
local kind = kind_label(c.kind)
local function open_this()
dispatch_ipc("select_combo", combo_id)
end
local actions
if pending_delete == combo_id then
actions = ui.row({ gap = 6 }, {
ui.button({
glyph = "check",
variant = "primary",
tooltip = tr("list.delete_confirm"),
onClick = function()
dispatch_ipc("delete_combo", combo_id)
pending_delete = nil
render()
end,
}),
ui.button({
glyph = "x",
variant = "secondary",
tooltip = tr("list.delete_cancel"),
onClick = function()
pending_delete = nil
render()
end,
}),
})
else
actions = ui.row({ gap = 6 }, {
ui.button({
glyph = "trash",
variant = "secondary",
tooltip = tr("list.delete"),
onClick = function()
pending_delete = combo_id
render()
end,
}),
ui.button({
glyph = "arrow-right",
variant = "primary",
tooltip = tr("list.open"),
onClick = open_this,
}),
})
end
list_items[#list_items + 1] = ui.row({
key = combo_id,
justify = "space_between",
align = "center",
padding = 10,
radius = 10,
fill = "surface",
border = "surface",
borderWidth = 1,
}, {
ui.column({
gap = 2, flexGrow = 1,
onClick = open_this,
}, {
ui.label({
text = name,
maxWidth = WRAP - 140,
fontWeight = "bold",
color = "on_surface",
}),
ui.label({
text = chain,
maxWidth = WRAP - 140,
opacity = 0.6,
fontSize = 12,
}),
ui.label({
text = tr("list.kind", { kind = kind }),
maxWidth = WRAP - 140,
opacity = 0.5,
fontSize = 11,
}),
}),
actions,
})
end
end
end
rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 8, align = "stretch", justify = "start" }, list_items)
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
end
-- ── create view ──────────────────────────────────────────────────────────────
local function render_create()
local rows = {}
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.row({ gap = 8, align = "center" }, {
ui.button({
glyph = "chevron-left",
variant = "ghost",
tooltip = tr("create.back"),
onClick = function()
creating = false
render()
end,
}),
ui.glyph({ name = "plus", color = "primary", size = 20 }),
ui.label({ text = tr("create.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
}),
})
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.label({ text = tr("create.name_label"), fontWeight = "bold", fontSize = 13 })
rows[#rows + 1] = ui.input({
key = "create_name",
value = create_name,
placeholder = tr("create.name_placeholder"),
onChange = function(text)
create_name = text or ""
create_error = ""
end,
})
if create_error ~= "" then
rows[#rows + 1] = ui.label({ text = create_error, color = "error", fontSize = 12, maxWidth = WRAP })
end
rows[#rows + 1] = ui.label({ text = tr("create.models_label"), fontWeight = "bold", fontSize = 13 })
rows[#rows + 1] = ui.label({ text = tr("create.models_hint"), opacity = 0.6, fontSize = 11, maxWidth = WRAP })
rows[#rows + 1] = ui.input({
key = "create_models",
value = create_models,
placeholder = tr("create.models_placeholder"),
multiline = true,
flexGrow = 1,
onChange = function(text)
create_models = text or ""
end,
})
rows[#rows + 1] = ui.label({ text = tr("create.kind_label"), fontWeight = "bold", fontSize = 13 })
local kind_labels = {}
local kind_values = {}
local kind_index = nil
for i, o in ipairs(KIND_OPTIONS) do
kind_labels[i] = tr(o.label_key)
kind_values[i] = o.value
if o.value == create_kind then kind_index = i - 1 end
end
local kind_props = {
key = "create_kind",
options = kind_labels,
placeholder = tr("create.kind_label"),
onChange = function(idx)
local n = tonumber(idx)
if n ~= nil then
create_kind = kind_values[math.floor(n) + 1] or "none"
end
end,
}
if kind_index ~= nil then kind_props.selectedIndex = kind_index end
rows[#rows + 1] = ui.select(kind_props)
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.row({ justify = "end", gap = 8 }, {
ui.button({
text = tr("create.cancel"),
variant = "secondary",
onClick = function()
creating = false
render()
end,
}),
ui.button({
text = tr("create.submit"),
glyph = "check",
variant = "primary",
onClick = function()
if not valid_name(create_name) then
create_error = tr("error.invalid_name")
render()
return
end
local models = {}
for line in string.gmatch(create_models, "[^\n]+") do
local m = line:gsub("^%s+", ""):gsub("%s+$", "")
if m ~= "" then models[#models + 1] = m end
end
if #models == 0 then
create_error = tr("error.need_name_models")
render()
return
end
local payload = noctalia.json.encode({
name = create_name,
models = models,
kind = (create_kind == "none" or create_kind == "") and nil or create_kind,
})
dispatch_ipc("create_combo", payload)
creating = false
create_error = ""
render()
end,
}),
})
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
end
-- ── detail view ─────────────────────────────────────────────────────────────
local function render_detail(combo)
local rows = {}
-- Header with back button
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.button({
glyph = "chevron-left",
variant = "ghost",
tooltip = tr("detail.back"),
onClick = function()
dispatch_ipc("back_to_list")
end,
}),
ui.glyph({ name = "route", color = "primary", size = 20 }),
ui.column({ gap = 0, flexGrow = 1 }, {
ui.label({ text = combo.name or combo.id, fontWeight = "bold", maxWidth = WRAP - 200, fontSize = 14 }),
ui.label({ text = tr("detail.models_count", { count = #(combo.models or {}) }), opacity = 0.6, fontSize = 11 }),
}),
}),
ui.row({ gap = 6 }, {
ui.button({
glyph = "refresh",
tooltip = tr("detail.refresh"),
onClick = function()
dispatch_ipc("refresh")
end,
}),
ui.button({
glyph = "trash",
variant = "secondary",
tooltip = tr("detail.delete"),
onClick = function()
pending_delete = combo.id
render()
end,
}),
}),
})
rows[#rows + 1] = ui.separator({})
-- Inline delete confirmation
if pending_delete == combo.id then
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, {
ui.label({ text = tr("detail.delete_confirm"), color = "error", fontWeight = "bold", maxWidth = WRAP - 120 }),
ui.row({ gap = 6 }, {
ui.button({
glyph = "check",
variant = "primary",
tooltip = tr("detail.delete_yes"),
onClick = function()
dispatch_ipc("delete_combo", combo.id)
pending_delete = nil
render()
end,
}),
ui.button({
glyph = "x",
variant = "secondary",
tooltip = tr("detail.delete_no"),
onClick = function()
pending_delete = nil
render()
end,
}),
}),
})
end
-- Kind selector
local kind_labels = {}
local kind_values = {}
local kind_index = nil
for i, o in ipairs(KIND_OPTIONS) do
kind_labels[i] = tr(o.label_key)
kind_values[i] = o.value
if o.value == (combo.kind or "none") then kind_index = i - 1 end
end
local kind_props = {
key = "detail_kind",
options = kind_labels,
placeholder = tr("detail.kind_label"),
flexGrow = 1,
onChange = function(idx)
local n = tonumber(idx)
if n ~= nil then
local chosen = kind_values[math.floor(n) + 1] or "none"
dispatch_ipc("set_kind", combo.id .. ":" .. chosen)
end
end,
}
if kind_index ~= nil then kind_props.selectedIndex = kind_index end
rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, {
ui.label({ text = tr("detail.kind_label"), fontWeight = "bold", fontSize = 13 }),
ui.select(kind_props),
})
-- Rename
if rename_id == combo.id then
rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, {
ui.input({
key = "rename_input",
value = rename_value,
placeholder = tr("detail.rename_placeholder"),
flexGrow = 1,
onChange = function(text)
rename_value = text or ""
rename_error = ""
end,
}),
ui.button({
glyph = "check",
variant = "primary",
tooltip = tr("detail.rename_save"),
onClick = function()
if not valid_name(rename_value) then
rename_error = tr("error.invalid_name")
render()
return
end
dispatch_ipc("rename_combo", combo.id .. ":" .. rename_value)
rename_id = nil
rename_value = ""
rename_error = ""
render()
end,
}),
ui.button({
glyph = "x",
variant = "secondary",
tooltip = tr("detail.rename_cancel"),
onClick = function()
rename_id = nil
rename_value = ""
rename_error = ""
render()
end,
}),
})
if rename_error ~= "" then
rows[#rows + 1] = ui.label({ text = rename_error, color = "error", fontSize = 12, maxWidth = WRAP })
end
else
rows[#rows + 1] = ui.button({
text = tr("detail.rename"),
glyph = "pencil",
variant = "outline",
onClick = function()
rename_id = combo.id
rename_value = combo.name or ""
render()
end,
})
end
rows[#rows + 1] = ui.separator({})
-- Model chain (reorderable via drag-and-drop)
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = tr("detail.models_title"), fontWeight = "bold", fontSize = 13 }),
ui.button({
glyph = "plus",
variant = "secondary",
tooltip = tr("detail.add_model"),
onClick = function()
adding_model = true
model_search = ""
render()
end,
}),
})
local models = combo.models or {}
local model_items = {}
if #models == 0 then
model_items[#model_items + 1] = ui.label({ text = tr("detail.no_models"), opacity = 0.7, maxWidth = WRAP })
else
model_items[#model_items + 1] = ui.label({ text = tr("detail.drag_hint"), opacity = 0.6, fontSize = 11, maxWidth = WRAP })
for i, m in ipairs(models) do
local idx = i
model_items[#model_items + 1] = ui.dropZone({
key = "drop_" .. tostring(idx),
value = tostring(idx),
accepts = { "model" },
onDrop = function(payload, target)
dispatch_ipc("move_model", combo.id .. ":" .. tostring(payload) .. ":" .. tostring(target))
end,
direction = "column",
gap = 6,
padding = 2,
radius = 8,
}, {
ui.dragSource({
key = "drag_" .. tostring(idx),
dragType = "model",
payload = tostring(idx),
}, {
ui.row({
justify = "space_between",
align = "center",
padding = 8,
radius = 8,
fill = "surface",
border = "surface",
borderWidth = 1,
}, {
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.glyph({ name = "grip-vertical", color = "on_surface", size = 16, opacity = 0.5 }),
ui.label({ text = tostring(idx), color = "primary", fontWeight = "bold", fontSize = 12 }),
ui.label({ text = tostring(m), maxWidth = WRAP - 140, fontSize = 13 }),
}),
ui.button({
glyph = "x",
variant = "ghost",
tooltip = tr("detail.remove_model"),
onClick = function()
dispatch_ipc("remove_model", combo.id .. ":" .. tostring(idx))
end,
}),
}),
}),
})
end
end
rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 6, align = "stretch", justify = "start" }, model_items)
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
end
-- ── add model view ───────────────────────────────────────────────────────────────
local function render_add_model(combo)
local rows = {}
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.button({
glyph = "chevron-left",
variant = "ghost",
tooltip = tr("add_model.back"),
onClick = function()
adding_model = false
model_search = ""
render()
end,
}),
ui.glyph({ name = "plus", color = "primary", size = 20 }),
ui.label({ text = tr("add_model.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
}),
})
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.input({
key = "model_search",
value = model_search,
placeholder = tr("add_model.search"),
onChange = function(text)
model_search = text or ""
render()
end,
})
-- Available models from the system, minus ones already in this combo.
local all_models = noctalia.state.get(STATE.models) or {}
local in_combo = {}
for _, m in ipairs(combo.models or {}) do in_combo[m] = true end
local needle = string.lower(model_search)
local items = {}
local count = 0
for _, m in ipairs(all_models) do
if type(m) == "table" and type(m.fullModel) == "string" then
local full = m.fullModel
if not in_combo[full] then
if needle == "" or string.find(string.lower(full), needle, 1, true) then
count = count + 1
items[#items + 1] = ui.button({
key = "add_" .. full,
text = full,
variant = "outline",
contentAlign = "start",
onClick = function()
dispatch_ipc("add_model", combo.id .. ":" .. full)
adding_model = false
model_search = ""
render()
end,
})
end
end
end
end
if count == 0 then
items[#items + 1] = ui.label({
text = needle ~= "" and tr("add_model.no_match") or tr("add_model.empty"),
opacity = 0.7,
maxWidth = WRAP,
})
end
rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 6, align = "stretch", justify = "start" }, items)
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
end
-- ── login view ────────────────────────────────────────────────────────────────
local function render_login()
local rows = {}
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "lock", color = "error", size = 24 }),
ui.label({ text = tr("login.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
}),
})
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.label({ text = tr("login.hint"), opacity = 0.7, fontSize = 12, maxWidth = WRAP })
rows[#rows + 1] = ui.input({
key = "login_password",
value = login_password,
placeholder = tr("login.password_placeholder"),
password = true,
onChange = function(text)
login_password = text or ""
end,
})
-- Error banner (if any)
local err = noctalia.state.get(STATE.last_error)
if type(err) == "table" and err.message then
rows[#rows + 1] = ui.row({ gap = 8, align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, {
ui.glyph({ name = "alert-circle", color = "error", size = 16 }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }),
ui.label({ text = err.detail or "", fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 }),
}),
})
end
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.row({ justify = "end", gap = 8 }, {
ui.button({
text = tr("login.submit"),
glyph = "login",
variant = "primary",
onClick = function()
dispatch_ipc("login", login_password)
login_password = ""
render()
end,
}),
})
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
end
-- ── main render ──────────────────────────────────────────────────────────────
render = function()
local conn = noctalia.state.get(STATE.connection)
local combos = noctalia.state.get(STATE.combos) or {}
local selected_id = noctalia.state.get(STATE.selected)
-- Login view takes priority when the dashboard requires authentication.
if type(conn) == "table" and conn.status == "auth_required" then
render_login()
return
end
-- Create view takes priority
if creating then
render_create()
return
end
-- Detail view when a combo is selected and still exists
local selected = find_combo(combos, selected_id)
if selected then
if adding_model then
render_add_model(selected)
return
end
render_detail(selected)
return
end
-- Otherwise the list view
render_list(combos, conn)
end
-- ── lifecycle ───────────────────────────────────────────────────────────────
local function fingerprint_state()
local conn = noctalia.state.get(STATE.connection)
local combos = noctalia.state.get(STATE.combos)
local selected_id = noctalia.state.get(STATE.selected)
local err = noctalia.state.get(STATE.last_error)
return table.concat({
(type(conn) == "table" and conn.status or "offline"),
tostring(selected_id or "none"),
(type(err) == "table" and tostring(err.message) or "none"),
combos_fingerprint(combos),
}, "\1")
end
local snap_cache = { fingerprint = nil }
function onOpen(_context)
panel.setWantsSecondTicks(true)
for _, key in pairs(STATE) do
noctalia.state.watch(key, function()
local fp = fingerprint_state()
if fp ~= snap_cache.fingerprint then
snap_cache.fingerprint = fp
render()
end
end)
end
render()
end
function onClose()
end
function update()
local fp = fingerprint_state()
if fp ~= snap_cache.fingerprint then
snap_cache.fingerprint = fp
render()
end
end
-68
View File
@@ -1,68 +0,0 @@
id = "weinguyen/9router-control"
name = "9Router Control"
version = "0.1.0"
plugin_api = 3
author = "weinguyen"
license = "MIT"
icon = "route"
description = "Manage 9Router model combos from the Noctalia bar — list, reorder chains, edit routing without the web dashboard."
tags = ["ai", "productivity", "development", "network", "bar", "panel"]
dependencies = ["cat", "curl", "cut", "sha256sum", "sleep"]
# ── User settings ─────────────────────────────────────────────────────────────
[[setting]]
key = "server_host"
type = "string"
default = "127.0.0.1"
label_key = "settings.server_host.label"
description_key = "settings.server_host.description"
[[setting]]
key = "server_port"
type = "double"
default = 20128
label_key = "settings.server_port.label"
description_key = "settings.server_port.description"
[[setting]]
key = "data_dir"
type = "string"
default = "~/.9router"
label_key = "settings.data_dir.label"
description_key = "settings.data_dir.description"
[[setting]]
key = "language"
type = "select"
default = "auto"
label_key = "settings.language.label"
description_key = "settings.language.description"
options = [
{ value = "auto", label_key = "settings.language.options.auto" },
{ value = "en", label_key = "settings.language.options.en" },
{ value = "vi", label_key = "settings.language.options.vi" },
]
[[setting]]
key = "debug_logging"
type = "bool"
default = false
label_key = "settings.debug_logging.label"
description_key = "settings.debug_logging.description"
# ── Entries ───────────────────────────────────────────────────────────────────
[[widget]]
id = "widget"
entry = "widget.luau"
[[panel]]
id = "panel"
entry = "panel.luau"
open_near_click = true
placement = "floating"
width = 520
height = 680
[[service]]
id = "service"
entry = "service.luau"
-553
View File
@@ -1,553 +0,0 @@
-- 9Router Control service — the headless backend and single source of truth.
--
-- Responsibilities:
-- • Talk to the 9Router REST API (GET/POST/PUT/DELETE /api/combos)
-- • Authenticate: plain requests work when the dashboard has login disabled
-- (the default local setup); on a 401 we compute the CLI token from the
-- machine-id + cli-secret files and retry with the x-9r-cli-token header.
-- • Track connection state, the combo list, and the selected combo
-- • Publish shared state for the widget and panel subscribers
--
-- The widget and panel are pure subscribers of `9router.*` state keys.
-- ── helpers ─────────────────────────────────────────────────────────────────
-- Translation with an optional per-plugin language override (mirrors panel.luau).
local i18n_cache = { lang = nil, table = nil }
local function load_lang_table(lang)
if i18n_cache.lang == lang and i18n_cache.table then
return i18n_cache.table
end
local merged = {}
local function merge_file(path)
local ok, content = pcall(noctalia.readFile, path)
if not ok or type(content) ~= "string" or content == "" then return end
local ok2, data = pcall(noctalia.json.decode, content)
if not ok2 or type(data) ~= "table" then return end
local function deep(dst, src)
for k, v in pairs(src) do
if type(v) == "table" and type(dst[k]) == "table" then
deep(dst[k], v)
else
dst[k] = v
end
end
end
deep(merged, data)
end
merge_file("translations/en.json")
if lang ~= "en" then
merge_file("translations/" .. lang .. ".json")
end
i18n_cache.lang = lang
i18n_cache.table = merged
return merged
end
local function tr(key, args)
local lang = noctalia.getConfig and noctalia.getConfig("language")
if type(lang) ~= "string" or lang == "" or lang == "auto" then
return noctalia.tr(key, args)
end
local node = load_lang_table(lang)
for part in string.gmatch(key, "[^%.]+") do
if type(node) ~= "table" then node = nil break end
node = node[part]
end
if type(node) ~= "string" then
return noctalia.tr(key, args)
end
if type(args) == "table" then
node = string.gsub(node, "{(%w+)}", function(name)
local v = args[name]
if v == nil then return "{" .. name .. "}" end
return tostring(v)
end)
end
return node
end
local function debug_log(msg)
if noctalia.getConfig and noctalia.getConfig("debug_logging") then
noctalia.log("9router-control: " .. tostring(msg))
end
end
-- Single-quote a value so it is treated as a literal argument by the shell,
-- neutralising any metacharacters in user-provided strings (data_dir, host).
local function shell_quote(value)
return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'"
end
local function get_server_host()
local v = noctalia.getConfig and noctalia.getConfig("server_host")
if type(v) == "string" and v ~= "" then return v end
return "127.0.0.1"
end
local function get_server_port()
local v = noctalia.getConfig and noctalia.getConfig("server_port")
if type(v) == "number" and v > 0 then return v end
return 20128
end
local function get_data_dir()
local v = noctalia.getConfig and noctalia.getConfig("data_dir")
if type(v) == "string" and v ~= "" then return v end
return "~/.9router"
end
-- ── state ────────────────────────────────────────────────────────────────────
local connection = { status = "offline", error = "" }
local combos = {}
local models = {}
local selected_combo_id = nil
local last_error = nil
local auth_token = nil -- JWT cookie captured from a successful login
local function publish()
noctalia.state.set("9router.connection", connection)
noctalia.state.set("9router.combos", combos)
noctalia.state.set("9router.models", models)
noctalia.state.set("9router.selected_combo", selected_combo_id)
noctalia.state.set("9router.last_error", last_error)
end
local function set_connection(status, err)
connection = { status = status, error = err or "" }
publish()
end
local function set_last_error(message, detail)
last_error = { message = message, detail = detail }
publish()
end
local function clear_last_error()
last_error = nil
publish()
end
-- ── HTTP ─────────────────────────────────────────────────────────────────────
local base_url = nil
local cli_token = nil
local token_loading = false
local function build_base_url()
return "http://" .. get_server_host() .. ":" .. tostring(get_server_port())
end
-- Compute the 9Router CLI token: sha256(rawMachineId + "9r-cli-auth" +
-- cliSecret).substring(0,16). Noctalia has no sha256 binding, so we shell out.
-- The command reads the machine-id and cli-secret files from the data dir.
local function compute_cli_token(callback)
if cli_token then
callback(cli_token)
return
end
if token_loading then
-- Wait for the in-flight computation to finish.
local tries = 0
local function poll()
tries = tries + 1
if cli_token then
callback(cli_token)
elseif tries > 50 then
callback(nil)
else
noctalia.runAsync("sleep 0.1 && echo done", function()
poll()
end)
end
end
poll()
return
end
token_loading = true
local dir = get_data_dir()
-- Expand a leading "~/" to the user's home directory here (not in the
-- shell) so the path is an absolute path with no shell variable expansion
-- and can be safely single-quoted.
if dir:sub(1, 2) == "~/" then
dir = (noctalia.getenv and noctalia.getenv("HOME") or "") .. dir:sub(2)
end
local cmd = "printf '%s' \"$(cat " .. shell_quote(dir .. "/machine-id") .. " 2>/dev/null)9r-cli-auth$(cat " .. shell_quote(dir .. "/auth/cli-secret") .. " 2>/dev/null)\" | sha256sum | cut -c1-16"
noctalia.runAsync(cmd, function(result)
token_loading = false
local token = ""
if result and result.exitCode == 0 and type(result.stdout) == "string" then
token = noctalia.string and noctalia.string.trim and noctalia.string.trim(result.stdout) or result.stdout
end
if token ~= "" then
cli_token = token
debug_log("computed CLI token")
end
callback(token ~= "" and token or nil)
end)
end
-- Perform a request with auth fallback. `with_token` is true on the retry pass.
-- Once a CLI token or login cookie is known it is attached to every request, so
-- the retry only fires when a 401 arrives before we have any auth material.
local function request(method, path, body, callback, with_token)
local url = (base_url or build_base_url()) .. path
local headers = { "Accept: application/json" }
local body_str = nil
if body then
body_str = noctalia.json.encode(body)
table.insert(headers, "Content-Type: application/json")
end
-- Attach the CLI token on every request once it is known.
if cli_token then
table.insert(headers, "x-9r-cli-token: " .. cli_token)
end
-- Attach the login cookie on every request once it is known.
if auth_token then
table.insert(headers, "Cookie: auth_token=" .. auth_token)
end
noctalia.http({
url = url,
method = method,
headers = headers,
body = body_str,
follow_redirects = false,
}, function(response)
if not response then
callback({ ok = false, status = 0, body = "" })
return
end
local status = response.status or 0
-- On 401: if we have not tried with auth yet, compute the CLI token (if
-- needed) and retry once. If we already have auth material but still got
-- a 401, the token/cookie is stale — surface it so the UI can re-login.
if status == 401 and not with_token then
if cli_token or auth_token then
request(method, path, body, callback, true)
else
compute_cli_token(function(token)
if token then
request(method, path, body, callback, true)
else
callback({ ok = false, status = status, body = response.body })
end
end)
end
return
end
callback({ ok = status >= 200 and status < 300, status = status, body = response.body })
end)
end
local function decode_body(body)
if type(body) ~= "string" or body == "" then return nil end
local ok, data = pcall(noctalia.json.decode, body)
if not ok or type(data) ~= "table" then return nil end
return data
end
-- ── combo operations ─────────────────────────────────────────────────────────
local function load_combos()
request("GET", "/api/combos", nil, function(resp)
if not resp.ok then
if resp.status == 0 then
set_connection("offline", tr("error.connection_failed"))
elseif resp.status == 401 then
set_connection("auth_required", tr("error.auth_required"))
else
set_connection("offline", tr("error.http_status", { status = tostring(resp.status) }))
end
return
end
local data = decode_body(resp.body)
if not data or type(data.combos) ~= "table" then
set_connection("offline", tr("error.bad_response"))
return
end
combos = data.combos
set_connection("online", "")
clear_last_error()
publish()
end)
end
local function refresh_combos()
load_combos()
end
local function load_models()
request("GET", "/api/models", nil, function(resp)
if not resp.ok then return end
local data = decode_body(resp.body)
if not data or type(data.models) ~= "table" then return end
models = data.models
publish()
end)
end
local function select_combo(id)
selected_combo_id = id
publish()
end
local function back_to_list()
selected_combo_id = nil
publish()
end
local function find_combo(id)
for _, c in ipairs(combos) do
if type(c) == "table" and c.id == id then return c end
end
return nil
end
local function reorder_model(payload)
-- payload: "combo_id:index:direction"
local id, idx_str, direction = payload:match("^([^:]+):(%d+):(%w+)$")
if not id or not idx_str or not direction then return end
local combo = find_combo(id)
if not combo or type(combo.models) ~= "table" then return end
local idx = tonumber(idx_str)
local models = {}
for _, m in ipairs(combo.models) do models[#models + 1] = m end
local n = #models
if idx < 1 or idx > n then return end
local target = direction == "up" and idx - 1 or idx + 1
if target < 1 or target > n then return end
models[idx], models[target] = models[target], models[idx]
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
if resp.ok then
refresh_combos()
else
set_last_error(tr("error.reorder_failed"), tr("error.http_status", { status = tostring(resp.status) }))
end
end)
end
local function move_model(payload)
-- payload: "combo_id:from_index:to_index" (1-based)
local id, from_str, to_str = payload:match("^([^:]+):(%d+):(%d+)$")
if not id or not from_str or not to_str then return end
local combo = find_combo(id)
if not combo or type(combo.models) ~= "table" then return end
local from = tonumber(from_str)
local to = tonumber(to_str)
local models = {}
for _, m in ipairs(combo.models) do models[#models + 1] = m end
local n = #models
if from < 1 or from > n or to < 1 or to > n or from == to then return end
local moved = table.remove(models, from)
table.insert(models, to, moved)
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
if resp.ok then
refresh_combos()
else
set_last_error(tr("error.reorder_failed"), tr("error.http_status", { status = tostring(resp.status) }))
end
end)
end
local function add_model(payload)
-- payload: "combo_id:model_fullModel" (provider/model)
local id, model = payload:match("^([^:]+):(.+)$")
if not id or not model or model == "" then return end
local combo = find_combo(id)
if not combo or type(combo.models) ~= "table" then return end
local models = {}
for _, m in ipairs(combo.models) do models[#models + 1] = m end
for _, m in ipairs(models) do
if m == model then return end -- already present
end
models[#models + 1] = model
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
if resp.ok then
refresh_combos()
else
set_last_error(tr("error.add_model_failed"), tr("error.http_status", { status = tostring(resp.status) }))
end
end)
end
local function remove_model(payload)
-- payload: "combo_id:index" (1-based)
local id, idx_str = payload:match("^([^:]+):(%d+)$")
if not id or not idx_str then return end
local combo = find_combo(id)
if not combo or type(combo.models) ~= "table" then return end
local idx = tonumber(idx_str)
local models = {}
for _, m in ipairs(combo.models) do models[#models + 1] = m end
if idx < 1 or idx > #models then return end
table.remove(models, idx)
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
if resp.ok then
refresh_combos()
else
set_last_error(tr("error.remove_model_failed"), tr("error.http_status", { status = tostring(resp.status) }))
end
end)
end
local function create_combo(payload)
-- payload: JSON string { name, models, kind }
local ok, data = pcall(noctalia.json.decode, payload)
if not ok or type(data) ~= "table" then return end
local name = type(data.name) == "string" and data.name or ""
local models = type(data.models) == "table" and data.models or {}
local kind = type(data.kind) == "string" and data.kind or nil
if name == "" or #models == 0 then
set_last_error(tr("error.create_failed"), tr("error.need_name_models"))
return
end
request("POST", "/api/combos", { name = name, models = models, kind = kind }, function(resp)
if resp.ok then
refresh_combos()
else
local data2 = decode_body(resp.body)
local detail = (type(data2) == "table" and type(data2.error) == "string" and data2.error)
or tr("error.http_status", { status = tostring(resp.status) })
set_last_error(tr("error.create_failed"), detail)
end
end)
end
local function delete_combo(id)
request("DELETE", "/api/combos/" .. id, nil, function(resp)
if resp.ok then
if selected_combo_id == id then selected_combo_id = nil end
refresh_combos()
else
set_last_error(tr("error.delete_failed"), tr("error.http_status", { status = tostring(resp.status) }))
end
end)
end
local function rename_combo(payload)
-- payload: "combo_id:new_name"
local id, name = payload:match("^([^:]+):(.+)$")
if not id or not name or name == "" then return end
request("PUT", "/api/combos/" .. id, { name = name }, function(resp)
if resp.ok then
refresh_combos()
else
local data = decode_body(resp.body)
local detail = (type(data) == "table" and type(data.error) == "string" and data.error)
or tr("error.http_status", { status = tostring(resp.status) })
set_last_error(tr("error.rename_failed"), detail)
end
end)
end
local function set_kind(payload)
-- payload: "combo_id:kind"
local id, kind = payload:match("^([^:]+):(.+)$")
if not id or not kind then return end
local value = (kind == "none" or kind == "") and nil or kind
request("PUT", "/api/combos/" .. id, { kind = value }, function(resp)
if resp.ok then
refresh_combos()
else
set_last_error(tr("error.kind_failed"), tr("error.http_status", { status = tostring(resp.status) }))
end
end)
end
-- ── auth (login / logout) ────────────────────────────────────────────────────
-- Log in against the dashboard and capture the auth_token cookie. Noctalia's
-- http binding does not expose response headers, so we shell out to curl with
-- `-i` to read the Set-Cookie header, then reuse the cookie on later requests.
local function login(payload)
if type(payload) ~= "string" or payload == "" then
set_last_error(tr("error.login_failed"), tr("error.need_password"))
return
end
local url = (base_url or build_base_url()) .. "/api/auth/login"
-- Escape the password for the JSON body, then for the shell single-quote.
local json_password = payload:gsub("\\", "\\\\"):gsub('"', '\\"')
local body = '{"password":"' .. json_password .. '"}'
local cmd = "curl -s -i -X POST " .. shell_quote(url) .. " -H 'Content-Type: application/json' -d " .. shell_quote(body)
debug_log("login: posting to /api/auth/login")
noctalia.runAsync(cmd, function(result)
if not result or result.exitCode ~= 0 then
set_last_error(tr("error.login_failed"), tr("error.connection_failed"))
return
end
local stdout = result.stdout or ""
local cookie = stdout:match("auth_token=([^;%s]+)")
if not cookie then
-- No cookie set — likely a bad password. Surface the server error.
local body_part = stdout:match("\r?\n\r?\n(.*)$") or stdout
local ok, data = pcall(noctalia.json.decode, body_part)
local detail = (ok and type(data) == "table" and type(data.error) == "string" and data.error)
or tr("error.login_failed")
set_last_error(tr("error.login_failed"), detail)
return
end
auth_token = cookie
debug_log("login: authenticated")
clear_last_error()
set_connection("online", "")
load_combos()
end)
end
local function logout()
auth_token = nil
cli_token = nil
set_connection("auth_required", tr("error.auth_required"))
end
-- ── ipc handling ─────────────────────────────────────────────────────────────
function onIpc(event, payload)
debug_log("IPC: " .. tostring(event))
if event == "refresh" then
refresh_combos()
elseif event == "select_combo" then
if type(payload) == "string" then select_combo(payload) end
elseif event == "back_to_list" then
back_to_list()
elseif event == "reorder_model" then
if type(payload) == "string" then reorder_model(payload) end
elseif event == "move_model" then
if type(payload) == "string" then move_model(payload) end
elseif event == "add_model" then
if type(payload) == "string" then add_model(payload) end
elseif event == "remove_model" then
if type(payload) == "string" then remove_model(payload) end
elseif event == "create_combo" then
if type(payload) == "string" then create_combo(payload) end
elseif event == "delete_combo" then
if type(payload) == "string" then delete_combo(payload) end
elseif event == "rename_combo" then
if type(payload) == "string" then rename_combo(payload) end
elseif event == "set_kind" then
if type(payload) == "string" then set_kind(payload) end
elseif event == "login" then
if type(payload) == "string" then login(payload) end
elseif event == "logout" then
logout()
elseif event == "clear_error" then
clear_last_error()
end
end
-- ── init ─────────────────────────────────────────────────────────────────────
function onOpen()
base_url = build_base_url()
publish()
load_combos()
load_models()
end
function onExit()
end
-- Start on load
onOpen()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

-120
View File
@@ -1,120 +0,0 @@
{
"add_model": {
"back": "Back to combo",
"empty": "No models available.",
"no_match": "No models match your search.",
"search": "Search models…",
"title": "Add Model"
},
"create": {
"back": "Back",
"cancel": "Cancel",
"kind_label": "Kind",
"models_hint": "One model per line, in fallback order (e.g. cc/claude-opus-4-7).",
"models_label": "Models",
"models_placeholder": "cc/claude-opus-4-7\nglm/glm-5.1",
"name_label": "Name",
"name_placeholder": "e.g. my-fallback",
"submit": "Create",
"title": "New Combo"
},
"detail": {
"add_model": "Add model",
"back": "Back to combos",
"delete": "Delete combo",
"delete_confirm": "Delete this combo?",
"delete_no": "Cancel",
"delete_yes": "Yes, delete",
"drag_hint": "Drag a model to reorder",
"kind_label": "Kind",
"models_count": "{count} model(s)",
"models_title": "Model Chain",
"move_down": "Move down",
"move_up": "Move up",
"no_models": "This combo has no models yet.",
"refresh": "Refresh",
"remove_model": "Remove model",
"rename": "Rename",
"rename_cancel": "Cancel",
"rename_placeholder": "New name…",
"rename_save": "Save"
},
"error": {
"add_model_failed": "Failed to add model",
"auth_required": "Authentication required",
"bad_response": "Unexpected response from 9Router",
"connection_failed": "Could not connect to 9Router",
"create_failed": "Failed to create combo",
"delete_failed": "Failed to delete combo",
"dismiss": "Dismiss",
"http_status": "HTTP {status}",
"invalid_name": "Name can only contain letters, numbers, -, _ and .",
"kind_failed": "Failed to update kind",
"login_failed": "Login failed",
"need_name_models": "A name and at least one model are required.",
"need_password": "Please enter your password.",
"remove_model_failed": "Failed to remove model",
"rename_failed": "Failed to rename combo",
"reorder_failed": "Failed to reorder models"
},
"kind": {
"fallback": "Fallback",
"fusion": "Fusion",
"none": "None",
"round_robin": "Round-robin"
},
"list": {
"delete": "Delete combo",
"delete_cancel": "Cancel",
"delete_confirm": "Confirm delete",
"empty": "No combos yet. Create one to get started.",
"kind": "Kind: {kind}",
"new_combo": "New Combo",
"no_match": "No combos match your search.",
"open": "Open combo",
"refresh": "Refresh",
"search": "Search combos…",
"title": "9Router — Combos"
},
"login": {
"hint": "The dashboard requires authentication. Enter your 9Router password to continue.",
"password_placeholder": "Password…",
"submit": "Log in",
"title": "9Router Login"
},
"settings": {
"data_dir": {
"description": "9Router data directory (used to compute the CLI token when the dashboard requires login). Default: ~/.9router.",
"label": "Data Directory"
},
"debug_logging": {
"description": "Print debug messages to the Noctalia log.",
"label": "Debug Logging"
},
"language": {
"description": "Language for this plugin's UI. 'Auto' follows the Noctalia shell language.",
"label": "Language",
"options": {
"auto": "Auto (follow shell)",
"en": "English",
"vi": "Tiếng Việt"
}
},
"server_host": {
"description": "Hostname of the 9Router dashboard. Default: 127.0.0.1.",
"label": "Server Host"
},
"server_port": {
"description": "Port of the 9Router dashboard. Default: 20128.",
"label": "Server Port"
}
},
"state": {
"combos": "{count} combo(s)",
"tip": {
"auth_required": "9Router: login required",
"offline": "9Router: offline",
"online": "9Router: connected"
}
}
}
-120
View File
@@ -1,120 +0,0 @@
{
"add_model": {
"back": "Quay lại combo",
"empty": "Không có model nào.",
"no_match": "Không có model khớp tìm kiếm.",
"search": "Tìm model…",
"title": "Thêm Model"
},
"create": {
"back": "Quay lại",
"cancel": "Hủy",
"kind_label": "Loại",
"models_hint": "Mỗi model một dòng, theo thứ tự fallback (vd: cc/claude-opus-4-7).",
"models_label": "Models",
"models_placeholder": "cc/claude-opus-4-7\nglm/glm-5.1",
"name_label": "Tên",
"name_placeholder": "vd: my-fallback",
"submit": "Tạo",
"title": "Combo mới"
},
"detail": {
"add_model": "Thêm model",
"back": "Quay lại danh sách",
"delete": "Xóa combo",
"delete_confirm": "Xóa combo này?",
"delete_no": "Hủy",
"delete_yes": "Có, xóa",
"drag_hint": "Kéo model để sắp xếp lại",
"kind_label": "Loại",
"models_count": "{count} model",
"models_title": "Chuỗi Model",
"move_down": "Di chuyển xuống",
"move_up": "Di chuyển lên",
"no_models": "Combo này chưa có model nào.",
"refresh": "Làm mới",
"remove_model": "Xóa model",
"rename": "Đổi tên",
"rename_cancel": "Hủy",
"rename_placeholder": "Tên mới…",
"rename_save": "Lưu"
},
"error": {
"add_model_failed": "Không thể thêm model",
"auth_required": "Cần xác thực",
"bad_response": "Phản hồi không mong đợi từ 9Router",
"connection_failed": "Không kết nối được 9Router",
"create_failed": "Không thể tạo combo",
"delete_failed": "Không xóa được combo",
"dismiss": "Đóng",
"http_status": "HTTP {status}",
"invalid_name": "Tên chỉ được chứa chữ, số, -, _ và .",
"kind_failed": "Không cập nhật được loại",
"login_failed": "Đăng nhập thất bại",
"need_name_models": "Cần tên và ít nhất một model.",
"need_password": "Vui lòng nhập mật khẩu.",
"remove_model_failed": "Không thể xóa model",
"rename_failed": "Không đổi tên được combo",
"reorder_failed": "Không thể sắp xếp lại model"
},
"kind": {
"fallback": "Fallback",
"fusion": "Fusion",
"none": "Không",
"round_robin": "Round-robin"
},
"list": {
"delete": "Xóa combo",
"delete_cancel": "Hủy",
"delete_confirm": "Xác nhận xóa",
"empty": "Chưa có combo nào. Tạo một combo để bắt đầu.",
"kind": "Loại: {kind}",
"new_combo": "Combo mới",
"no_match": "Không có combo nào khớp tìm kiếm.",
"open": "Mở combo",
"refresh": "Làm mới",
"search": "Tìm combo…",
"title": "9Router — Combos"
},
"login": {
"hint": "Dashboard yêu cầu xác thực. Nhập mật khẩu 9Router của bạn để tiếp tục.",
"password_placeholder": "Mật khẩu…",
"submit": "Đăng nhập",
"title": "Đăng nhập 9Router"
},
"settings": {
"data_dir": {
"description": "Thư mục dữ liệu 9Router (dùng để tính CLI token khi dashboard yêu cầu đăng nhập). Mặc định: ~/.9router.",
"label": "Thư mục dữ liệu"
},
"debug_logging": {
"description": "In thông điệp gỡ lỗi vào log Noctalia.",
"label": "Ghi log gỡ lỗi"
},
"language": {
"description": "Ngôn ngữ giao diện của plugin. 'Tự động' theo ngôn ngữ shell Noctalia.",
"label": "Ngôn ngữ",
"options": {
"auto": "Tự động (theo shell)",
"en": "English",
"vi": "Tiếng Việt"
}
},
"server_host": {
"description": "Hostname của dashboard 9Router. Mặc định: 127.0.0.1.",
"label": "Máy chủ"
},
"server_port": {
"description": "Cổng của dashboard 9Router. Mặc định: 20128.",
"label": "Cổng"
}
},
"state": {
"combos": "{count} combo",
"tip": {
"auth_required": "9Router: cần đăng nhập",
"offline": "9Router: ngoại tuyến",
"online": "9Router: đã kết nối"
}
}
}
-165
View File
@@ -1,165 +0,0 @@
-- 9Router Control bar widget — a pure subscriber of 9router.connection state.
-- Shows connection status as an accent-colored glyph with a breathing glow,
-- plus the number of combos when connected. Click opens the panel.
local STATE_KEY = "9router.connection"
local COMBOS_KEY = "9router.combos"
local GLYPH = {
online = "route",
offline = "route-off",
auth_required = "lock",
}
local COLOR = {
online = "primary",
offline = "error",
auth_required = "error",
}
local BREATH_PERIOD = 6.0
local BREATH_FLOOR = 0.45
local BREATH_CEIL = 1.0
local TICK_MS = 100
local snap = { status = "offline" }
local combo_count = 0
local phase = BREATH_PERIOD / 2
-- Translation with an optional per-plugin language override (mirrors panel.luau).
local i18n_cache = { lang = nil, table = nil }
local function load_lang_table(lang)
if i18n_cache.lang == lang and i18n_cache.table then
return i18n_cache.table
end
local merged = {}
local function merge_file(path)
local ok, content = pcall(noctalia.readFile, path)
if not ok or type(content) ~= "string" or content == "" then return end
local ok2, data = pcall(noctalia.json.decode, content)
if not ok2 or type(data) ~= "table" then return end
local function deep(dst, src)
for k, v in pairs(src) do
if type(v) == "table" and type(dst[k]) == "table" then
deep(dst[k], v)
else
dst[k] = v
end
end
end
deep(merged, data)
end
merge_file("translations/en.json")
if lang ~= "en" then
merge_file("translations/" .. lang .. ".json")
end
i18n_cache.lang = lang
i18n_cache.table = merged
return merged
end
local function tr(key, args)
local lang = noctalia.getConfig and noctalia.getConfig("language")
if type(lang) ~= "string" or lang == "" or lang == "auto" then
return noctalia.tr(key, args)
end
local node = load_lang_table(lang)
for part in string.gmatch(key, "[^%.]+") do
if type(node) ~= "table" then node = nil break end
node = node[part]
end
if type(node) ~= "string" then
return noctalia.tr(key, args)
end
if type(args) == "table" then
node = string.gsub(node, "{(%w+)}", function(name)
local v = args[name]
if v == nil then return "{" .. name .. "}" end
return tostring(v)
end)
end
return node
end
-- Scale an RRGGBB hex toward black by factor b, returning "#RRGGBB"
local function dim(hex, b)
if type(hex) ~= "string" or #hex ~= 6 then return "#808080" end
local function ch(i)
local x = math.floor((tonumber(hex:sub(i, i + 1), 16) or 0) * b + 0.5)
if x < 0 then x = 0 elseif x > 255 then x = 255 end
return x
end
return string.format("#%02X%02X%02X", ch(1), ch(3), ch(5))
end
local ACCENT_RGB = {
primary = "B4FF00",
secondary = "EAFF00",
error = "FF4D1F",
}
local function level_for(period)
local t = phase % period
local s = 0.5 - 0.5 * math.cos((t / period) * 2 * math.pi)
return BREATH_FLOOR + (BREATH_CEIL - BREATH_FLOOR) * s
end
local function paint()
local g = GLYPH[snap.status] or GLYPH.offline
local c = COLOR[snap.status] or COLOR.offline
barWidget.setGlyph(g)
barWidget.setGlyphColor(dim(ACCENT_RGB[c] or ACCENT_RGB.secondary, level_for(BREATH_PERIOD)))
end
local function render()
paint()
local tip = tr("state.tip." .. tostring(snap.status)) or tr("state.tip.offline")
if snap.error and snap.error ~= "" then
tip = tip .. "\n" .. snap.error
end
if snap.status == "online" then
tip = tip .. "\n" .. tr("state.combos", { count = combo_count })
end
barWidget.setTooltip(tip)
end
local function apply(s)
if type(s) ~= "table" then return end
local status = type(s.status) == "string" and s.status or "offline"
local changed = (status ~= snap.status)
snap = { status = status, error = s.error }
if changed then
phase = BREATH_PERIOD / 2
end
render()
end
local function apply_combos(list)
if type(list) == "table" then
combo_count = #list
end
render()
end
function onClick()
noctalia.togglePanel("weinguyen/9router-control:panel")
end
function onRightClick()
noctalia.runAsync("noctalia msg plugin 'weinguyen/9router-control:service' all refresh")
end
function update()
noctalia.setUpdateInterval(TICK_MS)
phase = phase + TICK_MS / 1000
if phase > 1e6 then phase = 0 end
paint()
end
noctalia.state.watch(STATE_KEY, apply)
noctalia.state.watch(COMBOS_KEY, apply_combos)
noctalia.setUpdateInterval(TICK_MS)
apply(noctalia.state.get(STATE_KEY))
apply_combos(noctalia.state.get(COMBOS_KEY))
render()
-80
View File
@@ -1,80 +0,0 @@
# AniList (UNOFFICIAL)
Unofficial Noctalia plugin to browse and update your AniList anime and manga lists from the bar. Open the panel to see what you are watching, planning, or have finished, then increment or decrement episode/chapter progress without opening the website.
## Plugin
| Field | Value |
| --- | --- |
| ID | `cleboost/anilist` |
| Entries | Bar widget: `tracker`; panel: `library`; service: `api` |
| Launcher Prefix | — |
## Requirements
1. Create an AniList API application at [anilist.co/settings/developer](https://anilist.co/settings/developer).
2. Set the application **Redirect URL** to `http://127.0.0.1:7823/callback`.
3. Copy the **Client ID** and **Client secret** into the plugin settings.
4. `python3` must be available on `PATH` (used for the temporary localhost login helper).
5. `xdg-open` must be available on `PATH` (used to open AniList media pages from the panel).
6. `zenity` or `kdialog` is required only if you want to download cover images from the panel preview.
Network access: GraphQL and OAuth on `anilist.co`, plus cover image URLs returned by the API (typically AniList CDN hosts such as `s4.anilist.co`).
## Usage
Add the **AniList (UNOFFICIAL)** bar widget (`tracker`) from Settings, then click it to open the library panel.
```sh
noctalia msg panel-toggle cleboost/anilist:library
```
First launch:
1. Open plugin settings and paste your AniList **client ID** and **client secret**.
2. Open the panel and click **Connect with AniList**.
3. Approve the app in your browser.
4. When the browser shows “Connected to AniList”, return to Noctalia — your lists load automatically.
Inside the panel:
- Switch between **Anime** and **Manga** tabs.
- Filter by list status. Anime uses **Watching**, **Planning**, and so on; manga uses **Reading** instead of Watching and **Plan to Read** instead of Planning.
- The **Watching** / **Reading** filter sorts entries by progress (highest first). Other filters sort A–Z.
- Use **Reload list** to refresh the active tab without logging in again.
- Use the settings button to open this plugin's settings.
- Click a cover image to open a larger preview. From there you can download the cover or close the preview.
- Use **−** / **+** to go back or forward one episode/chapter.
- Use the check button to mark an entry completed.
- Use the external-link button to open the entry on AniList.
Right-click the bar widget to open the AniList website.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `client_id` | `string` | `""` | Your AniList OAuth client ID (required for login). |
| `client_secret` | `string` | `""` | Your AniList OAuth client secret (required for login). |
| `access_token` | `string` | `""` | Optional bearer token. If empty, the token saved after browser login is used. |
| `glyph` | `glyph` | `device-tv` | Bar widget icon. |
| `count_mode` | `select` | `current` | Which count to show in the bar widget: `current` (anime in progress), `completed`, `total`, `in_progress` (anime + manga), or `planning`. |
## IPC
```sh
noctalia msg plugin cleboost/anilist:api all refresh
noctalia msg plugin cleboost/anilist:api all logout
noctalia msg plugin cleboost/anilist:api all login "<jwt-access-token>"
```
## Notes
- This is an unofficial third-party plugin and is not affiliated with or endorsed by AniList.
- Login starts a temporary localhost server on port `7823` only for the duration of the OAuth flow. The helper also opens your default browser for authorization.
- OAuth login briefly writes temporary credential/result files in the plugin data directory; they are removed when login finishes.
- Access tokens are stored in the plugin data directory (`token.json`) after a successful login.
- Cover images are cached under the plugin data directory (`covers/v2/`).
- Downloading a cover from the preview writes the image to a path you choose (for example `~/Downloads/`).
- AniList tokens last about one year; connect again when they expire.
- Incrementing progress on a **Planning** / **Plan to Read** entry moves it to **Watching** / **Reading**. Reaching the last episode/chapter marks it **Completed**.
-740
View File
@@ -1,740 +0,0 @@
--!nonstrict
-- AniList (UNOFFICIAL) library panel: login, filters, scrollable list, episode/chapter controls.
local COVER_WIDTH = 44
local COVER_HEIGHT = 62
local PREVIEW_WIDTH = 280
local PREVIEW_HEIGHT = math.floor(PREVIEW_WIDTH * COVER_HEIGHT / COVER_WIDTH)
local INITIAL_VISIBLE_ROWS = 12
local ROW_BATCH_SIZE = 10
local MAX_VISIBLE_ROWS = 200
local LIST_LOAD_INTERVAL_MS = 80
local LIST_IDLE_INTERVAL_MS = 1000
local lastCoverPriorityKey = ""
local visibleRowLimit = INITIAL_VISIBLE_ROWS
local snapshot = noctalia.state.get("anilist_snapshot") or {
revision = 0,
oauthLoading = false,
loading = false,
refreshing = false,
busy = false,
error = "",
viewer = nil,
anime = {},
manga = {},
loadProgress = nil,
}
local mediaTab = "ANIME"
local statusFilter = "CURRENT"
local dirty = true
local render
local coverPreview = nil
local coverDownloadBusy = false
local function coverLoadingSet()
local set = {}
for _, id in ipairs(noctalia.state.get("anilist_cover_loading") or {}) do
local mediaId = tonumber(id)
if mediaId then
set[mediaId] = true
end
end
return set
end
local function tr(key, subst)
return noctalia.tr("panel." .. key, subst)
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function sendCommand(action, values)
local command = { action = action }
if type(values) == "table" then
for k, v in pairs(values) do
command[k] = v
end
end
noctalia.state.set("anilist_command", command)
end
local function resetListView()
visibleRowLimit = INITIAL_VISIBLE_ROWS
lastCoverPriorityKey = ""
end
local function listRowCap(rows)
return math.min(#rows, MAX_VISIBLE_ROWS)
end
local function syncListLoadInterval(rows)
if visibleRowLimit < listRowCap(rows) then
noctalia.setUpdateInterval(LIST_LOAD_INTERVAL_MS)
else
noctalia.setUpdateInterval(LIST_IDLE_INTERVAL_MS)
end
end
local function growVisibleRows(rows)
local cap = listRowCap(rows)
if visibleRowLimit >= cap then
return false
end
visibleRowLimit = math.min(visibleRowLimit + ROW_BATCH_SIZE, cap)
return true
end
local function titleCompare(a, b)
return (a.title or ""):lower() < (b.title or ""):lower()
end
local function sortEntries(entries, filter)
local sorted = {}
for _, entry in ipairs(entries) do
table.insert(sorted, entry)
end
if filter == "CURRENT" then
table.sort(sorted, function(a, b)
local progressA = a.progress or 0
local progressB = b.progress or 0
if progressA ~= progressB then
return progressA > progressB
end
return titleCompare(a, b)
end)
else
table.sort(sorted, titleCompare)
end
return sorted
end
local function entriesForView()
local source = if mediaTab == "MANGA" then snapshot.manga else snapshot.anime
if statusFilter == "ALL" then
return sortEntries(source, statusFilter)
end
local filtered = {}
for _, entry in ipairs(source) do
if entry.status == statusFilter then
table.insert(filtered, entry)
end
end
return sortEntries(filtered, statusFilter)
end
local function progressLabel(entry)
local current = entry.progress or 0
local total = entry.total
if entry.mediaType == "MANGA" then
if total then
return tr("progress_manga", { current = current, total = total })
end
return tr("progress_manga_unknown", { current = current })
end
if total then
return tr("progress_anime", { current = current, total = total })
end
return tr("progress_anime_unknown", { current = current })
end
local STAR_GLYPH_SIZE = 15
local function renderScoreStars(score)
local value = tonumber(score)
if not value or value <= 0 then
return nil
end
local filled = math.max(1, math.min(5, math.floor(value / 20 + 0.5)))
local stars = {}
for index = 1, 5 do
table.insert(stars, ui.glyph({
name = if index <= filled then "star-filled" else "star",
size = STAR_GLYPH_SIZE,
color = "on_surface_variant",
}))
end
return ui.row({ gap = 1, align = "center" }, stars)
end
local function subtitleTextFor(entry)
local parts = { progressLabel(entry) }
if entry.nextEpisode and entry.mediaType == "ANIME" then
table.insert(parts, tr("next_episode", { episode = entry.nextEpisode }))
end
return table.concat(parts, " · ")
end
local function renderTitle(entry)
return ui.label({
text = entry.title or "?",
fontWeight = "medium",
maxLines = 2,
})
end
local function renderSubtitle(entry)
return ui.label({
text = subtitleTextFor(entry),
fontSize = 12,
color = "on_surface_variant",
maxLines = 2,
})
end
local function syncVisibleCoverPriority(rows)
local ids = {}
local cap = math.min(listRowCap(rows), visibleRowLimit)
for index = 1, cap do
local entry = rows[index]
if entry and entry.mediaId then
table.insert(ids, entry.mediaId)
end
end
local key = table.concat(ids, ",")
if key == lastCoverPriorityKey then
return
end
lastCoverPriorityKey = key
sendCommand("prioritize_covers", { mediaIds = ids })
end
local function filterButton(key, label, filter)
local active = statusFilter == filter
return ui.button({
key = "filter-" .. key,
text = label,
variant = if active then "primary" else "ghost",
onClick = function()
statusFilter = filter
resetListView()
dirty = true
render()
end,
})
end
local function settingsButton()
return ui.button({
glyph = "settings",
variant = "ghost",
tooltip = tr("settings"),
onClick = noctalia.openSettings,
})
end
local function loadProgressText()
local progress = snapshot.loadProgress
if type(progress) ~= "table" then
return tr("loading")
end
return tr("loading_progress", {
anime = progress.animeLoaded or 0,
manga = progress.mangaLoaded or 0,
})
end
local function renderLogin()
local children = {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.label({ text = tr("login_title"), fontSize = 18, fontWeight = "bold", flexGrow = 1 }),
settingsButton(),
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("close"), onClick = "onClosePanel" }),
}),
ui.label({ text = tr("login_help"), fontSize = 12, color = "on_surface_variant", maxLines = 8 }),
}
if snapshot.oauthLoading then
table.insert(children, ui.label({ text = tr("login_waiting"), color = "primary", fontSize = 13 }))
elseif snapshot.loading then
table.insert(children, ui.label({ text = loadProgressText(), color = "primary", fontSize = 13 }))
else
table.insert(children, ui.button({
text = tr("connect"),
variant = "primary",
onClick = "onConnect",
flexGrow = 1,
}))
end
if snapshot.error ~= "" then
table.insert(children, ui.label({ text = tr("error", { message = snapshot.error }), color = "error", fontSize = 12 }))
end
panel.render(ui.column({ gap = 12, padding = 16 }, children))
end
local function coverFilename(title)
local name = (title or "cover"):gsub("[^%w%-%._ ]", ""):gsub("%s+", " ")
name = noctalia.string.trim(name)
if name == "" then
name = "cover"
end
if not name:lower():match("%.jpe?g$") then
name = name .. ".jpg"
end
return name
end
local function ensureJpegExtension(path)
path = noctalia.string.trim(path or "")
if path == "" then
return path
end
if not path:lower():match("%.jpe?g$") then
return path .. ".jpg"
end
return path
end
local function pickCoverSavePath(defaultName, callback)
local suggested = noctalia.expandPath("~/Downloads/" .. defaultName)
local cmd
if noctalia.commandExists("zenity") then
cmd = "zenity --file-selection --save --confirm-overwrite --filename="
.. shellQuote(suggested)
elseif noctalia.commandExists("kdialog") then
cmd = "kdialog --getsavefilename " .. shellQuote(suggested) .. " 'Images (*.jpg)'"
else
callback(nil, tr("download_cover_unavailable"))
return
end
noctalia.runAsync(cmd, function(result)
local path = (result.stdout or ""):gsub("^%s+", ""):gsub("%s+$", "")
if result.exitCode ~= 0 or path == "" then
callback(nil, nil)
return
end
callback(ensureJpegExtension(path), nil)
end)
end
local function writeCoverToPath(coverPath, coverUrl, destPath, callback)
if coverPath and noctalia.fileExists(coverPath) then
local data = noctalia.readFile(coverPath)
if type(data) == "string" and data ~= "" then
local ok = noctalia.writeFile(destPath, data)
callback(ok, ok and nil or tr("download_cover_failed"))
return
end
end
if type(coverUrl) == "string" and coverUrl ~= "" then
local started = noctalia.download(coverUrl, destPath, function(ok)
callback(ok == true, ok and nil or tr("download_cover_failed"))
end)
if not started then
callback(false, tr("download_cover_failed"))
end
return
end
callback(false, tr("download_cover_failed"))
end
local function startCoverDownload(preview)
if coverDownloadBusy or type(preview) ~= "table" then
return
end
coverDownloadBusy = true
dirty = true
render()
pickCoverSavePath(coverFilename(preview.title), function(destPath, pickError)
if pickError then
coverDownloadBusy = false
noctalia.notifyError(tr("title"), pickError)
dirty = true
render()
return
end
if not destPath then
coverDownloadBusy = false
dirty = true
render()
return
end
writeCoverToPath(preview.coverPath, preview.coverUrl, destPath, function(ok, saveError)
coverDownloadBusy = false
if ok then
noctalia.notify(tr("title"), tr("download_cover_success", { path = destPath }))
elseif saveError then
noctalia.notifyError(tr("title"), saveError)
end
dirty = true
render()
end)
end)
end
local function openCoverPreview(entry)
if not entry.coverPath then
return
end
coverPreview = {
mediaId = entry.mediaId,
title = entry.title or "?",
coverPath = entry.coverPath,
coverUrl = entry.coverUrl,
}
dirty = true
render()
end
local function renderCoverPreview()
return ui.column({ gap = 12, padding = 16, flexGrow = 1 }, {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.label({
text = coverPreview.title or "?",
fontSize = 16,
fontWeight = "bold",
flexGrow = 1,
maxLines = 2,
}),
ui.row({ gap = 4, align = "center" }, {
ui.button({
glyph = "download",
variant = "ghost",
tooltip = tr("download_cover"),
disabled = coverDownloadBusy,
onClick = coverDownloadBusy and "onNoop" or "onDownloadCoverPreview",
}),
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("close_preview"), onClick = "onCloseCoverPreview" }),
}),
}),
ui.column({ flexGrow = 1, align = "center", justify = "center" }, {
ui.image({
path = coverPreview.coverPath,
width = PREVIEW_WIDTH,
height = PREVIEW_HEIGHT,
radius = 10,
fit = "cover",
}),
}),
})
end
local function renderCover(entry, loadingSet)
local mediaId = entry.mediaId
local coverPath = entry.coverPath
if coverPath then
return ui.image({
key = "cover-" .. tostring(mediaId),
path = coverPath,
width = COVER_WIDTH,
height = COVER_HEIGHT,
radius = 6,
fit = "cover",
tooltip = tr("cover_preview"),
onClick = function()
openCoverPreview(entry)
end,
})
end
local children = {}
if loadingSet[entry.mediaId] then
table.insert(children, ui.glyph({
name = "loader",
size = 18,
color = "on_surface",
}))
end
return ui.column({
width = COVER_WIDTH,
height = COVER_HEIGHT,
radius = 6,
fill = entry.coverColor or "#334155",
align = "center",
justify = "center",
}, children)
end
local function renderEntryRow(entry, index, loadingSet)
if index > visibleRowLimit then
return nil
end
local mediaId = entry.mediaId
local canDecrement = (entry.progress or 0) > 0 and not snapshot.busy
local canIncrement = not snapshot.busy
local isComplete = entry.status == "COMPLETED"
local textChildren = {
renderTitle(entry),
renderSubtitle(entry),
}
local stars = renderScoreStars(entry.score)
local mainChildren = {
ui.column({ gap = 2, flexGrow = 1, justify = "center" }, textChildren),
}
if stars then
table.insert(mainChildren, stars)
end
local rowChildren = {
renderCover(entry, loadingSet),
ui.row({
height = COVER_HEIGHT,
gap = 8,
flexGrow = 1,
align = "center",
}, mainChildren),
}
table.insert(rowChildren, ui.button({
glyph = "minus",
variant = "ghost",
tooltip = tr("decrement"),
onClick = canDecrement and function()
sendCommand("decrement", { mediaId = mediaId })
end or "onNoop",
}))
table.insert(rowChildren, ui.button({
glyph = "plus",
variant = "ghost",
tooltip = tr("increment"),
onClick = canIncrement and function()
sendCommand("increment", { mediaId = mediaId })
end or "onNoop",
}))
table.insert(rowChildren, ui.button({
glyph = if isComplete then "check" else "circle-check",
variant = if isComplete then "primary" else "ghost",
tooltip = tr("mark_complete"),
onClick = snapshot.busy and "onNoop" or function()
sendCommand("complete", { mediaId = mediaId })
end,
}))
table.insert(rowChildren, ui.button({
glyph = "external-link",
variant = "ghost",
tooltip = tr("open_anilist"),
onClick = function()
sendCommand("open_media", { mediaId = mediaId, mediaType = entry.mediaType })
end,
}))
return ui.row({
key = "entry-" .. tostring(mediaId),
gap = 10,
align = "center",
padding = { top = 8, bottom = 8 },
}, rowChildren)
end
local function renderLibrary()
local rows = entriesForView()
local loadingSet = coverLoadingSet()
local children = {}
local headerChildren = {
ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }),
}
if snapshot.viewer and snapshot.viewer.name then
table.insert(headerChildren, ui.label({
text = tr("logged_in_as", { name = snapshot.viewer.name }),
fontSize = 12,
color = "on_surface_variant",
}))
end
table.insert(children, ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.column({ gap = 2, flexGrow = 1 }, headerChildren),
settingsButton(),
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("refresh"), onClick = "onRefresh" }),
ui.button({ glyph = "logout", variant = "ghost", tooltip = tr("logout"), onClick = "onLogout" }),
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("close"), onClick = "onClosePanel" }),
}))
table.insert(children, ui.row({ gap = 6 }, {
ui.button({
text = tr("tab_anime"),
variant = if mediaTab == "ANIME" then "primary" else "ghost",
onClick = "onTabAnime",
}),
ui.button({
text = tr("tab_manga"),
variant = if mediaTab == "MANGA" then "primary" else "ghost",
onClick = "onTabManga",
}),
}))
table.insert(children, ui.scroll({ horizontal = true }, {
ui.row({ gap = 6 }, {
filterButton(
"current",
if mediaTab == "MANGA" then tr("filter_reading") else tr("filter_current"),
"CURRENT"
),
filterButton(
"planning",
if mediaTab == "MANGA" then tr("filter_planning_manga") else tr("filter_planning"),
"PLANNING"
),
filterButton("completed", tr("filter_completed"), "COMPLETED"),
filterButton("paused", tr("filter_paused"), "PAUSED"),
filterButton("dropped", tr("filter_dropped"), "DROPPED"),
filterButton("repeating", tr("filter_repeating"), "REPEATING"),
filterButton("all", tr("filter_all"), "ALL"),
}),
}))
if snapshot.loading then
table.insert(children, ui.label({ text = loadProgressText(), color = "on_surface_variant", padding = { top = 12 } }))
elseif snapshot.error ~= "" then
table.insert(children, ui.label({ text = tr("error", { message = snapshot.error }), color = "error", padding = { top = 12 } }))
elseif snapshot.refreshing and #rows == 0 then
table.insert(children, ui.label({ text = tr("refreshing"), color = "on_surface_variant", padding = { top = 8 } }))
elseif snapshot.busy then
table.insert(children, ui.label({ text = tr("updating"), color = "on_surface_variant", padding = { top = 8 } }))
end
if not snapshot.loading and #rows == 0 then
table.insert(children, ui.label({ text = tr("empty"), color = "on_surface_variant", padding = { top = 12 } }))
else
local listChildren = {}
local rowCap = listRowCap(rows)
for index = 1, rowCap do
local entry = rows[index]
local row = renderEntryRow(entry, index, loadingSet)
if row then
table.insert(listChildren, row)
end
end
if visibleRowLimit < rowCap then
table.insert(listChildren, ui.row({ gap = 8, align = "center", justify = "center", padding = { top = 4, bottom = 4 } }, {
ui.glyph({ name = "loader", size = 16, color = "on_surface_variant" }),
ui.label({ text = tr("loading_more"), fontSize = 12, color = "on_surface_variant" }),
}))
end
table.insert(children, ui.scroll({ flexGrow = 1, gap = 8 }, listChildren))
syncVisibleCoverPriority(rows)
syncListLoadInterval(rows)
end
panel.render(ui.column({ gap = 10, padding = 16, flexGrow = 1 }, children))
end
render = function()
if coverPreview then
panel.render(renderCoverPreview())
dirty = false
return
end
if snapshot.viewer and snapshot.viewer.id then
renderLibrary()
else
renderLogin()
end
dirty = false
end
noctalia.state.watch("anilist_snapshot", function(value)
if type(value) == "table" then
snapshot = value
dirty = true
render()
end
end)
noctalia.state.watch("anilist_cover_loading", function()
dirty = true
render()
end)
panel.setWantsSecondTicks(true)
function onOpen(_context)
noctalia.state.set("anilist_open", true)
resetListView()
if snapshot.viewer and snapshot.viewer.id then
sendCommand("refresh", { mediaType = mediaTab, silent = true })
end
dirty = true
render()
end
function onClose()
coverPreview = nil
noctalia.state.set("anilist_open", false)
if not snapshot.oauthLoading then
noctalia.setUpdateInterval(LIST_IDLE_INTERVAL_MS)
end
end
function update()
if snapshot.viewer and snapshot.viewer.id then
local rows = entriesForView()
if growVisibleRows(rows) then
dirty = true
end
syncListLoadInterval(rows)
end
if dirty then
render()
end
end
function onClosePanel()
coverPreview = nil
panel.close()
end
function onCloseCoverPreview()
coverPreview = nil
coverDownloadBusy = false
dirty = true
render()
end
function onDownloadCoverPreview()
if coverPreview then
startCoverDownload(coverPreview)
end
end
function onNoop() end
function onConnect()
sendCommand("start_oauth")
dirty = true
render()
end
function onLogout()
sendCommand("logout")
end
function onRefresh()
sendCommand("refresh", { mediaType = mediaTab })
end
function onTabAnime()
mediaTab = "ANIME"
resetListView()
dirty = true
render()
end
function onTabManga()
mediaTab = "MANGA"
resetListView()
dirty = true
render()
end
render()
-67
View File
@@ -1,67 +0,0 @@
id = "cleboost/anilist"
name = "AniList (UNOFFICIAL)"
version = "1.1.2"
plugin_api = 15
author = "Cleboost"
license = "MIT"
icon = "device-tv"
description = "Browse and update your AniList anime and manga lists from a quick panel without opening the site."
tags = ["media", "bar", "panel", "service", "network", "productivity"]
dependencies = ["python3", "xdg-open"]
[[setting]]
key = "client_id"
type = "string"
label_key = "settings.client_id.label"
description_key = "settings.client_id.description"
default = ""
[[setting]]
key = "client_secret"
type = "string"
label_key = "settings.client_secret.label"
description_key = "settings.client_secret.description"
default = ""
[[setting]]
key = "access_token"
type = "string"
label_key = "settings.access_token.label"
description_key = "settings.access_token.description"
default = ""
[[widget]]
id = "tracker"
entry = "widget.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
default = "device-tv"
[[widget.setting]]
key = "count_mode"
type = "select"
label_key = "settings.count_mode.label"
description_key = "settings.count_mode.description"
default = "current"
options = [
{ value = "current", label_key = "settings.count_mode.options.current" },
{ value = "completed", label_key = "settings.count_mode.options.completed" },
{ value = "total", label_key = "settings.count_mode.options.total" },
{ value = "in_progress", label_key = "settings.count_mode.options.in_progress" },
{ value = "planning", label_key = "settings.count_mode.options.planning" }
]
[[panel]]
id = "library"
entry = "panel.luau"
width = 720
height = 640
placement = "floating"
position = "center"
[[service]]
id = "api"
entry = "service.luau"
-260
View File
@@ -1,260 +0,0 @@
#!/usr/bin/env python3
"""Temporary localhost OAuth callback server for AniList login."""
from __future__ import annotations
import base64
import json
import socket
import sys
import threading
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
HOST = "127.0.0.1"
PORT = 7823
REDIRECT_URI = f"http://{HOST}:{PORT}/callback"
AUTH_URL = "https://anilist.co/api/v2/oauth/authorize"
TOKEN_URL = "https://anilist.co/api/v2/oauth/token"
TIMEOUT_SECONDS = 180
def emit(payload: dict, result_path: str | None = None) -> None:
encoded = json.dumps(payload)
print(encoded, flush=True)
if result_path:
with open(result_path, "w", encoding="utf-8") as handle:
handle.write(encoded)
def load_credentials(path: str) -> tuple[str, str]:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
client_id = str(data.get("client_id", "")).strip()
client_secret = str(data.get("client_secret", "")).strip()
if not client_id or not client_secret:
raise ValueError("missing client_id or client_secret")
return client_id, client_secret
def exchange_code(client_id: str, client_secret: str, code: str) -> str:
body = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
}
).encode("utf-8")
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii")
request = urllib.request.Request(
TOKEN_URL,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"Authorization": f"Basic {credentials}",
"User-Agent": "noctalia-anilist-plugin",
},
method="POST",
)
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.loads(response.read().decode("utf-8"))
token = payload.get("access_token")
if not token:
message = payload.get("error_description") or payload.get("message") or payload.get("error")
raise RuntimeError(message or "token response missing access_token")
return str(token)
def format_http_error(exc: urllib.error.HTTPError) -> str:
try:
detail = json.loads(exc.read().decode("utf-8"))
message = detail.get("error_description") or detail.get("message") or detail.get("error")
if message:
return str(message)
except Exception: # noqa: BLE001
pass
return exc.reason or "token exchange failed"
def port_available(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((HOST, port))
except OSError:
return False
return True
def main() -> int:
if len(sys.argv) not in (2, 3):
emit({"ok": False, "error": "usage: oauth_login.py <credentials.json> [result.json]"}, None)
return 1
result_path = sys.argv[2] if len(sys.argv) == 3 else None
if not port_available(PORT):
emit(
{
"ok": False,
"error": f"port {PORT} is already in use; close the other login attempt first",
},
result_path,
)
return 1
try:
client_id, client_secret = load_credentials(sys.argv[1])
except Exception as exc: # noqa: BLE001
emit({"ok": False, "error": str(exc)}, result_path)
return 1
result: dict[str, str | bool] = {"status": "pending"}
emitted = False
done = threading.Event()
def report(payload: dict) -> None:
nonlocal emitted
emit(payload, result_path)
emitted = True
class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
if done.is_set():
self.send_error(404)
return
parsed = urllib.parse.urlparse(self.path)
if parsed.path != "/callback":
self.send_error(404)
return
params = urllib.parse.parse_qs(parsed.query)
error = params.get("error", [None])[0]
if error:
result["status"] = "error"
result["error"] = str(error)
report({"ok": False, "error": str(error)})
self._success_page("Login failed", "Return to Noctalia and try again.")
done.set()
return
code = params.get("code", [None])[0]
if not code:
result["status"] = "error"
result["error"] = "missing authorization code"
report({"ok": False, "error": "missing authorization code"})
self._success_page("Login failed", "No authorization code was received.")
done.set()
return
try:
token = exchange_code(client_id, client_secret, code)
except urllib.error.HTTPError as exc:
message = format_http_error(exc)
result["status"] = "error"
result["error"] = message
report({"ok": False, "error": message})
self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.")
done.set()
return
except Exception as exc: # noqa: BLE001
result["status"] = "error"
result["error"] = str(exc)
report({"ok": False, "error": str(exc)})
self._success_page("Login failed", "Could not finish login. Return to Noctalia and try again.")
done.set()
return
result["status"] = "ok"
result["access_token"] = token
report({"ok": True, "access_token": token})
self._success_page(
"Connected to AniList",
"You can close this tab and return to Noctalia.",
)
done.set()
def log_message(self, format: str, *args) -> None: # noqa: A003
return
def _success_page(self, title: str, message: str) -> None:
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
<style>
body {{
font-family: system-ui, sans-serif;
background: #0f172a;
color: #e2e8f0;
display: grid;
place-items: center;
min-height: 100vh;
margin: 0;
}}
.card {{
background: #1e293b;
border-radius: 12px;
padding: 32px;
max-width: 420px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}}
h1 {{ margin-top: 0; color: #38bdf8; }}
</style>
</head>
<body>
<div class="card">
<h1>{title}</h1>
<p>{message}</p>
</div>
</body>
</html>"""
encoded = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
server = HTTPServer((HOST, PORT), CallbackHandler)
server.timeout = 1
authorize = (
f"{AUTH_URL}?client_id={urllib.parse.quote(client_id)}"
f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
"&response_type=code"
)
webbrowser.open(authorize)
def serve_until_done() -> None:
while not done.is_set():
server.handle_request()
worker = threading.Thread(target=serve_until_done, daemon=True)
worker.start()
if not done.wait(TIMEOUT_SECONDS):
result["status"] = "error"
result["error"] = "login timed out"
server.server_close()
if emitted:
return 0 if result.get("status") == "ok" else 1
if result.get("status") == "ok" and result.get("access_token"):
emit({"ok": True, "access_token": result["access_token"]}, result_path)
return 0
emit({"ok": False, "error": result.get("error") or "login cancelled"}, result_path)
return 1
if __name__ == "__main__":
raise SystemExit(main())
-1483
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

-12
View File
@@ -1,12 +0,0 @@
{
"panel": {
"close": "Schließen",
"close_preview": "Vorschau schließen",
"connect": "Mit AniList verbinden",
"cover_preview": "Cover anzeigen",
"decrement": "Vorherige Folge/Kapitel",
"download_cover": "Cover herunterladen",
"download_cover_failed": "Das Coverbild konnte nicht gespeichert werden.",
"download_cover_success": "Cover wurde unter {path} gespeichert"
}
}
-97
View File
@@ -1,97 +0,0 @@
{
"panel": {
"close": "Close",
"close_preview": "Close preview",
"connect": "Connect with AniList",
"cover_preview": "View cover",
"decrement": "Previous episode/chapter",
"download_cover": "Download cover",
"download_cover_failed": "Could not save the cover image.",
"download_cover_success": "Cover saved to {path}",
"download_cover_unavailable": "Install zenity or kdialog to choose a save location.",
"empty": "No entries in this list.",
"error": "Error: {message}",
"filter_all": "All",
"filter_completed": "Completed",
"filter_current": "Watching",
"filter_dropped": "Dropped",
"filter_paused": "Paused",
"filter_planning": "Planning",
"filter_planning_manga": "Plan to Read",
"filter_reading": "Reading",
"filter_repeating": "Repeating",
"increment": "Next episode/chapter",
"loading": "Loading your lists…",
"loading_more": "Loading more entries…",
"loading_progress": "Loading lists… {anime} anime, {manga} manga",
"logged_in_as": "Signed in as {name}",
"login_help": "Set your client ID and client secret in plugin settings, then click Connect. Your browser opens, you approve AniList, and the plugin finishes login automatically.",
"login_title": "Connect to AniList",
"login_waiting": "Waiting for browser login…",
"logout": "Log out",
"mark_complete": "Complete",
"next_episode": "Ep {episode} soon",
"open_anilist": "Open on AniList",
"progress_anime": "Ep {current}/{total}",
"progress_anime_unknown": "Ep {current}",
"progress_manga": "Ch {current}/{total}",
"progress_manga_unknown": "Ch {current}",
"refresh": "Reload list",
"refreshing": "Refreshing…",
"settings": "Plugin settings",
"tab_anime": "Anime",
"tab_manga": "Manga",
"title": "AniList (UNOFFICIAL)",
"updating": "Updating…"
},
"service": {
"empty_response": "AniList returned an empty response.",
"invalid_response": "AniList returned an unreadable response.",
"invalid_token": "Invalid or expired access token.",
"mutation_failed": "Update failed.",
"network_error": "Could not reach AniList.",
"not_configured": "Set AniList client ID and client secret in plugin settings.",
"oauth_busy": "A login is already in progress.",
"oauth_failed": "Browser login failed.",
"oauth_unavailable": "Could not start the local login helper. Is python3 installed?"
},
"settings": {
"access_token": {
"description": "Skip browser login if you already have a bearer token. Usually left empty.",
"label": "Access token (optional)"
},
"client_id": {
"description": "Your AniList developer app client ID (anilist.co/settings/developer). Redirect URL must be http://127.0.0.1:7823/callback",
"label": "Client ID"
},
"client_secret": {
"description": "The client secret from the same AniList app. Never share it publicly — each user should use their own app.",
"label": "Client secret"
},
"count_mode": {
"description": "Which number to show next to the bar glyph.",
"label": "Bar count",
"options": {
"completed": "Completed (anime)",
"current": "Watching (anime in progress)",
"in_progress": "In progress (anime + manga)",
"planning": "Planning (anime)",
"total": "Total (all anime)"
}
},
"glyph": {
"label": "Bar glyph"
}
},
"widget": {
"tooltip_completed": "{count} anime completed",
"tooltip_current": "{count} anime in progress",
"tooltip_empty": "AniList — open library",
"tooltip_error": "AniList — {error}",
"tooltip_in_progress": "{count} in progress (anime + manga)",
"tooltip_loading": "AniList — loading…",
"tooltip_oauth": "AniList — waiting for browser login…",
"tooltip_planning": "{count} anime planned",
"tooltip_total": "{count} anime on your list"
}
}
-97
View File
@@ -1,97 +0,0 @@
{
"panel": {
"close": "Fermer",
"close_preview": "Fermer l'aperçu",
"connect": "Se connecter avec AniList",
"cover_preview": "Voir la pochette",
"decrement": "Épisode ou chapitre précédent",
"download_cover": "Télécharger la pochette",
"download_cover_failed": "Impossible d'enregistrer la pochette.",
"download_cover_success": "Pochette enregistrée dans {path}",
"download_cover_unavailable": "Installez zenity ou kdialog pour choisir où enregistrer l'image.",
"empty": "Aucune entrée dans cette liste.",
"error": "Erreur : {message}",
"filter_all": "Tout",
"filter_completed": "Terminé",
"filter_current": "En cours",
"filter_dropped": "Abandonné",
"filter_paused": "En pause",
"filter_planning": "À voir",
"filter_planning_manga": "À lire",
"filter_reading": "En lecture",
"filter_repeating": "En reprise",
"increment": "Épisode ou chapitre suivant",
"loading": "Chargement de vos listes…",
"loading_more": "Chargement de la liste…",
"loading_progress": "Chargement… {anime} anime, {manga} manga",
"logged_in_as": "Connecté en tant que {name}",
"login_help": "Renseignez votre ID client et votre secret client dans les paramètres du plugin, puis cliquez sur Se connecter. Votre navigateur s'ouvre, vous autorisez AniList, et le plugin termine la connexion automatiquement.",
"login_title": "Se connecter à AniList",
"login_waiting": "En attente de la connexion dans le navigateur…",
"logout": "Se déconnecter",
"mark_complete": "Terminer",
"next_episode": "Ép. {episode} bientôt",
"open_anilist": "Ouvrir sur AniList",
"progress_anime": "Ép. {current}/{total}",
"progress_anime_unknown": "Ép. {current}",
"progress_manga": "Ch. {current}/{total}",
"progress_manga_unknown": "Ch. {current}",
"refresh": "Actualiser la liste",
"refreshing": "Actualisation…",
"settings": "Paramètres du plugin",
"tab_anime": "Anime",
"tab_manga": "Manga",
"title": "AniList (UNOFFICIEL)",
"updating": "Mise à jour…"
},
"service": {
"empty_response": "AniList a renvoyé une réponse vide.",
"invalid_response": "AniList a renvoyé une réponse illisible.",
"invalid_token": "Jeton d'accès invalide ou expiré.",
"mutation_failed": "La mise à jour a échoué.",
"network_error": "Impossible de joindre AniList.",
"not_configured": "Renseignez l'ID client et le secret client AniList dans les paramètres du plugin.",
"oauth_busy": "Une connexion est déjà en cours.",
"oauth_failed": "La connexion via le navigateur a échoué.",
"oauth_unavailable": "Impossible de démarrer l'assistant de connexion local. python3 est-il installé ?"
},
"settings": {
"access_token": {
"description": "Ignore la connexion navigateur si vous avez déjà un jeton bearer. Laissez vide en temps normal.",
"label": "Jeton d'accès (optionnel)"
},
"client_id": {
"description": "ID client de votre application développeur AniList (anilist.co/settings/developer). L'URL de redirection doit être http://127.0.0.1:7823/callback",
"label": "ID client"
},
"client_secret": {
"description": "Secret client de la même application AniList. Ne le partagez jamais publiquement — chaque utilisateur doit utiliser sa propre application.",
"label": "Secret client"
},
"count_mode": {
"description": "Quel nombre afficher à côté de l'icône dans la barre.",
"label": "Compteur barre",
"options": {
"completed": "Terminé (anime)",
"current": "En cours (anime)",
"in_progress": "En cours (anime + manga)",
"planning": "Prévu (anime)",
"total": "Total (tous les anime)"
}
},
"glyph": {
"label": "Icône de la barre"
}
},
"widget": {
"tooltip_completed": "{count} anime terminés",
"tooltip_current": "{count} anime en cours",
"tooltip_empty": "AniList — ouvrir la bibliothèque",
"tooltip_error": "AniList — {error}",
"tooltip_in_progress": "{count} en cours (anime + manga)",
"tooltip_loading": "AniList — chargement…",
"tooltip_oauth": "AniList — en attente de la connexion navigateur…",
"tooltip_planning": "{count} anime prévus",
"tooltip_total": "{count} anime sur votre liste"
}
}
-29
View File
@@ -1,29 +0,0 @@
{
"panel": {
"close": "Kapat",
"close_preview": "Önizlemeyi kapat",
"connect": "AniList'e Bağlan",
"cover_preview": "Kapak fotoğrafını gör",
"decrement": "Önceki bölüm",
"download_cover_failed": "Kapak fotoğrafı kaydedilemedi.",
"download_cover_success": "Kapak fotoğrafı {path} dizinine kaydedildi",
"download_cover_unavailable": "Kayıt dizini seçmek için zenity veya kdialog yükleyin.",
"error": "Hata: {message}",
"filter_all": "Tümü",
"filter_completed": "Tamamlanan",
"filter_current": "İzlenen",
"filter_dropped": "Bırakılan",
"filter_paused": "Durdurulan",
"filter_planning": "Planlanan",
"filter_reading": "Okunan",
"filter_repeating": "Tekrar eden",
"increment": "Sonraki bölüm",
"loading": "Listelerin yükleniyor…",
"logged_in_as": "{name} olarak giriş yapıldı",
"login_title": "AniList'e bağlan",
"login_waiting": "Tarayıcıdan oturum açma bekleniyor…",
"logout": "Oturumu kapat",
"mark_complete": "Tamamla",
"open_anilist": "AniList'te aç"
}
}
-126
View File
@@ -1,126 +0,0 @@
--!nonstrict
-- AniList (UNOFFICIAL) bar widget: opens the library panel and shows a configurable list count.
local PANEL_ID = "cleboost/anilist:library"
local open = false
local snapshot = noctalia.state.get("anilist_snapshot") or {}
local function tr(key, subst)
return noctalia.tr("widget." .. key, subst)
end
local function matchesStatus(entry, statuses)
for _, status in ipairs(statuses) do
if entry.status == status then
return true
end
end
return false
end
local function countEntries(rows, statuses)
local n = 0
for _, entry in ipairs(rows or {}) do
if not statuses or matchesStatus(entry, statuses) then
n += 1
end
end
return n
end
local COUNT_MODES = {
current = {
tooltip = "tooltip_current",
count = function(anime, _)
return countEntries(anime, { "CURRENT", "REPEATING" })
end,
},
completed = {
tooltip = "tooltip_completed",
count = function(anime, _)
return countEntries(anime, { "COMPLETED" })
end,
},
total = {
tooltip = "tooltip_total",
count = function(anime, _)
return #anime
end,
},
in_progress = {
tooltip = "tooltip_in_progress",
count = function(anime, manga)
local statuses = { "CURRENT", "REPEATING" }
return countEntries(anime, statuses) + countEntries(manga, statuses)
end,
},
planning = {
tooltip = "tooltip_planning",
count = function(anime, _)
return countEntries(anime, { "PLANNING" })
end,
},
}
local function activeCountMode()
local mode = noctalia.getConfig("count_mode")
if type(mode) ~= "string" or mode == "" then
mode = "current"
end
return COUNT_MODES[mode] or COUNT_MODES.current
end
local function render()
local glyph = noctalia.getConfig("glyph") or "device-tv"
barWidget.setGlyph(glyph)
barWidget.setGlyphColor(if open then "primary" else "on_surface")
if snapshot.oauthLoading or snapshot.loading then
barWidget.setText("")
barWidget.setTooltip(if snapshot.oauthLoading then tr("tooltip_oauth") else tr("tooltip_loading"))
return
end
if snapshot.error and snapshot.error ~= "" and not snapshot.viewer then
barWidget.setText("")
barWidget.setTooltip(tr("tooltip_error", { error = snapshot.error }))
return
end
local mode = activeCountMode()
local count = mode.count(snapshot.anime or {}, snapshot.manga or {})
if count > 0 then
barWidget.setText(tostring(count))
barWidget.setTooltip(tr(mode.tooltip, { count = count }))
else
barWidget.setText("")
barWidget.setTooltip(tr("tooltip_empty"))
end
end
noctalia.state.watch("anilist_open", function(value)
open = value == true
render()
end)
noctalia.state.watch("anilist_snapshot", function(value)
if type(value) == "table" then
snapshot = value
render()
end
end)
function update()
render()
end
function onClick()
noctalia.togglePanel(PANEL_ID)
end
function onRightClick()
noctalia.runAsync("xdg-open 'https://anilist.co/home' >/dev/null 2>&1")
end
render()
-146
View File
@@ -1,146 +0,0 @@
# Arch Updater
Check pacman, AUR and Flatpak for updates from the bar, with an estimated
download size and an Arch news heads-up before you upgrade. Runs the update
in a terminal window.
## Plugin
| Field | Value |
| --- | --- |
| ID | `yuuto/arch-updater` |
| Entries | Bar widget: `widget`; panel: `panel`; service: `service`; launcher: `launcher` |
| Launcher Prefix | `/arch` |
## Requirements
- `pacman-contrib` on `PATH` (for `checkupdates`), required.
- `pacman`, `sh`, `sudo`, `awk`, `sed`, `test` and `uname`, required — base
tools from any standard Arch install, used to run and parse the pacman
check, build the download size estimate, check the running kernel, and
run the plain-pacman upgrade.
- `yay` or `paru` on `PATH`, optional, for the AUR check and update.
Auto-detected by default, see the **AUR helper** setting.
- `flatpak`, optional, for the Flatpak check and update.
- `xdg-open`, optional, to open a package page or the Arch news page.
- A terminal emulator for the update run: 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 except `pacman-contrib`, `pacman`, `sh`, `sudo`, `awk`, `sed`,
`test` and `uname` is optional. A missing optional tool is skipped, not
treated as an error.
## Usage
Add the `widget` bar widget from Noctalia's widget picker. Left click opens
the panel, right click checks for updates now, middle click opens the
widget's own settings. You can also open the panel directly or bind it in
your compositor:
```sh
noctalia msg panel-toggle yuuto/arch-updater:panel
```
The panel groups pending packages by source (Pacman, AUR, Flatpak). Click a
source row to expand it into its packages. Each package row has a copy
button (name and versions) and an open button (its page on archlinux.org,
the AUR, or Flathub). **Check Updates** queries all sources, **Update** opens
a terminal running the upgrade, **Dismiss** clears the pending list and
closes the panel, returning the bar glyph to its resting colour.
Type `/arch` in the launcher for quick actions (check, update, open news), or
`/arch <text>` to fuzzy-search the packages from the last check. Activating a
result opens that package's page.
## Extras
Not in the v4 plugin:
- **Arch Linux news.** The news feed is checked periodically. An unread post
is flagged in the panel and the bar tooltip, with a button that opens the
news page and marks it read. Arch news is where manual-intervention steps
get announced, so it's worth seeing before a big upgrade.
- **Reboot recommendation.** Detected from whether the running kernel's
`/usr/lib/modules/<version>` directory still exists, so it works for any
kernel flavour (`linux`, `linux-lts`, `linux-zen`, `linux-cachyos`, ...)
without naming one. Shown in the panel and the bar tooltip, and colours the
bar glyph independently of the pending count.
- **Estimated download size** for the pending pacman packages (`pacman -Si`),
shown before you commit to **Update**.
- **An ignore list that's actually enforced.** v4 only let you rewrite the
raw check/update commands. Here, packages in **Ignore packages** are
filtered out of both the count and the update run (`--ignore`), on top of
`pacman.conf`'s own `IgnorePkg`.
- **Auto-detected AUR helper**, with `yay`/`paru`/a custom command/off as
explicit overrides.
- **A generic package-page link** (`archlinux.org/packages`,
`aur.archlinux.org`, `flathub.org`) instead of hardcoded per-repo mirror
URLs, so it stays correct across Arch-based distros.
- **An activity graph.** A small trend line of the pending-update count
across the most recent checks, plus when you last ran an update. Persisted
to disk so it survives restarts. On by default, and turning it off also
stops recording the history, not just hiding it.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `aur_helper` | `select` | `auto` | Which AUR helper to use: auto-detect (yay, then paru), `yay`, `paru`, a custom command, or off. |
| `aur_check_cmd` | `string` | *(empty)* | Custom AUR check command, only used when `aur_helper` is `custom`. Must print `name oldver -> newver` per line. |
| `flatpak_enabled` | `bool` | `true` | Also check and update Flatpak. Skipped automatically when `flatpak` isn't installed. |
| `ignore_packages` | `string_list` | *(empty)* | Package names excluded from the count and passed as `--ignore` on update. |
| `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_download_size` | `bool` | `true` | Show the estimated pacman download size (`pacman -Si`) in the panel. |
| `check_arch_news` | `bool` | `true` | Check the Arch Linux news feed and flag unread posts. |
| `check_reboot_needed` | `bool` | `true` | Flag when the running kernel is no longer installed on disk. |
| `show_activity_graph` | `bool` | `true` | Track and show the pending-update trend and last-update time in the panel. Off also stops recording history. |
| `activity_history_length` | `int` | `10` | How many of the most recent checks to keep for the activity graph. |
| `terminal` | `string` | *(empty)* | Terminal command for the update run, e.g. `kitty`. Empty uses Noctalia's detection. |
| `assume_yes` | `bool` | `false` | Pass `--noconfirm` / `-y` so package managers do not ask for confirmation. |
| `update_cmd` | `string` | *(empty)* | Full override for the update command. Empty builds it from the settings above. |
| `glyph` | `glyph` | `package` | The glyph shown for the widget on the bar. |
| `show_count` | `bool` | `true` | Show the pending-update count next to the bar glyph. |
| `hide_on_empty` | `bool` | `false` | Hide the widget entirely when there is nothing to show. |
## IPC
```sh
noctalia msg plugin yuuto/arch-updater:service all check
noctalia msg plugin yuuto/arch-updater:service all update
noctalia msg plugin yuuto/arch-updater:service all dismiss
```
## Notes
- **Commands spawned.** `checkupdates`; the configured AUR helper's `-Qua`
(or your custom command); `flatpak list` / `flatpak remote-ls --updates`;
`pacman -Si` for the download size (piped through `awk` to sum it); a local
`test -d` against `uname -r` for the reboot check; and, for the update,
your terminal running `sh` to launch `sudo pacman`/the AUR helper,
optionally `flatpak update` (filtered through `awk` when packages are
ignored, using `sed` to combine the Flatpak listings during the check). No
upgrade command runs outside the terminal.
- **Network.** `checkupdates`, the AUR helper and the Flatpak check contact
mirrors, the AUR RPC, or a Flatpak remote, same as the corresponding
upgrade would. The Arch news check fetches `archlinux.org/feeds/news/` on
its own schedule, independent of a manual check.
- **Privileges.** The plugin never elevates anything itself. Plain pacman
updates run via `sudo pacman -Syu` in the terminal, where `sudo` prompts
normally. An AUR helper handles its own privilege escalation as usual.
- **Files.** One small file in the plugin's data directory tracks the last
Arch news post you've read, so unread counts survive a restart. Nothing
else is written; `pacman.conf` is never modified.
- **Sizes are pacman-only.** AUR and Flatpak downloads aren't sized. Most AUR
packages build from source, where a download size wouldn't mean much.
## Credits
Ported from the v4 QML "Arch Updater" plugin (MIT), rebuilt for v5's Luau
plugin API with a different feature set. See **Extras** above.
## License
MIT.
-103
View File
@@ -1,103 +0,0 @@
--!nonstrict
-- arch-updater launcher provider, under the `/arch` prefix.
--
-- Empty query shows the three quick actions. Any other text is fuzzy-matched
-- against the pending packages from the last check, read straight from the
-- shared "arch_state" the engine publishes. Activating a package opens its
-- page, same as the panel's "open" button on a package row.
local STATE_KEY = "arch_state"
local REQUEST_KEY = "arch_request"
local function tr(key, args)
return noctalia.tr(key, args)
end
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 shellQuote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function openUrl(url)
if noctalia.commandExists("xdg-open") then
noctalia.runAsync("xdg-open " .. shellQuote(url) .. " >/dev/null 2>&1")
end
end
local SOURCE_GLYPH = { pacman = "package", aur = "box", flatpak = "app-window" }
local function packageUrl(sourceKey, name)
if sourceKey == "pacman" then
return "https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name)
elseif sourceKey == "aur" then
return "https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name)
end
return "https://flathub.org/apps/" .. noctalia.string.urlEncode(name)
end
local function commandResults()
return {
{ id = "cmd-check", title = tr("action_check"), subtitle = tr("launcher.check_subtitle"), glyph = "refresh" },
{ id = "cmd-update", title = tr("action_update"), subtitle = tr("launcher.update_subtitle"), glyph = "download" },
{ id = "cmd-news", title = tr("action_open_news"), subtitle = tr("launcher.news_subtitle"), glyph = "news" },
}
end
local function packageResults(query)
local state = noctalia.state.get(STATE_KEY)
if type(state) ~= "table" then
return {}
end
local results = {}
local sourceList = { { key = "pacman", entry = state.pacman }, { key = "aur", entry = state.aur }, { key = "flatpak", entry = state.flatpak } }
for _, source in ipairs(sourceList) do
local items = type(source.entry) == "table" and source.entry.items or nil
if type(items) == "table" then
for _, item in ipairs(items) do
local score = noctalia.fuzzyScore(query, item.name)
if score ~= nil then
local subtitle = (item.from ~= nil and item.from ~= "" and item.to ~= nil and item.to ~= "")
and (item.from .. " → " .. item.to)
or tr("source." .. source.key)
table.insert(results, {
id = "pkg-" .. source.key .. "-" .. item.name,
title = item.name,
subtitle = subtitle,
glyph = SOURCE_GLYPH[source.key] or "package",
score = score,
})
end
end
end
end
return results
end
function onQuery(query)
if query == "" then
launcher.setResults(query, commandResults())
return
end
launcher.setResults(query, packageResults(query))
end
function onActivate(id)
if id == "cmd-check" then
request("check")
elseif id == "cmd-update" then
request("update")
elseif id == "cmd-news" then
request("open_news")
else
local sourceKey, name = id:match("^pkg%-([a-z]+)%-(.+)$")
if sourceKey ~= nil and name ~= nil then
openUrl(packageUrl(sourceKey, name))
end
end
end
-619
View File
@@ -1,619 +0,0 @@
--!nonstrict
-- arch-updater update panel. Pure renderer over the shared state: the engine
-- (service.luau) publishes "arch_state" and performs the "arch_request"
-- actions this panel emits, so closing the panel never interrupts a check or
-- a run in progress.
--
-- "Check Updates" only queries pacman, the AUR helper and (optionally)
-- Flatpak. Only then do "Update" (open a terminal and run the upgrade) and
-- "Dismiss" (clear the pending list and close the panel) light up.
local STATE_KEY = "arch_state"
local REQUEST_KEY = "arch_request"
local snapshot = nil
local expanded = {} -- source key -> the package list is open
local hoverKey = nil -- package row currently under the pointer
local hoverText = "" -- what the detail line shows
local activityHoverIndex = nil -- activity graph point currently under the pointer
local listOpen = false -- at least one source is expanded this render
local render
local function tr(key, args)
return noctalia.tr(key, args)
end
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 shellQuote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function openUrl(url)
if not noctalia.commandExists("xdg-open") then
noctalia.notifyError(tr("title"), tr("err_no_xdg_open"))
return
end
noctalia.runAsync("xdg-open " .. shellQuote(url) .. " >/dev/null 2>&1")
end
-- Opens a generic Arch package search instead of a per-repo mirror URL, so
-- it stays correct across Arch-based distros.
local function openPackage(sourceKey, name)
if sourceKey == "pacman" then
openUrl("https://archlinux.org/packages/?q=" .. noctalia.string.urlEncode(name))
elseif sourceKey == "aur" then
openUrl("https://aur.archlinux.org/packages/" .. noctalia.string.urlEncode(name))
elseif sourceKey == "flatpak" then
openUrl("https://flathub.org/apps/" .. noctalia.string.urlEncode(name))
end
end
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
-- Only "checking" blocks a new check: "running" still allows one, since it
-- doubles as a manual unstick if the update's process-poll heuristic ever
-- misses the terminal finishing.
local function checking()
return phaseOf() == "checking"
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
local VERSION_WIDTH = 78
local function sourceLabel(key, entry)
if key == "aur" and type(entry.helper) == "string" and entry.helper ~= "" then
return tr("source.aur_named", { helper = entry.helper })
end
return tr("source." .. key)
end
local SOURCE_GLYPHS = { pacman = "package", aur = "cloud", flatpak = "app-window" }
local function sourceGlyph(key)
return SOURCE_GLYPHS[key] or "package"
end
-- pacman, then the AUR helper, then Flatpak. Only sources with pending
-- packages, biggest first.
local function orderedSources()
if snapshot == nil then
return {}
end
local candidates = {
{ key = "pacman", entry = snapshot.pacman },
{ key = "aur", entry = snapshot.aur },
{ key = "flatpak", entry = snapshot.flatpak },
}
local pending = {}
for _, candidate in ipairs(candidates) do
if type(candidate.entry) == "table" and (candidate.entry.n or 0) > 0 then
table.insert(pending, candidate)
end
end
table.sort(pending, function(a, b)
return a.entry.n > b.entry.n
end)
return pending
end
local function cleanSources()
if snapshot == nil then
return {}
end
local names = {}
local candidates = { { key = "pacman", entry = snapshot.pacman }, { key = "flatpak", entry = snapshot.flatpak } }
if snapshot.aur ~= nil and (snapshot.aur.n or 0) == 0 and noctalia.getConfig("aur_helper") ~= "off" then
table.insert(candidates, { key = "aur", entry = snapshot.aur })
end
for _, candidate in ipairs(candidates) do
if type(candidate.entry) == "table" and (candidate.entry.n or 0) == 0 then
table.insert(names, sourceLabel(candidate.key, candidate.entry))
end
end
return names
end
local function packageRow(sourceKey, index, item)
local key = "pkg-" .. sourceKey .. "-" .. index
local children = {
ui.label({ text = item.name, fontSize = 11, color = "on_surface", flexGrow = 1, maxLines = 1 }),
}
local from = item.from ~= nil and item.from or ""
local to = item.to ~= nil and item.to or ""
if 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
table.insert(children, ui.button({
glyph = "copy", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12,
tooltip = tr("tip_copy"),
onClick = function()
noctalia.copyToClipboard(detailFor(item), "text/plain")
end,
}))
table.insert(children, ui.button({
glyph = "external-link", variant = "ghost", controlSize = "sm", width = 22, height = 22, glyphSize = 12,
tooltip = tr("tip_open_page"),
onClick = function()
openPackage(sourceKey, item.name)
end,
}))
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
local function sourceRows()
local rows = {}
for _, source in ipairs(orderedSources()) do
local names = type(source.entry.items) == "table" and source.entry.items or {}
local open = expanded[source.key] == true and #names > 0
listOpen = listOpen or open
local header = { gap = 8, align = "center", key = "src-" .. source.key .. (open and "-open" or "") }
if #names > 0 then
local sourceKey = source.key
header.onClick = function()
expanded[sourceKey] = not expanded[sourceKey]
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.glyph({ name = sourceGlyph(source.key), size = 13, color = "on_surface_variant" }),
ui.label({ text = sourceLabel(source.key, source.entry), color = "on_surface", flexGrow = 1 }),
ui.label({ text = tostring(source.entry.n), color = "primary", fontWeight = "bold" }),
}))
if open then
for index, item in ipairs(names) do
table.insert(rows, packageRow(source.key, index, item))
end
if source.entry.n > #names then
table.insert(rows, ui.row({ key = "more-" .. source.key, paddingH = 18 }, {
ui.label({ text = tr("more_packages", { count = source.entry.n - #names }), fontSize = 11, color = "on_surface_variant" }),
}))
end
end
end
return rows
end
-- Download size, reboot recommendation and Arch news. Each is its own line,
-- so turning one off in settings just removes that line.
local function extras()
local lines = {}
if snapshot == nil then
return lines
end
if type(snapshot.downloadSizeMiB) == "number" then
local size = snapshot.downloadSizeMiB
local text = size >= 1024 and tr("size_gib", { value = string.format("%.2f", size / 1024) })
or tr("size_mib", { value = string.format("%.1f", size) })
table.insert(lines, ui.row({ key = "size", gap = 6, align = "center" }, {
ui.glyph({ name = "download", size = 13, color = "on_surface_variant" }),
ui.label({ text = text, fontSize = 12, color = "on_surface_variant" }),
}))
end
if snapshot.rebootRecommended == true then
table.insert(lines, ui.row({ key = "reboot", gap = 6, align = "center" }, {
ui.glyph({ name = "alert-triangle", size = 13, color = "warning" }),
ui.label({ text = tr("reboot_recommended"), fontSize = 12, color = "warning", flexGrow = 1 }),
}))
end
if (snapshot.newsUnread or 0) > 0 then
table.insert(lines, ui.row({ key = "news", gap = 6, align = "center" }, {
ui.glyph({ name = "news", size = 13, color = "on_surface" }),
ui.label({
text = noctalia.trp("news_unread", snapshot.newsUnread, { title = snapshot.newsLatestTitle or "" }),
fontSize = 12,
color = "on_surface",
flexGrow = 1,
maxLines = 2,
}),
ui.button({
text = tr("action_open_news"), variant = "ghost", controlSize = "sm",
onClick = function()
request("open_news")
end,
}),
}))
end
return lines
end
-- Normalizes the pending-count history to 0..1 for ui.graph, relative to the
-- min/max in the window (not a fixed scale, since pending counts vary wildly
-- between systems). A flat window (min == max, e.g. every check so far found
-- the same count) centers the line at 0.5 instead of pinning it to the top
-- edge, where it would be indistinguishable from the box border.
local function activityValues(history)
local minN, maxN = tonumber(history[1].n) or 0, tonumber(history[1].n) or 0
for _, entry in ipairs(history) do
local n = tonumber(entry.n) or 0
if n < minN then
minN = n
end
if n > maxN then
maxN = n
end
end
local range = maxN - minN
local values = {}
for _, entry in ipairs(history) do
local n = tonumber(entry.n) or 0
table.insert(values, range > 0 and (n - minN) / range or 0.5)
end
return values
end
local ACTIVITY_GRAPH_SUBDIVISIONS = 8
local function upsampleLinear(values, subdivisions)
if #values < 2 or subdivisions <= 1 then
return values
end
local out = {}
for i = 1, #values - 1 do
local a, b = values[i], values[i + 1]
for s = 0, subdivisions - 1 do
table.insert(out, a + (b - a) * (s / subdivisions))
end
end
table.insert(out, values[#values])
return out
end
local function padGraphLookbehind(values)
if #values == 0 then
return values
end
local out = { values[1], values[1] }
for _, v in ipairs(values) do
table.insert(out, v)
end
return out
end
-- "2 hours ago", "3 days ago", etc. nil for entries migrated from the old
-- history format, which never recorded a timestamp.
local function relativeTime(at)
local t = tonumber(at)
if t == nil then
return nil
end
local diff = os.time() - t
if diff < 60 then
return tr("activity.just_now")
elseif diff < 3600 then
local m = math.floor(diff / 60)
return noctalia.trp("activity.minutes_ago", m, { count = m })
elseif diff < 86400 then
local h = math.floor(diff / 3600)
return noctalia.trp("activity.hours_ago", h, { count = h })
else
local d = math.floor(diff / 86400)
return noctalia.trp("activity.days_ago", d, { count = d })
end
end
-- "Updated · 2 hours ago" for the check that verified an update run, else
-- "3 pending updates · 2 hours ago". Shared by the hover tooltip and the
-- top-right caption so both describe a point the same way.
local function describeEntry(entry)
local n = tonumber(entry.n) or 0
local text = entry.afterUpdate == true and tr("activity.updated")
or noctalia.trp("activity.pending_at", n, { count = n })
local when = relativeTime(entry.at)
if when ~= nil then
text = text .. " · " .. when
end
return text
end
-- ui.graph takes no pointer props of its own (only row/column/box/image/button
-- do), so per-point hover is a row of hit targets placed right under the
-- line. Each is a ghost button so it can carry a native tooltip at the
-- pointer, not just a box for the hit test. It cannot highlight the point on
-- the line itself, only drive the tooltip and the caption above it.
--
-- Real points sit at (k-1)/(#history-1) of the width (see
-- padGraphLookbehind), i.e. #history-1 equal gaps, not #history equal slots
-- - so this builds one equal-width segment per gap rather than per entry,
-- ending each segment exactly on the point at its right edge. That leaves
-- entry 1 (at the left edge, with no gap before it) without its own hover
-- zone, but keeps every other spike landing right at the end of its segment
-- instead of drifting toward the start of an oversized one.
local function activityHoverRow(history)
local segments = {}
for i = 2, #history do
segments[i - 1] = ui.button({
key = "activity-hit-" .. i,
variant = "ghost",
flexGrow = 1,
height = 12,
tooltip = describeEntry(history[i]),
onHover = function(state)
if state == "true" then
activityHoverIndex = i
elseif activityHoverIndex == i then
activityHoverIndex = nil
else
return
end
render()
end,
})
end
return ui.row({ key = "activity-hits", gap = 1 }, segments)
end
-- A small trend graph of pending-update counts across recent checks, plus
-- when the last update ran (or, while hovering a point, that point's own
-- description). Off entirely when show_activity_graph is off, and hidden
-- until there is enough history to draw a line.
local function activitySection()
if snapshot == nil or noctalia.getConfig("show_activity_graph") ~= true then
return nil
end
local history = type(snapshot.history) == "table" and snapshot.history or {}
if #history < 2 then
return nil
end
local hoveredEntry = activityHoverIndex ~= nil and history[activityHoverIndex] or nil
local caption
if hoveredEntry ~= nil then
caption = describeEntry(hoveredEntry)
else
local lastUpdateAt = tonumber(snapshot.lastUpdateAt)
if lastUpdateAt == nil then
caption = tr("activity.never_updated")
else
local days = math.floor((os.time() - lastUpdateAt) / 86400)
caption = days <= 0 and tr("activity.updated_today")
or noctalia.trp("activity.updated_days_ago", days, { count = days })
end
end
return ui.column({ key = "activity", gap = 4 }, {
ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = tr("activity.title"), fontSize = 11, fontWeight = "bold", color = "on_surface_variant" }),
ui.label({ text = caption, fontSize = 10, color = "on_surface_variant" }),
}),
ui.graph({
values = padGraphLookbehind(upsampleLinear(activityValues(history), ACTIVITY_GRAPH_SUBDIVISIONS)),
color = "primary",
fillOpacity = 0.15,
lineWidth = 2,
height = 36,
}),
activityHoverRow(history),
})
end
local function body()
local children = {}
local rows = sourceRows()
if #rows > 0 then
table.insert(children, ui.scroll({ key = "sources", flexGrow = 1, gap = 3 }, rows))
else
table.insert(children, ui.spacer({ key = "filler", flexGrow = 1 }))
end
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
for _, line in ipairs(extras()) do
table.insert(children, line)
end
local clean = cleanSources()
if #clean > 0 then
table.insert(children, ui.label({
text = tr("up_to_date", { sources = table.concat(clean, ", ") }),
fontSize = 11,
color = "on_surface_variant",
maxLines = 2,
}))
end
local activity = activitySection()
if activity ~= nil then
table.insert(children, activity)
end
return children
end
local function footerCaption()
if snapshot == nil or snapshot.checkedAt == nil or snapshot.checkedAt == "" then
return nil
end
local parts = { tr("caption_checked", { time = snapshot.checkedAt }) }
local ignored = tonumber(snapshot.ignoredCount) or 0
if ignored > 0 then
table.insert(parts, noctalia.trp("caption_ignored", ignored, {}))
end
return table.concat(parts, " · ")
end
render = function()
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" .. (checking() and "-off" or ""),
glyph = "refresh",
variant = "ghost",
enabled = not checking() 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 = footerCaption()
if caption ~= nil then
table.insert(children, ui.separator({}))
table.insert(children, ui.label({ text = caption, fontSize = 11, color = "on_surface_variant", maxLines = 2 }))
end
if phase ~= "missing" then
table.insert(children, ui.row({ gap = 8, align = "center" }, {
ui.button({
key = "check" .. (checking() and "-off" or ""),
glyph = "refresh", text = tr("action_check"), variant = "ghost", enabled = not checking(), flexGrow = 1,
onClick = function()
request("check")
end,
}),
ui.button({
key = "dismiss" .. (hasUpdates and "" or "-off"),
text = tr("action_dismiss"), variant = "ghost", enabled = hasUpdates,
onClick = function()
request("dismiss")
panel.close()
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")
panel.close()
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 = ""
activityHoverIndex = nil
render()
end
noctalia.state.watch(STATE_KEY, function(value)
if type(value) ~= "table" then
return
end
if value.phase == "checking" and (snapshot == nil or snapshot.phase ~= "checking") then
expanded = {}
hoverKey = nil
hoverText = ""
activityHoverIndex = nil
end
snapshot = value
render()
end)
-176
View File
@@ -1,176 +0,0 @@
id = "yuuto/arch-updater"
name = "Arch Updater"
version = "1.1.0"
plugin_api = 9
author = "yuuto"
license = "MIT"
icon = "package"
description = "Check pacman, AUR and Flatpak updates, get an Arch news and reboot heads-up, then upgrade from a terminal."
dependencies = ["pacman-contrib", "awk", "flatpak", "pacman", "paru", "sed", "sh", "sudo", "test", "uname", "xdg-open", "yay"]
tags = ["arch", "bar", "panel", "launcher", "system", "utility"]
# ── General ──────────────────────────────────────────────────────────────────
[[setting]]
key = "aur_helper"
type = "select"
label_key = "settings.aur_helper.label"
description_key = "settings.aur_helper.description"
default = "auto"
options = [
{ value = "auto", label_key = "settings.aur_helper.options.auto" },
{ value = "yay", label_key = "settings.aur_helper.options.yay" },
{ value = "paru", label_key = "settings.aur_helper.options.paru" },
{ value = "custom", label_key = "settings.aur_helper.options.custom" },
{ value = "off", label_key = "settings.aur_helper.options.off" },
]
[[setting]]
key = "aur_check_cmd"
type = "string"
label_key = "settings.aur_check_cmd.label"
description_key = "settings.aur_check_cmd.description"
default = ""
visible_when = { key = "aur_helper", values = ["custom"] }
[[setting]]
key = "flatpak_enabled"
type = "bool"
label_key = "settings.flatpak_enabled.label"
description_key = "settings.flatpak_enabled.description"
default = true
[[setting]]
key = "ignore_packages"
type = "string_list"
label_key = "settings.ignore_packages.label"
description_key = "settings.ignore_packages.description"
default = []
[[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
# ── Extras (not in the v4 plugin) ───────────────────────────────────────────
[[setting]]
key = "show_download_size"
type = "bool"
label_key = "settings.show_download_size.label"
description_key = "settings.show_download_size.description"
default = true
[[setting]]
key = "check_arch_news"
type = "bool"
label_key = "settings.check_arch_news.label"
description_key = "settings.check_arch_news.description"
default = true
[[setting]]
key = "check_reboot_needed"
type = "bool"
label_key = "settings.check_reboot_needed.label"
description_key = "settings.check_reboot_needed.description"
default = true
# ── Activity ─────────────────────────────────────────────────────────────────
[[setting]]
key = "show_activity_graph"
type = "bool"
label_key = "settings.show_activity_graph.label"
description_key = "settings.show_activity_graph.description"
default = true
[[setting]]
key = "activity_history_length"
type = "int"
label_key = "settings.activity_history_length.label"
description_key = "settings.activity_history_length.description"
default = 10
min = 3
max = 30
visible_when = { key = "show_activity_graph", values = ["true"] }
# ── Update run ───────────────────────────────────────────────────────────────
[[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
advanced = true
[[setting]]
key = "update_cmd"
type = "string"
label_key = "settings.update_cmd.label"
description_key = "settings.update_cmd.description"
default = ""
advanced = true
[[service]]
id = "service"
entry = "service.luau"
[[panel]]
id = "panel"
entry = "panel.luau"
width = 420
height = 540
placement = "attached"
position = "auto"
open_near_click = true
[[widget]]
id = "widget"
entry = "widget.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
[[widget.setting]]
key = "hide_on_empty"
type = "bool"
label_key = "settings.hide_on_empty.label"
description_key = "settings.hide_on_empty.description"
default = false
[[launcher_provider]]
id = "launcher"
entry = "launcher.luau"
prefix = "arch"
glyph = "package"
include_in_global_search = false
-850
View File
@@ -1,850 +0,0 @@
--!nonstrict
-- arch-updater singleton engine. Checks pacman, the AUR helper and Flatpak,
-- publishes the result as shared state, and runs the update in a terminal.
--
-- state "arch_state" = { nonce, phase, step, total, pacman, aur,
-- flatpak, downloadSizeMiB, rebootRecommended,
-- newsUnread, newsLatestTitle, err,
-- checkedAt, ignoredCount, history, lastUpdateAt }
-- requests "arch_request" = { nonce, action } -- check|update|dismiss
--
-- Checking runs pacman, then the AUR helper, then Flatpak, then (if pacman
-- has pending packages) a `pacman -Si` pass for the download size.
--
-- Update never runs in the background. runUpdate() opens a terminal so
-- pacman/the AUR helper can prompt and sudo can ask for a password, then
-- polls for the process to end and re-checks automatically.
local STATE_KEY = "arch_state"
local REQUEST_KEY = "arch_request"
local NEWS_FILE = "news_state.json"
local NEWS_URL = "https://archlinux.org/feeds/news/"
local NEWS_PAGE = "https://archlinux.org/news/"
local HISTORY_FILE = "history_state.json"
local CHECK_TIMEOUT_MS = 45000 -- pacman/AUR/flatpak checks: each may sync a mirror
local SIZE_TIMEOUT_MS = 20000 -- pacman -Si: local db, no mirror sync
local FAST_TIMEOUT_MS = 5000 -- reboot check: local filesystem only
local NEWS_RECHECK_HOURS = 6
local RUN_POLL_SECONDS = 2
local RUN_GRACE_SECONDS = 12
local AUTO_CHECK_DELAY = 10 -- ticks before an enabled auto-check's first run
local MAX_LISTED = 300 -- packages kept per source for the panel's expandable list
local phase = "idle" -- idle|checking|clean|ready|running|error|missing
local step = "" -- source label being checked (phase == "checking")
local total = 0
local sources = { pacman = { n = 0, items = {} }, aur = { n = 0, items = {}, helper = "" }, flatpak = { n = 0, items = {} } }
local downloadSizeMiB = nil
local rebootRecommended = false
local newsUnread = 0
local newsLatestTitle = nil
local newsItems = {}
local newsLastSeenGuid = nil
local errMsg = nil
local checkedAt = ""
local stateNonce = 0
local lastRequestNonce = 0
local runTicks = 0
local runSeen = false
local runPollTicks = 0
local updateProcessName = "pacman"
local sinceCheck = 0
local startupTicks = 0
local sinceNewsCheck = 0
local newsStateLoaded = false
local newsDirty = false
local history = {} -- { n, at, afterUpdate } per check, oldest first, trimmed to activity_history_length
local lastUpdateAt = nil -- os.time() of the last update run that finished
local historyStateLoaded = false
local checkIsPostUpdate = false -- next finished check followed an update run
local startCheck
local checkNews
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
-- Package names reach the command line (--ignore, pacman -Si), so only
-- pacman's own name grammar is accepted. Anything else is dropped with a log
-- line instead of being quoted.
local function ignoreList()
local raw = cfg("ignore_packages")
if type(raw) ~= "table" then
return {}
end
local names = {}
for _, entry in ipairs(raw) do
local name = trim(tostring(entry))
if name:match("^[a-zA-Z0-9._+-]+$") ~= nil then
table.insert(names, name)
elseif name ~= "" then
noctalia.log("arch-updater: ignoring invalid package name '" .. name .. "'")
end
end
return names
end
local function ignoreSet()
local set = {}
for _, name in ipairs(ignoreList()) do
set[name] = true
end
return set
end
-- ── Parsing ──────────────────────────────────────────────────────────────────
-- One "name oldver -> newver" line per package: checkupdates' format, which
-- yay -Qua and paru -Qua also use.
local function parseVersionLines(output, ignored)
local items = {}
local n = 0
for line in (output or ""):gmatch("[^\n]+") do
local parts = {}
for token in line:gmatch("%S+") do
table.insert(parts, token)
end
if #parts >= 4 and not ignored[parts[1]] then
n += 1
if #items < MAX_LISTED then
table.insert(items, { name = parts[1], from = parts[2], to = parts[4] })
end
end
end
return n, items
end
-- Flatpak has no "name oldver -> newver" line, so the query joins installed
-- and pending by application id (tab-separated name/from/to).
local function parseTabLines(output, ignored)
local items = {}
local n = 0
for line in (output or ""):gmatch("[^\n]+") do
local fields = {}
for field in (line .. "\t"):gmatch("([^\t]*)\t") do
table.insert(fields, field)
end
local name = fields[1] or ""
if name ~= "" and not ignored[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
return n, items
end
-- ── AUR helper resolution ────────────────────────────────────────────────────
-- auto tries yay then paru. An explicit choice is trusted as-is and reported
-- missing instead of falling back to another helper.
local function resolveAurHelper()
local choice = cfg("aur_helper")
if choice == "off" then
return nil
end
if choice == "custom" then
return "custom"
end
if choice == "yay" or choice == "paru" then
return choice
end
if noctalia.commandExists("yay") then
return "yay"
end
if noctalia.commandExists("paru") then
return "paru"
end
return nil
end
local function aurCheckCommand(helper)
if helper == "custom" then
local raw = trim(cfg("aur_check_cmd"))
return raw ~= "" and raw or nil
end
-- No 2>/dev/null and no formatting pipe here: stderr is inspected below
-- to tell a real failure from the "-Qua" family's usual no-updates exit
-- code, and the raw output already matches checkupdates' own format.
return helper .. " -Qua"
end
-- ── Publishing ───────────────────────────────────────────────────────────────
local function publish()
stateNonce += 1
noctalia.state.set(STATE_KEY, {
nonce = stateNonce,
phase = phase,
step = step,
total = total,
pacman = sources.pacman,
aur = sources.aur,
flatpak = sources.flatpak,
downloadSizeMiB = downloadSizeMiB,
rebootRecommended = rebootRecommended,
newsUnread = newsUnread,
newsLatestTitle = newsLatestTitle,
err = errMsg,
checkedAt = checkedAt,
ignoredCount = #ignoreList(),
history = history,
lastUpdateAt = lastUpdateAt,
flatpakEnabled = cfg("flatpak_enabled") == true,
})
end
-- ── Checking pipeline: pacman → AUR → Flatpak → size → reboot → done ────────
local checkFlatpak
local checkSize
local checkReboot
local finishCheck
local recordCheck
local function failCheck(message)
phase = "error"
errMsg = message
publish()
end
checkReboot = function()
if cfg("check_reboot_needed") ~= true then
rebootRecommended = false
finishCheck()
return
end
-- A kernel upgrade replaces the whole /usr/lib/modules/<version> tree.
-- Once the running kernel's own directory is gone, a reboot is what
-- switches to the new one. Works for any kernel flavour since it checks
-- against `uname -r` directly, without naming one.
local started = noctalia.runAsync(
[[test -d "/usr/lib/modules/$(uname -r)" && echo present || echo missing]],
function(result)
rebootRecommended = trim(result.stdout or "") == "missing"
finishCheck()
end,
FAST_TIMEOUT_MS
)
if not started then
rebootRecommended = false
finishCheck()
end
end
checkSize = function()
if cfg("show_download_size") ~= true or sources.pacman.n == 0 then
downloadSizeMiB = nil
checkReboot()
return
end
local names = {}
for _, item in ipairs(sources.pacman.items) do
table.insert(names, shellQuote(item.name))
end
if #names == 0 then
-- More pending than MAX_LISTED kept a name for, so the size would
-- under-count. Left unknown instead of wrong.
downloadSizeMiB = nil
checkReboot()
return
end
local cmd = "LC_ALL=C pacman -Si " .. table.concat(names, " ") .. [[ 2>/dev/null | awk '
/^Name/ { name=$3 }
/^Download Size/ && !(name in done) {
done[name]=1
v=$4; u=$5
gsub(",", ".", v)
if (u == "GiB") v = v * 1024
else if (u == "KiB") v = v / 1024
else if (u == "B") v = v / 1024 / 1024
sum += v
}
END { printf "%.2f", sum }']]
local started = noctalia.runAsync(cmd, function(result)
local value = tonumber(trim(result.stdout or ""))
downloadSizeMiB = (not result.timedOut and result.exitCode == 0 and value ~= nil) and value or nil
checkReboot()
end, SIZE_TIMEOUT_MS)
if not started then
downloadSizeMiB = nil
checkReboot()
end
end
checkFlatpak = function(ignored)
if cfg("flatpak_enabled") ~= true or not noctalia.commandExists("flatpak") then
sources.flatpak = { n = 0, items = {} }
checkSize()
return
end
step = tr("source.flatpak")
publish()
-- Flatpak tracks commits, so a version string often doesn't move across
-- an update. Short commits stand in when it doesn't, joined by
-- application id in one awk pass.
-- Each call's own output and exit code are captured before piping into
-- awk, so a real flatpak failure (e.g. no remote, network down) fails
-- the whole command instead of awk quietly succeeding on empty input.
local cmd = [[
listOut=$(flatpak list --columns=application,version,active 2>/dev/null); listCode=$?
updOut=$(flatpak remote-ls --updates --columns=application,version,commit 2>/dev/null); updCode=$?
if [ "$listCode" -ne 0 ] || [ "$updCode" -ne 0 ]; then
exit 1
fi
{ printf '%s\n' "$listOut" | sed 's/^/L /'
printf '%s\n' "$updOut" | 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 }']]
local started = noctalia.runAsync(cmd, function(result)
if result.timedOut then
sources.flatpak = { n = 0, items = {} }
checkSize()
return
end
if result.exitCode ~= 0 then
noctalia.log("arch-updater: flatpak check failed (exit " .. tostring(result.exitCode) .. ")")
failCheck(tr("err_flatpak_failed"))
return
end
local n, items = parseTabLines(result.stdout, ignored)
sources.flatpak = { n = n, items = items }
checkSize()
end, CHECK_TIMEOUT_MS)
if not started then
sources.flatpak = { n = 0, items = {} }
checkSize()
end
end
local function checkAur(ignored)
local helper = resolveAurHelper()
if helper == nil then
sources.aur = { n = 0, items = {}, helper = "" }
checkFlatpak(ignored)
return
end
if helper ~= "custom" and not noctalia.commandExists(helper) then
sources.aur = { n = 0, items = {}, helper = "" }
checkFlatpak(ignored)
return
end
local cmd = aurCheckCommand(helper)
if cmd == nil then
sources.aur = { n = 0, items = {}, helper = "" }
checkFlatpak(ignored)
return
end
step = helper == "custom" and tr("source.aur") or tr("source.aur_named", { helper = helper })
publish()
local started = noctalia.runAsync(cmd, function(result)
if result.timedOut then
sources.aur = { n = 0, items = {}, helper = "" }
checkFlatpak(ignored)
return
end
-- "-Qua" (like plain pacman -Qu) exits non-zero for "nothing to
-- upgrade" too, so exit code alone can't tell that apart from a real
-- failure. A real failure prints something to stderr; "no updates"
-- doesn't.
if result.exitCode ~= 0 and trim(result.stderr or "") ~= "" then
noctalia.log("arch-updater: " .. helper .. " -Qua failed: " .. trim(result.stderr))
failCheck(tr("err_aur_failed"))
return
end
local n, items = parseVersionLines(result.stdout, ignored)
sources.aur = { n = n, items = items, helper = helper == "custom" and "" or helper }
checkFlatpak(ignored)
end, CHECK_TIMEOUT_MS)
if not started then
sources.aur = { n = 0, items = {}, helper = "" }
checkFlatpak(ignored)
end
end
startCheck = function()
if phase == "checking" then
return
end
if not noctalia.commandExists("checkupdates") then
phase = "missing"
errMsg = tr("err_no_checkupdates")
publish()
return
end
phase = "checking"
errMsg = nil
sources = { pacman = { n = 0, items = {} }, aur = { n = 0, items = {}, helper = "" }, flatpak = { n = 0, items = {} } }
downloadSizeMiB = nil
step = tr("source.pacman")
publish()
local ignored = ignoreSet()
-- checkupdates exits 2 for "no updates" (not an error), 1 for a real
-- failure (mirror/db sync, etc). Normalize the former to 0 so only a
-- genuine failure reaches result.exitCode below.
local cmd = [[checkupdates 2>/dev/null; code=$?; if [ "$code" -eq 2 ]; then exit 0; fi; exit "$code"]]
local started = noctalia.runAsync(cmd, function(result)
if result.timedOut then
failCheck(tr("err_check_timeout"))
return
end
if result.exitCode ~= 0 then
noctalia.log("arch-updater: checkupdates failed (exit " .. tostring(result.exitCode) .. ")")
failCheck(tr("err_check_failed"))
return
end
local n, items = parseVersionLines(result.stdout, ignored)
sources.pacman = { n = n, items = items }
checkAur(ignored)
end, CHECK_TIMEOUT_MS)
if not started then
failCheck(tr("err_spawn"))
end
end
finishCheck = function()
total = sources.pacman.n + sources.aur.n + sources.flatpak.n
step = ""
phase = total > 0 and "ready" or "clean"
checkedAt = noctalia.formatTime("%H:%M")
sinceCheck = 0
recordCheck()
publish()
if total > 0 and cfg("notify_on_updates") == true then
noctalia.notify(tr("title"), noctalia.trp("notify_updates", total, { count = total }))
end
end
-- ── Arch Linux news ──────────────────────────────────────────────────────────
local function newsStatePath()
local dir, err = noctalia.pluginDataDir()
if dir == nil then
noctalia.log("arch-updater: cannot resolve plugin data dir: " .. tostring(err))
return nil
end
return dir .. "/" .. NEWS_FILE
end
local function loadNewsState()
if newsStateLoaded then
return
end
newsStateLoaded = true
local path = newsStatePath()
local encoded = path ~= nil and noctalia.readFile(path) or nil
local ok, decoded = pcall(function()
return encoded ~= nil and noctalia.json.decode(encoded) or nil
end)
if ok and type(decoded) == "table" and type(decoded.lastSeenGuid) == "string" then
newsLastSeenGuid = decoded.lastSeenGuid
end
end
local function saveNewsState()
local path = newsStatePath()
if path == nil then
return
end
local encoded = noctalia.json.encode({ lastSeenGuid = newsLastSeenGuid })
if encoded ~= nil then
noctalia.writeFile(path, encoded)
end
end
-- ── Activity history ─────────────────────────────────────────────────────────
local function historyStatePath()
local dir, err = noctalia.pluginDataDir()
if dir == nil then
noctalia.log("arch-updater: cannot resolve plugin data dir: " .. tostring(err))
return nil
end
return dir .. "/" .. HISTORY_FILE
end
local function loadHistoryState()
if historyStateLoaded then
return
end
historyStateLoaded = true
local path = historyStatePath()
local encoded = path ~= nil and noctalia.readFile(path) or nil
local ok, decoded = pcall(function()
return encoded ~= nil and noctalia.json.decode(encoded) or nil
end)
if ok and type(decoded) == "table" then
if type(decoded.history) == "table" then
-- Migrates the old format (a plain array of counts) to entries with
-- a timestamp and an afterUpdate flag, both unknown for old data.
local migrated = {}
for _, entry in ipairs(decoded.history) do
if type(entry) == "table" then
table.insert(migrated, {
n = tonumber(entry.n) or 0,
at = tonumber(entry.at),
afterUpdate = entry.afterUpdate == true,
})
elseif type(entry) == "number" then
table.insert(migrated, { n = entry, at = nil, afterUpdate = false })
end
end
history = migrated
end
if type(decoded.lastUpdateAt) == "number" then
lastUpdateAt = decoded.lastUpdateAt
end
end
end
local function saveHistoryState()
local path = historyStatePath()
if path == nil then
return
end
local encoded = noctalia.json.encode({ history = history, lastUpdateAt = lastUpdateAt })
if encoded ~= nil then
noctalia.writeFile(path, encoded)
end
end
-- Appends the current total to the activity history, trimmed to the
-- configured length. A no-op when the graph is turned off, so disabling it
-- also stops collecting data, not just hides it.
recordCheck = function()
local wasPostUpdate = checkIsPostUpdate
checkIsPostUpdate = false
if cfg("show_activity_graph") ~= true then
return
end
loadHistoryState()
table.insert(history, { n = total, at = os.time(), afterUpdate = wasPostUpdate })
local maxLen = math.max(3, math.min(30, tonumber(cfg("activity_history_length")) or 10))
while #history > maxLen do
table.remove(history, 1)
end
saveHistoryState()
end
-- Marks the check that follows as the one that verifies an update run, so its
-- history entry can say "Updated" instead of just a pending count.
local function recordUpdateRun()
checkIsPostUpdate = true
if cfg("show_activity_graph") ~= true then
return
end
loadHistoryState()
lastUpdateAt = os.time()
saveHistoryState()
end
local HTML_ENTITIES = {
["&lt;"] = "<",
["&gt;"] = ">",
["&quot;"] = '"',
["&#39;"] = "'",
["&apos;"] = "'",
["&amp;"] = "&",
}
local function unescapeHtml(text)
return (text:gsub("&#?%w+;", HTML_ENTITIES))
end
-- Plain RSS 2.0, so a few gmatch patterns are enough, no XML library needed.
-- Wrapped in pcall: a feed change degrades to no news data, not a crash.
local function parseNewsFeed(xml)
local items = {}
for block in xml:gmatch("<item>(.-)</item>") do
local title = block:match("<title>(.-)</title>")
local link = block:match("<link>(.-)</link>")
local guid = block:match("<guid[^>]*>(.-)</guid>")
if title ~= nil and link ~= nil then
table.insert(items, {
title = unescapeHtml(trim(title)),
link = trim(link),
guid = guid ~= nil and trim(guid) or trim(link),
})
end
end
return items
end
local function applyNewsItems(items)
newsItems = items
if #items == 0 then
newsUnread = 0
newsLatestTitle = nil
return
end
newsLatestTitle = items[1].title
if newsLastSeenGuid == nil then
-- First run: today's news is the baseline, not a backlog to alert on.
newsLastSeenGuid = items[1].guid
saveNewsState()
newsUnread = 0
return
end
local unread = 0
for _, item in ipairs(items) do
if item.guid == newsLastSeenGuid then
break
end
unread += 1
end
newsUnread = unread
end
checkNews = function()
if cfg("check_arch_news") ~= true then
return
end
loadNewsState()
local ok = noctalia.http({ url = NEWS_URL }, function(res)
if not res.ok or res.body == nil or res.body == "" then
return
end
local parsed, items = pcall(parseNewsFeed, res.body)
if parsed and type(items) == "table" then
applyNewsItems(items)
newsDirty = true
end
end)
if not ok then
noctalia.log("arch-updater: could not start the Arch news request")
end
end
-- Opens the news page and marks everything fetched so far as read.
local function openNews()
if #newsItems > 0 then
newsLastSeenGuid = newsItems[1].guid
saveNewsState()
newsUnread = 0
publish()
end
noctalia.runAsync("xdg-open " .. shellQuote(NEWS_PAGE) .. " >/dev/null 2>&1")
end
-- ── Updating ─────────────────────────────────────────────────────────────────
-- Uses Noctalia's terminal discovery ($TERMINAL, then the usual emulators)
-- unless a terminal is configured.
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
-- Builds the update command from the AUR helper, ignore list and Flatpak
-- settings. "update_cmd" overrides it outright, for cases the built default
-- doesn't cover.
local function buildUpdateCommand()
local override = trim(cfg("update_cmd"))
if override ~= "" then
updateProcessName = "pacman"
return override
end
local ignored = ignoreList()
local ignoreFlag = #ignored > 0 and (" --ignore " .. table.concat(ignored, ",")) or ""
local yesFlag = cfg("assume_yes") == true and " --noconfirm" or ""
local helper = resolveAurHelper()
local parts = {}
if helper ~= nil and helper ~= "custom" and noctalia.commandExists(helper) then
table.insert(parts, helper .. " -Syu" .. yesFlag .. ignoreFlag)
updateProcessName = helper
else
table.insert(parts, "sudo pacman -Syu" .. yesFlag .. ignoreFlag)
updateProcessName = "pacman"
end
if cfg("flatpak_enabled") == true and noctalia.commandExists("flatpak") then
local flatpakYes = cfg("assume_yes") == true and " -y" or ""
if #ignored > 0 then
-- Same ignore list as pacman/AUR: filter ignored refs out of the
-- pending Flatpak list before updating, so an app hidden from the
-- panel's count can't still slip in through a bare `flatpak update`.
local skipList = shellQuote(table.concat(ignored, "\n"))
table.insert(
parts,
"flatpak_refs=$(flatpak remote-ls --updates --columns=application 2>/dev/null | awk -v ignore="
.. skipList
.. [=[ 'BEGIN { n = split(ignore, arr, "\n"); for (i = 1; i <= n; i++) skip[arr[i]] = 1 } !($0 in skip)'); ]=]
.. [[if [ -n "$flatpak_refs" ]; then flatpak update]]
.. flatpakYes
.. [[ $flatpak_refs; fi]]
)
else
table.insert(parts, "flatpak update" .. flatpakYes)
end
end
table.insert(parts, "echo; echo " .. shellQuote(tr("run.press_key")) .. "; read -n 1")
return table.concat(parts, "; ")
end
local function runUpdate()
if phase == "running" or phase == "checking" then
return
end
if not noctalia.commandExists("checkupdates") then
phase = "missing"
errMsg = tr("err_no_checkupdates")
publish()
return
end
if not launchTerminal(buildUpdateCommand()) 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 update process. Seeing it then losing it means the run
-- ended, and triggers a re-check. Never seeing it within the grace period
-- re-checks anyway instead of sitting 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
recordUpdateRun()
startCheck()
end
end, updateProcessName)
end
-- ── Requests, lifecycle ──────────────────────────────────────────────────────
local function handle(action)
if action == "check" then
-- Also works as a manual unstick: if the process-poll heuristic in
-- pollRun() ever misses the update finishing, this forces a re-check
-- instead of leaving the UI stuck on "running".
startCheck()
elseif action == "update" then
runUpdate()
elseif action == "dismiss" then
sources.pacman = { n = 0, items = {} }
sources.aur = { n = 0, items = {}, helper = sources.aur.helper }
sources.flatpak = { n = 0, items = {} }
total = 0
downloadSizeMiB = nil
phase = "clean"
publish()
elseif action == "open_news" then
openNews()
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 yuuto/arch-updater:service all check
-- noctalia msg plugin yuuto/arch-updater:service all update
-- noctalia msg plugin yuuto/arch-updater:service all dismiss
function onIpc(event, _payload)
handle(event)
end
function update()
if phase == "running" then
runTicks += 1
pollRun()
elseif phase ~= "checking" then
local hours = tonumber(cfg("auto_check_hours")) or 0
if hours > 0 then
if phase == "idle" then
startupTicks += 1
if startupTicks >= AUTO_CHECK_DELAY then
startCheck()
end
else
sinceCheck += 1
if sinceCheck >= hours * 3600 then
startCheck()
end
end
end
end
if cfg("check_arch_news") == true then
sinceNewsCheck += 1
if sinceNewsCheck >= NEWS_RECHECK_HOURS * 3600 or sinceNewsCheck == AUTO_CHECK_DELAY then
sinceNewsCheck = 0
checkNews()
end
end
if newsDirty then
newsDirty = false
publish()
end
end
noctalia.setUpdateInterval(1000)
if not noctalia.commandExists("checkupdates") then
phase = "missing"
errMsg = tr("err_no_checkupdates")
end
loadHistoryState() -- so the graph shows prior sessions' data before the first check runs
publish()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

-176
View File
@@ -1,176 +0,0 @@
{
"action_check": "Updates prüfen",
"action_dismiss": "Verwerfen",
"action_open_news": "News öffnen",
"action_update": "Aktualisieren",
"activity": {
"days_ago": {
"one": "vor 1 Tag",
"other": "vor {count} Tagen"
},
"hours_ago": {
"one": "vor 1 Stunde",
"other": "vor {count} Stunden"
},
"just_now": "gerade eben",
"minutes_ago": {
"one": "vor 1 Minute",
"other": "vor {count} Minuten"
},
"never_updated": "Noch nie aktualisiert",
"pending_at": {
"one": "1 ausstehendes Update",
"other": "{count} ausstehende Updates"
},
"title": "Aktivität",
"updated": "Aktualisiert",
"updated_days_ago": {
"one": "Vor 1 Tag aktualisiert",
"other": "Vor {count} Tagen aktualisiert"
},
"updated_today": "Heute aktualisiert"
},
"caption_checked": "geprüft {time}",
"caption_ignored": {
"one": "1 Paket ignoriert",
"other": "{count} Pakete ignoriert"
},
"err_check_timeout": "Zeitüberschreitung beim Prüfen auf Updates",
"err_no_checkupdates": "checkupdates nicht gefunden, pacman-contrib installieren und PATH prüfen",
"err_no_terminal": "Kein Terminal-Emulator gefunden, in den Plugin-Einstellungen festlegen",
"err_no_xdg_open": "xdg-open nicht gefunden, Paketseite kann nicht geöffnet werden",
"err_spawn": "checkupdates konnte nicht gestartet werden",
"hover_hint": "Zeigt beim Überfahren eines Pakets die vollständigen Versionen",
"launcher": {
"check_subtitle": "Pacman, AUR und Flatpak auf Updates prüfen",
"news_subtitle": "Arch-Linux-News-Seite öffnen",
"update_subtitle": "Terminal öffnen und Update ausführen"
},
"more_packages": "+{count} weitere",
"news_unread": {
"one": "1 ungelesener Arch-News-Beitrag: „{title}“",
"other": "{count} ungelesene Arch-News-Beiträge, zuletzt: „{title}“"
},
"notify_updates": {
"one": "1 Paket zu aktualisieren",
"other": "{count} Pakete zu aktualisieren"
},
"reboot_recommended": "Neustart empfohlen, der laufende Kernel ist nicht mehr installiert",
"run": {
"press_key": "Beliebige Taste zum Schließen drücken"
},
"settings": {
"activity_history_length": {
"description": "Wie viele der letzten Prüfungen für das Aktivitätsdiagramm aufbewahrt werden.",
"label": "Länge des Aktivitätsverlaufs"
},
"assume_yes": {
"description": "Übergibt --noconfirm / -y, damit Paketmanager nicht nach Bestätigung fragen. Aus bedeutet, du bestätigst jedes Paket im Terminal-Fenster selbst.",
"label": "Automatisch mit Ja bestätigen"
},
"aur_check_cmd": {
"description": "Wird nur verwendet, wenn oben „Eigener Befehl“ gewählt ist. Muss pro Paket eine Zeile im Format 'name oldver -> newver' ausgeben, wie 'yay -Qua' es tut.",
"label": "Eigener AUR-Prüfbefehl"
},
"aur_helper": {
"description": "Welcher AUR-Helfer AUR-Pakete prüft und aktualisiert. „Automatisch erkennen“ versucht zuerst yay, dann paru. „Eigener Befehl“ verwendet den unten angegebenen Prüfbefehl.",
"label": "AUR-Helfer",
"options": {
"auto": "Automatisch erkennen (yay, dann paru)",
"custom": "Eigener Befehl",
"off": "Aus, nur Pacman",
"paru": "paru",
"yay": "yay"
}
},
"auto_check_hours": {
"description": "Prüft automatisch alle N Stunden auf Updates. 0 (Standard) prüft nie von selbst.",
"label": "Intervall für automatische Prüfung (Stunden)"
},
"check_arch_news": {
"description": "Ruft den Arch-Linux-News-Feed ab und markiert ungelesene Beiträge im Panel.",
"label": "Arch-Linux-News prüfen"
},
"check_reboot_needed": {
"description": "Prüft, ob die Dateien des aktuell laufenden Kernels noch vorhanden sind. Fehlen sie, wurde ein neuerer Kernel installiert, und erst ein Neustart wechselt darauf.",
"label": "Auf nötigen Neustart prüfen"
},
"flatpak_enabled": {
"description": "Prüft zusätzlich (und führt beim Aktualisieren) 'flatpak update' aus. Wird automatisch übersprungen, wenn Flatpak nicht installiert ist.",
"label": "Flatpak einbeziehen"
},
"glyph": {
"description": "Das für das Widget in der Bar angezeigte Symbol.",
"label": "Bar-Symbol"
},
"hide_on_empty": {
"description": "Blendet das Widget vollständig aus, wenn keine Updates ausstehen und kein Neustart empfohlen wird. Aus (Standard) zeigt das Symbol immer.",
"label": "Ausblenden, wenn nichts anliegt"
},
"ignore_packages": {
"description": "Paketnamen, die aus der Zählung ausgeschlossen und beim Update zusätzlich als --ignore übergeben werden, ergänzend zu pacman.confs eigenem IgnorePkg.",
"label": "Pakete ignorieren"
},
"notify_on_updates": {
"description": "Sendet eine Desktop-Benachrichtigung, wenn eine Prüfung aktualisierbare Pakete findet.",
"label": "Bei gefundenen Updates benachrichtigen"
},
"show_activity_graph": {
"description": "Zeichnet die Anzahl ausstehender Updates über die letzten Prüfungen sowie den letzten Update-Zeitpunkt auf und zeigt sie als kleines Diagramm im Panel. Deaktiviert stoppt auch die Aufzeichnung.",
"label": "Aktivitätsdiagramm anzeigen"
},
"show_count": {
"description": "Zeigt die Anzahl ausstehender Updates neben dem Bar-Symbol.",
"label": "Anzahl der Updates anzeigen"
},
"show_download_size": {
"description": "Schätzt die gesamte Downloadgröße der ausstehenden Pacman-Pakete mit 'pacman -Si'. AUR- und Flatpak-Größen sind nicht enthalten.",
"label": "Downloadgröße anzeigen"
},
"terminal": {
"description": "Terminal-Befehl für den Update-Lauf, z. B. kitty oder ghostty. Leer (Standard) verwendet Noctalias eigene Terminal-Erkennung ($TERMINAL, dann die gängigen Emulatoren).",
"label": "Terminal"
},
"update_cmd": {
"description": "Vollständige Überschreibung des im Terminal ausgeführten Befehls beim Aktualisieren. Leer (Standard) baut ihn aus AUR-Helfer, Ignorierliste, Flatpak und „Automatisch mit Ja bestätigen“ oben zusammen.",
"label": "Eigener Update-Befehl"
}
},
"size_gib": "≈ {value} GiB Downloadgröße",
"size_mib": "≈ {value} MiB Downloadgröße",
"source": {
"aur": "AUR",
"aur_named": "AUR ({helper})",
"flatpak": "Flatpak",
"pacman": "Pacman"
},
"status_checking": "Prüfe auf Updates…",
"status_checking_step": "Prüfe {step}…",
"status_clean": "System ist aktuell",
"status_error": "Prüfung fehlgeschlagen",
"status_idle": "Noch nicht geprüft",
"status_missing": "checkupdates fehlt, pacman-contrib installieren",
"status_ready": {
"one": "1 Paket zu aktualisieren",
"other": "{count} Pakete zu aktualisieren"
},
"status_running": "Update läuft in einem Terminal…",
"tip_check": "Jetzt auf Updates prüfen",
"tip_close": "Schließen",
"tip_copy": "Name und Versionen kopieren",
"tip_open_page": "Paketseite öffnen",
"tip_update": "Update in einem Terminal-Fenster ausführen",
"title": "Arch Updater",
"tooltip_checked": "Geprüft",
"tooltip_hints": "Klick: Panel · rechts: jetzt prüfen",
"tooltip_news": "Arch News",
"tooltip_news_value": {
"one": "1 ungelesen",
"other": "{count} ungelesen"
},
"tooltip_pending": "Ausstehend",
"tooltip_reboot_key": "Neustart",
"tooltip_reboot_value": "empfohlen",
"tooltip_status": "Status",
"up_to_date": "Aktuell: {sources}"
}
-179
View File
@@ -1,179 +0,0 @@
{
"action_check": "Check Updates",
"action_dismiss": "Dismiss",
"action_open_news": "Open news",
"action_update": "Update",
"activity": {
"days_ago": {
"one": "1 day ago",
"other": "{count} days ago"
},
"hours_ago": {
"one": "1 hour ago",
"other": "{count} hours ago"
},
"just_now": "just now",
"minutes_ago": {
"one": "1 minute ago",
"other": "{count} minutes ago"
},
"never_updated": "Never updated",
"pending_at": {
"one": "1 pending update",
"other": "{count} pending updates"
},
"title": "Activity",
"updated": "Updated",
"updated_days_ago": {
"one": "Updated 1 day ago",
"other": "Updated {count} days ago"
},
"updated_today": "Updated today"
},
"caption_checked": "checked {time}",
"caption_ignored": {
"one": "1 package ignored",
"other": "{count} packages ignored"
},
"err_aur_failed": "AUR check failed, see the system log for details",
"err_check_failed": "checkupdates failed, see the system log for details",
"err_check_timeout": "Timed out while checking for updates",
"err_flatpak_failed": "Flatpak check failed, see the system log for details",
"err_no_checkupdates": "checkupdates not found, install pacman-contrib and check your PATH",
"err_no_terminal": "No terminal emulator found, set one in the plugin settings",
"err_no_xdg_open": "xdg-open not found, cannot open the package page",
"err_spawn": "Could not run checkupdates",
"hover_hint": "Hover a package for its full versions",
"launcher": {
"check_subtitle": "Check pacman, AUR and Flatpak for updates",
"news_subtitle": "Open the Arch Linux news page",
"update_subtitle": "Open a terminal and run the update"
},
"more_packages": "+{count} more",
"news_unread": {
"one": "1 unread Arch news post: \"{title}\"",
"other": "{count} unread Arch news posts, latest: \"{title}\""
},
"notify_updates": {
"one": "1 package to upgrade",
"other": "{count} packages to upgrade"
},
"reboot_recommended": "Reboot recommended, the running kernel is no longer installed",
"run": {
"press_key": "Press any key to close"
},
"settings": {
"activity_history_length": {
"description": "How many of the most recent checks to keep for the activity graph.",
"label": "Activity history length"
},
"assume_yes": {
"description": "Pass --noconfirm / -y so package managers do not ask for confirmation. Off means you confirm each one in the terminal window.",
"label": "Answer yes automatically"
},
"aur_check_cmd": {
"description": "Only used when the AUR helper above is 'Custom command'. Must print one 'name oldver -> newver' line per package, like 'yay -Qua' does.",
"label": "Custom AUR check command"
},
"aur_helper": {
"description": "Which AUR helper checks and upgrades AUR packages. 'Auto-detect' tries yay, then paru. 'Custom command' lets you supply your own check command below.",
"label": "AUR helper",
"options": {
"auto": "Auto-detect (yay, then paru)",
"custom": "Custom command",
"off": "Off, pacman only",
"paru": "paru",
"yay": "yay"
}
},
"auto_check_hours": {
"description": "Check for updates automatically every N hours. 0 (default) never checks on its own.",
"label": "Auto-check interval (hours)"
},
"check_arch_news": {
"description": "Fetch the Arch Linux news feed and flag unread posts in the panel.",
"label": "Check Arch Linux news"
},
"check_reboot_needed": {
"description": "Detect whether the currently running kernel's files are still on disk. When they are gone, a newer kernel was installed and a reboot is what switches you to it.",
"label": "Check if a reboot is needed"
},
"flatpak_enabled": {
"description": "Also check (and, on Update, run) 'flatpak update'. Ignored automatically when flatpak is not installed.",
"label": "Include Flatpak"
},
"glyph": {
"description": "The glyph shown for the widget on the bar.",
"label": "Bar glyph"
},
"hide_on_empty": {
"description": "Hide the widget entirely when there are no pending updates and no reboot recommendation. Off (default) always shows the glyph.",
"label": "Hide when there is nothing to show"
},
"ignore_packages": {
"description": "Package names to leave out of the count and pass as --ignore to the update itself, in addition to pacman.conf's own IgnorePkg.",
"label": "Ignore packages"
},
"notify_on_updates": {
"description": "Send a desktop notification when a check finds packages to upgrade.",
"label": "Notify when updates are found"
},
"show_activity_graph": {
"description": "Track pending-update counts across recent checks and when you last updated, shown as a small graph in the panel. Turning this off also stops recording the history.",
"label": "Show activity graph"
},
"show_count": {
"description": "Show the number of pending updates next to the bar glyph.",
"label": "Show the update count"
},
"show_download_size": {
"description": "Estimate the total download size for the pending pacman packages with 'pacman -Si'. AUR and Flatpak sizes are not included.",
"label": "Show download size"
},
"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"
},
"update_cmd": {
"description": "Full override for the command run in the terminal on Update. Empty (default) builds it from the AUR helper, ignore list, Flatpak and 'Answer yes' settings above.",
"label": "Custom update command"
}
},
"size_gib": "≈ {value} GiB to download",
"size_mib": "≈ {value} MiB to download",
"source": {
"aur": "AUR",
"aur_named": "AUR ({helper})",
"flatpak": "Flatpak",
"pacman": "Pacman"
},
"status_checking": "Checking for updates…",
"status_checking_step": "Checking {step}…",
"status_clean": "System is up to date",
"status_error": "Update check failed",
"status_idle": "Not checked yet",
"status_missing": "checkupdates not found, install pacman-contrib",
"status_ready": {
"one": "1 package to upgrade",
"other": "{count} packages to upgrade"
},
"status_running": "Update running in a terminal…",
"tip_check": "Check for updates now",
"tip_close": "Close",
"tip_copy": "Copy name and versions",
"tip_open_page": "Open package page",
"tip_update": "Run the update in a terminal window",
"title": "Arch Updater",
"tooltip_checked": "Checked",
"tooltip_hints": "click: panel · right: check now",
"tooltip_news": "Arch news",
"tooltip_news_value": {
"one": "1 unread",
"other": "{count} unread"
},
"tooltip_pending": "Pending",
"tooltip_reboot_key": "Reboot",
"tooltip_reboot_value": "recommended",
"tooltip_status": "Status",
"up_to_date": "Up to date: {sources}"
}
-38
View File
@@ -1,38 +0,0 @@
{
"action_update": "Mettre à jour",
"caption_ignored": {
"one": "1 paquet ignoré",
"other": "{count} paquets ignorés"
},
"notify_updates": {
"one": "1 paquet à mettre à jour",
"other": "{count} paquets à mettre à jour"
},
"reboot_recommended": "Un redémarrage est recommandé, le kernel en cous d'exécution n'est plus installé",
"settings": {
"aur_helper": {
"options": {
"paru": "paru",
"yay": "yay"
}
},
"flatpak_enabled": {
"label": "Inclure Flatpak"
},
"ignore_packages": {
"label": "Ignorer des paquets"
},
"notify_on_updates": {
"description": "Envoie une notification lorsqu'il y a des paquets à mettre à niveau"
},
"show_count": {
"label": "Afficher le nombre de mises à jour"
},
"show_download_size": {
"label": "Afficher la taille de téléchargement"
},
"terminal": {
"label": "Terminal"
}
}
}
-118
View File
@@ -1,118 +0,0 @@
{
"action_check": "Güncellemeleri kontrol et",
"action_dismiss": "Kapat",
"action_open_news": "Haberleri aç",
"action_update": "Güncelle",
"caption_checked": "kontrol süresi {time}",
"caption_ignored": {
"one": "1 paket yoksayıldı",
"other": "{count} paket yoksayıldı"
},
"err_aur_failed": "AUR kontrolü başarısız, detaylar için sistem loglarına bak",
"err_check_failed": "checkupdates başarısız, detaylar için sistem log'larını kontrol et",
"err_check_timeout": "Güncellemeler kontrol edilirken zaman aşımına uğrandı",
"err_flatpak_failed": "Flatpak kontrolü başarısız, detaylar için sistem log'larını kontrol et",
"err_no_checkupdates": "checkupdates bulunamadı, pacman-contrib'i yükle ve PATH'i kontrol et",
"err_no_terminal": "Terminal emülatörü bulunamadı, eklenti ayarlarından ekle",
"err_no_xdg_open": "xdg-open bulunamadı, paket sayfası açılamıyor",
"err_spawn": "checkupdates çalıştırılamadı",
"hover_hint": "Bir paketin full versiyonları için fareyle üzerine gelin",
"launcher": {
"check_subtitle": "pacman, AUR, ve Flatpak'i güncellemeler için kontrol et",
"news_subtitle": "Arch Linux bültenini aç",
"update_subtitle": "Terminali aç ve güncellemeleri başlat"
},
"more_packages": "+{count} tane daha",
"news_unread": {
"one": "Okunmamış 1 Arch haberi mevcut: \"{title}\"",
"other": "Okunmamış {couınt} Arch haberleri mevcut, sonuncusu: \"{title}\""
},
"notify_updates": {
"one": "Güncellenecek 1 paket mevcut",
"other": "Güncellenecek {count} paket mevcut"
},
"reboot_recommended": "Yeniden başlatma önerilir, mevcutta çalışan kernel artık yüklü değil",
"run": {
"press_key": "Kapatmak için bir tuşa basın"
},
"settings": {
"assume_yes": {
"description": "Paket yöneticilerinin onay istememesi için --noconfirm / -y bayraklarını ekle. Ayar kapalıysa tek tek terminal ekranından onaylamanız gerekir.",
"label": "Otomatikman evetle cevapla"
},
"aur_check_cmd": {
"description": "Yalnızca yukarıdaki AUR yardımcısı 'Özel komut'ken kullanılır. Paket başına, 'yay -Qua' komutunun yaptığı gibi, tek satır 'isim eski-ver. -> yeni-ver.' yazdırması zorunludur.",
"label": "Özel AUR kontrol komutu"
},
"aur_helper": {
"description": "AUR paketlerini kontrol eden ve güncelleyen AUR yardımcısı. 'Otomatik tespit' önce yay'ı, sonra da paru'yu dener. 'Özel komut' ise aşağıya kendi kontrol komutunuzu girmenizi sağlar.",
"label": "AUR yardımcısı",
"options": {
"auto": "Otomatik tespit (önce yay sonra paru)",
"custom": "Özel komut",
"off": "Kapalı, sadece pacman",
"paru": "paru",
"yay": "yay"
}
},
"auto_check_hours": {
"description": "Her N saatte bir güncellemeleri otomatikman kontrol et. 0 (varsayılan) hiçbir zaman kendisi kontrol etmez.",
"label": "Otomatik kontrol aralığı (saat)"
},
"check_arch_news": {
"label": "Arch Linux bültenini kontrol et"
},
"check_reboot_needed": {
"label": "Yeniden başlatma gerekliliğini kontrol et"
},
"flatpak_enabled": {
"label": "Flatpak'i dahil et"
},
"glyph": {
"description": "Çubukta gösterilen bileşen simgesi.",
"label": "Çubuk simgesi"
},
"hide_on_empty": {
"label": "Gösterilecek birşey yokken gizle"
},
"ignore_packages": {
"label": "Paketleri görmezden gel"
},
"notify_on_updates": {
"description": "Güncellenmeyi bekleyen paketler bulunduğunda bir bildirim gönder.",
"label": "Güncelleme bulunduğunda bildirim ver"
},
"show_count": {
"description": "Çubuk simgesinin yanında bekleyen güncelleme sayısını göster.",
"label": "Güncelleme adedini göster"
},
"show_download_size": {
"label": "İndirme boyutunu göster"
},
"terminal": {
"label": "Terminal"
}
},
"size_gib": "≈ {value} GiB indirilecek",
"size_mib": "≈ {value} MiB indirilecek",
"source": {
"aur": "AUR",
"aur_named": "AUR ({helper})",
"flatpak": "Flatpak",
"pacman": "Pacman"
},
"status_checking": "Güncellemeler kontrol ediliyor…",
"status_checking_step": "{step} kontrol adımı…",
"status_clean": "Sistem güncel",
"tooltip_checked": "Kontrol edildi",
"tooltip_news": "Arch bülteni",
"tooltip_news_value": {
"one": "1 okunmayan",
"other": "{count} okunmayan"
},
"tooltip_pending": "Bekleyen",
"tooltip_reboot_key": "Yeniden başlat",
"tooltip_reboot_value": "önerilir",
"tooltip_status": "Durum",
"up_to_date": "Güncel: {sources}"
}
-152
View File
@@ -1,152 +0,0 @@
--!nonstrict
-- arch-updater bar widget: pending-update badge and panel toggle.
--
-- Pure renderer over the "arch_state" the engine (service.luau) publishes.
-- Every bar showing the widget agrees on the count without running any
-- command itself. Actions go back as "arch_request" entries, so one engine
-- owns the checks and the update run no matter how many widgets exist.
--
-- Click mapping:
-- Left click: open/close the panel
-- Right click: check for updates now
--
-- No middle-click handler: the host already binds it to the widget's own
-- settings, so a callback here would be dead code.
local PANEL_ID = "yuuto/arch-updater:panel"
local REQUEST_KEY = "arch_request"
local STATE_KEY = "arch_state"
local snapshot = nil
local function tr(key, args)
return noctalia.tr(key, args)
end
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 pending()
return snapshot ~= nil
and snapshot.phase == "ready"
and (snapshot.total or 0) > 0
end
-- "Pacman 12 · AUR (yay) 3 · Flatpak 1": non-zero sources only.
local function breakdown()
if snapshot == nil then
return ""
end
local parts = {}
local pacman = snapshot.pacman
if type(pacman) == "table" and (pacman.n or 0) > 0 then
table.insert(parts, tr("source.pacman") .. " " .. pacman.n)
end
local aur = snapshot.aur
if type(aur) == "table" and (aur.n or 0) > 0 then
local label = (aur.helper ~= nil and aur.helper ~= "") and tr("source.aur_named", { helper = aur.helper }) or tr("source.aur")
table.insert(parts, label .. " " .. aur.n)
end
local flatpak = snapshot.flatpak
if type(flatpak) == "table" and (flatpak.n or 0) > 0 then
table.insert(parts, tr("source.flatpak") .. " " .. flatpak.n)
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
local function render()
barWidget.setGlyph(noctalia.getConfig("glyph"))
local phase = snapshot ~= nil and snapshot.phase or "idle"
local reboot = snapshot ~= nil and snapshot.rebootRecommended == true
if phase == "missing" or phase == "error" then
barWidget.setGlyphColor("error")
elseif phase == "checking" or phase == "running" then
barWidget.setGlyphColor("secondary")
elseif reboot then
barWidget.setGlyphColor("warning")
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
local empty = snapshot == nil or (snapshot.total or 0) == 0
barWidget.setVisible(not (noctalia.getConfig("hide_on_empty") == true and empty and not reboot))
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 reboot then
table.insert(rows, { key = tr("tooltip_reboot_key"), value = tr("tooltip_reboot_value") })
end
if snapshot ~= nil and (snapshot.newsUnread or 0) > 0 then
table.insert(rows, { key = tr("tooltip_news"), value = noctalia.trp("tooltip_news_value", snapshot.newsUnread, {}) })
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/visibility 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()
-123
View File
@@ -1,123 +0,0 @@
# Audio Switcher
Audio Switcher puts PipeWire inputs, outputs, volume controls, and Bluetooth
audio handoff in one compact Noctalia panel. Devices can be renamed or hidden,
multiple outputs can be grouped for simultaneous playback, and compositor
keybinds can cycle devices or connect a specific Bluetooth device by its
persistent number.
![Audio Switcher panel](screenshots/panel.webp)
Add the **Audio Switcher** widget from Noctalia's bar editor. Left click opens
the panel, right click cycles through visible outputs, and middle click cycles
through visible inputs. Scrolling over it changes the output volume.
## Plugin
| Field | Value |
| --- | --- |
| ID | `blackbartblues/audio-switcher` |
| Entries | Widget: `widget`; panel: `audio-switcher`; service: `service` |
## Requirements
Install `pactl`, `bluetoothctl`, and `sleep` on `PATH`. On Arch Linux they are
provided by the `libpulse`, `bluez-utils`, and `coreutils` packages respectively.
PipeWire's PulseAudio compatibility service and BlueZ must be running.
Output grouping also requires PulseAudio's or PipeWire Pulse's
`module-combine-sink`.
## Usage
Open the panel from a configured bar widget or run:
```sh
noctalia msg panel-toggle blackbartblues/audio-switcher:audio-switcher
```
The top sliders control the default output and input volume. Select **Outputs**
or **Inputs**, then choose **Use**. For a disconnected Bluetooth output the same
button connects it, waits for its PipeWire endpoint, makes it the default, and
moves current playback streams to it.
When an input exposes multiple hardware ports, such as an internal microphone
and a headset microphone, the active port name is shown in the panel and
widget. Use the port selector below the input to switch it; **Cycle** also
includes every currently available port.
Use the pencil button to set a local display name, choose the device icon, and
change a Bluetooth keybind number. The number is assigned automatically after a
device connects successfully for the first time and can then be changed. Hidden
devices remain available in the panel but are skipped by cycling commands.
To play through multiple outputs at once, open **Outputs**, choose **Group
outputs**, select at least two available physical outputs, then choose **Create
group**. The combined output becomes the default and current playback streams
move to it. Use **Disband** on its row to remove it; if it is active, playback
moves to the first available member before removal.
Use the settings button in the panel header to open Noctalia's plugin settings.
## Settings
| Entry | Setting | Type | Default | Description |
| --- | --- | --- | --- | --- |
| Plugin | `show_percentage` | `bool` | `true` | Show the output volume beside the bar icon; disable it for an icon-only widget. |
| Plugin | `show_notification_on_switch` | `bool` | `true` | Show a notification when switching device. |
| Plugin | `show_actions_in_tooltip` | `bool` | `true` | Show actions in tooltip. |
## IPC and keybinds
The background service exposes commands that can be used by any compositor:
```sh
# Next non-hidden output. Disconnected Bluetooth outputs are connected as needed.
noctalia msg plugin blackbartblues/audio-switcher:service all cycle-output
# Next non-hidden, currently available input.
noctalia msg plugin blackbartblues/audio-switcher:service all cycle-input
# Connect the Bluetooth device assigned to number 2 and use its output.
noctalia msg plugin blackbartblues/audio-switcher:service all connect 2
# Refresh device state.
noctalia msg plugin blackbartblues/audio-switcher:service all refresh
```
For example, Niri bindings can spawn the commands directly:
```kdl
Mod+F9 { spawn "noctalia" "msg" "plugin" "blackbartblues/audio-switcher:service" "all" "cycle-output"; }
Mod+F10 { spawn "noctalia" "msg" "plugin" "blackbartblues/audio-switcher:service" "all" "cycle-input"; }
Mod+1 { spawn "noctalia" "msg" "plugin" "blackbartblues/audio-switcher:service" "all" "connect" "1"; }
```
Equivalent Hyprland bindings:
```ini
bind = SUPER, F9, exec, noctalia msg plugin blackbartblues/audio-switcher:service all cycle-output
bind = SUPER, F10, exec, noctalia msg plugin blackbartblues/audio-switcher:service all cycle-input
bind = SUPER, 1, exec, noctalia msg plugin blackbartblues/audio-switcher:service all connect 1
```
## Notes
Preferences are written to `preferences.json` in Noctalia's data directory for
this plugin. They contain only aliases, hidden flags, remembered Bluetooth input
capabilities, MAC addresses, keybind numbers, and per-device icon choices.
Before connecting a requested Bluetooth audio device, Audio Switcher disconnects
other Bluetooth audio devices connected to this computer. It cannot disconnect
the target from another computer or phone; that device must release the target
first unless it supports multipoint connections.
Output groups are `module-combine-sink` instances owned by the running audio
server. Audio Switcher rediscovers its groups after the plugin service restarts,
but they disappear if PipeWire/PulseAudio restarts. Combining outputs may add
latency and resampling CPU cost, especially when the devices use different
clocks or sample rates.
The plugin does not access the network. Its service spawns only the declared
`pactl`, `bluetoothctl`, and `sleep` commands. The short sleep is used while
waiting for a newly connected Bluetooth endpoint to appear. The panel may also
invoke the local Noctalia executable to open the plugin settings page.
-627
View File
@@ -1,627 +0,0 @@
--!nonstrict
local SNAPSHOT_KEY = "audio_switcher_snapshot"
local COMMAND_KEY = "audio_switcher_command"
local RESULT_KEY = "audio_switcher_result"
local snapshot = noctalia.state.get(SNAPSHOT_KEY) or {
available = false,
bluetoothAvailable = false,
loading = true,
busy = false,
scanning = false,
outputs = {},
inputs = {},
defaultOutputId = "",
defaultInputId = "",
outputVolume = 0,
inputVolume = 0,
outputMuted = false,
inputMuted = false,
error = "",
updatedAt = 0,
}
local activeTab = "output"
local showHidden = false
local editingId = nil
local editAlias = ""
local editIconStyle = "automatic"
local editSlot = ""
local feedback = ""
local feedbackError = false
local requestCounter = 0
local groupingOutputs = false
local selectedGroupOutputs = {}
-- Slider onChange is also emitted when the host applies a new controlled value.
-- Keep the last callback value only for onDragEnd; rendering from it would turn
-- the first external update into a sticky local draft and freeze later updates.
local outputVolumeCommitValue = nil
local inputVolumeCommitValue = nil
local volumeControlRevision = 0
local render
local ICON_STYLE_VALUES = { "automatic", "speaker", "over_ear", "tws", "wired" }
local function tr(key, substitutions)
return noctalia.tr(key, substitutions)
end
local function asArray(value)
return type(value) == "table" and value or {}
end
local function nextRequestId()
requestCounter += 1
return "panel-" .. tostring(requestCounter)
end
local function sendCommand(action, values)
local command = { action = action, requestId = nextRequestId() }
if type(values) == "table" then
for key, value in pairs(values) do command[key] = value end
end
noctalia.state.set(COMMAND_KEY, command)
return command.requestId
end
local function activeDevice(kind)
local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
for _, device in ipairs(devices) do
if device.active then return device end
end
return nil
end
local function inputDisplayName(device)
if device == nil then return "" end
local port = noctalia.string.trim(tostring(device.activePortDescription or ""))
return port ~= "" and port or tostring(device.name or "")
end
local function visibleCount(kind)
local count = 0
local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
for _, device in ipairs(devices) do
if not device.hidden then count += 1 end
end
return count
end
local function hiddenCount(kind)
local count = 0
local devices = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
for _, device in ipairs(devices) do
if device.hidden then count += 1 end
end
return count
end
local function selectedGroupCount()
local count = 0
for _, selected in pairs(selectedGroupOutputs) do
if selected then count += 1 end
end
return count
end
local function selectedIndex(values, selected)
for index, value in ipairs(values) do
if value == selected then return index - 1 end
end
return 0
end
local function volumeCard(kind)
local isOutput = kind == "output"
local device = activeDevice(kind)
local volume = tonumber(isOutput and snapshot.outputVolume or snapshot.inputVolume) or 0
local muted = (isOutput and snapshot.outputMuted == true) or (not isOutput and snapshot.inputMuted == true)
local icon = isOutput and (muted and "volume-off" or "volume") or (muted and "microphone-mute" or "microphone")
return ui.column({ flexGrow = 1, gap = 8, padding = 12, radius = 12, fill = "surface_variant/0.45" }, {
ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = isOutput and "volume" or "microphone", size = 18, color = isOutput and "primary" or "tertiary" }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = tr(isOutput and "panel.output_volume" or "panel.input_volume"), fontWeight = "bold" }),
ui.label({
text = device and (isOutput and device.name or inputDisplayName(device)) or tr("panel.no_device"),
fontSize = 11,
color = "on_surface_variant",
maxLines = 1,
}),
}),
ui.label({ text = tostring(volume) .. "%", fontSize = 11, color = "on_surface_variant" }),
ui.button({
glyph = icon,
variant = muted and "destructive" or "ghost",
controlSize = "sm",
tooltip = tr(muted and "panel.unmute" or "panel.mute"),
enabled = device ~= nil,
onClick = isOutput and "onToggleOutputMute" or "onToggleInputMute",
}),
}),
ui.slider({
key = "volume-" .. kind .. "-" .. tostring(volumeControlRevision),
min = 0,
max = 100,
step = 1,
value = volume,
enabled = device ~= nil,
onChange = isOutput and "onOutputVolumeChange" or "onInputVolumeChange",
onDragEnd = isOutput and "onOutputVolumeCommit" or "onInputVolumeCommit",
}),
})
end
local function deviceStatus(device)
if device.active then return tr("device.active") end
if device.bluetooth and not device.available then return tr("device.bluetooth_disconnected") end
if device.bluetooth then return tr("device.bluetooth_connected") end
return tr("device.available")
end
local function deviceSubtitle(device)
local status = deviceStatus(device)
local description = noctalia.string.trim(tostring(device.description or ""))
local port = noctalia.string.trim(tostring(device.activePortDescription or ""))
if port ~= "" and port ~= device.name and port ~= description then status = status .. " · " .. port end
if description == "" or description == "(null)" or description:lower() == "null" then return status end
if description == device.name or description == port then return status end
return status .. " · " .. description
end
local function editor(device)
local children = {
ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "edit", size = 16, color = "primary" }),
ui.label({ text = tr("editor.title"), fontWeight = "bold", flexGrow = 1 }),
ui.button({ glyph = "close", variant = "ghost", controlSize = "sm", onClick = "onCancelEdit" }),
}),
ui.label({ text = tr("editor.alias"), fontSize = 11, color = "on_surface_variant" }),
ui.input({
key = "alias-" .. tostring(device.id),
value = editAlias,
placeholder = device.description,
onChange = "onEditAliasChange",
}),
ui.label({ text = tr("editor.icon"), fontSize = 11, color = "on_surface_variant" }),
ui.select({
options = {
tr("editor.icon_options.automatic"),
tr("editor.icon_options.speaker"),
tr("editor.icon_options.over_ear"),
tr("editor.icon_options.tws"),
tr("editor.icon_options.wired"),
},
selectedIndex = selectedIndex(ICON_STYLE_VALUES, editIconStyle),
width = 220,
onChange = "onEditIconChange",
}),
}
if device.bluetooth then
table.insert(children, ui.label({ text = tr("editor.slot"), fontSize = 11, color = "on_surface_variant" }))
table.insert(children, ui.input({
key = "slot-" .. tostring(device.id),
value = editSlot,
placeholder = "1",
onChange = "onEditSlotChange",
}))
table.insert(children, ui.label({ text = tr("editor.slot_hint"), fontSize = 10, color = "on_surface_variant", maxLines = 2 }))
end
table.insert(children, ui.row({ justify = "end", gap = 8 }, {
ui.button({ text = tr("actions.cancel"), variant = "ghost", onClick = "onCancelEdit" }),
ui.button({ text = tr("actions.save"), glyph = "device-floppy", variant = "primary", onClick = "onSaveEdit" }),
}))
return ui.column({ gap = 7, padding = 10, radius = 9, fill = "surface_variant/0.45", border = "primary/0.35", borderWidth = 1 }, children)
end
local function deviceRow(device, kind)
local useAction = function()
sendCommand(kind == "output" and "set_output" or "set_input", { id = device.id })
end
local editAction = function()
editingId = device.id
editAlias = tostring(device.name or "")
editIconStyle = tostring(device.iconStyle or "automatic")
editSlot = device.slot and tostring(device.slot) or ""
feedback = ""
render()
end
local hideAction = function()
sendCommand("set_hidden", { id = device.id, kind = kind, hidden = not device.hidden })
end
local selectAction = function()
selectedGroupOutputs[device.id] = not selectedGroupOutputs[device.id]
render()
end
local disbandAction = function()
sendCommand("remove_output_group", { id = device.id })
end
local ports = kind == "input" and asArray(device.ports) or {}
local portOptions = {}
local selectedPortIndex = 0
for index, port in ipairs(ports) do
local label = tostring(port.description or port.name or "")
if port.available == false then label = tr("device.unavailable_port", { port = label }) end
portOptions[#portOptions + 1] = label
if port.active then selectedPortIndex = index - 1 end
end
local selectPortAction = function(index, _label)
local port = ports[math.floor(tonumber(index) or -1) + 1]
if port ~= nil and not port.active then
sendCommand("set_input_port", { id = device.id, port = port.name })
end
end
local actionText = device.active and tr("device.current")
or (device.bluetooth and not device.available and tr("actions.connect_and_use") or tr("actions.use"))
local actionVariant = device.active and "ghost" or "primary"
local statusColor = device.active and "secondary" or "on_surface_variant"
local actions = {}
if groupingOutputs and kind == "output" then
table.insert(actions, ui.button({
text = selectedGroupOutputs[device.id] and tr("actions.selected") or tr("actions.select"),
glyph = selectedGroupOutputs[device.id] and "check" or "plus",
variant = selectedGroupOutputs[device.id] and "primary" or "outline",
controlSize = "sm",
enabled = device.available == true and device.group ~= true and snapshot.busy ~= true,
onClick = selectAction,
}))
else
table.insert(actions, ui.button({
text = actionText,
variant = actionVariant,
controlSize = "sm",
enabled = not device.active and snapshot.busy ~= true,
onClick = useAction,
}))
if kind == "output" and device.group then
table.insert(actions, ui.button({
text = tr("actions.disband"),
glyph = "unlink",
variant = "outline",
controlSize = "sm",
enabled = snapshot.busy ~= true,
onClick = disbandAction,
}))
else
table.insert(actions, ui.button({ glyph = "edit", variant = "ghost", controlSize = "sm", tooltip = tr("actions.edit"), onClick = editAction }))
table.insert(actions, ui.button({
glyph = device.hidden and "eye" or "eye-off",
variant = "ghost",
controlSize = "sm",
tooltip = tr(device.hidden and "actions.show" or "actions.hide"),
onClick = hideAction,
}))
end
end
local rowChildren = {
ui.glyph({ name = device.icon or (kind == "output" and "volume" or "microphone"), size = 19, color = device.active and "primary" or "on_surface_variant" }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = tostring(device.name or device.description or device.id), fontWeight = "bold", maxLines = 1 }),
ui.label({
text = deviceSubtitle(device),
fontSize = 10,
color = statusColor,
maxLines = 1,
}),
}),
ui.label({
text = device.slot and ("#" .. tostring(device.slot)) or "",
color = "primary",
fontSize = 11,
fontWeight = "bold",
visible = device.bluetooth == true,
}),
}
for _, action in ipairs(actions) do table.insert(rowChildren, action) end
local row = ui.column({ gap = 6 }, {
ui.row({ align = "center", gap = 8, padding = 7, radius = 9, border = selectedGroupOutputs[device.id] and "primary" or (device.active and "primary" or "outline/0.55"), borderWidth = 1, fill = selectedGroupOutputs[device.id] and "primary/0.1" or (device.active and "primary/0.07" or "surface/0") }, rowChildren),
})
local children = { row }
if kind == "input" and #ports > 1 then
table.insert(children, ui.row({ gap = 8, align = "center", paddingH = 8 }, {
ui.label({ text = tr("device.port"), color = "on_surface_variant", fontSize = 10 }),
ui.select({
options = portOptions,
selectedIndex = selectedPortIndex,
flexGrow = 1,
enabled = snapshot.busy ~= true,
onChange = selectPortAction,
}),
}))
end
if editingId == device.id then table.insert(children, editor(device)) end
return ui.column({ gap = 6 }, children)
end
local function groupingToolbar()
if activeTab ~= "output" then return ui.column({ visible = false }, {}) end
if not groupingOutputs then
return ui.row({ justify = "end" }, {
ui.button({
text = tr("actions.group_outputs"),
glyph = "link",
variant = "outline",
enabled = snapshot.busy ~= true,
onClick = "onStartGrouping",
}),
})
end
local count = selectedGroupCount()
return ui.row({ align = "center", gap = 8, padding = 8, radius = 9, fill = "primary/0.08" }, {
ui.label({
text = tr("panel.group_hint", { count = count }),
flexGrow = 1,
color = "on_surface_variant",
fontSize = 11,
}),
ui.button({ text = tr("actions.cancel"), variant = "ghost", onClick = "onCancelGrouping" }),
ui.button({
text = tr("actions.create_group", { count = count }),
glyph = "link",
variant = "primary",
enabled = count >= 2 and snapshot.busy ~= true,
onClick = "onCreateGroup",
}),
})
end
local function deviceList(kind)
local source = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
local rows = {}
for _, device in ipairs(source) do
if showHidden or not device.hidden then
table.insert(rows, deviceRow(device, kind))
end
end
if #rows == 0 then
table.insert(rows, ui.column({ align = "center", justify = "center", gap = 8, padding = 24 }, {
ui.glyph({ name = kind == "output" and "volume-off" or "microphone-off", size = 32, color = "on_surface_variant" }),
ui.label({ text = tr(showHidden and "panel.no_devices" or "panel.no_visible_devices"), color = "on_surface_variant", textAlign = "center" }),
}))
end
return ui.column({ gap = 6 }, rows)
end
local function header()
local output = activeDevice("output")
local input = activeDevice("input")
local subtitle = (output and output.name or tr("panel.no_output")) .. " · "
.. (input and inputDisplayName(input) or tr("panel.no_input"))
return ui.row({ align = "center", gap = 8 }, {
ui.glyph({ name = "switch-horizontal", size = 24, color = "primary" }),
ui.column({ flexGrow = 1, gap = 2 }, {
ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold" }),
ui.label({ text = subtitle, fontSize = 11, color = "on_surface_variant", maxLines = 1 }),
}),
ui.button({
glyph = "bluetooth",
variant = snapshot.scanning and "primary" or "ghost",
tooltip = tr(snapshot.scanning and "panel.scanning" or "panel.scan_bluetooth"),
enabled = snapshot.bluetoothAvailable == true and snapshot.scanning ~= true,
onClick = "onScanBluetooth",
}),
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("actions.refresh"), onClick = "onRefresh" }),
ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("actions.settings"), onClick = "onOpenSettingsClicked" }),
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("actions.close"), onClick = "onCloseClicked" }),
})
end
local function toolbar()
local kind = activeTab
local hidden = hiddenCount(kind)
return ui.row({ align = "center", gap = 4 }, {
ui.button({
text = tr("tabs.outputs"),
glyph = "volume",
variant = activeTab == "output" and "primary" or "ghost",
selected = activeTab == "output",
onClick = "onShowOutputs",
}),
ui.button({
text = tr("tabs.inputs"),
glyph = "microphone",
variant = activeTab == "input" and "primary" or "ghost",
selected = activeTab == "input",
onClick = "onShowInputs",
}),
ui.spacer({ flexGrow = 1 }),
ui.label({
text = tr("panel.visible_count", { count = visibleCount(kind) }),
fontSize = 10,
color = "on_surface_variant",
}),
ui.button({
text = hidden > 0 and tr("panel.hidden_count", { count = hidden }) or tr("panel.hidden"),
glyph = showHidden and "eye" or "eye-off",
variant = showHidden and "primary" or "ghost",
selected = showHidden,
enabled = hidden > 0 or showHidden,
onClick = "onToggleHidden",
}),
ui.button({
text = tr("actions.cycle"),
glyph = "refresh",
variant = "outline",
enabled = snapshot.busy ~= true,
onClick = activeTab == "output" and "onCycleOutput" or "onCycleInput",
}),
})
end
render = function()
local statusRows = {}
if snapshot.loading then
table.insert(statusRows, ui.label({ text = tr("panel.loading"), color = "primary" }))
end
if snapshot.busy then
table.insert(statusRows, ui.label({ text = tr("panel.switching"), color = "primary" }))
end
if tostring(snapshot.error or "") ~= "" then
table.insert(statusRows, ui.label({ text = snapshot.error, color = "error", maxLines = 2 }))
end
if feedback ~= "" then
table.insert(statusRows, ui.label({ text = feedback, color = feedbackError and "error" or "secondary", maxLines = 2 }))
end
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, {
header(),
ui.row({ gap = 12, align = "stretch" }, { volumeCard("output"), volumeCard("input") }),
toolbar(),
groupingToolbar(),
ui.column({ gap = 3 }, statusRows),
ui.scroll({ flexGrow = 1, gap = 6 }, { deviceList(activeTab) }),
ui.label({
text = tr("panel.keybind_hint"),
fontSize = 10,
color = "on_surface_variant",
maxLines = 2,
}),
}))
end
function onOpen(_context)
feedback = ""
feedbackError = false
groupingOutputs = false
selectedGroupOutputs = {}
sendCommand("refresh")
render()
end
function onConfigChanged()
render()
end
function onCloseClicked() panel.close() end
function onRefresh() sendCommand("refresh") end
function onScanBluetooth() sendCommand("scan_bluetooth") end
function onCycleOutput() sendCommand("cycle_output") end
function onCycleInput() sendCommand("cycle_input") end
function onOpenSettingsClicked() noctalia.openSettings() end
function onShowOutputs()
activeTab = "output"
editingId = nil
showHidden = false
render()
end
function onShowInputs()
activeTab = "input"
editingId = nil
showHidden = false
groupingOutputs = false
selectedGroupOutputs = {}
render()
end
function onStartGrouping()
groupingOutputs = true
selectedGroupOutputs = {}
editingId = nil
showHidden = false
feedback = ""
render()
end
function onCancelGrouping()
groupingOutputs = false
selectedGroupOutputs = {}
render()
end
function onCreateGroup()
local ids = {}
for _, device in ipairs(asArray(snapshot.outputs)) do
if selectedGroupOutputs[device.id] then table.insert(ids, device.id) end
end
if #ids < 2 then return end
sendCommand("create_output_group", { ids = ids })
end
function onToggleHidden()
showHidden = not showHidden
editingId = nil
render()
end
function onCancelEdit()
editingId = nil
render()
end
function onEditAliasChange(value) editAlias = tostring(value or "") end
function onEditIconChange(index, _label)
editIconStyle = ICON_STYLE_VALUES[(math.floor(tonumber(index) or 0)) + 1] or "automatic"
end
function onEditSlotChange(value) editSlot = tostring(value or "") end
function onSaveEdit()
local devices = activeTab == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
for _, device in ipairs(devices) do
if device.id == editingId then
sendCommand("update_device", {
id = device.id,
address = device.address,
alias = editAlias,
iconStyle = editIconStyle,
slot = editSlot,
})
editingId = nil
render()
return
end
end
end
function onOutputVolumeChange(value)
outputVolumeCommitValue = math.floor(tonumber(value) or 0)
end
function onInputVolumeChange(value)
inputVolumeCommitValue = math.floor(tonumber(value) or 0)
end
-- Noctalia's slider onDragEnd callback has no arguments. Keep the latest
-- onChange value for the commit without using it as controlled render state.
function onOutputVolumeCommit()
local value = outputVolumeCommitValue or snapshot.outputVolume
outputVolumeCommitValue = nil
sendCommand("set_output_volume", { value = value })
end
function onInputVolumeCommit()
local value = inputVolumeCommitValue or snapshot.inputVolume
inputVolumeCommitValue = nil
sendCommand("set_input_volume", { value = value })
end
function onToggleOutputMute() sendCommand("toggle_output_mute") end
function onToggleInputMute() sendCommand("toggle_input_mute") end
noctalia.state.watch(SNAPSHOT_KEY, function(value)
if type(value) == "table" then
snapshot = value
render()
end
end)
noctalia.state.watch(RESULT_KEY, function(result)
if type(result) ~= "table" or not tostring(result.requestId or ""):match("^panel%-") then return end
feedback = tostring(result.message or "")
feedbackError = result.ok ~= true
if result.ok == true and result.action == "create_output_group" then
groupingOutputs = false
selectedGroupOutputs = {}
end
if result.ok ~= true and (result.action == "set_output_volume" or result.action == "set_input_volume") then
outputVolumeCommitValue = nil
inputVolumeCommitValue = nil
-- A failed optimistic edit may have moved the native slider while the
-- controlled snapshot stayed unchanged. Recreate both controls so their
-- visual values are seeded from the authoritative snapshot again.
volumeControlRevision += 1
end
render()
end)
render()
-55
View File
@@ -1,55 +0,0 @@
id = "blackbartblues/audio-switcher"
name = "Audio Switcher"
version = "0.5.0"
plugin_api = 15
author = "blackbartblues"
license = "MIT"
icon = "switch-horizontal"
description = "Switch audio inputs and outputs, hand off Bluetooth devices, and control them from compositor keybinds."
tags = ["audio", "bar", "hardware", "panel", "service", "system", "utility"]
dependencies = ["pactl", "bluetoothctl", "sleep"]
[[setting]]
key = "show_percentage"
type = "bool"
label_key = "settings.show_percentage.label"
description_key = "settings.show_percentage.description"
default = true
[[setting]]
key = "show_notification_on_switch"
type = "bool"
label_key = "settings.show_notification_on_switch.label"
description_key = "settings.show_notification_on_switch.description"
default = true
[[setting]]
key = "show_actions_in_tooltip"
type = "bool"
label_key = "settings.show_actions_in_tooltip.label"
description_key = "settings.show_actions_in_tooltip.description"
default = true
[[widget]]
id = "widget"
entry = "widget.luau"
[widget.actions]
scroll_up = "volume-up"
scroll_down = "volume-down"
left = "panel-toggle blackbartblues/audio-switcher:audio-switcher"
right = "plugin blackbartblues/audio-switcher:service all cycle-output"
middle = "plugin blackbartblues/audio-switcher:service all cycle-input"
[[panel]]
id = "audio-switcher"
entry = "panel.luau"
width = 620
height = 650
placement = "attached"
position = "top_right"
open_near_click = true
[[service]]
id = "service"
entry = "service.luau"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
local sourceFile = assert(io.open("service.luau", "rb"))
local source = sourceFile:read("*a")
sourceFile:close()
local beginMarker = "-- BEGIN INPUT PORT HELPERS"
local endMarker = "-- END INPUT PORT HELPERS"
local beginAt = assert(source:find(beginMarker, 1, true), "input port helper start marker missing")
local bodyAt = assert(source:find("\n", beginAt, true)) + 1
local endAt = assert(source:find(endMarker, bodyAt, true), "input port helper end marker missing")
local helperSource = source:sub(bodyAt, endAt - 1)
local loader = assert(load(helperSource .. "\nreturn parseInputPorts", "input port helpers", "t", _G))
local parseInputPorts = loader()
local ports, active = parseInputPorts({
active_port = "analog-input-internal-mic",
ports = {
{ name = "analog-input-internal-mic", description = "Internal Microphone", availability = "available" },
{ name = "analog-input-headset-mic", description = "Headset Microphone", availability = "not available" },
{ name = "analog-input-mic", description = "Microphone", availability = "unknown" },
},
})
assert(active == "analog-input-internal-mic")
assert(#ports == 3)
assert(ports[1].active and ports[1].available and ports[1].description == "Internal Microphone")
assert(not ports[2].active and not ports[2].available and ports[2].description == "Headset Microphone")
assert(ports[3].available, "unknown availability should remain selectable")
local objectPorts, objectActive = parseInputPorts({
active_port = { name = "mic" },
ports = { { name = "mic", description = "", availability = "available" } },
})
assert(objectActive == "mic" and objectPorts[1].active)
assert(objectPorts[1].description == "mic", "port name should be the description fallback")
print("audio input port tests: ok")
-158
View File
@@ -1,158 +0,0 @@
local function clone(value)
if type(value) ~= "table" then return value end
local result = {}
for key, item in pairs(value) do result[clone(key)] = clone(item) end
return result
end
local values = {}
local watchers = {}
local pending = {}
local streamCallback = nil
local decoded = {
INFO = {
default_sink_name = "sink.main",
default_source_name = "source.main",
server_name = "PulseAudio (on PipeWire 1.6.8)",
},
SINKS_UNMUTED = {
{
name = "sink.main",
description = "Main output",
mute = false,
volume = { front_left = { value_percent = "42%" } },
properties = {},
},
},
SOURCES_UNMUTED = {
{
name = "source.main",
description = "Main input",
mute = false,
volume = { front_left = { value_percent = "37%" } },
properties = {},
},
},
OUTPUT_VOLUME = {
volume = { front_left = { value_percent = "42%" } },
},
INPUT_VOLUME = {
volume = { front_left = { value_percent = "37%" } },
},
MUTED = { mute = true },
UNMUTED = { mute = false },
}
noctalia = {
pluginDataDir = function() return nil end,
getConfig = function() return nil end,
commandExists = function(command) return command == "pactl" end,
readFile = function() return nil end,
writeFile = function() return true end,
setUpdateInterval = function() end,
runAsync = function(command, callback)
pending[#pending + 1] = { command = command, callback = callback }
return true
end,
runStream = function(_command, callback)
streamCallback = callback
return true
end,
json = {
decode = function(value) return clone(decoded[value]) end,
encode = function() return "PREFERENCES" end,
},
string = {
trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") or "" end,
},
state = {
get = function(key) return clone(values[key]) end,
set = function(key, value)
values[key] = clone(value)
if watchers[key] ~= nil then watchers[key](clone(value)) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
tr = function(key) return key end,
log = function() end,
notify = function() end,
notifyError = function() end,
}
local function complete(expected, stdout, exitCode)
local call = table.remove(pending, 1)
assert(call ~= nil, "expected pending command containing " .. expected)
assert(call.command:find(expected, 1, true) ~= nil, "unexpected command: " .. call.command)
call.callback({
exitCode = exitCode or 0,
stdout = stdout or "",
stderr = "",
timedOut = false,
stdoutTruncated = false,
stderrTruncated = false,
})
end
local function snapshot()
return values["audio_switcher_snapshot"]
end
local serviceFile = assert(io.open("service.luau", "r"))
local serviceSource = serviceFile:read("*a")
serviceFile:close()
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%+=%s*([^\n]+)", "%1 = %1 + %2")
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%-=%s*([^\n]+)", "%1 = %1 - %2")
assert(load(serviceSource, "@service.luau"))()
assert(type(streamCallback) == "function", "pactl subscription was not started")
complete("'info'", "INFO")
complete("'list' 'short' 'modules'", "")
complete("'list' 'sinks'", "SINKS_UNMUTED")
complete("'list' 'sources'", "SOURCES_UNMUTED")
assert(snapshot().outputMuted == false, "initial output mute state is incorrect")
assert(snapshot().inputMuted == false, "initial input mute state is incorrect")
noctalia.state.set("audio_switcher_command", {
requestId = "mute-output",
action = "toggle_output_mute",
})
streamCallback("Event 'change' on sink #1")
complete("'set-sink-mute'", "")
assert(snapshot().outputMuted == true, "successful output toggle did not update the snapshot immediately")
assert(snapshot().outputs[1].muted == true, "successful output toggle did not update the active device")
complete("'get-sink-volume'", "OUTPUT_VOLUME")
complete("'get-sink-mute'", "MUTED")
complete("'get-sink-volume'", "OUTPUT_VOLUME")
complete("'get-sink-mute'", "MUTED")
assert(snapshot().outputMuted == true, "authoritative output mute refresh lost the toggled state")
noctalia.state.set("audio_switcher_command", {
requestId = "mute-input",
action = "toggle_input_mute",
})
complete("'set-source-mute'", "")
assert(snapshot().inputMuted == true, "successful input toggle did not update the snapshot immediately")
assert(snapshot().inputs[1].muted == true, "successful input toggle did not update the active device")
complete("'get-source-volume'", "INPUT_VOLUME")
complete("'get-source-mute'", "MUTED")
assert(snapshot().inputMuted == true, "authoritative input mute refresh lost the toggled state")
noctalia.state.set("audio_switcher_command", {
requestId = "unmute-output",
action = "toggle_output_mute",
})
complete("'set-sink-mute'", "")
assert(snapshot().outputMuted == false, "successful output unmute did not update the snapshot immediately")
assert(snapshot().inputMuted == true, "output unmute changed the input mute state")
complete("'get-sink-volume'", "OUTPUT_VOLUME")
complete("'get-sink-mute'", "UNMUTED")
assert(snapshot().outputMuted == false, "authoritative output refresh lost the unmuted state")
assert(#pending == 0, "mute toggles unexpectedly started a full device refresh")
print("audio-switcher mute state tests: ok")
-200
View File
@@ -1,200 +0,0 @@
local function clone(value)
if type(value) ~= "table" then return value end
local result = {}
for key, item in pairs(value) do result[clone(key)] = clone(item) end
return result
end
local values = {}
local watchers = {}
local pending = {}
local decoded = {
INFO_PHYSICAL = {
default_sink_name = "sink.a",
default_source_name = "source.main",
server_name = "PulseAudio (on PipeWire 1.6.8)",
},
INFO_GROUPED = {
default_sink_name = "noctalia_group_123_1",
default_source_name = "source.main",
server_name = "PulseAudio (on PipeWire 1.6.8)",
},
SINKS_PHYSICAL = {
{
name = "sink.a",
description = "Speakers",
mute = false,
volume = { front_left = { value_percent = "40%" } },
properties = {},
},
{
name = "sink.b",
description = "Headphones",
mute = false,
volume = { front_left = { value_percent = "40%" } },
properties = {},
},
},
SINKS_GROUPED = {
{
name = "sink.a",
description = "Speakers",
mute = false,
volume = { front_left = { value_percent = "40%" } },
properties = {},
},
{
name = "sink.b",
description = "Headphones",
mute = false,
volume = { front_left = { value_percent = "40%" } },
properties = {},
},
{
name = "noctalia_group_123_1",
description = "Noctalia Output Group",
mute = false,
volume = { front_left = { value_percent = "40%" } },
properties = {},
},
},
SOURCES = {
{
name = "source.main",
description = "Microphone",
mute = false,
volume = { front_left = { value_percent = "30%" } },
properties = {},
},
},
STREAMS_EMPTY = {},
}
local fakeTime = 123
noctalia = {
pluginDataDir = function() return nil end,
getConfig = function() return nil end,
commandExists = function(command) return command == "pactl" end,
readFile = function() return nil end,
writeFile = function() return true end,
setUpdateInterval = function() end,
runAsync = function(command, callback)
pending[#pending + 1] = { command = command, callback = callback }
return true
end,
runStream = function() return true end,
json = {
decode = function(value) return clone(decoded[value]) end,
encode = function() return "PREFERENCES" end,
},
string = {
trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") or "" end,
},
state = {
get = function(key) return clone(values[key]) end,
set = function(key, value)
values[key] = clone(value)
if watchers[key] ~= nil then watchers[key](clone(value)) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
tr = function(key) return key end,
log = function() end,
notify = function() end,
notifyError = function() end,
}
local function complete(expected, stdout, exitCode)
local call = table.remove(pending, 1)
assert(call ~= nil, "expected pending command containing " .. expected)
assert(call.command:find(expected, 1, true) ~= nil, "unexpected command: " .. call.command)
call.callback({
exitCode = exitCode or 0,
stdout = stdout or "",
stderr = "",
timedOut = false,
stdoutTruncated = false,
stderrTruncated = false,
})
end
local function completeRefresh(info, modules, sinks)
complete("'info'", info)
complete("'list' 'short' 'modules'", modules)
complete("'list' 'sinks'", sinks)
complete("'list' 'sources'", "SOURCES")
end
local originalTime = os.time
os.time = function() return fakeTime end
local serviceFile = assert(io.open("service.luau", "r"))
local serviceSource = serviceFile:read("*a")
serviceFile:close()
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%+=%s*([^\n]+)", "%1 = %1 + %2")
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%-=%s*([^\n]+)", "%1 = %1 - %2")
assert(load(serviceSource, "@service.luau"))()
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
noctalia.state.set("audio_switcher_command", {
requestId = "create-group",
action = "create_output_group",
ids = { "sink.a", "sink.b" },
})
assert(pending[1].command:find("'sinks=sink.a,sink.b'", 1, true), "PipeWire must use the sinks module option")
complete("'load-module' 'module-combine-sink'", "77\n")
complete("'set-default-sink' 'noctalia_group_123_1'")
complete("'list' 'sink-inputs'", "STREAMS_EMPTY")
completeRefresh(
"INFO_GROUPED",
"77\tmodule-combine-sink\tsink_name=noctalia_group_123_1 sinks=sink.a,sink.b sink_properties=device.description=Noctalia_Output_Group\t\n",
"SINKS_GROUPED"
)
local snapshot = values["audio_switcher_snapshot"]
local group = nil
for _, output in ipairs(snapshot.outputs) do
if output.group then group = output end
end
assert(group ~= nil, "plugin-created combine sink was not detected")
assert(group.active == true, "new output group is not active")
assert(group.groupModuleId == 77, "module index was not retained")
assert(#group.groupMembers == 2, "group members were not parsed")
noctalia.state.set("audio_switcher_command", {
requestId = "remove-group",
action = "remove_output_group",
id = group.id,
})
complete("'set-default-sink' 'sink.a'")
complete("'list' 'sink-inputs'", "STREAMS_EMPTY")
complete("'unload-module' '77'")
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
snapshot = values["audio_switcher_snapshot"]
for _, output in ipairs(snapshot.outputs) do
assert(output.group ~= true, "removed group remained in the snapshot")
end
decoded.INFO_PHYSICAL.server_name = "pulseaudio"
noctalia.state.set("audio_switcher_command", {
requestId = "refresh-pulseaudio",
action = "refresh",
})
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
noctalia.state.set("audio_switcher_command", {
requestId = "create-pulseaudio-group",
action = "create_output_group",
ids = { "sink.a", "sink.b" },
})
assert(pending[1].command:find("'slaves=sink.a,sink.b'", 1, true), "PulseAudio must use the slaves module option")
complete("'load-module' 'module-combine-sink'", "", 1)
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
assert(#pending == 0, "unexpected commands remain pending")
os.time = originalTime
print("audio-switcher output group tests: ok")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

-149
View File
@@ -1,149 +0,0 @@
{
"actions": {
"cancel": "Cancel",
"close": "Close",
"connect_and_use": "Connect & use",
"create_group": "Create group ({count})",
"cycle": "Cycle",
"disband": "Disband",
"edit": "Edit device",
"group_outputs": "Group outputs",
"hide": "Hide device",
"refresh": "Refresh",
"save": "Save",
"select": "Select",
"selected": "Selected",
"settings": "Plugin settings",
"show": "Show device",
"use": "Use"
},
"device": {
"active": "Active",
"available": "Available",
"bluetooth_connected": "Bluetooth connected",
"bluetooth_disconnected": "Bluetooth disconnected",
"current": "Current",
"output_group": "Output group",
"port": "Port",
"unavailable_port": "{port} (unavailable)"
},
"editor": {
"alias": "Display name",
"icon": "Device icon",
"icon_options": {
"automatic": "Automatic",
"over_ear": "Over-ear headphones",
"speaker": "Speaker",
"tws": "TWS earbuds",
"wired": "Wired headphones"
},
"slot": "Keybind number",
"slot_hint": "Use this number with the connect IPC command, for example: connect 2.",
"title": "Device preferences"
},
"errors": {
"audio_unavailable": "The audio service is unavailable.",
"bluetooth_audio_timeout": "Bluetooth connected, but its audio output did not appear in PipeWire in time.",
"bluetooth_connect_failed": "Could not connect the Bluetooth device.",
"bluetooth_device_not_found": "The Bluetooth device was not found. Try scanning again.",
"bluetooth_pair_failed": "Could not pair the Bluetooth device.",
"busy": "Another audio operation is still running.",
"command_start": "Could not start the system command.",
"device_not_found": "The selected device no longer exists.",
"device_unavailable": "The selected device is not currently available.",
"group_create_failed": "Could not create the output group.",
"group_not_found": "The output group no longer exists.",
"group_remove_failed": "Could not disband the output group.",
"group_requires_two": "Select at least two available outputs.",
"group_switch_failed": "The output group was created, but could not be selected.",
"input_port_not_found": "The selected input port no longer exists.",
"input_port_switch_failed": "Could not switch the input port.",
"input_port_unavailable": "The selected input port is not currently available.",
"invalid_audio_data": "The audio service returned invalid data.",
"invalid_slot": "The keybind number must be a whole number from 1 to 99.",
"invalid_volume": "The requested volume is invalid.",
"no_visible_devices": "There are no visible devices to cycle through.",
"pactl_missing": "pactl is required but was not found on PATH.",
"scan_in_progress": "Bluetooth scanning is already in progress.",
"slot_in_use": "Keybind number {slot} is already assigned to another device.",
"slot_not_found": "No Bluetooth device is assigned to keybind number {slot}.",
"switch_failed": "Could not switch the default audio device."
},
"notifications": {
"bluetooth_disconnected": "Bluetooth device disconnected.",
"group_created": "Output group created and selected.",
"group_removed": "Output group disbanded.",
"input_port_selected": "Input port switched to {port}.",
"input_selected": "Input switched to {device}.",
"output_selected": "Output switched to {device}.",
"preferences_saved": "Device preferences saved.",
"scan_complete": "Bluetooth scan complete.",
"slot_saved": "Keybind number {slot} saved."
},
"panel": {
"group_hint": "{count} selected. Choose at least two available outputs.",
"hidden": "Hidden",
"hidden_count": "Hidden {count}",
"input_volume": "Microphone",
"keybind_hint": "Hidden devices are skipped by cycle-output and cycle-input. Bluetooth numbers can be used with the connect IPC command.",
"loading": "Loading audio devices…",
"mute": "Mute",
"no_device": "No device",
"no_devices": "No audio devices found.",
"no_input": "No input",
"no_output": "No output",
"no_visible_devices": "All devices in this list are hidden.",
"output_volume": "Sound",
"scan_bluetooth": "Scan for Bluetooth audio devices",
"scanning": "Scanning for Bluetooth devices…",
"switching": "Switching audio device…",
"unmute": "Unmute",
"visible_count": "Visible {count}"
},
"settings": {
"scroll_step": {
"description": "How many percentage points each mouse-wheel step changes volume on the bar widget.",
"label": "Scroll step"
},
"show_actions_in_tooltip": {
"description": "Show actions in tooltip.",
"label": "Show actions in tooltip"
},
"show_notification_on_switch": {
"description": "Show a notification when switching device.",
"label": "Show notification on switch"
},
"show_percentage": {
"description": "Show the current output volume percentage next to the bar icon. Disable it to show only the icon.",
"label": "Show percentage"
}
},
"tabs": {
"inputs": "Inputs",
"outputs": "Outputs"
},
"title": "Audio Switcher",
"widget": {
"action": {
"left_click": {
"description": "Open Audio Switcher",
"key": "Left click"
},
"middle_click": {
"description": "Cycle visible inputs",
"key": "Middle click"
},
"right_click": {
"description": "Cycle visible outputs",
"key": "Right click"
},
"scroll": {
"description": "Change output volume",
"key": "Scroll"
}
},
"input": "Input",
"output": "Output",
"tooltip": "Output: {output} ({output_volume})\nInput: {input} ({input_volume})\nScroll: change output volume\nLeft click: open Audio Switcher\nRight click: cycle outputs\nMiddle click: cycle inputs"
}
}
-74
View File
@@ -1,74 +0,0 @@
--!nonstrict
local SNAPSHOT_KEY = "audio_switcher_snapshot"
local snapshot = noctalia.state.get(SNAPSHOT_KEY) or {
available = false,
loading = true,
outputs = {},
inputs = {},
outputVolume = 0,
inputVolume = 0,
outputMuted = false,
}
local function activeDevice(devices, fallback)
for _, device in ipairs(type(devices) == "table" and devices or {}) do
if device.active == true then return device end
end
return nil
end
local function percent(value)
return tostring(math.floor((tonumber(value) or 0) + 0.5)) .. "%"
end
local function render()
local outputDevice = activeDevice(snapshot.outputs)
local inputDevice = activeDevice(snapshot.inputs)
local outputName = outputDevice and tostring(outputDevice.name or "") or noctalia.tr("panel.no_output")
local inputPortName = inputDevice and tostring(inputDevice.activePortDescription or "") or ""
local inputName = inputDevice and (inputPortName ~= "" and inputPortName or tostring(inputDevice.name or ""))
or noctalia.tr("panel.no_input")
local outputVolume = percent(snapshot.outputVolume)
local inputVolume = percent(snapshot.inputVolume)
local glyph = snapshot.outputMuted == true and "volume-off" or "volume"
if outputDevice ~= nil and snapshot.outputMuted ~= true then
glyph = tostring(outputDevice.icon or "volume")
end
barWidget.setGlyph(glyph)
if snapshot.busy == true then
barWidget.setGlyphColor("secondary")
elseif snapshot.available == true then
barWidget.setGlyphColor("default")
else
barWidget.setGlyphColor("error")
end
local showPercentage = noctalia.getConfig("show_percentage") ~= false
barWidget.setText(showPercentage and not barWidget.isVertical() and outputVolume or "")
local tooltipItems = {{key = noctalia.tr("widget.output"), value = `{outputName} ({outputVolume})`},
{key = noctalia.tr("widget.input"), value = `{inputName} ({inputVolume})`}}
if noctalia.getConfig("show_actions_in_tooltip") == true then
table.insert(tooltipItems, {key = noctalia.tr("widget.action.scroll.key"), value = noctalia.tr("widget.action.scroll.description")})
table.insert(tooltipItems, {key = noctalia.tr("widget.action.left_click.key"), value = noctalia.tr("widget.action.left_click.description")})
table.insert(tooltipItems, {key = noctalia.tr("widget.action.right_click.key"), value = noctalia.tr("widget.action.right_click.description")})
table.insert(tooltipItems, {key = noctalia.tr("widget.action.middle_click.key"), value = noctalia.tr("widget.action.middle_click.description")})
end
barWidget.setTooltip(tooltipItems)
end
noctalia.state.watch(SNAPSHOT_KEY, function(value)
if type(value) == "table" then
snapshot = value
render()
end
end)
function onConfigChanged()
render()
end
render()
-39
View File
@@ -1,39 +0,0 @@
# Battery graph
Uses the Upowerd to display the historical charge of the battery on a graph.
## Plugin
| Field | Value |
| --- | --- |
| ID | `frai3mega/battery-graph` |
| Entries | Bar widget: `battery-graph-widget`; panel: `battery-panel`; Desktop widget: `battery-graph-desktop` |
## Requirements
Requires a running `upowerd` deamon.
Install `gdbus` on `PATH`.
## Usage
You can use it as a desktop/locksreen widget or on the bar.
Show the panel using
```sh
noctalia msg panel-toggle frai3mega/battery-graph:battery-panel
```
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `graph_time` | `int` | `6` | Changes the timeframe of the graph |
| `battery_path` | `string` | `/org/freedesktop/UPower/devices/battery_BAT0` | The path to the battery to be graphed |
| `interpol_time` | `int` | `5` | The time beetween points on the graph. |
```
-79
View File
@@ -1,79 +0,0 @@
graph_time = 3600 * noctalia.getConfig("graph_time")
battery_path = noctalia.getConfig("battery_path")
interpol_time = 60 * noctalia.getConfig("interpol_time")
local function validate(s)
if s:match("^/org/freedesktop/UPower/devices/[%a%d_]+$") ~= nil then
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
else
noctalia.log("Invalid battery dbus path. Check that it is correct")
end
end
local cmd = table.concat({"gdbus call --system --dest org.freedesktop.UPower --object-path",validate(battery_path),"--method org.freedesktop.UPower.Device.GetHistory charge",graph_time, "1000"}, " ")
function update()
noctalia.setUpdateInterval(interpol_time * 1000)
noctalia.runAsync(cmd, parse)
end
function parse(result)
local points = {}
for tuple in result.stdout:gmatch("%(([^%)]+)%)") do
local fields = {}
for field in tuple:gmatch("[^,]+") do
field = field:match("^%s*(.-)%s*$") -- trim whitespace
field = field:gsub("^%S+%s+", "") -- strip "uint32 " style prefix (only if followed by more content)
table.insert(fields, field)
end
local ts, value = tonumber(fields[1]), tonumber(fields[2])
if value ~= 0.0 then
points[ts] = value
end
end
local interpolated_points = interpolate(points)
render(table.concat(interpolated_points, " "), interpolated_points)
end
function interpolate(points )
local interpolated_points = {}
local tkeys = {}
for k in pairs(points) do
table.insert(tkeys, k)
end
table.sort(tkeys)
-- use the keys to retrieve the values in the sorted order
local prev_sample = 1
local first_sample_time = tkeys[1]
table.insert(interpolated_points, tonumber(points[tkeys[1]])/100)
local time_done = 0
while tkeys[prev_sample + 1] and time_done < graph_time do
local cur_time = time_done + first_sample_time
if cur_time > tkeys[prev_sample + 1] then
prev_sample += 1
end
table.insert(interpolated_points, tonumber(points[tkeys[prev_sample]])/100)
time_done += interpol_time
end
return interpolated_points
end
function render(point_str,points)
panel.render(ui.column({ gap = 8, radius = 8, align = "stretch", fill = "surface_variant" }, {
ui.row( {paddingH= 10, paddingV = 6},{ ui.label({text = noctalia.tr("text.graph_name"), fontSize = 24, fontWeight = "heavy", textAlign = "center"}),}),
ui.graph({
values = points,
color = "primary",
lineWidth = 2,
fillOpacity = 0.45,
height = 200,
width = 400
}),
}))
end
-8
View File
@@ -1,8 +0,0 @@
function update()
noctalia.setUpdateInterval(1000)
barWidget.setGlyph("battery")
end
function onClick()
noctalia.togglePanel("frai3mega/battery-graph:battery-panel")
end
-82
View File
@@ -1,82 +0,0 @@
graph_time = 3600 * noctalia.getConfig("graph_time")
battery_path = noctalia.getConfig("battery_path")
interpol_time = 60 * noctalia.getConfig("interpol_time")
local function quote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
local function validate(s)
if s:match("^/org/freedesktop/UPower/devices/[%a%d_]+$") ~= nil then
return quote(s)
else
noctalia.log("Invalid battery dbus path. Check that it is correct")
end
end
local cmd = table.concat({"gdbus call --system --dest org.freedesktop.UPower --object-path",validate(battery_path),"--method org.freedesktop.UPower.Device.GetHistory charge",quote( graph_time ), "1000"}, " ")
function onOpen()
-- noctalia.setUpdateInterval(1000)
noctalia.runAsync(cmd, parse)
end
function parse(result)
local points = {}
for tuple in result.stdout:gmatch("%(([^%)]+)%)") do
local fields = {}
for field in tuple:gmatch("[^,]+") do
field = field:match("^%s*(.-)%s*$") -- trim whitespace
field = field:gsub("^%S+%s+", "") -- strip "uint32 " style prefix (only if followed by more content)
table.insert(fields, field)
end
local ts, value = tonumber(fields[1]), tonumber(fields[2])
if value ~= 0.0 then
points[ts] = value
end
end
local interpolated_points = interpolate(points)
render(table.concat(interpolated_points, " "), interpolated_points)
end
function interpolate(points )
local interpolated_points = {}
local tkeys = {}
for k in pairs(points) do
table.insert(tkeys, k)
end
table.sort(tkeys)
-- use the keys to retrieve the values in the sorted order
local prev_sample = 1
local first_sample_time = tkeys[1]
table.insert(interpolated_points, tonumber(points[tkeys[1]])/100)
local time_done = 0
while tkeys[prev_sample + 1] and time_done < graph_time do
local cur_time = time_done + first_sample_time
if cur_time > tkeys[prev_sample + 1] then
prev_sample += 1
end
table.insert(interpolated_points, tonumber(points[tkeys[prev_sample]])/100)
time_done += interpol_time
end
return interpolated_points
end
function render(point_str,points)
panel.render(ui.column({ gap = 8, radius = 8, align = "stretch", fill = "surface_variant" }, {
ui.row( {paddingH= 10, paddingV = 6},{ ui.label({text = noctalia.tr("text.graph_name"), fontSize = 24, fontWeight = "heavy", textAlign = "center"}),}),
ui.graph({
values = points,
color = "primary",
lineWidth = 2,
fillOpacity = 0.45,
height = 200,
}),
}))
end
-44
View File
@@ -1,44 +0,0 @@
id = "frai3mega/battery-graph"
name = "Battery graph"
version = "1.0.0"
author = "frai3mega"
license = "MIT"
icon = "battery"
description = "Plots battery level on a graph"
plugin_api = 3
tags = ["hardware", "utility", "desktop", "panel"]
dependencies = ["upowerd", "gdbus"]
[[setting]]
key = "graph_time"
type = "int"
label_key = "settings.graph_time.label"
description_key = "settings.graph_time.description"
default = 6
[[setting]]
key = "battery_path"
type = "string"
label_key = "settings.battery_path.label"
description_key = "settings.battery_path.description"
default = "/org/freedesktop/UPower/devices/battery_BAT0"
[[setting]]
key = "interpol_time"
type = "int"
label_key = "settings.interpol_time.label"
description_key = "settings.interpol_time.description"
default = 5
[[widget]]
id = "battery-graph-widget"
entry = "battery-graph.luau"
[[panel]]
id = "battery-panel"
entry = "battery-panel.luau"
[[desktop_widget]]
id = "battery-graph-desktop"
entry = "battery-desktop.luau"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

-19
View File
@@ -1,19 +0,0 @@
{
"settings": {
"battery_path": {
"description": "Dbus path to the upower battery device",
"label": "Battery path"
},
"graph_time": {
"description": "How long of a timeframe does the graph plot in hours",
"label": "Graph timeframe"
},
"interpol_time": {
"description": "How long should the thime between points be in minutes",
"label": "Time between points"
}
},
"text": {
"graph_name": "Battery"
}
}
-63
View File
@@ -1,63 +0,0 @@
# Battery Threshold Control
Control the battery charge threshold on laptop batteries to help extend overall
battery lifespan. Someone would use this plugin to limit maximum charge levels
while plugged in, reducing battery wear and heat.
## Plugin
| Field | Value |
| ------- | ------------------------------------------------------------------- |
| ID | `damian-ds7/battery-threshold` |
| Entries | Bar widget: `battery-threshold`; panel: `panel`; service: `service` |
## Requirements
- Laptop hardware supporting battery charge threshold control in sysfs
(`/sys/class/power_supply/*/charge_control_end_threshold`).
- The following external programs must be available on `PATH`: `test`, `sudo`,
`bash`, `readlink`, `cat`, `getent`, `groupadd`, `usermod`, `udevadm`, `chgrp`, and `chmod`.
## Usage
- **Bar Widget (`battery-threshold`)**: Displays the current battery threshold
in the bar. Click to toggle the panel.
- **Panel (`panel`)**: Adjust the battery threshold using a slider (40–100%).
Includes a **Configure Permissions** button if write access is missing. Toggle
the panel using:
```sh
noctalia msg panel-toggle damian-ds7/battery-threshold:panel
```
## Settings
| Setting | Type | Default | Description |
| ------------------ | -------- | ------------------------------ | ---------------------------------------------- |
| `battery_device` | `folder` | `/sys/class/power_supply/BAT0` | Path to the battery sysfs directory. |
| `charge_threshold` | `int` | `80` | Default charge threshold percentage (40–100%). |
## IPC
```sh
# Set charge threshold percentage (between 40 and 100)
noctalia msg plugin damian-ds7/battery-threshold:service all set 80
# Trigger setup script for udev permissions
noctalia msg plugin damian-ds7/battery-threshold:service all setup
```
## Notes
- **Supported Devices**: Only works on laptops with battery charge threshold
support (ThinkPad, ASUS), tested on Asus Zenbook 14
- **Permissions & Setup**: Requires write access to
`/sys/class/power_supply/BAT0/charge_control_end_threshold`. Automated setup
creates the `battery_ctl` group, adds the active user to it, and installs
`99-battery-threshold.rules` to `/etc/udev/rules.d/`.
- **Relogin / Reboot**: A logout or system reboot is required after running
setup for `battery_ctl` group membership changes to take effect.
- **Manual Setup Fallback**: If Polkit is not available, run
`sudo ./setup_rules.sh` manually from the plugin directory.
- **Persistence**: Threshold settings are stored in `threshold.txt` in plugin
data directory and restored across reboots.
-172
View File
@@ -1,172 +0,0 @@
--!nonstrict
local is_open = false
local function render_panel()
if not is_open then
return
end
local is_available = noctalia.state.get("is_available") == true
local is_writable = noctalia.state.get("is_writable") == true
local threshold = noctalia.state.get("current_threshold") or 0
local model_name = noctalia.state.get("battery_model_name") or ""
local title_text = noctalia.tr("panel.title")
local sub_text = ""
if not is_available then
sub_text = noctalia.tr("panel.not-available")
else
sub_text = model_name
end
local hint_text = ""
local hint_color = "on_surface_variant"
if not is_writable then
hint_text = noctalia.tr("panel.read-only")
hint_color = "error"
else
hint_text = noctalia.tr("panel.adjust-limit")
end
local children = {
ui.row({ align = "center", justify = "space_between" }, {
ui.column({ gap = 2 }, {
ui.label({
text = title_text,
fontSize = 16,
fontWeight = "bold",
color = "primary",
}),
ui.label({
text = sub_text,
fontSize = 12,
color = "on_surface_variant",
}),
}),
ui.button({
glyph = "close",
variant = "ghost",
onClick = "onCloseClicked",
}),
}),
}
if is_available then
table.insert(
children,
ui.separator({ thickness = 1, color = "outline/0.3", spacing = 8 })
)
if is_writable then
table.insert(
children,
ui.column({ gap = 8 }, {
ui.row({ align = "center", justify = "space_between" }, {
ui.label({
text = title_text,
fontSize = 14,
color = "on_surface",
}),
ui.label({
text = tostring(threshold) .. "%",
fontSize = 16,
fontWeight = "bold",
color = "primary",
}),
}),
ui.row({ align = "center", gap = 8 }, {
ui.label({
text = "40%",
fontSize = 11,
color = "on_surface_variant",
}),
ui.slider({
min = 40,
max = 100,
step = 5,
value = threshold,
enabled = is_writable,
onChange = "onSliderChange",
}),
ui.label({
text = "100%",
fontSize = 11,
color = "on_surface_variant",
}),
}),
ui.label({
text = hint_text,
fontSize = 11,
color = hint_color,
textAlign = "center",
}),
})
)
else
table.insert(
children,
ui.column({ gap = 12, align = "center" }, {
ui.label({
text = hint_text,
fontSize = 12,
color = "error",
textAlign = "center",
maxLines = 2,
}),
ui.button({
text = noctalia.tr("panel.button"),
glyph = "shield-lock",
variant = "primary",
onClick = "onRunSetup",
}),
})
)
end
end
panel.render(ui.column({ gap = 12, padding = 12 }, children))
end
function onOpen(context: string?)
is_open = true
render_panel()
end
function onClose()
is_open = false
end
function onSliderChange(value: string)
local num = tonumber(value)
if num then
noctalia.state.set("set_threshold_request", num)
noctalia.state.set("current_threshold", num)
render_panel()
end
end
function onCloseClicked()
panel.close()
end
function onRunSetup()
noctalia.state.set("run_setup_request", true)
end
noctalia.state.watch("current_threshold", function()
if is_open then
render_panel()
end
end)
noctalia.state.watch("is_writable", function()
if is_open then
render_panel()
end
end)
noctalia.state.watch("is_available", function()
if is_open then
render_panel()
end
end)
-43
View File
@@ -1,43 +0,0 @@
id = "damian-ds7/battery-threshold"
name = "Battery Threshold Control"
version = "1.0.1"
plugin_api = 3
author = "Damian D'Souza"
description = "Set the battery threshold for laptop batteries to extend battery lifespan"
license = "MIT"
icon = "battery-eco"
tags = ["hardware", "system", "utility", "bar", "panel"]
dependencies = ["test", "sudo", "bash", "readlink", "cat", "getent", "groupadd", "usermod", "udevadm", "chgrp", "chmod"]
[[service]]
id = "service"
entry = "service.luau"
[[widget]]
id = "battery-threshold"
entry = "widget.luau"
[[panel]]
id = "panel"
entry = "panel.luau"
width = 320
height = 220
placement = "floating"
position = "center"
open_near_click = true
[[setting]]
key = "battery_device"
type = "folder"
label_key = "settings.battery-device"
description_key = "settings.battery-device-desc"
default = "/sys/class/power_supply/BAT0"
[[setting]]
key = "charge_threshold"
type = "int"
label_key = "settings.charge-threshold"
description_key = "settings.charge-threshold-desc"
default = 80
min = 40
max = 100
-222
View File
@@ -1,222 +0,0 @@
--!nonstrict
local function get_batteries(): { string }
local batteries = {}
local files, _ = noctalia.listDir("/sys/class/power_supply")
if files then
for _, name in files do
local path = "/sys/class/power_supply/" .. name
if noctalia.fileExists(path .. "/charge_control_end_threshold") then
table.insert(batteries, path)
end
end
end
return batteries
end
local function get_active_battery(): string?
local configured = noctalia.getConfig("battery_device")
if
typeof(configured) == "string"
and configured ~= ""
and noctalia.fileExists(configured .. "/charge_control_end_threshold")
then
return configured
end
local list = get_batteries()
return list[1]
end
local function shell_quote(str: string): string
return "'" .. string.gsub(str, "'", "'\\''") .. "'"
end
local function check_writable(path: string, callback: (boolean) -> ())
noctalia.runAsync("test -w " .. shell_quote(path), function(res)
callback(res.exitCode == 0)
end)
end
local data_dir, data_dir_err = noctalia.pluginDataDir()
if not data_dir or data_dir_err then
noctalia.log(
"Failed to get plugin data directory: "
.. tostring(data_dir_err or "unknown error")
)
end
local THRESHOLD_FILE_PATH: string? = if data_dir
then data_dir .. "/threshold.txt"
else nil
local function set_threshold(value: number)
local battery = get_active_battery()
if not battery then
return
end
local threshold_file = battery .. "/charge_control_end_threshold"
local v = math.floor(value + 0.5)
if v < 40 then
v = 40
end
if v > 100 then
v = 100
end
noctalia.log("Setting charge threshold to " .. tostring(v) .. "% on " .. battery)
local ok, err = noctalia.writeFile(threshold_file, tostring(v) .. "\n")
if ok then
noctalia.state.set("current_threshold", v)
if THRESHOLD_FILE_PATH then
local save_ok, save_err =
noctalia.writeFile(THRESHOLD_FILE_PATH, tostring(v))
if not save_ok then
noctalia.log(
"Failed to write saved threshold to "
.. THRESHOLD_FILE_PATH
.. ": "
.. tostring(save_err)
)
end
end
else
noctalia.log("Failed to write threshold: " .. tostring(err))
noctalia.notifyError(
noctalia.tr("notification.error-title"),
noctalia.tr("notification.error-msg", { file = threshold_file })
)
end
end
local function check_status()
local battery = get_active_battery()
if not battery then
noctalia.state.set("is_available", false)
noctalia.state.set("is_writable", false)
noctalia.state.set("current_threshold", 0)
noctalia.state.set("battery_model_name", "")
return
end
noctalia.state.set("is_available", true)
local model_name = ""
local content, err = noctalia.readFile(battery .. "/model_name")
if content and not err then
model_name = noctalia.string.trim(content)
end
noctalia.state.set("battery_model_name", model_name)
local threshold_file = battery .. "/charge_control_end_threshold"
local threshold_content = noctalia.readFile(threshold_file)
local current_val = 0
if threshold_content then
current_val = tonumber(noctalia.string.trim(threshold_content)) or 0
noctalia.state.set("current_threshold", current_val)
end
check_writable(threshold_file, function(writable)
noctalia.state.set("is_writable", writable)
if writable then
local saved_val = nil
if THRESHOLD_FILE_PATH and noctalia.fileExists(THRESHOLD_FILE_PATH) then
local saved_content, read_err = noctalia.readFile(THRESHOLD_FILE_PATH)
if saved_content and not read_err then
saved_val = tonumber(noctalia.string.trim(saved_content))
elseif read_err then
noctalia.log(
"Failed to read saved threshold from "
.. THRESHOLD_FILE_PATH
.. ": "
.. tostring(read_err)
)
end
end
if not saved_val then
local config_val = noctalia.getConfig("charge_threshold")
if typeof(config_val) == "number" then
saved_val = config_val
end
end
if saved_val and saved_val >= 40 and saved_val <= 100 then
if saved_val ~= current_val then
set_threshold(saved_val)
end
end
end
end)
end
local function run_setup()
local user = noctalia.getenv("USER")
if not user or user == "" then
noctalia.log("Could not get USER environment variable")
return
end
local plugin_dir = noctalia.pluginDir()
if not plugin_dir then
noctalia.log("Could not get plugin directory")
return
end
local rules_script = plugin_dir .. "/setup_rules.sh"
local cmd =
string.format("bash %s %s", shell_quote(rules_script), shell_quote(user))
noctalia.log("Launching setup script in terminal: " .. cmd)
local launched = noctalia.runInTerminal(cmd)
if launched then
noctalia.log("Setup script launched in terminal")
else
noctalia.log("Failed to launch terminal for setup script")
noctalia.notifyError(
noctalia.tr("notification.setup-title"),
noctalia.tr("notification.setup-fail")
)
end
noctalia.state.set("run_setup_request", false)
end
noctalia.state.watch("run_setup_request", function(val)
if val == true then
run_setup()
end
end)
noctalia.state.watch("set_threshold_request", function(val)
if val then
local num = tonumber(val)
if num then
set_threshold(num)
end
end
end)
function onIpc(event, payload)
if event == "set" then
if payload then
local val = tonumber(payload)
if val and val >= 40 and val <= 100 then
set_threshold(val)
end
end
elseif event == "setup" then
run_setup()
end
end
function onConfigChanged()
check_status()
end
check_status()
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env bash
# ------------------------------
# Battery Threshold Udev Setup
# ------------------------------
# This script sets up udev rules to allow a non-root user to write to
# /sys/class/power_supply/BAT0/charge_control_end_threshold.
# It creates a group 'battery_ctl' and adds the target user to this group.
#
# Usage:
# $ ./setup_rules.sh [username] [--non-interactive|-y]
# ------------------------------
set -e
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || echo "$0")"
TARGET_USER=""
SKIP_PROMPT=false
for arg in "$@"; do
case "$arg" in
-y | --non-interactive)
SKIP_PROMPT=true
;;
-*)
;;
*)
if [ -z "$TARGET_USER" ]; then
TARGET_USER="$arg"
fi
;;
esac
done
TARGET_USER="${TARGET_USER:-${SUDO_USER:-$USER}}"
if [ "$SKIP_PROMPT" = false ]; then
echo "===================================================="
echo " Battery Threshold Udev Setup"
echo "===================================================="
echo "Script location: $SCRIPT_PATH"
echo "Target user: $TARGET_USER"
echo ""
echo "Please examine the script location and contents above before proceeding."
echo ""
read -rp "Do you want to proceed with setup? (y/N): " CONFIRM
case "$CONFIRM" in
[yY][eE][sS] | [yY])
;;
*)
echo "Setup cancelled by user."
read -rp "Press Enter to exit..."
exit 1
;;
esac
fi
# Escalate privileges via sudo only after user confirmation
if [ "$EUID" -ne 0 ]; then
echo ""
echo "Escalating privileges via sudo..."
exec sudo bash "$SCRIPT_PATH" "$TARGET_USER" --non-interactive
fi
if [ -z "$TARGET_USER" ]; then
echo "Error: No target user specified." >&2
read -rp "Press Enter to exit..."
exit 1
fi
if ! getent group battery_ctl >/dev/null; then
echo "Creating battery_ctl group..."
groupadd battery_ctl
fi
echo "Adding $TARGET_USER to battery_ctl group..."
usermod -aG battery_ctl "$TARGET_USER"
echo "Writing udev rule to /etc/udev/rules.d/99-battery-threshold.rules..."
cat <<'EOF' >/etc/udev/rules.d/99-battery-threshold.rules
# Battery Threshold Control - udev rule
# Grants write access to charge_control_end_threshold for users in the
# 'battery_ctl' group.
SUBSYSTEM=="power_supply", KERNEL=="BAT*", \
RUN+="/bin/chgrp battery_ctl /sys$devpath/charge_control_end_threshold", \
RUN+="/bin/chmod g+w /sys$devpath/charge_control_end_threshold"
EOF
echo "Reloading rules..."
udevadm control --reload-rules && udevadm trigger
echo ""
echo "You may need a reboot for the plugin's write access to take effect."
echo "Done!"
echo ""
read -rp "Press Enter to exit..."
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

-26
View File
@@ -1,26 +0,0 @@
{
"actions": {
"widget-settings": "Widget Settings"
},
"notification": {
"error-msg": "Failed to write battery threshold to {file}",
"error-title": "Battery Threshold Error",
"setup-fail": "Failed to configure permissions.",
"setup-success": "Permissions configured. Please log out and back in for group changes to take effect.",
"setup-title": "Battery Threshold Setup"
},
"panel": {
"adjust-limit": "Drag slider to adjust limit",
"button": "Configure Permissions",
"not-available": "Not available on this system",
"read-only": "Read-only: Install udev rule for write access",
"title": "Battery Threshold"
},
"settings": {
"battery-device": "Battery Device",
"battery-device-desc": "Battery to configure threshold for",
"charge-threshold": "Charge Threshold",
"charge-threshold-desc": "The percentage at which the battery should stop charging",
"no-battery-device": "No configurable batteries are available on this system"
}
}
-35
View File
@@ -1,35 +0,0 @@
--!nonstrict
local function render_widget()
local is_available = noctalia.state.get("is_available") == true
local threshold = noctalia.state.get("current_threshold") or 0
local container = barWidget.isVertical() and ui.column or ui.row
if is_available then
barWidget.render(container({ gap = 4, align = "center" }, {
ui.glyph({ name = "charging-pile", size = 14 }),
ui.label({ text = tostring(threshold) .. "%", fontWeight = "bold" }),
}))
barWidget.setTooltip(
noctalia.tr("panel.title") .. " " .. tostring(threshold) .. "%"
)
else
barWidget.render(container({ gap = 4, align = "center" }, {
ui.glyph({ name = "charging-pile", size = 14, color = "on_surface/0.4" }),
}))
barWidget.setTooltip(noctalia.tr("settings.no-battery-device"))
end
end
noctalia.state.watch("current_threshold", render_widget)
noctalia.state.watch("is_available", render_widget)
function onClick()
noctalia.togglePanel("damian-ds7/battery-threshold:panel")
end
function update()
render_widget()
end
render_widget()
-39
View File
@@ -1,39 +0,0 @@
# Battery Widget
Desktop/lockscreen widget that displays the current battery level and charging status.
## Plugin
| Field | Value |
| --- | --- |
| ID | `yocraft/battery-widget` |
| Entries | Desktop widget: `widget` |
## Requirements
The plugin requires `gdbus` on `PATH` and `upower`.
## Usage
Add it to your desktop/lockscreen widgets using the widget editor. You can configure it in the widget settings.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `layout` | `select` | `horizontal` | Layout of the widget. |
| `show_glyph` | `bool` | `true` | Show the battery icon. |
| `show_label` | `bool` | `true` | Show text next to the icon. |
| `label_content` | `select` | `percent` | What the battery label shows: charge percentage, time remaining, or power draw. |
| `hide_plugged` | `bool` | `false` | Hide the battery widget when connected to AC power. |
| `hide_full` | `bool` | `false` | Hide the battery widget when fully charged. |
| `color` | `color` | `on_surface` | Color role for this widget's icon and label; fixed hex colors are also supported. |
| `warning_color` | `color` | `error` | Color applied when charge is at or below warning threshold. |
| `charging_color` | `color` | `on_surface` | Color applied when connected to AC power. |
| `battery` | `select` | `auto` | Detect the battery automatically or set a custom name. |
| `battery_name` | `string` | `BAT0` | Set a custom name for the battery. |
## Notes
`hide_full` and `hide_plugged` do not hide the background due to plugin limitations.
The warning threshold is pulled from noctalia's settings.
-112
View File
@@ -1,112 +0,0 @@
id = "yocraft/battery-widget"
name = "Battery Widget"
version = "2.0.0"
plugin_api = 3
author = "yocraft"
license = "MIT"
deprecated = false
icon = "battery"
description = "Desktop/Lockscreen widget to show battery."
tags = [ "desktop", "hardware", "system", "utility" ]
dependencies = [ "upower", "gdbus" ]
[[desktop_widget]]
id = "widget"
entry = "widget.luau"
[[desktop_widget.setting]]
key = "layout"
type = "select"
label_key = "settings.layout.label"
description_key = "settings.layout.description"
default = "horizontal"
options = [
{ value = "horizontal", label_key = "settings.layout.horizontal" },
{ value = "vertical", label_key = "settings.layout.vertical" },
]
[[desktop_widget.setting]]
key = "show_glyph"
type = "bool"
label_key = "settings.show_glyph.label"
description_key = "settings.show_glyph.description"
default = true
[[desktop_widget.setting]]
key = "show_label"
type = "bool"
label_key = "settings.show_label.label"
description_key = "settings.show_label.description"
default = true
[[desktop_widget.setting]]
key = "label_content"
type = "select"
label_key = "settings.label_content.label"
description_key = "settings.label_content.description"
default = "percent"
options = [
{ value = "percent", label_key = "settings.label_content.percent" },
{ value = "time", label_key = "settings.label_content.time" },
{ value = "rate", label_key = "settings.label_content.rate" },
]
[[desktop_widget.setting]]
key = "hide_plugged"
type = "bool"
label_key = "settings.hide_plugged.label"
description_key = "settings.hide_plugged.description"
default = false
[[desktop_widget.setting]]
key = "hide_full"
type = "bool"
label_key = "settings.hide_full.label"
description_key = "settings.hide_full.description"
default = false
[[desktop_widget.setting]]
key = "color"
type = "color"
label_key = "settings.color.label"
description_key = "settings.color.description"
default = "on_surface"
advanced = true
[[desktop_widget.setting]]
key = "warning_color"
type = "color"
label_key = "settings.warning_color.label"
description_key = "settings.warning_color.description"
default = "error"
advanced = true
[[desktop_widget.setting]]
key = "charging_color"
type = "color"
label_key = "settings.charging_color.label"
description_key = "settings.charging_color.description"
default = "on_surface"
advanced = true
[[desktop_widget.setting]]
key = "battery"
type = "select"
label_key = "settings.battery.label"
description_key = "settings.battery.description"
default = "auto"
options = [
{ value = "auto", label_key = "settings.battery.auto" },
{ value = "bat0", label_key = "settings.battery.bat0" },
{ value = "custom", label_key = "settings.battery.custom" },
]
[[desktop_widget.setting]]
key = "battery_name"
type = "string"
label_key = "settings.battery_name.label"
description_key = "settings.battery_name.description"
default = "BAT0"
visible_when = { key = "battery", values = ["custom"] }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

-63
View File
@@ -1,63 +0,0 @@
{
"settings": {
"battery": {
"auto": "Auto",
"bat0": "BAT0",
"custom": "Custom",
"description": "Detect the battery automatically or set a custom name.",
"label": "Battery"
},
"battery_name": {
"description": "Name of the battery.",
"label": "Battery Name"
},
"charging_color": {
"description": "Color applied when connected to AC power.",
"label": "Charging Color"
},
"color": {
"description": "Color role for this widget's icon and label; fixed hex colors are also supported.",
"label": "Color"
},
"hide_full": {
"description": "Hide the battery widget when fully charged.",
"label": "Hide When Full"
},
"hide_plugged": {
"description": "Hide the battery widget when connected to AC power.",
"label": "Hide When Plugged In"
},
"layout": {
"description": "Widget layout.",
"horizontal": "Horizontal",
"label": "Layout",
"vertical": "Vertical"
},
"refresh_interval": {
"description": "Controls how frequently the widget updates its content. (in seconds)",
"label": "Refresh Interval"
},
"show_glyph": {
"description": "Show the battery icon.",
"label": "Show Glyph"
},
"show_label": {
"description": "Show text next to the icon.",
"label": "Show Label"
},
"warning_color": {
"description": "Color applied when charge is at or below noctalia's warning threshold.",
"label": "Warning Color"
},
"label_content": {
"label": "Label Content",
"description": "What the battery label shows: charge percentage, time remaining, or power draw.",
"percent": "Percent",
"time": "Time",
"rate": "Rate"
}
},
"notify": {
"error": "Something went wrong. Check the Noctalia log for details."
}
}
-53
View File
@@ -1,53 +0,0 @@
{
"settings": {
"battery": {
"auto": "Automatique",
"bat0": "BAT0",
"custom": "Personnalisé",
"description": "Détection automatique de la batterie ou nom personnalisé.",
"label": "Batterie"
},
"battery_name": {
"description": "Nom de la batterie.",
"label": "Nom de la Batterie"
},
"charging_color": {
"description": "Couleur utilisée lorsque l'appareil est branché au secteur.",
"label": "Couleur de Charge"
},
"color": {
"description": "Couleur de l'icône et du texte de ce widget ; les codes hex fixes sont également pris en charge.",
"label": "Couleur"
},
"hide_full": {
"description": "Cacher le widget lorsque la batterie est pleine.",
"label": "Cacher quand plein"
},
"hide_plugged": {
"description": "Cacher le widget lorsque l'appareil est branché au secteur.",
"label": "Cacher quand branché"
},
"layout": {
"description": "Disposition du widget.",
"horizontal": "Horizontal",
"label": "Disposition",
"vertical": "Vertical"
},
"refresh_interval": {
"description": "Définit la fréquence de mise à jour du contenu du widget (en secondes).",
"label": "Intervalle d'actualisation"
},
"show_glyph": {
"description": "Afficher l’icône de la batterie.",
"label": "Afficher l’icône"
},
"show_label": {
"description": "Afficher le texte à coté de l’icône.",
"label": "Afficher le texte"
},
"warning_color": {
"description": "Couleur utilisée quand le niveau de batterie est en dessous ou égal au seuil d'alerte de Noctalia.",
"label": "Couleur d'alerte"
}
}
}
-278
View File
@@ -1,278 +0,0 @@
local glyph
local color
local data = { percent = 0, status = "unknown" }
local label_content = noctalia.getConfig("label_content")
local warning_threshold = 20
local path = "/sys/class/power_supply/"
local batName
local stateMap = {
[0] = "unknown",
[1] = "charging",
[2] = "discharging",
[3] = "empty",
[4] = "fully-charged",
[5] = "pending-charge",
[6] = "pending-discharge",
}
local function shellEscape(raw)
return "'" .. raw:gsub("'", "'\\''") .. "'"
end
local function notifyError()
noctalia.notifyError("Battery widget", noctalia.tr("notify.error"))
end
if not noctalia.commandExists("gdbus") then
noctalia.log("battery-widget: gdbus not found.")
notifyError()
return
end
local function parse(output)
data = data or {}
local percent = output:match("'Percentage':%s*<([%d%.]+)>")
if percent then
data.percent = tonumber(percent) or 0
end
local state = output:match("'State':%s*<uint32%s+(%d+)>")
if state then
data.status = stateMap[tonumber(state)] or "unknown"
end
if label_content == "time" then
local timeToEmpty = output:match("'TimeToEmpty':%s*<int64%s+(%-?%d+)>")
if timeToEmpty then
data.timeToEmpty = tonumber(timeToEmpty)
end
local timeToFull = output:match("'TimeToFull':%s*<int64%s+(%-?%d+)>")
if timeToFull then
data.timeToFull = tonumber(timeToFull)
end
elseif label_content == "rate" then
local rate = output:match("'EnergyRate':%s*<([%-%d%.]+)>")
if rate then
data.rate = tonumber(rate)
end
end
return data
end
local function getBatName()
local config = noctalia.getConfig("battery")
if config == "bat0" then
return "BAT0"
elseif config == "custom" then
return noctalia.getConfig("battery_name")
end
local folders = noctalia.listDir(path)
if folders then
for _, name in ipairs(folders) do
if name:match("^BAT") then
return name
end
end
end
return nil
end
local function getGlyph()
if data.status == "charging" then
glyph = "battery-charging"
elseif data.status == "fully-charged" or data.status == "pending-charge" then
glyph = "battery-plugged"
elseif data.status == "unknown" then
glyph = "battery-exclamation"
else
glyph = data.percent >= 85 and "battery-4"
or data.percent >= 55 and "battery-3"
or data.percent >= 30 and "battery-2"
or data.percent >= 10 and "battery-1"
or "battery-0"
end
end
local function getWarningThreshold()
noctalia.runAsync(
"grep warning_threshold ~/.local/state/noctalia/settings.toml | awk '{print $3}'",
function(result)
if result.exitCode ~= 0 then
noctalia.log("battery-widget: failed to get warning_threshold: " .. result.stderr)
notifyError()
return
end
local res = result.stdout
if not res then
noctalia.log("battery-widget: invalid warning_threshold, keeping default")
notifyError()
return
end
local parsed = tonumber(noctalia.string.trim(res))
if parsed then
warning_threshold = parsed
else
noctalia.log("battery-widget: invalid warning_threshold, keeping default")
notifyError()
end
end
)
end
local function getColor()
if data.status == "charging" or data.status == "fully-charged" or data.status == "pending-charge" then
color = noctalia.getConfig("charging_color")
return
end
if data.percent <= warning_threshold then
color = noctalia.getConfig("warning_color")
return
end
color = noctalia.getConfig("color")
end
local function formatTime(seconds)
if not seconds or seconds <= 0 then
return nil
end
local hours = math.floor(seconds / 3600)
local minutes = math.floor((seconds % 3600) / 60)
if hours > 0 and minutes > 0 then
return string.format("%dh %dm", hours, minutes)
elseif hours > 0 then
return string.format("%dh", hours)
else
return string.format("%dm", minutes)
end
end
local function formatRate(rate)
if not rate or rate <= 0 then
return nil
end
return string.format("%.1f W", rate)
end
local function formatPercent()
return math.floor((data.percent or 0) + 0.5) .. "%"
end
local function getLabelText()
if label_content == "time" then
local connected = data.status == "charging" or data.status == "fully-charged" or data.status == "pending-charge"
local seconds = connected and data.timeToFull or data.timeToEmpty
return formatTime(seconds) or formatPercent()
elseif label_content == "rate" then
return formatRate(data.rate) or formatPercent()
end
return formatPercent()
end
local function render()
if (noctalia.getConfig("hide_plugged") and (data.status == "charging" or data.status == "pending-charge"))
or (noctalia.getConfig("hide_full") and data.status == "fully-charged") then
desktopWidget.render(ui.row())
return
end
local content = {}
if noctalia.getConfig("show_glyph") then
table.insert(content, ui.glyph({ name = glyph, color = color }))
end
if noctalia.getConfig("show_label") then
table.insert(content, ui.label({ text = getLabelText(), color = color }))
end
local vertical = noctalia.getConfig("layout") == "vertical"
local layout = vertical and ui.column or ui.row
desktopWidget.render(
layout({
gap = vertical and 0 or 4,
align = "center"
}, content)
)
end
local function refreshBattery(output)
parse(output)
getGlyph()
getColor()
render()
end
local function initData()
local cmd = string.format(
"gdbus call --system --dest org.freedesktop.UPower --object-path %s --method org.freedesktop.DBus.Properties.GetAll org.freedesktop.UPower.Device",
shellEscape("/org/freedesktop/UPower/devices/battery_" .. batName)
)
noctalia.runAsync(cmd,
function(result)
if result.exitCode ~= 0 then
noctalia.log("battery-widget: failed to read battery: " .. result.stderr)
return
end
if not result.stdout then
noctalia.log("battery-widget: invalid battery output")
return
end
refreshBattery(result.stdout)
end
)
end
local function watchBattery()
local patterns = { "Percentage", "State" }
if label_content == "time" then
table.insert(patterns, "TimeToEmpty")
table.insert(patterns, "TimeToFull")
elseif label_content == "rate" then
table.insert(patterns, "EnergyRate")
end
local cmd = string.format(
"gdbus monitor --system --dest org.freedesktop.UPower --object-path %s",
shellEscape("/org/freedesktop/UPower/devices/battery_" .. batName)
)
noctalia.runStream(cmd,
function(line)
for _, pattern in ipairs(patterns) do
if line:find(pattern) then
refreshBattery(line)
break
end
end
end
)
end
batName = getBatName()
if not batName then
noctalia.log("battery-widget: battery not found")
notifyError()
return
end
getWarningThreshold()
initData()
watchBattery()
-206
View File
@@ -1,206 +0,0 @@
# Bookmarks
![](./thumbnail.webp)
A user-managed bookmarks plugin for Noctalia. You can add bookmarks by selecting a glyph, a label,
and a shell command to execute. You can backup your bookmarks as well!
## Plugin
| Field | Value |
| --------------- | ----------------------------------------------------------------- |
| ID | `dunarand/bookmarks` |
| Entries | Bar widget: `bar`; panel: `panel`; Launcher provider: `provider`; |
| Launcher Prefix | `/bk` |
## Requirements
- Noctalia v5.0.0 or higher
- `nohup` (Optional): For "Run in background" wrapper toggle
## IPC & Keybinds
1. Open the bookmarks panel:
```sh
noctalia msg panel-toggle dunarand/bookmarks:panel
```
2. Open the bookmarks panel in search mode (immediately puts you into search mode) so that you can
use your bookmarks panel as a launcher:
```sh
noctalia msg panel-toggle dunarand/bookmarks:panel search
```
You can interact with the bookmarks list via keybinds:
| Keybind | Purpose |
| ------------------------- | ---------------------------------------------------------------- |
| `CTRL + J` or Down Arrow | Select the next (below) item |
| `CTRL + K` or Up Arrow | Select the previous (above) item |
| `CTRL + H` or Left Arrow | Return to the root level when in a folder |
| `CTRL + L` or Right Arrow | Get into a folder |
| `CTRL + F` | Search bookmarks |
| `CTRL + N` | New bookmark |
| `Enter` / `Return` | Execute the selected bookmark's command / Navigate into a folder |
## Usage
The plugin ships a bar widget, a panel, and a launcher provider. The bar widget just launches the
panel. The panel is able to be toggled via IPC. For example, in Hyprland v0.55 or higher, you can
assign it to a keybind as follows:
```
hl.bind(
"SUPER + SHIFT + B",
hl.dsp.exec_cmd("noctalia msg panel-toggle dunarand/bookmarks:panel")
)
```
- Bookmarks and folders are listed in the main panel.
![](./assets/preview-1.png)
- You can create or edit bookmarks by assigning them a glyph, a label, a command, and an
optional description.
![](./assets/preview-2.png)
- "Run in background" toggle wraps the command you defined in the following way:
`nohup <bookmark-command> >/dev/null 2>&1 &`
For example, instead of typing the whole command
`nohup xdg-open "$HOME" >/dev/null 2>&1 &`
each time, you can instead define the command as `xdg-open "$HOME"` and toggle "Run in
background" switch.
- "Run in terminal" switch executes the command with the default terminal. These switches are
mutually exclusive so you can only choose one. Toggling on a switch will result the other to
turn off.
- You can create folders and nest other bookmarks within folders.
Folders cannot nest other folders. This is by design and it'll not change unless I find a
genuine use case. You can edit folders by clicking on the "pen" icon next to its name.
- You can press the "eye" icon to enter edit mode where you can edit, delete, and reorder bookmarks.
This is a setting that you can disable.
![](./assets/preview-3.png)
- You can also use your launcher to query your bookmarks with the `/bk` prefix.
![](./assets/preview-4.png)
- Queries are folder-aware, meaning if you nested a bookmark inside a folder, the folder name will
be displayed as well.
## Bookmarks Data
The saved bookmarks are written to `$NOCTALIA_STATE_HOME/plugins/data/dunarand/bookmarks/data.json`.
By default, `$NOCTALIA_STATE_HOME` should point to `~/.local/state/noctalia`. You can point to a
different location for saving and backing up your bookmarks. This setting is configurable via
**plugin settings** under Settings -> Plugins. Only JSON format is accepted.
You can manage the bookmark data via external scripts.
**Root**
```
[ <entry>, <entry>]
```
The root is a JSON array of entries. Order in the array is display order (used directly by drag-and
drop reordering).
**Entry**
Two types of entries exist: `bookmark` and `folder`.
### Bookmarks
```JSON
{
"type": "bookmark",
"glyph": "bookmark",
"label": "My Bookmark",
"cmd": "firefox",
"description": "Opens Firefox",
"runInBackground": false,
"runInTerminal": false
}
```
| Key | Type | Required / Default | Notes |
| ----------------- | ------- | ------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `type` | string | `"bookmark"` | |
| `glyph` | string | falls back to `"bookmark"` if empty/missing | |
| `label` | string | required, enforced at save time | `""` fails validation |
| `cmd` | string | required for bookmarks | shell command to execute, enforced at save time |
| `description` | string | optional, defaults to `""` | shown in the info tooltip if enabled |
| `runInBackground` | boolean | optional, defaults to `false` | mutually exclusive with `runInTerminal` in the UI but the schema itself doesn't enforce that |
| `runInTerminal` | boolean | optional, defaults to `false` |
### Folders
```JSON
{
"type": "folder",
"glyph": "folder",
"label": "My Folder",
"items": [ <bookmark>, <bookmark>, ... ]
}
```
| Key | Type | Required/Default | Notes |
| ------- | ------ | ----------------------------------------------------------------------- | ----------------------------------------------- |
| `type` | string | `"folder"` |
| `glyph` | string | falls back to `"folder"` if empty/missing |
| `label` | string | required |
| `items` | array | optional, treated as `{}` if missing, `folder.items=folder.items or {}` | contents — bookmarks only, one level of nesting |
## Settings
The bar widget has the following settings:
| Setting | Type | Default | Description |
| --------- | -------- | ----------- | -------------------------------- |
| `glyph` | `glyph` | `bookmark` | Glyph displayed on the bar |
| `tooltip` | `string` | `Bookmarks` | Tooltip displayed on the bar |
| `text` | `string` | `Bookmarks` | Widget text displayed on the bar |
The plugin itself has the following settings:
| Setting | Type | Default | Description |
| -------------------- | -------- | ----------- | ------------------------------------------------------------------------------------- |
| `data_path` | `file` | | data.json file to store the saved bookmarks. Leave empty to use the default location. |
| `show_info_button` | `bool` | `true` | Shows the "?" tooltip button on the bookmark entries. |
| `enable_bk_provider` | `bool` | `true` | Enable bookmarks launcher provider |
| `bk_sort_by` | `select` | `"history"` | Provider's sorting strategy. Options: `"history"`, `"usage_count"` |
## Changelog
### v1.3.0
- Added `/bk` launcher provider
- Changed the following keybinds (check IPC & Keybinds section for what they do)
- CTRL + N changed to CTRL + J
- CTRL + P changed to CTRL + K
- CTRL + S changed to CTRL + F
- Added the following keybinds (check IPC & Keybinds section for what they do)
- CTRL + H or Left Arrow
- CTRL + L or Right Arrow
- CTRL + N
- Index tracking on root level for keyboard-centric usage
- Previously, when using a keyboard to navigate, going into a folder would reset the selected
entry's index to 1 causing fat fingers to be annoying
### v1.3.1
- Fixed a bug where hovering over a bookmark would trigger scrolling event in populated lists
- It was also causing the scroll bar to render incorrectly
- The fix introduces a new bug where navigating items via keybinds doesn't trigger scrolling.
This issue will be fixed in future
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

-30
View File
@@ -1,30 +0,0 @@
local PANEL_ID = "dunarand/bookmarks:panel"
local open = false
local function render()
barWidget.setGlyph(noctalia.getConfig("glyph"))
if open then
barWidget.setGlyphColor("primary")
barWidget.setColor("primary")
else
barWidget.setGlyphColor("on_surface")
barWidget.setColor("on_surface")
end
barWidget.setTooltip(noctalia.getConfig("tooltip"))
barWidget.setText(noctalia.getConfig("text"))
end
noctalia.state.watch("bookmarks_open", function(value)
open = value == true
render()
end)
function update()
render()
end
function onClick()
noctalia.togglePanel(PANEL_ID)
end
-292
View File
@@ -1,292 +0,0 @@
-- Launcher provider: "/bk" fuzzy-searches bookmark labels (root + one level
-- of folders, bookmarks only - same scope as the panel's own search) and
-- runs the selected one on activation. Reads the same data.json the panel
-- writes, resolved the same way (configured data_path, else the plugin's
-- data dir), so no state is shared beyond the file on disk.
local function resolveDataPath()
local configured = noctalia.getConfig("data_path")
if type(configured) == "string" and noctalia.string.trim(configured) ~= "" then
return noctalia.expandPath(noctalia.string.trim(configured)), nil
end
local dataDir, dataDirErr = noctalia.pluginDataDir()
if dataDir == nil then
return nil, dataDirErr
end
return dataDir .. "/data.json", nil
end
local function entryType(entry)
return entry.type == "folder" and "folder" or "bookmark"
end
-- Reads and decodes data.json fresh on every query. The panel is the only
-- writer and bookmark lists are small, so there's no need to cache across
-- queries here.
local function loadBookmarks()
local path, pathErr = resolveDataPath()
if path == nil then
noctalia.log("bookmarks-provider: could not resolve data path: " .. tostring(pathErr))
return {}
end
if not noctalia.fileExists(path) then
return {}
end
local raw, readErr = noctalia.readFile(path)
if raw == nil then
noctalia.log("bookmarks-provider: failed to read data file: " .. tostring(readErr))
return {}
end
local decoded, decodeErr = noctalia.json.decode(raw)
if type(decoded) ~= "table" then
noctalia.log("bookmarks-provider: data file is corrupt: " .. tostring(decodeErr))
return {}
end
return decoded
end
-- Flat list of { entry, path } across root and one level of folders,
-- bookmarks only (folders themselves aren't launchable results).
local function flatten(bookmarks)
local flat = {}
for _, entry in ipairs(bookmarks) do
if entryType(entry) == "folder" then
for _, subEntry in ipairs(entry.items or {}) do
if entryType(subEntry) ~= "folder" then
table.insert(flat, { entry = subEntry, path = entry.label or "" })
end
end
else
table.insert(flat, { entry = entry, path = nil })
end
end
return flat
end
-- Bookmarks are indexed by a stable synthetic id (path-qualified label),
-- rather than by array position, since onActivate only receives the id
-- string and the list is re-read from disk on every query.
local function resultId(item)
return (item.path or "") .. "\30" .. (item.entry.label or "")
end
local function buildResults(query)
local bookmarks = loadBookmarks()
local flat = flatten(bookmarks)
local results = {}
for _, item in ipairs(flat) do
local score
if query == "" then
score = 0
else
score = noctalia.fuzzyScore(query, item.entry.label or "")
end
if score ~= nil then
table.insert(results, {
id = resultId(item),
title = item.entry.label or "",
subtitle = item.path,
glyph = item.entry.glyph or "bookmark",
score = score,
})
end
end
table.sort(results, function(a, b)
return (a.score or 0) > (b.score or 0)
end)
return results
end
local function providerEnabled()
return noctalia.getConfig("enable_bk_provider") == true
end
-- "history" (most-recently-used first) or "usage_count" (most-frequently-
-- used first). Only affects ordering when the query is empty; a non-empty
-- query always ranks by fuzzy match score, same as before.
local function sortBy()
local value = noctalia.getConfig("bk_sort_by")
if value == "usage_count" then
return "usage_count"
end
return "history"
end
local function statsPath()
local dataDir = noctalia.pluginDataDir()
if dataDir == nil then
return nil
end
return dataDir .. (sortBy() == "history" and "/bk_history.json" or "/bk_usage.json")
end
-- Reads and decodes the current stats file (history array or usage-count
-- map, per sortBy()), falling back to an empty table when the file is
-- missing, unreadable, or corrupt. Shared by getSortScores/recordActivation
-- so the read/decode/default boilerplate only lives in one place.
local function readStatsData()
local path = statsPath()
if path == nil then
return {}, nil
end
local file = noctalia.readFile(path)
if file == nil then
return {}, path
end
local decoded = noctalia.json.decode(file)
if type(decoded) ~= "table" then
return {}, path
end
return decoded, path
end
-- Scores used to order results when the query is empty, keyed by result id.
-- History ranks by recency (higher = more recently used); usage_count ranks
-- by frequency (higher = used more often). Reads/decodes the stats file
-- once for the whole batch rather than once per result. Ids missing from
-- the stats file fall back to 0 (unranked, sorted last).
local function getSortScores(ids)
local data = readStatsData()
local scores = {}
if sortBy() == "history" then
for _, id in ipairs(ids) do
local i = table.find(data, id)
scores[id] = i ~= nil and (#data - i) or 0
end
else
for _, id in ipairs(ids) do
scores[id] = data[id] or 0
end
end
return scores
end
-- Records an activation for ranking purposes: pushes `id` to the front of
-- the history list, or increments its usage count, depending on `sort_by`.
local function recordActivation(id)
local data, path = readStatsData()
if path == nil then
return
end
if sortBy() == "history" then
local i = table.find(data, id)
if i ~= nil then
table.remove(data, i)
end
table.insert(data, 1, id)
else
data[id] = (data[id] or 0) + 1
end
local encoded = noctalia.json.encode(data)
if encoded ~= nil then
noctalia.writeFile(path, encoded)
end
end
function onQuery(query)
query = noctalia.string.trim(query)
if not providerEnabled() then
launcher.setResults(query, {})
return
end
local results = buildResults(query)
if query == "" then
local ids = {}
for _, result in ipairs(results) do
table.insert(ids, result.id)
end
local scores = getSortScores(ids)
for _, result in ipairs(results) do
result.score = scores[result.id] or 0
end
table.sort(results, function(a, b)
return (a.score or 0) > (b.score or 0)
end)
end
if #results == 0 then
launcher.setResults(query, {
{
id = "",
title = noctalia.tr("no_bookmarks"),
glyph = "bookmark-off",
},
})
return
end
launcher.setResults(query, results)
end
function onActivate(id)
if id == "" or not providerEnabled() then
return
end
local wantPath, wantLabel = id:match("^([^\30]*)\30(.*)$")
if wantLabel == nil then
return
end
if wantPath == "" then
wantPath = nil
end
local bookmarks = loadBookmarks()
for _, item in ipairs(flatten(bookmarks)) do
if item.entry.label == wantLabel and (item.path or nil) == wantPath then
local entry = item.entry
local cmd = entry.cmd
if type(cmd) ~= "string" or cmd == "" then
noctalia.notifyError(noctalia.tr("title"), noctalia.tr("err.no_command"))
return
end
local label = entry.label or cmd
recordActivation(id)
if entry.runInTerminal == true then
noctalia.runInTerminal(cmd)
return
end
local finalCmd = cmd
if entry.runInBackground == true then
finalCmd = "nohup " .. cmd .. " >/dev/null 2>&1 &"
end
local ok = noctalia.runAsync(finalCmd, function(result)
if entry.runInBackground == true then
return
end
if result.exitCode ~= 0 then
local detail = noctalia.string.trim(result.stderr or "")
local msg = noctalia.tr(
"err.exit_code",
{ label = label, code = tostring(result.exitCode) }
)
if detail ~= "" then
msg = msg .. ": " .. detail
end
noctalia.notifyError(noctalia.tr("title"), msg)
end
end)
if not ok then
noctalia.notifyError(
noctalia.tr("title"),
noctalia.tr("err.launch_failed", { label = label })
)
end
return
end
end
end
-1447
View File
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
id = "dunarand/bookmarks"
name = "Bookmarks"
version = "1.3.1"
plugin_api = 13
author = "dunarand"
license = "MIT"
icon = "bookmark"
description = "A user-defined list of bookmarks. Each item has a label, a glyph, and a shell command."
tags = ["launcher", "panel", "productivity", "utility"]
dependencies = ["nohup"]
[[setting]]
key = "data_path"
type = "file"
label_key = "settings.data_path.label"
description_key = "settings.data_path.description"
default = ""
extensions = [".json"]
[[setting]]
key = "show_info_button"
type = "bool"
label_key = "settings.show_info_button.label"
description_key = "settings.show_info_button.description"
default = true
[[widget]]
id = "bar"
entry = "bar.luau"
[[widget.setting]]
key = "glyph"
type = "glyph"
label_key = "settings.glyph.label"
description_key = "settings.glyph.description"
default = "bookmark"
[[widget.setting]]
key = "tooltip"
type = "string"
label_key = "settings.tooltip.label"
description_key = "settings.tooltip.description"
default = "Bookmarks"
[[widget.setting]]
key = "text"
type = "string"
label_key = "settings.text.label"
description_key = "settings.text.description"
default = "Bookmarks"
[[setting]]
key = "enable_bk_provider"
type = "bool"
label_key = "settings.enable_bk_provider.label"
description_key = "settings.enable_bk_provider.description"
default = true
[[setting]]
key = "bk_sort_by"
type = "select"
label_key = "settings.bk_sort_by.label"
description_key = "settings.bk_sort_by.description"
default = "history"
visible_when = { key = "enable_bk_provider", values = ["true"] }
[[setting.options]]
value = "history"
label_key = "settings.bk_sort_by.options.history"
[[setting.options]]
value = "usage_count"
label_key = "settings.bk_sort_by.options.usage_count"
[[launcher_provider]]
id = "provider"
entry = "bk_provider.luau"
prefix = "bk"
glyph = "bookmark"
include_in_global_search = false
debounce_ms = 100
[[panel]]
id = "panel"
entry = "panel.luau"
width = 380
height = 470
placement = "attached"
position = "auto"
open_near_click = true
capture_keys = ["ctrl+h", "ctrl+j", "ctrl+k", "ctrl+l", "left", "down", "up", "right", "ctrl+n", "ctrl+f", "return"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

-122
View File
@@ -1,122 +0,0 @@
{
"back_button": "Back",
"cancel_button": "Cancel",
"cmd_field": "Shell command",
"delete_folder_button": "Delete folder",
"delete_tooltip": "Delete",
"description_field": "Description (optional)",
"drag_tooltip": "Drag to reorder, or onto a folder to file it inside",
"drop_out_of_folder": "Drop here to move back to the main panel",
"edit": {
"delete_tooltip": "Delete",
"drag_tooltip": "Drag to reorder, or onto a folder to file it inside",
"drop_out_of_folder": "Drop here to move back to the main panel",
"edit_tooltip": "Edit",
"hide_controls_tooltip": "Hide reorder/edit/delete controls",
"show_controls_tooltip": "Show reorder/edit/delete controls"
},
"edit_bookmark": "Edit Bookmark",
"edit_folder": "Edit Folder",
"edit_tooltip": "Edit",
"entry_form": {
"cancel_button": "Cancel",
"cmd_field": "Shell command",
"description_field": "Description (optional)",
"edit_bookmark": "Edit Bookmark",
"edit_folder": "Edit Folder",
"err_label_cmd_required": "Label and command are required",
"err_label_required": "Label is required",
"folder_name_field": "Folder name",
"glyph_field": "Glyph name (e.g. bookmark)",
"label_field": "Label",
"new_bookmark": "New Bookmark",
"new_folder": "New Folder",
"run_in_background_field": "Run in background",
"run_in_terminal_field": "Run in terminal",
"save_button": "Save"
},
"entry_saved": "Saved \"{label}\"",
"entry_updated": "Updated \"{label}\"",
"err": {
"corrupt": "Bookmarks file is corrupt: {error}",
"delete_failed": "Failed to delete: {error}",
"exit_code": "\"{label}\" exited with code {code}",
"launch_failed": "Failed to launch \"{label}\"",
"no_command": "This bookmark has no command",
"no_data_dir": "No data directory available",
"read_failed": "Failed to read bookmarks: {error}",
"reorder_failed": "Failed to reorder: {error}",
"save_failed": "Failed to save: {error}"
},
"err_corrupt": "Bookmarks file is corrupt: {error}",
"err_delete_failed": "Failed to delete: {error}",
"err_exit_code": "\"{label}\" exited with code {code}",
"err_label_cmd_required": "Label and command are required",
"err_label_required": "Label is required",
"err_launch_failed": "Failed to launch \"{label}\"",
"err_no_command": "This bookmark has no command",
"err_no_data_dir": "No data directory available",
"err_read_failed": "Failed to read bookmarks: {error}",
"err_reorder_failed": "Failed to reorder: {error}",
"err_save_failed": "Failed to save: {error}",
"folder": {
"delete_folder_button": "Delete folder",
"folder_deleted": "Deleted folder \"{label}\" and its contents"
},
"folder_deleted": "Deleted folder \"{label}\" and its contents",
"folder_name_field": "Folder name",
"glyph_field": "Glyph name (e.g. bookmark)",
"hide_controls_tooltip": "Hide reorder/edit/delete controls",
"info": {
"cmd_line": "Command: {cmd}",
"description_line": "Description: {description}"
},
"info_cmd_line": "Command: {cmd}",
"info_description_line": "Description: {description}",
"label_field": "Label",
"new_bookmark": "New Bookmark",
"new_folder": "New Folder",
"no_bookmarks": "No bookmarks yet",
"no_search_results": "No matching bookmarks",
"open_tooltip": "Open",
"run_in_background_field": "Run in background",
"run_in_terminal_field": "Run in terminal",
"save_button": "Save",
"search_placeholder": "Search bookmarks...",
"settings": {
"bk_sort_by": {
"description": "How bookmarks are ordered in \"/bk\" results when there's no search text.",
"label": "/bk sort order",
"options": {
"history": "Recently used",
"usage_count": "Most used"
}
},
"data_path": {
"description": "Where data.json is stored. Leave empty to use the default plugin data directory.",
"label": "Data file"
},
"enable_bk_provider": {
"description": "Adds a \"/bk\" launcher command to fuzzy-search and run your bookmarks.",
"label": "Enable /bk launcher provider"
},
"glyph": {
"description": "Bar widget's glyph",
"label": "Glyph"
},
"show_info_button": {
"description": "Show a \"?\" button on bookmarks (not folders) to view their description and command on hover.",
"label": "Show info button"
},
"text": {
"description": "Bar widget's label. Leave empty to display only the glyph.",
"label": "Text"
},
"tooltip": {
"description": "Bar widget's tooltip",
"label": "Tooltip"
}
},
"show_controls_tooltip": "Show reorder/edit/delete controls",
"title": "Bookmarks"
}
-122
View File
@@ -1,122 +0,0 @@
{
"back_button": "Geri",
"cancel_button": "İptal",
"cmd_field": "Shell Komutu",
"delete_folder_button": "Klasörü Sil",
"delete_tooltip": "Sil",
"description_field": "Açıklama (İsteğe bağlı)",
"drag_tooltip": "Sıralamak için sürükle veya bir klasörün içine bırak",
"drop_out_of_folder": "Ana panele göndermek için buraya bırak",
"edit": {
"delete_tooltip": "Sil",
"drag_tooltip": "Sıralamak için sürükle veya bir klasörün içine bırak",
"drop_out_of_folder": "Ana panele göndermek için buraya bırak",
"edit_tooltip": "Düzenle",
"hide_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini gizle",
"show_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini göster"
},
"edit_bookmark": "Yer İmini Düzenle",
"edit_folder": "Klasörü Düzenle",
"edit_tooltip": "Düzenle",
"entry_form": {
"cancel_button": "İptal",
"cmd_field": "Shell Komutu",
"description_field": "Açıklama (İsteğe bağlı)",
"edit_bookmark": "Yer İmini Düzenle",
"edit_folder": "Klasörü Düzenle",
"err_label_cmd_required": "Etiket ve komut alanları gerekli",
"err_label_required": "Etiket gerekli",
"folder_name_field": "Klasör ismi",
"glyph_field": "Simge (ör. bookmark)",
"label_field": "İsim etiketi",
"new_bookmark": "Yeni Yer İmi",
"new_folder": "Yeni Klasör",
"run_in_background_field": "Arka planda çalıştır",
"run_in_terminal_field": "Konsolda çalıştır",
"save_button": "Kaydet"
},
"entry_saved": "\"{label}\" kaydedildi",
"entry_updated": "\"{label}\" güncellendi",
"err": {
"corrupt": "Bozuk yer imleri dosyası: {error}",
"delete_failed": "Silinemedi: {error}",
"exit_code": "\"{label}\" {code} ile sonlandı",
"launch_failed": "\"{label}\" açılamadı",
"no_command": "Bu yer iminin bir komutu yok",
"no_data_dir": "Eksik veri dizgini",
"read_failed": "Yer imleri okunamadı: {error}",
"reorder_failed": "Sıralama Yapılamadı: {error}",
"save_failed": "Kaydedilemedi: {error}"
},
"err_corrupt": "Bozuk yer imleri dosyası: {error}",
"err_delete_failed": "Silinemedi: {error}",
"err_exit_code": "\"{label}\" {code} ile sonlandı",
"err_label_cmd_required": "Etiket ve komut alanları gerekli",
"err_label_required": "Etiket gerekli",
"err_launch_failed": "\"{label}\" açılamadı",
"err_no_command": "Bu yer iminin bir komutu yok",
"err_no_data_dir": "Eksik veri dizgini",
"err_read_failed": "Yer imleri okunamadı: {error}",
"err_reorder_failed": "Sıralama Yapılamadı: {error}",
"err_save_failed": "Kaydedilemedi: {error}",
"folder": {
"delete_folder_button": "Klasörü Sil",
"folder_deleted": "\"{label}\" klasörü ve içeriği silindi"
},
"folder_deleted": "\"{label}\" klasörü ve içeriği silindi",
"folder_name_field": "Klasör ismi",
"glyph_field": "Simge (ör. bookmark)",
"hide_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini gizle",
"info": {
"cmd_line": "Komut: {cmd}",
"description_line": "Açıklama: {description}"
},
"info_cmd_line": "Komut: {cmd}",
"info_description_line": "Açıklama: {description}",
"label_field": "İsim etiketi",
"new_bookmark": "Yeni Yer İmi",
"new_folder": "Yeni Klasör",
"no_bookmarks": "Henüz bir yer imi yok",
"no_search_results": "Eşleşen yer imi yok",
"open_tooltip": "Aç",
"run_in_background_field": "Arka planda çalıştır",
"run_in_terminal_field": "Konsolda çalıştır",
"save_button": "Kaydet",
"search_placeholder": "Yer imlerinde ara...",
"settings": {
"bk_sort_by": {
"description": "\"/bk\" sonuçlarının arama yapılmadığı durumda yer imlerini nasıl sıralacağını belirler.",
"label": "/bk sıralama önceliği",
"options": {
"history": "Son kullanılanlar",
"usage_count": "En sık kullanılanlar"
}
},
"data_path": {
"description": "data.json dosyasının saklandığı konum. Varsayılan eklenti veri konumunu kullanmak için boş bırakın.",
"label": "Veri dosyası"
},
"enable_bk_provider": {
"description": "Başlatıcıya, yer imlerini fuzzy-aramak ve çalıştırmak için \"/bk\" komutunu ekler.",
"label": "Enable /bk launcher provider"
},
"glyph": {
"description": "Çubuk aracının simgesi (ör. bookmark)",
"label": "Simge"
},
"show_info_button": {
"description": "Yer imlerinde (klasörlerde değil) üzerine gelindiğinde açıklamalarını ve komutlarını görüntülemek için bir \"?\" düğmesi göster.",
"label": "Bilgi butonunu göster"
},
"text": {
"description": "Çubuk aracının etiketi. Çubukta sadece simgenin görünmesi için boş bırakın.",
"label": "Yazı"
},
"tooltip": {
"description": "Çubuk aracının isim etiketi",
"label": "İsim etiketi"
}
},
"show_controls_tooltip": "Sıralama/düzenleme/silme kontrollerini göster",
"title": "Yer İmleri"
}
-99
View File
@@ -1,99 +0,0 @@
# Calculator
A calculator for the Noctalia bar. It evaluates whole expressions with operator
precedence, offers both a keypad and a typed input line in its panel, and keeps
the last result visible in the bar.
## Plugin
| Field | Value |
| --- | --- |
| ID | `yuuto/calculator` |
| Entries | Bar widget: `bar`; panel: `panel` |
The `panel` entry owns the calculator model and publishes it through Noctalia's
per-plugin state channel. The `bar` widget is a thin client: it reads that state
and opens the panel.
## Usage
Add the `bar` bar widget to your bar. It shows a calculator glyph, plus the last
result once there is one. Left click opens the panel, right click clears the
calculator. On a vertical bar only the glyph is shown, with the current
expression as its tooltip.
The panel evaluates as you type. The small line shows the expression, the large
line the live result, and the copy button next to it puts the result on the
clipboard.
Enter expressions either with the keypad or by typing into the input line, where
Enter evaluates. Pressing `=` keeps the result in the input, so the next
operator continues from it. The keypad uses plain text labels (`AC`, `+/-`,
`DEL`, `*`, `/`) rather than symbol glyphs, so it renders with any bar font.
Open the panel over IPC:
```sh
noctalia msg panel-toggle yuuto/calculator:panel
```
### Expression syntax
Operators are `+`, `-`, `*`, `/` and `^`, with parentheses and the usual
precedence; `^` is right associative. A trailing `%` divides by 100, so
`200*15%` is `30`. The typed input also accepts `×`, `÷` and `−`.
Available constants: `pi`, `tau`, `e`.
Available functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`,
`sinh`, `cosh`, `tanh`, `ln`, `log`, `log2`, `log10`, `exp`, `sqrt`, `cbrt`,
`abs`, `floor`, `ceil`, `round`, `trunc`, `sign`, `mod`, `pow`, `hypot`, `min`,
`max`.
`log(x)` is base 10; `log(x, b)` is base `b`. `mod(a, b)` is the remainder, since
`%` means percent here. The trigonometric functions follow the angle unit
setting, shown in the panel header as `RAD` or `DEG`.
## Settings
### Plugin
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `precision` | `int` | `8` | Maximum decimals used when formatting results, 0 to 10. |
| `angle_unit` | `select` | `rad` | Angle unit for the trigonometric functions. |
### Bar Widget
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `show_bar_value` | `bool` | `true` | Shows the last result next to the calculator glyph. |
| `max_bar_length` | `int` | `9` | Longer results are shortened with a trailing `~`. |
## IPC
Beyond opening the panel, the `panel` entry accepts three events. The `all`
target addresses every live instance.
```sh
noctalia msg plugin yuuto/calculator:panel all eval "2+3*4"
noctalia msg plugin yuuto/calculator:panel all insert "+8"
noctalia msg plugin yuuto/calculator:panel all clear
```
`eval` replaces the expression and evaluates it, `insert` appends to the current
expression, and `clear` resets the calculator.
## Notes
Expressions are tokenized and parsed by the plugin itself, so evaluating never
runs a shell command and no external tool is required. Results reach the
clipboard through `noctalia.copyToClipboard`.
The panel keeps its model in the plugin state channel rather than in script
locals, so clearing from the bar widget and reopening the panel stay in sync.
## Credits
Keypad layout and feature set are modelled on the v4 Quickshell calculator plugin
by pir0c0pter0 (MIT).
-94
View File
@@ -1,94 +0,0 @@
--!nonstrict
local PANEL_ID = "yuuto/calculator:panel"
local result = "0"
local expression = ""
local hasError = false
local function maxLength()
local value = noctalia.getConfig("max_bar_length")
if type(value) ~= "number" then
return 9
end
return math.clamp(math.floor(value), 3, 20)
end
local function compact(text)
local limit = maxLength()
if #text <= limit then
return text
end
return text:sub(1, math.max(1, limit - 1)) .. "~"
end
local function badge()
if noctalia.getConfig("show_bar_value") == false then
return ""
end
if hasError then
return noctalia.tr("state.error")
end
if result == "" or result == "0" then
return ""
end
return compact(result)
end
local function render()
local container = barWidget.isVertical() and ui.column or ui.row
local text = badge()
local color = hasError and "error" or "on_surface"
barWidget.setTooltip(expression ~= "" and expression or noctalia.tr("bar.tooltip"))
local children = {
ui.glyph({ name = "calculator", color = color }),
}
if text ~= "" and not barWidget.isVertical() then
table.insert(children, ui.label({ text = text, fontWeight = "bold", color = color }))
end
barWidget.render(container({ gap = 6, align = "center" }, children))
end
function update()
render()
end
noctalia.state.watch("calc.result", function(value)
if type(value) == "string" then
result = value
render()
end
end)
noctalia.state.watch("calc.expression", function(value)
if type(value) == "string" then
expression = value
render()
end
end)
noctalia.state.watch("calc.error", function(value)
hasError = value == true
render()
end)
function onClick()
noctalia.togglePanel(PANEL_ID)
end
function onRightClick()
noctalia.state.set("calc.result", "0")
noctalia.state.set("calc.expression", "")
noctalia.state.set("calc.last_expression", "")
noctalia.state.set("calc.error", false)
end
function onIpc(event, _payload)
if event == "toggle" then
noctalia.togglePanel(PANEL_ID)
end
end
-863
View File
@@ -1,863 +0,0 @@
--!nonstrict
local DEG = math.pi / 180
local WIDE_OPERATORS = {
["\u{00d7}"] = "*",
["\u{00f7}"] = "/",
["\u{2212}"] = "-",
["\u{2013}"] = "-",
["\u{00b7}"] = "*",
}
local CONSTANTS = {
pi = math.pi,
tau = math.pi * 2,
e = math.exp(1),
}
local ARITY = {
atan2 = 2,
mod = 2,
pow = 2,
hypot = 2,
}
local function sinh(x)
return (math.exp(x) - math.exp(-x)) / 2
end
local function cosh(x)
return (math.exp(x) + math.exp(-x)) / 2
end
local function tanh(x)
if x > 20 then
return 1
end
if x < -20 then
return -1
end
local a, b = math.exp(x), math.exp(-x)
return (a - b) / (a + b)
end
local function isDigit(c)
return c >= "0" and c <= "9"
end
local function isAlpha(c)
return (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or c == "_"
end
local function tokenize(src)
local tokens = {}
local i = 1
local n = #src
while i <= n do
local wide = WIDE_OPERATORS[src:sub(i, i + 1)] or WIDE_OPERATORS[src:sub(i, i + 2)]
if wide ~= nil then
table.insert(tokens, { kind = "op", text = wide })
i += WIDE_OPERATORS[src:sub(i, i + 1)] ~= nil and 2 or 3
continue
end
local c = src:sub(i, i)
if c == " " or c == "\t" then
i += 1
elseif isDigit(c) or (c == "." and isDigit(src:sub(i + 1, i + 1))) then
local start = i
while i <= n and isDigit(src:sub(i, i)) do
i += 1
end
if src:sub(i, i) == "." then
i += 1
while i <= n and isDigit(src:sub(i, i)) do
i += 1
end
end
local mark = i
if src:sub(i, i):lower() == "e" then
local j = i + 1
local sign = src:sub(j, j)
if sign == "+" or sign == "-" then
j += 1
end
if isDigit(src:sub(j, j)) then
i = j
while i <= n and isDigit(src:sub(i, i)) do
i += 1
end
else
i = mark
end
end
local value = tonumber(src:sub(start, i - 1))
if value == nil then
return nil
end
table.insert(tokens, { kind = "num", value = value })
elseif isAlpha(c) then
local start = i
while i <= n and (isAlpha(src:sub(i, i)) or isDigit(src:sub(i, i))) do
i += 1
end
table.insert(tokens, { kind = "name", text = src:sub(start, i - 1):lower() })
elseif c == "(" or c == ")" then
table.insert(tokens, { kind = c })
i += 1
elseif c == "," or c == ";" then
table.insert(tokens, { kind = "," })
i += 1
elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" or c == "%" then
table.insert(tokens, { kind = "op", text = c })
i += 1
else
return nil
end
end
return tokens
end
local function makeFunctions(degrees)
local toAngle = degrees and function(x)
return x * DEG
end or function(x)
return x
end
local fromAngle = degrees and function(x)
return x / DEG
end or function(x)
return x
end
return {
sin = function(a)
return math.sin(toAngle(a[1]))
end,
cos = function(a)
return math.cos(toAngle(a[1]))
end,
tan = function(a)
return math.tan(toAngle(a[1]))
end,
asin = function(a)
return fromAngle(math.asin(a[1]))
end,
acos = function(a)
return fromAngle(math.acos(a[1]))
end,
atan = function(a)
if #a >= 2 then
return fromAngle(math.atan2(a[1], a[2]))
end
return fromAngle(math.atan(a[1]))
end,
atan2 = function(a)
return fromAngle(math.atan2(a[1], a[2]))
end,
sinh = function(a)
return sinh(a[1])
end,
cosh = function(a)
return cosh(a[1])
end,
tanh = function(a)
return tanh(a[1])
end,
ln = function(a)
return math.log(a[1])
end,
log = function(a)
if #a >= 2 then
return math.log(a[1]) / math.log(a[2])
end
return math.log(a[1], 10)
end,
log2 = function(a)
return math.log(a[1], 2)
end,
log10 = function(a)
return math.log(a[1], 10)
end,
exp = function(a)
return math.exp(a[1])
end,
sqrt = function(a)
return math.sqrt(a[1])
end,
cbrt = function(a)
local x = a[1]
if x < 0 then
return -((-x) ^ (1 / 3))
end
return x ^ (1 / 3)
end,
abs = function(a)
return math.abs(a[1])
end,
floor = function(a)
return math.floor(a[1])
end,
ceil = function(a)
return math.ceil(a[1])
end,
round = function(a)
return math.round(a[1])
end,
trunc = function(a)
local x = a[1]
return x >= 0 and math.floor(x) or math.ceil(x)
end,
sign = function(a)
return math.sign(a[1])
end,
mod = function(a)
return a[1] % a[2]
end,
pow = function(a)
return a[1] ^ a[2]
end,
hypot = function(a)
return math.sqrt(a[1] * a[1] + a[2] * a[2])
end,
min = function(a)
return math.min(table.unpack(a))
end,
max = function(a)
return math.max(table.unpack(a))
end,
}
end
local function evaluate(source, degrees)
local tokens = tokenize(source)
if tokens == nil or #tokens == 0 then
return nil
end
local functions = makeFunctions(degrees)
local pos = 1
local failed = false
local function peek()
return tokens[pos]
end
local function fail()
failed = true
return 0
end
local parseExpression
local parseUnary
local function parsePrimary()
local token = peek()
if token == nil then
return fail()
end
if token.kind == "num" then
pos += 1
return token.value
end
if token.kind == "(" then
pos += 1
local value = parseExpression()
local closing = peek()
if closing == nil or closing.kind ~= ")" then
return fail()
end
pos += 1
return value
end
if token.kind == "name" then
pos += 1
local name = token.text
local following = peek()
if following ~= nil and following.kind == "(" then
local fn = functions[name]
if fn == nil then
return fail()
end
pos += 1
local args = {}
if peek() ~= nil and peek().kind ~= ")" then
table.insert(args, parseExpression())
while peek() ~= nil and peek().kind == "," do
pos += 1
table.insert(args, parseExpression())
end
end
local closing = peek()
if closing == nil or closing.kind ~= ")" then
return fail()
end
pos += 1
if #args < (ARITY[name] or 1) then
return fail()
end
return fn(args)
end
local constant = CONSTANTS[name]
if constant ~= nil then
return constant
end
return fail()
end
return fail()
end
local function parsePostfix()
local value = parsePrimary()
while true do
local token = peek()
if token ~= nil and token.kind == "op" and token.text == "%" then
pos += 1
value /= 100
else
break
end
end
return value
end
local function parsePower()
local base = parsePostfix()
local token = peek()
if token ~= nil and token.kind == "op" and token.text == "^" then
pos += 1
return base ^ parseUnary()
end
return base
end
function parseUnary()
local token = peek()
if token ~= nil and token.kind == "op" and (token.text == "-" or token.text == "+") then
pos += 1
local value = parseUnary()
return token.text == "-" and -value or value
end
return parsePower()
end
local function parseTerm()
local value = parseUnary()
while true do
local token = peek()
if token ~= nil and token.kind == "op" and (token.text == "*" or token.text == "/") then
pos += 1
local rhs = parseUnary()
if token.text == "*" then
value *= rhs
else
if rhs == 0 then
return fail()
end
value /= rhs
end
elseif token ~= nil and token.kind == "(" then
value *= parseUnary()
else
break
end
end
return value
end
function parseExpression()
local value = parseTerm()
while true do
local token = peek()
if token ~= nil and token.kind == "op" and (token.text == "+" or token.text == "-") then
pos += 1
local rhs = parseTerm()
if token.text == "+" then
value += rhs
else
value -= rhs
end
else
break
end
end
return value
end
local ok, value = pcall(parseExpression)
if not ok or failed or pos <= #tokens then
return nil
end
if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
return nil
end
return value
end
local function formatNumber(value, precision)
if value == 0 then
return "0"
end
local magnitude = math.abs(value)
if magnitude >= 1e16 or magnitude < 1e-9 then
local text = string.format("%." .. math.min(precision, 8) .. "e", value)
local mantissa, exponent = text:match("^(.-)e([-+]%d+)$")
if mantissa ~= nil then
if mantissa:find("%.") then
mantissa = mantissa:gsub("0+$", ""):gsub("%.$", "")
end
local sign, digits = exponent:match("^([-+])0*(%d+)$")
return mantissa .. "e" .. (sign == "-" and "-" or "") .. digits
end
return text
end
local text = string.format("%." .. precision .. "f", value)
if text:find("%.") then
text = text:gsub("0+$", ""):gsub("%.$", "")
end
if text == "-0" then
return "0"
end
return text
end
local expression = ""
local lastExpression = ""
local result = "0"
local lastPreview = nil
local hasError = false
local inputKey = 0
local function precision()
local value = noctalia.getConfig("precision")
if type(value) ~= "number" then
return 8
end
return math.clamp(math.floor(value), 0, 10)
end
local function useDegrees()
return noctalia.getConfig("angle_unit") == "deg"
end
local function restore()
local storedResult = noctalia.state.get("calc.result")
if type(storedResult) == "string" and storedResult ~= "" then
result = storedResult
end
local storedExpression = noctalia.state.get("calc.expression")
if type(storedExpression) == "string" then
expression = storedExpression
end
local storedLast = noctalia.state.get("calc.last_expression")
if type(storedLast) == "string" then
lastExpression = storedLast
end
hasError = noctalia.state.get("calc.error") == true
lastPreview = nil
end
local function publish()
noctalia.state.set("calc.result", result)
noctalia.state.set("calc.expression", expression)
noctalia.state.set("calc.last_expression", lastExpression)
noctalia.state.set("calc.error", hasError)
end
local function preview()
if expression == "" then
return nil
end
local value = evaluate(expression, useDegrees())
if value == nil then
return nil
end
return formatNumber(value, precision())
end
local function displayValue()
if hasError then
return noctalia.tr("state.error")
end
if expression == "" then
return result
end
local current = preview()
if current ~= nil then
lastPreview = current
end
return current or lastPreview or result
end
local function toggleSign()
if expression == "" then
expression = "-"
return
end
local head, tail = expression:match("^(.-)([%d%.]+)$")
if tail == nil then
local last = expression:sub(-1)
if last:match("[%+%-%*/%^%(]") then
expression ..= "-"
end
return
end
local before = head:sub(-1)
if before == "-" then
local previous = head:sub(-2, -2)
if head == "-" or previous:match("[%+%-%*/%^%(,]") then
expression = head:sub(1, -2) .. tail
return
end
end
expression = head .. "-" .. tail
end
local render
local function clearAll()
expression = ""
lastExpression = ""
result = "0"
lastPreview = nil
hasError = false
inputKey += 1
end
local function commit()
if expression == "" then
return
end
local value = evaluate(expression, useDegrees())
if value == nil then
hasError = true
lastExpression = expression
expression = ""
lastPreview = nil
inputKey += 1
return
end
result = formatNumber(value, precision())
lastPreview = result
lastExpression = expression
expression = result
hasError = false
inputKey += 1
end
local function press(action, literal)
if hasError and action ~= "clear" then
hasError = false
expression = ""
lastExpression = ""
end
if action == "clear" then
clearAll()
elseif action == "delete" then
expression = expression:sub(1, -2)
inputKey += 1
elseif action == "sign" then
toggleSign()
inputKey += 1
elseif action == "equals" then
commit()
else
expression ..= literal
inputKey += 1
end
publish()
render()
end
local KEYPAD = {
{
{ label = "AC", handler = "onCalcClear", variant = "destructive" },
{ label = "+/-", handler = "onCalcSign", variant = "secondary" },
{ label = "%", handler = "onCalcPercent", variant = "secondary" },
{ label = "DEL", handler = "onCalcDelete", variant = "secondary" },
},
{
{ label = "7", handler = "onCalcSeven" },
{ label = "8", handler = "onCalcEight" },
{ label = "9", handler = "onCalcNine" },
{ label = "/", handler = "onCalcDivide", variant = "secondary" },
},
{
{ label = "4", handler = "onCalcFour" },
{ label = "5", handler = "onCalcFive" },
{ label = "6", handler = "onCalcSix" },
{ label = "*", handler = "onCalcMultiply", variant = "secondary" },
},
{
{ label = "1", handler = "onCalcOne" },
{ label = "2", handler = "onCalcTwo" },
{ label = "3", handler = "onCalcThree" },
{ label = "-", handler = "onCalcMinus", variant = "secondary" },
},
{
{ label = "0", handler = "onCalcZero", grow = 2 },
{ label = ".", handler = "onCalcDecimal" },
{ label = "+", handler = "onCalcPlus", variant = "secondary" },
},
{
{ label = "(", handler = "onCalcOpen", variant = "ghost" },
{ label = ")", handler = "onCalcClose", variant = "ghost" },
{ label = "=", handler = "onCalcEquals", variant = "primary", grow = 2 },
},
}
local function keypadRow(row, rowIndex)
local buttons = {}
for index, spec in ipairs(row) do
table.insert(
buttons,
ui.button({
key = "k" .. rowIndex .. "-" .. index,
text = spec.label,
variant = spec.variant or "default",
fontSize = 15,
height = 34,
flexGrow = spec.grow or 1,
onClick = spec.handler,
})
)
end
return ui.row({ key = "row" .. rowIndex, gap = 6 }, buttons)
end
function render()
local shown = displayValue()
local hint = hasError and lastExpression or (expression ~= "" and expression or lastExpression)
local atRest = shown == "0" and expression == "" and lastExpression == ""
local canCopy = not hasError and shown ~= "" and not atRest
local keypad = {}
for index, row in ipairs(KEYPAD) do
table.insert(keypad, keypadRow(row, index))
end
panel.render(ui.column({ gap = 10, padding = 14 }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "calculator", size = 16, color = "primary" }),
ui.label({
text = noctalia.tr("panel.title"),
fontSize = 15,
fontWeight = "bold",
color = "on_surface",
flexGrow = 1,
}),
ui.label({
text = noctalia.tr(useDegrees() and "panel.deg" or "panel.rad"),
fontSize = 11,
color = "on_surface/0.55",
}),
}),
ui.column({ gap = 2, padding = 10, fill = "primary/0.08", radius = 10 }, {
ui.label({
text = hint,
fontSize = 11,
color = "on_surface/0.6",
textAlign = "right",
maxLines = 1,
}),
ui.row({ gap = 6, align = "center" }, {
ui.label({
text = shown,
fontSize = 30,
fontWeight = "bold",
color = hasError and "error" or "on_surface",
textAlign = "right",
maxLines = 1,
flexGrow = 1,
}),
ui.button({
key = "copy",
glyph = "copy",
glyphSize = 15,
variant = "ghost",
controlSize = "sm",
contentAlign = "center",
tooltip = noctalia.tr("panel.copy"),
visible = canCopy,
onClick = "onCalcCopy",
}),
}),
}),
ui.input({
key = "expr-" .. inputKey,
value = expression,
placeholder = noctalia.tr("panel.placeholder"),
fontSize = 13,
controlSize = "sm",
focus = true,
onChange = "onCalcInputChange",
onSubmit = "onCalcInputSubmit",
}),
ui.column({ gap = 6 }, keypad),
}))
end
function onCalcClear()
press("clear")
end
function onCalcSign()
press("sign")
end
function onCalcPercent()
press("append", "%")
end
function onCalcDelete()
press("delete")
end
function onCalcZero()
press("append", "0")
end
function onCalcOne()
press("append", "1")
end
function onCalcTwo()
press("append", "2")
end
function onCalcThree()
press("append", "3")
end
function onCalcFour()
press("append", "4")
end
function onCalcFive()
press("append", "5")
end
function onCalcSix()
press("append", "6")
end
function onCalcSeven()
press("append", "7")
end
function onCalcEight()
press("append", "8")
end
function onCalcNine()
press("append", "9")
end
function onCalcDecimal()
press("append", ".")
end
function onCalcPlus()
press("append", "+")
end
function onCalcMinus()
press("append", "-")
end
function onCalcMultiply()
press("append", "*")
end
function onCalcDivide()
press("append", "/")
end
function onCalcOpen()
press("append", "(")
end
function onCalcClose()
press("append", ")")
end
function onCalcEquals()
press("equals")
end
function onCalcCopy()
local shown = displayValue()
if hasError or shown == "" then
return
end
if noctalia.copyToClipboard(shown, "text/plain") then
noctalia.notify(noctalia.tr("panel.title"), noctalia.tr("panel.copied", { value = shown }))
end
end
function onCalcInputChange(value)
expression = value
hasError = false
publish()
render()
end
function onCalcInputSubmit()
press("equals")
end
function onOpen(_context)
restore()
inputKey += 1
render()
end
function onIpc(event, payload)
if event == "clear" then
clearAll()
elseif event == "eval" and type(payload) == "string" then
expression = payload
commit()
elseif event == "insert" and type(payload) == "string" then
expression ..= payload
inputKey += 1
else
return
end
publish()
render()
end
noctalia.state.watch("calc.expression", function(value)
if type(value) ~= "string" or value == expression then
return
end
restore()
inputKey += 1
render()
end)
restore()
publish()
-59
View File
@@ -1,59 +0,0 @@
id = "yuuto/calculator"
name = "Calculator"
version = "1.0.0"
plugin_api = 3
author = "yuuto"
license = "MIT"
icon = "calculator"
description = "A theme-aware calculator with a bar widget and a panel: full expression evaluation, a button grid, and typed input."
dependencies = []
tags = ["utility", "productivity", "bar", "panel"]
[[setting]]
key = "precision"
type = "int"
label_key = "settings.precision.label"
description_key = "settings.precision.description"
default = 8
min = 0
max = 10
[[setting]]
key = "angle_unit"
type = "select"
label_key = "settings.angle_unit.label"
description_key = "settings.angle_unit.description"
default = "rad"
options = [
{ value = "rad", label_key = "settings.angle_unit.options.rad" },
{ value = "deg", label_key = "settings.angle_unit.options.deg" },
]
[[widget]]
id = "bar"
entry = "bar.luau"
[[widget.setting]]
key = "show_bar_value"
type = "bool"
label_key = "settings.show_bar_value.label"
description_key = "settings.show_bar_value.description"
default = true
[[widget.setting]]
key = "max_bar_length"
type = "int"
label_key = "settings.max_bar_length.label"
description_key = "settings.max_bar_length.description"
default = 9
min = 3
max = 20
[[panel]]
id = "panel"
entry = "panel.luau"
width = 320
height = 452
placement = "attached"
position = "auto"
open_near_click = true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

-38
View File
@@ -1,38 +0,0 @@
{
"bar": {
"tooltip": "Rechner"
},
"panel": {
"copied": "Kopiert: {value}",
"copy": "Ergebnis kopieren",
"deg": "GRAD",
"placeholder": "Ausdruck eingeben, Enter drücken",
"rad": "RAD",
"title": "Rechner"
},
"settings": {
"angle_unit": {
"description": "Einheit, die die trigonometrischen Funktionen verwenden.",
"label": "Winkeleinheit",
"options": {
"deg": "Grad",
"rad": "Bogenmaß"
}
},
"max_bar_length": {
"description": "Längere Ergebnisse werden in der Leiste mit Auslassungspunkten gekürzt.",
"label": "Maximale Länge in der Leiste"
},
"precision": {
"description": "Maximale Anzahl Nachkommastellen bei der Ergebnisformatierung.",
"label": "Dezimalgenauigkeit"
},
"show_bar_value": {
"description": "Zeigt das letzte Ergebnis neben dem Rechner-Symbol an.",
"label": "Wert in der Leiste anzeigen"
}
},
"state": {
"error": "Fehler"
}
}
-38
View File
@@ -1,38 +0,0 @@
{
"bar": {
"tooltip": "Calculator"
},
"panel": {
"copied": "Copied {value}",
"copy": "Copy result",
"deg": "DEG",
"placeholder": "Type an expression, press Enter",
"rad": "RAD",
"title": "Calculator"
},
"settings": {
"angle_unit": {
"description": "Unit used by the trigonometric functions.",
"label": "Angle unit",
"options": {
"deg": "Degrees",
"rad": "Radians"
}
},
"max_bar_length": {
"description": "Longer results are shortened with an ellipsis in the bar.",
"label": "Maximum bar length"
},
"precision": {
"description": "Maximum number of decimals used when formatting results.",
"label": "Decimal precision"
},
"show_bar_value": {
"description": "Display the last result next to the calculator icon.",
"label": "Show value in bar"
}
},
"state": {
"error": "Error"
}
}
-38
View File
@@ -1,38 +0,0 @@
{
"bar": {
"tooltip": "Hesap Makinesi"
},
"panel": {
"copied": "{value} kopyalandı",
"copy": "Sonucu kopyala",
"deg": "DEG",
"placeholder": "Bir ifade yazıp Enter'a bas",
"rad": "RAD",
"title": "Hesap Makinesi"
},
"settings": {
"angle_unit": {
"description": "Trigonometrik fonksiyoların kullandığı birim.",
"label": "Açı birimi",
"options": {
"deg": "Derece",
"rad": "Radyan"
}
},
"max_bar_length": {
"description": "Uzun sonuçlar çubukta üç nokta ile kısaltılır.",
"label": "Maksimum çubuk genişliği"
},
"precision": {
"description": "Sonuçlar biçimlendirildiğinde kullanılacak maksimum basamak sayısı.",
"label": "Ondalık hassasiyet"
},
"show_bar_value": {
"description": "Son sonucu hesap makinesi ikonunun yanında göster.",
"label": "Sonucu çubukta göster"
}
},
"state": {
"error": "Hata"
}
}
-46
View File
@@ -1,46 +0,0 @@
# Cat
An animated cat that lives in your bar. It sleeps when your CPU is idle,
walks as load picks up, and breaks into a full sprint under heavy load —
colored to match your theme, or any color you pick.
## Plugin
| Field | Value |
| --- | --- |
| ID | `dotnetrob/cat` |
| Entries | Bar widget: `cat`; panel: `panel` |
## Usage
Add the "Cat" widget to any bar from Settings → Bar → Add Widget. Click the
widget to toggle a popup panel showing the same cat at panel size — animating
in step with the bar cat — plus the current CPU percentage; click anywhere
outside the panel to dismiss it. The panel can also be toggled over IPC:
```sh
noctalia msg panel-toggle dotnetrob/cat:panel
```
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `cat_size` | `int` | `24` | Sprite size in the bar, in pixels (12–48). |
| `show_cpu_percent` | `bool` | `false` | Display the CPU percentage next to the cat. |
| `walk_threshold` | `int` | `15` | CPU percentage at which the cat wakes up and starts walking. |
| `run_threshold` | `int` | `60` | CPU percentage at which the cat breaks into a run. |
| `poll_interval` | `int` | `2` | How often to sample CPU usage, in seconds. |
| `cat_color_mode` | `select` | `theme` | `theme` colors the cat with the palette's `secondary` role and tracks theme changes; `custom` uses the color below. |
| `cat_color` | `color` | `#E8A24C` | Used when `cat_color_mode` is `custom`. |
## Notes
Every `poll_interval` seconds the widget reads `/proc/stat` to compute CPU
usage — no other files are read or written, nothing is downloaded, and no
processes are spawned. The panel does no sampling of its own: it mirrors the
bar widget through the plugin's in-process shared state store, so without a
cat widget in any bar it shows a sleeping cat with no CPU reading. The cat's shape comes from a small custom icon font
(`fonts/catwalk2.otf`) traced from the MIT-licensed
[CatWalk](https://store.kde.org/p/2055225) plasmoid by Driglu4it, which lets
it be recolored like normal bar text instead of a fixed-color image.
-176
View File
@@ -1,176 +0,0 @@
--!nonstrict
-- Cat: an animated running cat bar widget. Pace reflects CPU usage, sampled
-- from /proc/stat, with separately configurable walk/run thresholds. Color
-- follows the theme's secondary role by default, or a pinned custom color.
--
-- The cat shape is a custom pictographic font (fonts/catwalk2.otf, traced
-- from the MIT-licensed CatWalk plasmoid by Driglu4it) so it can be colored
-- like any other bar text: "a" = idle/asleep, "b".."f" = the 5-frame run
-- cycle. Each glyph's advance width is trimmed to the shared ink extent
-- (non-negative left bearing, no dead trailing space) so bar layout doesn't
-- overlap the previous widget or leave a large gap before the next one.
--
-- Font registration is process-global and keyed by file path/family, so this
-- filename must change (not just its contents) whenever the glyph set changes
-- during development, or the shell keeps rendering whatever it cached the
-- first time it saw this path.
local catFont = noctalia.loadFont("fonts/catwalk2.otf")
local GLYPH_IDLE = "a"
local RUN_GLYPHS = { "b", "c", "d", "e", "f" }
local TICK_MS = 80
local WALK_MAX_MS = 380
local WALK_MIN_MS = 180
local RUN_MAX_MS = 160
local RUN_MIN_MS = 55
local catSize = noctalia.getConfig("cat_size")
local showCpuPercent = noctalia.getConfig("show_cpu_percent")
local walkThreshold = noctalia.getConfig("walk_threshold")
local runThreshold = noctalia.getConfig("run_threshold")
local pollIntervalMs = noctalia.getConfig("poll_interval") * 1000
local colorMode = noctalia.getConfig("cat_color_mode")
local customColor = noctalia.getConfig("cat_color")
local frameIndex = 1
local frameElapsed = 0
local frameDurationMs = WALK_MAX_MS
local sampleElapsed = pollIntervalMs -- sample immediately on first tick
local cpuPercent = 0
local prevTotal, prevIdle = nil, nil
local function sampleCpu()
local stat = noctalia.readFile("/proc/stat")
if not stat then
return
end
local line = stat:match("^cpu%s+(.-)\n") or stat:match("^cpu%s+(.-)$")
if not line then
return
end
local fields = {}
for n in line:gmatch("%d+") do
table.insert(fields, tonumber(n))
end
local idle = (fields[4] or 0) + (fields[5] or 0)
local total = 0
for _, v in fields do
total += v
end
if prevTotal then
local totalDelta = total - prevTotal
local idleDelta = idle - prevIdle
if totalDelta > 0 then
cpuPercent = math.clamp((totalDelta - idleDelta) / totalDelta * 100, 0, 100)
end
end
prevTotal = total
prevIdle = idle
end
-- Returns ("idle" | "walk" | "run", frame-duration-ms-for-that-pace).
local function paceFor(cpu)
local runFloor = math.max(runThreshold, walkThreshold + 1)
if cpu < walkThreshold then
return "idle", WALK_MAX_MS
elseif cpu < runFloor then
local range = math.max(runFloor - walkThreshold, 1)
local t = math.clamp((cpu - walkThreshold) / range, 0, 1)
return "walk", WALK_MAX_MS - t * (WALK_MAX_MS - WALK_MIN_MS)
else
local range = math.max(100 - runFloor, 1)
local t = math.clamp((cpu - runFloor) / range, 0, 1)
return "run", RUN_MAX_MS - t * (RUN_MAX_MS - RUN_MIN_MS)
end
end
local function resolveColor()
if colorMode == "custom" and customColor and customColor ~= "" then
return customColor
end
return "secondary"
end
-- The click panel (cat_panel.luau) runs in a separate runtime; it mirrors the
-- bar cat from this shared-state snapshot, republished whenever the visible
-- glyph, whole CPU percent, or color changes (so at most once per animation
-- frame, and only on sample/theme changes while idle).
local lastPublished = nil
local function publishState(pace, glyph, color)
local cpu = math.floor(cpuPercent)
local key = `{glyph}|{cpu}|{color}|{pace}`
if key == lastPublished then
return
end
lastPublished = key
noctalia.state.set("cat", { glyph = glyph, cpu = cpu, color = color, pace = pace })
end
local function render(pace)
local glyph = pace == "idle" and GLYPH_IDLE or RUN_GLYPHS[frameIndex]
local color = resolveColor()
publishState(pace, glyph, color)
local children = {
ui.label({ text = glyph, fontFamily = catFont, baseline = "inkCentered", fontSize = catSize, color = color }),
}
if showCpuPercent then
table.insert(children, ui.label({ text = `{math.floor(cpuPercent)}%`, fontSize = 11, color = color }))
end
local container = barWidget.isVertical() and ui.column or ui.row
barWidget.render(container({ gap = 4, align = "center" }, children))
barWidget.setTooltip(`Cat — CPU {math.floor(cpuPercent)}% ({pace})`)
end
noctalia.setUpdateInterval(TICK_MS)
function update()
sampleElapsed += TICK_MS
if sampleElapsed >= pollIntervalMs then
sampleElapsed = 0
sampleCpu()
end
local pace, duration = paceFor(cpuPercent)
frameDurationMs = duration
if pace == "idle" then
frameElapsed = 0
frameIndex = 1
else
frameElapsed += TICK_MS
if frameElapsed >= frameDurationMs then
frameElapsed = 0
frameIndex = frameIndex % #RUN_GLYPHS + 1
end
end
render(pace)
end
function onClick()
noctalia.togglePanel("dotnetrob/cat:panel")
end
function onConfigChanged()
catSize = noctalia.getConfig("cat_size")
showCpuPercent = noctalia.getConfig("show_cpu_percent")
walkThreshold = noctalia.getConfig("walk_threshold")
runThreshold = noctalia.getConfig("run_threshold")
pollIntervalMs = noctalia.getConfig("poll_interval") * 1000
colorMode = noctalia.getConfig("cat_color_mode")
customColor = noctalia.getConfig("cat_color")
render((paceFor(cpuPercent)))
end
-44
View File
@@ -1,44 +0,0 @@
--!nonstrict
-- Cat panel: the popup opened by clicking the bar widget. Shows the same cat
-- glyph as the bar, just bigger, plus the CPU percentage.
--
-- The panel runs in its own Luau runtime, so it can't share locals with
-- cat.luau. Instead the bar widget publishes { glyph, cpu, color, pace } to
-- the plugin's shared state store under "cat" whenever any of them change,
-- and this script re-renders on every update — which is also what keeps the
-- big cat's run cycle in step with the bar cat, frame for frame. No ticks of
-- its own: with no widget publishing (state.get returns nil) it just shows
-- the sleeping cat with no reading.
local catFont = noctalia.loadFont("fonts/catwalk2.otf")
local PANEL_CAT_SIZE = 120
local snapshot = nil
local function render()
local glyph = snapshot and snapshot.glyph or "a"
local color = snapshot and snapshot.color or "secondary"
local cpuText = snapshot and `CPU {snapshot.cpu}%` or "CPU —"
panel.render(ui.column({ gap = 12, align = "center", padding = 24 }, {
ui.label({
text = glyph,
fontFamily = catFont,
baseline = "inkCentered",
fontSize = PANEL_CAT_SIZE,
color = color,
}),
ui.label({ text = cpuText, fontSize = 20, color = color }),
}))
end
noctalia.state.watch("cat", function(value)
snapshot = value
render()
end)
function onOpen()
snapshot = noctalia.state.get("cat")
render()
end
Binary file not shown.
-87
View File
@@ -1,87 +0,0 @@
id = "dotnetrob/cat"
name = "Cat"
version = "1.2.0"
plugin_api = 3
author = "DotNetRob"
license = "MIT"
dependencies = []
icon = "paw"
description = "An animated running cat bar widget whose speed reflects CPU usage."
tags = ["bar", "animation", "system", "fun"]
# Popup opened by clicking the bar widget: the same cat, bigger, with the
# CPU percentage. Attached to the bar near the click; the host default
# dismiss-on-outside-click makes it behave like a transient popup.
[[panel]]
id = "panel"
entry = "cat_panel.luau"
width = 280
height = 240
placement = "attached"
open_near_click = true
[[widget]]
id = "cat"
entry = "cat.luau"
[[widget.setting]]
key = "cat_size"
type = "int"
label_key = "settings.cat_size.label"
description_key = "settings.cat_size.description"
default = 24
min = 12
max = 48
[[widget.setting]]
key = "show_cpu_percent"
type = "bool"
label_key = "settings.show_cpu_percent.label"
description_key = "settings.show_cpu_percent.description"
default = false
[[widget.setting]]
key = "walk_threshold"
type = "int"
label_key = "settings.walk_threshold.label"
description_key = "settings.walk_threshold.description"
default = 15
min = 0
max = 100
[[widget.setting]]
key = "run_threshold"
type = "int"
label_key = "settings.run_threshold.label"
description_key = "settings.run_threshold.description"
default = 60
min = 0
max = 100
[[widget.setting]]
key = "poll_interval"
type = "int"
label_key = "settings.poll_interval.label"
description_key = "settings.poll_interval.description"
default = 2
min = 1
max = 10
[[widget.setting]]
key = "cat_color_mode"
type = "select"
label_key = "settings.cat_color_mode.label"
description_key = "settings.cat_color_mode.description"
default = "theme"
options = [
{ value = "theme", label_key = "settings.cat_color_mode.option_theme" },
{ value = "custom", label_key = "settings.cat_color_mode.option_custom" },
]
[[widget.setting]]
key = "cat_color"
type = "color"
label_key = "settings.cat_color.label"
description_key = "settings.cat_color.description"
default = "#E8A24C"
visible_when = { key = "cat_color_mode", values = ["custom"] }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

-35
View File
@@ -1,35 +0,0 @@
{
"settings": {
"cat_color": {
"description": "Used when Cat Color is set to Custom.",
"label": "Custom Color"
},
"cat_color_mode": {
"description": "Use the current theme color, or pick a custom color below.",
"label": "Cat Color",
"option_custom": "Custom",
"option_theme": "Match Theme"
},
"cat_size": {
"description": "Sprite size in the bar, in pixels.",
"label": "Cat Size"
},
"poll_interval": {
"description": "How often to sample CPU usage, in seconds.",
"label": "CPU Poll Interval"
},
"run_threshold": {
"description": "CPU percentage at which the cat breaks into a run.",
"label": "Run Threshold"
},
"show_cpu_percent": {
"description": "Display the CPU percentage next to the cat.",
"label": "Show CPU Percentage"
},
"walk_threshold": {
"description": "CPU percentage at which the cat wakes up and starts walking.",
"label": "Walk Threshold"
}
},
"title": "Cat"
}
+5 -1352
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
# Python bytecode (the MCP shim and hooks are interpreted; caches are build artifacts)
__pycache__/
*.pyc
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 lowcache
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-174
View File
@@ -1,174 +0,0 @@
# The Pulse Protocol
An agent-agnostic contract for driving the `pulse-svc` service aggregator (and everything
downstream of it: the pulse bar widget, the presence orb, tooltips, `claude.pulse` subscribers). The
service knows nothing about Claude Code — it consumes **events** and an optional
**telemetry payload** over noctalia's plugin IPC. Any coding agent that can run
a shell command on its lifecycle hooks (gemini-cli, codex, opencode, aider, a
CI job, a cron script) can light up the same bar dot.
Two adapters ship in `hooks/`:
| Adapter | For | Telemetry |
|---|---|---|
| `pulse.py` | Claude Code (reads hook JSON on stdin, parses the session transcript) | live token burn, O(delta) |
| `pulse-emit` | anything else (plain POSIX sh, args only) | whatever you pass, or none |
## Transport
```
noctalia msg plugin <target> all <event> [payload]
```
- `<target>` is the plugin dispatch id: `<plugin-id>:<entry>` —
`lowcache/claude-companion:pulse-svc` (the headless aggregator service) for this
install. Adapters must treat it as configurable (`pulse-emit` reads `$PULSE_TARGET`).
- `all` addresses every monitor's widget instance. (`focused` or a bare
connector errors when the widget sits on multiple bars.)
- `[payload]` is a **single positional token** — noctalia's msg CLI splits on
whitespace, so the payload must be space-free. That's why it's a CSV, not
JSON.
- Fire-and-forget. The dispatch returns `ok: dispatched N` or an error string;
adapters ignore both (see the fail-open contract below).
## Event vocabulary
Eight events. Priority decides which session the bar shows when several are
active; "resting" matters for the default-slot rule below.
| Event | Meaning | Priority | Resting |
|---|---|---|---|
| `needs_attention` | agent is blocked on the human (permission prompt, question) | 6 | no |
| `error` | hard failure | 5 | yes |
| `tool_start` | executing a tool / command | 4 | no |
| `turn_start` | thinking — a turn has begun | 3 | no |
| `text` | streaming a response | 3 | no |
| `turn_end` | turn finished — output ready for the human | 2 | yes |
| `idle` | session alive, nothing happening | 1 | yes |
| `session_end` | session is over — **retires** its slot | — | — |
Unknown events render as idle-with-the-event-kept-as-state-word; stick to the
vocabulary. Glyph, accent color, and breath animation are widget-side concerns
(see `VISUAL` in `pulse.luau`, and the `breath_speed`, `pulse_glow_floor`, and
`orb_swell` user settings in `README.md`) — the protocol only fixes the *semantics*.
## Payload
```
model,in,out,cacheCreate,cacheRead,session
```
- `session` (field 6) is the only field that changes behavior: it keys the
per-session slot, so every event from the same agent session must carry the
same short id (Claude's adapter uses the first `-` segment of the session
UUID; any stable `[A-Za-z0-9_-]+` token works).
- `model` is a display string; use `?` when unknown.
- Token fields are lifetime-cumulative for the session, not per-turn deltas.
The widget displays *input* as `in + cacheCreate` (full-rate work) and shows
`cacheRead` separately. All-zero telemetry is fine — the burn line is simply
omitted (`model` of `?` or zero in+out hides it).
- No commas or whitespace inside fields.
**Minimum viable adapter:** fire bare events with just a session id —
`?,0,0,0,0,<sid>`. State tracking, urgency priority, multi-session tooltip all
work; you only lose the burn readout.
## Session semantics (what the service guarantees)
- One slot per `session` id; re-sending updates the slot in place.
- The service aggregates the **most urgent** state across all live slots (priority
table above) into `claude.pulse`; widgets render this rollup and the tooltip lists
every session, most recent first, with a Σ burn total.
- `session_end` retires the slot. Nothing else does — a real session may sit
at `idle` or `turn_end` indefinitely and stays listed.
- Because only the trailing `session` field is read for routing, a `session_end`
whose payload populates *only* that field is a well-formed retire for one
session and nothing else: `,,,,,<session>`. The `sessions` panel's Retire
control emits exactly that, which is why manual retirement needs no new verb —
anything that can send `session_end` can already clear a stuck slot.
- **Payload-less events** (no CSV at all — e.g. a manual
`noctalia msg plugin … all needs_attention` poke from a terminal) land in a
single shared `default` slot. To keep CLI pokes from leaving a phantom
session, any **resting** event (`idle`, `turn_end`, `error`) retires the
`default` slot instead of updating it. Consequence for adapters: *always
send a session id*; the default slot is a test surface, not a home.
## Adapter contract
1. **Fail-open, always.** Exit 0 no matter what — noctalia offline, binary
missing, malformed input. An adapter runs inside an agent's hook path and
must never block or error the agent. Swallow stdout/stderr, cap the
dispatch with a timeout (~3 s).
2. **Tag everything with the session id** (see above).
3. **Send cumulative telemetry or none** — don't send per-turn deltas.
4. Don't invent events; map your agent's lifecycle onto the eight above.
### Lifecycle mapping guide
The Claude Code mapping (from `hooks/settings.snippet.json`) doubles as the
template for any agent:
| Agent moment | Event |
|---|---|
| session starts / process launches | `idle` |
| prompt submitted / turn begins | `turn_start` |
| about to run a tool or shell command | `tool_start` |
| tool finished, agent resumes thinking | `turn_start` |
| response streaming to the user | `text` |
| waiting on permission / a question for the human | `needs_attention` |
| turn complete, output delivered | `turn_end` |
| unrecoverable failure | `error` |
| session exits (however it exits) | `session_end` |
If your agent only exposes a subset (say, just "done" notifications), map what
you have — a session that only ever sends `turn_end`/`session_end` still
renders correctly.
### The generic emitter
```
hooks/pulse-emit <event> [session] [model] [in] [out] [cacheCreate] [cacheRead]
```
POSIX sh, no dependencies beyond `noctalia` on PATH. Omitted fields default to
`?`/`0`; omitting `session` sends a bare (default-slot) event. Env:
`PULSE_TARGET` overrides the dispatch id, `PULSE_DRYRUN=1` prints the command
instead of running it. Examples:
```sh
pulse-emit turn_start mysess # state only
pulse-emit turn_end mysess gpt-5 12000 800 # with burn figures
pulse-emit session_end mysess # retire the slot
long_build && pulse-emit needs_attention ci # non-agent uses work too
```
## Downstream: the `claude.pulse` state mirror
The headless `pulse-svc` **service** is the **single aggregator**; subscribers (the
bar dot, the orb, or any future surface) never parse events themselves. On every
event — never from a timer — it publishes a rollup snapshot to noctalia shared state
under `claude.pulse` (top-level fields below, plus a `sessions` array of per-session
`{sid,state,model,tin,tout,cr}` for multi-session tooltips):
```lua
{ state = <most-urgent event name>, -- "idle" when no sessions
count = <live session count>,
model = <model or "?">, -- single-session only
tin = <in + cacheCreate>, -- one session's, or the Σ across all
tout = <output tokens>,
cr = <cacheRead; 0 when count > 1> }
```
Both desktop and bar widgets receive it via `noctalia.state.watch("claude.pulse",
cb)` — state.watch fires across all of a plugin's runtimes as of the Noctalia 5 beta
(the earlier "bars must poll" limitation is gone).
## Deployment (retired invariant)
The aggregator is the headless `pulse-svc` **`[[service]]`** — it starts at shell
launch and runs with no surface, so event capture never depends on any widget being
placed. (Historically the aggregator lived in the `pulse` bar widget: if that widget
wasn't on a bar, every event was silently dropped and all subscribers froze — the
**D10** fragility. Retired by the `[[service]]` entry kind added in the Noctalia 5
beta; requires `plugin_api >= 3` on a service-capable build.) The bar dot and orb are
now pure subscribers of `claude.pulse`, so placing them is purely cosmetic.
-137
View File
@@ -1,137 +0,0 @@
# Claude Companion
![Claude Companion — a Claude Code companion for Noctalia: pulse, orb, and answer panel](thumbnail.webp)
A Noctalia v5 plugin that puts [Claude Code](https://claude.com/claude-code)'s live status on your desktop — a **pulse** on the bar, a breathing **orb** on the desktop, and an **answer panel** for quick questions.
![version](https://img.shields.io/badge/version-1.3.0-blue) ![license](https://img.shields.io/badge/license-MIT-informational) ![noctalia](https://img.shields.io/badge/noctalia-5.0.0-blueviolet)
Claude Code is a brilliant agent trapped in a text box. It can't see the windows you have open, can't tap you on the shoulder when it hits a wall, and gives you nothing to glance at while it churns. So you sit there watching a terminal, or you wander off and miss the moment it needed you.
This plugin gives it a body. It wires Noctalia into Claude's lifecycle so a **pulse** on your bar tracks every session, an **orb** on your desktop breathes along with the work, and an **answer panel** catches one-shot replies before they scroll away. The terminal keeps doing the actual thinking — permissions, tools, MCP, all native. This is just the nervous system that lets the rest of your desktop feel it.
Don't run Claude Code? The signal bus is agent-agnostic — any agent, CI job, or shell script that can run a command on its own lifecycle can light up the same bar. See [Wiring up other agents](#wiring-up-other-agents).
## Plugin
| Field | Value |
| --- | --- |
| ID | `lowcache/claude-companion` |
| Entries | Service: `pulse-svc`; bar widget: `pulse`; desktop widget: `orb`; panels: `answer`, `sessions`; launcher: `claude` |
| Launcher Prefix | `/claude` |
Built and live-tested against Noctalia 5.0.0 (build `623210223c`), with an offline widget spec suite keeping the state machine honest.
## See it
![The bar pulse and desktop orb breathing through a Claude session's lifecycle](assets/pulse.gif)
One session, start to finish: the **pulse** on the bar and the **orb** on the desktop breathe through idle, thinking, a tool run, done, and needs-you.
![A quick question answered in the answer panel](assets/question.gif)
Ask something quick with `/claude ?` and the whole answer waits for you in the panel, instead of scrolling off the top of the terminal.
## How it works
**Perceive.** `shim/noctalia-mcp.py` is a stdio MCP shim that hands Claude a live read on your machine: `niri msg -j` for the windows you have open, `playerctl` for what's playing, `noctalia msg status` for the state of the shell itself. Nothing to wire up by hand. Launch through `/claude` and it attaches itself.
**Practice.** Everything on the backend funnels through `claude.luau`, the `/claude` launcher and the one door in. It normalizes the event vocabulary, throws `notify-send` toasts, and calls `noctalia msg` to move panels around. One chokepoint on purpose — so when something acts up, there's exactly one place to go look.
**Pulse.** `pulse-svc.luau` is a headless `[[service]]` that runs the show. Hook events land here over IPC at `lowcache/claude-companion:pulse-svc`, and from there it does the rest: tracks every session at once, surfaces whichever one's most urgent, and publishes a rollup to shared state under `claude.pulse` for subscribers to read.
And downstream is where the surfaces live. `pulse.luau` on the bar and `orb.luau` on the desktop are independent subscribers that only render. Both watch `claude.pulse` — `pulse.luau` breathes the accent color and shows per-session tooltips, while `orb.luau` breathes the same state frame by frame, glyph and opacity riding a sine wave, tempo picking up as things get urgent. Neither holds hooks or logic of its own. `answer.luau` is the `answer` panel that catches a `/claude ?` reply and holds the whole thing: wrapped, scrollable, all the parts a toast lops off the end.
## Requirements
- **Noctalia 5.0.0** on a supported Wayland compositor — **niri**, **Hyprland**, or **Sway**. The shim detects which one is running and speaks its IPC; the widgets themselves are compositor-agnostic. You only need the CLI for the compositor you actually run — `niri`, `hyprctl` (Hyprland), or `swaymsg` (Sway) — not all three.
- **[Claude Code](https://claude.com/claude-code)** — the `claude` agent being visualized. Optional if you're driving the widgets from another agent via [PROTOCOL.md](PROTOCOL.md).
- **`python3`** for the MCP shim (stdlib only, no pip installs)
- On the PATH as the shim's senses need them: `playerctl`, `nmcli`, `notify-send`, `ps`
- For the generic shell adapter (`hooks/pulse-emit`, only used when driving the widgets from a non-Claude agent): `tr` is required; `timeout` is optional — the adapter falls back to a direct dispatch when it's absent.
## Install
```sh
# clone and symlink into the plugins dir
ln -s "$PWD" ~/.local/share/noctalia/plugins/claude-companion
# enable the plugin
noctalia msg plugins enable lowcache/claude-companion
```
Then, in order:
1. **(Optional) Put the `pulse` widget on a bar** (Settings → Bar) for the glanceable dot — capture no longer depends on it; the headless `pulse-svc` service does the listening.
2. Add the `orb` desktop widget if you want the ambient presence.
3. Merge `hooks/settings.snippet.json` into `~/.claude/settings.json` so Claude's lifecycle hooks actually drive the pulse.
4. Point Claude at `shim/noctalia-mcp.py` with `--mcp-config` to hand it the senses and hands. (Sessions you launch through `/claude` do this for you.)
Prove it works:
```sh
noctalia msg plugin lowcache/claude-companion:pulse-svc all needs_attention # bar icon → red bell
noctalia msg plugin lowcache/claude-companion:pulse-svc all idle # back to robot
```
> [!WARNING]
> **`pulse` no longer has to stay on a bar.** The sole aggregator is now the headless `pulse-svc` service, which starts with the shell and listens whether or not any widget is placed — so pulling the `pulse` dot off a bar just hides the glanceable icon; the orb keeps updating and hooks/IPC still land. This retires the old **D10** requirement, made possible by the `[[service]]` entry kind added in the Noctalia 5 beta.
## Usage
`/claude <task>` opens a real Claude Code session in your terminal, shim already wired in. Bare `/claude` picks up where you left off (`claude --continue`). And `/claude ? <question>` is the quick one — a read-only ask that comes back as a toast and lands, in full, in the answer panel.
That panel opens however you like it: click the pulse, use the "Show last answer" row under `/claude`, or toggle it from the CLI:
```sh
noctalia msg panel-toggle lowcache/claude-companion:answer
```
Leave it open and it refreshes live while suppressing the toast, so you're never reading the same answer twice. A click outside or Esc puts it away.
Hover the bar and the tooltip tells you where each session stands and what it's burning — input, output, cache reads. Run a few at once and you get a line per session plus a Σ total, with the icon always showing whichever one needs you most.
**Right-click the pulse** for the sessions panel — the same rollup, but you can act on it. A tooltip disappears on the way to it; this doesn't. One row per live session with its state, model and burn, and a **Retire** button on each.
Retire is there for the one failure mode you'll actually hit: a session whose `SessionEnd` hook never fired — terminal killed, hook interrupted mid-distill — sits at idle forever and keeps inflating the count. Retiring it corrects the tally from the shell, without going back to a terminal. It adds no new protocol: a retire is the ordinary `session_end` event carrying that session's id, exactly what `hooks/pulse.py` sends.
```sh
noctalia msg panel-toggle lowcache/claude-companion:sessions
```
## Settings
The plugin declares three user settings, read via `noctalia.getConfig(<key>)`:
| Setting | Type | Range | Step | Default | Description |
| --- | --- | --- | --- | --- | --- |
| `breath_speed` | double | 0.25–3.0 | 0.05 | 1.0 | Phase-rate multiplier for the breathing animation on both the bar dot and the desktop orb. Higher = faster. |
| `pulse_glow_floor` | double | 0.0–0.9 | 0.05 | 0.45 | How dim the bar dot gets at the trough of its breath. 0 = dims to black, higher = stays brighter. |
| `orb_swell` | double | 0.0–3.0 | 0.05 | 1.0 | How far the desktop orb glyph magnifies as it breathes. 0 = static size, higher = a bigger swing. |
There are no color settings — both surfaces follow the active theme palette via accent role names (`secondary`, `primary`, `error`).
## Wiring up other agents
None of this is Claude-specific under the hood. The pulse speaks a plain event format and doesn't care who's talking — any agent, CI job, or shell script that can run a command on its own lifecycle can light up the same bar. [PROTOCOL.md](PROTOCOL.md) has the full eight-event vocabulary, the CSV payload, session semantics, and the adapter contract. The reference emitter, `hooks/pulse-emit`, is plain POSIX sh and needs nothing but `noctalia` on your PATH:
```sh
hooks/pulse-emit turn_start mysess
hooks/pulse-emit turn_end mysess gpt-5 12000 800
hooks/pulse-emit session_end mysess
```
## Rough edges
A few things worth knowing before they surprise you:
- Plugin panels render at `Layer::Top`, so an overlay window — a notification, a quake terminal, a polkit prompt — can sit on top of the answer panel. The answer's still there; clear the overlay and you'll see it. There's an upstream ask in for panel layer control.
- Eight-digit hex alpha is ignored by bar widgets — brightness is done by scaling RGB toward black. (Earlier builds didn't fire `state.watch` on bars, so the pulse polled; the Noctalia 5 beta fires it, so the bar dot is now event-driven like the orb.)
- Builtin and wallpaper-generated palettes have no on-disk JSON, so those fall back to fixed accent colors. Custom and community palettes are followed live, rechecked every ~8 s.
- Quick-ask rides headless `claude -p`, which doesn't refresh an expired OAuth login token — only an interactive session does ([upstream](https://github.com/anthropics/claude-code/issues/53063)). The plugin checks the token's expiry before launching and, instead of burning the request on a guaranteed 401, tells you to open a terminal Claude session first; a failure it couldn't predict gets the same message in place of the raw API error.
- The MCP shim is a Python prototype. A compiled port is the intended endgame.
- The shim's memory tool drops notes into `~/.memory/inbox` for the memd curator to pick up. No memd, no reader — the files get written and simply sit there. It follows memd's Inbox Protocol v1.0 (`INBOX-PROTOCOL.md` in the memd repo).
## License
MIT — see [LICENSE](LICENSE).
-90
View File
@@ -1,90 +0,0 @@
-- The answer panel — the full-length surface for /claude ? quick-ask answers.
-- A notification toast clips long bodies and cannot scroll, so claude.luau publishes
-- the complete answer to noctalia.state "claude.answer" and this panel renders it
-- wrapped and scrollable. Ways in: click the bar pulse, the "Show last answer"
-- row under /claude, or `noctalia msg panel-open lowcache/claude-companion:answer`.
--
-- Pure subscriber, same doctrine as the orb: claude.luau owns the state, this panel
-- only renders it. state.watch() callbacks proved unreliable for bar widgets
-- (see pulse.luau) and a panel only lives while open anyway, so this polls on
-- second ticks and re-renders only when the payload actually changed — an
-- unconditional re-render each second would reset the scroll position mid-read.
local KEY = "claude.answer"
-- User-facing display strings live in translations/<lang>.json.
local function tr(key, args) return noctalia.tr(key, args) end
-- The panel surface is 460 logical px wide (plugin.toml); wrap text inside the
-- padding with room for the scrollbar.
local WRAP = 408
local PAD = 14
-- Fallback error accent, same as the pulse dot; normal body text keeps the
-- theme's default foreground by not setting a color at all.
local ERROR_RGB = "#FF4D1F"
local last = nil -- fingerprint of the last-rendered payload (skip no-op renders)
local function fingerprint(a)
if type(a) ~= "table" then return "" end
return tostring(a.at) .. "\1" .. tostring(a.q) .. "\1" .. tostring(a.error) .. "\1" .. tostring(a.text)
end
local function render(a)
local rows = { ui.label({ text = tr("answer.title"), fontWeight = "bold" }) }
local body
if type(a) ~= "table" or type(a.text) ~= "string" or a.text == "" then
body = ui.label({
text = tr("answer.empty"),
maxWidth = WRAP,
opacity = 0.7,
})
else
if type(a.q) == "string" and a.q ~= "" then
local q = "? " .. a.q .. (type(a.at) == "string" and a.at ~= "" and (" · " .. a.at) or "")
rows[#rows + 1] = ui.label({ text = q, maxWidth = WRAP, maxLines = 3, opacity = 0.7 })
end
body = ui.label({
text = a.text,
maxWidth = WRAP,
color = a.error == true and ERROR_RGB or nil,
})
end
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.scroll({ flexGrow = 1 }, { body })
-- flexGrow on the ROOT is load-bearing: the host sizes its root flex to the
-- panel, but this column is a child of it — without grow it takes its natural
-- (full-content) height, overflows, and the panel hard-clips the answer. Grown
-- to the panel height, the scroll child above is the one that gets bounded and
-- actually scrolls.
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1 }, rows))
end
-- Visibility flag for claude.luau: while the panel is open it live-refreshes the
-- answer (update below), so a toast for the same answer would be a redundant
-- second surface — and dismissing that toast clicks outside the panel, which
-- the click-shield turns into closing the panel too. claude.luau reads this flag
-- and skips the toast when the panel is already showing.
local OPEN_KEY = "claude.answer.open"
function onOpen(_context)
panel.setWantsSecondTicks(true) -- the host stops ticks on close, re-arms on reopen
noctalia.state.set(OPEN_KEY, true)
local a = noctalia.state.get(KEY)
last = fingerprint(a)
render(a)
end
function onClose()
noctalia.state.set(OPEN_KEY, false)
end
function update()
local a = noctalia.state.get(KEY)
local fp = fingerprint(a)
if fp ~= last then
last = fp
render(a)
end
end
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

-89
View File
@@ -1,89 +0,0 @@
-- EXPERIMENTAL — prototyping always-visible bar-widget animation, since the desktop
-- orb only shows on bare desktop. The real pulse.luau bar is UNTOUCHED; this is a
-- throwaway comparison surface. The winning behaviour gets folded into pulse.luau.
--
-- Bar plugin widgets have NO per-frame tick (that's desktop-only) — they re-render on
-- a timer via noctalia.setUpdateInterval(ms) + a global update(). The only animatable
-- channel is the glyph colour. Rather than alpha (the bar didn't honour 8-digit
-- #RRGGBBAA), we breathe BRIGHTNESS: scale the accent RGB toward black (the near-black
-- surface) and emit a plain 6-digit "#RRGGBB" so the glyph glows brighter/dimmer while
-- keeping its hue. Two waveforms to compare by eye:
-- MODE "A" raised-cosine sine breath (smooth glow, like the orb)
-- MODE "B" square wave (discrete blink)
--
-- It reads state from the same claude.pulse rollup pulse.luau publishes (so it needs none
-- of the session bookkeeping). Flip MODE + reload to compare.
--
-- State RGB is hardcoded from the active dark palette (volnix:
-- mSecondary/mPrimary/mError) to validate the look fast.
local MODE = "A" -- "A" sine breath | "B" square blink
local INTERVAL_MS = 60 -- ~16 fps re-render (bars can't do 60 fps)
local BMIN, BMAX = 0.45, 1.00 -- brightness floor/ceiling the glyph breathes between
-- glyph + accent RGB (hex, no #) per state — same glyph/role map as the bar dot/orb.
local VISUAL = {
idle = { glyph = "robot", rgb = "EAFF00", period = 8.0 },
turn_start = { glyph = "brain", rgb = "B4FF00", period = 5.0 },
text = { glyph = "message-dots", rgb = "B4FF00", period = 4.5 },
tool_start = { glyph = "tool", rgb = "EAFF00", period = 5.0 },
needs_attention = { glyph = "bell-ringing", rgb = "FF4D1F", period = 3.0 },
turn_end = { glyph = "bell", rgb = "B4FF00", period = 5.5 },
error = { glyph = "alert-triangle", rgb = "FF4D1F", period = 3.5 },
}
local state = "idle"
local phase = 0.0
local function level_for(v) -- brightness scale in [BMIN, BMAX]
local t = phase % v.period
if MODE == "B" then
return (t < v.period * 0.5) and BMAX or BMIN -- bright for the first half, dim for the second
end
local s = 0.5 - 0.5 * math.cos((t / v.period) * 2 * math.pi)
return BMIN + (BMAX - BMIN) * s
end
-- Scale an "RRGGBB" accent toward black by factor b, returning "#RRGGBB".
local function dim(rgb, b)
local function ch(i)
local x = math.floor((tonumber(rgb:sub(i, i + 1), 16) or 0) * b + 0.5)
if x < 0 then x = 0 elseif x > 255 then x = 255 end
return x
end
return string.format("#%02X%02X%02X", ch(1), ch(3), ch(5))
end
local function paint()
local v = VISUAL[state] or VISUAL.idle
barWidget.setGlyph(v.glyph)
barWidget.setGlyphColor(dim(v.rgb, level_for(v)))
barWidget.setTooltip(string.format("barpulse [%s] · %s", MODE, state))
end
-- Pull the most-urgent state from the bar pulse's published rollup. Bar widgets don't
-- seem to receive noctalia.state.watch callbacks (the desktop orb does; this bar one
-- did not), so we POLL noctalia.state.get each tick instead — cheap, and the timer is
-- already running. Only adopt a recognised state so a nil/garbage read can't blank it.
local function refresh_state()
local snap = noctalia.state.get and noctalia.state.get("claude.pulse")
if type(snap) == "table" and type(snap.state) == "string" and VISUAL[snap.state] then
state = snap.state
elseif type(snap) == "string" and VISUAL[snap] then
state = snap
end
end
-- Timer loop. Re-arm the interval each tick (mirrors the scratchpad pattern), refresh
-- the state by polling, and advance the breath by the known step (no dt on bar widgets).
function update()
noctalia.setUpdateInterval(INTERVAL_MS)
refresh_state()
phase = phase + INTERVAL_MS / 1000
if phase > 1e6 then phase = 0 end
paint()
end
refresh_state()
noctalia.setUpdateInterval(INTERVAL_MS)
paint()
-332
View File
@@ -1,332 +0,0 @@
-- /claude — the hands: launch Claude Code, or a quick one-shot ask.
-- /claude <task> → real Claude Code TUI in the terminal (full fidelity)
-- /claude → resume last session (claude --continue)
-- /claude ? <q> → one-shot read-only ask, streamed; answer via notify
--
-- Holds the BACKEND CHOKEPOINT (invoke + parse). v5 plugins load each entry as a
-- single chunk (no module system), and this launcher is the only entry that talks to a model,
-- so the seam lives here, inlined.
-- noctalia.runInTerminal / runStream both exec via `/bin/sh -c <string>`, so every
-- interpolated value must be shell-quoted. Single-quote wrap + escape embedded
-- single quotes ('\'') is sh-safe for arbitrary text.
local function shq(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
-- User-facing display strings live in translations/<lang>.json; resolve them at
-- call time (the noctalia global is ready by then). Model-facing prompts
-- (ASK_NOTE, SYSTEM_NOTE below) are deliberately NOT translated — they are
-- instructions to the model, not UI, and rewording them can shift its behavior.
local function tr(key, args) return noctalia.tr(key, args) end
-- A double-quoted sh argument: unlike shq it lets the shell expand $VAR (we need
-- $HOME in the shim path so nothing user-specific is hardcoded). ONLY use this on
-- fixed, trusted strings — never on user input, which must go through shq().
local function dq(s) return '"' .. tostring(s):gsub('"', '\\"') .. '"' end
-- ── backend seam: normalize at the boundary ──────────────────────────────────
-- Everything downstream consumes this vocabulary, never raw backend output.
local EVENT = {
turn_start = "turn_start", text = "text", tool_start = "tool_start",
tool_end = "tool_end", needs_attention = "needs_attention",
turn_end = "turn_end", error = "error",
}
-- noctalia launches us with the GUI-session PATH, which lacks ~/.local/bin (it's
-- added by the user's shell profile, not the graphical session). Claude Code's
-- own session hooks live there (memd, agent-scaffold, …), so a /claude session would
-- hit "command not found" that a terminal-started one never does. Prepend it to
-- every launch so a /claude session inherits the same PATH as a terminal. Fixed,
-- trusted literal — $HOME/$PATH are meant to expand in the `/bin/sh -c` context.
local PATH_PREFIX = 'PATH="$HOME/.local/bin:$PATH" '
-- A quick-ask answer is delivered as a desktop toast first, and toasts clip
-- after a couple of lines with no scroll — so steer the model toward toast-sized
-- answers at the source. Long answers still arrive intact: the full text goes to
-- the answer panel (see publish_answer below), the toast just leads with less.
local ASK_NOTE = table.concat({
"Your answer is delivered as a desktop notification. Lead with the direct answer",
"in one or two short sentences; add detail only if the question genuinely needs it.",
"Plain text only — no markdown formatting.",
}, " ")
-- claude only; this is the single backend seam.
-- A quick-ask is promised as read-only (README "Usage"), so it must NOT inherit
-- the user's Claude config, where pre-authorized permissions would let a plain
-- question run commands or edit files: --tools "" strips every built-in tool,
-- --strict-mcp-config (with no --mcp-config) strips inherited MCP servers, and
-- --setting-sources "" skips user/project settings and with them hooks, plugins,
-- and permission grants. NOT --bare: it never reads OAuth logins, which are the
-- auth path quick-ask depends on (see the auth guard below).
local function backend_command(prompt)
-- Claude Code: -p (print/non-interactive) requires --verbose for stream-json.
-- `-p --` before the prompt is load-bearing: without the `--` end-of-options
-- marker, a question whose first token starts with `-` (e.g. pasted text
-- leading with `--dangerously-skip-permissions`) is parsed as CLI flags and
-- can undo the read-only sandbox above. `--` forces the prompt to be a
-- positional (verified: a prompt of "--version" is answered, not executed).
return PATH_PREFIX .. "claude --tools '' --strict-mcp-config --setting-sources ''"
.. " --output-format stream-json --verbose"
.. " --append-system-prompt " .. shq(ASK_NOTE) .. " -p -- " .. shq(prompt)
end
-- one stream-json line → an EVENT (or nil). Claude Code emits one JSON object per
-- line: type "system" (init), "assistant" (a message; content is an array of
-- text / tool_use blocks), "user" (tool_result), "result" (final). We map the
-- subset we care about and ignore the rest (version-defensive).
-- Note: the content-block shape can shift across claude versions — if events
-- stop firing, re-dump a live stream and re-check the field names here.
local function parse(line)
local ok, msg = pcall(noctalia.json.decode, line)
if not ok or type(msg) ~= "table" then return nil end
local t = msg.type
if t == "system" then
return { kind = EVENT.turn_start }
elseif t == "assistant" then
local content = type(msg.message) == "table" and msg.message.content or msg.content
local text = ""
if type(content) == "table" then
for _, block in ipairs(content) do
if type(block) == "table" then
if block.type == "tool_use" then
return { kind = EVENT.tool_start }
elseif block.type == "text" and type(block.text) == "string" then
text = text .. block.text
end
end
end
end
return { kind = EVENT.text, text = text }
elseif t == "user" then
-- a tool result came back; the model is thinking again (clears tool_start)
return { kind = EVENT.turn_start }
elseif t == "result" then
local err = msg.is_error == true or msg.subtype == "error_during_execution"
return { kind = err and EVENT.error or EVENT.turn_end, text = msg.result }
end
return nil
end
-- publish quick-ask state for the pulse widget (cross-VM channel via shared
-- state). Only the hookless `/claude ? <q>` stream uses this; the widget tracks it as
-- one ephemeral "ask" session and drops it when the ask ends.
local function set_state(s) noctalia.state.set("claude.state", s) end
-- ── answer delivery ──────────────────────────────────────────────────────────
-- A toast clips long bodies with no scroll, so it is the preview surface only:
-- the complete answer is published to "claude.answer" and rendered wrapped +
-- scrollable by the answer panel (answer.luau). The panel opens from a click on
-- the bar pulse, the "Show last answer" launcher row, or the CLI.
local ANSWER_PANEL = "lowcache/claude-companion:answer"
local function publish_answer(q, text, is_err)
noctalia.state.set("claude.answer", {
q = q,
text = text,
error = is_err == true,
at = noctalia.formatTime("%H:%M"),
})
end
-- Fit the toast: collapse whitespace and cut at a word boundary. Returns the
-- preview and whether anything was dropped (→ the toast gains a pointer to the
-- full answer).
local PREVIEW_MAX = 200
local function preview(text)
local flat = text:gsub("%s+", " ")
if #flat <= PREVIEW_MAX then return flat, false end
local cut = flat:sub(1, PREVIEW_MAX):match("^(.*)%s%S*$") or flat:sub(1, PREVIEW_MAX)
return cut .. " …", true
end
-- ONE surface per answer: while the answer panel is open (it sets this flag) it
-- live-refreshes from claude.answer, so it IS the delivery — a toast on top would
-- duplicate it, and dismissing that toast clicks outside the panel, which the
-- click-shield turns into closing the panel as well (verified live: toast +
-- panel died to one click). Panel open → publish only; panel closed → toast.
local function panel_showing()
return noctalia.state.get("claude.answer.open") == true
end
-- ── auth guard ───────────────────────────────────────────────────────────────
-- Headless `claude -p` does NOT refresh an expired OAuth access token (8 h
-- lifetime) even when the refresh token is valid — only an interactive session
-- does (upstream: anthropics/claude-code#53063 and friends). So a quick-ask can
-- 401 whenever no terminal session has run recently. Two layers, both fail-open:
-- a pre-flight that skips the launch when the token is positively expired, and
-- an error-text match that swaps the raw API error for the remedy.
-- Resolved per call, not once at load: a language change should be reflected in the
-- remedy text without a plugin reload.
local function auth_remedy() return tr("notify.auth_remedy") end
local CREDS_PATH = "~/.claude/.credentials.json"
-- true ONLY when the credentials file positively says the token is expired.
-- Missing file, undecodable JSON, absent expiresAt, or no epoch clock all mean
-- "unknown" → proceed and let the error match below catch a real failure. The
-- token itself is never read, only the expiry timestamp.
-- Note: %s is a glibc strftime extension; if noctalia's formatTime doesn't
-- pass it through, tonumber yields nil and the pre-flight silently disables —
-- the detect layer still covers.
local function token_expired()
local now = tonumber(noctalia.formatTime("%s"))
if not now then return false end
local raw = noctalia.readFile(noctalia.expandPath(CREDS_PATH))
if type(raw) ~= "string" then return false end
local ok, creds = pcall(noctalia.json.decode, raw)
if not ok or type(creds) ~= "table" then return false end
local oauth = creds.claudeAiOauth
local exp = type(oauth) == "table" and tonumber(oauth.expiresAt) or nil
if not exp then return false end
if exp > 1e12 then exp = exp / 1000 end -- stored in ms; tolerate seconds too
return exp <= now + 30 -- 30 s margin: don't start an ask about to 401 mid-flight
end
-- Recognize the auth-failure shapes claude -p emits in its result line:
-- "Not logged in · Please run /login" (no credentials) and "Failed to
-- authenticate. API Error: 401 {...authentication_error...}" (expired token).
local function is_auth_error(text)
return text:find("Please run /login", 1, true) ~= nil
or text:find("API Error: 401", 1, true) ~= nil
or text:find("authentication_error", 1, true) ~= nil
end
-- ── context injection ────────────────────────────────────────────────────────
-- Make /claude-launched sessions desktop-AWARE (senses) and desktop-CAPABLE (hands)
-- by wiring the noctalia MCP shim and a role note into the launch.
--
-- We pass the shim via inline --mcp-config (not a file) so the terminal's cwd is
-- irrelevant: /claude opens in the user's project dir, not the plugin dir. We push a
-- role note, NOT a senses snapshot — a snapshot taken at launch is stale before
-- the first turn; instead Claude pulls fresh senses on demand via the tools.
-- The shim is assumed at the canonical install path
-- ($HOME/.local/share/noctalia/plugins/<id>). $HOME stays bare for the shell to
-- expand (portable; nothing user-specific is committed). The JSON is fixed and
-- trusted (no user input), so dq() is injection-safe here.
local SHIM = "$HOME/.local/share/noctalia/plugins/claude-companion/shim/noctalia-mcp.py"
local MCP_JSON = '{"mcpServers":{"noctalia":{"command":"python3","args":["' .. SHIM .. '"]}}}'
local SYSTEM_NOTE = table.concat({
"You are running inside the Noctalia desktop shell (Wayland — niri, Hyprland, or Sway), launched from its Claude Code companion plugin.",
"An MCP server named 'noctalia' gives you live desktop senses and hands:",
"PERCEIVE — get_window (focused app/title), get_workspace (focused output + workspace), get_media (now playing), get_shell_state (shell status), get_power (battery/AC), get_network (connectivity/Wi-Fi), get_processes (top by CPU).",
"ACT — notify (desktop toast), set_theme_mode (dark/light/auto), set_color_scheme, focus_window (by the id from get_window), switch_workspace (by index/name), move_to_workspace (move focused window), set_wallpaper (path or random).",
"MEMORY — remember (persist a durable fact for future sessions).",
"Call the perceive tools when current desktop context matters instead of assuming it, and use notify for ambient status updates.",
}, " ")
-- Flags shared by every interactive launch (task + continue). Built once.
local CONTEXT_FLAGS =
"--mcp-config " .. dq(MCP_JSON) .. " --append-system-prompt " .. shq(SYSTEM_NOTE)
-- ── /claude routing ──────────────────────────────────────────────────────────────
local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
-- The last quick-ask answer, if any, earns a launcher row that reopens the
-- answer panel — the recovery path when the toast has already expired.
local function answer_row()
local a = noctalia.state.get("claude.answer")
if type(a) ~= "table" or type(a.text) ~= "string" or a.text == "" then return nil end
return { id = "answer", title = tr("launcher.show_answer"), subtitle = a.q, glyph = "message-2" }
end
function onQuery(query)
local text = trim(query)
if text == "" then
local results = {
{ id = "continue", title = tr("launcher.continue"), glyph = "robot" },
}
results[#results + 1] = answer_row()
launcher.setResults(query, results)
return
end
local ask = text:match("^%?%s*(.*)$")
if ask ~= nil then
if ask == "" then
local results = {
{ id = "_askhint", title = tr("launcher.ask"), subtitle = tr("launcher.ask_hint"), glyph = "message-dots" },
}
results[#results + 1] = answer_row()
launcher.setResults(query, results)
else
launcher.setResults(query, {
{ id = "ask:" .. ask, title = tr("launcher.ask"), subtitle = ask, glyph = "message-dots" },
})
end
return
end
launcher.setResults(query, {
{ id = "task:" .. text, title = tr("launcher.launch"), subtitle = text, glyph = "robot" },
})
end
function onActivate(id)
-- A launched TUI session drives the pulse itself via the global Claude Code
-- hooks (with its own session id + token telemetry), so we DON'T set claude.state
-- here — doing so would register a phantom session the hooks never update or
-- retire. claude.state is only for the hookless quick-ask stream below.
if id == "continue" then
noctalia.runInTerminal(PATH_PREFIX .. "claude " .. CONTEXT_FLAGS .. " --continue")
return
end
if id == "answer" then
-- reopen the answer panel with the last quick-ask answer (fixed id, no user input)
noctalia.runAsync("noctalia msg panel-open " .. shq(ANSWER_PANEL))
return
end
local task = id:match("^task:(.*)$")
if task then
noctalia.runInTerminal(PATH_PREFIX .. "claude " .. CONTEXT_FLAGS .. " " .. shq(task))
return
end
local ask = id:match("^ask:(.*)$")
if ask then
-- pre-flight: an expired token would 401 only after burning the request,
-- and the remedy needs a terminal anyway — so say so now and skip the
-- launch. No claude.state touch: no ask session ever starts.
if token_expired() then
publish_answer(ask, auth_remedy(), true)
if not panel_showing() then noctalia.notifyError(tr("notify.title"), auth_remedy()) end
return
end
set_state(EVENT.turn_start)
-- Accumulate streamed assistant text; the final `result` event also carries
-- the full text, so prefer it at turn_end and fall back to the accumulation.
local acc = ""
noctalia.runStream(backend_command(ask), function(line)
local ev = parse(line)
if not ev then return end
set_state(ev.kind)
if ev.kind == EVENT.text and ev.text and ev.text ~= "" then
acc = acc .. ev.text
elseif ev.kind == EVENT.turn_end then
local final = (ev.text and ev.text ~= "") and ev.text or acc
if final == "" then
noctalia.notify(tr("notify.title"), tr("notify.no_output"))
else
publish_answer(ask, final, false)
if not panel_showing() then
local body, clipped = preview(final)
if clipped then body = body .. "\n" .. tr("notify.full_answer_hint") end
noctalia.notify(tr("notify.title"), body)
end
end
elseif ev.kind == EVENT.error then
local final = (ev.text and ev.text ~= "") and ev.text or acc
if final == "" then final = tr("notify.ask_failed") end
-- a 401 that slipped past the pre-flight: deliver the remedy, not the
-- raw API error blob
if is_auth_error(final) then final = auth_remedy() end
publish_answer(ask, final, true)
if not panel_showing() then
local body, clipped = preview(final)
if clipped then body = body .. "\n" .. tr("notify.full_answer_hint") end
noctalia.notifyError(tr("notify.title"), body)
end
end
end)
return
end
-- "_askhint" and any other ids: no-op.
end

Some files were not shown because too many files have changed in this diff Show More