Files
dotfiles/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
2026-08-27 13:02:34 +01:00

1176 lines
46 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import "../../config"
Item {
id: root
implicitWidth: label.implicitWidth
implicitHeight: Appearance.barHeight
// The bar's own PanelWindow — passed in from Bar.qml so this popup can
// anchor to the bar itself rather than to this icon, keeping it a fixed
// distance from the bar and the screen edge regardless of where this
// icon sits in the row.
required property var barWindow
// How often to poll `tailscale status` in the background, in seconds.
// Overridable per-instance; clamped so a bad value can't hammer the CLI
// or effectively stop refreshing.
property int refreshIntervalSec: 30
readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000
// Quickshell has no native Tailscale API — this shells out to the
// `tailscale` CLI, the same way NetworkIndicator.qml gets its live
// stats. `status --json` gives a full snapshot in one call.
readonly property var defaultStatus: ({
ok: false, backendState: "Unknown", running: false, needsLogin: false,
authUrl: "", selfName: "", selfIp: "", tailnetName: "",
peers: [], exitNodePeers: [], activeExitNode: null
})
property var status: defaultStatus
property bool busy: false
// Whether the `tailscale` binary itself is on PATH — distinct from
// whether the daemon is running/connected. Starts optimistic so the
// panel doesn't flash a "not installed" message before the first check
// (whichProc, below) has had a chance to land.
property bool installed: true
// Optimistic desired power state, so the header switch flips the
// instant you click it instead of waiting on the next poll. -1 means
// "no override, just follow root.status.running"; 0/1 means a toggle
// is in flight and hasn't been confirmed by a poll yet.
property int _desired: -1
readonly property bool active: _desired === -1 ? root.status.running : (_desired === 1)
// Surfaced under the header for toggle failures and the login-link
// timeout; cleared automatically after a few seconds.
property string actionStatus: ""
property bool _loginPending: false
property bool _authUrlOpened: false
// Set when the VPN-exclusivity guard (below) forces Tailscale down —
// as opposed to you turning it off yourself — so it knows to bring
// Tailscale back up automatically once Mullvad/ProtonVPN disconnect,
// without overriding a deliberate manual "off".
property bool _forcedDownByVpn: false
// Other logged-in Tailscale accounts on this machine (`tailscale switch
// --list`) — separate tailnets/identities you can flip between, not
// logins for other VPN products. Only ever shown when there's more than
// one, same as omarchy's panel.
property var accounts: []
property string switchingAccountId: ""
function parseTailscaleStatus(text) {
try {
const data = JSON.parse(text)
const backendState = data.BackendState || "Stopped"
const self = data.Self || {}
const selfIps = (self.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) })
const rawPeers = data.Peer || {}
const peers = []
for (const id in rawPeers) {
const p = rawPeers[id]
if (!p || !p.Online) continue
const name = (p.HostName || "").trim()
if (!name) continue
const ips = (p.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) })
const ip6s = (p.TailscaleIPs || []).filter(function(ip) { return /^fd7a:115c:a1e0:/i.test(ip) })
const dnsName = String(p.DNSName || "").replace(/\.$/, "")
peers.push({
id: id,
name: name,
ip: ips.length > 0 ? ips[0] : "",
ip6: ip6s.length > 0 ? ip6s[0] : "",
dnsName: dnsName,
os: (p.OS || "").toLowerCase(),
exitNodeOption: !!p.ExitNodeOption,
exitNodeActive: !!p.ExitNode
})
}
peers.sort(function(a, b) { return a.name.localeCompare(b.name) })
return {
ok: true,
backendState: backendState,
running: backendState === "Running",
needsLogin: backendState === "NeedsLogin",
authUrl: data.AuthURL || "",
selfName: self.HostName || "",
selfIp: selfIps.length > 0 ? selfIps[0] : "",
tailnetName: (data.CurrentTailnet && data.CurrentTailnet.Name) || "",
peers: peers,
exitNodePeers: peers.filter(function(p) { return p.exitNodeOption }),
activeExitNode: peers.find(function(p) { return p.exitNodeActive }) || null
}
} catch (e) {
return root.defaultStatus
}
}
function osIcon(os) {
if (os === "windows") return "desktop_windows"
if (os === "macos" || os === "ios") return "desktop_mac"
if (os === "linux") return "terminal"
return "devices_other"
}
function accountLabel(account) {
if (!account) return "Unknown account"
return account.nickname || account.tailnet || account.account || account.id || "Unknown account"
}
function parseAccounts(text) {
try {
const data = JSON.parse(text)
if (!Array.isArray(data)) return []
return data.map(function(a) {
return {
id: String(a.id || a.ID || ""),
nickname: String(a.nickname || a.Nickname || ""),
tailnet: String(a.tailnet || a.Tailnet || ""),
account: String(a.account || a.Account || ""),
selected: a.selected === true || a.Selected === true
}
}).filter(function(a) { return a.id !== "" })
} catch (e) {
return []
}
}
// Applies a freshly-parsed status snapshot and reacts to what changed:
// clears the optimistic toggle once reality catches up, and auto-opens
// a login link the moment the daemon reports one.
function applyStatus(text) {
const next = root.parseTailscaleStatus(text)
root.status = next
if (root._desired !== -1 && next.running === (root._desired === 1)) root._desired = -1
if (next.running) {
root._loginPending = false
root._authUrlOpened = false
loginTimeoutTimer.stop()
// Tailscale, Mullvad, and ProtonVPN all fight over the default
// route/DNS if more than one is active — see VpnIndicator.qml.
// Tailscale is the one that backs off, rather than leave you
// with a broken connection until you notice and fix it by hand.
if (VpnExclusivity.commercialVpnActive && !root.busy) {
root.busy = true
root._desired = 0
root._forcedDownByVpn = true
root.actionStatus = "Tailscale stopped — a VPN provider is active"
actionStatusTimer.restart()
actionProc.command = ["tailscale", "down"]
actionProc.running = true
}
} else if (next.needsLogin && next.authUrl !== "" && !root._authUrlOpened) {
root.openAuthUrl(next.authUrl)
}
}
function openAuthUrl(url) {
if (root._authUrlOpened || !url) return
root._authUrlOpened = true
root._loginPending = false
loginTimeoutTimer.stop()
Qt.openUrlExternally(url)
}
function bringTailscaleUp() {
if (root.busy || !root.installed) return
root.busy = true
root._desired = 1
root._loginPending = true
root._authUrlOpened = false
root.actionStatus = ""
actionProc.command = ["tailscale", "up"]
loginTimeoutTimer.restart()
actionProc.running = true
}
function toggleTailscale() {
if (root.busy || !root.installed) return
// Any manual action overrides the guard's own memory of why
// Tailscale went down — a deliberate "off" here should stick even
// once the VPN it deferred to eventually disconnects.
root._forcedDownByVpn = false
if (root.active) {
root.busy = true
root._desired = 0
actionProc.command = ["tailscale", "down"]
actionProc.running = true
return
}
if (VpnExclusivity.commercialVpnActive) {
root.actionStatus = "Disconnect Mullvad/ProtonVPN first"
actionStatusTimer.restart()
return
}
root.bringTailscaleUp()
}
function setExitNode(peer) {
if (root.busy) return
root.busy = true
exitNodeProc.command = ["tailscale", "set", "--exit-node=" + (peer ? peer.ip : "")]
exitNodeProc.running = true
}
function copyToClipboard(text) {
if (text) Quickshell.clipboardText = text
}
function switchAccount(id) {
if (root.busy || !id || root.switchingAccountId !== "" || switchProc.running) return
root.busy = true
root.switchingAccountId = id
switchProc.command = ["tailscale", "switch", id]
switchProc.running = true
}
function refresh() {
if (!root.installed) {
if (!whichProc.running) whichProc.running = true
return
}
if (!statusProc.running) {
statusProc.running = true
pollWatchdog.restart()
}
if (!accountsProc.running) accountsProc.running = true
}
// Detects whether the CLI is on PATH at all, separately from whether a
// poll succeeded — a poll can fail for lots of reasons (daemon down,
// permissions), but only this tells us the binary is actually missing.
Process {
id: whichProc
command: ["which", "tailscale"]
running: true
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
root.installed = exitCode === 0
if (root.installed) root.refresh()
}
}
Process {
id: statusProc
command: ["tailscale", "status", "--json"]
stdout: StdioCollector {
id: statusCollector
onStreamFinished: root.applyStatus(statusCollector.text)
}
onExited: (exitCode, exitStatus) => {
// A poll that fails outright is worth double-checking against
// `which` — covers the CLI having been uninstalled after this
// widget started up.
if (exitCode !== 0 && !whichProc.running) whichProc.running = true
}
}
Process {
id: actionProc
stdout: StdioCollector {}
stderr: StdioCollector {}
onExited: (exitCode, exitStatus) => {
root.busy = false
if (exitCode !== 0) {
root._desired = -1
root._loginPending = false
loginTimeoutTimer.stop()
root.actionStatus = "Tailscale command failed"
actionStatusTimer.restart()
}
if (!statusProc.running) statusProc.running = true
}
}
Process {
id: exitNodeProc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
root.busy = false
if (!statusProc.running) statusProc.running = true
}
}
Process {
id: accountsProc
command: ["tailscale", "switch", "--list", "--json"]
stdout: StdioCollector {
id: accountsCollector
onStreamFinished: root.accounts = root.parseAccounts(accountsCollector.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) root.accounts = []
}
}
Process {
id: switchProc
stdout: StdioCollector {}
stderr: StdioCollector {}
onExited: (exitCode, exitStatus) => {
root.busy = false
root.switchingAccountId = ""
if (exitCode !== 0) {
root.actionStatus = "Account switch failed"
actionStatusTimer.restart()
}
if (!statusProc.running) statusProc.running = true
if (!accountsProc.running) accountsProc.running = true
}
}
// Auto-resumes Tailscale once neither commercial VPN is connected
// anymore — but only if it was this guard that took Tailscale down in
// the first place, never overriding a manual "off".
Connections {
target: VpnExclusivity
function onCommercialVpnActiveChanged() {
if (!VpnExclusivity.commercialVpnActive && root._forcedDownByVpn && !root.active) {
root._forcedDownByVpn = false
root.actionStatus = "Resuming Tailscale — VPN disconnected"
actionStatusTimer.restart()
root.bringTailscaleUp()
}
}
}
// Background poll, independent of whether the panel is open, so the bar
// icon itself stays live.
Timer {
id: refreshTimer
interval: root._refreshIntervalMs
repeat: true
running: true
triggeredOnStart: true
onTriggered: root.refresh()
}
// Right after launch tailscaled often hasn't come up yet — poll faster
// for the first ~30s until it reports running, instead of waiting out a
// full refresh interval.
Timer {
id: startupRamp
property int ticks: 0
interval: 2000
repeat: true
running: true
onTriggered: {
ticks += 1
if (root.status.running || ticks >= 15) running = false
else root.refresh()
}
}
// Every poll is skipped while its own process is still running, so one
// that never exits (a flaky network can make `tailscale status` hang)
// would silently stop the panel refreshing at all. Reap it well inside
// the refresh interval so the next tick starts clean.
Timer {
id: pollWatchdog
interval: 15000
repeat: false
onTriggered: if (statusProc.running) statusProc.running = false
}
Timer {
id: actionStatusTimer
interval: 4000
repeat: false
onTriggered: root.actionStatus = ""
}
Timer {
id: loginTimeoutTimer
interval: 10000
repeat: false
onTriggered: {
if (!root._loginPending || root._authUrlOpened) return
root._loginPending = false
root.actionStatus = "Tailscale login link not available yet"
actionStatusTimer.restart()
}
}
// The real Tailscale mark: a 3x3 grid of dots where the center dot and
// its four edge-neighbors sit at full color (a "+" shape) and the four
// corners sit at a distinctly darker shade — not a flat monochrome
// glyph. Built from plain circles rather than the brand SVG (which
// encodes that dimming as fixed opacity on specific dots, unrelated to
// app state) so the full-color dots can still recolor with connection
// state. A diagonal slash overlays it when fully off, and a small
// warning badge appears when the daemon wants a login.
component TailscaleLogo: Item {
id: logo
property color color: Colors.foreground
property real size: 16
property bool crossed: false
property bool warning: false
property color badgeColor: Colors.urgent
implicitWidth: size
implicitHeight: size
readonly property real dotSize: logo.size / 4.5
readonly property real dotGap: (logo.size - dotSize * 3) / 2
// A real darker shade for the corner dots rather than partial
// transparency — opacity over a dark bar background nearly
// disappears; a distinct tone stays visible at any size.
readonly property color dimColor: Qt.darker(logo.color, 1.8)
Grid {
anchors.centerIn: parent
columns: 3
rows: 3
spacing: logo.dotGap
Repeater {
model: [0, 0, 0, 1, 1, 1, 0, 1, 0]
Rectangle {
required property int modelData
width: logo.dotSize - 1
height: logo.dotSize - 1
radius: width / 2
color: modelData ? '#c9f0f0f0' : logo.dimColor
}
}
}
Rectangle {
visible: logo.crossed
anchors.centerIn: parent
width: parent.width * 1.2
height: Math.max(1.5, parent.height * 0.13)
radius: height / 2
color: logo.color
rotation: -45
}
Rectangle {
visible: logo.warning
width: Math.max(7, logo.size * 0.42)
height: width
radius: width / 2
color: logo.badgeColor
border.color: Colors.surface
border.width: 1
anchors.right: parent.right
anchors.bottom: parent.bottom
Text {
anchors.centerIn: parent
text: "!"
color: Colors.surface
font.family: Appearance.fontFamily
font.pixelSize: Math.max(6, parent.height * 0.75)
font.bold: true
}
}
}
TailscaleLogo {
id: label
anchors.centerIn: parent
size: 16
color: root.active ? Colors.foreground : Colors.muted
crossed: !root.active && !root.status.needsLogin
warning: root.status.needsLogin
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
panel.visible = !panel.visible
grab.active = panel.visible
}
}
// --- Reusable pieces ---------------------------------------------------
component ToggleSwitch: Item {
id: toggleRoot
property bool checked: false
signal toggled()
implicitWidth: 34
implicitHeight: 18
Rectangle {
anchors.fill: parent
radius: 0
color: toggleRoot.checked ? Colors.primary : Colors.muted
opacity: toggleRoot.checked ? 1 : 0.35
}
// Hover-only outline, offset outside the switch itself rather than
// drawn on its edge — the switch lags behind a click until the
// backend confirms it (e.g. root.active), so this is just a "you
// can click this" cue, not click feedback.
Rectangle {
anchors.fill: parent
anchors.margins: -4
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: toggleMouse.containsMouse ? 1 : 0
}
Rectangle {
width: parent.height - 4
height: parent.height - 4
radius: 0
color: Colors.background
anchors.verticalCenter: parent.verticalCenter
x: toggleRoot.checked ? parent.width - width - 2 : 2
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
}
MouseArea {
id: toggleMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: toggleRoot.toggled()
}
}
// A selectable exit-node row (including the "None" entry that clears
// it) — highlighted like the active state elsewhere in this project's
// panels, click to activate.
component ExitNodeRow: Rectangle {
id: exitRow
property string label: ""
property string subIcon: "lan"
property bool active: false
signal activate()
width: parent ? parent.width : 0
radius: 0
implicitHeight: rowContent.implicitHeight + 12
// Hover checked first (same ordering as Volume.qml's DeviceRow):
// hovering always shows a solid Colors.primary fill, even for an
// already-active row, so the primaryText used below never has to
// sit on top of the dimmer translucent tint instead.
color: rowMouse.containsMouse ? Colors.primary
: (exitRow.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
// Hover-only outline, offset outside the row rather than drawn on
// its own edge — same treatment as NetworkIndicator.qml's NetworkRow.
// listColumn below is inset by 3px to give this room without being
// cropped by listFlick's clip.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: rowMouse.containsMouse ? 1 : 0
}
RowLayout {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 6
spacing: 8
Text {
Layout.preferredWidth: 20
Layout.minimumWidth: 20
Layout.maximumWidth: 20
horizontalAlignment: Text.AlignLeft
text: exitRow.subIcon
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
Layout.fillWidth: true
text: exitRow.label
elide: Text.ElideRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
font.bold: exitRow.active
color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
visible: exitRow.active
text: "check_circle"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: rowMouse.containsMouse ? Colors.primaryText : Colors.primary
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: exitRow.activate()
}
}
// A logged-in Tailscale account row — click to switch to it. Spins its
// glyph while the switch is in flight, same convention as the exit-node
// rotation below.
component AccountRow: Rectangle {
id: accountRow
property var account: null
readonly property bool selected: accountRow.account && accountRow.account.selected === true
readonly property bool switching: accountRow.account && root.switchingAccountId === accountRow.account.id
width: parent ? parent.width : 0
radius: 0
implicitHeight: accountContent.implicitHeight + 12
// Hover checked first (same ordering as Volume.qml's DeviceRow):
// hovering always shows a solid Colors.primary fill, even for an
// already-selected row, so the primaryText the texts below already
// assume on hover is always correct (this row had the same
// active-first ordering bug as ExitNodeRow, just never reported).
color: accountMouse.containsMouse ? Colors.primary
: (accountRow.selected ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
// Hover-only outline, offset outside the row — same treatment as
// ExitNodeRow/NetworkRow. Not inside a clipped Flickable, so no
// inset needed on its container.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: accountMouse.containsMouse ? 1 : 0
}
RowLayout {
id: accountContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 6
spacing: 8
Text {
Layout.preferredWidth: 20
Layout.minimumWidth: 20
Layout.maximumWidth: 20
horizontalAlignment: Text.AlignLeft
text: "person"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground
opacity: accountRow.switching ? 0.45 : 1
SequentialAnimation on opacity {
running: accountRow.switching
loops: Animation.Infinite
NumberAnimation { to: 1.0; duration: 420; easing.type: Easing.InOutQuad }
NumberAnimation { to: 0.45; duration: 420; easing.type: Easing.InOutQuad }
}
}
Text {
Layout.fillWidth: true
text: root.accountLabel(accountRow.account)
elide: Text.ElideRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
font.bold: accountRow.selected
color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
visible: accountRow.selected
text: "check_circle"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: accountMouse.containsMouse ? Colors.primaryText : Colors.primary
}
}
MouseArea {
id: accountMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (accountRow.account) root.switchAccount(accountRow.account.id)
}
}
// A peer row that expands in place into up to four copyable identifiers
// (name / DNS name / IPv6 / IPv4) instead of copying a single fixed
// field on click.
component PeerRow: Column {
id: peerRow
required property var peer
width: parent ? parent.width : 0
spacing: 2
property bool expanded: false
readonly property var copyOptions: {
const opts = []
if (peerRow.peer.name) opts.push({ sub: "Name", value: peerRow.peer.name })
if (peerRow.peer.dnsName) opts.push({ sub: "DNS name", value: peerRow.peer.dnsName })
if (peerRow.peer.ip6) opts.push({ sub: "IPv6", value: peerRow.peer.ip6 })
if (peerRow.peer.ip) opts.push({ sub: "IPv4", value: peerRow.peer.ip })
return opts
}
Rectangle {
width: parent.width
radius: 0
color: summaryMouse.containsMouse ? Colors.primary
: (peerRow.expanded ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
implicitHeight: peerContent.implicitHeight + 12
// Hover-only outline, offset outside the row — same treatment as
// ExitNodeRow/NetworkRow. listColumn is inset by 3px to give this
// room without being cropped by listFlick's clip.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: summaryMouse.containsMouse ? 1 : 0
}
RowLayout {
id: peerContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 6
spacing: 8
Text {
Layout.preferredWidth: 20
Layout.minimumWidth: 20
Layout.maximumWidth: 20
horizontalAlignment: Text.AlignLeft
text: root.osIcon(peerRow.peer.os)
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
Layout.fillWidth: true
text: peerRow.peer.name
elide: Text.ElideRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
text: peerRow.expanded ? "expand_less" : "content_copy"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.muted
}
}
MouseArea {
id: summaryMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: peerRow.expanded = !peerRow.expanded
}
}
Column {
width: parent.width
visible: peerRow.expanded
spacing: 1
Repeater {
model: peerRow.copyOptions
delegate: Rectangle {
required property var modelData
width: parent.width
radius: 0
color: optionMouse.containsMouse ? Colors.primary : "transparent"
implicitHeight: optionRow.implicitHeight + 8
// Hover-only outline, offset outside the row — same
// treatment as the rest of this file's selectable rows.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: optionMouse.containsMouse ? 1 : 0
}
RowLayout {
id: optionRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 26
anchors.rightMargin: 8
spacing: 8
Text {
Layout.fillWidth: true
text: modelData.value
elide: Text.ElideRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: optionMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
text: modelData.sub
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: optionMouse.containsMouse ? Colors.primaryText : Colors.muted
}
}
MouseArea {
id: optionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.copyToClipboard(modelData.value)
peerRow.expanded = false
}
}
}
}
}
}
// --- Panel -------------------------------------------------------------
PopupWindow {
id: panel
visible: false
color: "transparent"
readonly property int contentWidth: 244
readonly property int contentPadding: 16
readonly property int infoLabelWidth: 70
readonly property int maxListHeight: 220
implicitWidth: contentWidth + contentPadding * 2
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
Behavior on implicitHeight {
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
}
onVisibleChanged: if (visible) root.refresh()
anchor {
window: root.barWindow
// Unlike anchor.item, anchor.window doesn't default rect to the
// window's own bounds — set it explicitly so edges/gravity below
// resolve against the full bar (and thus the true screen edges)
// instead of a near-zero rect at the window's origin.
rect.x: 0
rect.y: 0
rect.width: root.barWindow.width
rect.height: root.barWindow.height
edges: Edges.Bottom | Edges.Right
gravity: Edges.Bottom | Edges.Left
margins.bottom: -4
margins.right: 0
}
HyprlandFocusGrab {
id: grab
windows: [panel]
onCleared: panel.visible = false
}
Rectangle {
anchors.fill: parent
radius: 0
color: Colors.surface
border.color: Colors.muted
border.width: 3
Column {
id: mainColumn
anchors.fill: parent
anchors.margins: panel.contentPadding
spacing: 10
// Header
RowLayout {
width: parent.width
spacing: 8
TailscaleLogo {
size: 25
color: label.color
crossed: label.crossed
warning: label.warning
}
Text {
Layout.fillWidth: true
text: "Tailscale"
font.family: Appearance.fontFamily
font.pixelSize: 18
font.bold: true
color: Colors.foreground
}
ToggleSwitch {
checked: root.active
onToggled: root.toggleTailscale()
}
}
Text {
visible: !root.installed || root.actionStatus !== ""
width: parent.width
text: !root.installed ? "Tailscale CLI not found on PATH" : root.actionStatus
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
wrapMode: Text.WordWrap
}
// Other logged-in accounts — only worth a section once
// there's actually a choice to make.
Column {
width: parent.width
visible: root.accounts.length > 1
spacing: 4
Text {
text: "Accounts"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Column {
width: parent.width
spacing: 2
Repeater {
model: root.accounts
AccountRow {
required property var modelData
account: modelData
}
}
}
}
// Sign-in prompt — only ever appears if the daemon reports
// NeedsLogin with a real auth URL to open. Doubles as the
// manual fallback if the automatic browser-open above missed.
Rectangle {
width: parent.width
visible: root.status.needsLogin && root.status.authUrl !== ""
radius: 0
color: signInMouse.containsMouse ? Colors.primary : "transparent"
border.color: Colors.muted
border.width: 1
implicitHeight: signInRow.implicitHeight + 12
RowLayout {
id: signInRow
anchors.fill: parent
anchors.margins: 6
spacing: 8
Text {
text: "open_in_new"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
Layout.fillWidth: true
text: "Sign in to Tailscale"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
color: signInMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
}
MouseArea {
id: signInMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openAuthUrl(root.status.authUrl)
}
}
// Info grid — tailnet, your IP (click to copy), status.
GridLayout {
width: parent.width
visible: root.status.running
columns: 2
columnSpacing: 12
rowSpacing: 5
Text {
text: "Tailnet"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
Layout.preferredWidth: panel.infoLabelWidth
Layout.minimumWidth: panel.infoLabelWidth
Layout.maximumWidth: panel.infoLabelWidth
color: Colors.muted
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
text: root.status.tailnetName || "—"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
Text {
text: "Your IP"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
Layout.preferredWidth: panel.infoLabelWidth
Layout.minimumWidth: panel.infoLabelWidth
Layout.maximumWidth: panel.infoLabelWidth
color: Colors.muted
}
RowLayout {
Layout.fillWidth: true
spacing: 4
Item { Layout.fillWidth: true }
Text {
text: root.status.selfIp || "—"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
Text {
visible: root.status.selfIp !== ""
text: "content_copy"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 12
color: copyMouse.containsMouse ? Colors.primary : Colors.muted
MouseArea {
id: copyMouse
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.copyToClipboard(root.status.selfIp)
}
}
}
}
Text {
width: parent.width
visible: root.installed && !root.status.running && !root.status.needsLogin
text: "Tailscale is off"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
// Peers + exit-node picker — capped height, scrolls once
// the tailnet has more devices than fit, same convention as
// NetworkIndicator.qml's Known/Other Networks list.
Flickable {
id: listFlick
width: parent.width
visible: root.status.running && listColumn.implicitHeight > 0
height: Math.min(listColumn.implicitHeight, panel.maxListHeight)
contentWidth: width
contentHeight: listColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: listColumn
// Inset 3px on every side so ExitNodeRow/PeerRow's
// hover halo has room without being cropped by
// listFlick's clip. Left/right needs the manual
// x/width shift (children bind width: parent.width,
// which ignores Column's own padding); top/bottom
// just needs padding, since that's along Column's
// own layout axis and Flickable's contentHeight
// tracks it.
x: 3
width: listFlick.width - 6
topPadding: 3
bottomPadding: 3
spacing: 6
Column {
width: parent.width
spacing: 4
visible: root.status.exitNodePeers.length > 0 || root.status.activeExitNode !== null
Text {
text: "Exit Node"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Column {
width: parent.width
spacing: 2
ExitNodeRow {
label: "None"
subIcon: "link_off"
active: root.status.activeExitNode === null
onActivate: root.setExitNode(null)
}
Repeater {
model: root.status.exitNodePeers
ExitNodeRow {
required property var modelData
label: modelData.name
subIcon: "shield"
active: modelData.exitNodeActive
onActivate: root.setExitNode(modelData)
}
}
}
}
Column {
width: parent.width
spacing: 4
visible: root.status.peers.length > 0
Text {
text: "Peers"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Column {
width: parent.width
spacing: 2
Repeater {
model: root.status.peers
PeerRow {
required property var modelData
peer: modelData
}
}
}
}
}
}
}
}
}
}