diff --git a/ssh-agent/README.md b/ssh-agent/README.md new file mode 100644 index 00000000..2c8fd0da --- /dev/null +++ b/ssh-agent/README.md @@ -0,0 +1,103 @@ +# SSH Agent + +Manage your SSH keys and saved sessions directly from the Noctalia bar. This plugin keeps a small SSH agent running in the background, lets you add or remove keys, and provides a panel for quick session management. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `martasskv5/ssh-agent` | +| Entries | Bar widget: `ssh-agent-widget`; panel: `ssh-agent-panel`; service: `ssh-agent-service` | + +## Requirements + +Install the following on `PATH` for the plugin to work correctly: + +- `ssh` +- `ssh-agent` +- `ssh-add` +- `zenity` + +For passphrase prompts, install one of these programs as well: + +- `ksshaskpass` +- `ssh-askpass` +- `lxqt-openssh-askpass` + +The plugin also uses `pkill`, `mkdir`, and the standard SSH agent socket pattern under `/tmp` to manage the agent lifecycle and temporary files. + +To make SSH tools available outside the plugin process, set `SSH_AUTH_SOCK` in your shell or desktop environment. For example: + +```sh +export SSH_AUTH_SOCK="/tmp/ssh-agent-$USER.sock" +``` + +For a compositor or window manager configuration, add the same value there as well so applications launched from the desktop environment can find the agent. + +### Example Niri config + +```kdl +environment { + SSH_ASKPASS "/usr/bin/ksshaskpass" + SSH_ASKPASS_REQUIRE "prefer" + SSH_AUTH_SOCK "/tmp/ssh-agent-.sock" +} +``` + +### Example Hyprland config + +```conf +env = SSH_ASKPASS,/usr/bin/ksshaskpass +env = SSH_ASKPASS_REQUIRE,prefer +env = SSH_AUTH_SOCK,/tmp/ssh-agent-.sock +``` + +## Usage + +Add the `SSH Agent` widget under Settings → Bar. + +To open the panel manually, run: + +```sh +noctalia msg panel-toggle martasskv5/ssh-agent:ssh-agent-panel +``` + +The panel lets you add SSH keys, remove them, and manage saved SSH sessions. If no SSH agent is running yet, the plugin will start one automatically and connect to it using the configured socket. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `socket_path` | `string` | `""` | The path to the SSH agent socket. Leave empty to use the default per-user agent socket location. | +| `sessions_file` | `string` | `"~/.ssh/sessions.json"` | The path to the JSON file used to store saved SSH sessions. | +| `default_key_browse_path` | `string` | `"~/.ssh"` | The default folder shown when browsing for SSH private keys. | +| `terminal_command` | `string` | `""` | The terminal command used to launch SSH sessions. Leave empty to use the system default terminal. | +| `show_saved_sessions` | `boolean` | `true` | Whether saved SSH sessions are shown in the panel. | +| `show_notifications` | `boolean` | `true` | Whether the plugin shows success and failure notifications for SSH operations. | +| `auto_start_mode` | `string` | `"connect_existing"` | How the plugin decides whether to reuse an existing agent or start a new one. Supported values are `"connect_existing"`, `"ask_each_time"`, and `"create_new"`. | + +## IPC + +Refresh the plugin state and update the bar widget by sending the following IPC message: + +```sh +noctalia msg plugin martasskv5/ssh-agent:ssh-agent-service all refresh +``` + +Stop the SSH agent and remove the socket by sending: + +```sh +noctalia msg plugin martasskv5/ssh-agent:ssh-agent-service all stop-agent +``` + +Start a new SSH agent and connect to it by sending: + +```sh +noctalia msg plugin martasskv5/ssh-agent:ssh-agent-service all start-agent +``` + +## Notes + +The plugin creates and manages the SSH agent socket and stores saved connection data in a JSON file under the configured `sessions_file` path. It may spawn `ssh-agent`, `ssh-add`, and the configured askpass helper to prompt for passphrases or manage keys, and it writes temporary files in `/tmp` while creating and switching SSH agent sockets. + +If your environment does not export `SSH_AUTH_SOCK`, applications outside the plugin may not see the agent even when it is running, so it is often helpful to set it in both the shell and the window manager or compositor config. \ No newline at end of file diff --git a/ssh-agent/panel.luau b/ssh-agent/panel.luau new file mode 100644 index 00000000..e4a6a82d --- /dev/null +++ b/ssh-agent/panel.luau @@ -0,0 +1,315 @@ +--!nonstrict +-- Floating panel built from the declarative ui.* vocabulary. Replaces +-- Panel.qml. There's no native file picker in the plugin API, so "Add Key" +-- is a path input instead of NFilePicker — paste or type the key path. +-- +-- Everything here is a thin client: it reads noctalia.state("status") +-- (published by service.luau) and requests actions by writing to the +-- "command" channel. It never touches ssh-agent/ssh-add directly. + +local status = noctalia.state.get("status") or {} + +local editing = false +local selectedSessionName = "" +local editName, editHost, editUser, editPort, editKeyPath, editExtraArgs = "", "", "", "22", "", "" +local addKeyPath = "" + +local function sendCommand(action, extra) + local cmd = extra or {} + cmd.action = action + cmd.ts = noctalia.nowMs() + noctalia.state.set("command", cmd) +end + +local function clearForm() + editing = false + selectedSessionName = "" + editName, editHost, editUser, editPort, editKeyPath, editExtraArgs = "", "", "", "22", "", "" +end + +local function loadIntoForm(session) + editing = true + selectedSessionName = session.name or "" + editName = session.name or "" + editHost = session.host or "" + editUser = session.user or "" + editPort = tostring(session.port or "22") + editKeyPath = session.key_path or "" + editExtraArgs = session.extra_args or "" +end + +local function render() + local keyRows = {} + for _, key in ipairs(status.loaded_keys or {}) do + table.insert( + keyRows, + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = key.name or "(unknown)", flexGrow = 1 }), + ui.label({ text = key.type or "", color = "on_surface_variant", fontSize = 11 }), + ui.button({ + glyph = "trash", + onClick = function() + sendCommand("remove-key", { name = key.name }) + end, + }), + }) + ) + end + if #keyRows == 0 then + table.insert(keyRows, ui.label({ text = "No keys loaded", color = "on_surface_variant" })) + end + + local children = { + -- Header row + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ text = "SSH Agent", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.label({ + text = status.agent_running and "Running" or "Stopped", + color = status.agent_running and "primary" or "error", + }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + + -- Loaded Keys Section + ui.column({ gap = 8 }, { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ text = "Loaded Keys", fontWeight = "medium", color = "on_surface_variant", flexGrow = 1 }), + ui.button({ glyph = "refresh", onClick = "onRefreshClicked" }), + }), + ui.column({ gap = 4 }, keyRows), + ui.row({ gap = 8, align = "center" }, { + ui.input({ + key = "add_key_path", + placeholder = "Path to private key…", + value = addKeyPath, + onChange = "onAddKeyPathChange", + onSubmit = "onAddKeySubmit", + flexGrow = 1 + }), + ui.button({ + text = (not addKeyPath or addKeyPath == "") and "Browse…" or "Add Key", + onClick = "onAddKeyClicked", + }), + }), + }), + } + + -- Conditionally evaluate and load Saved Sessions + local showSessionsConfig = noctalia.getConfig("show_saved_sessions") + if showSessionsConfig == true or showSessionsConfig == "true" then + local sessionRows = {} + for _, s in ipairs(status.sessions or {}) do + table.insert( + sessionRows, + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = s.name or "", fontWeight = "medium", flexGrow = 1 }), + ui.label({ text = s.host or "", color = "on_surface_variant" }), + ui.button({ + glyph = "terminal", + onClick = function() + sendCommand("launch-session", { session = s }) + end, + }), + ui.button({ + glyph = "edit", + onClick = function() + loadIntoForm(s) + render() + end, + }), + ui.button({ + glyph = "trash", + onClick = function() + sendCommand("delete-session", { name = s.name }) + end, + }), + }) + ) + end + if #sessionRows == 0 then + table.insert(sessionRows, ui.label({ text = "No saved sessions", color = "on_surface_variant" })) + end + + table.insert(children, ui.column({ gap = 8 }, { + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ text = "Saved Sessions", fontWeight = "medium", color = "on_surface_variant", flexGrow = 1 }), + ui.button({ text = "New Session", glyph = "plus", onClick = "onNewSessionClicked" }), + }), + ui.column({ gap = 4 }, sessionRows), + })) + end + + -- Editor Form Section + if editing then + table.insert( + children, + ui.column({ gap = 8 }, { + ui.label({ text = selectedSessionName ~= "" and "Edit Session" or "New Session", fontWeight = "medium" }), + ui.input({ key = "name", placeholder = "Session name", value = editName, onChange = "onNameChange" }), + ui.input({ key = "host", placeholder = "Host", value = editHost, onChange = "onHostChange" }), + ui.input({ key = "user", placeholder = "User", value = editUser, onChange = "onUserChange" }), + ui.input({ key = "port", placeholder = "Port", value = editPort, onChange = "onPortChange" }), + ui.input({ key = "key_path", placeholder = "Key path", value = editKeyPath, onChange = "onKeyPathChange" }), + ui.input({ + key = "extra_args", + placeholder = "Extra SSH args", + value = editExtraArgs, + onChange = "onExtraArgsChange", + }), + ui.row({ gap = 8 }, { + ui.button({ + text = (selectedSessionName ~= "" and "Update Session" or "Save Session"), + glyph = "device-floppy", + onClick = "onSaveSession", + }), + ui.button({ text = "Cancel", glyph = "x", onClick = "onCancelEdit" }), + }), + }) + ) + end + + -- Using a structural column with padding rather than an aggressive scrolling viewport tracker. + -- This enables Noctalia's window framework engine to dynamically compress boundaries. + panel.render(ui.scroll({ + -- Caps the physical view boundary so it doesn't bleed out of the display edge + maxHeight = 500, + -- Instructs the scroll panel to occupy the full width of the parent panel frame + width = "stretch" + }, { + -- Your main structural layout block nesting inside the view tracker + ui.column({ + gap = 16, + padding = 16, + align = "stretch", + -- Tightly binds the column height around the generated Luau node tree array + height = "shrink" + }, children) + })) +end + + +function onOpen(_context) + status = noctalia.state.get("status") or {} + render() +end + +noctalia.state.watch("status", function(value) + status = value or {} + render() +end) + +function onNameChange(value) + editName = value +end +function onHostChange(value) + editHost = value +end +function onUserChange(value) + editUser = value +end +function onPortChange(value) + editPort = value +end +function onKeyPathChange(value) + editKeyPath = value +end +function onExtraArgsChange(value) + editExtraArgs = value +end +function onAddKeyPathChange(value) + addKeyPath = value +end + +function onNewSessionClicked() + clearForm() + editing = true + render() +end + +function onCancelEdit() + clearForm() + render() +end + +function onSaveSession() + sendCommand("upsert-session", { + session = { + name = editName, + host = editHost, + user = editUser, + port = editPort, + key_path = editKeyPath, + extra_args = editExtraArgs, + }, + }) + clearForm() + render() +end + +-- function onAddKeyClicked() +-- if addKeyPath ~= "" then +-- sendCommand("add-key", { path = addKeyPath }) +-- addKeyPath = "" +-- render() +-- end +-- end + +function onAddKeySubmit(value) + addKeyPath = value + if addKeyPath ~= "" then + sendCommand("add-key", { path = addKeyPath }) + addKeyPath = "" + end + render() +end + +function onRefreshClicked() + sendCommand("refresh") +end + +function onCloseClicked() + panel.close() +end + +local function openFileBrowser(callback) + print(noctalia.getConfig("default_key_browse_path")) + local defaultPath = noctalia.getConfig("default_key_browse_path") or "~/.ssh" + local filenameArg = "" + + -- 1. Ensure the path is explicitly recognized as a directory by Zenity using a trailing slash + -- 2. Construct the exact shell execution string bypassing Lua's sandboxed environment restrictions + if defaultPath:sub(1, 1) == "~" then + -- Appends a slash after stripping '~', resulting in: --filename=$HOME"/.ssh/" + filenameArg = '--filename=$HOME"' .. defaultPath:sub(2) .. '/"' + else + -- Forces a trailing slash if it doesn't have one + if defaultPath:sub(-1) ~= "/" then + defaultPath = defaultPath .. "/" + end + filenameArg = '--filename="' .. defaultPath .. '"' + end + + -- Run Zenity safely with double quotes wrapping the command arguments + noctalia.runAsync('zenity --file-selection --title="Select SSH Private Key" ' .. filenameArg .. ' 2>/dev/null', function(res) + local output = (res and res.stdout) or "" + if res and res.exitCode == 0 and output ~= "" then + callback((output:gsub("%s+$", ""))) + else + callback(nil) + end + end, 60000) +end + +function onAddKeyClicked() + if not addKeyPath or addKeyPath:match("^%s*$") then + openFileBrowser(function(selectedPath) + if selectedPath then + addKeyPath = selectedPath + onAddKeySubmit(selectedPath) + end + end) + else + onAddKeySubmit(addKeyPath) + end +end + diff --git a/ssh-agent/plugin.toml b/ssh-agent/plugin.toml new file mode 100644 index 00000000..618de515 --- /dev/null +++ b/ssh-agent/plugin.toml @@ -0,0 +1,107 @@ +# Noctalia v5 plugin manifest for SSH Agent. +# v5 plugins run as Luau scripts in isolated VMs (no more QML). Each [[entry]] +# below is a separate script; they only talk to each other through +# noctalia.state (shared key/value) and onIpc — there is no shared +# "mainInstance" object like v4's pluginApi.mainInstance. +# +# Docs: https://docs.noctalia.dev/v5/plugins/ + +id = "martasskv5/ssh-agent" +name = "SSH Agent" +version = "1.0.0" +plugin_api = 9 +author = "martasskv5" +license = "MIT" +dependencies = ["zenity", "ssh-add", "ssh-agent", "pkill", "mkdir", "ksshaskpass", "ssh-askpass", "lxqt-openssh-askpass", "ssh"] +tags = ["utility", "network"] +icon = "key" +description = "Manage your SSH agent, loaded keys, and saved SSH sessions from the bar." + +# ── Bar widget ──────────────────────────────────────────────────────────── +# Settings are declared once here but shared across every entry in the +# plugin via noctalia.getConfig(key) — the service and panel read the same +# values. This replaces v4's Settings.qml + manifest.metadata.defaultSettings. +[[widget]] +id = "ssh-agent-widget" +entry = "widget.luau" + +[[setting]] +key = "socket_path" +type = "string" +label_key = "settings.socket_path.label" +description_key = "settings.socket_path.description" +default = "" + +[[setting]] +key = "sessions_file" +type = "string" +label_key = "settings.sessions_file.label" +description_key = "settings.sessions_file.description" +default = "~/.ssh/sessions.json" + +[[setting]] +key = "default_key_browse_path" +type = "string" +label_key = "settings.default_key_browse_path.label" +description_key = "settings.default_key_browse_path.description" +default = "~/.ssh/" + +[[setting]] +key = "terminal_command" +type = "string" +label_key = "settings.terminal_command.label" +description_key = "settings.terminal_command.description" +default = "" + +[[setting]] +key = "show_saved_sessions" +type = "bool" +label_key = "settings.show_saved_sessions.label" +description_key = "settings.show_saved_sessions.description" +default = true + +[[setting]] +key = "show_notifications" +type = "bool" +label_key = "settings.show_notifications.label" +description_key = "settings.show_notifications.description" +default = true + +[[setting]] +key = "auto_start_mode" +type = "select" +label_key = "settings.auto_start_mode.label" +description_key = "settings.auto_start_mode.description" +default = "connect_existing" + +[[setting.options]] +value = "create_new" +label_key = "settings.auto_start_mode.options.create_new" + +[[setting.options]] +value = "connect_existing" +label_key = "settings.auto_start_mode.options.connect_existing" + +[[setting.options]] +value = "ask_each_time" +label_key = "settings.auto_start_mode.options.ask_each_time" + +# ── Headless service ────────────────────────────────────────────────────── +# Owns the ssh-agent process, ssh-add calls, and the sessions file. Runs for +# the whole session, independent of whether the widget/panel are open — +# this replaces Main.qml + its Process{} blocks. +[[service]] +id = "ssh-agent-service" +entry = "service.luau" + +# ── Panel ───────────────────────────────────────────────────────────────── +# Declarative UI (ui.* tree), replaces Panel.qml. Size is host-owned and +# declared here so the surface is right on first open. +# Open manually with: noctalia msg panel-toggle martasskv5/ssh-agent:panel +[[panel]] +id = "ssh-agent-panel" +entry = "panel.luau" +# width = 480 +# height = 340 +placement = "attached" +position = "auto" diff --git a/ssh-agent/service.luau b/ssh-agent/service.luau new file mode 100644 index 00000000..07d88194 --- /dev/null +++ b/ssh-agent/service.luau @@ -0,0 +1,478 @@ +--!nonstrict +-- Headless [[service]] entry. Owns everything Main.qml used to own: the +-- ssh-agent process, ssh-add calls, and the sessions file. Runs for the +-- whole session. Other entries (widget.luau, panel.luau) never call these +-- functions directly — Luau VMs are isolated per entry — they instead: +-- * read published data via noctalia.state.watch("status", ...) +-- * request actions by writing noctalia.state.set("command", {...}) +-- * or drive it externally via: noctalia msg plugin martasskv5/ssh-agent:agent + +local agentRunning = false +local loadingKeys = false +local loadedKeys = {} +local sessions = {} +local sshVersion = "Loading..." +local isStartupPhase = true + +local function trimmed(s) + return (tostring(s or ""):match("^%s*(.-)%s*$")) +end + +local function shellQuote(text) + return "'" .. tostring(text or ""):gsub("'", "'\\''") .. "'" +end + +-- ── Settings (shared across all of this plugin's entries via getConfig) ──── + +local function defaultSocketPath() + return "/tmp/ssh-agent-" .. (noctalia.getenv("USER") or "default") .. ".sock" +end + +local function agentSocketPath() + local v = trimmed(noctalia.getConfig("socket_path")) + if v == "" then + return defaultSocketPath() + end + return v +end + +local function sessionsFilePath() + local v = trimmed(noctalia.getConfig("sessions_file")) + if v == "" then + return "~/.ssh/sessions.json" + end + return v +end + +local function terminalCommand() + return trimmed(noctalia.getConfig("terminal_command")) +end + +local function showNotifications() + local v = noctalia.getConfig("show_notifications") + if v == nil then + return true + end + return v == true +end + +local function autoStartMode() + local v = trimmed(noctalia.getConfig("auto_start_mode")) + if v == "" then + return "connect_existing" + end + return v +end + +local function notifyInfo(title, msg) + if showNotifications() then + noctalia.notify(title, msg or "") + end +end + +local function notifyError(title, msg) + noctalia.notifyError(title, msg or "") +end + +-- ── Publish state for widget.luau / panel.luau ───────────────────────────── + +local function publishStatus() + noctalia.state.set("status", { + agent_running = agentRunning, + loading_keys = loadingKeys, + loaded_keys = loadedKeys, + sessions = sessions, + ssh_version = sshVersion, + }) +end + +-- ── ssh-agent lifecycle ───────────────────────────────────────────────────── + +local function extractVersionLine(output) + for line in tostring(output or ""):gmatch("[^\r\n]+") do + local l = trimmed(line) + if l ~= "" then + local lower = l:lower() + if lower:sub(1, 6) ~= "usage:" and not lower:find("^unknown option") and not lower:find("^option requires an argument") then + return l + end + end + end + return "Unknown" +end + +local function getSshVersion() + noctalia.runAsync( + "if command -v ssh >/dev/null 2>&1; then ssh -V 2>&1; else echo 'Not installed'; fi", + function(res) + sshVersion = extractVersionLine(res.stdout ~= "" and res.stdout or res.stderr) + publishStatus() + end, + 5000 + ) +end + +local function checkAgentRunning(onDone) + noctalia.runAsync("test -S " .. shellQuote(agentSocketPath()), function(res) + agentRunning = res.exitCode == 0 + publishStatus() + if onDone then + onDone() + end + end, 5000) +end + +local function parseLoadedKeys(text, exitCode) + text = tostring(text or "") + if exitCode ~= 0 then + if text:find("The agent has no identities") or text:find("The agent has no keys") then + loadedKeys = {} + else + loadedKeys = {} + if not isStartupPhase and trimmed(text) ~= "" then + notifyError("SSH Agent", trimmed(text)) + end + end + return + end + + local keys = {} + for line in text:gmatch("[^\r\n]+") do + local l = trimmed(line) + if l ~= "" then + local bits, fingerprint, rest = l:match("^(%d+)%s+(%S+)%s+(.*)$") + if bits then + local name, ktype = rest:match("^(.-)%s+%(([^%)]+)%)$") + if not name then + name = rest + ktype = "" + end + table.insert(keys, { bits = bits, fingerprint = fingerprint, name = name, type = ktype }) + end + end + end + loadedKeys = keys +end + +local function refreshLoadedKeys() + loadingKeys = true + publishStatus() + local cmd = "SSH_AUTH_SOCK=" .. shellQuote(agentSocketPath()) .. " ssh-add -l 2>&1" + noctalia.runAsync(cmd, function(res) + loadingKeys = false + parseLoadedKeys(res.stdout, res.exitCode) + publishStatus() + end, 8000) +end + +local function refreshState() + checkAgentRunning() + refreshLoadedKeys() +end + +local function startAgent(killPrevious) + local sock = agentSocketPath() + local dir = sock:match("^(.*)/[^/]*$") or "/tmp" + local script = "" + if killPrevious then + script = script .. "pkill -f " .. shellQuote("ssh-agent -a " .. sock) .. " 2>/dev/null || true\n" + end + script = script .. "mkdir -p " .. shellQuote(dir) .. "\n" + script = script .. "ssh-agent -a " .. shellQuote(sock) .. " >/dev/null" + + noctalia.runAsync(script, function(res) + if res.exitCode == 0 then + if not isStartupPhase then + notifyInfo("SSH Agent", "Agent started") + end + elseif not isStartupPhase then + local msg = trimmed(res.stderr ~= "" and res.stderr or res.stdout) + if msg ~= "" then + notifyError("SSH Agent", msg) + end + end + refreshState() + end, 8000) +end + +local function stopAgent() + local sock = agentSocketPath() + noctalia.runAsync("pkill -f " .. shellQuote("ssh-agent -a " .. sock), function(res) + if res.exitCode == 0 and not isStartupPhase then + notifyInfo("SSH Agent", "Agent stopped") + end + refreshState() + end, 5000) +end + +-- ── Keys ───────────────────────────────────────────────────────────────── + +local function addKey(keyPath) + local path = noctalia.expandPath(trimmed(keyPath)) + if path == "" then + return + end + local script = table.concat({ + "ASKPASS=${SSH_ASKPASS:-}", + "if [ -z \"$ASKPASS\" ] || [ ! -x \"$ASKPASS\" ]; then", + " for p in /usr/bin/ksshaskpass /usr/bin/ssh-askpass /usr/lib/ssh/ssh-askpass /usr/bin/lxqt-openssh-askpass; do", + " if [ -x \"$p\" ]; then ASKPASS=\"$p\"; break; fi", + " done", + "fi", + "if [ -n \"$ASKPASS\" ]; then", + " export SSH_ASKPASS=\"$ASKPASS\"", + " export SSH_ASKPASS_REQUIRE=prefer", + "fi", + "SSH_AUTH_SOCK=" .. shellQuote(agentSocketPath()) .. " ssh-add " .. shellQuote(path) .. " 2>&1", + }, "\n") + + -- Long timeout: an askpass GUI prompt can sit open for a while. + noctalia.runAsync(script, function(res) + if res.exitCode == 0 then + notifyInfo("SSH Key Added", path) + else + local msg = trimmed(res.stderr ~= "" and res.stderr or res.stdout) + notifyError("Failed to Add Key", msg ~= "" and msg or path) + end + refreshLoadedKeys() + end, 60000) +end + +local function removeKey(keyName) + if not keyName or keyName == "" then + return + end + local cmd = "SSH_AUTH_SOCK=" .. shellQuote(agentSocketPath()) .. " ssh-add -d " .. shellQuote(keyName) .. " 2>&1" + noctalia.runAsync(cmd, function(res) + if res.exitCode == 0 then + notifyInfo("SSH Key Unloaded", keyName) + else + local msg = trimmed(res.stderr ~= "" and res.stderr or res.stdout) + notifyError("Failed to Unload Key", msg ~= "" and msg or keyName) + end + refreshLoadedKeys() + end, 8000) +end + +local function removeAllKeys() + local cmd = "SSH_AUTH_SOCK=" .. shellQuote(agentSocketPath()) .. " ssh-add -D 2>&1" + noctalia.runAsync(cmd, function(res) + if res.exitCode == 0 then + notifyInfo("SSH Agent", "All keys removed") + else + notifyError("Failed to Remove Keys", trimmed(res.stderr ~= "" and res.stderr or res.stdout)) + end + refreshLoadedKeys() + end, 8000) +end + +-- ── Sessions ───────────────────────────────────────────────────────────── + +local function launchSession(session) + if not session or trimmed(session.host or "") == "" then + return + end + local user = trimmed(session.user or "") + local host = trimmed(session.host or "") + local destination = user ~= "" and (user .. "@" .. host) or host + local port = trimmed(tostring(session.port or "22")) + if port == "" then + port = "22" + end + local keyPath = noctalia.expandPath(trimmed(session.key_path or "")) + local extraArgs = trimmed(session.extra_args or "") + + local sshCmd = "ssh -p " .. shellQuote(port) + if keyPath ~= "" then + sshCmd = sshCmd .. " -i " .. shellQuote(keyPath) + end + if extraArgs ~= "" then + sshCmd = sshCmd .. " " .. extraArgs + end + sshCmd = sshCmd .. " " .. shellQuote(destination) + + local wrapped = "SSH_AUTH_SOCK=" .. shellQuote(agentSocketPath()) .. " " .. sshCmd + local term = terminalCommand() + + if term ~= "" then + noctalia.runAsync(term .. " -e sh -lc " .. shellQuote(wrapped)) + elseif not noctalia.runInTerminal(wrapped) then + notifyError("Launch Failed", "No terminal available to launch the session") + end +end + +local function normalizeSession(raw) + local port = trimmed(tostring((raw and raw.port) or "22")) + if port == "" then + port = "22" + end + return { + name = trimmed(raw and raw.name), + host = trimmed(raw and raw.host), + user = trimmed(raw and raw.user), + port = port, + key_path = trimmed(raw and raw.key_path), + extra_args = trimmed(raw and raw.extra_args), + } +end + +local function writeSessions() + local path = sessionsFilePath() + local encoded = noctalia.json.encode(sessions, true) + local expanded = noctalia.expandPath(path) + local dir = expanded:match("^(.*)/[^/]*$") + if dir and dir ~= "" then + noctalia.mkdirAll(dir) + end + local ok, err = noctalia.writeFile(path, (encoded or "[]") .. "\n") + if not ok then + notifyError("Saved Sessions", err or "Failed to save sessions") + end +end + +local function upsertSession(raw) + local session = normalizeSession(raw) + if session.name == "" or session.host == "" then + notifyError("Saved Sessions", "Session name and host are required") + return + end + + local updated = {} + local replaced = false + for _, s in ipairs(sessions) do + if s.name == session.name then + table.insert(updated, session) + replaced = true + else + table.insert(updated, s) + end + end + if not replaced then + table.insert(updated, session) + end + + sessions = updated + writeSessions() + notifyInfo("Saved Sessions", "Stored '" .. session.name .. "'") + publishStatus() +end + +local function deleteSession(name) + local n = trimmed(name) + if n == "" then + return + end + local updated = {} + for _, s in ipairs(sessions) do + if s.name ~= n then + table.insert(updated, s) + end + end + sessions = updated + writeSessions() + notifyInfo("Saved Sessions", "Deleted '" .. n .. "'") + publishStatus() +end + +local function loadSessionsFromDisk() + local path = sessionsFilePath() + local contents = noctalia.readFile(path) + if not contents or trimmed(contents) == "" then + sessions = {} + publishStatus() + return + end + + local decoded, err = noctalia.json.decode(trimmed(contents)) + if err or decoded == nil then + sessions = {} + if not isStartupPhase then + notifyError("Saved Sessions", "Invalid JSON in sessions file") + end + publishStatus() + return + end + + if decoded.entries then + sessions = decoded.entries + else + sessions = decoded + end + publishStatus() +end + +-- ── Cross-entry command channel ───────────────────────────────────────────── +-- widget.luau / panel.luau write here; each write includes a fresh `ts` so +-- repeat commands (e.g. clicking "refresh" twice) still trigger the watcher. + +noctalia.state.watch("command", function(cmd) + if type(cmd) ~= "table" or not cmd.action then + return + end + local action = cmd.action + if action == "refresh" then + refreshState() + elseif action == "start-agent" then + startAgent(cmd.kill == true) + elseif action == "stop-agent" then + stopAgent() + elseif action == "add-key" then + addKey(cmd.path) + elseif action == "remove-key" then + removeKey(cmd.name) + elseif action == "remove-all-keys" then + removeAllKeys() + elseif action == "launch-session" then + launchSession(cmd.session) + elseif action == "upsert-session" then + upsertSession(cmd.session) + elseif action == "delete-session" then + deleteSession(cmd.name) + end +end) + +-- ── External IPC ───────────────────────────────────────────────────────── + +function onIpc(event, _payload) + if event == "refresh" then + refreshState() + elseif event == "start-agent" then + startAgent(false) + elseif event == "stop-agent" then + stopAgent() + end +end + +-- Plugin-level settings changed (e.g. socket path edited in Settings). +function onConfigChanged() + refreshState() + loadSessionsFromDisk() +end + +-- ── Startup ────────────────────────────────────────────────────────────── +-- Runs once when the service loads. There's no QML Timer here, so a short +-- update() tick stands in for the old 500ms one-shot startup delay; after +-- that the service settles into a slow background sync. + +refreshState() +getSshVersion() +loadSessionsFromDisk() + +local ticks = 0 +noctalia.setUpdateInterval(500) + +function update() + ticks += 1 + if ticks == 1 then + local mode = autoStartMode() + if mode == "create_new" then + startAgent(true) + elseif mode == "connect_existing" and not agentRunning then + startAgent(false) + end + isStartupPhase = false + noctalia.setUpdateInterval(60000) -- background sync every minute thereafter + else + refreshState() + end +end diff --git a/ssh-agent/thumbnail.webp b/ssh-agent/thumbnail.webp new file mode 100644 index 00000000..41f3eff8 Binary files /dev/null and b/ssh-agent/thumbnail.webp differ diff --git a/ssh-agent/translations/en.json b/ssh-agent/translations/en.json new file mode 100644 index 00000000..3cee7baa --- /dev/null +++ b/ssh-agent/translations/en.json @@ -0,0 +1,37 @@ +{ + "settings": { + "socket_path": { + "label": "Agent Socket Path", + "description": "Path to the ssh-agent UNIX socket. Leave empty to use /tmp/ssh-agent-$USER.sock" + }, + "sessions_file": { + "label": "Saved Sessions File", + "description": "JSON file used to store saved SSH sessions" + }, + "default_key_browse_path": { + "label": "Default Key Browse Path", + "description": "Default path used when browsing for SSH keys to add" + }, + "terminal_command": { + "label": "Preferred Terminal", + "description": "Optional terminal command used to launch SSH sessions (e.g. kitty). Leave empty to use the system default terminal" + }, + "show_saved_sessions": { + "label": "Show Saved Sessions", + "description": "Display saved SSH sessions in the main window" + }, + "show_notifications": { + "label": "Show Notifications", + "description": "Display success and failure toasts for SSH operations" + }, + "auto_start_mode": { + "label": "Startup Behavior", + "description": "What to do when Noctalia starts", + "options": { + "create_new": "Create New", + "connect_existing": "Connect Existing", + "ask_each_time": "Ask Each Time" + } + } + } +} diff --git a/ssh-agent/widget.luau b/ssh-agent/widget.luau new file mode 100644 index 00000000..dc1f0320 --- /dev/null +++ b/ssh-agent/widget.luau @@ -0,0 +1,47 @@ +--!nonstrict +-- Bar widget — a thin client of the [[service]] entry (agent.luau owns all +-- the process/state logic). This replaces BarWidget.qml. +-- +-- v5 bar widgets don't support a custom multi-item right-click context menu +-- (there's no ui.* menu component). The old "Refresh / Start Agent / +-- Settings" menu maps onto gestures instead: +-- left click -> toggle the panel (has Refresh / Start / Stop buttons) +-- right click -> quick refresh +-- middle click -> opens this plugin's settings (built-in host default) + +local PLUGIN_PANEL = "martasskv5/ssh-agent:ssh-agent-panel" + +local agentRunning = false +local loadedKeys = {} + +local function render() + barWidget.setGlyph(agentRunning and "key" or "key-off") + barWidget.setGlyphColor(agentRunning and "primary" or "on_surface_variant") + + local count = #loadedKeys + local statusText = agentRunning and "Running" or "Stopped" + local tooltip = statusText + if count > 0 then + tooltip = tooltip .. " — Keys: " .. count + end + barWidget.setTooltip(tooltip) +end + +local function applyStatus(status) + if type(status) == "table" then + agentRunning = status.agent_running == true + loadedKeys = status.loaded_keys or {} + end + render() +end + +applyStatus(noctalia.state.get("status")) +noctalia.state.watch("status", applyStatus) + +function onClick() + noctalia.togglePanel(PLUGIN_PANEL) +end + +function onRightClick() + noctalia.state.set("command", { action = "refresh", ts = noctalia.nowMs() }) +end