diff --git a/ssh-launcher/README.md b/ssh-launcher/README.md new file mode 100644 index 0000000..2483091 --- /dev/null +++ b/ssh-launcher/README.md @@ -0,0 +1,40 @@ +# SSH Launcher + +![SSH Launcher thumbnail](thumbnail.webp) + +SSH Launcher lists hosts from your OpenSSH config and opens an SSH session in a +terminal from the Noctalia launcher. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `cleboost/ssh-launcher` | +| Entry | Launcher provider: `launcher` | +| Launcher Prefix | `/ssh` | + +## Requirements + +Install [OpenSSH](https://www.openssh.com/) and ensure `ssh` is available on +`PATH`. + +## Usage + +Open the Noctalia launcher and type `/ssh` to list hosts from your SSH config. +Continue typing to filter by host alias, hostname, or user, then select one to +open a terminal running `ssh `. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `config_path` | `file` | `~/.ssh/config` | Path to your SSH config file. | +| `max_results` | `int` | `30` | Maximum number of hosts shown. | +| `extra_args` | `string` | `""` | Extra arguments appended to every `ssh` command. | +| `terminal` | `string` | `""` | Custom terminal command. Empty uses Noctalia's terminal discovery. | + +## Notes + +Hosts are read from simple `Host` entries in your SSH config. Wildcard patterns +such as `Host *` or `Host *.example.com` are ignored. `Include` directives are +not expanded in this version. The host list is cached for the launcher session. diff --git a/ssh-launcher/launcher.luau b/ssh-launcher/launcher.luau new file mode 100644 index 0000000..96350cb --- /dev/null +++ b/ssh-launcher/launcher.luau @@ -0,0 +1,201 @@ +--!nonstrict + +local cachedHosts = nil + +local function trim(s) + return noctalia.string.trim(s or "") +end + +local function shellQuote(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +local function getConfigPath() + local configured = noctalia.getConfig("config_path") + if type(configured) == "string" and configured ~= "" then + return noctalia.expandPath(configured) + end + return noctalia.expandPath("~/.ssh/config") +end + +local function getMaxResults() + local n = noctalia.getConfig("max_results") + return (type(n) == "number" and n > 0) and math.floor(n) or 30 +end + +local function statusRow(title, subtitle, glyph) + return { id = "", title = title, subtitle = subtitle, glyph = glyph } +end + +local function isValidHostAlias(alias) + return alias ~= "*" and not alias:match("[*?]") +end + +local function hostSubtitle(host) + if host.user and host.hostname then + return host.user .. "@" .. host.hostname + end + return host.hostname or host.user or "" +end + +local function parseSshConfig(content) + local entries = {} + local current = nil + + local function flushBlock() + if not current or #current.aliases == 0 then + return + end + local subtitle = hostSubtitle(current) + for _, alias in current.aliases do + table.insert(entries, { alias = alias, subtitle = subtitle }) + end + end + + for line in content:gmatch("[^\r\n]+") do + local trimmed = trim(line) + if trimmed ~= "" and not trimmed:match("^#") then + local key, value = trimmed:match("^(%S+)%s+(.+)$") + if key and value then + key = key:lower() + if key == "host" then + flushBlock() + current = { aliases = {}, hostname = nil, user = nil } + for alias in value:gmatch("%S+") do + if isValidHostAlias(alias) then + table.insert(current.aliases, alias) + end + end + elseif current then + if key == "hostname" then + current.hostname = trim(value) + elseif key == "user" then + current.user = trim(value) + end + end + end + end + end + + flushBlock() + return entries +end + +local function loadHosts() + local content = noctalia.readFile(getConfigPath()) + if type(content) ~= "string" or content == "" then + return nil + end + return parseSshConfig(content) +end + +local function launchTerminal(cmd) + local term = trim(noctalia.getConfig("terminal") or "") + if term == "" then + return noctalia.runInTerminal(cmd) + end + + local first = term:match("^%S+") or term + local bin = first:match("([^/]+)$") or first + local separator = (bin == "gnome-terminal" or bin == "kgx" or bin == "ptyxis") and "--" or "-e" + return noctalia.runAsync(term .. " " .. separator .. " sh -lc " .. shellQuote(cmd)) +end + +local function fuzzyScoreForHost(filter, host) + for _, text in { host.alias, host.subtitle } do + if text ~= "" then + local score = noctalia.fuzzyScore(filter, text) + if score then + return score + end + end + end +end + +local function makeRows(hosts, filter) + local rows = {} + local limit = getMaxResults() + + for _, host in hosts do + local score = filter == "" and nil or fuzzyScoreForHost(filter, host) + if filter == "" or score ~= nil then + table.insert(rows, { + id = host.alias, + title = host.alias, + subtitle = host.subtitle ~= "" and host.subtitle or nil, + glyph = "terminal", + score = score, + }) + end + end + + if filter == "" then + table.sort(rows, function(a, b) + return a.title:lower() < b.title:lower() + end) + while #rows > limit do + table.remove(rows) + end + end + + return rows +end + +local function showResults(query, hosts, filter) + local rows = makeRows(hosts, filter) + if #rows == 0 then + launcher.setResults(query, { + statusRow( + noctalia.tr("no-hosts-found"), + filter ~= "" and noctalia.tr("filter-empty", { filter = filter }) or noctalia.tr("config-empty"), + "terminal" + ), + }) + else + launcher.setResults(query, rows) + end +end + +function onQuery(query) + local filter = trim(query) + + if not noctalia.commandExists("ssh") then + launcher.setResults(query, { + statusRow(noctalia.tr("err-no-ssh"), noctalia.tr("err-no-ssh-subtitle"), "triangle-alert"), + }) + return + end + + if cachedHosts then + showResults(query, cachedHosts, filter) + return + end + + launcher.setResults(query, { + statusRow(noctalia.tr("loading"), noctalia.tr("loading-subtitle"), "loader"), + }) + + local hosts = loadHosts() + if not hosts then + launcher.setResults(query, { + statusRow(noctalia.tr("config-missing"), getConfigPath(), "file-x"), + }) + return + end + + cachedHosts = hosts + showResults(query, hosts, filter) +end + +function onActivate(id) + if id == "" then + return + end + + local extra = trim(noctalia.getConfig("extra_args") or "") + local cmd = "ssh " .. shellQuote(id) .. (extra ~= "" and (" " .. extra) or "") + + if not launchTerminal(cmd) then + noctalia.notifyError("SSH Launcher", noctalia.tr("err-terminal")) + end +end diff --git a/ssh-launcher/plugin.toml b/ssh-launcher/plugin.toml new file mode 100644 index 0000000..75849f4 --- /dev/null +++ b/ssh-launcher/plugin.toml @@ -0,0 +1,49 @@ +id = "cleboost/ssh-launcher" +name = "SSH Launcher" +version = "1.0.0" +plugin_api = 3 +author = "Cleboost" +license = "MIT" +icon = "terminal" +description = "Connect to SSH hosts from ~/.ssh/config via /ssh." +tags = ["launcher", "network", "utility", "development"] +dependencies = ["ssh"] + +[[launcher_provider]] +id = "launcher" +entry = "launcher.luau" +prefix = "ssh" +glyph = "terminal" +include_in_global_search = false +debounce_ms = 0 + +[[setting]] +key = "config_path" +type = "file" +label_key = "settings.config_path.label" +description_key = "settings.config_path.description" +default = "~/.ssh/config" + +[[setting]] +key = "max_results" +type = "int" +label_key = "settings.max_results.label" +description_key = "settings.max_results.description" +default = 30 +min = 1 +max = 100 + +[[setting]] +key = "extra_args" +type = "string" +label_key = "settings.extra_args.label" +description_key = "settings.extra_args.description" +default = "" + +[[setting]] +key = "terminal" +type = "string" +label_key = "settings.terminal.label" +description_key = "settings.terminal.description" +default = "" +advanced = true diff --git a/ssh-launcher/thumbnail.webp b/ssh-launcher/thumbnail.webp new file mode 100644 index 0000000..93b982d Binary files /dev/null and b/ssh-launcher/thumbnail.webp differ diff --git a/ssh-launcher/translations/en.json b/ssh-launcher/translations/en.json new file mode 100644 index 0000000..b733229 --- /dev/null +++ b/ssh-launcher/translations/en.json @@ -0,0 +1,29 @@ +{ + "config-empty": "No SSH hosts found in config", + "config-missing": "SSH config file not found", + "err-no-ssh": "ssh command not found", + "err-no-ssh-subtitle": "Install OpenSSH client and ensure ssh is on PATH", + "err-terminal": "Could not open a terminal", + "filter-empty": "Filter: \"{filter}\"", + "loading": "Loading…", + "loading-subtitle": "Reading SSH config", + "no-hosts-found": "No SSH hosts found", + "settings": { + "config_path": { + "description": "Path to your SSH config file.", + "label": "SSH config path" + }, + "extra_args": { + "description": "Extra arguments appended to every ssh command (for example -A).", + "label": "Extra SSH arguments" + }, + "max_results": { + "description": "Maximum number of hosts shown in the launcher.", + "label": "Maximum results" + }, + "terminal": { + "description": "Custom terminal command. Leave empty to use Noctalia's terminal discovery.", + "label": "Terminal command" + } + } +} diff --git a/ssh-launcher/translations/fr.json b/ssh-launcher/translations/fr.json new file mode 100644 index 0000000..d46bf67 --- /dev/null +++ b/ssh-launcher/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config-empty": "Aucun hôte SSH trouvé dans la config", + "config-missing": "Fichier de config SSH introuvable", + "err-no-ssh": "Commande ssh introuvable", + "err-no-ssh-subtitle": "Installez le client OpenSSH et vérifiez que ssh est dans le PATH", + "err-terminal": "Impossible d'ouvrir un terminal", + "filter-empty": "Filtre : « {filter} »", + "loading": "Chargement…", + "loading-subtitle": "Lecture de la config SSH", + "no-hosts-found": "Aucun hôte SSH trouvé", + "settings": { + "config_path": { + "description": "Chemin vers votre fichier de config SSH.", + "label": "Chemin de la config SSH" + }, + "extra_args": { + "description": "Arguments ajoutés à chaque commande ssh (par exemple -A).", + "label": "Arguments SSH supplémentaires" + }, + "max_results": { + "description": "Nombre maximum d'hôtes affichés dans le launcher.", + "label": "Nombre maximum de résultats" + }, + "terminal": { + "description": "Commande terminal personnalisée. Laissez vide pour la détection automatique de Noctalia.", + "label": "Commande terminal" + } + } +}