diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml new file mode 100644 index 0000000..7ec96f6 --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -0,0 +1,596 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import "../../config" + +Item { + id: root + implicitWidth: label.implicitWidth + 4 + 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 + + // 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 + + 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) }) + peers.push({ + id: id, + name: name, + ip: ips.length > 0 ? ips[0] : "", + 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 toggleTailscale() { + if (root.busy) return + root.busy = true + actionProc.command = root.status.running ? ["tailscale", "down"] : ["tailscale", "up"] + actionProc.running = true + } + + 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 + } + + Process { + id: statusProc + command: ["tailscale", "status", "--json"] + stdout: StdioCollector { + id: statusCollector + onStreamFinished: root.status = root.parseTailscaleStatus(statusCollector.text) + } + } + + Process { + id: actionProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.busy = false + if (!statusProc.running) statusProc.running = true + } + } + + Process { + id: exitNodeProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.busy = false + if (!statusProc.running) statusProc.running = true + } + } + + Timer { + interval: 3000 + repeat: true + triggeredOnStart: true + running: panel.visible + onTriggered: if (!statusProc.running) statusProc.running = true + } + + // 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. + component TailscaleLogo: Item { + id: logo + property color color: Colors.foreground + property real size: 16 + 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 + height: logo.dotSize + radius: width / 2 + color: modelData ? '#c9f0f0f0' : logo.dimColor + } + } + } + } + + TailscaleLogo { + id: label + anchors.centerIn: parent + size: 16 + color: root.status.running ? Colors.foreground : Colors.muted + } + + 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 + } + 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 { + anchors.fill: parent + 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 + color: exitRow.active + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (rowMouse.containsMouse ? Colors.primary : "transparent") + + 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.iconFontFamily + 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.iconFontFamily + 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() + } + } + + // --- 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 } + } + + anchor { + window: root.barWindow + 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: 30 + color: label.color + } + + Text { + Layout.fillWidth: true + text: "Tailscale" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + ToggleSwitch { + checked: root.status.running + onToggled: root.toggleTailscale() + } + } + + // Sign-in prompt — only ever appears if the daemon reports + // NeedsLogin with a real auth URL to open. + 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.iconFontFamily + 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: Qt.openUrlExternally(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.iconFontFamily + 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.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 + width: listFlick.width + 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 + Rectangle { + id: peerRow + required property var modelData + width: parent.width + radius: 0 + color: peerMouse.containsMouse ? Colors.primary : "transparent" + implicitHeight: peerContent.implicitHeight + 12 + + 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.modelData.os) + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: peerRow.modelData.name + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + text: peerRow.modelData.ip + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: peerMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + MouseArea { + id: peerMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.copyToClipboard(peerRow.modelData.ip) + } + } + } + } + } + } + } + } + } + } +}