From a0b9ea29fe208b309f7bfa17e24e10ea4dfe6205 Mon Sep 17 00:00:00 2001 From: thomas Date: Sat, 5 Sep 2026 21:56:42 +0100 Subject: [PATCH] Feature: Added options for mullvad vpn --- .../quickshell/modules/bar/VpnIndicator.qml | 536 +++++++++++++++++- 1 file changed, 505 insertions(+), 31 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index aa0d93f..cc28131 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -45,9 +45,41 @@ Item { // 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: "" + // Surfaced under each provider's own row for a failed connect/ + // disconnect/settings change; cleared automatically after a few + // seconds. Kept separate per provider so an error from one doesn't + // show up hanging under the other's row. + property string mullvadActionStatus: "" + property string protonActionStatus: "" + + // --- Mullvad advanced options (auto-connect, relay location, multihop) -- + // ProtonVPN's CLI is plain-text-only and far less predictable across + // versions, so this extra panel is Mullvad-specific rather than a + // second ProviderRow feature. + + property bool mullvadOptionsOpen: false + property bool mullvadAutoConnect: false + property bool mullvadMultihop: false + // Human-readable current relay constraint, e.g. "Any" or "Sweden" — + // resolved from `relay get`'s "country xx[ city yyy]" syntax against + // mullvadCountries once that list has loaded. + property string mullvadLocationLabel: "Any" + property string mullvadEntryLabel: "Any" + // [{name, code}], parsed from `mullvad relay list` and cached for the + // life of the panel — the full relay list rarely changes and re-parsing + // ~50 countries on every open would just be wasted work. + property var mullvadCountries: [] + // "" | "exit" | "entry" — which location field's picker is open, if any. + property string mullvadPickerTarget: "" + property string mullvadPickerFilter: "" + + readonly property var mullvadFilteredCountries: { + const f = root.mullvadPickerFilter.trim().toLowerCase() + if (!f) return root.mullvadCountries + return root.mullvadCountries.filter(function(c) { + return c.name.toLowerCase().indexOf(f) !== -1 || c.code.indexOf(f) !== -1 + }) + } function shortLine(text) { const line = String(text || "").trim().split("\n")[0] || "" @@ -82,6 +114,95 @@ Item { VpnExclusivity.protonConnected = root.protonState === "connected" } + function applyMullvadAutoConnect(text) { + const m = String(text || "").match(/autoconnect:\s*(on|off)/i) + root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on" + } + + // Resolves relay get's "any" / "country xx" / "country xx city yyy" / + // bare-hostname syntax into a display label. Falls back to the raw + // country code (or the raw string) when mullvadCountries hasn't loaded + // yet, rather than showing nothing. + function describeMullvadLocation(raw) { + const s = String(raw || "").trim() + if (!s || s === "any") return "Any" + const m = s.match(/^country\s+([a-z]{2})\b/i) + if (!m) return s + const code = m[1].toLowerCase() + const country = root.mullvadCountries.find(function(c) { return c.code === code }) + return country ? country.name : code.toUpperCase() + } + + function applyMullvadRelay(text) { + const body = String(text || "") + const locMatch = body.match(/^\s*Location:\s*(.+)$/mi) + const multihopMatch = body.match(/^\s*Multihop state:\s*(\w+)/mi) + const entryMatch = body.match(/^\s*Multihop entry:\s*(.+)$/mi) + root.mullvadMultihop = !!multihopMatch && multihopMatch[1].toLowerCase() === "enabled" + root.mullvadLocationLabel = root.describeMullvadLocation(locMatch ? locMatch[1].trim() : "") + root.mullvadEntryLabel = root.describeMullvadLocation(entryMatch ? entryMatch[1].trim() : "") + } + + // Only country-level lines are of interest ("Sweden (se)", flush left) — + // city ("\tGothenburg (got) @ ...") and server lines are indented and + // their parenthesised code is never exactly two letters, so the regex + // alone is enough to tell them apart without checking indentation. + function applyMullvadCountries(text) { + const lines = String(text || "").split("\n") + const countries = [] + for (const line of lines) { + const m = line.match(/^(\S.*) \(([a-z]{2})\)\s*$/) + if (m) countries.push({ name: m[1].trim(), code: m[2] }) + } + countries.sort(function(a, b) { return a.name.localeCompare(b.name) }) + root.mullvadCountries = countries + // Re-resolve any labels already showing raw codes now that names + // are available. + if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true + } + + function openMullvadOptions() { + root.mullvadOptionsOpen = !root.mullvadOptionsOpen + if (root.mullvadOptionsOpen) { + if (!mullvadAutoConnectGetProc.running) mullvadAutoConnectGetProc.running = true + if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true + if (root.mullvadCountries.length === 0 && !mullvadRelayListProc.running) mullvadRelayListProc.running = true + } else { + root.mullvadPickerTarget = "" + } + } + + function openMullvadPicker(target) { + root.mullvadPickerFilter = "" + root.mullvadPickerTarget = root.mullvadPickerTarget === target ? "" : target + } + + function pickMullvadLocation(code) { + const target = root.mullvadPickerTarget + root.mullvadPickerTarget = "" + root.setMullvadLocation(target, code) + } + + function setMullvadAutoConnect(on) { + if (mullvadAutoConnectSetProc.running) return + mullvadAutoConnectSetProc.command = ["mullvad", "auto-connect", "set", on ? "on" : "off"] + mullvadAutoConnectSetProc.running = true + } + + function setMullvadMultihop(on) { + if (mullvadRelaySetProc.running) return + mullvadRelaySetProc.command = ["mullvad", "relay", "set", "multihop", on ? "on" : "off"] + mullvadRelaySetProc.running = true + } + + function setMullvadLocation(target, code) { + if (mullvadRelaySetProc.running) return + mullvadRelaySetProc.command = target === "entry" + ? ["mullvad", "relay", "set", "entry", "location", code] + : ["mullvad", "relay", "set", "location", code] + mullvadRelaySetProc.running = true + } + function refreshMullvad() { if (!root.mullvadInstalled) { if (!mullvadWhichProc.running) mullvadWhichProc.running = true @@ -197,8 +318,8 @@ Item { stderr: StdioCollector { id: mullvadConnectErr } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) { - root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed") - actionStatusTimer.restart() + root.mullvadActionStatus = root.shortLine(mullvadConnectErr.text || "Connect failed") + mullvadActionStatusTimer.restart() } else if (!tailscaleDownProc.running) { tailscaleDownProc.running = true } @@ -213,8 +334,8 @@ Item { stderr: StdioCollector { id: mullvadDisconnectErr } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) { - root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed") - actionStatusTimer.restart() + root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed") + mullvadActionStatusTimer.restart() } root.refreshMullvad() if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } @@ -229,6 +350,77 @@ Item { onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false } + // --- Mullvad advanced options: auto-connect, relay location, multihop -- + // Only queried while the options section is open (see + // root.openMullvadOptions), not on the regular status poll — these + // don't change on their own the way tunnel state does. + + Process { + id: mullvadAutoConnectGetProc + command: ["mullvad", "auto-connect", "get"] + stdout: StdioCollector { + id: mullvadAutoConnectGetCollector + onStreamFinished: root.applyMullvadAutoConnect(mullvadAutoConnectGetCollector.text) + } + } + + Process { + id: mullvadAutoConnectSetProc + stdout: StdioCollector {} + stderr: StdioCollector { id: mullvadAutoConnectSetErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.mullvadActionStatus = root.shortLine(mullvadAutoConnectSetErr.text || "Auto-connect change failed") + mullvadActionStatusTimer.restart() + } + if (!mullvadAutoConnectGetProc.running) mullvadAutoConnectGetProc.running = true + } + } + + Process { + id: mullvadRelayGetProc + command: ["mullvad", "relay", "get"] + stdout: StdioCollector { + id: mullvadRelayGetCollector + onStreamFinished: root.applyMullvadRelay(mullvadRelayGetCollector.text) + } + } + + Process { + id: mullvadRelayListProc + command: ["mullvad", "relay", "list"] + stdout: StdioCollector { + id: mullvadRelayListCollector + onStreamFinished: root.applyMullvadCountries(mullvadRelayListCollector.text) + } + } + + Process { + id: mullvadRelaySetProc + stdout: StdioCollector {} + stderr: StdioCollector { id: mullvadRelaySetErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.mullvadActionStatus = root.shortLine(mullvadRelaySetErr.text || "Relay change failed") + mullvadActionStatusTimer.restart() + } + if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true + // Relay constraint changes don't take effect on an already-open + // tunnel by themselves — kick a reconnect so switching location/ + // multihop while connected actually moves the tunnel, instead of + // silently only applying on the next manual reconnect. + if (root.mullvadState === "connected" && !mullvadReconnectProc.running) mullvadReconnectProc.running = true + } + } + + Process { + id: mullvadReconnectProc + command: ["mullvad", "reconnect"] + stdout: StdioCollector {} + stderr: StdioCollector {} + onExited: (exitCode, exitStatus) => root.refreshMullvad() + } + // --- ProtonVPN --------------------------------------------------------- Process { @@ -261,8 +453,8 @@ Item { stderr: StdioCollector { id: protonConnectErr } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) { - root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed") - actionStatusTimer.restart() + root.protonActionStatus = root.shortLine(protonConnectErr.text || "Connect failed") + protonActionStatusTimer.restart() } else if (!tailscaleDownProc.running) { tailscaleDownProc.running = true } @@ -277,8 +469,8 @@ Item { stderr: StdioCollector { id: protonDisconnectErr } onExited: (exitCode, exitStatus) => { if (exitCode !== 0) { - root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed") - actionStatusTimer.restart() + root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed") + protonActionStatusTimer.restart() } root.refreshProton() if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } @@ -305,10 +497,17 @@ Item { } Timer { - id: actionStatusTimer + id: mullvadActionStatusTimer interval: 4000 repeat: false - onTriggered: root.actionStatus = "" + onTriggered: root.mullvadActionStatus = "" + } + + Timer { + id: protonActionStatusTimer + interval: 4000 + repeat: false + onTriggered: root.protonActionStatus = "" } // --- Bar icon ------------------------------------------------------------ @@ -416,7 +615,13 @@ Item { property string detailText: "" property string state: "disconnected" property bool installed: true + // When true, shows a chevron that expands a provider-specific + // options section below this row (used for Mullvad's advanced + // options; ProtonVPN's row leaves this false). + property bool expandable: false + property bool expanded: false signal toggle() + signal expandToggle() width: parent ? parent.width : 0 radius: 0 implicitHeight: rowContent.implicitHeight + 12 @@ -477,9 +682,91 @@ Item { busy: provRow.state === "connecting" || provRow.state === "disconnecting" onToggled: provRow.toggle() } + + Text { + visible: provRow.expandable + text: provRow.expanded ? "expand_less" : "expand_more" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 15 + color: expandMouse.containsMouse ? Colors.foreground : Colors.muted + + MouseArea { + id: expandMouse + anchors.fill: parent + anchors.margins: -5 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: provRow.expandToggle() + } + } } } + // A single clickable "Location: Sweden ›" row inside the Mullvad + // options section — used for both the exit location and (when multihop + // is on) the entry location. Clicking routes back to root, which opens + // the shared country picker below rather than each field owning one. + component LocationField: Item { + id: locField + property string fieldLabel: "" + property string valueText: "Any" + property bool open: false + signal clicked() + width: parent ? parent.width : 0 + implicitHeight: locRow.implicitHeight + + RowLayout { + id: locRow + width: parent.width + spacing: 6 + + Text { + text: locField.fieldLabel + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + text: locField.valueText + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + font.bold: true + color: Colors.foreground + } + Text { + text: locField.open ? "expand_less" : "chevron_right" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: Colors.muted + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: locField.clicked() + } + } + + // A failed connect/disconnect/settings-change message, shown directly + // under the provider row it came from rather than in one shared line + // for both providers — keeps an error attributed to whichever CLI it + // actually came from. + component StatusLine: Text { + property string message: "" + visible: message !== "" + width: parent ? parent.width : 0 + text: message + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + wrapMode: Text.WordWrap + } + // --- Panel ----------------------------------------------------------- PopupWindow { @@ -494,7 +781,18 @@ Item { NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } } - onVisibleChanged: if (visible) root.refresh() + // Leaving mullvadOptionsOpen itself alone across opens (it's a + // deliberate "keep this expanded" choice), but the country picker + // is transient state scoped to one visit — closing the panel with + // it open would otherwise leave a stale filter/list waiting. + onVisibleChanged: { + if (visible) { + root.refresh() + } else { + root.mullvadPickerTarget = "" + root.mullvadPickerFilter = "" + } + } anchor { window: root.barWindow @@ -548,28 +846,202 @@ Item { } } - 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: "gpp_good" - state: root.mullvadState - detailText: root.mullvadDetail - installed: root.mullvadInstalled - onToggle: root.toggleMullvad() + // Mullvad's connect row and its advanced options share a + // single bordered card — the border wraps the whole + // section (row, status line, and options together) and + // grows to include the options/picker as they open, + // rather than the options having a separate box of + // their own below an unbordered connect row. + Rectangle { + width: parent.width + radius: 0 + color: Colors.background + border.color: Colors.muted + border.width: 1 + implicitHeight: mullvadSectionColumn.implicitHeight + 16 + clip: true + // Without this, the card snapped to its new size + // instantly while the popup around it was still + // animating open, so the border visibly lagged + // behind (or cut off) content like the country + // picker appearing/disappearing beneath it. + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + Column { + id: mullvadSectionColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 8 + + ProviderRow { + label: "Mullvad" + subIcon: "gpp_good" + state: root.mullvadState + detailText: root.mullvadDetail + installed: root.mullvadInstalled + expandable: root.mullvadInstalled + expanded: root.mullvadOptionsOpen + onToggle: root.toggleMullvad() + onExpandToggle: root.openMullvadOptions() + } + + StatusLine { message: root.mullvadActionStatus } + + // Advanced options — auto-connect, exit/entry + // relay location, multihop. + Column { + id: mullvadOptionsColumn + width: parent.width + visible: root.mullvadOptionsOpen && root.mullvadInstalled + spacing: 8 + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "Auto-connect" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + ToggleSwitch { + checked: root.mullvadAutoConnect + busy: mullvadAutoConnectSetProc.running + onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect) + } + } + + RowLayout { + width: parent.width + Text { + Layout.fillWidth: true + text: "Multihop" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + ToggleSwitch { + checked: root.mullvadMultihop + busy: mullvadRelaySetProc.running + onToggled: root.setMullvadMultihop(!root.mullvadMultihop) + } + } + + LocationField { + fieldLabel: root.mullvadMultihop ? "Exit" : "Location" + valueText: root.mullvadLocationLabel + open: root.mullvadPickerTarget === "exit" + onClicked: root.openMullvadPicker("exit") + } + + LocationField { + visible: root.mullvadMultihop + fieldLabel: "Entry" + valueText: root.mullvadEntryLabel + open: root.mullvadPickerTarget === "entry" + onClicked: root.openMullvadPicker("entry") + } + + // Shared country picker for whichever LocationField + // was last clicked — filter box plus a capped, + // scrollable list ("Any" always pinned first). + Column { + width: parent.width + visible: root.mullvadPickerTarget !== "" + spacing: 6 + + Rectangle { + width: parent.width + implicitHeight: 24 + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + Text { + anchors.fill: parent + anchors.margins: 6 + visible: mullvadFilterInput.text.length === 0 + text: "Search country…" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + TextInput { + id: mullvadFilterInput + anchors.fill: parent + anchors.margins: 6 + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + focus: root.mullvadPickerTarget !== "" + text: root.mullvadPickerFilter + onTextChanged: root.mullvadPickerFilter = text + } + } + + Flickable { + id: mullvadPickerFlick + width: parent.width + height: Math.min(mullvadPickerColumn.implicitHeight, 150) + contentWidth: width + contentHeight: mullvadPickerColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: mullvadPickerColumn + width: mullvadPickerFlick.width + spacing: 1 + + Repeater { + model: [{ name: "Any", code: "any" }].concat(root.mullvadFilteredCountries) + Rectangle { + required property var modelData + width: parent.width + implicitHeight: 22 + radius: 0 + color: countryMouse.containsMouse ? Colors.primary : "transparent" + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 4 + anchors.rightMargin: 4 + elide: Text.ElideRight + text: modelData.name + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: countryMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + + MouseArea { + id: countryMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.pickMullvadLocation(modelData.code) + } + } + } + } + } + } + } + } } + ProviderRow { label: "ProtonVPN" subIcon: "gpp_good" @@ -578,6 +1050,8 @@ Item { installed: root.protonInstalled onToggle: root.toggleProton() } + + StatusLine { message: root.protonActionStatus } } Text {