dms_tailscalectl/tailscalectl/TailscaleWidget.qml
vybe 59ed4ebf44 chore: evaluate structured logging per AGENTS.md rule 1 — no addition justified (#47)
- Existing toasts + truncated stderr provide the actionable information for users and errors
- Adding persistent debug logs would introduce state, rotation, and maintenance concerns
- with no demonstrable improvement to UX, decisions, or security for normal operation
- Explicit no-op commit closes the planning issue

Written by AI agent working for @jtmorris. Model: Grok build-0.1.
2026-05-22 06:34:14 +00:00

348 lines
No EOL
13 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 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 poll-then-act (#42) — reset immediately after use, no "desired state" cache
layerNamespacePlugin: "tailscalectl"
popoutWidth: 360
popoutHeight: 400
Timer {
id: refreshTimer
interval: 5000
running: true
repeat: true
onTriggered: {
if (!statusCheck.running) {
statusCheck.running = true
}
}
}
Component.onCompleted: {
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"))
}
// #42 poll-then-act: if a toggle was requested, use the *fresh* state we just received
// #47: structured logging evaluated and rejected — toasts + 120-char stderr already give
// actionable feedback; persistent logs would add state/rotation with no net UX benefit.
if (root._pendingToggle) {
root._pendingToggle = false
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
toggleProcess.running = true
}
}
}
function toggleTailscale() {
// #42: poll-then-act — never decide up/down from potentially stale root.isConnected.
// Force a fresh statusCheck; the decision happens in its onExited using the just-received value.
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
}
}
}
}