import QtQuick import QtQuick.Layouts import qs.Common import qs.Services import qs.Widgets import qs.Modules.Plugins import Quickshell.Io import "./lib.js" as TailscaleLib PluginComponent { id: root property bool isConnected: false property bool binaryAvailable: true property string tailscaleIP: "" property string currentExitNode: "" property var peers: [] property string clipboardCmd: "" layerNamespacePlugin: "tailscalectl" popoutWidth: 360 popoutHeight: 400 Timer { interval: 5000 running: true repeat: true onTriggered: statusCheck.running = true } Component.onCompleted: { detectClipboard.running = true } Process { id: toggleProcess onExited: (code, status) => { if (code !== 0) { var action = root.isConnected ? "disconnect" : "connect" ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action)) } statusCheck.running = true } } Process { id: copyProcess } Process { id: exitNodeProcess onExited: (code, status) => { if (code !== 0) { ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set")) } statusCheck.running = true } } Process { id: detectClipboard command: ["sh", "-c", "which dms >/dev/null 2>&1 && echo 'dms cl copy' || which wl-copy >/dev/null 2>&1 && echo 'wl-copy' || which clipmanctl >/dev/null 2>&1 && echo 'clipmanctl copy' || echo none"] stdout: StdioCollector { onStreamFinished: { root.clipboardCmd = this.text.trim() } } onExited: (code, status) => { statusCheck.running = true } } Process { id: statusCheck command: ["tailscale", "status", "--json"] stdout: StdioCollector { onStreamFinished: { try { const data = JSON.parse(this.text) root.isConnected = data.BackendState === "Running" root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || "" root.currentExitNode = data.CurrentExitNode?.HostName || "" root.peers = TailscaleLib.parsePeers(data.Peer || {}) } catch (e) { root.isConnected = false root.tailscaleIP = "" root.currentExitNode = "" root.peers = [] ToastService.showError("tailscalectl", "Failed to parse Tailscale status") } } } onExited: (code, status) => { if (code !== 0) { root.isConnected = false root.tailscaleIP = "" root.currentExitNode = "" root.peers = [] if (code === 127) { root.binaryAvailable = false ToastService.showError("tailscalectl", "Tailscale binary not found") } else { ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status")) } } } } function toggleTailscale() { if (!root.binaryAvailable) { ToastService.showError("tailscalectl", "Tailscale not available") return } if (root.isConnected) { toggleProcess.command = ["tailscale", "down"] } else { toggleProcess.command = ["tailscale", "up"] } toggleProcess.running = true } function setExitNode(hostname) { if (!root.binaryAvailable) { ToastService.showError("tailscalectl", "Tailscale not available") return } exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname) exitNodeProcess.running = true } function copyToClipboard(text) { if (!root.clipboardCmd || root.clipboardCmd === "none") { ToastService.showError("tailscalectl", "No clipboard tool found") return } copyProcess.command = ["sh", "-c", "printf '%s' '" + text.replace(/'/g, "'\\''") + "' | " + root.clipboardCmd] copyProcess.running = true ToastService.showInfo("Copied " + text + " to clipboard") } popoutContent: Component { PopoutComponent { headerText: "Tailscale" detailsText: root.isConnected ? "Connected" : "Disconnected" showCloseButton: true Item { id: contentItem width: parent.width height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM StyledText { visible: !root.binaryAvailable text: "Tailscale not available" anchors.centerIn: parent font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText } Row { id: statusRow y: Theme.spacingM width: parent.width spacing: Theme.spacingS anchors.left: parent.left anchors.leftMargin: Theme.spacingM anchors.right: parent.right anchors.rightMargin: Theme.spacingM MouseArea { cursorShape: Qt.PointingHandCursor hoverEnabled: true anchors.verticalCenter: parent.verticalCenter width: toggleIcon.implicitWidth height: toggleIcon.implicitHeight onClicked: { root.toggleTailscale() } DankIcon { id: toggleIcon name: root.isConnected ? "power" : "power_off" size: Theme.iconSize color: Theme.primary anchors.centerIn: parent } } StyledText { text: root.tailscaleIP || "—" font.pixelSize: Theme.fontSizeSmall color: Theme.primary anchors.verticalCenter: parent.verticalCenter } Item { width: 1; height: 1; Layout.fillWidth: true } StyledText { text: "Exit node: " + (root.currentExitNode || "None") font.pixelSize: Theme.fontSizeSmall 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 { id: peerList y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM width: parent.width - Theme.spacingM * 2 height: Math.min(root.peers.length * (Theme.fontSizeSmall + Theme.spacingXS), 200) anchors.left: parent.left anchors.leftMargin: Theme.spacingM model: root.peers interactive: true boundsBehavior: Flickable.DragAndOvershootBounds delegate: Item { width: peerList.width height: Theme.fontSizeSmall + Theme.spacingXS Row { anchors.fill: parent spacing: Theme.spacingS anchors.verticalCenter: parent.verticalCenter MouseArea { cursorShape: Qt.PointingHandCursor hoverEnabled: true anchors.verticalCenter: parent.verticalCenter width: peerHostnameText.implicitWidth height: peerHostnameText.implicitHeight onClicked: { root.copyToClipboard(modelData.hostname) } StyledText { id: peerHostnameText text: modelData.hostname font.pixelSize: Theme.fontSizeSmall color: modelData.online ? Theme.primary : Theme.surfaceVariantText } } MouseArea { cursorShape: Qt.PointingHandCursor hoverEnabled: true anchors.verticalCenter: parent.verticalCenter width: peerIpText.implicitWidth height: peerIpText.implicitHeight onClicked: { root.copyToClipboard(modelData.ip) } StyledText { id: peerIpText text: modelData.ip font.pixelSize: Theme.fontSizeSmall 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 } } } } } } } } MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton propagateComposedEvents: true onClicked: { root.toggleTailscale() } } horizontalBarPill: Component { Row { spacing: Theme.spacingS DankIcon { name: root.isConnected ? "vpn_key" : "vpn_key_off" size: Theme.iconSize color: root.isConnected ? Theme.primary : Theme.surfaceText } } } verticalBarPill: Component { Column { spacing: Theme.spacingXS DankIcon { name: root.isConnected ? "vpn_key" : "vpn_key_off" size: Theme.iconSize color: root.isConnected ? Theme.primary : Theme.surfaceText } } } }