diff --git a/cmd-runner/README.md b/cmd-runner/README.md new file mode 100644 index 00000000..71715f8f --- /dev/null +++ b/cmd-runner/README.md @@ -0,0 +1,48 @@ +# Command Runner + +Save and execute CLI commands silently in the background with a single click and a one-time sudo password. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `nocode-96/cmd-runner` | +| Entries | Bar widget: `cmd-runner`; panel: `panel`; service: `cmd-service` | + +## Requirements + +- **`sudo`** – used to run commands with elevated privileges when the user enables the sudo option for a command. +- **`secret-tool`** (part of `libsecret`) – used to store and retrieve sudo passwords in the system keyring (GNOME Keyring, KWallet, or any libsecret-compatible backend). Must be unlocked at session start. + +Install on Arch Linux: `sudo pacman -S libsecret` +Install on Debian/Ubuntu: `sudo apt install libsecret-tools` + +## Usage + +Add the `cmd-runner` widget to a bar. Left-click it to open the command panel. + +From the panel you can: +- **Add** a new command with a custom name, CLI command line, and optional sudo password. +- **Run** a saved command silently in the background (no terminal opens). +- **Edit** an existing command. The sudo password field is always empty; enter a new password only when changing it. +- **Delete** a command (also removes its keyring entry). +- **Log** – view the stdout/stderr output and exit code of the last run. + +Open the panel directly with: + +```sh +noctalia msg panel-toggle nocode-96/cmd-runner:panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `show_toast` | `bool` | `true` | Shows a desktop notification when a command completes or fails. | +| `show_label` | `bool` | `true` | Displays 'Commands' next to the icon in the top bar. | + +## Notes + +**Sudo password storage:** When a sudo password is provided, it is stored in the system keyring via `secret-tool store`. It is never written to disk in the plugin's data files. At runtime the password is retrieved from the keyring via `secret-tool lookup` and piped directly to `sudo -S`; it does not appear in any process's command-line arguments. + +Command data (names, CLI strings, icons) is persisted to `commands.json` in the plugin's data directory (`pluginDataDir()`). No credentials are stored in that file. diff --git a/cmd-runner/command-runner-thumbnail.webp b/cmd-runner/command-runner-thumbnail.webp new file mode 100644 index 00000000..175fba42 Binary files /dev/null and b/cmd-runner/command-runner-thumbnail.webp differ diff --git a/cmd-runner/commands.json b/cmd-runner/commands.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/cmd-runner/commands.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/cmd-runner/panel.luau b/cmd-runner/panel.luau new file mode 100644 index 00000000..22c831d4 --- /dev/null +++ b/cmd-runner/panel.luau @@ -0,0 +1,296 @@ +--!nonstrict +-- Panel entry for Command Runner plugin + +local state = noctalia.state.get("cmd_runner_state") or { + commands = {}, + runningMap = {}, + outputMap = {}, + exitCodeMap = {} +} + +-- UI state +local isFormOpen = false +local formGen = 1 +local editId = "" +local editName = "" +local editCommand = "" +local editIcon = "terminal" +local editIsSudo = false +local editSudoPassword = "" +local hasPassword = false +local activeLogId = "" + +local render + +local function tr(key, values) + return noctalia.tr(key, values) +end + +local function sendAction(actData) + noctalia.state.set("cmd_runner_action", actData) +end + +local function openAddForm() + editId = "" + editName = "" + editCommand = "" + editIcon = "terminal" + editIsSudo = false + editSudoPassword = "" + hasPassword = false + formGen = formGen + 1 + isFormOpen = true + render() +end + +local function openEditForm(cmd) + if not cmd then return end + editId = cmd.id or "" + editName = cmd.name or "" + editCommand = cmd.command or "" + editIcon = cmd.icon or "terminal" + editIsSudo = cmd.isSudo == true + editSudoPassword = "" -- never pre-fill; password lives only in the keyring + hasPassword = cmd.hasPassword == true + formGen = formGen + 1 + isFormOpen = true + render() +end + +local function closeForm() + isFormOpen = false + render() +end + +local function saveForm() + local trimmedName = noctalia.string.trim(editName) + local trimmedCmd = noctalia.string.trim(editCommand) + if trimmedName == "" or trimmedCmd == "" then return end + + local item = { + id = editId ~= "" and editId or ("cmd_" .. tostring(math.random(100000, 999999))), + name = trimmedName, + command = trimmedCmd, + icon = editIcon ~= "" and editIcon or "terminal", + isSudo = editIsSudo, + sudoPassword = editSudoPassword -- sent to service; service stores in keyring, never on disk + } + + sendAction({ action = editId ~= "" and "update" or "add", item = item }) + closeForm() +end + +-- ── Form UI ────────────────────────────────────────────────────────────────── + +local function renderForm() + local isEditing = editId ~= "" + + local formItems = { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ + text = isEditing and tr("panel.edit") or tr("panel.add_button"), + fontWeight = "bold", + fontSize = 14, + color = "primary" + }), + ui.button({ glyph = "close", variant = "ghost", onClick = function() closeForm() end }) + }), + + ui.column({ gap = 4 }, { + ui.label({ text = tr("panel.name_label"), fontSize = 12, color = "on_surface_variant" }), + ui.input({ + key = "edit_name_" .. tostring(formGen), + value = editName, + placeholder = tr("panel.name_placeholder"), + onChange = function(val) editName = val or "" end + }) + }), + + ui.column({ gap = 4 }, { + ui.label({ text = tr("panel.cmd_label"), fontSize = 12, color = "on_surface_variant" }), + ui.input({ + key = "edit_cmd_" .. tostring(formGen), + value = editCommand, + placeholder = tr("panel.cmd_placeholder"), + onChange = function(val) editCommand = val or "" end + }) + }), + + ui.button({ + text = editIsSudo and tr("panel.sudo_enabled") or tr("panel.sudo_disabled"), + variant = editIsSudo and "primary" or "secondary", + onClick = function() + editIsSudo = not editIsSudo + render() + end + }) + } + + if editIsSudo then + local placeholder = isEditing and hasPassword + and tr("panel.sudo_pass_placeholder_saved") + or tr("panel.sudo_pass_placeholder") + + table.insert(formItems, ui.column({ gap = 4 }, { + ui.label({ text = tr("panel.sudo_pass_label"), fontSize = 12, color = "primary", fontWeight = "bold" }), + ui.input({ + key = "edit_pass_" .. tostring(formGen), + value = editSudoPassword, + placeholder = placeholder, + secret = true, + onChange = function(val) editSudoPassword = val or "" end + }) + })) + end + + table.insert(formItems, ui.row({ justify = "end", gap = 8 }, { + ui.button({ text = tr("panel.cancel_button"), variant = "ghost", onClick = function() closeForm() end }), + ui.button({ text = tr("panel.save_button"), variant = "primary", onClick = function() saveForm() end }) + })) + + return ui.column({ fill = "surface_variant/0.3", radius = 10, padding = 12, gap = 12 }, formItems) +end + +-- ── Command card ────────────────────────────────────────────────────────────── + +local function renderCommandCard(cmd) + local runningMap = (type(state) == "table" and type(state.runningMap) == "table") and state.runningMap or {} + local outputMap = (type(state) == "table" and type(state.outputMap) == "table") and state.outputMap or {} + local exitCodeMap = (type(state) == "table" and type(state.exitCodeMap) == "table") and state.exitCodeMap or {} + + local isRunning = runningMap[cmd.id] == true + local hasOutput = outputMap[cmd.id] ~= nil + local exitCode = exitCodeMap[cmd.id] + local isLogOpen = activeLogId == cmd.id + + -- Status glyph + local statusGlyph = nil + if isRunning then + statusGlyph = ui.glyph({ name = "loader-2", size = 14, color = "primary" }) + elseif exitCode == 0 then + statusGlyph = ui.glyph({ name = "check", size = 14, color = "success" }) + elseif exitCode ~= nil then + statusGlyph = ui.glyph({ name = "x", size = 14, color = "error" }) + end + + local cardHeader = { + ui.glyph({ name = cmd.icon or "terminal", size = 16, color = "primary" }), + ui.label({ text = cmd.name or "", fontWeight = "bold", fontSize = 13, color = "on_surface", flexGrow = 1, maxLines = 1 }) + } + if cmd.isSudo then + table.insert(cardHeader, ui.label({ text = "[sudo]", fontSize = 10, color = "primary", fontWeight = "bold" })) + end + if statusGlyph then table.insert(cardHeader, statusGlyph) end + + local actionButtons = { + ui.button({ + text = isRunning and tr("panel.running") or tr("panel.run_button"), + glyph = isRunning and "loader-2" or "player-play", + variant = "primary", + enabled = not isRunning, + onClick = function() sendAction({ action = "run", id = cmd.id }) end + }), + ui.button({ + glyph = "edit", + text = tr("panel.edit"), + variant = "secondary", + onClick = function() openEditForm(cmd) end + }), + ui.button({ + glyph = "trash", + variant = "destructive", + onClick = function() + sendAction({ action = "delete", id = cmd.id }) + if activeLogId == cmd.id then activeLogId = "" end + render() + end + }) + } + + if hasOutput then + table.insert(actionButtons, ui.button({ + glyph = isLogOpen and "chevron-up" or "code", + text = tr("panel.logs"), + variant = "ghost", + onClick = function() + activeLogId = isLogOpen and "" or cmd.id + render() + end + })) + end + + local cardItems = { + ui.row({ align = "center", gap = 8 }, cardHeader), + ui.label({ text = cmd.command or "", fontSize = 11, color = "on_surface_variant", maxLines = 2 }), + ui.row({ align = "center", gap = 6, justify = "end" }, actionButtons) + } + + if isLogOpen and hasOutput then + local outText = outputMap[cmd.id] or "" + local logLines = {} + if exitCode == 0 then + table.insert(logLines, ui.label({ text = tr("panel.status_success"), fontSize = 10, color = "success", fontWeight = "bold" })) + elseif exitCode ~= nil then + table.insert(logLines, ui.label({ text = tr("panel.status_failed", { code = tostring(exitCode) }), fontSize = 10, color = "error", fontWeight = "bold" })) + end + table.insert(logLines, ui.label({ text = outText, fontSize = 10, color = "on_surface_variant", maxLines = 12 })) + table.insert(cardItems, ui.column({ fill = "surface_variant/0.5", radius = 6, padding = 8, gap = 4 }, logLines)) + end + + return ui.column({ fill = "surface_variant/0.25", radius = 8, padding = 10, gap = 8 }, cardItems) +end + +-- ── Root render ─────────────────────────────────────────────────────────────── + +render = function() + local commands = (type(state) == "table" and type(state.commands) == "table") and state.commands or {} + local children = {} + + -- Row 1: icon · title · close + table.insert(children, ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "terminal", size = 18, color = "primary" }), + ui.label({ text = tr("panel.title"), fontSize = 15, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.button({ glyph = "close", variant = "ghost", onClick = function() panel.close() end }) + })) + + -- Row 2: subtitle · add button (hidden when form is open) + if not isFormOpen then + table.insert(children, ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.column({ flexGrow = 1, overflow = "hidden" }, { + ui.label({ text = tr("panel.subtitle"), fontSize = 10, color = "on_surface_variant", maxLines = 1 }) + }), + ui.button({ glyph = "plus", text = tr("panel.add_button"), variant = "primary", onClick = function() openAddForm() end }) + })) + end + + if isFormOpen then + table.insert(children, renderForm()) + elseif #commands == 0 then + table.insert(children, ui.column({ align = "center", gap = 12, padding = 32 }, { + ui.glyph({ name = "terminal", size = 42, color = "on_surface_variant" }), + ui.label({ text = tr("panel.no_commands"), color = "on_surface_variant", fontSize = 13 }), + ui.button({ glyph = "plus", text = tr("panel.add_button"), variant = "primary", onClick = function() openAddForm() end }) + })) + else + local cardList = {} + for _, cmd in ipairs(commands) do + table.insert(cardList, renderCommandCard(cmd)) + end + table.insert(children, ui.scroll({ flexGrow = 1, gap = 8 }, cardList)) + end + + panel.render(ui.column({ flexGrow = 1, gap = 8, padding = 4 }, children)) +end + +-- ── State & lifecycle ───────────────────────────────────────────────────────── + +noctalia.state.watch("cmd_runner_state", function(val) + if type(val) == "table" then + state = val + render() + end +end) + +function onOpen() + render() +end diff --git a/cmd-runner/plugin.toml b/cmd-runner/plugin.toml new file mode 100644 index 00000000..4f73f108 --- /dev/null +++ b/cmd-runner/plugin.toml @@ -0,0 +1,41 @@ +id = "nocode-96/cmd-runner" +name = "Command Runner" +version = "1.0.0" +plugin_api = 9 +author = "Noah B." +license = "MIT" +icon = "terminal" +description = "Save and execute CLI commands silently in the background with a one-time sudo password cache." +dependencies = ["sudo", "secret-tool"] +tags = ["bar", "panel", "service", "utility", "system"] + +[[setting]] +key = "show_toast" +type = "bool" +label_key = "settings.show_toast.label" +description_key = "settings.show_toast.description" +default = true + +[[widget]] +id = "cmd-runner" +entry = "widget.luau" + + [[widget.setting]] + key = "show_label" + type = "bool" + label_key = "settings.show_label.label" + description_key = "settings.show_label.description" + default = true + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 460 +height = 540 +placement = "attached" +position = "auto" +open_near_click = true + +[[service]] +id = "cmd-service" +entry = "service.luau" diff --git a/cmd-runner/service.luau b/cmd-runner/service.luau new file mode 100644 index 00000000..fe95f702 --- /dev/null +++ b/cmd-runner/service.luau @@ -0,0 +1,287 @@ +--!nonstrict +-- Service entry for Command Runner plugin + +local function trim(s) + return noctalia.string.trim(tostring(s or "")) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +-- System keyring attributes used to scope all secrets to this plugin +local KR_PLUGIN = "noctalia-plugin" +local KR_PLUGIN_VAL = "nocode-96/cmd-runner" +local KR_ID = "cmd-id" + +local function keyringLabel(cmdName) + return "Noctalia Command Runner: " .. tostring(cmdName or "") +end + +-- Store a password in the system keyring (secret-tool / libsecret) +-- The password is piped via stdin; it never appears in /proc/cmdline of secret-tool. +-- Note: printf's argv briefly contains the password – acknowledged as transient. +local function storePassword(cmdId, cmdName, pass, callback) + if not pass or pass == "" then + if callback then callback(false) end + return + end + local label = keyringLabel(cmdName) + local cmd = "printf '%s' " .. shellQuote(pass) .. + " | secret-tool store --label " .. shellQuote(label) .. + " " .. KR_PLUGIN .. " " .. shellQuote(KR_PLUGIN_VAL) .. + " " .. KR_ID .. " " .. shellQuote(cmdId) + noctalia.runAsync(cmd, function(result) + if callback then callback(result and result.exitCode == 0) end + end, 5000) +end + +-- Remove a stored password from the system keyring +local function clearPassword(cmdId, callback) + local cmd = "secret-tool clear " .. + KR_PLUGIN .. " " .. shellQuote(KR_PLUGIN_VAL) .. + " " .. KR_ID .. " " .. shellQuote(cmdId) + noctalia.runAsync(cmd, function(_) + if callback then callback() end + end, 5000) +end + +-- Check whether the keyring holds a secret for a given command +local function hasPasswordInKeyring(cmdId, callback) + local cmd = "secret-tool lookup " .. + KR_PLUGIN .. " " .. shellQuote(KR_PLUGIN_VAL) .. + " " .. KR_ID .. " " .. shellQuote(cmdId) + noctalia.runAsync(cmd, function(result) + local has = result and result.exitCode == 0 and + type(result.stdout) == "string" and result.stdout ~= "" + callback(has == true) + end, 5000) +end + +-- Default demo command (no sudo, no stored password) +local defaultCommands = { + { + id = "cmd_demo_1", + name = "System Update Check", + command = "echo 'Checking system updates...' && sleep 1 && echo 'System is up to date'", + icon = "refresh", + isSudo = false + } +} + +-- User data lives in pluginDataDir, not pluginDir. +-- pluginDir is a managed checkout that may be re-materialised on update. +local function commandsFilePath() + local dir = noctalia.pluginDataDir() + if not dir or dir == "" then return nil end + return dir .. "/commands.json" +end + +local function loadCommands() + local path = commandsFilePath() + if not path then return defaultCommands end + + local content = noctalia.readFile(path) + if content and trim(content) ~= "" then + local decoded = noctalia.json.decode(content) + if type(decoded) == "table" then + -- Strip any legacy sudoPassword fields that may have been written by older versions + for _, c in ipairs(decoded) do + c.sudoPassword = nil + end + return decoded + end + end + return defaultCommands +end + +local function saveCommands(list) + local path = commandsFilePath() + if not path then return end + + local saved = {} + for _, c in ipairs(list) do + local copy = {} + for k, v in pairs(c) do + -- sudoPassword must never be persisted to disk; passwords live in the keyring only + if k ~= "sudoPassword" then copy[k] = v end + end + table.insert(saved, copy) + end + + local encoded = noctalia.json.encode(saved) + if encoded then noctalia.writeFile(path, encoded) end +end + +local currentCommands = loadCommands() +local runningMap = {} +local outputMap = {} +local exitCodeMap = {} +local hasPasswordMap = {} + +-- Refresh which commands have a password stored in the keyring, then publish state +local function refreshPasswordFlags(callback) + local pending = 0 + for _, cmd in ipairs(currentCommands) do + if cmd.isSudo then + pending = pending + 1 + end + end + if pending == 0 then + if callback then callback() end + return + end + local done = 0 + for _, cmd in ipairs(currentCommands) do + if cmd.isSudo then + local id = cmd.id + hasPasswordInKeyring(id, function(has) + hasPasswordMap[id] = has + done = done + 1 + if done >= pending and callback then callback() end + end) + end + end +end + +local function publishState() + local publicCommands = {} + for _, c in ipairs(currentCommands) do + local copy = {} + for k, v in pairs(c) do + if k ~= "sudoPassword" then copy[k] = v end + end + copy.hasPassword = hasPasswordMap[c.id] == true + table.insert(publicCommands, copy) + end + + noctalia.state.set("cmd_runner_state", { + commands = publicCommands, + runningMap = runningMap, + outputMap = outputMap, + exitCodeMap = exitCodeMap, + updatedAt = os.time() + }) +end + +-- Initial state publish after async keyring checks +refreshPasswordFlags(function() publishState() end) + +local function executeCommand(cmdId) + local targetCmd = nil + for _, c in ipairs(currentCommands) do + if c.id == cmdId then targetCmd = c; break end + end + if not targetCmd or trim(targetCmd.command) == "" then return end + + runningMap[cmdId] = true + publishState() + + local fullCmd + if targetCmd.isSudo then + -- Retrieve the password from the system keyring via stdout and pipe directly to + -- sudo -S. The password never appears in any process's argv, so it is not + -- visible in /proc//cmdline. + fullCmd = "secret-tool lookup " .. + KR_PLUGIN .. " " .. shellQuote(KR_PLUGIN_VAL) .. + " " .. KR_ID .. " " .. shellQuote(cmdId) .. + " | sudo -S -p '' -- " .. targetCmd.command + else + fullCmd = targetCmd.command + end + + noctalia.runAsync(fullCmd, function(result) + runningMap[cmdId] = false + local code = (result and type(result.exitCode) == "number") and result.exitCode or -1 + local out = trim((result and result.stdout or "") .. "\n" .. (result and result.stderr or "")) + exitCodeMap[cmdId] = code + outputMap[cmdId] = out + publishState() + + if noctalia.getConfig("show_toast") ~= false then + if code == 0 then + noctalia.notify( + noctalia.tr("title"), + noctalia.tr("notify.success", { name = targetCmd.name })) + else + noctalia.notifyError( + noctalia.tr("title"), + noctalia.tr("notify.error", { name = targetCmd.name, code = code })) + end + end + end, 120000) +end + +local function handleCommandAction(actionData) + if type(actionData) ~= "table" then return end + local act = actionData.action + + if act == "run" and actionData.id then + executeCommand(actionData.id) + + elseif act == "add" and actionData.item then + local item = actionData.item + local pass = item.sudoPassword or "" + item.sudoPassword = nil + table.insert(currentCommands, item) + saveCommands(currentCommands) + if item.isSudo and pass ~= "" then + storePassword(item.id, item.name, pass, function(ok) + hasPasswordMap[item.id] = ok + publishState() + end) + else + publishState() + end + + elseif act == "update" and actionData.item then + local incoming = actionData.item + local newPass = incoming.sudoPassword or "" + incoming.sudoPassword = nil + for i, c in ipairs(currentCommands) do + if c.id == incoming.id then + currentCommands[i] = { + id = incoming.id, + name = incoming.name, + command = incoming.command, + icon = incoming.icon, + isSudo = incoming.isSudo, + } + if incoming.isSudo and newPass ~= "" then + -- New password provided: update keyring entry + storePassword(incoming.id, incoming.name, newPass, function(ok) + hasPasswordMap[incoming.id] = ok + saveCommands(currentCommands) + publishState() + end) + elseif not incoming.isSudo then + -- Sudo disabled: remove any keyring entry + clearPassword(incoming.id, function() + hasPasswordMap[incoming.id] = false + saveCommands(currentCommands) + publishState() + end) + else + -- Sudo still enabled but no new password entered: keep existing keyring entry + saveCommands(currentCommands) + publishState() + end + break + end + end + + elseif act == "delete" and actionData.id then + local newList = {} + for _, c in ipairs(currentCommands) do + if c.id ~= actionData.id then table.insert(newList, c) end + end + clearPassword(actionData.id, function() + hasPasswordMap[actionData.id] = nil + end) + currentCommands = newList + saveCommands(currentCommands) + publishState() + end +end + +noctalia.state.watch("cmd_runner_action", handleCommandAction) diff --git a/cmd-runner/thumbnail.webp b/cmd-runner/thumbnail.webp new file mode 100644 index 00000000..175fba42 Binary files /dev/null and b/cmd-runner/thumbnail.webp differ diff --git a/cmd-runner/translations/de.json b/cmd-runner/translations/de.json new file mode 100644 index 00000000..23895b7d --- /dev/null +++ b/cmd-runner/translations/de.json @@ -0,0 +1,38 @@ +{ + "title": "Command Runner", + "widget": { + "label": "Befehle", + "tooltip": "Command Runner - CLI-Befehle im Hintergrund ausführen" + }, + "panel": { + "title": "Command Runner", + "subtitle": "Befehle per Knopfdruck lautlos im Hintergrund ausführen", + "add_button": "Neuer Befehl", + "cancel_button": "Abbrechen", + "save_button": "Speichern", + "run_button": "Ausführen", + "running": "Läuft...", + "logs": "Log", + "delete": "Löschen", + "edit": "Bearbeiten", + "name_label": "Befehlsname", + "cmd_label": "CLI Befehl", + "sudo_label": "Sudo (root) erforderlich", + "sudo_pass_label": "Sudo-Passwort (einmalig)", + "no_commands": "Keine Befehle vorhanden." + }, + "settings": { + "show_toast": { + "label": "Benachrichtigungen anzeigen", + "description": "Zeigt eine Toast-Meldung bei Abschluss oder Fehler eines Befehls an" + }, + "show_label": { + "label": "Text-Label in der Bar anzeigen", + "description": "Blendet den Text 'Befehle' neben dem Icon in der Top-Bar ein" + } + }, + "notify": { + "success": "Befehl '{name}' erfolgreich ausgeführt.", + "error": "Befehl '{name}' fehlgeschlagen (Exit Code {code})." + } +} diff --git a/cmd-runner/translations/en.json b/cmd-runner/translations/en.json new file mode 100644 index 00000000..5988170b --- /dev/null +++ b/cmd-runner/translations/en.json @@ -0,0 +1,46 @@ +{ + "title": "Command Runner", + "widget": { + "label": "Commands", + "tooltip": "Command Runner - Run CLI commands in background" + }, + "panel": { + "title": "Command Runner", + "subtitle": "Execute commands silently in background at a click", + "add_button": "New Command", + "cancel_button": "Cancel", + "save_button": "Save", + "run_button": "Run", + "running": "Running...", + "logs": "Log", + "delete": "Delete", + "edit": "Edit", + "name_label": "Command Name", + "name_placeholder": "e.g. System Update", + "cmd_label": "CLI Command", + "cmd_placeholder": "e.g. apt update && apt upgrade -y", + "sudo_label": "Requires Sudo (root)", + "sudo_enabled": "[✓] Requires sudo (root)", + "sudo_disabled": "[ ] Requires sudo (root)", + "sudo_pass_label": "Sudo Password (entered once)", + "sudo_pass_placeholder": "Sudo password...", + "sudo_pass_placeholder_saved": "Password saved (leave blank to keep)", + "no_commands": "No commands saved.", + "status_success": "✓ Status: Success (Code 0)", + "status_failed": "✗ Status: Failed (Code {code})" + }, + "settings": { + "show_toast": { + "label": "Show notifications", + "description": "Shows a toast notice upon command completion or error" + }, + "show_label": { + "label": "Show text label in bar", + "description": "Displays 'Commands' next to the icon in the top bar" + } + }, + "notify": { + "success": "Command '{name}' completed successfully.", + "error": "Command '{name}' failed (Exit Code {code})." + } +} diff --git a/cmd-runner/widget.luau b/cmd-runner/widget.luau new file mode 100644 index 00000000..155bc03f --- /dev/null +++ b/cmd-runner/widget.luau @@ -0,0 +1,60 @@ +--!nonstrict +-- Bar widget entry for Command Runner plugin + +local PANEL_ID = "nocode-96/cmd-runner:panel" +local state = noctalia.state.get("cmd_runner_state") or { runningMap = {} } + +local function isAnyRunning() + if type(state) == "table" and type(state.runningMap) == "table" then + for _, running in pairs(state.runningMap) do + if running == true then return true end + end + end + return false +end + +local function render() + local running = isAnyRunning() + local showLabel = noctalia.getConfig("show_label") ~= false + + local children = { + ui.glyph({ + name = running and "player-play" or "terminal", + size = 16, + color = running and "primary" or "on_surface", + }), + } + + if showLabel or running then + table.insert(children, ui.label({ + text = running and noctalia.tr("panel.running") or noctalia.tr("widget.label"), + fontWeight = "bold", + color = running and "primary" or "on_surface", + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center" }, children)) + barWidget.setTooltip(noctalia.tr("widget.tooltip")) +end + +noctalia.state.watch("cmd_runner_state", function(val) + if type(val) == "table" then + state = val + render() + end +end) + +render() + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + noctalia.togglePanel(PANEL_ID) +end