diff --git a/tailscale/README.md b/tailscale/README.md new file mode 100644 index 00000000..dfa1dac2 --- /dev/null +++ b/tailscale/README.md @@ -0,0 +1,109 @@ +# Tailscale + +Tailscale status and control for Noctalia, built around one core flow: **see +which hosts are online and copy their IP or name in one gesture** — from the +launcher or from the panel. Plus connect/disconnect, exit-node management, +per-host ping and SSH, Taildrop receive, and a desktop widget. A port of the +v4 `tailscale` plugin to the v5 Luau plugin API. + +> Community plugin built on top of the Tailscale CLI. Not affiliated with +> Tailscale Inc. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `rylos/tailscale` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `poller`; shortcut: `toggle`; launcher: `hosts`; desktop widget: `desktop` | +| Launcher Prefix | `/ts` | + +## Requirements + +| Command | Needed for | +| --- | --- | +| `tailscale` | Everything: status polling, up/down, exit nodes, ping, Taildrop receive. | +| `ssh` | The panel's per-peer SSH button (opens `ssh ` in your terminal). | +| `xdg-user-dir` | Resolving the default Taildrop download folder when `taildrop_dir` is empty (`xdg-utils`). | +| `gio` | Opening the daemon's login URL (`glib2`). | +| `xdg-open` | Fallback for opening the login URL when `gio` is unavailable (`xdg-utils`). | + +Only `tailscale` is required for the core flows; the others are used by the +corresponding optional actions. Install `tailscale` and authenticate the node. +For Taildrop receive without a root prompt, grant your user operator rights +once: + +```sh +sudo tailscale set --operator=$USER +``` + +## Usage + +- **Launcher** (`/ts`): type `/ts ` to fuzzy-search the tailnet by host + name, IP, or OS; activating a result **copies the host's Tailscale IP** to + the clipboard. Type `/ts n ` to copy the host **name** (the Tailscale + short name, the form `tailscale ssh`/`file cp` expect) instead. Online hosts + rank first. +- **Bar widget** (`bar`): add it from the Add-widget picker. Tailscale logo + with state badge, online/total peer count, and optionally this node's IP. + Left click opens the panel, right click toggles `tailscale up`/`down`. +- **Panel** (`panel`): connect/disconnect, Taildrop receive, and refresh in + the header; this node's row with copy buttons; a live filter box; then the + peer list sorted online-first with status dot, OS icon, last-seen time for + offline peers, and per-row buttons: **copy IP**, **copy name**, **ping**, + **SSH in terminal**, and **use as exit node** (on peers that advertise it). + When an exit node is active a banner shows it with a disable button. + + ```sh + noctalia msg panel-toggle rylos/tailscale:panel + ``` + +- **Shortcut** (`toggle`): add it from Settings → Control Center shortcuts. It + toggles the Tailscale connection up/down. +- **Desktop widget** (`desktop`): add it from the desktop-widgets editor. A + compact card with state, this node's IP, online/total peers, and the active + exit node. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `poll_interval` | `int` | `5` | Status refresh interval in seconds (1–60). | +| `hide_mullvad` | `bool` | `true` | Hide Mullvad exit nodes from the peer lists. | +| `hide_offline` | `bool` | `false` | Hide offline peers from the panel and launcher. | +| `taildrop_dir` | `folder` | `""` | Where Taildrop saves received files. Empty = the XDG download folder (advanced). | +| `ssh_username` | `string` | `""` | Username for the SSH button. Empty = system default (advanced). | +| `show_ip` | `bool` | `false` | Bar widget: show this node's Tailscale IP. | +| `show_count` | `bool` | `true` | Bar widget: show the online/total peer count. | + +## IPC + +```sh +noctalia msg plugin rylos/tailscale:poller all refresh # force a status refresh +noctalia msg plugin rylos/tailscale:poller all up # tailscale up +noctalia msg plugin rylos/tailscale:poller all down # tailscale down +noctalia msg panel-toggle rylos/tailscale:panel +``` + +## Notes + +- **Processes**: the service polls `tailscale status --json`; actions spawn + `tailscale up|down`, `tailscale set --exit-node=…`, `tailscale ping -c 3 + `, `tailscale file get `, and `xdg-user-dir DOWNLOAD` (to resolve + the default Taildrop folder). The login button opens the daemon's auth URL + with `gio open` (fallback `xdg-open`); the SSH button runs `ssh ` in + your terminal via the shell's run-in-terminal facility — the peer's Tailscale + IP, not its name, so it works whether or not MagicDNS is enabled. There is no direct + network access from the plugin itself — everything goes through the + Tailscale CLI. +- **Filesystem**: Taildrop receive lists the download directory before and + after `tailscale file get` to report exactly which files arrived. Nothing + else is written. +- **Privacy**: host names, IPs, and last-seen times come from your own + tailnet via the local CLI and are only shown/copied locally. +- Not ported from v4: Taildrop **send** (the plugin API has no file picker) + and multi-account switching. + +## Credits + +Based on the v4 [tailscale](https://github.com/noctalia-dev/legacy-v4-plugins/tree/main/tailscale) +plugin. MIT license. diff --git a/tailscale/bar.luau b/tailscale/bar.luau new file mode 100644 index 00000000..2433a338 --- /dev/null +++ b/tailscale/bar.luau @@ -0,0 +1,110 @@ +--!nonstrict +-- Tailscale bar widget — the [[widget]] entry. +-- +-- State-colored glyph, plus (optionally) the online peer count and this +-- node's Tailscale IP. Left click opens the panel, right click toggles the +-- connection up/down. + +local snapshot = nil +local cmdNonce = 0 + +-- Real Tailscale logo, shipped with the plugin: light fill for dark themes, +-- dark fill for light themes. Problem states add a small colored glyph overlay +-- since the image itself cannot be tinted. +local function logo(state) + return ui.image({ + path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg", + width = 16, + height = 16, + fit = "contain", + opacity = state == "running" and 1 or 0.45, + }) +end + +local BADGES = { + ["needs-login"] = { glyph = "lock-exclamation", color = "error" }, + ["error"] = { glyph = "alert-triangle", color = "error" }, + ["not-installed"] = { glyph = "x", color = "outline" }, +} + +local COLORS = { + ["running"] = "primary", + ["stopped"] = "outline", + ["needs-login"] = "error", + ["not-installed"] = "outline", + ["error"] = "error", +} + +local function sendCmd(action) + cmdNonce += 1 + noctalia.state.set("ts.cmd", noctalia.json.encode({ n = cmdNonce, action = action })) +end + +local function stateOf() + return snapshot and snapshot.state or "stopped" +end + +local function render() + local state = stateOf() + local color = COLORS[state] or "outline" + local children = { logo(state) } + local badge = BADGES[state] + if badge then + table.insert(children, ui.glyph({ name = badge.glyph, size = 11, color = badge.color })) + end + + if state == "running" and snapshot and not barWidget.isVertical() then + if noctalia.getConfig("show_count") ~= false then + table.insert(children, ui.label({ + text = `{snapshot.online}/{snapshot.total}`, + fontWeight = "bold", + color = color, + })) + end + if noctalia.getConfig("show_ip") == true and snapshot.self.ip ~= "" then + table.insert(children, ui.label({ text = snapshot.self.ip, color = "on_surface/0.7" })) + end + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) + + local rows = { { key = noctalia.tr("tooltip.state"), value = noctalia.tr(`state.{state}`) } } + if snapshot and state == "running" then + table.insert(rows, { key = noctalia.tr("tooltip.self"), value = `{snapshot.self.name} ({snapshot.self.ip})` }) + table.insert(rows, { key = noctalia.tr("tooltip.online"), value = `{snapshot.online}/{snapshot.total}` }) + if snapshot.exitNode then + table.insert(rows, { key = noctalia.tr("tooltip.exit-node"), value = snapshot.exitNode.name }) + end + end + barWidget.setTooltip(rows) +end + +noctalia.state.watch("ts.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function update() + noctalia.setUpdateInterval(2000) + render() +end + +function onClick() + noctalia.togglePanel("rylos/tailscale:panel") +end + +function onRightClick() + local state = stateOf() + if state == "running" then + sendCmd("down") + elseif state == "stopped" then + sendCmd("up") + end +end + +function onIpc(event) + if event == "refresh" then + sendCmd("refresh") + end +end diff --git a/tailscale/desktop.luau b/tailscale/desktop.luau new file mode 100644 index 00000000..f3ebce69 --- /dev/null +++ b/tailscale/desktop.luau @@ -0,0 +1,60 @@ +--!nonstrict +-- Tailscale desktop widget — a compact status card pinned to the desktop. +-- +-- Logo + state, this node's IP, online/total peers, and the active exit +-- node. Pure display; open the panel for actions. + +local snapshot = nil + +local function render() + local state = snapshot and snapshot.state or "stopped" + local running = state == "running" + local color = running and "primary" or (state == "stopped" and "outline" or "error") + + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.image({ + path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg", + width = 18, + height = 18, + fit = "contain", + opacity = running and 1 or 0.5, + }), + ui.label({ text = "Tailscale", fontSize = 14, fontWeight = "bold", flexGrow = 1 }), + ui.label({ text = noctalia.tr(`state.{state}`), fontSize = 11, color = color }), + }), + } + + if running and snapshot then + table.insert(children, ui.row({ gap = 6, align = "center" }, { + ui.label({ text = snapshot.self.name, fontSize = 12, fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ text = snapshot.self.ip, fontSize = 11, color = "on_surface/0.65" }), + })) + table.insert(children, ui.row({ gap = 6, align = "center" }, { + ui.label({ + text = `{noctalia.tr("tooltip.online")}: {snapshot.online}/{snapshot.total}`, + fontSize = 11, + color = "on_surface/0.7", + flexGrow = 1, + }), + ui.label({ + text = snapshot.exitNode and `⇢ {snapshot.exitNode.name}` or "", + fontSize = 11, + color = "secondary", + visible = snapshot.exitNode ~= nil, + }), + })) + end + + desktopWidget.render(ui.column({ gap = 6, padding = 10, fill = "surface_variant/0.4", radius = 12 }, children)) +end + +noctalia.state.watch("ts.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function update() + noctalia.setUpdateInterval(5000) + render() +end diff --git a/tailscale/icon-light.svg b/tailscale/icon-light.svg new file mode 100644 index 00000000..ca52e72c --- /dev/null +++ b/tailscale/icon-light.svg @@ -0,0 +1 @@ + diff --git a/tailscale/icon.svg b/tailscale/icon.svg new file mode 100644 index 00000000..1f79ff68 --- /dev/null +++ b/tailscale/icon.svg @@ -0,0 +1 @@ + diff --git a/tailscale/launcher.luau b/tailscale/launcher.luau new file mode 100644 index 00000000..961039dd --- /dev/null +++ b/tailscale/launcher.luau @@ -0,0 +1,93 @@ +--!nonstrict +-- Tailscale launcher provider — type `/ts` in the launcher. +-- +-- The fast path for "give me that host's IP": fuzzy-search the tailnet by +-- host name / IP / OS, Enter copies the peer's Tailscale IP. Prefix the query +-- with "n " (e.g. `/ts n pc-c`) to copy the host name instead. + +local function decodeSnapshot() + local encoded = noctalia.state.get("ts.snapshot") + return encoded and noctalia.json.decode(encoded) or nil +end + +local OS_GLYPHS = { + linux = "device-desktop", + windows = "device-desktop", + macos = "device-laptop", + ios = "device-mobile", + android = "device-mobile", +} + +local function entries(snapshot) + local list = {} + table.insert(list, { + name = snapshot.self.name, + shortName = snapshot.self.dnsName, + ip = snapshot.self.ip, + online = true, + os = "", + isSelf = true, + }) + for _, peer in snapshot.peers do + table.insert(list, peer) + end + return list +end + +function onQuery(text) + local snapshot = decodeSnapshot() + if not snapshot or snapshot.state ~= "running" then + launcher.setResults(text, { + { + id = "noop", + title = noctalia.tr(`state.{snapshot and snapshot.state or "stopped"}`), + glyph = "topology-star-3", + }, + }) + return + end + + local copyName = false + local query = noctalia.string.trim(text) + local rest = query:match("^[nN]%s+(.*)$") + if rest or query == "n" or query == "N" then + copyName = true + query = rest or "" + end + + local results = {} + for _, host in entries(snapshot) do + local haystack = `{host.name} {host.ip} {host.os}` + local score = 0 + if query ~= "" then + score = noctalia.fuzzyScore(query, haystack) + if not score then + continue + end + end + local target = copyName and host.shortName or host.ip + -- The state goes in the subtitle, not in `badge`: a badge is drawn in the + -- icon slot and scaled up, so a whole word there would hide the OS glyph. + local action = copyName and noctalia.tr("launcher.copy-name", { value = host.shortName }) + or noctalia.tr("launcher.copy-ip", { value = host.ip }) + local status = host.online and noctalia.tr("launcher.online") or noctalia.tr("launcher.offline") + table.insert(results, { + id = `{copyName and "name" or "ip"}|{target}|{host.name}`, + title = host.isSelf and `{host.name} ({noctalia.tr("launcher.this-host")})` or host.name, + subtitle = `{status} · {action}`, + glyph = OS_GLYPHS[host.os:lower()] or "device-desktop", + score = score + (host.online and 1 or 0), + }) + end + launcher.setResults(text, results) +end + +function onActivate(id) + local kind, value, name = id:match("^(%w+)|([^|]*)|(.*)$") + if not kind or value == "" then + return + end + noctalia.copyToClipboard(value, "text/plain") + noctalia.notify(noctalia.tr("toast.title"), + noctalia.tr(kind == "name" and "toast.copied-name" or "toast.copied-ip", { host = name, value = value })) +end diff --git a/tailscale/panel.luau b/tailscale/panel.luau new file mode 100644 index 00000000..5379a957 --- /dev/null +++ b/tailscale/panel.luau @@ -0,0 +1,570 @@ +--!nonstrict +-- Tailscale panel — the [[panel]] entry. +-- +-- Header with connection state + up/down and refresh buttons, this node's +-- name/IP with copy buttons, a filter input, then the peer list (online +-- first) with per-row copy-IP / copy-name buttons and an exit-node toggle on +-- peers that offer it. +-- +-- Per-row buttons use the predeclared-slot pattern (onIp0…, onName0…, +-- onExit0…): ui.* callbacks are global function names, so each row binds its +-- peer into a slot table the numbered globals read back. + +local MAX_SLOTS = 24 +-- Must match height = … in the [[panel]] entry of plugin.toml: the root +-- column's height is what actually sizes the panel content. +local PANEL_HEIGHT = 540 + +local snapshot = nil +local isOpen = false +local cmdNonce = 0 +local filterText = "" +local slots = {} + +local OS_GLYPHS = { + linux = "device-desktop", + windows = "device-desktop", + macos = "device-laptop", + ios = "device-mobile", + android = "device-mobile", +} + +-- The runtime only takes a command *line*, so every dynamic argument has to be +-- shell-quoted by hand: wrap in single quotes and escape embedded ones. +local function shq(value) + local quoted = tostring(value or ""):gsub("'", "'\\''") + return `'{quoted}'` +end + +local function tr(key, subst) + return noctalia.tr(key, subst) +end + +local function sendCmd(action, extra) + cmdNonce += 1 + local cmd = { n = cmdNonce, action = action } + if extra then + for k, v in extra do + cmd[k] = v + end + end + noctalia.state.set("ts.cmd", noctalia.json.encode(cmd)) +end + +local function copyValue(value, name, isName) + if value == "" then + return + end + noctalia.copyToClipboard(value, "text/plain") + noctalia.notify(tr("toast.title"), + tr(isName and "toast.copied-name" or "toast.copied-ip", { host = name, value = value })) +end + +local function stateOf() + return snapshot and snapshot.state or "stopped" +end + +-- ── Sections ───────────────────────────────────────────────────────────────── + +local function header() + local state = stateOf() + local running = state == "running" + local stateColor = running and "primary" or (state == "stopped" and "outline" or "error") + return ui.row({ gap = 8, align = "center" }, { + ui.image({ + path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg", + width = 20, + height = 20, + fit = "contain", + }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = tr("panel.title"), fontSize = 16, fontWeight = "bold" }), + ui.label({ text = tr(`state.{state}`), fontSize = 12, color = stateColor }), + }), + ui.button({ + glyph = "download", + variant = "ghost", + controlSize = "sm", + enabled = running, + tooltip = tr("panel.taildrop"), + onClick = "onTaildrop", + }), + ui.button({ + glyph = running and "plug-connected-x" or "plug-connected", + variant = running and "outline" or "primary", + controlSize = "sm", + enabled = state == "running" or state == "stopped", + tooltip = running and tr("panel.disconnect") or tr("panel.connect"), + onClick = running and "onDown" or "onUp", + }), + ui.button({ + glyph = "refresh", + variant = "outline", + controlSize = "sm", + tooltip = tr("panel.refresh"), + onClick = "onRefresh", + }), + }) +end + +local function selfRow() + if not snapshot or stateOf() ~= "running" then + return nil + end + return ui.row({ gap = 8, padding = 8, align = "center", fill = "primary/0.12", radius = 8 }, { + ui.glyph({ name = "home", size = 14, color = "primary" }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = snapshot.self.name, fontWeight = "bold", fontSize = 13, maxLines = 1 }), + ui.label({ text = snapshot.self.ip, fontSize = 11, color = "on_surface/0.65", maxLines = 1 }), + }), + ui.button({ + glyph = "copy", + variant = "ghost", + controlSize = "sm", + tooltip = tr("panel.copy-ip"), + onClick = "onCopySelfIp", + }), + ui.button({ + glyph = "tag", + variant = "ghost", + controlSize = "sm", + tooltip = tr("panel.copy-name"), + onClick = "onCopySelfName", + }), + }) +end + +local function exitNodeRow() + if not snapshot or not snapshot.exitNode then + return nil + end + return ui.row({ gap = 8, padding = 8, align = "center", fill = "secondary/0.15", radius = 8 }, { + ui.glyph({ name = "world", size = 14, color = "secondary" }), + ui.label({ + text = tr("panel.exit-node-active", { name = snapshot.exitNode.name }), + fontSize = 12, + flexGrow = 1, + maxLines = 1, + }), + ui.button({ + text = tr("panel.exit-node-clear"), + variant = "outline", + controlSize = "sm", + onClick = "onExitClear", + }), + }) +end + +local function matchesFilter(peer) + if filterText == "" then + return true + end + local needle = filterText:lower() + local haystack = (`{peer.name} {peer.ip} {peer.os}`):lower() + return haystack:find(needle, 1, true) ~= nil +end + +local function peerRow(peer, slot) + slots[slot] = peer + local dotColor = peer.online and "primary" or "outline" + local buttons = { + ui.button({ + glyph = "copy", + variant = "ghost", + controlSize = "sm", + enabled = peer.ip ~= "", + tooltip = tr("panel.copy-ip-value", { value = peer.ip }), + onClick = "onIp" .. slot, + }), + ui.button({ + glyph = "tag", + variant = "ghost", + controlSize = "sm", + enabled = peer.shortName ~= "", + tooltip = tr("panel.copy-name-value", { value = peer.shortName }), + onClick = "onName" .. slot, + }), + ui.button({ + glyph = "activity", + variant = "ghost", + controlSize = "sm", + enabled = peer.online and peer.ip ~= "", + tooltip = tr("panel.ping"), + onClick = "onPing" .. slot, + }), + ui.button({ + glyph = "terminal-2", + variant = "ghost", + controlSize = "sm", + enabled = peer.online and peer.shortName ~= "", + tooltip = tr("panel.ssh"), + onClick = "onSsh" .. slot, + }), + } + if peer.exitNodeOption then + table.insert(buttons, ui.button({ + glyph = "world", + variant = "ghost", + controlSize = "sm", + selected = peer.exitNode, + enabled = peer.online or peer.exitNode, + tooltip = peer.exitNode and tr("panel.exit-node-clear") or tr("panel.exit-node-use"), + onClick = "onExit" .. slot, + })) + end + + local children = { + ui.box({ width = 8, height = 8, radius = 4, fill = dotColor }), + ui.glyph({ name = OS_GLYPHS[peer.os:lower()] or "device-desktop", size = 14, color = "on_surface/0.7" }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = peer.name, fontWeight = "bold", fontSize = 13, maxLines = 1 }), + ui.label({ + text = (function() + local base = peer.ip ~= "" and peer.ip or (peer.online and tr("launcher.online") or tr("launcher.offline")) + if not peer.online and peer.lastSeen and peer.lastSeen ~= "" then + return `{base} · {tr("peer.last-seen", { value = peer.lastSeen })}` + end + return base + end)(), + fontSize = 11, + color = "on_surface/0.65", + maxLines = 1, + }), + }), + } + for _, b in buttons do + table.insert(children, b) + end + + return ui.row({ + key = `peer-{peer.shortName ~= "" and peer.shortName or peer.name}`, + gap = 8, + padding = 8, + align = "center", + fill = "surface_variant/0.5", + radius = 8, + opacity = peer.online and 1 or 0.6, + }, children) +end + +local function peerList() + local rows = {} + if snapshot and stateOf() == "running" then + local slot = 0 + for _, peer in snapshot.peers do + if slot >= MAX_SLOTS then + break + end + if matchesFilter(peer) then + table.insert(rows, peerRow(peer, slot)) + slot += 1 + end + end + end + if #rows == 0 then + table.insert(rows, ui.label({ text = tr("panel.no-peers"), fontSize = 12, color = "on_surface/0.6" })) + end + return ui.scroll({ gap = 6, flexGrow = 1 }, rows) +end + +local function needsLoginBox() + if stateOf() ~= "needs-login" then + return nil + end + return ui.column({ gap = 8, padding = 10, fill = "error/0.12", radius = 8 }, { + ui.label({ text = tr("panel.needs-login"), fontSize = 12, maxLines = 3 }), + ui.button({ + text = tr("panel.login"), + variant = "primary", + controlSize = "sm", + enabled = snapshot ~= nil and snapshot.authUrl ~= "", + onClick = "onLogin", + }), + }) +end + +local function render() + if not isOpen then + return + end + slots = {} + local children = { header() } + local login = needsLoginBox() + if login then + table.insert(children, login) + end + local selfR = selfRow() + if selfR then + table.insert(children, selfR) + end + local exitR = exitNodeRow() + if exitR then + table.insert(children, exitR) + end + if snapshot and stateOf() == "running" then + table.insert(children, ui.row({ gap = 6, align = "center" }, { + ui.input({ + key = "filter", + value = filterText, + placeholder = tr("panel.filter", { online = snapshot.online, total = snapshot.total }), + controlSize = "sm", + onChange = "onFilter", + flexGrow = 1, + }), + })) + end + table.insert(children, peerList()) + if snapshot then + table.insert(children, ui.label({ + text = tr("panel.last-check-info", { value = snapshot.checkedAt }), + fontSize = 11, + color = "on_surface/0.55", + })) + end + -- height pins the root to the [[panel]] size so the scroll area actually + -- scrolls instead of growing past the panel background + panel.render(ui.column({ gap = 10, padding = 14, height = PANEL_HEIGHT }, children)) +end + +-- ── Lifecycle & callbacks ──────────────────────────────────────────────────── + +noctalia.state.watch("ts.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function onOpen(_context) + isOpen = true + local encoded = noctalia.state.get("ts.snapshot") + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() + sendCmd("refresh") +end + +function onClose() + isOpen = false +end + +function onFilter(value) + filterText = noctalia.string.trim(value) + render() +end + +function onRefresh() + sendCmd("refresh") +end + +function onUp() + sendCmd("up") +end + +function onDown() + sendCmd("down") +end + +function onLogin() + if snapshot and snapshot.authUrl ~= "" then + sendCmd("login", { url = snapshot.authUrl }) + end +end + +function onExitClear() + sendCmd("exit_clear") +end + +function onTaildrop() + sendCmd("taildrop_get") +end + +function onCopySelfIp() + if snapshot then + copyValue(snapshot.self.ip, snapshot.self.name, false) + end +end + +function onCopySelfName() + if snapshot then + copyValue(snapshot.self.dnsName, snapshot.self.name, true) + end +end + +local function ipAt(slot) + local peer = slots[slot] + if peer then + copyValue(peer.ip, peer.name, false) + end +end + +local function nameAt(slot) + local peer = slots[slot] + if peer then + copyValue(peer.shortName, peer.name, true) + end +end + +local function exitAt(slot) + local peer = slots[slot] + if not peer then + return + end + if peer.exitNode then + sendCmd("exit_clear") + elseif peer.ip ~= "" then + sendCmd("exit_set", { ip = peer.ip }) + end +end + +local function pingAt(slot) + local peer = slots[slot] + if peer and peer.ip ~= "" then + sendCmd("ping", { ip = peer.ip, name = peer.name }) + end +end + +local function sshAt(slot) + local peer = slots[slot] + if not peer then + return + end + -- The Tailscale IP always works; the short name and the MagicDNS FQDN only + -- resolve when MagicDNS is enabled, so the IP is the target and the name is + -- just a fallback. + local host = peer.ip ~= "" and peer.ip or peer.shortName + if host == "" then + return + end + local user = noctalia.string.trim(noctalia.getConfig("ssh_username") or "") + -- Only a plain login name is accepted; anything else is ignored rather than + -- interpolated into the command line. + if user ~= "" and not user:match("^[%w%._%-]+$") then + noctalia.notifyError(tr("toast.title"), tr("toast.invalid-username")) + return + end + local target = user ~= "" and `{user}@{host}` or host + noctalia.runInTerminal(`ssh {shq(target)}`) +end + +function onPing0() pingAt(0) end +function onPing1() pingAt(1) end +function onPing2() pingAt(2) end +function onPing3() pingAt(3) end +function onPing4() pingAt(4) end +function onPing5() pingAt(5) end +function onPing6() pingAt(6) end +function onPing7() pingAt(7) end +function onPing8() pingAt(8) end +function onPing9() pingAt(9) end +function onPing10() pingAt(10) end +function onPing11() pingAt(11) end +function onPing12() pingAt(12) end +function onPing13() pingAt(13) end +function onPing14() pingAt(14) end +function onPing15() pingAt(15) end +function onPing16() pingAt(16) end +function onPing17() pingAt(17) end +function onPing18() pingAt(18) end +function onPing19() pingAt(19) end +function onPing20() pingAt(20) end +function onPing21() pingAt(21) end +function onPing22() pingAt(22) end +function onPing23() pingAt(23) end + +function onSsh0() sshAt(0) end +function onSsh1() sshAt(1) end +function onSsh2() sshAt(2) end +function onSsh3() sshAt(3) end +function onSsh4() sshAt(4) end +function onSsh5() sshAt(5) end +function onSsh6() sshAt(6) end +function onSsh7() sshAt(7) end +function onSsh8() sshAt(8) end +function onSsh9() sshAt(9) end +function onSsh10() sshAt(10) end +function onSsh11() sshAt(11) end +function onSsh12() sshAt(12) end +function onSsh13() sshAt(13) end +function onSsh14() sshAt(14) end +function onSsh15() sshAt(15) end +function onSsh16() sshAt(16) end +function onSsh17() sshAt(17) end +function onSsh18() sshAt(18) end +function onSsh19() sshAt(19) end +function onSsh20() sshAt(20) end +function onSsh21() sshAt(21) end +function onSsh22() sshAt(22) end +function onSsh23() sshAt(23) end + +function onIp0() ipAt(0) end +function onIp1() ipAt(1) end +function onIp2() ipAt(2) end +function onIp3() ipAt(3) end +function onIp4() ipAt(4) end +function onIp5() ipAt(5) end +function onIp6() ipAt(6) end +function onIp7() ipAt(7) end +function onIp8() ipAt(8) end +function onIp9() ipAt(9) end +function onIp10() ipAt(10) end +function onIp11() ipAt(11) end +function onIp12() ipAt(12) end +function onIp13() ipAt(13) end +function onIp14() ipAt(14) end +function onIp15() ipAt(15) end +function onIp16() ipAt(16) end +function onIp17() ipAt(17) end +function onIp18() ipAt(18) end +function onIp19() ipAt(19) end +function onIp20() ipAt(20) end +function onIp21() ipAt(21) end +function onIp22() ipAt(22) end +function onIp23() ipAt(23) end + +function onName0() nameAt(0) end +function onName1() nameAt(1) end +function onName2() nameAt(2) end +function onName3() nameAt(3) end +function onName4() nameAt(4) end +function onName5() nameAt(5) end +function onName6() nameAt(6) end +function onName7() nameAt(7) end +function onName8() nameAt(8) end +function onName9() nameAt(9) end +function onName10() nameAt(10) end +function onName11() nameAt(11) end +function onName12() nameAt(12) end +function onName13() nameAt(13) end +function onName14() nameAt(14) end +function onName15() nameAt(15) end +function onName16() nameAt(16) end +function onName17() nameAt(17) end +function onName18() nameAt(18) end +function onName19() nameAt(19) end +function onName20() nameAt(20) end +function onName21() nameAt(21) end +function onName22() nameAt(22) end +function onName23() nameAt(23) end + +function onExit0() exitAt(0) end +function onExit1() exitAt(1) end +function onExit2() exitAt(2) end +function onExit3() exitAt(3) end +function onExit4() exitAt(4) end +function onExit5() exitAt(5) end +function onExit6() exitAt(6) end +function onExit7() exitAt(7) end +function onExit8() exitAt(8) end +function onExit9() exitAt(9) end +function onExit10() exitAt(10) end +function onExit11() exitAt(11) end +function onExit12() exitAt(12) end +function onExit13() exitAt(13) end +function onExit14() exitAt(14) end +function onExit15() exitAt(15) end +function onExit16() exitAt(16) end +function onExit17() exitAt(17) end +function onExit18() exitAt(18) end +function onExit19() exitAt(19) end +function onExit20() exitAt(20) end +function onExit21() exitAt(21) end +function onExit22() exitAt(22) end +function onExit23() exitAt(23) end diff --git a/tailscale/plugin.toml b/tailscale/plugin.toml new file mode 100644 index 00000000..b2181053 --- /dev/null +++ b/tailscale/plugin.toml @@ -0,0 +1,106 @@ +# Tailscale status & control for Noctalia v5. +# Port of the v4 tailscale plugin to the Luau plugin API. Core focus: a fast +# view of online/offline hosts and one-keystroke copy of a peer's IP or name +# (launcher provider /ts + per-row copy buttons in the panel). Also: up/down +# toggle and exit-node management. Taildrop and account switching from the v4 +# plugin are not ported (no file picker in the v5 plugin API). + +id = "rylos/tailscale" +name = "Tailscale" +version = "2.1.0" +plugin_api = 3 +author = "Marco Ziliani" +license = "MIT" +dependencies = ["tailscale", "ssh", "xdg-user-dir", "gio", "xdg-open"] +tags = ["bar", "panel", "launcher", "service", "network", "privacy", "utility"] +icon = "topology-star-3" +description = "Tailscale status and control: online/offline peers, quick IP/name copy, launcher search, exit nodes, Taildrop." + +# ── Plugin-level settings ──────────────────────────────────────────────────── + +[[setting]] +key = "poll_interval" +type = "int" +label_key = "settings.poll_interval.label" +description_key = "settings.poll_interval.description" +default = 5 +min = 1 +max = 60 + +[[setting]] +key = "hide_mullvad" +type = "bool" +label_key = "settings.hide_mullvad.label" +description_key = "settings.hide_mullvad.description" +default = true + +[[setting]] +key = "hide_offline" +type = "bool" +label_key = "settings.hide_offline.label" +description_key = "settings.hide_offline.description" +default = false + +[[setting]] +key = "taildrop_dir" +type = "folder" +label_key = "settings.taildrop_dir.label" +description_key = "settings.taildrop_dir.description" +default = "" +advanced = true + +[[setting]] +key = "ssh_username" +type = "string" +label_key = "settings.ssh_username.label" +description_key = "settings.ssh_username.description" +default = "" +advanced = true + +# ── Entries ────────────────────────────────────────────────────────────────── + +[[service]] +id = "poller" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + + [[widget.setting]] + key = "show_ip" + type = "bool" + label_key = "settings.show_ip.label" + description_key = "settings.show_ip.description" + default = false + + [[widget.setting]] + key = "show_count" + type = "bool" + label_key = "settings.show_count.label" + description_key = "settings.show_count.description" + default = true + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 420 +height = 540 +placement = "attached" +position = "auto" +open_near_click = true + +[[launcher_provider]] +id = "hosts" +entry = "launcher.luau" +prefix = "ts" +glyph = "topology-star-3" +include_in_global_search = false + +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" + +[[desktop_widget]] +id = "desktop" +entry = "desktop.luau" diff --git a/tailscale/service.luau b/tailscale/service.luau new file mode 100644 index 00000000..99de24f3 --- /dev/null +++ b/tailscale/service.luau @@ -0,0 +1,338 @@ +--!nonstrict +-- Tailscale poller — the headless [[service]] entry. +-- +-- Polls `tailscale status --json`, normalizes the result into a compact +-- snapshot (self + sorted peer list with online state), and publishes it +-- JSON-encoded to noctalia.state under "ts.snapshot". Executes commands sent +-- by the UI entries through "ts.cmd": refresh, up, down, exit-node set/clear. + +local busy = false +local pendingPoll = false +local installed = nil -- nil = unknown, checked on first poll + +local function trim(s) + return noctalia.string.trim(s or "") +end + +-- The runtime only takes a command *line*, so every dynamic argument has to be +-- shell-quoted by hand: wrap in single quotes and escape embedded ones. +local function shq(value) + local quoted = tostring(value or ""):gsub("'", "'\\''") + return `'{quoted}'` +end + +-- Tailscale short name: first label of the DNS name ("pc-work.tail1234.ts.net." → "pc-work"). +-- This is what tailscale ssh / file cp expect as a target. +local function shortName(dnsName) + return trim(dnsName):match("^([^%.]+)") or "" +end + +-- Some devices (e.g. Android) report "localhost" as HostName; fall back to the +-- DNS short name in that case. +local function displayName(hostName, dnsName) + hostName = trim(hostName) + if hostName ~= "" and hostName:lower() ~= "localhost" then + return hostName + end + local label = shortName(dnsName) + return label ~= "" and label or hostName +end + +local function firstIPv4(ips) + for _, ip in (ips or {}) do + if ip:match("^100%.") then + return ip + end + end + return (ips or {})[1] or "" +end + +local function emptySnapshot(state, detail) + return { + state = state, -- running | stopped | needs-login | not-installed | error + detail = detail or "", + checkedAt = noctalia.formatTime("%H:%M:%S"), + authUrl = "", + self = { name = "", ip = "", dnsName = "" }, + peers = {}, + online = 0, + total = 0, + exitNode = nil, + } +end + +local function publish(snapshot) + busy = false + local encoded = noctalia.json.encode(snapshot) + if encoded then + noctalia.state.set("ts.snapshot", encoded) + end + if pendingPoll then + pendingPoll = false + poll() + end +end + +local function buildSnapshot(data) + local snapshot = emptySnapshot("running") + local backend = data.BackendState or "" + + if backend == "NeedsLogin" then + snapshot.state = "needs-login" + snapshot.authUrl = trim(data.AuthURL) + return snapshot + end + if backend ~= "Running" then + snapshot.state = "stopped" + return snapshot + end + + local selfNode = data.Self or {} + snapshot.self = { + name = displayName(selfNode.HostName, selfNode.DNSName), + ip = firstIPv4(selfNode.TailscaleIPs), + dnsName = shortName(selfNode.DNSName), + } + + local hideMullvad = noctalia.getConfig("hide_mullvad") ~= false + local hideOffline = noctalia.getConfig("hide_offline") == true + + for _, peer in (data.Peer or {}) do + local dns = trim(peer.DNSName) + if hideMullvad and dns:find(".mullvad.ts.net", 1, true) then + continue + end + local online = peer.Online == true + if hideOffline and not online then + continue + end + -- Last-seen timestamp for offline peers (skip Go zero dates). + local lastSeen = trim(peer.LastSeen) + if lastSeen ~= "" and lastSeen:sub(1, 4) ~= "0001" then + lastSeen = lastSeen:sub(1, 16):gsub("T", " ") + else + lastSeen = "" + end + table.insert(snapshot.peers, { + name = displayName(peer.HostName, dns), + shortName = shortName(dns), + ip = firstIPv4(peer.TailscaleIPs), + online = online, + os = trim(peer.OS), + lastSeen = lastSeen, + exitNodeOption = peer.ExitNodeOption == true, + exitNode = peer.ExitNode == true, + }) + snapshot.total += 1 + if online then + snapshot.online += 1 + end + end + + -- Two nodes can share a HostName (e.g. two "NAS"); fall back to the unique + -- Tailscale short name for duplicates so the lists stay unambiguous. + local nameCount = {} + for _, peer in snapshot.peers do + nameCount[peer.name] = (nameCount[peer.name] or 0) + 1 + end + for _, peer in snapshot.peers do + if nameCount[peer.name] > 1 and peer.shortName ~= "" then + peer.name = peer.shortName + end + end + + -- Online hosts first, then alphabetical. + table.sort(snapshot.peers, function(a, b) + if a.online ~= b.online then + return a.online + end + return a.name:lower() < b.name:lower() + end) + + local exitStatus = data.ExitNodeStatus + if exitStatus then + local exitIp = firstIPv4(exitStatus.TailscaleIPs) + local exitName = exitIp + for _, peer in snapshot.peers do + if peer.exitNode then + exitName = peer.name + break + end + end + snapshot.exitNode = { name = exitName, ip = exitIp, online = exitStatus.Online == true } + end + + return snapshot +end + +function poll() + if busy then + pendingPoll = true + return + end + busy = true + + if installed == nil then + installed = noctalia.commandExists("tailscale") + end + if not installed then + publish(emptySnapshot("not-installed")) + return + end + + local launched = noctalia.runAsync("tailscale status --json", function(result) + if result.exitCode ~= 0 or result.timedOut then + publish(emptySnapshot("stopped", trim(result.stderr))) + return + end + if result.stdoutTruncated then + publish(emptySnapshot("error", "status output truncated")) + return + end + local data = noctalia.json.decode(result.stdout) + if not data then + publish(emptySnapshot("error", "invalid status JSON")) + return + end + publish(buildSnapshot(data)) + end, 10000) + if not launched then + publish(emptySnapshot("error", "failed to run tailscale")) + end +end + +-- ── Commands from UI entries ───────────────────────────────────────────────── + +local function runThenRefresh(cmdline, okKey, failKey) + noctalia.runAsync(cmdline, function(result) + if result.exitCode == 0 then + if okKey then + noctalia.notify(noctalia.tr("toast.title"), noctalia.tr(okKey)) + end + else + noctalia.notifyError(noctalia.tr("toast.title"), + trim(result.stderr) ~= "" and trim(result.stderr) or noctalia.tr(failKey or "toast.failed")) + end + poll() + end, 30000) +end + +-- Taildrop receive: snapshot the target dir, run `tailscale file get`, then +-- diff to list what actually arrived. Requires operator mode +-- (`sudo tailscale set --operator=$USER`) to run without root. +local function receiveTaildrop() + local dir = trim(noctalia.getConfig("taildrop_dir")) + + local function go(target) + local pre = {} + for _, f in (noctalia.listDir(target) or {}) do + pre[f] = true + end + noctalia.runAsync(`tailscale file get {shq(target)}`, function(result) + if result.exitCode ~= 0 then + local err = trim(result.stderr) + noctalia.notifyError(noctalia.tr("toast.title"), err ~= "" and err or noctalia.tr("taildrop.failed")) + return + end + local fresh = {} + for _, f in (noctalia.listDir(target) or {}) do + if not pre[f] then + table.insert(fresh, f) + end + end + if #fresh > 0 then + noctalia.notify(noctalia.tr("toast.title"), + `{noctalia.tr("taildrop.received", { count = #fresh, dir = target })}\n{table.concat(fresh, "\n")}`) + else + noctalia.notify(noctalia.tr("toast.title"), noctalia.tr("taildrop.none")) + end + end, 60000) + end + + if dir ~= "" then + go(noctalia.expandPath(dir)) + else + -- Default: the XDG download dir (locale-aware, e.g. ~/Scaricati). + noctalia.runAsync("xdg-user-dir DOWNLOAD", function(result) + local target = trim(result.stdout) + go(target ~= "" and target or noctalia.expandPath("~/Downloads")) + end, 5000) + end +end + +local function pingPeer(ip, name) + noctalia.runAsync(`tailscale ping -c 3 {shq(ip)}`, function(result) + local out = trim(result.stdout) + local err = trim(result.stderr) + -- A peer reachable only through a DERP relay still answers every ping, but + -- `tailscale ping` then prints "direct connection not established" as its + -- last line and exits non-zero. Report the last pong instead, and say the + -- connection is relayed rather than calling the ping a failure. + local pong = nil + for line in out:gmatch("[^\n]+") do + if line:match("^%s*pong") then + pong = trim(line) + end + end + if pong then + local combined = `{out}\n{err}` + local relayed = combined:find("direct connection not established", 1, true) + noctalia.notify(`Ping {name}`, relayed and `{pong}\n{noctalia.tr("ping.relayed")}` or pong) + else + noctalia.notifyError(`Ping {name}`, err ~= "" and err or noctalia.tr("toast.failed")) + end + end, 25000) +end + +noctalia.state.watch("ts.cmd", function(raw) + local cmd = raw and noctalia.json.decode(raw) or nil + if not cmd or not cmd.action then + return + end + + if cmd.action == "refresh" then + poll() + elseif cmd.action == "up" then + runThenRefresh("tailscale up", "toast.connected") + elseif cmd.action == "down" then + runThenRefresh("tailscale down", "toast.disconnected") + elseif cmd.action == "exit_set" and cmd.ip then + runThenRefresh(`tailscale set --exit-node={shq(cmd.ip)}`, "toast.exit-node-enabled") + elseif cmd.action == "exit_clear" then + runThenRefresh("tailscale set --exit-node=", "toast.exit-node-disabled") + elseif cmd.action == "taildrop_get" then + receiveTaildrop() + elseif cmd.action == "ping" and cmd.ip then + pingPeer(cmd.ip, cmd.name or cmd.ip) + elseif cmd.action == "login" then + -- Open the pending auth URL surfaced by the daemon (panel guards on it). + -- gio, not xdg-open: see the syncthing plugin — xdg-open no-ops with + -- XDG_CURRENT_DESKTOP=KDE outside a real KDE session. + if cmd.url and cmd.url:match("^https?://[%w%.%-%_%~%:%/%?%#%[%]%@%!%$%&%'%(%)%*%+%,%;%=%%]+$") then + local url = shq(cmd.url) + noctalia.runAsync(`gio open {url} || xdg-open {url}`) + end + else + noctalia.log(`[tailscale] unknown command: {cmd.action}`) + end +end) + +function onConfigChanged() + poll() +end + +function onIpc(event) + if event == "refresh" then + poll() + elseif event == "up" then + runThenRefresh("tailscale up", "toast.connected") + elseif event == "down" then + runThenRefresh("tailscale down", "toast.disconnected") + end +end + +function update() + local seconds = tonumber(noctalia.getConfig("poll_interval")) or 5 + noctalia.setUpdateInterval(math.clamp(seconds, 1, 60) * 1000) + poll() +end diff --git a/tailscale/shortcut.luau b/tailscale/shortcut.luau new file mode 100644 index 00000000..f463cef9 --- /dev/null +++ b/tailscale/shortcut.luau @@ -0,0 +1,30 @@ +--!nonstrict +-- Tailscale toggle — the [[shortcut]] control-center tile. +-- +-- Active while Tailscale is up; click to bring the connection down, click +-- again to bring it up. Mirrors the state published by the poller service. + +local snapshot = nil +local cmdNonce = 0 + +local function render() + local state = snapshot and snapshot.state or "stopped" + shortcut.setLabel(noctalia.tr("shortcut.label")) + shortcut.setIcon("topology-star-3", "topology-star-3") + shortcut.setActive(state == "running") + shortcut.setEnabled(state == "running" or state == "stopped") +end + +noctalia.state.watch("ts.snapshot", function(encoded) + snapshot = encoded and noctalia.json.decode(encoded) or nil + render() +end) + +function onClick() + local state = snapshot and snapshot.state or "stopped" + cmdNonce += 1 + local action = state == "running" and "down" or "up" + noctalia.state.set("ts.cmd", noctalia.json.encode({ n = cmdNonce, action = action })) +end + +render() diff --git a/tailscale/thumbnail.webp b/tailscale/thumbnail.webp new file mode 100644 index 00000000..d8f2f56a Binary files /dev/null and b/tailscale/thumbnail.webp differ diff --git a/tailscale/translations/en.json b/tailscale/translations/en.json new file mode 100644 index 00000000..ca838169 --- /dev/null +++ b/tailscale/translations/en.json @@ -0,0 +1,98 @@ +{ + "settings": { + "poll_interval": { + "label": "Poll interval (seconds)", + "description": "How often the Tailscale status is refreshed." + }, + "hide_mullvad": { + "label": "Hide Mullvad exit nodes", + "description": "Hide Mullvad VPN exit nodes from the peer list." + }, + "hide_offline": { + "label": "Hide offline peers", + "description": "Hide offline peers from the panel and launcher lists." + }, + "show_ip": { + "label": "Show own IP", + "description": "Show this node's Tailscale IP in the bar." + }, + "show_count": { + "label": "Show peer count", + "description": "Show online/total peers next to the icon." + }, + "taildrop_dir": { + "label": "Taildrop download folder", + "description": "Where received files are saved. Empty = the XDG download folder." + }, + "ssh_username": { + "label": "SSH username", + "description": "Username for the SSH button. Empty = system default." + } + }, + "state": { + "running": "Connected", + "stopped": "Disconnected", + "needs-login": "Login required", + "not-installed": "Tailscale not installed", + "error": "Error" + }, + "tooltip": { + "state": "State", + "self": "This node", + "online": "Peers online", + "exit-node": "Exit node" + }, + "panel": { + "title": "Tailscale", + "connect": "Connect (tailscale up)", + "disconnect": "Disconnect (tailscale down)", + "refresh": "Refresh", + "copy-ip": "Copy IP", + "copy-name": "Copy name", + "copy-ip-value": "Copy IP {value}", + "copy-name-value": "Copy name {value}", + "exit-node-active": "Exit node: {name}", + "exit-node-clear": "Disable", + "exit-node-use": "Use as exit node", + "filter": "Filter {online}/{total} hosts…", + "no-peers": "No peers to show.", + "needs-login": "Tailscale needs authentication. Open the login page in the browser.", + "login": "Log in", + "last-check-info": "Last check: {value}", + "taildrop": "Receive Taildrop files", + "ping": "Ping", + "ssh": "Open SSH in terminal" + }, + "launcher": { + "this-host": "this host", + "online": "online", + "offline": "offline", + "copy-ip": "Enter: copy {value}", + "copy-name": "Enter: copy {value}" + }, + "toast": { + "title": "Tailscale", + "connected": "Connected.", + "disconnected": "Disconnected.", + "exit-node-enabled": "Exit node enabled.", + "exit-node-disabled": "Exit node disabled.", + "failed": "Command failed.", + "copied-ip": "{host}: IP {value} copied.", + "copied-name": "{host}: name {value} copied.", + "invalid-username": "Invalid SSH username: use letters, digits, dots, dashes or underscores." + }, + "shortcut": { + "label": "Tailscale" + }, + "peer": { + "last-seen": "seen {value}" + }, + "taildrop": { + "received": "{count} file(s) received in {dir}:", + "none": "No pending files.", + "failed": "Taildrop receive failed (operator mode set?)." + }, + "ping": { + "relayed": "No direct connection — traffic is relayed through a DERP server." + } +} diff --git a/tailscale/translations/it.json b/tailscale/translations/it.json new file mode 100644 index 00000000..b3868bc2 --- /dev/null +++ b/tailscale/translations/it.json @@ -0,0 +1,98 @@ +{ + "settings": { + "poll_interval": { + "label": "Intervallo di aggiornamento (secondi)", + "description": "Frequenza di aggiornamento dello stato di Tailscale." + }, + "hide_mullvad": { + "label": "Nascondi exit node Mullvad", + "description": "Nasconde gli exit node Mullvad VPN dalla lista dei peer." + }, + "hide_offline": { + "label": "Nascondi peer offline", + "description": "Nasconde i peer offline dal pannello e dal launcher." + }, + "show_ip": { + "label": "Mostra IP proprio", + "description": "Mostra l'IP Tailscale di questo nodo nella barra." + }, + "show_count": { + "label": "Mostra conteggio peer", + "description": "Mostra peer online/totali accanto all'icona." + }, + "taildrop_dir": { + "label": "Cartella download Taildrop", + "description": "Dove salvare i file ricevuti. Vuoto = cartella download XDG." + }, + "ssh_username": { + "label": "Utente SSH", + "description": "Utente per il bottone SSH. Vuoto = default di sistema." + } + }, + "state": { + "running": "Connesso", + "stopped": "Disconnesso", + "needs-login": "Login richiesto", + "not-installed": "Tailscale non installato", + "error": "Errore" + }, + "tooltip": { + "state": "Stato", + "self": "Questo nodo", + "online": "Peer online", + "exit-node": "Exit node" + }, + "panel": { + "title": "Tailscale", + "connect": "Connetti (tailscale up)", + "disconnect": "Disconnetti (tailscale down)", + "refresh": "Aggiorna", + "copy-ip": "Copia IP", + "copy-name": "Copia nome", + "copy-ip-value": "Copia IP {value}", + "copy-name-value": "Copia nome {value}", + "exit-node-active": "Exit node: {name}", + "exit-node-clear": "Disattiva", + "exit-node-use": "Usa come exit node", + "filter": "Filtra {online}/{total} host…", + "no-peers": "Nessun peer da mostrare.", + "needs-login": "Tailscale richiede l'autenticazione. Apri la pagina di login nel browser.", + "login": "Accedi", + "last-check-info": "Ultimo controllo: {value}", + "taildrop": "Ricevi file Taildrop", + "ping": "Ping", + "ssh": "Apri SSH nel terminale" + }, + "launcher": { + "this-host": "questo host", + "online": "online", + "offline": "offline", + "copy-ip": "Invio: copia {value}", + "copy-name": "Invio: copia {value}" + }, + "toast": { + "title": "Tailscale", + "connected": "Connesso.", + "disconnected": "Disconnesso.", + "exit-node-enabled": "Exit node attivato.", + "exit-node-disabled": "Exit node disattivato.", + "failed": "Comando fallito.", + "copied-ip": "{host}: IP {value} copiato.", + "copied-name": "{host}: nome {value} copiato.", + "invalid-username": "Nome utente SSH non valido: usa lettere, cifre, punti, trattini o underscore." + }, + "shortcut": { + "label": "Tailscale" + }, + "peer": { + "last-seen": "visto {value}" + }, + "taildrop": { + "received": "{count} file ricevuti in {dir}:", + "none": "Nessun file in attesa.", + "failed": "Ricezione Taildrop fallita (operator mode impostato?)." + }, + "ping": { + "relayed": "Nessuna connessione diretta — il traffico passa da un relay DERP." + } +}