dms_tailscalectl/tailscalectl/TailscaleWidget.qml
vybe 2ece3cc103 fix: use if/elif/else in clipboard detection script
The previous &&/|| chain evaluated all commands, producing multi-line
output when multiple tools were installed. parseClipboardDetection
correctly rejected this as invalid, falling back to 'none'. The
if/elif/else structure ensures only the first match is echoed.
2026-05-21 02:36:40 +00:00

343 lines
No EOL
12 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 bool binaryAvailable: true
property string tailscaleIP: ""
property string currentExitNode: ""
property var peers: []
property string clipboardCmd: ""
layerNamespacePlugin: "tailscalectl"
popoutWidth: 360
popoutHeight: 400
Timer {
interval: 5000
running: true
repeat: true
onTriggered: statusCheck.running = true
}
Component.onCompleted: {
detectClipboard.running = true
}
Process {
id: toggleProcess
onExited: (code, status) => {
if (code !== 0) {
var action = root.isConnected ? "disconnect" : "connect"
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action))
}
statusCheck.running = true
}
}
Process {
id: copyProcess
}
Process {
id: exitNodeProcess
onExited: (code, status) => {
if (code !== 0) {
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set"))
}
statusCheck.running = true
}
}
Process {
id: detectClipboard
command: ["sh", "-c", "if which dms >/dev/null 2>&1; then echo 'dms cl copy'; elif which wl-copy >/dev/null 2>&1; then echo 'wl-copy'; elif which clipmanctl >/dev/null 2>&1; then echo 'clipmanctl copy'; elif which xclip >/dev/null 2>&1; then echo 'xclip -selection clipboard'; elif which xsel >/dev/null 2>&1; then echo 'xsel --clipboard --input'; else echo none; fi"]
stdout: StdioCollector {
onStreamFinished: {
root.clipboardCmd = TailscaleLib.parseClipboardDetection(this.text)
}
}
onExited: (code, status) => {
statusCheck.running = true
}
}
Process {
id: statusCheck
command: ["tailscale", "status", "--json"]
stdout: StdioCollector {
onStreamFinished: {
const state = TailscaleLib.parseStatusResult(this.text)
root.isConnected = state.isConnected
root.tailscaleIP = state.tailscaleIP
root.currentExitNode = state.currentExitNode
root.peers = state.peers
}
}
onExited: (code, status) => {
if (code !== 0) {
root.isConnected = false
root.tailscaleIP = ""
root.currentExitNode = ""
root.peers = []
if (code === 127) {
root.binaryAvailable = false
ToastService.showError("tailscalectl", "Tailscale binary not found")
} else {
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status"))
}
}
}
}
function toggleTailscale() {
if (!root.binaryAvailable) {
ToastService.showError("tailscalectl", "Tailscale not available")
return
}
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
toggleProcess.running = true
}
function setExitNode(hostname) {
if (!root.binaryAvailable) {
ToastService.showError("tailscalectl", "Tailscale not available")
return
}
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
exitNodeProcess.running = true
}
function copyToClipboard(text) {
if (!root.clipboardCmd || root.clipboardCmd === "none") {
ToastService.showError("tailscalectl", "No clipboard tool found")
return
}
var cmd = TailscaleLib.buildCopyCommand(text, root.clipboardCmd)
if (!cmd) {
ToastService.showError("tailscalectl", "Invalid clipboard command")
return
}
copyProcess.command = cmd
copyProcess.running = true
ToastService.showInfo("Copied " + text + " to clipboard")
}
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
StyledText {
visible: !root.binaryAvailable
text: "Tailscale not available"
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
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: 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: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
}
}
}
}
}
}
}
}
// propagateComposedEvents: true so that right-clicks both trigger our context menu
// *and* bubble to any parent MouseArea (e.g. for shell-level drag handling).
// Documented because the default (false) is far more common and this choice
// frequently surprises future maintainers.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
propagateComposedEvents: true
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
}
}
}
}