import QtQuick import QtQuick.Layouts import qs.Common import qs.Services import qs.Widgets import qs.Modules.Plugins import "./lib.js" as TailscaleLib PluginComponent { id: root property bool isConnected: false property string tailscaleIP: "" property string currentExitNode: "" property var peers: [] property string _copyText: "" property int _copyIndex: 0 // Transient coordination for defensive poll-act-poll toggle (not long-term cache). // Poll for ground truth → act → poll again for verification. property string _pendingAction: "" // Single-flight guard: prevent interleaved status/toggle/exit/copy chains (#15/#30 class). property bool _busy: false layerNamespacePlugin: "tailscalectl" popoutWidth: 360 popoutHeight: 400 Component.onCompleted: { root._runStatusCheck(); } function _runStatusCheck() { if (root._busy && root._pendingAction === "") { // A non-toggle status refresh while something is already in flight: skip. // Toggle path sets _pendingAction first and is allowed to chain after actions clear busy carefully. return; } root._busy = true; Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { var statusOk = (code === 0); if (statusOk) { const state = TailscaleLib.parseStatusResult(stdout); 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", I18n.tr(TailscaleLib.formatError("status"))); } const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk); if (cmd) { // Fresh poll succeeded; act, then verify with another status poll. Proc.runCommand("tailscale-toggle", cmd, (out, c) => { if (c !== 0) { const action = root.isConnected ? "disconnect" : "connect"; ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action))); } root._pendingAction = ""; // Keep busy through verification poll: call internal runner that assumes we own the lock. root._runStatusCheckUnlocked(); }); } else { // Includes failed status while a toggle was pending: abort rather than invent up/down. root._pendingAction = ""; root._busy = false; } }); } // Used only as the continuation after toggle action; assumes _busy is already true. function _runStatusCheckUnlocked() { Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { if (code === 0) { const state = TailscaleLib.parseStatusResult(stdout); 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", I18n.tr(TailscaleLib.formatError("status"))); } root._busy = false; }); } function toggleTailscale() { if (root._busy) { return; } root._pendingAction = TailscaleLib.PendingAction.TOGGLE; root._runStatusCheck(); } function refreshStatus() { if (root._busy) { return; } root._runStatusCheck(); } function setExitNode(hostname) { if (root._busy) { return; } const cmd = TailscaleLib.makeExitNodeCommand(hostname); if (!cmd) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname)); return; } root._busy = true; Proc.runCommand("tailscale-exit", cmd, (stdout, code) => { if (code !== 0) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set"))); } // Verify via unlocked status continuation (busy already held). root._runStatusCheckUnlocked(); }); } function copyToClipboard(text) { if (root._busy) { return; } root._copyText = text; root._copyIndex = 0; root._busy = true; root._runNextCopy(); } function _runNextCopy() { const cmds = TailscaleLib.getClipboardCommands(root._copyText); if (root._copyIndex >= cmds.length) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard"))); root._busy = false; return; } Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => { if (code === 0) { ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText)); root._busy = false; } else { root._copyIndex += 1; root._runNextCopy(); } }); } popoutContent: Component { PopoutComponent { headerText: I18n.tr(TailscaleLib.getStrings().header) detailsText: root.isConnected ? I18n.tr(TailscaleLib.getStrings().connected) : I18n.tr(TailscaleLib.getStrings().disconnected) showCloseButton: true Item { id: contentItem width: parent.width height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerArea.height + Theme.spacingM RowLayout { 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 Layout.alignment: Qt.AlignVCenter 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 Layout.alignment: Qt.AlignVCenter } Item { Layout.fillWidth: true height: 1 } StyledText { text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none)) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText Layout.alignment: Qt.AlignVCenter } MouseArea { visible: root.currentExitNode !== "" cursorShape: Qt.PointingHandCursor hoverEnabled: true Layout.alignment: Qt.AlignVCenter width: clearExitNodeText.implicitWidth height: clearExitNodeText.implicitHeight onClicked: { root.setExitNode(""); } StyledText { id: clearExitNodeText text: "×" font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText } } } // Peer list when connected; empty-state hint when not (#55). Item { id: peerArea y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM width: parent.width - Theme.spacingM * 2 height: root.isConnected ? Math.min(Math.max(root.peers.length, 1) * (Theme.fontSizeSmall + Theme.spacingXS), 200) : (Theme.fontSizeSmall + Theme.spacingXS) anchors.left: parent.left anchors.leftMargin: Theme.spacingM StyledText { visible: !root.isConnected anchors.fill: parent text: I18n.tr(TailscaleLib.getStrings().notConnectedHint) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } ListView { id: peerList visible: root.isConnected anchors.fill: parent model: root.peers interactive: true // Desktop popout: no rubber-band overshoot (#27). boundsBehavior: Flickable.StopAtBounds clip: true delegate: Item { width: peerList.width height: Theme.fontSizeSmall + Theme.spacingXS Row { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingS 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 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 } } } }