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: "" // Lazy true-egress IP — only filled by explicit user-triggered check (#54). property string publicIP: "" property bool publicIPLoading: false property string currentExitNode: "" property var peers: [] property string _copyText: "" property int _copyIndex: 0 property int _egressIndex: 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/egress chains. property bool _busy: false layerNamespacePlugin: "tailscalectl" popoutWidth: 360 popoutHeight: 400 Component.onCompleted: { root._runStatusCheck(); } function _applyStatusState(state) { // Invalidate lazy public IP when connectivity or exit node changes. if (!state.isConnected || state.currentExitNode !== root.currentExitNode) { root.publicIP = ""; root.publicIPLoading = false; } root.isConnected = state.isConnected; root.tailscaleIP = state.tailscaleIP; root.currentExitNode = state.currentExitNode; root.peers = state.peers; } function _clearConnectionState() { root.isConnected = false; root.tailscaleIP = ""; root.publicIP = ""; root.publicIPLoading = false; root.currentExitNode = ""; root.peers = []; } function _runStatusCheck() { if (root._busy && root._pendingAction === "") { return; } root._busy = true; Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { var statusOk = (code === 0); if (statusOk) { root._applyStatusState(TailscaleLib.parseStatusResult(stdout)); } else { root._clearConnectionState(); ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); } const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk); if (cmd) { 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._runStatusCheckUnlocked(); }); } else { root._pendingAction = ""; root._busy = false; } }); } // Continuation after toggle/exit; assumes _busy is already true. function _runStatusCheckUnlocked() { Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { if (code === 0) { root._applyStatusState(TailscaleLib.parseStatusResult(stdout)); } else { root._clearConnectionState(); 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; // Exit-node change invalidates any previously checked egress IP. root.publicIP = ""; root.publicIPLoading = false; Proc.runCommand("tailscale-exit", cmd, (stdout, code) => { if (code !== 0) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set"))); } 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(); } }); } // #54: on-demand true egress check (lazy). Click "tap to check" to run. function fetchPublicIP() { if (root._busy || !root.isConnected || root.publicIPLoading) { return; } root._busy = true; root.publicIPLoading = true; root._egressIndex = 0; root._runNextEgressCheck(); } function _runNextEgressCheck() { const cmds = TailscaleLib.getEgressCheckCommands(); if (root._egressIndex >= cmds.length) { root.publicIPLoading = false; root._busy = false; ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("egress"))); return; } Proc.runCommand("tailscale-egress-" + root._egressIndex, cmds[root._egressIndex], (stdout, code) => { if (code === 0) { const ip = TailscaleLib.parseEgressCheckResponse(stdout); if (ip !== "") { root.publicIP = ip; root.publicIPLoading = false; root._busy = false; return; } } root._egressIndex += 1; root._runNextEgressCheck(); }); } function onPublicIpRowClicked() { if (!root.isConnected) { return; } if (root.publicIPLoading) { return; } if (root.publicIP !== "") { root.copyToClipboard(root.publicIP); return; } root.fetchPublicIP(); } 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.spacingXS + (root.isConnected ? Theme.fontSizeSmall + Theme.spacingXS : 0) + 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 } } } // #54: lazy true-egress public IP (on-demand only) MouseArea { id: publicIpRow visible: root.isConnected y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS anchors.left: parent.left anchors.leftMargin: Theme.spacingM width: publicIpText.implicitWidth height: publicIpText.implicitHeight cursorShape: Qt.PointingHandCursor hoverEnabled: true onClicked: { root.onPublicIpRowClicked(); } StyledText { id: publicIpText text: { var prefix = I18n.tr(TailscaleLib.getStrings().publicIPPrefix); if (root.publicIPLoading) { return prefix + I18n.tr(TailscaleLib.getStrings().publicIPLoading); } if (root.publicIP !== "") { return prefix + root.publicIP; } return prefix + I18n.tr(TailscaleLib.getStrings().publicIPTapHint); } 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.spacingXS + (publicIpRow.visible ? publicIpRow.height + Theme.spacingXS : 0) + 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 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 } } } }