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 } } } } }