dms_tailscalectl/tailscalectl/TailscaleWidget.qml
Ebeneezer (Hermes Agent) 6a586119be feat: lazy on-demand true egress public IP check (#54)
Replace status-Addrs public IP with user-triggered curl probes
(api.ipify.org, icanhazip.com). Shown as "tap to check" until fetched;
cleared on disconnect or exit-node change. Direct argv, fallback chain,
busy-mutex shared with other actions.

v0.2.3

Written by AI agent working for @jtmorris. Model: Grok 4.5.
2026-07-15 01:35:53 -07:00

464 lines
18 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}
}
}