Files
community-plugins/tailscale/launcher.luau
T
Dave HammerandGitHub 1abe67bd4b Add davemhammer/tailscale (#307)
Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
2026-08-08 20:22:15 -04:00

416 lines
10 KiB
Luau

--!nonstrict
-- /ts launcher: Tailscale peers, exit nodes, connect/disconnect.
local STATE_KEY = "ts_snapshot"
local COMMAND_KEY = "ts_command"
local PANEL_ID = "davemhammer/tailscale:manager"
local MAX_ROWS = 40
local snapshot = noctalia.state.get(STATE_KEY) or {
available = false,
loading = true,
running = false,
installed = false,
peers = {},
exitNodes = {},
onlineCount = 0,
peerCount = 0,
backendState = "",
exitNode = "",
hostname = "",
error = "",
}
noctalia.state.watch(STATE_KEY, function(value)
if type(value) == "table" then
snapshot = value
end
end)
local function trim(s)
return noctalia.string.trim(tostring(s or ""))
end
local function lower(s)
return string.lower(tostring(s or ""))
end
local function send(action, values)
local command = { action = action, requestId = "launcher-" .. tostring(os.time()) }
if type(values) == "table" then
for k, v in pairs(values) do
command[k] = v
end
end
noctalia.state.set(COMMAND_KEY, command)
end
local function scoreText(filter, ...)
if filter == "" then
return 1
end
local best = nil
for i = 1, select("#", ...) do
local text = tostring(select(i, ...) or "")
if text ~= "" then
local s = noctalia.fuzzyScore(filter, text)
if s ~= nil and (best == nil or s > best) then
best = s
end
if best == nil and lower(text):find(lower(filter), 1, true) then
best = 0.5
end
end
end
return best
end
local function statusRow(title, subtitle, glyph)
return {
id = "",
title = title,
subtitle = subtitle,
glyph = glyph or "circles",
}
end
local function ensureSnapshot()
if snapshot.loading or not snapshot.available then
send("refresh")
end
end
local function topCategories()
local state = snapshot.backendState ~= "" and snapshot.backendState or "—"
local summary = `{state} · {snapshot.onlineCount or 0}/{snapshot.peerCount or 0} online`
if snapshot.exitNode and snapshot.exitNode ~= "" then
summary = summary .. " · exit " .. snapshot.exitNode
end
return {
{
id = "cat:peers",
title = noctalia.tr("launcher.cat.peers"),
subtitle = noctalia.tr("launcher.cat.peers-sub"),
glyph = "device-desktop",
score = 100,
},
{
id = "cat:exit",
title = noctalia.tr("launcher.cat.exit"),
subtitle = noctalia.tr("launcher.cat.exit-sub"),
glyph = "world",
score = 95,
},
{
id = "act:status",
title = noctalia.tr("launcher.cat.status"),
subtitle = summary,
glyph = "info-circle",
score = 90,
},
{
id = "act:up",
title = noctalia.tr("launcher.cat.up"),
subtitle = noctalia.tr("launcher.cat.up-sub"),
glyph = "player-play",
score = 85,
},
{
id = "act:down",
title = noctalia.tr("launcher.cat.down"),
subtitle = noctalia.tr("launcher.cat.down-sub"),
glyph = "player-stop",
score = 80,
},
{
id = "act:panel",
title = noctalia.tr("launcher.cat.panel"),
subtitle = noctalia.tr("launcher.cat.panel-sub"),
glyph = "layout-dashboard",
score = 70,
},
{
id = "act:admin",
title = noctalia.tr("launcher.cat.admin"),
subtitle = noctalia.tr("launcher.cat.admin-sub"),
glyph = "external-link",
score = 60,
},
{
id = "act:refresh",
title = noctalia.tr("launcher.cat.refresh"),
subtitle = noctalia.tr("launcher.cat.refresh-sub"),
glyph = "refresh",
score = 50,
},
}
end
local function peerActions(peer)
local ref = peer.id
return {
{
id = "peeract:ping:" .. ref,
title = noctalia.tr("launcher.action.ping"),
subtitle = peer.name .. " · " .. (peer.ipv4 ~= "" and peer.ipv4 or peer.dnsName),
glyph = "activity",
score = 100,
},
{
id = "peeract:ssh:" .. ref,
title = noctalia.tr("launcher.action.ssh"),
subtitle = peer.dnsName ~= "" and peer.dnsName or peer.name,
glyph = "terminal-2",
score = 95,
},
{
id = "peeract:copyip:" .. ref,
title = noctalia.tr("launcher.action.copy_ip"),
subtitle = peer.ipv4,
glyph = "copy",
score = 90,
},
{
id = "peeract:copydns:" .. ref,
title = noctalia.tr("launcher.action.copy_dns"),
subtitle = peer.dnsName,
glyph = "copy",
score = 85,
},
}
end
local function findPeer(id)
for _, p in ipairs(snapshot.peers or {}) do
if p.id == id then return p end
end
return nil
end
local function findExit(id)
for _, e in ipairs(snapshot.exitNodes or {}) do
if e.id == id then return e end
end
return nil
end
local function listPeers(filter)
local rows = {}
for _, p in ipairs(snapshot.peers or {}) do
local sc = scoreText(filter, p.name, p.hostName, p.dnsName, p.ipv4, p.os)
if sc then
table.insert(rows, {
id = "peer:" .. p.id,
title = p.name,
subtitle = (p.online and "online" or "offline")
.. (p.ipv4 ~= "" and (" · " .. p.ipv4) or "")
.. (p.exitNodeOption and " · exit" or ""),
glyph = p.online and "device-desktop" or "device-desktop-off",
score = sc + (p.online and 10 or 0),
})
end
end
table.sort(rows, function(a, b)
return (a.score or 0) > (b.score or 0)
end)
while #rows > MAX_ROWS do
table.remove(rows)
end
return rows
end
local function listExits(filter)
local rows = {
{
id = "act:clear-exit",
title = noctalia.tr("launcher.action.clear_exit"),
subtitle = snapshot.exitNode ~= "" and snapshot.exitNode or "—",
glyph = "x",
score = 200,
},
}
for _, e in ipairs(snapshot.exitNodes or {}) do
local sc = scoreText(filter, e.name, e.host, e.ip, e.status)
if sc then
table.insert(rows, {
id = "exit:" .. e.id,
title = e.name,
subtitle = e.ip .. (e.selected and " · selected" or "") .. (e.online and "" or " · offline"),
glyph = "world",
score = sc + (e.selected and 20 or 0),
})
end
end
table.sort(rows, function(a, b)
return (a.score or 0) > (b.score or 0)
end)
return rows
end
function search(query)
ensureSnapshot()
query = trim(query)
if not snapshot.installed then
launcher.setResults(query, { statusRow(noctalia.tr("launcher.missing"), "", "alert-triangle") })
return
end
if snapshot.loading and not snapshot.available then
launcher.setResults(query, { statusRow(noctalia.tr("launcher.loading"), snapshot.hostname, "loader") })
return
end
local head, rest = query:match("^(%S+)%s*(.-)$")
head = lower(head or "")
rest = trim(rest or "")
if head == "" then
launcher.setResults(query, topCategories())
return
end
if head == "peers" or head == "peer" or head == "p" then
-- peer actions if exact match id after peers
local peerId = rest:match("^id:(%S+)") or ""
if peerId ~= "" then
local peer = findPeer(peerId)
if peer then
launcher.setResults(query, peerActions(peer))
return
end
end
-- if rest matches a single peer name exactly, show actions
if rest ~= "" then
local matches = {}
for _, p in ipairs(snapshot.peers or {}) do
if lower(p.name) == lower(rest) or lower(p.dnsName) == lower(rest) or p.ipv4 == rest then
table.insert(matches, p)
end
end
if #matches == 1 then
launcher.setResults(query, peerActions(matches[1]))
return
end
end
launcher.setResults(query, listPeers(rest))
return
end
if head == "exit" or head == "exits" or head == "e" then
launcher.setResults(query, listExits(rest))
return
end
if head == "status" or head == "up" or head == "down" or head == "panel" or head == "refresh" or head == "admin" then
local map = {
status = "act:status",
up = "act:up",
down = "act:down",
panel = "act:panel",
refresh = "act:refresh",
admin = "act:admin",
}
-- still show categories filtered
local rows = {}
for _, row in ipairs(topCategories()) do
if row.id == map[head] or scoreText(query, row.title, row.subtitle) then
table.insert(rows, row)
end
end
launcher.setResults(query, #rows > 0 and rows or topCategories())
return
end
-- free text: peers + categories
local rows = listPeers(query)
for _, row in ipairs(topCategories()) do
local sc = scoreText(query, row.title, row.subtitle)
if sc then
row.score = sc
table.insert(rows, row)
end
end
table.sort(rows, function(a, b)
return (a.score or 0) > (b.score or 0)
end)
while #rows > MAX_ROWS do
table.remove(rows)
end
launcher.setResults(query, rows)
end
function activate(id)
if type(id) ~= "string" or id == "" then
return
end
if id == "cat:peers" then
launcher.setQuery("peers ")
return
end
if id == "cat:exit" then
launcher.setQuery("exit ")
return
end
if id == "act:status" then
local state = snapshot.backendState or "—"
noctalia.notify(noctalia.tr("title"), `{state} · {snapshot.onlineCount or 0}/{snapshot.peerCount or 0} online · {snapshot.ipv4 or ""}`)
return
end
if id == "act:up" then
send("up")
return
end
if id == "act:down" then
send("down")
return
end
if id == "act:panel" then
noctalia.togglePanel(PANEL_ID)
return
end
if id == "act:admin" then
send("open_admin")
return
end
if id == "act:refresh" then
send("refresh")
return
end
if id == "act:clear-exit" then
send("clear_exit_node")
return
end
local peerId = id:match("^peer:(.+)$")
if peerId then
launcher.setQuery("peers id:" .. peerId)
return
end
local exitId = id:match("^exit:(.+)$")
if exitId then
local e = findExit(exitId)
if e then
send("set_exit_node", { node = e.ip })
end
return
end
local act, ref = id:match("^peeract:([^:]+):(.+)$")
if act and ref then
local peer = findPeer(ref)
if not peer then return end
if act == "ping" then
send("ping", { host = peer.ipv4 ~= "" and peer.ipv4 or peer.name })
elseif act == "ssh" then
send("ssh", { host = peer.dnsName ~= "" and peer.dnsName or peer.name })
elseif act == "copyip" then
send("copy", { text = peer.ipv4 })
elseif act == "copydns" then
send("copy", { text = peer.dnsName })
end
end
end