Add Tailscale panel connection controls
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Tailscale Omarchy Widget
|
||||
|
||||
Native Omarchy bar widget for Tailscale.
|
||||
|
||||
## Features
|
||||
|
||||
- Shows Tailscale connection state in the bar
|
||||
- Left click opens a keyboard-friendly panel
|
||||
- Right click toggles Tailscale on/off
|
||||
- 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
|
||||
|
||||
Inside the panel:
|
||||
|
||||
- `j` / `k` or arrows: move cursor
|
||||
- `enter` / `space`: activate current row
|
||||
- `c`: copy selected peer IP
|
||||
- `n`: copy selected peer name
|
||||
- `d`: copy selected peer DNS name
|
||||
- `t`: toggle Tailscale
|
||||
- `r`: refresh status
|
||||
- `esc`: close
|
||||
|
||||
## Requirements
|
||||
|
||||
- `tailscale` CLI on `PATH`
|
||||
- `wl-copy` for clipboard copy actions
|
||||
|
||||
## Icon
|
||||
|
||||
Renders the Tailscale mark natively as a theme-colored 3×3 dot grid, matching the official SVG silhouette while avoiding tiny-SVG rendering quirks in the bar.
|
||||
|
||||
## Add to the bar
|
||||
|
||||
This widget ships as first-party plugin `omarchy.tailscale`. Add it through Omarchy's bar settings UI, or add an entry such as `{ "id": "omarchy.tailscale" }` to one of the `bar.layout` sections in `~/.config/omarchy/shell.json` and restart the shell.
|
||||
@@ -0,0 +1,502 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import "Model.js" as Model
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var settings: ({})
|
||||
|
||||
property bool installed: false
|
||||
property bool running: false
|
||||
property bool needsLogin: false
|
||||
property bool refreshing: false
|
||||
property string backendState: "Unknown"
|
||||
property string statusText: "Checking…"
|
||||
property string selfName: ""
|
||||
property string selfDnsName: ""
|
||||
property string selfIp: ""
|
||||
property string authUrl: ""
|
||||
property var peers: []
|
||||
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 || 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: ""
|
||||
property string _loginError: ""
|
||||
property bool _loginInProgress: false
|
||||
property bool _loginUrlOpened: false
|
||||
property string _preLoginAuthUrl: ""
|
||||
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
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
function intSetting(name, fallback, min, max) {
|
||||
var n = parseInt(String(setting(name, fallback)), 10)
|
||||
if (!isFinite(n)) n = fallback
|
||||
if (n < min) n = min
|
||||
if (n > max) n = max
|
||||
return n
|
||||
}
|
||||
|
||||
function filterIPv4(ips) {
|
||||
return Model.filterIPv4(ips)
|
||||
}
|
||||
|
||||
function cleanDnsName(name) {
|
||||
return Model.cleanDnsName(name)
|
||||
}
|
||||
|
||||
function shortDnsName(name) {
|
||||
return Model.shortDnsName(name)
|
||||
}
|
||||
|
||||
function displayHostName(hostName, dnsName) {
|
||||
return Model.displayHostName(hostName, dnsName)
|
||||
}
|
||||
|
||||
function osIcon(os) {
|
||||
return Model.osIcon(os)
|
||||
}
|
||||
|
||||
function accountLabel(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"])
|
||||
}
|
||||
|
||||
function copyPeerIp(peer) {
|
||||
if (!peer) return
|
||||
var ips = filterIPv4(peer.TailscaleIPs || [])
|
||||
copyToClipboard(ips.length > 0 ? ips[0] : "", displayHostName(peer.HostName, peer.DNSName) + " IP")
|
||||
}
|
||||
|
||||
function copyPeerName(peer) {
|
||||
if (!peer) return
|
||||
copyToClipboard(displayHostName(peer.HostName, peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " name")
|
||||
}
|
||||
|
||||
function copyPeerDnsName(peer) {
|
||||
if (!peer) return
|
||||
copyToClipboard(cleanDnsName(peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " DNS name")
|
||||
}
|
||||
|
||||
function refresh(forceAccounts) {
|
||||
if (installed) {
|
||||
refreshStatusAndAccounts(forceAccounts === true)
|
||||
return
|
||||
}
|
||||
if (!whichProcess.running) {
|
||||
refreshing = true
|
||||
whichProcess.command = ["which", "tailscale"]
|
||||
whichProcess.running = true
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStatusAndAccounts(forceAccounts) {
|
||||
if (!installed) return
|
||||
if (!statusProcess.running) {
|
||||
_statusOutput = ""
|
||||
_statusError = ""
|
||||
refreshing = true
|
||||
statusProcess.command = ["tailscale", "status", "--json"]
|
||||
statusProcess.running = true
|
||||
}
|
||||
var now = Date.now()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function elideStatus(text) {
|
||||
var value = String(text || "").replace(/\s+/g, " ").trim()
|
||||
return value.length > 140 ? value.substring(0, 137) + "…" : value
|
||||
}
|
||||
|
||||
function resetUnavailable(message) {
|
||||
running = false
|
||||
needsLogin = false
|
||||
backendState = "Unavailable"
|
||||
statusText = message
|
||||
selfName = ""
|
||||
selfDnsName = ""
|
||||
selfIp = ""
|
||||
authUrl = ""
|
||||
peers = []
|
||||
accounts = []
|
||||
selectedAccountId = ""
|
||||
selectedAccountLabel = ""
|
||||
switchingAccountId = ""
|
||||
settingExitNodeId = ""
|
||||
accountsAccessDenied = false
|
||||
}
|
||||
|
||||
function parseStatus(raw) {
|
||||
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
|
||||
}
|
||||
|
||||
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 : []
|
||||
|
||||
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 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()
|
||||
}
|
||||
|
||||
function loginOrUp() {
|
||||
if (!installed || loginProcess.running) return
|
||||
_loginOutput = ""
|
||||
_loginError = ""
|
||||
actionStatus = needsLogin ? "Starting Tailscale login…" : "Turning Tailscale on…"
|
||||
_loginInProgress = needsLogin
|
||||
_loginUrlOpened = false
|
||||
_preLoginAuthUrl = authUrl
|
||||
var command = ["tailscale", "up"]
|
||||
if (needsLogin) command.push("--force-reauth")
|
||||
loginProcess.command = command
|
||||
loginProcess.running = true
|
||||
if (needsLogin) loginTimeoutTimer.restart()
|
||||
}
|
||||
|
||||
function switchAccount(id) {
|
||||
var accountId = String(id || "")
|
||||
if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return
|
||||
_switchOutput = ""
|
||||
_switchError = ""
|
||||
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 = ""
|
||||
_actionError = ""
|
||||
actionStatus = label || "Working…"
|
||||
actionProcess.command = command
|
||||
actionProcess.running = true
|
||||
}
|
||||
|
||||
function openAuthUrlFrom(text, allowFallback) {
|
||||
if (_loginUrlOpened) return true
|
||||
var match = String(text || "").match(/https?:\/\/\S+/)
|
||||
var url = match && match[0] ? match[0] : (allowFallback === true ? authUrl : "")
|
||||
if (url !== "") {
|
||||
_loginUrlOpened = true
|
||||
_loginInProgress = false
|
||||
loginTimeoutTimer.stop()
|
||||
Qt.openUrlExternally(url)
|
||||
actionStatus = "Opened login link"
|
||||
actionStatusTimer.restart()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleLoginOutput(data, isError) {
|
||||
var text = String(data || "")
|
||||
if (isError) _loginError += text + "\n"
|
||||
else _loginOutput += text + "\n"
|
||||
if (_loginInProgress && !_loginUrlOpened) openAuthUrlFrom(text, false)
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: root.refreshIntervalSec * 1000
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: delayedRefresh
|
||||
interval: 600
|
||||
repeat: false
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: actionStatusTimer
|
||||
interval: 2200
|
||||
repeat: false
|
||||
onTriggered: root.actionStatus = ""
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: loginTimeoutTimer
|
||||
interval: 10000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (!root._loginInProgress || root._loginUrlOpened) return
|
||||
if (!root.openAuthUrlFrom(root.authUrl, true)) {
|
||||
root._loginInProgress = false
|
||||
root.actionStatus = "Tailscale login link not available yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: whichProcess
|
||||
running: false
|
||||
command: []
|
||||
onExited: function(exitCode) {
|
||||
root.installed = exitCode === 0
|
||||
if (root.installed) root.refreshStatusAndAccounts()
|
||||
else {
|
||||
root.refreshing = false
|
||||
root.resetUnavailable("Not installed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusProcess
|
||||
running: false
|
||||
command: []
|
||||
stdout: StdioCollector { id: statusStdout; waitForEnd: true; onStreamFinished: root._statusOutput = text }
|
||||
stderr: StdioCollector { id: statusStderr; waitForEnd: true; onStreamFinished: root._statusError = text }
|
||||
onExited: function(exitCode) {
|
||||
root.refreshing = false
|
||||
var stdout = String(statusStdout.text || root._statusOutput || "")
|
||||
var stderr = String(statusStderr.text || root._statusError || "")
|
||||
if (exitCode === 0) root.parseStatus(stdout)
|
||||
else {
|
||||
root.resetUnavailable("Disconnected")
|
||||
root.lastError = stderr.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: accountsProcess
|
||||
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("")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: actionProcess
|
||||
running: false
|
||||
command: []
|
||||
stdout: StdioCollector { id: actionStdout; waitForEnd: true; onStreamFinished: root._actionOutput = text }
|
||||
stderr: StdioCollector { id: actionStderr; waitForEnd: true; onStreamFinished: root._actionError = text }
|
||||
onExited: function(exitCode) {
|
||||
var stdout = String(actionStdout.text || root._actionOutput || "")
|
||||
var stderr = String(actionStderr.text || root._actionError || "")
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = elideStatus(stderr || stdout || "Tailscale command failed")
|
||||
root.actionStatus = root.lastError
|
||||
} else {
|
||||
root.lastError = ""
|
||||
root.actionStatus = ""
|
||||
}
|
||||
delayedRefresh.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: loginProcess
|
||||
running: false
|
||||
command: []
|
||||
stdout: SplitParser { onRead: function(data) { root.handleLoginOutput(data, false) } }
|
||||
stderr: SplitParser { onRead: function(data) { root.handleLoginOutput(data, true) } }
|
||||
onExited: function(exitCode) {
|
||||
var combined = String(root._loginOutput || "") + "\n" + String(root._loginError || "")
|
||||
var opened = root.openAuthUrlFrom(combined, true)
|
||||
if (exitCode !== 0 && !opened) {
|
||||
root._loginInProgress = false
|
||||
root.lastError = elideStatus(combined || "tailscale up failed")
|
||||
root.actionStatus = root.lastError
|
||||
} else if (!opened) {
|
||||
root.lastError = ""
|
||||
root.actionStatus = ""
|
||||
}
|
||||
delayedRefresh.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: switchProcess
|
||||
running: false
|
||||
command: []
|
||||
stdout: StdioCollector { id: switchStdout; waitForEnd: true; onStreamFinished: root._switchOutput = text }
|
||||
stderr: StdioCollector { id: switchStderr; waitForEnd: true; onStreamFinished: root._switchError = text }
|
||||
onExited: function(exitCode) {
|
||||
var stdout = String(switchStdout.text || root._switchOutput || "")
|
||||
var stderr = String(switchStderr.text || root._switchError || "")
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = elideStatus(stderr || stdout || "Account switch failed")
|
||||
root.actionStatus = root.lastError
|
||||
} else {
|
||||
root.lastError = ""
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real iconSize: Style.font.icon
|
||||
property color color: Color.foreground
|
||||
property color badgeColor: Color.urgent
|
||||
property bool crossed: false
|
||||
property bool warning: false
|
||||
|
||||
width: iconSize
|
||||
height: iconSize
|
||||
implicitWidth: iconSize
|
||||
implicitHeight: iconSize
|
||||
|
||||
readonly property real dotSize: Math.max(2, root.iconSize * 0.24)
|
||||
readonly property real mid: (root.iconSize - dotSize) / 2
|
||||
readonly property real end: root.iconSize - dotSize
|
||||
|
||||
// Native rendering of the Tailscale mark from the SVG: a 3×3 dot grid
|
||||
// with the inactive dots faded. This avoids Qt SVG/effect rendering quirks
|
||||
// in tiny bar slots while keeping the official silhouette.
|
||||
Dot { x: 0; y: 0; opacity: 0.24 }
|
||||
Dot { x: root.mid; y: 0; opacity: 0.24 }
|
||||
Dot { x: root.end; y: 0; opacity: 0.24 }
|
||||
Dot { x: 0; y: root.mid; opacity: 1.0 }
|
||||
Dot { x: root.mid; y: root.mid; opacity: 1.0 }
|
||||
Dot { x: root.end; y: root.mid; opacity: 1.0 }
|
||||
Dot { x: 0; y: root.end; opacity: 0.24 }
|
||||
Dot { x: root.mid; y: root.end; opacity: 1.0 }
|
||||
Dot { x: root.end; y: root.end; opacity: 0.24 }
|
||||
|
||||
Rectangle {
|
||||
visible: root.crossed
|
||||
anchors.centerIn: parent
|
||||
width: parent.width * 1.22
|
||||
height: Math.max(2, parent.height * 0.14)
|
||||
radius: height / 2
|
||||
color: root.badgeColor
|
||||
rotation: -45
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: root.warning
|
||||
width: Math.max(7, parent.width * 0.42)
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: root.badgeColor
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
border.color: Color.popups.background
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "!"
|
||||
color: Color.background
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Math.max(6, parent.height * 0.72)
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
component Dot: Rectangle {
|
||||
width: root.dotSize
|
||||
height: root.dotSize
|
||||
radius: width / 2
|
||||
color: root.color
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.tailscale",
|
||||
"name": "Tailscale",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"license": "MIT",
|
||||
"description": "Tailscale status, connection switching, machine browsing, and quick copy actions in the Omarchy bar.",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Panel.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Tailscale",
|
||||
"description": "Toggle Tailscale, switch connections, browse machines, and copy machine IPs or names.",
|
||||
"category": "Network",
|
||||
"allowMultiple": false,
|
||||
"defaults": {
|
||||
"refreshIntervalSec": 30
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"key": "refreshIntervalSec",
|
||||
"type": "integer",
|
||||
"label": "Refresh interval (seconds)",
|
||||
"min": 5,
|
||||
"max": 3600,
|
||||
"step": 5,
|
||||
"defaultValue": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user