Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3118d7f8b5 | ||
|
|
86ff7b83c2 | ||
|
|
114129f72e | ||
|
|
a1c7540880 | ||
|
|
0d106e8f1e | ||
|
|
b9c7680124 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
Before Width: | Height: | Size: 85 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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**.
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
|
Before Width: | Height: | Size: 47 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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ç"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
["<"] = "<",
|
||||
[">"] = ">",
|
||||
["""] = '"',
|
||||
["'"] = "'",
|
||||
["'"] = "'",
|
||||
["&"] = "&",
|
||||
}
|
||||
|
||||
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()
|
||||
|
Before Width: | Height: | Size: 50 KiB |
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
Before Width: | Height: | Size: 20 KiB |
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
Before Width: | Height: | Size: 40 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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. |
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
function update()
|
||||
noctalia.setUpdateInterval(1000)
|
||||
barWidget.setGlyph("battery")
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel("frai3mega/battery-graph:battery-panel")
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
Before Width: | Height: | Size: 36 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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..."
|
||||
|
Before Width: | Height: | Size: 53 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -1,112 +0,0 @@
|
||||
id = "yocraft/battery-widget"
|
||||
name = "Battery Widget"
|
||||
version = "2.0.1"
|
||||
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"] }
|
||||
|
Before Width: | Height: | Size: 45 KiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
local glyph
|
||||
local color
|
||||
local data = { percent = 0, status = "unknown" }
|
||||
|
||||
local label_content = noctalia.getConfig("label_content")
|
||||
local warning_threshold = 10
|
||||
|
||||
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)
|
||||
return
|
||||
end
|
||||
|
||||
local res = result.stdout
|
||||
|
||||
if not res then
|
||||
noctalia.log("battery-widget: invalid warning_threshold, keeping default")
|
||||
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")
|
||||
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)
|
||||
notifyError()
|
||||
return
|
||||
end
|
||||
|
||||
if not result.stdout then
|
||||
noctalia.log("battery-widget: invalid battery output")
|
||||
notifyError()
|
||||
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()
|
||||
@@ -1,206 +0,0 @@
|
||||
# Bookmarks
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
- You can create or edit bookmarks by assigning them a glyph, a label, a command, and an
|
||||
optional description.
|
||||
|
||||

|
||||
|
||||
- "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.
|
||||
|
||||

|
||||
|
||||
- You can also use your launcher to query your bookmarks with the `/bk` prefix.
|
||||
|
||||

|
||||
- 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
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
Before Width: | Height: | Size: 51 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 75 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] }
|
||||
|
Before Width: | Height: | Size: 23 KiB |
@@ -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"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Python bytecode (the MCP shim and hooks are interpreted; caches are build artifacts)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,137 +0,0 @@
|
||||
# Claude Companion
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
  
|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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).
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -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()
|
||||
@@ -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
|
||||