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 layerNamespacePlugin: "tailscalectl" popoutWidth: 360 popoutHeight: 400 // Transient coordination for the defensive poll-act-poll toggle (exact behavior preserved). // We poll for on-the-ground truth (so we choose the correct "up"/"down" and don't lie to the user), // act, then poll again for verification. This is *not* long-term cached state. // The _pendingAction is short-lived per user action only. property string _pendingAction: "" Component.onCompleted: { // Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification). root._runStatusCheck(); } function _runStatusCheck() { 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"))); } const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected); if (cmd) { // Exact same decision point as before: act based on fresh poll truth, then verify. 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 = ""; root._runStatusCheck(); // post-action verification poll (exact behavior) }); } else { root._pendingAction = ""; } }); } function toggleTailscale() { root._pendingAction = "toggle"; root._runStatusCheck(); } function refreshStatus() { root._runStatusCheck(); } function setExitNode(hostname) { const cmd = TailscaleLib.makeExitNodeCommand(hostname); if (!cmd) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname)); return; } Proc.runCommand("tailscale-exit", cmd, (stdout, code) => { if (code !== 0) { // Note: no stderr detail available from Proc (accepted per plan). ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set"))); } root._runStatusCheck(); }); } function copyToClipboard(text) { root._copyText = text; root._copyIndex = 0; root._runNextCopy(); } function _runNextCopy() { const cmds = TailscaleLib.getClipboardCommands(root._copyText); if (root._copyIndex >= cmds.length) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard"))); 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)); } 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 + 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: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().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 } } } }