1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-16 16:15:23 -04:00
Files
DankMaterialShell/quickshell/Modules/ControlCenter/BuiltinPlugins/TailscaleWidget.qml
T
Rocho 988b54515e feat(tailscale): add connect/disconnect, exit-node and LAN-access controls (#2644)
* feat(tailscale): add connect/disconnect/exit-node/LAN-access backend

The Tailscale backend previously exposed only read-only status
(tailscale.getStatus, tailscale.refresh). This adds write actions through the
existing tailscale.com/client/local integration:

- tailscale.connect / tailscale.disconnect (EditPrefs WantRunning)
- tailscale.setExitNode (EditPrefs ExitNodeID; empty id clears it and any
  legacy ExitNodeIP, mirroring `tailscale set --exit-node`)
- tailscale.setAllowLanAccess (EditPrefs ExitNodeAllowLANAccess)

The manager's client interface gains GetPrefs/EditPrefs; fetchState merges
ExitNodeAllowLANAccess from prefs, and Peer exposes ExitNodeOption so the UI
can list exit-node-capable peers.

* feat(tailscale): expose the new actions in TailscaleService

Adds connectTailscale/disconnectTailscale, setExitNode/clearExitNode and
setAllowLanAccess wrappers, plus derived exitNodeOptions/currentExitNode and the
exitNodeAllowLanAccess state. Write-action errors surface via ToastService.

* feat(tailscale): add connection, exit-node and LAN-access controls to the widget

The control-center widget toggle was a no-op. It now connects/disconnects, and
the detail panel gains a connection status row with a connect/disconnect button,
an exit-node picker and a LAN-access toggle.
2026-06-16 09:08:22 -04:00

479 lines
24 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
id: root
Ref {
service: TailscaleService
}
ccWidgetIcon: "device_hub"
ccWidgetPrimaryText: I18n.tr("Tailscale", "Tailscale mesh VPN widget title")
ccWidgetSecondaryText: {
if (!TailscaleService.available)
return I18n.tr("Not available", "Tailscale service not available");
if (!TailscaleService.connected)
return I18n.tr("Disconnected", "Tailscale disconnected status");
const count = TailscaleService.onlinePeerCount;
return I18n.tr("%1 online", "Number of online Tailscale peers").arg(count);
}
ccWidgetIsActive: TailscaleService.connected
onCcWidgetToggled: {
if (!TailscaleService.available)
return;
if (TailscaleService.connected)
TailscaleService.disconnectTailscale(null);
else
TailscaleService.connectTailscale(null);
}
ccDetailContent: Component {
Rectangle {
id: detailRoot
property string searchQuery: ""
property int filterIndex: 0 // 0=My Online, 1=All Online, 2=All
property string expandedHostname: ""
implicitHeight: detailColumn.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Column {
id: detailColumn
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
// Not available state
Column {
visible: !TailscaleService.available
width: parent.width
spacing: Theme.spacingS
Item {
width: parent.width
height: 80
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "vpn_key_off"
size: 36
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("Tailscale not available", "Warning when Tailscale service is not running")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
// Connected content
Item {
visible: TailscaleService.available
width: parent.width
height: parent.height - (parent.visibleChildren[0] === this ? 0 : y)
clip: true
Column {
id: headerColumn
width: parent.width
spacing: Theme.spacingS
// Connection status + connect/disconnect. Always shown
// (when available) so the connection can be toggled from
// the detail, including while disconnected.
RowLayout {
width: parent.width
spacing: Theme.spacingS
Column {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 1
StyledText {
text: TailscaleService.connected ? I18n.tr("Connected", "Tailscale connection status: connected") : I18n.tr("Disconnected", "Tailscale connection status: disconnected")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
visible: TailscaleService.connected && TailscaleService.tailnetName.length > 0
text: TailscaleService.tailnetName
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
}
Rectangle {
id: connButton
Layout.alignment: Qt.AlignVCenter
height: 28
radius: 14
width: connButtonRow.implicitWidth + Theme.spacingM * 2
readonly property bool isConnected: TailscaleService.connected
color: isConnected ? (connButtonArea.containsMouse ? Theme.errorHover : Theme.surfaceLight) : (connButtonArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: connButtonRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: connButton.isConnected ? "link_off" : "link"
size: Theme.fontSizeSmall
color: connButton.isConnected ? Theme.surfaceText : Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: connButton.isConnected ? I18n.tr("Disconnect", "Tailscale disconnect button") : I18n.tr("Connect", "Tailscale connect button")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: connButton.isConnected ? Theme.surfaceText : Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: connButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (TailscaleService.connected)
TailscaleService.disconnectTailscale(null);
else
TailscaleService.connectTailscale(null);
}
}
}
}
// Connection controls: exit node picker + LAN access.
// Only meaningful while the backend is connected.
Column {
id: controlsColumn
width: parent.width
spacing: Theme.spacingS
visible: TailscaleService.connected
readonly property string noneLabel: I18n.tr("None", "Tailscale exit node: none selected")
DankDropdown {
width: parent.width
text: I18n.tr("Exit node", "Tailscale exit node selector label")
currentValue: TailscaleService.currentExitNode ? TailscaleService.currentExitNode.hostname : controlsColumn.noneLabel
options: {
const opts = [controlsColumn.noneLabel];
for (const p of TailscaleService.exitNodeOptions)
opts.push(p.hostname);
return opts;
}
onValueChanged: value => {
if (value === controlsColumn.noneLabel) {
TailscaleService.clearExitNode(null);
return;
}
const peer = TailscaleService.exitNodeOptions.find(p => p.hostname === value);
if (peer)
TailscaleService.setExitNode(peer.id, null);
}
}
DankToggle {
width: parent.width
text: I18n.tr("Allow LAN access", "Tailscale allow LAN access toggle")
description: I18n.tr("Reach local network devices while using an exit node", "Tailscale allow LAN access description")
visible: TailscaleService.currentExitNode !== null
checked: TailscaleService.exitNodeAllowLanAccess
onToggled: value => TailscaleService.setAllowLanAccess(value, null)
}
}
// Search bar + refresh button
RowLayout {
width: parent.width
spacing: Theme.spacingS
DankTextField {
Layout.fillWidth: true
placeholderText: I18n.tr("Search devices...", "Tailscale device search placeholder")
leftIconName: "search"
showClearButton: true
text: detailRoot.searchQuery
onTextEdited: detailRoot.searchQuery = text
}
DankActionButton {
iconName: "sync"
buttonSize: 28
iconSize: 16
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Refresh", "Refresh Tailscale device status")
onClicked: TailscaleService.refresh(null)
}
}
// Filter chips
DankFilterChips {
width: parent.width
currentIndex: detailRoot.filterIndex
showCounts: true
chipHeight: 26
model: [
{
"label": I18n.tr("My Online", "Tailscale filter: my online devices"),
"count": TailscaleService.myOnlinePeers.length
},
{
"label": I18n.tr("Online", "Tailscale filter: all online devices"),
"count": TailscaleService.onlinePeers.length
},
{
"label": I18n.tr("All", "Tailscale filter: all devices"),
"count": TailscaleService.allPeersList.length
}
]
onSelectionChanged: index => {
detailRoot.filterIndex = index;
}
}
}
// Scrollable peer list — fills remaining space below header
DankFlickable {
anchors.top: headerColumn.bottom
anchors.topMargin: Theme.spacingS
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
contentHeight: peerListColumn.implicitHeight
clip: true
Column {
id: peerListColumn
width: parent.width
spacing: Theme.spacingXS
property var filteredPeers: {
let base;
switch (detailRoot.filterIndex) {
case 0:
base = TailscaleService.myOnlinePeers;
break;
case 1:
base = TailscaleService.onlinePeers;
break;
case 2:
base = TailscaleService.allPeersList;
break;
default:
base = [];
}
if (detailRoot.searchQuery.length > 0)
return TailscaleService.searchPeers(detailRoot.searchQuery, base);
return base;
}
// Empty state
Item {
width: parent.width
height: 60
visible: peerListColumn.filteredPeers.length === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "devices"
size: 28
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: detailRoot.searchQuery.length > 0 ? I18n.tr("No matching devices", "No Tailscale devices match search") : I18n.tr("No peers found", "No Tailscale peers found")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
// Peer cards
Repeater {
model: peerListColumn.filteredPeers
delegate: Rectangle {
required property var modelData
required property int index
width: peerListColumn.width
height: peerCardColumn.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "") ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.surfaceContainerHighest
property bool isSelf: modelData.hostname === (TailscaleService.selfNode ? TailscaleService.selfNode.hostname : "")
property bool isExpanded: detailRoot.expandedHostname === modelData.hostname
Column {
id: peerCardColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingS
spacing: 2
RowLayout {
width: parent.width
spacing: Theme.spacingS
Rectangle {
width: 8
height: 8
radius: 4
color: modelData.online ? "#4caf50" : Theme.surfaceVariantText
Layout.alignment: Qt.AlignVCenter
}
StyledText {
text: modelData.hostname || ""
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Bold
color: Theme.surfaceText
Layout.fillWidth: true
elide: Text.ElideRight
}
StyledText {
visible: isSelf
text: I18n.tr("This device", "Label for the user's own device in Tailscale")
font.pixelSize: 10
color: Theme.primary
font.weight: Font.Medium
}
}
RowLayout {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: modelData.tailscaleIp || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
Layout.fillWidth: true
}
DankActionButton {
iconName: "content_copy"
buttonSize: 20
iconSize: 11
iconColor: Theme.surfaceVariantText
tooltipText: I18n.tr("Copy", "Copy to clipboard")
onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.tailscaleIp])
}
}
StyledText {
text: {
const parts = [];
if (modelData.os)
parts.push(modelData.os);
if (modelData.online) {
parts.push(modelData.relay ? I18n.tr("relay: %1", "Tailscale relay server name").arg(modelData.relay) : I18n.tr("direct", "Tailscale direct connection"));
} else if (modelData.lastSeen) {
parts.push(I18n.tr("last seen %1", "Tailscale peer last seen time").arg(modelData.lastSeen));
}
return parts.join(" \u2022 ");
}
font.pixelSize: 10
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
// Expanded: DNS name + copy, tags, owner
Column {
visible: isExpanded
width: parent.width
spacing: 2
topPadding: 4
RowLayout {
width: parent.width
spacing: Theme.spacingXS
visible: (modelData.dnsName || "").length > 0
StyledText {
text: modelData.dnsName || ""
font.pixelSize: 10
color: Theme.surfaceVariantText
Layout.fillWidth: true
elide: Text.ElideRight
}
DankActionButton {
iconName: "content_copy"
buttonSize: 20
iconSize: 11
iconColor: Theme.surfaceVariantText
onClicked: Quickshell.execDetached(["dms", "cl", "copy", modelData.dnsName])
}
}
StyledText {
visible: (modelData.tags || []).length > 0
text: I18n.tr("Tags: %1", "Tailscale device tags").arg((modelData.tags || []).join(", "))
font.pixelSize: 10
color: Theme.surfaceVariantText
}
StyledText {
visible: (modelData.owner || "").length > 0
text: I18n.tr("Owner: %1", "Tailscale device owner").arg(modelData.owner || "")
font.pixelSize: 10
color: Theme.surfaceVariantText
}
}
}
MouseArea {
z: -1
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: detailRoot.expandedHostname = (detailRoot.expandedHostname === modelData.hostname) ? "" : modelData.hostname
}
}
}
}
}
}
}
}
}
}