diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index 2ac7d04..36223c2 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -5,6 +5,7 @@ import qs.Services import qs.Widgets import qs.Modules.Plugins import Quickshell.Io +import "./lib.js" as TailscaleLib PluginComponent { id: root @@ -42,6 +43,17 @@ PluginComponent { id: copyProcess } + Process { + id: exitNodeProcess + + onExited: (code, status) => { + if (code !== 0) { + ToastService.showError("tailscalectl", "Failed to set exit node") + } + statusCheck.running = true + } + } + Process { id: detectClipboard @@ -70,14 +82,7 @@ PluginComponent { root.isConnected = data.BackendState === "Running" root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || "" root.currentExitNode = data.CurrentExitNode?.HostName || "" - root.peers = Object.keys(data.Peer || {}).map(function(key) { - var p = data.Peer[key] - return { - hostname: p.HostName || key, - ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "", - online: p.Online || false - } - }) + root.peers = TailscaleLib.parsePeers(data.Peer || {}) } catch (e) { root.isConnected = false root.tailscaleIP = "" @@ -107,6 +112,11 @@ PluginComponent { toggleProcess.running = true } + function setExitNode(hostname) { + exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname) + exitNodeProcess.running = true + } + function copyToClipboard(text) { if (!root.clipboardCmd || root.clipboardCmd === "none") { ToastService.showError("tailscalectl", "No clipboard tool found") @@ -172,6 +182,25 @@ PluginComponent { color: Theme.surfaceVariantText 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 { @@ -229,6 +258,25 @@ PluginComponent { 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 + } + } } } } diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js new file mode 100644 index 0000000..6e949f5 --- /dev/null +++ b/tailscalectl/lib.js @@ -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 } diff --git a/test/lib.test.js b/test/lib.test.js new file mode 100644 index 0000000..06bbbf9 --- /dev/null +++ b/test/lib.test.js @@ -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="]) +})