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
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# omarchy:summary=Mutate the user shell.json bar layout
# omarchy:args=<append|prepend> <left|center|right> <plugin>
# omarchy:examples=omarchy config shell append right omarchy.tailscale
set -euo pipefail
CONFIG_FILE="$HOME/.config/omarchy/shell.json"
OMARCHY_ROOT="${OMARCHY_PATH:-}"
DEFAULTS_FILE="$OMARCHY_ROOT/config/omarchy/shell.json"
usage() {
echo "Usage: omarchy-config-shell <append|prepend> <left|center|right> <plugin>" >&2
}
fail() {
echo "omarchy-config-shell: $*" >&2
exit 1
}
operation="${1:-}"
section="${2:-}"
plugin="${3:-}"
if (( $# != 3 )); then
usage
exit 1
fi
[[ $operation == "append" || $operation == "prepend" ]] || fail "operation must be append or prepend"
[[ $section =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right"
[[ -n $plugin ]] || fail "plugin is required"
[[ -n $OMARCHY_ROOT ]] || fail "OMARCHY_PATH is not set"
mkdir -p "$(dirname "$CONFIG_FILE")"
source_file="$CONFIG_FILE"
if [[ ! -s $source_file ]]; then
source_file="$DEFAULTS_FILE"
fi
[[ -s $source_file ]] || fail "could not find shell config or defaults"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
jq --arg operation "$operation" --arg section "$section" --arg plugin "$plugin" '
def object_or_empty: if type == "object" then . else {} end;
def array_or_empty: if type == "array" then . else [] end;
def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;
def append_anchor($section):
{
left: "omarchy.workspaces",
center: "omarchy.weather",
right: "omarchy.tray"
}[$section];
def insert_after_anchor($entries; $entry; $anchor):
($entries | map(entry_id) | index($anchor)) as $index
| if $index == null then
$entries + [$entry]
else
$entries[0:$index + 1] + [$entry] + $entries[$index + 1:]
end;
object_or_empty
| .version = 1
| .bar = (.bar | object_or_empty)
| .bar.layout = (.bar.layout | object_or_empty)
| .bar.layout.left = (.bar.layout.left | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.center = (.bar.layout.center | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.right = (.bar.layout.right | array_or_empty | map(select(entry_id != $plugin)))
| .plugins = (.plugins | array_or_empty)
| if $operation == "prepend" then
.bar.layout[$section] = ([{ id: $plugin }] + .bar.layout[$section])
else
.bar.layout[$section] = insert_after_anchor(.bar.layout[$section]; { id: $plugin }; append_anchor($section))
end
' "$source_file" >"$tmp"
mv "$tmp" "$CONFIG_FILE"
trap - EXIT
omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true
+3
View File
@@ -13,4 +13,7 @@ sudo tailscale up --accept-routes
echo -e "\nAllowing $USER to manage Tailscale..." echo -e "\nAllowing $USER to manage Tailscale..."
sudo tailscale set --operator="$USER" sudo tailscale set --operator="$USER"
echo -e "\nAdding Tailscale to the bar..."
omarchy-config-shell append right omarchy.tailscale
omarchy-webapp-install "Tailscale" "https://login.tailscale.com/admin/machines" https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tailscale-light.png omarchy-webapp-install "Tailscale" "https://login.tailscale.com/admin/machines" https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tailscale-light.png
+95
View File
@@ -0,0 +1,95 @@
import QtQuick
import qs.Commons
Item {
id: root
property Component iconComponent: null
property string title: ""
property string meta: ""
property string detail: ""
property color foreground: Color.foreground
property string fontFamily: Style.font.family
property real iconSize: Style.font.display
property real iconOpacity: 1.0
property alias metaOpacity: metaText.opacity
readonly property color dim: Qt.darker(foreground, 1.4)
width: parent ? parent.width : implicitWidth
implicitHeight: Math.max(iconLoader.implicitHeight, heroLabels.implicitHeight)
Loader {
id: iconLoader
sourceComponent: root.iconComponent
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
opacity: root.iconOpacity
}
Column {
id: heroLabels
anchors.left: iconLoader.right
anchors.leftMargin: Style.space(14)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Row {
id: titleRow
visible: root.title !== "" || detailPill.visible
width: parent.width
Text {
visible: root.title !== ""
text: root.title
width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0)))
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
}
Item {
width: Math.max(0, parent.width - parent.children[0].width - detailPill.implicitWidth)
height: 1
}
Rectangle {
id: detailPill
visible: root.detail !== ""
implicitWidth: detailText.implicitWidth + Style.space(10)
implicitHeight: detailText.implicitHeight + Style.space(4)
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
border.color: Style.normalBorderFor(root.foreground)
border.width: Style.normalBorderWidth
radius: Style.cornerRadius
Text {
id: detailText
anchors.centerIn: parent
text: root.detail
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
}
}
Text {
id: metaText
width: parent.width
text: root.meta.toUpperCase()
visible: text !== ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
}
}
}
+1
View File
@@ -13,6 +13,7 @@ Panel 1.0 Panel.qml
PanelActionButton 1.0 PanelActionButton.qml PanelActionButton 1.0 PanelActionButton.qml
PanelController 1.0 PanelController.qml PanelController 1.0 PanelController.qml
PanelKeyCatcher 1.0 PanelKeyCatcher.qml PanelKeyCatcher 1.0 PanelKeyCatcher.qml
PanelHero 1.0 PanelHero.qml
PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSectionHeader 1.0 PanelSectionHeader.qml
PanelSeparator 1.0 PanelSeparator.qml PanelSeparator 1.0 PanelSeparator.qml
PanelSlider 1.0 PanelSlider.qml PanelSlider 1.0 PanelSlider.qml
+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` | | Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` | | Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
| Power | `omarchy.power` | `bar-widget` | `panels/power/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` | | Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` |
| Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` |
| Battery | `omarchy.battery` | `service` | `services/battery/Service.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.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.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.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.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 | | `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 - Shows Tailscale connection state in the bar
- Left click opens a keyboard-friendly panel - Left click opens a keyboard-friendly panel
- Right click toggles Tailscale on/off - Right click toggles Tailscale on/off
- Switch between logged-in Tailscale accounts when multiple are available - Switch between available Tailscale connections when multiple are available
- Browse peers from `tailscale status --json` - Browse machines from `tailscale status --json`
- Copy a peer's Tailscale IP, host name, or DNS name - Copy a machine's Tailscale IP, host name, or DNS name
## Keyboard shortcuts ## Keyboard shortcuts
@@ -2,10 +2,10 @@ import QtQuick
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.Commons import qs.Commons
import "Model.js" as Model
Item { Item {
id: root id: root
visible: false
property var settings: ({}) property var settings: ({})
@@ -23,15 +23,20 @@ Item {
property var accounts: [] property var accounts: []
property string selectedAccountId: "" property string selectedAccountId: ""
property string selectedAccountLabel: "" property string selectedAccountLabel: ""
property string switchingAccountId: ""
property string settingExitNodeId: ""
property bool accountsAccessDenied: false
property string actionStatus: "" property string actionStatus: ""
property string lastError: "" property string lastError: ""
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600) 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 _statusOutput: ""
property string _statusError: "" property string _statusError: ""
property string _accountsOutput: "" property string _accountsOutput: ""
property string _accountsError: ""
property string _actionOutput: "" property string _actionOutput: ""
property string _actionError: "" property string _actionError: ""
property string _loginOutput: "" property string _loginOutput: ""
@@ -42,6 +47,10 @@ Item {
property double _lastAccountsRefreshMs: 0 property double _lastAccountsRefreshMs: 0
property string _switchOutput: "" property string _switchOutput: ""
property string _switchError: "" property string _switchError: ""
property string _exitNodeOutput: ""
property string _exitNodeError: ""
property string _operatorOutput: ""
property string _operatorError: ""
function setting(name, fallback) { function setting(name, fallback) {
var value = settings ? settings[name] : undefined var value = settings ? settings[name] : undefined
@@ -57,56 +66,33 @@ Item {
} }
function filterIPv4(ips) { function filterIPv4(ips) {
var result = [] return Model.filterIPv4(ips)
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 cleanDnsName(name) { function cleanDnsName(name) {
var value = String(name || "") return Model.cleanDnsName(name)
return value.charAt(value.length - 1) === "." ? value.slice(0, -1) : value
} }
function shortDnsName(name) { function shortDnsName(name) {
var clean = cleanDnsName(name) return Model.shortDnsName(name)
if (clean === "") return ""
return clean.split(".")[0] || clean
} }
function displayHostName(hostName, dnsName) { function displayHostName(hostName, dnsName) {
var host = String(hostName || "") return Model.displayHostName(hostName, dnsName)
if (host !== "" && host.toLowerCase() !== "localhost") return host
return shortDnsName(dnsName) || host || "Unknown"
} }
function osIcon(os) { function osIcon(os) {
var value = String(os || "").toLowerCase() return Model.osIcon(os)
if (value === "linux") return "󰌽"
if (value === "macos" || value === "ios") return "󰀵"
if (value === "windows") return "󰍲"
if (value === "android") return "󰀲"
return "󰟀"
} }
function accountLabel(account) { function accountLabel(account) {
if (!account) return "Unknown account" return Model.accountLabel(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")
} }
function copyToClipboard(value, label) { function copyToClipboard(value, label) {
var text = String(value || "") var text = String(value || "")
if (text === "") return if (text === "") return
Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"]) Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(text) + " | wl-copy"])
actionStatus = elideStatus("Copied " + (label || text))
actionStatusTimer.restart()
} }
function copyPeerIp(peer) { function copyPeerIp(peer) {
@@ -150,6 +136,7 @@ Item {
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000 var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
if (shouldRefreshAccounts && !accountsProcess.running) { if (shouldRefreshAccounts && !accountsProcess.running) {
_accountsOutput = "" _accountsOutput = ""
_accountsError = ""
_lastAccountsRefreshMs = now _lastAccountsRefreshMs = now
accountsProcess.command = ["tailscale", "switch", "--list", "--json"] accountsProcess.command = ["tailscale", "switch", "--list", "--json"]
accountsProcess.running = true accountsProcess.running = true
@@ -174,116 +161,61 @@ Item {
accounts = [] accounts = []
selectedAccountId = "" selectedAccountId = ""
selectedAccountLabel = "" selectedAccountLabel = ""
switchingAccountId = ""
settingExitNodeId = ""
accountsAccessDenied = false
} }
function parseStatus(raw) { function parseStatus(raw) {
var text = String(raw || "").trim() var parsed = Model.parseStatus(raw)
if (text === "") { if (!parsed.ok) {
resetUnavailable("Disconnected") 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 return
} }
try { backendState = parsed.backendState
var data = JSON.parse(text) running = parsed.running
backendState = String(data.BackendState || "Unknown") needsLogin = parsed.needsLogin
running = backendState === "Running" authUrl = parsed.authUrl
needsLogin = backendState === "NeedsLogin" if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) openAuthUrlFrom(authUrl, false)
authUrl = String(data.AuthURL || "") selfName = parsed.selfName
if (needsLogin && _loginInProgress && !_loginUrlOpened && authUrl !== "" && authUrl !== _preLoginAuthUrl) { selfDnsName = parsed.selfDnsName
openAuthUrlFrom(authUrl, false) selfIp = parsed.selfIp
} peers = parsed.running ? parsed.peers : []
var self = data.Self || {} if (needsLogin) statusText = "Needs login"
selfName = displayHostName(self.HostName, self.DNSName) else if (running) {
selfDnsName = cleanDnsName(self.DNSName) statusText = "Connected"
var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || []) _loginInProgress = false
selfIp = selfIps.length > 0 ? selfIps[0] : "" _loginUrlOpened = false
_preLoginAuthUrl = ""
var nextPeers = [] loginTimeoutTimer.stop()
var rawPeers = data.Peer || {} } else if (backendState === "Stopped") {
for (var id in rawPeers) { statusText = "Disconnected"
var peer = rawPeers[id] || {} } else {
var ipv4s = filterIPv4(peer.TailscaleIPs || []) statusText = backendState
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)
} }
lastError = ""
} }
function parseAccounts(raw) { function parseAccounts(raw) {
var text = String(raw || "").trim() var parsed = Model.parseAccounts(raw)
if (text === "") { accounts = parsed.accounts
accounts = [] selectedAccountId = parsed.selectedAccountId
selectedAccountId = "" selectedAccountLabel = parsed.selectedAccountLabel
selectedAccountLabel = "" accountsAccessDenied = false
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)
}
} }
function toggleTailscale() { function toggleTailscale() {
if (!installed) return if (!installed) return
if (running) { if (running) runAction(["tailscale", "down"], "Turning Tailscale off…")
runAction(["tailscale", "down"], "Turning Tailscale off…") else loginOrUp()
} else {
loginOrUp()
}
} }
function loginOrUp() { function loginOrUp() {
@@ -306,11 +238,39 @@ Item {
if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return if (!installed || accountId === "" || accountId === selectedAccountId || switchProcess.running) return
_switchOutput = "" _switchOutput = ""
_switchError = "" _switchError = ""
actionStatus = "Switching Tailscale account…" switchingAccountId = accountId
switchProcess.command = ["tailscale", "switch", accountId] switchProcess.command = ["tailscale", "switch", accountId]
switchProcess.running = true 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) { function runAction(command, label) {
if (actionProcess.running) return if (actionProcess.running) return
_actionOutput = "" _actionOutput = ""
@@ -416,10 +376,20 @@ Item {
running: false running: false
command: [] command: []
stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text } stdout: StdioCollector { id: accountsStdout; waitForEnd: true; onStreamFinished: root._accountsOutput = text }
stderr: StdioCollector { id: accountsStderr; waitForEnd: true; onStreamFinished: root._accountsError = text }
onExited: function(exitCode) { onExited: function(exitCode) {
var stdout = String(accountsStdout.text || root._accountsOutput || "") var stdout = String(accountsStdout.text || root._accountsOutput || "")
var stderr = String(accountsStderr.text || root._accountsError || "")
if (exitCode === 0) root.parseAccounts(stdout) 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 root.actionStatus = root.lastError
} else { } else {
root.lastError = "" 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() actionStatusTimer.restart()
root._lastAccountsRefreshMs = 0 root._lastAccountsRefreshMs = 0
} }
delayedRefresh.restart() 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", "version": "1.0.0",
"author": "Omarchy", "author": "Omarchy",
"license": "MIT", "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": [ "kinds": [
"bar-widget" "bar-widget"
], ],
"entryPoints": { "entryPoints": {
"barWidget": "Widget.qml" "barWidget": "Panel.qml"
}, },
"barWidget": { "barWidget": {
"displayName": "Tailscale", "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", "category": "Network",
"allowMultiple": false, "allowMultiple": false,
"defaults": { "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)
}
}
}
}
+50
View File
@@ -94,6 +94,56 @@ migration=$(grep -rl 'Place the system update indicator next to weather in the b
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
mkdir -p "$TMPDIR/home/.config/omarchy" mkdir -p "$TMPDIR/home/.config/omarchy"
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
{
"version": 1,
"bar": {
"layout": {
"left": [{ "id": "omarchy.menu" }, { "id": "omarchy.workspaces" }],
"center": [{ "id": "omarchy.clock" }, { "id": "omarchy.weather" }],
"right": [{ "id": "omarchy.tray" }, { "id": "omarchy.bluetooth" }]
}
},
"plugins": []
}
JSON
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append right omarchy.tailscale
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends right widgets after tray"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append left local.left
jq -e '
def ids: map(.id // .);
.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "local.left"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends left widgets after workspaces"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append center local.center
jq -e '
def ids: map(.id // .);
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "local.center"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends center widgets after weather"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell prepend right local.first
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["local.first", "omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config prepends widgets to section start"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell append right local.first
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "local.first", "omarchy.tailscale", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config moves existing widgets without duplicates"
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON' cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
{ {
"version": 1, "version": 1,
@@ -130,7 +130,10 @@ ShellRoot {
screenRecording.moduleName = "ScreenRecording" screenRecording.moduleName = "ScreenRecording"
root.injectBar(screenRecording) root.injectBar(screenRecording)
screenRecording.triggerPress(Qt.LeftButton) screenRecording.triggerPress(Qt.LeftButton)
root.assertTrue(root.commandCount("omarchy-capture-screenrecording") === 1, "Screen Recording left click runs capture command") root.assertTrue(root.commandCount("omarchy-menu toggle trigger.capture.screenrecord") === 1, "Screen Recording left click opens capture menu when idle")
screenRecording.recording = true
screenRecording.triggerPress(Qt.LeftButton)
root.assertTrue(root.commandCount("omarchy-capture-screenrecording --stop-recording") === 1, "Screen Recording left click stops active recording")
} }
var dictation = root.createIndicator("Dictation") var dictation = root.createIndicator("Dictation")
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const tailscale = requireFromRoot('shell/plugins/panels/tailscale/Model.js')
assertDeepEqual(
tailscale.filterIPv4(['100.64.0.1', 'fd7a:115c:a1e0::1', '192.168.1.2']),
['100.64.0.1'],
'tailscale keeps only Tailscale IPv4 addresses'
)
assertDeepEqual(
tailscale.filterIPv6(['100.64.0.1', 'fd7a:115c:a1e0::1', 'fe80::1']),
['fd7a:115c:a1e0::1'],
'tailscale keeps only Tailscale IPv6 addresses'
)
assertEqual(tailscale.cleanDnsName('work.tailnet.ts.net.'), 'work.tailnet.ts.net', 'tailscale strips trailing DNS dot')
assertEqual(tailscale.displayHostName('localhost', 'work.tailnet.ts.net.'), 'work', 'tailscale falls back from localhost to short DNS name')
const status = tailscale.parseStatus(JSON.stringify({
BackendState: 'Running',
AuthURL: '',
TailscaleIPs: ['100.74.97.73', 'fd7a:115c:a1e0::ff32:6149'],
Self: {
HostName: 'dhh-fd',
DNSName: 'dhh-fd.tail32f559.ts.net.',
TailscaleIPs: ['100.74.97.73']
},
Peer: {
onlineB: {
HostName: 'zed',
DNSName: 'zed.tail32f559.ts.net.',
TailscaleIPs: ['100.1.1.2'],
Online: true,
OS: 'linux',
ExitNodeOption: true,
ExitNode: true
},
offline: {
HostName: 'offline',
DNSName: 'offline.tail32f559.ts.net.',
TailscaleIPs: ['100.1.1.3'],
Online: false,
OS: 'linux'
},
onlineA: {
HostName: 'alpha',
DNSName: 'alpha.tail32f559.ts.net.',
TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'],
Online: true,
OS: 'macos'
}
}
}))
assert(status.ok && status.running, 'tailscale parses running status')
assertEqual(status.selfIp, '100.74.97.73', 'tailscale parses self IP')
assertDeepEqual(status.peers.map(peer => peer.HostName), ['alpha', 'zed'], 'tailscale filters offline peers and sorts online peers')
assertDeepEqual(status.peers[0].TailscaleIPv6, ['fd7a:115c:a1e0::1901:334b'], 'tailscale preserves peer IPv6 addresses for copy menu')
assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale preserves exit node flags')
const stopped = tailscale.parseStatus(JSON.stringify({
BackendState: 'Stopped',
Peer: {
online: {
HostName: 'alpha',
DNSName: 'alpha.tail32f559.ts.net.',
TailscaleIPs: ['100.1.1.1'],
Online: true,
OS: 'macos'
}
}
}))
assert(stopped.ok && !stopped.running, 'tailscale parses stopped status')
const accounts = tailscale.parseAccounts(JSON.stringify([
{
id: 'db1b',
nickname: 'Home',
tailnet: 'dhh.github',
account: 'dhh@github',
selected: true
},
{
id: '1785',
nickname: 'Work',
tailnet: '37signals.com',
account: 'david@37signals.com',
selected: false
}
]))
assertEqual(accounts.accounts.length, 2, 'tailscale parses multiple connections')
assertEqual(accounts.selectedAccountId, 'db1b', 'tailscale records selected connection id')
assertEqual(accounts.selectedAccountLabel, 'Home', 'tailscale labels connections by nickname')
assertDeepEqual(
accounts.accounts.map(account => account.nickname),
['Home', 'Work'],
'tailscale preserves connection nicknames'
)
assertEqual(
tailscale.accountLabel({ nickname: '', tailnet: 'tailnet.example', account: 'user@example', id: 'abcd' }),
'tailnet.example',
'tailscale labels connections by tailnet when nickname is missing'
)
assertDeepEqual(tailscale.parseStatus('{'), { ok: false, unavailable: true, message: 'Status error', error: 'Failed to parse tailscale status' }, 'tailscale reports invalid status JSON')
assertDeepEqual(tailscale.parseAccounts('{'), { accounts: [], selectedAccountId: '', selectedAccountLabel: '' }, 'tailscale handles invalid account JSON')
JS