feat: add exit node selection to Tailscale widget

- Extract peer parsing and exit-node command logic into lib.js
- Add ↗ button on peers that are ExitNodeOption candidates
- Highlight active exit node in primary color
- Add clear (×) button next to current exit node label
- Add unit tests for parsePeers and makeExitNodeCommand
This commit is contained in:
Vybe (Coding Agent) 2026-05-18 18:27:41 +00:00
parent 2d438415ee
commit 0c2376b3bb
3 changed files with 109 additions and 8 deletions

View file

@ -5,6 +5,7 @@ import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modules.Plugins import qs.Modules.Plugins
import Quickshell.Io import Quickshell.Io
import "./lib.js" as TailscaleLib
PluginComponent { PluginComponent {
id: root id: root
@ -42,6 +43,17 @@ PluginComponent {
id: copyProcess id: copyProcess
} }
Process {
id: exitNodeProcess
onExited: (code, status) => {
if (code !== 0) {
ToastService.showError("tailscalectl", "Failed to set exit node")
}
statusCheck.running = true
}
}
Process { Process {
id: detectClipboard id: detectClipboard
@ -70,14 +82,7 @@ PluginComponent {
root.isConnected = data.BackendState === "Running" root.isConnected = data.BackendState === "Running"
root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || "" root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || ""
root.currentExitNode = data.CurrentExitNode?.HostName || "" root.currentExitNode = data.CurrentExitNode?.HostName || ""
root.peers = Object.keys(data.Peer || {}).map(function(key) { root.peers = TailscaleLib.parsePeers(data.Peer || {})
var p = data.Peer[key]
return {
hostname: p.HostName || key,
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
online: p.Online || false
}
})
} catch (e) { } catch (e) {
root.isConnected = false root.isConnected = false
root.tailscaleIP = "" root.tailscaleIP = ""
@ -107,6 +112,11 @@ PluginComponent {
toggleProcess.running = true toggleProcess.running = true
} }
function setExitNode(hostname) {
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
exitNodeProcess.running = true
}
function copyToClipboard(text) { function copyToClipboard(text) {
if (!root.clipboardCmd || root.clipboardCmd === "none") { if (!root.clipboardCmd || root.clipboardCmd === "none") {
ToastService.showError("tailscalectl", "No clipboard tool found") ToastService.showError("tailscalectl", "No clipboard tool found")
@ -172,6 +182,25 @@ PluginComponent {
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
MouseArea {
visible: root.currentExitNode !== ""
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter
width: clearExitNodeText.implicitWidth
height: clearExitNodeText.implicitHeight
onClicked: {
root.setExitNode("")
}
StyledText {
id: clearExitNodeText
text: "×"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
} }
ListView { ListView {
@ -229,6 +258,25 @@ PluginComponent {
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
} }
MouseArea {
visible: modelData.exitNode
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
anchors.verticalCenter: parent.verticalCenter
width: exitNodeButton.implicitWidth
height: exitNodeButton.implicitHeight
onClicked: {
root.setExitNode(modelData.hostname)
}
StyledText {
id: exitNodeButton
text: "↗"
font.pixelSize: Theme.fontSizeSmall
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
}
}
} }
} }
} }

18
tailscalectl/lib.js Normal file
View file

@ -0,0 +1,18 @@
function parsePeers(peerMap) {
if (!peerMap) return []
return Object.keys(peerMap).map(function (key) {
var p = peerMap[key]
return {
hostname: p.HostName || key,
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
online: p.Online || false,
exitNode: p.ExitNodeOption || false
}
})
}
function makeExitNodeCommand(hostname) {
return ["tailscale", "set", "--exit-node=" + hostname]
}
export { parsePeers, makeExitNodeCommand }

35
test/lib.test.js Normal file
View file

@ -0,0 +1,35 @@
import { test } from "node:test"
import assert from "node:assert"
import { parsePeers, makeExitNodeCommand } from "../tailscalectl/lib.js"
test("parsePeers extracts exitNode from ExitNodeOption", () => {
const peerMap = {
"peer-1": {
HostName: "router",
TailscaleIPs: ["100.64.0.1"],
Online: true,
ExitNodeOption: true
},
"peer-2": {
HostName: "laptop",
TailscaleIPs: ["100.64.0.2"],
Online: true,
ExitNodeOption: false
}
}
const peers = parsePeers(peerMap)
assert.strictEqual(peers[0].exitNode, true)
assert.strictEqual(peers[1].exitNode, false)
})
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
const cmd = makeExitNodeCommand("router")
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"])
})
test("makeExitNodeCommand with empty string clears exit node", () => {
const cmd = makeExitNodeCommand("")
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="])
})