Add Tailscale panel connection controls

This commit is contained in:
David Heinemeier Hansson
2026-05-26 22:07:20 +02:00
parent 48a741e3ee
commit 67dd7a2953
16 changed files with 1568 additions and 784 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ User-installed plugins live alongside these conceptually but on disk under
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
| Tailscale | `omarchy.tailscale` | `bar-widget` | `tailscale/Widget.qml` |
| Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` |
| Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
+1 -1
View File
@@ -68,7 +68,7 @@ Example `shell.json` (bar subtree only shown):
| `omarchy.audio` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = popup · scroll = volume |
| `omarchy.network` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui |
| `omarchy.tailscale` | Tailscale status, account switcher, peer browser, and copy actions | left = popup · right = toggle · middle = refresh |
| `omarchy.tailscale` | Tailscale status, connection switcher, machine browser, and copy actions | left = popup · right = toggle · middle = refresh |
| `omarchy.power` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup |
| `omarchy.bluetooth` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI |
| `omarchy.monitor` | Brightness and laptop display controls | left = popup |
+149
View File
@@ -0,0 +1,149 @@
function filterIPv4(ips) {
var result = []
if (!ips || typeof ips.length !== "number") return result
for (var i = 0; i < ips.length; i++) {
var ip = String(ips[i] || "")
if (/^100\./.test(ip)) result.push(ip)
}
return result
}
function filterIPv6(ips) {
var result = []
if (!ips || typeof ips.length !== "number") return result
for (var i = 0; i < ips.length; i++) {
var ip = String(ips[i] || "")
if (/^fd7a:115c:a1e0:/i.test(ip)) result.push(ip)
}
return result
}
function cleanDnsName(name) {
var value = String(name || "")
return value.charAt(value.length - 1) === "." ? value.slice(0, -1) : value
}
function shortDnsName(name) {
var clean = cleanDnsName(name)
if (clean === "") return ""
return clean.split(".")[0] || clean
}
function displayHostName(hostName, dnsName) {
var host = String(hostName || "")
if (host !== "" && host.toLowerCase() !== "localhost") return host
return shortDnsName(dnsName) || host || "Unknown"
}
function osIcon(os) {
var value = String(os || "").toLowerCase()
if (value === "linux") return "󰌽"
if (value === "macos" || value === "ios") return "󰀵"
if (value === "windows") return "󰍲"
if (value === "android") return "󰀲"
return "󰟀"
}
function accountLabel(account) {
if (!account) return "Unknown account"
if (account.nickname) return String(account.nickname)
if (account.tailnet) return String(account.tailnet)
if (account.account) return String(account.account)
return String(account.id || "Unknown account")
}
function parseStatus(raw) {
var text = String(raw || "").trim()
if (text === "") return { ok: true, unavailable: true, message: "Disconnected" }
try {
var data = JSON.parse(text)
var backendState = String(data.BackendState || "Unknown")
var self = data.Self || {}
var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || [])
var peers = []
var rawPeers = data.Peer || {}
for (var id in rawPeers) {
var peer = rawPeers[id] || {}
if (peer.Online !== true) continue
peers.push({
id: id,
HostName: displayHostName(peer.HostName, peer.DNSName),
DNSName: cleanDnsName(peer.DNSName),
TailscaleIPs: filterIPv4(peer.TailscaleIPs || []),
TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []),
Online: true,
OS: String(peer.OS || ""),
Tags: peer.Tags || [],
ExitNodeOption: peer.ExitNodeOption === true,
ExitNode: peer.ExitNode === true
})
}
peers.sort(function(a, b) {
return String(a.HostName).localeCompare(String(b.HostName))
})
return {
ok: true,
unavailable: false,
backendState: backendState,
running: backendState === "Running",
needsLogin: backendState === "NeedsLogin",
authUrl: String(data.AuthURL || ""),
selfName: displayHostName(self.HostName, self.DNSName),
selfDnsName: cleanDnsName(self.DNSName),
selfIp: selfIps.length > 0 ? selfIps[0] : "",
peers: peers
}
} catch (e) {
return { ok: false, unavailable: true, message: "Status error", error: "Failed to parse tailscale status" }
}
}
function parseAccounts(raw) {
var text = String(raw || "").trim()
if (text === "") return { accounts: [], selectedAccountId: "", selectedAccountLabel: "" }
try {
var parsed = JSON.parse(text)
var next = []
var selected = null
if (parsed && typeof parsed.length === "number") {
for (var i = 0; i < parsed.length; i++) {
var rawAccount = parsed[i] || {}
var account = {
id: String(rawAccount.id || rawAccount.ID || ""),
nickname: String(rawAccount.nickname || rawAccount.Nickname || rawAccount.name || rawAccount.Name || ""),
tailnet: String(rawAccount.tailnet || rawAccount.Tailnet || ""),
account: String(rawAccount.account || rawAccount.Account || rawAccount.loginName || rawAccount.LoginName || rawAccount.user || rawAccount.User || ""),
selected: rawAccount.selected === true || rawAccount.Selected === true
}
next.push(account)
if (account.selected === true) selected = account
}
}
return {
accounts: next,
selectedAccountId: selected ? String(selected.id || "") : "",
selectedAccountLabel: selected ? accountLabel(selected) : ""
}
} catch (e) {
return { accounts: [], selectedAccountId: "", selectedAccountLabel: "" }
}
}
if (typeof module !== "undefined") {
module.exports = {
filterIPv4: filterIPv4,
filterIPv6: filterIPv6,
cleanDnsName: cleanDnsName,
shortDnsName: shortDnsName,
displayHostName: displayHostName,
osIcon: osIcon,
accountLabel: accountLabel,
parseStatus: parseStatus,
parseAccounts: parseAccounts
}
}
+919
View File
@@ -0,0 +1,919 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
Panel {
id: root
moduleName: "omarchy.tailscale"
ipcTarget: "omarchy.tailscale"
manageIpc: false
property string focusSection: "header"
property int headerIndex: 0
property int accountIndex: 0
property int peerIndex: 0
property int exitNodeIndex: 0
property bool cursorActive: false
property int phraseIndex: 0
readonly property var activePhrases: [
"Encrypting connections",
"Sending secrets",
"Guarding wires",
"Braiding packets",
"Polishing tunnels",
"Hiding routes",
"Sealing ports",
"Sorting tailnets",
"Shuffling keys",
"Watching machines"
]
readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length]
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied
readonly property bool showPeers: tailscale.running && tailscale.peers.length > 0
readonly property var exitNodes: {
var nodes = []
for (var i = 0; i < tailscale.peers.length; i++) {
var peer = tailscale.peers[i]
if (peer && peer.ExitNodeOption === true) nodes.push(peer)
}
return nodes
}
readonly property bool showExitNodes: tailscale.running && exitNodes.length > 0
readonly property color iconColor: tailscale.running ? foreground : dim
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent"
function selectedPeer() {
if (tailscale.peers.length === 0) return null
return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.length - 1))]
}
function selectedExitNode() {
if (exitNodes.length === 0) return null
return exitNodes[Math.max(0, Math.min(exitNodeIndex, exitNodes.length - 1))]
}
function selectedAccount() {
if (tailscale.accounts.length === 0) return null
return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))]
}
function ensureCursor() {
if (headerIndex < 0) headerIndex = 0
if (headerIndex > 0) headerIndex = 0
if (accountIndex >= tailscale.accounts.length) accountIndex = Math.max(0, tailscale.accounts.length - 1)
if (peerIndex >= tailscale.peers.length) peerIndex = Math.max(0, tailscale.peers.length - 1)
if (exitNodeIndex >= exitNodes.length) exitNodeIndex = Math.max(0, exitNodes.length - 1)
if (focusSection === "auth" && !tailscale.accountsAccessDenied) focusSection = tailscale.accounts.length > 1 ? "accounts" : (showExitNodes ? "exitNodes" : (showPeers ? "peers" : "header"))
if (focusSection === "accounts" && tailscale.accounts.length <= 1) focusSection = tailscale.accountsAccessDenied ? "auth" : (showExitNodes ? "exitNodes" : (showPeers ? "peers" : "header"))
if (focusSection === "peers" && !showPeers) focusSection = showExitNodes ? "exitNodes" : (tailscale.accountsAccessDenied ? "auth" : (tailscale.accounts.length > 1 ? "accounts" : "header"))
if (focusSection === "exitNodes" && !showExitNodes) focusSection = showPeers ? "peers" : (tailscale.accountsAccessDenied ? "auth" : (tailscale.accounts.length > 1 ? "accounts" : "header"))
}
function moveCursor(dx, dy) {
cursorActive = true
ensureCursor()
if (dy !== 0) {
if (focusSection === "header") {
if (dy > 0) {
if (tailscale.accountsAccessDenied) focusSection = "auth"
else if (tailscale.accounts.length > 1) focusSection = "accounts"
else if (showExitNodes) focusSection = "exitNodes"
else if (showPeers) focusSection = "peers"
}
} else if (focusSection === "auth") {
if (dy < 0) focusSection = "header"
else if (tailscale.accounts.length > 1) focusSection = "accounts"
else if (showExitNodes) focusSection = "exitNodes"
else if (showPeers) focusSection = "peers"
} else if (focusSection === "accounts") {
if (dy < 0) {
if (accountIndex <= 0) focusSection = tailscale.accountsAccessDenied ? "auth" : "header"
else accountIndex--
} else {
if (accountIndex < tailscale.accounts.length - 1) accountIndex++
else if (showExitNodes) focusSection = "exitNodes"
else if (showPeers) focusSection = "peers"
}
} else if (focusSection === "peers") {
if (dy < 0) {
if (peerIndex <= 0) focusSection = showExitNodes ? "exitNodes" : (tailscale.accounts.length > 1 ? "accounts" : (tailscale.accountsAccessDenied ? "auth" : "header"))
else peerIndex--
} else if (peerIndex < tailscale.peers.length - 1) {
peerIndex++
}
} else if (focusSection === "exitNodes") {
if (dy < 0) {
if (exitNodeIndex <= 0) focusSection = tailscale.accounts.length > 1 ? "accounts" : (tailscale.accountsAccessDenied ? "auth" : "header")
else exitNodeIndex--
} else if (exitNodeIndex < exitNodes.length - 1) {
exitNodeIndex++
} else if (showPeers) {
focusSection = "peers"
}
}
}
ensureCursor()
scrollCursorIntoView()
}
function activateCursor() {
ensureCursor()
if (focusSection === "header") {
tailscale.toggleTailscale()
} else if (focusSection === "auth") {
tailscale.authorizeProfileSwitching()
} else if (focusSection === "accounts") {
var account = selectedAccount()
if (account) tailscale.switchAccount(account.id)
} else if (focusSection === "peers") {
tailscale.copyPeerIp(selectedPeer())
} else if (focusSection === "exitNodes") {
tailscale.setExitNode(selectedExitNode())
}
}
function scrollItemIntoView(item) {
if (!panelFlick || !item) return
Qt.callLater(function() {
if (!item) return
var margin = Style.space(6)
var point = item.mapToItem(panelFlick.contentItem, 0, 0)
var top = point.y
var bottom = top + item.height
var viewTop = panelFlick.contentY
var viewBottom = viewTop + panelFlick.height
var maxY = Math.max(0, panelFlick.contentHeight - panelFlick.height)
if (top < viewTop + margin) panelFlick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin) panelFlick.contentY = Math.min(maxY, bottom + margin - panelFlick.height)
})
}
function scrollCursorIntoView() {
if (focusSection === "peers" && peerColumn && peerIndex >= 0 && peerIndex < peerColumn.children.length) scrollItemIntoView(peerColumn.children[peerIndex])
else if (focusSection === "exitNodes" && exitNodeColumn && exitNodeIndex >= 0 && exitNodeIndex < exitNodeColumn.children.length) scrollItemIntoView(exitNodeColumn.children[exitNodeIndex])
}
function setPeerCursor(index) {
cursorActive = true
focusSection = "peers"
peerIndex = index
scrollCursorIntoView()
}
function setExitNodeCursor(index) {
cursorActive = true
focusSection = "exitNodes"
exitNodeIndex = index
scrollCursorIntoView()
}
function setAccountCursor(index) {
cursorActive = true
focusSection = "accounts"
accountIndex = index
}
function setAuthCursor() {
cursorActive = true
focusSection = "auth"
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onOpenedChanged: if (opened) {
cursorActive = false
if (panelFlick) panelFlick.contentY = 0
tailscale.refresh()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
onPeerIndexChanged: scrollCursorIntoView()
onExitNodeIndexChanged: scrollCursorIntoView()
onShowConnectionsChanged: ensureCursor()
onShowPeersChanged: ensureCursor()
onShowExitNodesChanged: ensureCursor()
Service {
id: tailscale
settings: root.settings
}
Connections {
target: tailscale
function onPeersChanged() { root.ensureCursor() }
function onAccountsChanged() { root.ensureCursor() }
function onAccountsAccessDeniedChanged() { root.ensureCursor() }
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function refresh(): string { tailscale.refresh(); return "ok" }
function up(): string { tailscale.loginOrUp(); return "ok" }
function down(): string { tailscale.runAction(["tailscale", "down"], "Turning Tailscale off…"); return "ok" }
function status(): string { return tailscale.statusText }
}
Item {
id: button
anchors.fill: parent
implicitWidth: root.bar && root.bar.vertical ? root.bar.barSize : Style.space(26)
implicitHeight: root.bar && root.bar.vertical ? Style.space(26) : (root.bar ? root.bar.barSize : Style.space(26))
property var registeredBar: null
function triggerPress(buttonCode) {
if (buttonCode === Qt.RightButton) tailscale.toggleTailscale()
else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
else root.toggle()
}
function syncClickRegistration() {
if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
registeredBar = root.bar
if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(button)
}
Component.onCompleted: syncClickRegistration()
Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
Connections {
target: root
function onBarChanged() { button.syncClickRegistration() }
}
TailscaleIcon {
anchors.centerIn: parent
iconSize: Style.space(12)
color: root.iconColor
badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin
warning: tailscale.needsLogin
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) { button.triggerPress(mouse.button) }
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(560))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
root.moveCursor(dx, dy)
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close()
onTextKey: function(t) {
if (t === "t" || t === "T") tailscale.toggleTailscale()
else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer())
else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer())
else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer())
}
Flickable {
id: panelFlick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: column
width: panelFlick.width
spacing: Style.space(12)
Item {
id: header
width: parent.width
implicitHeight: hero.implicitHeight
PanelHero {
id: hero
width: parent.width
title: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
meta: root.heroPhraseText
foreground: root.foreground
fontFamily: root.fontFamily
iconOpacity: tailscale.running ? 1.0 : 0.5
iconComponent: Component {
Item {
implicitWidth: icon.implicitWidth
implicitHeight: icon.implicitHeight
TailscaleIcon {
id: icon
iconSize: Style.font.display
color: root.iconColor
badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin
warning: tailscale.needsLogin
anchors.centerIn: parent
}
MouseArea {
id: heroIconMouse
anchors.fill: parent
hoverEnabled: true
enabled: tailscale.installed && !tailscale.busy
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) {
root.focusSection = "header"
root.headerIndex = 0
}
onClicked: tailscale.toggleTailscale()
}
}
}
}
}
Text {
visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
width: parent.width
text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError
color: tailscale.lastError !== "" && tailscale.actionStatus === "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
CursorSurface {
visible: !tailscale.installed
width: parent.width
implicitHeight: missingText.implicitHeight + Style.spacing.rowPaddingX
foreground: root.foreground
Text {
id: missingText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.space(12)
text: "Tailscale CLI is not installed or not on PATH."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
wrapMode: Text.WordWrap
}
}
PanelSeparator {
visible: root.showConnections
foreground: root.foreground
}
Column {
visible: root.showConnections
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "CONNECTIONS"
foreground: root.foreground
fontFamily: root.fontFamily
}
AuthRow {
visible: tailscale.accountsAccessDenied
width: parent.width
}
Repeater {
model: tailscale.accounts
AccountRow {
required property var modelData
required property int index
width: parent.width
account: modelData
rowIndex: index
}
}
}
PanelSeparator {
visible: root.showExitNodes
foreground: root.foreground
}
Column {
visible: root.showExitNodes
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "EXIT NODES"
foreground: root.foreground
fontFamily: root.fontFamily
}
Column {
id: exitNodeColumn
width: parent.width
spacing: Style.space(6)
Repeater {
model: root.exitNodes
ExitNodeRow {
required property var modelData
required property int index
width: exitNodeColumn.width
peer: modelData
rowIndex: index
}
}
}
}
PanelSeparator {
visible: tailscale.installed && tailscale.running
foreground: root.foreground
}
Column {
visible: tailscale.installed && tailscale.running
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "MACHINES"
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0
width: parent.width
text: "No machines found on this tailnet."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
horizontalAlignment: Text.AlignHCenter
}
Column {
id: peerColumn
visible: root.showPeers
width: parent.width
spacing: Style.space(6)
Repeater {
model: tailscale.peers
PeerRow {
required property var modelData
required property int index
width: peerColumn.width
peer: modelData
rowIndex: index
}
}
}
}
}
}
}
}
Timer {
id: phraseTimer
interval: 2800
running: root.opened
repeat: true
onTriggered: phraseSwap.restart()
}
SequentialAnimation {
id: phraseSwap
PropertyAnimation {
target: hero; property: "metaOpacity"
to: 0.0; duration: 180; easing.type: Easing.OutQuad
}
ScriptAction {
script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length
}
PropertyAnimation {
target: hero; property: "metaOpacity"
to: 1.0; duration: 260; easing.type: Easing.InQuad
}
}
component AuthRow: CursorSurface {
id: authRow
hasCursor: root.cursorActive && root.focusSection === "auth"
foreground: root.foreground
implicitHeight: row.implicitHeight + Style.spacing.rowPaddingX
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: tailscale.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
enabled: !tailscale.busy
onEntered: root.setAuthCursor()
onClicked: tailscale.authorizeProfileSwitching()
}
RowLayout {
id: row
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
text: "󰒃"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.heading
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: "Authorize switching"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: "Allow this user to see and switch Tailscale connections"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
PanelActionButton {
iconText: "󰄬"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: !tailscale.busy
Layout.alignment: Qt.AlignVCenter
onClicked: tailscale.authorizeProfileSwitching()
}
}
}
component AccountRow: CursorSurface {
id: accountRow
property var account: null
property int rowIndex: 0
readonly property bool selectedAccount: account && account.selected === true
readonly property bool switchingAccount: account && tailscale.switchingAccountId === String(account.id || "")
readonly property string accountText: account ? tailscale.accountLabel(account) : "Account"
hasCursor: root.cursorActive && root.focusSection === "accounts" && root.accountIndex === rowIndex
current: selectedAccount
foreground: root.foreground
fill: root.hoverFill
currentFill: root.selectedFill
implicitHeight: accountInner.implicitHeight + Style.spacing.xl
Row {
id: accountInner
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
spacing: Style.space(8)
Text {
id: accountGlyph
text: ""
color: accountRow.selectedAccount || accountRow.switchingAccount ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
NumberAnimation on rotation {
running: accountRow.switchingAccount
from: 0
to: 360
duration: 900
loops: Animation.Infinite
}
onRotationChanged: if (!accountRow.switchingAccount && rotation !== 0) rotation = 0
}
Text {
text: accountRow.accountText
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: accountRow.selectedAccount
elide: Text.ElideRight
width: parent.width - Style.space(22) - Style.space(8)
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.setAccountCursor(accountRow.rowIndex)
onClicked: if (accountRow.account) tailscale.switchAccount(accountRow.account.id)
}
}
component PeerRow: CursorSurface {
id: peerRow
property var peer: null
property int rowIndex: 0
readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown"
readonly property string peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : ""
readonly property string peerIpv6: {
if (!peer || !peer.TailscaleIPv6 || peer.TailscaleIPv6.length === 0) return ""
return String(peer.TailscaleIPv6[0] || "")
}
readonly property string peerDns: peer ? String(peer.DNSName || "") : ""
hasCursor: root.cursorActive && root.focusSection === "peers" && root.peerIndex === rowIndex
foreground: root.foreground
implicitHeight: Math.max(peerContent.implicitHeight, copyButton.implicitHeight) + Style.spacing.rowPaddingX
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
hoverEnabled: true
cursorShape: Qt.ArrowCursor
onContainsMouseChanged: if (containsMouse) root.setPeerCursor(peerRow.rowIndex)
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(8)
spacing: Style.space(8)
Text {
text: tailscale.osIcon(peer ? peer.OS : "")
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.icon
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
id: peerContent
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: peerRow.peerName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: {
var parts = []
if (peerRow.peerIp !== "") parts.push(peerRow.peerIp)
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
return parts.join(" · ")
}
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
PanelActionButton {
id: copyButton
iconText: "󰆏"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: peerRow.peerIp !== "" || peerRow.peerName !== "" || peerRow.peerDns !== "" || peerRow.peerIpv6 !== ""
Layout.alignment: Qt.AlignVCenter
onClicked: copyPopup.open()
}
Popup {
id: copyPopup
x: copyButton.x + copyButton.width - width
y: copyButton.y + copyButton.height + Style.space(4)
width: Style.space(280)
padding: 0
modal: false
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: Color.background
border.color: root.dim
border.width: 1
radius: Style.radius.md
}
contentItem: Column {
width: parent.width
CopyChoice {
width: parent.width
label: peerRow.peerName
enabled: peerRow.peerName !== ""
onChosen: {
copyPopup.close()
tailscale.copyPeerName(peerRow.peer)
}
}
CopyChoice {
width: parent.width
label: peerRow.peerDns
enabled: peerRow.peerDns !== ""
onChosen: {
copyPopup.close()
tailscale.copyPeerDnsName(peerRow.peer)
}
}
CopyChoice {
width: parent.width
label: peerRow.peerIpv6
enabled: peerRow.peerIpv6 !== ""
onChosen: {
copyPopup.close()
tailscale.copyToClipboard(peerRow.peerIpv6, peerRow.peerName + " IPv6")
}
}
CopyChoice {
width: parent.width
label: peerRow.peerIp
enabled: peerRow.peerIp !== ""
onChosen: {
copyPopup.close()
tailscale.copyPeerIp(peerRow.peer)
}
}
}
}
}
}
component CopyChoice: CursorSurface {
id: copyChoice
signal chosen()
property string label: ""
visible: enabled
foreground: root.foreground
implicitHeight: Style.space(48)
radius: 0
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: copyChoice.chosen()
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
spacing: Style.space(10)
Text {
Layout.fillWidth: true
text: copyChoice.label
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
text: "󰆏"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.icon
Layout.alignment: Qt.AlignVCenter
}
}
}
component ExitNodeRow: CursorSurface {
id: exitNodeRow
property var peer: null
property int rowIndex: 0
readonly property bool activeExitNode: peer && peer.ExitNode === true
readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "")
readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown"
hasCursor: root.cursorActive && root.focusSection === "exitNodes" && root.exitNodeIndex === rowIndex
current: activeExitNode || settingExitNode
foreground: root.foreground
fill: root.hoverFill
currentFill: root.selectedFill
implicitHeight: exitNodeInner.implicitHeight + Style.spacing.xl
Row {
id: exitNodeInner
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
spacing: Style.space(8)
Text {
id: exitNodeGlyph
text: tailscale.osIcon(peer ? peer.OS : "")
color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
width: Style.space(22)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
NumberAnimation on rotation {
running: exitNodeRow.settingExitNode
from: 0
to: 360
duration: 900
loops: Animation.Infinite
}
onRotationChanged: if (!exitNodeRow.settingExitNode && rotation !== 0) rotation = 0
}
Text {
text: exitNodeRow.peerName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: exitNodeRow.activeExitNode
elide: Text.ElideRight
width: parent.width - Style.space(22) - Style.space(8)
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.setExitNodeCursor(exitNodeRow.rowIndex)
onClicked: if (exitNodeRow.peer) tailscale.setExitNode(exitNodeRow.peer)
}
}
}
@@ -7,9 +7,9 @@ Native Omarchy bar widget for Tailscale.
- Shows Tailscale connection state in the bar
- Left click opens a keyboard-friendly panel
- Right click toggles Tailscale on/off
- Switch between logged-in Tailscale accounts when multiple are available
- Browse peers from `tailscale status --json`
- Copy a peer's Tailscale IP, host name, or DNS name
- Switch between available Tailscale connections when multiple are available
- Browse machines from `tailscale status --json`
- Copy a machine's Tailscale IP, host name, or DNS name
## Keyboard shortcuts
@@ -2,10 +2,10 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import "Model.js" as Model
Item {
id: root
visible: false
property var settings: ({})
@@ -23,15 +23,20 @@ Item {
property var accounts: []
property string selectedAccountId: ""
property string selectedAccountLabel: ""
property string switchingAccountId: ""
property string settingExitNodeId: ""
property bool accountsAccessDenied: false
property string actionStatus: ""
property string lastError: ""
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600)
readonly property bool busy: whichProcess.running || statusProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running
readonly property bool busy: whichProcess.running || statusProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running || operatorProcess.running || exitNodeProcess.running
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
property string _statusOutput: ""
property string _statusError: ""
property string _accountsOutput: ""
property string _accountsError: ""
property string _actionOutput: ""
property string _actionError: ""
property string _loginOutput: ""
@@ -42,6 +47,10 @@ Item {
property double _lastAccountsRefreshMs: 0
property string _switchOutput: ""
property string _switchError: ""
property string _exitNodeOutput: ""
property string _exitNodeError: ""
property string _operatorOutput: ""
property string _operatorError: ""
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
@@ -57,56 +66,33 @@ Item {
}
function filterIPv4(ips) {
var result = []
if (!ips || typeof ips.length !== "number") return result
for (var i = 0; i < ips.length; i++) {
var ip = String(ips[i] || "")
if (/^100\./.test(ip)) result.push(ip)
}
return result
return Model.filterIPv4(ips)
}
function cleanDnsName(name) {
var value = String(name || "")
return value.charAt(value.length - 1) === "." ? value.slice(0, -1) : value
return Model.cleanDnsName(name)
}
function shortDnsName(name) {
var clean = cleanDnsName(name)
if (clean === "") return ""
return clean.split(".")[0] || clean
return Model.shortDnsName(name)
}
function displayHostName(hostName, dnsName) {
var host = String(hostName || "")
if (host !== "" && host.toLowerCase() !== "localhost") return host
return shortDnsName(dnsName) || host || "Unknown"
return Model.displayHostName(hostName, dnsName)
}
function osIcon(os) {
var value = String(os || "").toLowerCase()
if (value === "linux") return "󰌽"
if (value === "macos" || value === "ios") return "󰀵"
if (value === "windows") return "󰍲"
if (value === "android") return "󰀲"
return "󰟀"
return Model.osIcon(os)
}
function accountLabel(account) {
if (!account) return "Unknown account"
var parts = []
if (account.nickname) parts.push(String(account.nickname))
if (account.tailnet && String(account.tailnet) !== String(account.nickname || "")) parts.push(String(account.tailnet))
if (account.account) parts.push(String(account.account))
return parts.length > 0 ? parts.join(" · ") : String(account.id || "Unknown account")
return Model.accountLabel(account)
}
function copyToClipboard(value, label) {
var text = String(value || "")
if (text === "") return
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"])
actionStatus = elideStatus("Copied " + (label || text))
actionStatusTimer.restart()
}
function copyPeerIp(peer) {
@@ -150,6 +136,7 @@ Item {
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
if (shouldRefreshAccounts && !accountsProcess.running) {
_accountsOutput = ""
_accountsError = ""
_lastAccountsRefreshMs = now
accountsProcess.command = ["tailscale", "switch", "--list", "--json"]
accountsProcess.running = true
@@ -174,116 +161,61 @@ Item {
accounts = []
selectedAccountId = ""
selectedAccountLabel = ""
switchingAccountId = ""
settingExitNodeId = ""
accountsAccessDenied = false
}
function parseStatus(raw) {
var text = String(raw || "").trim()
if (text === "") {
resetUnavailable("Disconnected")
var parsed = Model.parseStatus(raw)
if (!parsed.ok) {
resetUnavailable(parsed.message || "Status error")
lastError = parsed.error || "Failed to parse tailscale status"
console.warn("tailscale", lastError)
return
}
if (parsed.unavailable) {
resetUnavailable(parsed.message || "Disconnected")
return
}
try {
var data = JSON.parse(text)
backendState = String(data.BackendState || "Unknown")
running = backendState === "Running"
needsLogin = backendState === "NeedsLogin"
authUrl = String(data.AuthURL || "")
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) {
openAuthUrlFrom(authUrl, false)
}
backendState = parsed.backendState
running = parsed.running
needsLogin = parsed.needsLogin
authUrl = parsed.authUrl
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false)
selfName = parsed.selfName
selfDnsName = parsed.selfDnsName
selfIp = parsed.selfIp
peers = parsed.running ? parsed.peers : []
var self = data.Self || {}
selfName = displayHostName(self.HostName, self.DNSName)
selfDnsName = cleanDnsName(self.DNSName)
var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || [])
selfIp = selfIps.length > 0 ? selfIps[0] : ""
var nextPeers = []
var rawPeers = data.Peer || {}
for (var id in rawPeers) {
var peer = rawPeers[id] || {}
var ipv4s = filterIPv4(peer.TailscaleIPs || [])
nextPeers.push({
id: id,
HostName: displayHostName(peer.HostName, peer.DNSName),
DNSName: cleanDnsName(peer.DNSName),
TailscaleIPs: ipv4s,
Online: peer.Online === true,
OS: String(peer.OS || ""),
Tags: peer.Tags || [],
ExitNodeOption: peer.ExitNodeOption === true,
ExitNode: peer.ExitNode === true
})
}
nextPeers.sort(function(a, b) {
if (a.Online !== b.Online) return a.Online ? -1 : 1
return String(a.HostName).localeCompare(String(b.HostName))
})
peers = nextPeers
if (needsLogin) statusText = "Needs login"
else if (running) {
statusText = "Connected"
_loginInProgress = false
_loginUrlOpened = false
_preLoginAuthUrl = ""
loginTimeoutTimer.stop()
} else if (backendState === "Stopped") statusText = "Disconnected"
else statusText = backendState
lastError = ""
} catch (e) {
resetUnavailable("Status error")
lastError = "Failed to parse tailscale status"
console.warn("tailscale", lastError, e)
if (needsLogin) statusText = "Needs login"
else if (running) {
statusText = "Connected"
_loginInProgress = false
_loginUrlOpened = false
_preLoginAuthUrl = ""
loginTimeoutTimer.stop()
} else if (backendState === "Stopped") {
statusText = "Disconnected"
} else {
statusText = backendState
}
lastError = ""
}
function parseAccounts(raw) {
var text = String(raw || "").trim()
if (text === "") {
accounts = []
selectedAccountId = ""
selectedAccountLabel = ""
return
}
try {
var parsed = JSON.parse(text)
var next = []
var selected = null
if (parsed && typeof parsed.length === "number") {
for (var i = 0; i < parsed.length; i++) {
var raw = parsed[i] || {}
var account = {
id: String(raw.id || raw.ID || ""),
nickname: String(raw.nickname || raw.Nickname || raw.name || raw.Name || ""),
tailnet: String(raw.tailnet || raw.Tailnet || ""),
account: String(raw.account || raw.Account || raw.loginName || raw.LoginName || raw.user || raw.User || ""),
selected: raw.selected === true || raw.Selected === true
}
next.push(account)
if (account.selected === true) selected = account
}
}
accounts = next
selectedAccountId = selected ? String(selected.id || "") : ""
selectedAccountLabel = selected ? accountLabel(selected) : ""
} catch (e) {
accounts = []
selectedAccountId = ""
selectedAccountLabel = ""
console.warn("tailscale", "Failed to parse account list", e)
}
var parsed = Model.parseAccounts(raw)
accounts = parsed.accounts
selectedAccountId = parsed.selectedAccountId
selectedAccountLabel = parsed.selectedAccountLabel
accountsAccessDenied = false
}
function toggleTailscale() {
if (!installed) return
if (running) {
runAction(["tailscale", "down"], "Turning Tailscale off…")
} else {
loginOrUp()
}
if (running) runAction(["tailscale", "down"], "Turning Tailscale off…")
else loginOrUp()
}
function loginOrUp() {
@@ -306,11 +238,39 @@ Item {
if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return
_switchOutput = ""
_switchError = ""
actionStatus = "Switching Tailscale account…"
switchingAccountId = accountId
switchProcess.command = ["tailscale", "switch", accountId]
switchProcess.running = true
}
function exitNodeTarget(peer) {
if (!peer) return ""
if (peer.DNSName) return cleanDnsName(peer.DNSName)
if (peer.HostName) return String(peer.HostName)
var ips = filterIPv4(peer.TailscaleIPs || [])
return ips.length > 0 ? ips[0] : ""
}
function setExitNode(peer) {
if (!installed || !running || !peer || exitNodeProcess.running) return
var target = exitNodeTarget(peer)
if (target === "") return
_exitNodeOutput = ""
_exitNodeError = ""
settingExitNodeId = String(peer.id || "")
exitNodeProcess.command = ["tailscale", "set", "--exit-node=" + target]
exitNodeProcess.running = true
}
function authorizeProfileSwitching() {
if (!installed || operatorProcess.running || userName === "") return
_operatorOutput = ""
_operatorError = ""
actionStatus = "Authorizing Tailscale profiles…"
operatorProcess.command = ["pkexec", "tailscale", "set", "--operator=" + userName]
operatorProcess.running = true
}
function runAction(command, label) {
if (actionProcess.running) return
_actionOutput = ""
@@ -416,10 +376,20 @@ Item {
running: false
command: []
stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text }
stderr: StdioCollector { id: accountsStderr; waitForEnd: true; onStreamFinished: root._accountsError = text }
onExited: function(exitCode) {
var stdout = String(accountsStdout.text || root._accountsOutput || "")
var stderr = String(accountsStderr.text || root._accountsError || "")
if (exitCode === 0) root.parseAccounts(stdout)
else root.parseAccounts("")
else {
root.parseAccounts("")
if (/profiles access denied/i.test(stderr) || /profiles access denied/i.test(stdout)) {
root.accountsAccessDenied = true
root.lastError = "Authorize Tailscale profiles to show connections"
} else {
root.lastError = elideStatus(stderr || stdout || "Could not list Tailscale connections")
}
}
}
}
@@ -478,20 +448,55 @@ Item {
root.actionStatus = root.lastError
} else {
root.lastError = ""
root.actionStatus = "Switched account"
root.actionStatus = ""
root._lastAccountsRefreshMs = 0
}
root.switchingAccountId = ""
delayedRefresh.restart()
}
}
Process {
id: exitNodeProcess
running: false
command: []
stdout: StdioCollector { id: exitNodeStdout; waitForEnd: true; onStreamFinished: root._exitNodeOutput = text }
stderr: StdioCollector { id: exitNodeStderr; waitForEnd: true; onStreamFinished: root._exitNodeError = text }
onExited: function(exitCode) {
var stdout = String(exitNodeStdout.text || root._exitNodeOutput || "")
var stderr = String(exitNodeStderr.text || root._exitNodeError || "")
if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Exit node selection failed")
root.actionStatus = root.lastError
} else {
root.lastError = ""
root.actionStatus = ""
}
root.settingExitNodeId = ""
delayedRefresh.restart()
}
}
Process {
id: operatorProcess
running: false
command: []
stdout: StdioCollector { id: operatorStdout; waitForEnd: true; onStreamFinished: root._operatorOutput = text }
stderr: StdioCollector { id: operatorStderr; waitForEnd: true; onStreamFinished: root._operatorError = text }
onExited: function(exitCode) {
var stdout = String(operatorStdout.text || root._operatorOutput || "")
var stderr = String(operatorStderr.text || root._operatorError || "")
if (exitCode !== 0) {
root.lastError = elideStatus(stderr || stdout || "Tailscale authorization failed")
root.actionStatus = root.lastError
} else {
root.accountsAccessDenied = false
root.lastError = ""
root.actionStatus = "Tailscale profiles authorized"
actionStatusTimer.restart()
root._lastAccountsRefreshMs = 0
}
delayedRefresh.restart()
}
}
IpcHandler {
target: "omarchy.tailscale"
function refresh(): string { root.refresh(); return "ok" }
function toggle(): string { root.toggleTailscale(); return "ok" }
function up(): string { root.loginOrUp(); return "ok" }
function down(): string { root.runAction(["tailscale", "down"], "Turning Tailscale off…"); return "ok" }
function status(): string { return root.statusText }
}
}
@@ -5,16 +5,16 @@
"version": "1.0.0",
"author": "Omarchy",
"license": "MIT",
"description": "Tailscale status, account switching, peer browsing, and quick copy actions in the Omarchy bar.",
"description": "Tailscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Widget.qml"
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Tailscale",
"description": "Toggle Tailscale, switch accounts, browse peers, and copy peer IPs or names.",
"description": "Toggle Tailscale, switch connections, browse machines, and copy machine IPs or names.",
"category": "Network",
"allowMultiple": false,
"defaults": {
-637
View File
@@ -1,637 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Ui
BarWidget {
id: root
moduleName: "omarchy.tailscale"
property bool popupOpen: false
property string focusSection: "header"
property int headerIndex: 0
property int accountIndex: 0
property int peerIndex: 0
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property color card: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.055)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property bool showAccounts: tailscale.accounts.length > 1
readonly property bool showPeers: tailscale.peers.length > 0
readonly property color iconColor: tailscale.running ? foreground : (tailscale.needsLogin ? urgent : dim)
function close() { popupOpen = false }
function openPanel() {
popupOpen = true
tailscale.refresh()
}
function togglePanel() {
if (popupOpen) close()
else openPanel()
}
function tooltipText() {
var lines = ["Tailscale: " + tailscale.statusText]
if (tailscale.selfIp !== "") lines.push(tailscale.selfName + " · " + tailscale.selfIp)
if (tailscale.selectedAccountLabel !== "") lines.push(tailscale.selectedAccountLabel)
if (tailscale.running) lines.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
return lines.join("\n")
}
function selectedPeer() {
if (tailscale.peers.length === 0) return null
return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.length - 1))]
}
function selectedAccount() {
if (tailscale.accounts.length === 0) return null
return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))]
}
function ensureCursor() {
if (headerIndex < 0) headerIndex = 0
if (headerIndex > 1) headerIndex = 1
if (accountIndex >= tailscale.accounts.length) accountIndex = Math.max(0, tailscale.accounts.length - 1)
if (peerIndex >= tailscale.peers.length) peerIndex = Math.max(0, tailscale.peers.length - 1)
if (focusSection === "accounts" && !showAccounts) focusSection = showPeers ? "peers" : "header"
if (focusSection === "peers" && !showPeers) focusSection = showAccounts ? "accounts" : "header"
}
function moveCursor(dx, dy) {
ensureCursor()
if (dy !== 0) {
if (focusSection === "header") {
if (dy > 0) {
if (showAccounts) focusSection = "accounts"
else if (showPeers) focusSection = "peers"
}
} else if (focusSection === "accounts") {
if (dy < 0) {
if (accountIndex <= 0) focusSection = "header"
else accountIndex--
} else {
if (accountIndex < tailscale.accounts.length - 1) accountIndex++
else if (showPeers) focusSection = "peers"
}
} else if (focusSection === "peers") {
if (dy < 0) {
if (peerIndex <= 0) focusSection = showAccounts ? "accounts" : "header"
else peerIndex--
} else if (peerIndex < tailscale.peers.length - 1) {
peerIndex++
}
}
}
if (dx !== 0 && focusSection === "header") headerIndex = (headerIndex + dx + 2) % 2
ensureCursor()
scrollPeerIntoView()
}
function activateCursor() {
ensureCursor()
if (focusSection === "header") {
if (headerIndex === 0) tailscale.toggleTailscale()
else tailscale.refresh()
} else if (focusSection === "accounts") {
var account = selectedAccount()
if (account) tailscale.switchAccount(account.id)
} else if (focusSection === "peers") {
tailscale.copyPeerIp(selectedPeer())
}
}
function scrollPeerIntoView() {
if (focusSection !== "peers" || !peerFlick || !peerColumn) return
Qt.callLater(function() {
if (root.focusSection !== "peers" || root.peerIndex < 0 || root.peerIndex >= peerColumn.children.length) return
var item = peerColumn.children[root.peerIndex]
if (!item) return
var margin = Style.space(6)
var top = item.y
var bottom = top + item.height
var viewTop = peerFlick.contentY
var viewBottom = viewTop + peerFlick.height
var maxY = Math.max(0, peerFlick.contentHeight - peerFlick.height)
if (top < viewTop + margin) peerFlick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin) peerFlick.contentY = Math.min(maxY, bottom + margin - peerFlick.height)
})
}
function setPeerCursor(index) {
focusSection = "peers"
peerIndex = index
scrollPeerIntoView()
}
function setAccountCursor(index) {
focusSection = "accounts"
accountIndex = index
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onPopupOpenChanged: if (popupOpen) Qt.callLater(function() { keyCatcher.forceActiveFocus() })
onPeerIndexChanged: scrollPeerIntoView()
onShowAccountsChanged: ensureCursor()
onShowPeersChanged: ensureCursor()
Main {
id: tailscale
settings: root.settings
onPeersChanged: root.ensureCursor()
onAccountsChanged: root.ensureCursor()
}
Item {
id: button
anchors.fill: parent
implicitWidth: root.bar && root.bar.vertical ? root.bar.barSize : Style.space(26)
implicitHeight: root.bar && root.bar.vertical ? Style.space(26) : (root.bar ? root.bar.barSize : Style.space(26))
property var registeredBar: null
readonly property bool tooltipHovered: mouseArea.containsMouse
function triggerPress(buttonCode) {
if (root.bar) root.bar.hideTooltip(button)
if (buttonCode === Qt.RightButton) tailscale.toggleTailscale()
else if (buttonCode === Qt.MiddleButton) tailscale.refresh()
else root.togglePanel()
}
function syncClickRegistration() {
if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
registeredBar = root.bar
if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(button)
}
Component.onCompleted: syncClickRegistration()
Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
Connections {
target: root
function onBarChanged() { button.syncClickRegistration() }
}
TailscaleIcon {
anchors.centerIn: parent
iconSize: Style.space(12)
color: root.iconColor
badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin
warning: tailscale.needsLogin
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: if (root.bar) root.bar.showTooltip(button, root.tooltipText())
onExited: if (root.bar) root.bar.hideTooltip(button)
onClicked: function(mouse) { button.triggerPress(mouse.button) }
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(400))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(580))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
onActivateRequested: root.activateCursor()
onCloseRequested: root.close()
onTextKey: function(t) {
if (t === "r" || t === "R") tailscale.refresh()
else if (t === "t" || t === "T") tailscale.toggleTailscale()
else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer())
else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer())
else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer())
}
Flickable {
id: panelFlick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: column
width: panelFlick.width
spacing: Style.space(12)
Item {
id: header
width: parent.width
implicitHeight: Math.max(heroIcon.implicitHeight, heroText.implicitHeight, headerActions.implicitHeight)
TailscaleIcon {
id: heroIcon
iconSize: Style.font.display
color: root.iconColor
badgeColor: root.urgent
crossed: !tailscale.running && !tailscale.needsLogin
warning: tailscale.needsLogin
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
Column {
id: heroText
anchors.left: heroIcon.right
anchors.leftMargin: Style.space(12)
anchors.right: headerActions.left
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
width: parent.width
text: tailscale.installed ? (tailscale.selfName || "Tailscale") : "Tailscale"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
}
Text {
width: parent.width
text: {
var parts = [tailscale.statusText]
if (tailscale.selfIp !== "") parts.push(tailscale.selfIp)
if (tailscale.running) parts.push(tailscale.peers.length + " peer" + (tailscale.peers.length === 1 ? "" : "s"))
return parts.join(" · ").toUpperCase()
}
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
}
}
Row {
id: headerActions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(5)
PanelActionButton {
id: toggleBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "⏻"
fontSize: Style.font.heading
size: Style.space(30)
tooltipText: tailscale.running ? "Disconnect Tailscale" : (tailscale.needsLogin ? "Log in to Tailscale" : "Connect Tailscale")
foreground: root.foreground
hoverColor: tailscale.running ? root.urgent : root.foreground
fontFamily: root.fontFamily
hasCursor: root.focusSection === "header" && root.headerIndex === 0
enabled: tailscale.installed && !tailscale.busy
onHovered: function(h) {
if (!h) return
root.focusSection = "header"
root.headerIndex = 0
}
onClicked: tailscale.toggleTailscale()
}
PanelActionButton {
id: refreshBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "󰑐"
fontSize: Style.font.heading
size: Style.space(30)
tooltipText: "Refresh"
foreground: root.foreground
fontFamily: root.fontFamily
hasCursor: root.focusSection === "header" && root.headerIndex === 1
enabled: !tailscale.busy
onHovered: function(h) {
if (!h) return
root.focusSection = "header"
root.headerIndex = 1
}
onClicked: tailscale.refresh()
}
}
}
Text {
visible: tailscale.actionStatus !== "" || tailscale.lastError !== ""
width: parent.width
text: tailscale.actionStatus !== "" ? tailscale.actionStatus : tailscale.lastError
color: tailscale.lastError !== "" && tailscale.actionStatus === "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Rectangle {
visible: !tailscale.installed
width: parent.width
implicitHeight: missingText.implicitHeight + Style.space(24)
color: root.card
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06)
border.width: Style.normalBorderWidth
radius: Style.cornerRadius
Text {
id: missingText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.space(12)
text: "Tailscale CLI is not installed or not on PATH."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
}
PanelSeparator {
visible: root.showAccounts
foreground: root.foreground
}
Column {
visible: root.showAccounts
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "ACCOUNTS"
foreground: root.foreground
fontFamily: root.fontFamily
}
Repeater {
model: tailscale.accounts
AccountRow {
required property var modelData
required property int index
width: parent.width
account: modelData
rowIndex: index
}
}
}
PanelSeparator {
visible: tailscale.installed
foreground: root.foreground
}
Column {
visible: tailscale.installed
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "PEERS"
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
visible: tailscale.installed && tailscale.running && tailscale.peers.length === 0
width: parent.width
text: "No peers found on this tailnet."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
horizontalAlignment: Text.AlignHCenter
}
Text {
visible: tailscale.installed && !tailscale.running
width: parent.width
text: tailscale.needsLogin ? "Log in to see tailnet peers." : "Turn Tailscale on to see tailnet peers."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
horizontalAlignment: Text.AlignHCenter
}
Flickable {
id: peerFlick
visible: tailscale.peers.length > 0
width: parent.width
height: Math.min(peerColumn.implicitHeight, Style.space(340))
contentWidth: width
contentHeight: peerColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: peerColumn
width: peerFlick.width
spacing: Style.space(6)
Repeater {
model: tailscale.peers
PeerRow {
required property var modelData
required property int index
width: peerColumn.width
peer: modelData
rowIndex: index
}
}
}
}
}
}
}
}
}
component AccountRow: Rectangle {
id: accountRow
property var account: null
property int rowIndex: 0
readonly property bool selectedAccount: account && account.selected === true
readonly property bool hasCursor: root.focusSection === "accounts" && root.accountIndex === rowIndex
implicitHeight: row.implicitHeight + Style.space(12)
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : (selectedAccount ? root.card : "transparent")
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : "transparent"
border.width: hasCursor ? Style.hoverBorderWidth : 0
radius: Style.cornerRadius
RowLayout {
id: row
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
text: accountRow.selectedAccount ? "●" : "○"
color: accountRow.selectedAccount ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: account ? (account.nickname || account.tailnet || account.account || account.id || "Account") : "Account"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: account ? [account.tailnet || "", account.account || ""].filter(function(x) { return String(x) !== "" }).join(" · ") : ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.setAccountCursor(accountRow.rowIndex)
onClicked: if (accountRow.account) tailscale.switchAccount(accountRow.account.id)
}
}
component PeerRow: Rectangle {
id: peerRow
property var peer: null
property int rowIndex: 0
readonly property bool online: peer && peer.Online === true
readonly property bool hasCursor: root.focusSection === "peers" && root.peerIndex === rowIndex
readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown"
readonly property string peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : ""
readonly property string peerDns: peer ? String(peer.DNSName || "") : ""
implicitHeight: Math.max(peerContent.implicitHeight, actions.implicitHeight) + Style.space(12)
color: hasCursor ? Style.hoverFillFor(root.foreground, root.urgent) : root.card
border.color: hasCursor ? Style.hoverBorderFor(root.foreground, root.urgent) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
border.width: hasCursor ? Style.hoverBorderWidth : Style.normalBorderWidth
radius: Style.cornerRadius
opacity: online ? 1.0 : 0.62
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(8)
spacing: Style.space(8)
Text {
text: tailscale.osIcon(peer ? peer.OS : "")
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.icon
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
id: peerContent
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: peerRow.peerName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: {
var parts = []
if (peerRow.peerIp !== "") parts.push(peerRow.peerIp)
if (peerRow.peerDns !== "") parts.push(peerRow.peerDns)
if (!peerRow.online) parts.push("offline")
return parts.join(" · ")
}
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
Row {
id: actions
spacing: Style.space(2)
Layout.alignment: Qt.AlignVCenter
PanelActionButton {
iconText: "󰆏"
tooltipText: "Copy IP"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: peerRow.peerIp !== ""
onClicked: tailscale.copyPeerIp(peerRow.peer)
}
PanelActionButton {
iconText: "󰉿"
tooltipText: "Copy name"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: peerRow.peerName !== ""
onClicked: tailscale.copyPeerName(peerRow.peer)
}
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
z: -1
onEntered: root.setPeerCursor(peerRow.rowIndex)
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) tailscale.copyPeerName(peerRow.peer)
else if (mouse.button === Qt.MiddleButton) tailscale.copyPeerDnsName(peerRow.peer)
else tailscale.copyPeerIp(peerRow.peer)
}
}
}
}