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 = TailscaleLib.parseClipboardDetection(this.text) } } onExited: (code, status) => { statusCheck.running = true } } Process { id: statusCheck command: ["tailscale", "status", "--json"] stdout: StdioCollector { onStreamFinished: { const state = TailscaleLib.parseStatusResult(this.text) root.isConnected = state.isConnected root.tailscaleIP = state.tailscaleIP root.currentExitNode = state.currentExitNode root.peers = state.peers } } 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 } toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected) 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 } var cmd = TailscaleLib.buildCopyCommand(text, root.clipboardCmd) if (!cmd) { ToastService.showError("tailscalectl", "Invalid clipboard command") return } copyProcess.command = cmd 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 } } } } } } } } // propagateComposedEvents: true so that right-clicks both trigger our context menu // *and* bubble to any parent MouseArea (e.g. for shell-level drag handling). // Documented because the default (false) is far more common and this choice // frequently surprises future maintainers. 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 } } } }