From 38069353161b6b32015e38301e6eaa0f23f322ae Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Aug 2026 09:34:55 +0100 Subject: [PATCH] implimented multiple VPN connectors --- .../modules/bar/TailscaleIndicator.qml | 623 ++++++++++++++++-- .../quickshell/modules/bar/VpnIndicator.qml | 554 ++++++++++++++++ 2 files changed, 1115 insertions(+), 62 deletions(-) create mode 100644 dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index 7ec96f6..777a759 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -16,6 +16,12 @@ Item { // 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. @@ -27,6 +33,38 @@ Item { 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) @@ -41,10 +79,14 @@ Item { 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 @@ -76,13 +118,99 @@ Item { return "devices_other" } - function toggleTailscale() { - if (root.busy) return + 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 - actionProc.command = root.status.running ? ["tailscale", "down"] : ["tailscale", "up"] + 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 @@ -94,20 +222,68 @@ Item { 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.status = root.parseTailscaleStatus(statusCollector.text) + 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 } } @@ -121,12 +297,104 @@ Item { } } + 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 { - interval: 3000 + id: refreshTimer + interval: root._refreshIntervalMs repeat: true + running: true triggeredOnStart: true - running: panel.visible - onTriggered: if (!statusProc.running) statusProc.running = 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 @@ -135,11 +403,15 @@ Item { // 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. + // 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 @@ -167,13 +439,46 @@ Item { } } } + + 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.status.running ? Colors.foreground : Colors.muted + color: root.active ? Colors.foreground : Colors.muted + crossed: !root.active && !root.status.needsLogin + warning: root.status.needsLogin } MouseArea { @@ -279,6 +584,201 @@ Item { } } + // 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 + color: accountRow.selected + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (accountMouse.containsMouse ? Colors.primary : "transparent") + + 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.iconFontFamily + 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.iconFontFamily + 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 + + 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.iconFontFamily + 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.iconFontFamily + 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 + + 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 { @@ -295,8 +795,14 @@ Item { 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 @@ -334,6 +840,8 @@ Item { TailscaleLogo { size: 30 color: label.color + crossed: label.crossed + warning: label.warning } Text { @@ -346,13 +854,51 @@ Item { } ToggleSwitch { - checked: root.status.running + 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. + // 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 !== "" @@ -387,7 +933,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: Qt.openUrlExternally(root.status.authUrl) + onClicked: root.openAuthUrl(root.status.authUrl) } } @@ -458,7 +1004,7 @@ Item { Text { width: parent.width - visible: !root.status.running && !root.status.needsLogin + visible: root.installed && !root.status.running && !root.status.needsLogin text: "Tailscale is off" font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall @@ -534,56 +1080,9 @@ Item { Repeater { model: root.status.peers - Rectangle { - id: peerRow + 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) - } + peer: modelData } } } diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml new file mode 100644 index 0000000..288ce5b --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -0,0 +1,554 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import "../../config" + +// A switcher between commercial VPN providers, distinct from +// TailscaleIndicator.qml — Tailscale is a mesh overlay network you generally +// leave running; these are traditional single-tunnel VPN clients where only +// one makes sense to have active at a time. Connecting one here disconnects +// the other first. +// +// V1 scope: connect (provider's own "fastest"/default) and disconnect per +// provider, live status, no region/server picker yet. Provider CLIs: +// - Mullvad: official `mullvad` CLI (mullvad-vpn-daemon package), has +// real JSON status output. +// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain- +// text status only — parsed defensively since its exact wording isn't +// guaranteed across versions. +Item { + id: root + implicitWidth: label.implicitWidth + 4 + implicitHeight: Appearance.barHeight + + required property var barWindow + + property int refreshIntervalSec: 20 + readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000 + + // Whether each CLI is on PATH at all. Starts optimistic so the panel + // doesn't flash "not installed" before the first `which` check lands. + property bool mullvadInstalled: true + property bool protonInstalled: true + + // "disconnected" | "connecting" | "connected" | "disconnecting" + property string mullvadState: "disconnected" + property string mullvadDetail: "" + property string protonState: "disconnected" + property string protonDetail: "" + + readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected" + + // Set while switching from one provider to the other: holds which one + // to connect once the current one has finished disconnecting. + property string pendingConnect: "" + + // Surfaced under the header for a failed connect/disconnect; cleared + // automatically after a few seconds. + property string actionStatus: "" + + function shortLine(text) { + const line = String(text || "").trim().split("\n")[0] || "" + return line.length > 80 ? line.substring(0, 77) + "…" : line + } + + function applyMullvadStatus(text) { + try { + const data = JSON.parse(text) + const state = String(data.state || "disconnected") + const loc = data.details && data.details.location + root.mullvadState = state + root.mullvadDetail = loc ? [loc.city, loc.country].filter(Boolean).join(", ") : "" + } catch (e) { + root.mullvadState = "disconnected" + root.mullvadDetail = "" + } + VpnExclusivity.mullvadConnected = root.mullvadState === "connected" + } + + // No --json here — ProtonVPN's CLI only prints plain text, so this + // just looks for a "Status: …" line and, best-effort, a following + // "Server:"/"Country:" line if the CLI happens to print one. + function applyProtonStatus(text) { + const body = String(text || "") + const statusMatch = body.match(/^Status:\s*(.+)$/mi) + const raw = statusMatch ? statusMatch[1].trim().toLowerCase() : "" + root.protonState = raw.indexOf("connected") === 0 ? "connected" : "disconnected" + const serverMatch = body.match(/^Server:\s*(.+)$/mi) + const countryMatch = body.match(/^Country:\s*(.+)$/mi) + root.protonDetail = serverMatch ? serverMatch[1].trim() : (countryMatch ? countryMatch[1].trim() : "") + VpnExclusivity.protonConnected = root.protonState === "connected" + } + + function refreshMullvad() { + if (!root.mullvadInstalled) { + if (!mullvadWhichProc.running) mullvadWhichProc.running = true + return + } + if (!mullvadStatusProc.running) { + mullvadStatusProc.running = true + mullvadPollWatchdog.restart() + } + } + + function refreshProton() { + if (!root.protonInstalled) { + if (!protonWhichProc.running) protonWhichProc.running = true + return + } + if (!protonStatusProc.running) { + protonStatusProc.running = true + protonPollWatchdog.restart() + } + } + + function refresh() { + root.refreshMullvad() + root.refreshProton() + } + + function connectMullvad() { + if (mullvadConnectProc.running || mullvadDisconnectProc.running) return + if (root.protonState !== "disconnected") { + root.pendingConnect = "mullvad" + root.disconnectProton() + return + } + root.pendingConnect = "" + mullvadConnectProc.running = true + } + + function connectProton() { + if (protonConnectProc.running || protonDisconnectProc.running) return + if (root.mullvadState !== "disconnected") { + root.pendingConnect = "protonvpn" + root.disconnectMullvad() + return + } + root.pendingConnect = "" + protonConnectProc.running = true + } + + function disconnectMullvad() { + if (mullvadDisconnectProc.running) return + mullvadDisconnectProc.running = true + } + + function disconnectProton() { + if (protonDisconnectProc.running) return + protonDisconnectProc.running = true + } + + function toggleMullvad() { + if (mullvadConnectProc.running || mullvadDisconnectProc.running) return + if (root.mullvadState === "connected") root.disconnectMullvad() + else root.connectMullvad() + } + + function toggleProton() { + if (protonConnectProc.running || protonDisconnectProc.running) return + if (root.protonState === "connected") root.disconnectProton() + else root.connectProton() + } + + // Tailscale fights Mullvad/ProtonVPN for the default route/DNS, so fire + // this right after either one finishes connecting — a harmless no-op + // if Tailscale was already down. TailscaleIndicator.qml also backs + // itself off reactively (via VpnExclusivity) as a backstop, but doing + // it here too avoids waiting out its poll interval. + Process { + id: tailscaleDownProc + command: ["tailscale", "down"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + // --- Mullvad --------------------------------------------------------- + + Process { + id: mullvadWhichProc + command: ["which", "mullvad"] + running: true + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.mullvadInstalled = exitCode === 0 + if (root.mullvadInstalled) root.refreshMullvad() + } + } + + Process { + id: mullvadStatusProc + command: ["mullvad", "status", "--json"] + stdout: StdioCollector { + id: mullvadStatusCollector + onStreamFinished: root.applyMullvadStatus(mullvadStatusCollector.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0 && !mullvadWhichProc.running) mullvadWhichProc.running = true + } + } + + Process { + id: mullvadConnectProc + command: ["mullvad", "connect", "--wait"] + stdout: StdioCollector {} + stderr: StdioCollector { id: mullvadConnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed") + actionStatusTimer.restart() + } else if (!tailscaleDownProc.running) { + tailscaleDownProc.running = true + } + root.refreshMullvad() + } + } + + Process { + id: mullvadDisconnectProc + command: ["mullvad", "disconnect", "--wait"] + stdout: StdioCollector {} + stderr: StdioCollector { id: mullvadDisconnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed") + actionStatusTimer.restart() + } + root.refreshMullvad() + if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } + else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() } + } + } + + Timer { + id: mullvadPollWatchdog + interval: 15000 + repeat: false + onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false + } + + // --- ProtonVPN --------------------------------------------------------- + + Process { + id: protonWhichProc + command: ["which", "protonvpn"] + running: true + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + root.protonInstalled = exitCode === 0 + if (root.protonInstalled) root.refreshProton() + } + } + + Process { + id: protonStatusProc + command: ["protonvpn", "status"] + stdout: StdioCollector { + id: protonStatusCollector + onStreamFinished: root.applyProtonStatus(protonStatusCollector.text) + } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0 && !protonWhichProc.running) protonWhichProc.running = true + } + } + + Process { + id: protonConnectProc + command: ["protonvpn", "connect"] + stdout: StdioCollector {} + stderr: StdioCollector { id: protonConnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed") + actionStatusTimer.restart() + } else if (!tailscaleDownProc.running) { + tailscaleDownProc.running = true + } + root.refreshProton() + } + } + + Process { + id: protonDisconnectProc + command: ["protonvpn", "disconnect"] + stdout: StdioCollector {} + stderr: StdioCollector { id: protonDisconnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed") + actionStatusTimer.restart() + } + root.refreshProton() + if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } + else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() } + } + } + + Timer { + id: protonPollWatchdog + interval: 15000 + repeat: false + onTriggered: if (protonStatusProc.running) protonStatusProc.running = false + } + + // --- Shared timers ----------------------------------------------------- + + Timer { + id: refreshTimer + interval: root._refreshIntervalMs + repeat: true + running: true + triggeredOnStart: true + onTriggered: root.refresh() + } + + Timer { + id: actionStatusTimer + interval: 4000 + repeat: false + onTriggered: root.actionStatus = "" + } + + // --- Bar icon ------------------------------------------------------------ + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: Appearance.iconSize + color: root.anyConnected ? Colors.primary : Colors.muted + text: "vpn_lock" + } + + 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 + property bool busy: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + opacity: toggleRoot.enabled ? 1 : 0.4 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: (toggleRoot.checked ? 1 : 0.35) * (toggleRoot.busy ? 0.6 : 1) + } + 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 + enabled: !toggleRoot.busy + cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + // A single provider's row — icon, name, live status line, and a toggle + // that connects/disconnects it (routed back through root so it can + // enforce the one-provider-at-a-time rule). + component ProviderRow: Rectangle { + id: provRow + property string label: "" + property string subIcon: "shield" + property string detailText: "" + property string state: "disconnected" + property bool installed: true + signal toggle() + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: rowContent.implicitHeight + 12 + color: provRow.state === "connected" + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : "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.AlignHCenter + text: provRow.subIcon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: provRow.state === "connected" ? Colors.primary : Colors.foreground + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + + Text { + Layout.fillWidth: true + text: provRow.label + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: provRow.state === "connected" + color: Colors.foreground + } + Text { + Layout.fillWidth: true + text: !provRow.installed ? "Not installed" + : provRow.state === "connecting" ? "Connecting…" + : provRow.state === "disconnecting" ? "Disconnecting…" + : provRow.state === "connected" ? (provRow.detailText || "Connected") + : "Disconnected" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + ToggleSwitch { + enabled: provRow.installed + checked: provRow.state === "connected" || provRow.state === "connecting" + busy: provRow.state === "connecting" || provRow.state === "disconnecting" + onToggled: provRow.toggle() + } + } + } + + // --- Panel ----------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + 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 + 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 + + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: "vpn_lock" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 22 + color: root.anyConnected ? Colors.primary : Colors.foreground + } + Text { + Layout.fillWidth: true + text: "VPN" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + Text { + visible: root.actionStatus !== "" + width: parent.width + text: root.actionStatus + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + wrapMode: Text.WordWrap + } + + Column { + width: parent.width + spacing: 2 + + ProviderRow { + label: "Mullvad" + subIcon: "shield" + state: root.mullvadState + detailText: root.mullvadDetail + installed: root.mullvadInstalled + onToggle: root.toggleMullvad() + } + ProviderRow { + label: "ProtonVPN" + subIcon: "gpp_good" + state: root.protonState + detailText: root.protonDetail + installed: root.protonInstalled + onToggle: root.toggleProton() + } + } + + Text { + width: parent.width + text: "Connecting one disconnects the other — only one VPN tunnel at a time." + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + wrapMode: Text.WordWrap + } + } + } + } +}