import QtQuick import QtQuick.Layouts import qs.Common import qs.Services import qs.Widgets import qs.Modules.Plugins import Quickshell.Io PluginComponent { id: root property bool isConnected: false property string tailscaleIP: "" property string currentExitNode: "" layerNamespacePlugin: "tailscalectl" popoutWidth: 360 popoutHeight: 200 Timer { interval: 5000 running: true repeat: true onTriggered: statusCheck.running = true } Component.onCompleted: statusCheck.running = true Process { id: toggleProcess onExited: (code, status) => { statusCheck.running = true } } Process { id: statusCheck command: ["tailscale", "status", "--json"] stdout: StdioCollector { onStreamFinished: { try { const data = JSON.parse(this.text) root.isConnected = data.BackendState === "Running" root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || "" root.currentExitNode = data.CurrentExitNode?.HostName || "" } catch (e) { root.isConnected = false root.tailscaleIP = "" root.currentExitNode = "" ToastService.showError("tailscalectl", "Failed to parse Tailscale status") } } } onExited: (code, status) => { if (code !== 0) { root.isConnected = false root.tailscaleIP = "" root.currentExitNode = "" } } } function toggleTailscale() { if (root.isConnected) { toggleProcess.command = ["tailscale", "down"] } else { toggleProcess.command = ["tailscale", "up"] } toggleProcess.running = true } popoutContent: Component { PopoutComponent { id: popout headerText: "Tailscale" detailsText: root.tailscaleIP ? root.tailscaleIP : "Not connected" showCloseButton: true Column { width: parent.width height: parent.height - popout.headerHeight - popout.detailsHeight - Theme.spacingXL anchors.top: parent.top anchors.topMargin: Theme.spacingM spacing: Theme.spacingM Row { width: parent.width spacing: Theme.spacingS anchors.horizontalCenter: parent.horizontalCenter StyledText { text: root.isConnected ? "Connected" : "Disconnected" font.pixelSize: Theme.fontSizeMedium color: root.isConnected ? Theme.primary : Theme.surfaceVariantText anchors.verticalCenter: parent.verticalCenter } Item { width: 1; height: 1; Layout.fillWidth: true } StyledText { text: "Toggle" font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } } StyledText { width: parent.width text: "Exit node: " + (root.currentExitNode || "None") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText horizontalAlignment: Text.AlignHCenter } } } } horizontalBarPill: Component { MouseArea { implicitWidth: contentRow.implicitWidth implicitHeight: contentRow.implicitHeight cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.RightButton | Qt.LeftButton propagateComposedEvents: true onClicked: (mouse) => { if (mouse.button === Qt.RightButton) { toggleTailscale() mouse.accepted = true } else { mouse.accepted = false } } Row { id: contentRow spacing: Theme.spacingS DankIcon { name: root.isConnected ? "vpn_key" : "vpn_key_off" size: Theme.iconSize color: root.isConnected ? Theme.primary : Theme.surfaceText anchors.verticalCenter: parent.verticalCenter } } } } verticalBarPill: Component { MouseArea { implicitWidth: contentColumn.implicitWidth implicitHeight: contentColumn.implicitHeight cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.RightButton | Qt.LeftButton propagateComposedEvents: true onClicked: (mouse) => { if (mouse.button === Qt.RightButton) { toggleTailscale() mouse.accepted = true } else { mouse.accepted = false } } Column { id: contentColumn spacing: Theme.spacingXS DankIcon { name: root.isConnected ? "vpn_key" : "vpn_key_off" size: Theme.iconSize color: root.isConnected ? Theme.primary : Theme.surfaceText anchors.horizontalCenter: parent.horizontalCenter } } } } }