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 string tailscaleIP: "" property string currentExitNode: "" property var peers: [] property string cachedClipboardTool: "" property string _copyText: "" property string _copyCurrentTool: "" property int _copyAttempted: 0 property bool _pendingToggle: false // transient one-shot for post-action verification (to be removed in first-principles refactor) layerNamespacePlugin: "tailscalectl" popoutWidth: 360 popoutHeight: 400 Component.onCompleted: { // Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification). statusCheck.running = true } Process { id: toggleProcess stderr: StdioCollector {} onExited: (code, status) => { if (code !== 0) { var action = root.isConnected ? "disconnect" : "connect" var detail = toggleProcess.stderr.text.trim().slice(0, 120) ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action) + (detail ? " — " + detail : "")) } statusCheck.running = true } } Process { id: copyProcess stderr: StdioCollector {} onExited: (code, status) => { if (code === 0) { root.cachedClipboardTool = root._copyCurrentTool ToastService.showInfo("Copied " + root._copyText + " to clipboard") } else { root._copyAttempted += 1 if (root._copyAttempted < TailscaleLib.allClipboardTools().length) { root._copyCurrentTool = TailscaleLib.nextClipboardTool(root._copyCurrentTool) root._executeCopy() } else { var detail = copyProcess.stderr.text.trim().slice(0, 120) ToastService.showError("tailscalectl", "No clipboard tool found" + (detail ? " — " + detail : "")) } } } } Process { id: exitNodeProcess stderr: StdioCollector {} onExited: (code, status) => { if (code !== 0) { var detail = exitNodeProcess.stderr.text.trim().slice(0, 120) ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set") + (detail ? " — " + detail : "")) } statusCheck.running = true } } Process { id: statusCheck command: ["tailscale", "status", "--json"] stdout: StdioCollector {} onExited: (code, status) => { if (code === 0) { const state = TailscaleLib.parseStatusResult(statusCheck.stdout.text) root.isConnected = state.isConnected root.tailscaleIP = state.tailscaleIP root.currentExitNode = state.currentExitNode root.peers = state.peers } else { root.isConnected = false root.tailscaleIP = "" root.currentExitNode = "" root.peers = [] ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status")) } // Post-action verification: if a toggle was requested, use the fresh state we just received. if (root._pendingToggle) { root._pendingToggle = false toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected) toggleProcess.running = true } } } function toggleTailscale() { // Post-action verification: force a fresh status check, then act on the real ground truth. root._pendingToggle = true statusCheck.running = true } function setExitNode(hostname) { exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname) exitNodeProcess.running = true } function copyToClipboard(text) { root._copyText = text root._copyAttempted = 0 if (root.cachedClipboardTool) { root._copyCurrentTool = root.cachedClipboardTool } else { root._copyCurrentTool = TailscaleLib.allClipboardTools()[0] } root._executeCopy() } function _executeCopy() { var cmd = TailscaleLib.buildCopyCommand(root._copyText, root._copyCurrentTool) if (!cmd) { ToastService.showError("tailscalectl", "Invalid clipboard command") return } copyProcess.command = cmd copyProcess.running = true } 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 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: TailscaleLib.shouldShowClearExitNode(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: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText } } } } } } } } MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton 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 } } } }