diff --git a/config/dot/quickshell/modules/bar/StatusCluster.qml b/config/dot/quickshell/modules/bar/StatusCluster.qml index 968ccef..7b1273b 100644 --- a/config/dot/quickshell/modules/bar/StatusCluster.qml +++ b/config/dot/quickshell/modules/bar/StatusCluster.qml @@ -84,6 +84,15 @@ Pill { } } + // A tunnel that is up changes what every connection means, so it earns a + // permanent glyph while active -- and its absence is the resting state, + // same shape as Bluetooth below. + StatusGlyph { + visible: Vpn.anyActive + glyph: "\u{F0582}" // md-vpn + color: Theme.accent + } + StatusGlyph { glyph: { if (root.muted || root.volume <= 0) diff --git a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml index 60346dd..251b007 100644 --- a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml +++ b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml @@ -21,8 +21,8 @@ Item { implicitWidth: Theme.controlCenterWidth implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 - // "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is - // open at a time, so the panel never grows past the screen. + // "" | "wifi" | "vpn" | "bluetooth" | "sink" | "source". Only one detail + // list is open at a time, so the panel never grows past the screen. property string expandedSection: "" function expand(name: string): void { @@ -128,6 +128,25 @@ Item { onToggled: Connectivity.setWired(!Connectivity.wiredOn) } + // Only when a VPN profile is saved at all: a machine with none has + // nothing to toggle, and the tile would be a control for absent + // configuration -- same reasoning as the Ethernet tile above. + Toggle { + width: root.cellWidth + visible: Vpn.available + icon: "network-vpn-symbolic" + label: "VPN" + active: Vpn.anyActive + enabled: !Vpn.busy + sublabel: { + if (Vpn.busy) + return "Working…"; + return Vpn.anyActive ? Vpn.activeSummary : "Off"; + } + onToggled: Vpn.toggle() + onExpanded: root.expand("vpn") + } + Toggle { width: root.cellWidth icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic" @@ -209,6 +228,16 @@ Item { } } + Section { + width: content.width + expanded: root.expandedSection === "vpn" + + VpnList { + anchors.left: parent.left + anchors.right: parent.right + } + } + Section { width: content.width expanded: root.expandedSection === "bluetooth" diff --git a/config/dot/quickshell/modules/quicksettings/VpnList.qml b/config/dot/quickshell/modules/quicksettings/VpnList.qml new file mode 100644 index 0000000..187410d --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/VpnList.qml @@ -0,0 +1,80 @@ +// VPN profile picker: every saved VPN or WireGuard connection, active first. +// Clicking a row flips that one profile, so a machine with several tunnels +// can switch without a trip through nmcli — which is the whole reason this +// list exists (see services/Vpn.qml). + +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + implicitHeight: list.implicitHeight + + readonly property var profiles: { + const list = Vpn.connections.slice(); + list.sort((a, b) => { + if (a.active !== b.active) + return a.active ? -1 : 1; + return (a.name || "").localeCompare(b.name || ""); + }); + return list; + } + + function stateText(profile): string { + const kind = profile.kind === "wireguard" ? "WireGuard" : "VPN"; + return profile.active ? kind + " · Connected" : kind; + } + + ScrollColumn { + id: list + anchors.fill: parent + maxHeight: 300 + + Text { + width: parent.width + visible: root.profiles.length === 0 + topPadding: 12 + bottomPadding: 12 + horizontalAlignment: Text.AlignHCenter + text: "No VPN profiles" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Repeater { + model: root.profiles + + RowButton { + required property var modelData + + width: parent.width + icon: "network-vpn-symbolic" + iconFallback: "network-workgroup-symbolic" + label: modelData.name + sublabel: root.stateText(modelData) + selected: modelData.active + dimmed: Vpn.busy + onClicked: Vpn.setActive(modelData.uuid, !modelData.active) + } + } + + // Activation failures land here rather than vanishing: "the toggle + // did nothing" was exactly the complaint that motivated this panel. + Text { + width: parent.width + visible: Vpn.lastError !== "" + topPadding: 4 + bottomPadding: 8 + leftPadding: 12 + rightPadding: 12 + wrapMode: Text.Wrap + text: Vpn.lastError + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } +} diff --git a/config/dot/quickshell/modules/quicksettings/qmldir b/config/dot/quickshell/modules/quicksettings/qmldir index 83a5679..fd44515 100644 --- a/config/dot/quickshell/modules/quicksettings/qmldir +++ b/config/dot/quickshell/modules/quicksettings/qmldir @@ -16,5 +16,6 @@ RecentExchange 1.0 RecentExchange.qml RowButton 1.0 RowButton.qml ScrollColumn 1.0 ScrollColumn.qml Section 1.0 Section.qml +VpnList 1.0 VpnList.qml WifiList 1.0 WifiList.qml PowerProfileList 1.0 PowerProfileList.qml diff --git a/config/dot/quickshell/scripts/panama-vpn b/config/dot/quickshell/scripts/panama-vpn new file mode 100755 index 0000000..697a7d5 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-vpn @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +# VPN connections, via NetworkManager. +# +# Quickshell.Networking covers Wi-Fi and wired devices but has no surface for +# VPN or WireGuard connections at all, so this is the one sanctioned exception +# to the no-nmcli rule stated in services/Connectivity.qml: there is nothing +# else to talk to. The moment Quickshell grows VPN support, this helper is what +# gets deleted. +# +# Usage: +# panama-vpn list -> {"connections":[{"name","uuid","kind","active","timestamp"}],"error":""} +# panama-vpn up +# panama-vpn down +# +# `up` is bounded and self-cleaning: a VPN whose server is unreachable is +# exactly the case this exists for, and NetworkManager's default is to keep +# trying for 90 seconds while every packet on a full-tunnel profile goes into +# the void. Waiting a bounded time and deactivating on failure is what makes +# the quick-settings toggle safe to press on a broken profile. + +set -uo pipefail + +emit_error() { + printf '{"connections":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" + exit 0 +} + +command -v nmcli >/dev/null 2>&1 || emit_error 'nmcli is not available' + +cmd_list() { + local rows active + # TIMESTAMP is when the connection last activated successfully; the toggle + # uses it to pick the profile the person most recently used. + rows="$(nmcli -t -f NAME,UUID,TYPE,TIMESTAMP connection show 2>/dev/null)" \ + || emit_error 'NetworkManager did not answer' + active="$(nmcli -t -f UUID connection show --active 2>/dev/null)" || active="" + + # Fields are parsed from the end because NAME may contain escaped colons; + # UUID, TYPE and TIMESTAMP never do. + awk -F: -v active="$active" ' + $(NF-1) == "wireguard" || $(NF-1) == "vpn" { + timestamp = $NF; kind = $(NF-1); uuid = $(NF-2); + name = $1; + for (i = 2; i <= NF-3; i++) name = name FS $i; + gsub(/\\:/, ":", name); + is_active = index(active, uuid) > 0 ? "true" : "false"; + printf "%s\t%s\t%s\t%s\t%s\n", name, uuid, kind, is_active, timestamp; + } + ' <<<"$rows" | jq -Rn ' + {"connections": [inputs | split("\t") + | {name: .[0], uuid: .[1], kind: .[2], + active: (.[3] == "true"), timestamp: (.[4] | tonumber? // 0)}], + "error": ""}' +} + +cmd_up() { + local uuid="$1" output + if ! output="$(nmcli -w 25 connection up uuid "$uuid" 2>&1)"; then + # Roll the half-activated connection back down so a dead server does + # not leave the machine with a black-hole default route. + nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true + printf '%s\n' "$output" >&2 + exit 1 + fi +} + +cmd_down() { + local uuid="$1" output + if ! output="$(nmcli connection down uuid "$uuid" 2>&1)"; then + printf '%s\n' "$output" >&2 + exit 1 + fi +} + +case "${1:-}" in + list) cmd_list ;; + up) [[ -n "${2:-}" ]] || { echo 'panama-vpn up needs a connection uuid' >&2; exit 1; } + cmd_up "$2" ;; + down) [[ -n "${2:-}" ]] || { echo 'panama-vpn down needs a connection uuid' >&2; exit 1; } + cmd_down "$2" ;; + *) echo 'usage: panama-vpn list | up | down ' >&2; exit 1 ;; +esac diff --git a/config/dot/quickshell/services/Vpn.qml b/config/dot/quickshell/services/Vpn.qml new file mode 100644 index 0000000..ad67303 --- /dev/null +++ b/config/dot/quickshell/services/Vpn.qml @@ -0,0 +1,134 @@ +pragma Singleton + +// VPN and WireGuard connections, for the quick-settings toggle and the bar. +// +// Everything goes through scripts/panama-vpn, the one sanctioned nmcli +// exception (see the note there and in Connectivity.qml): Quickshell's +// Networking module has no VPN surface yet. Nothing is stored in settings.json +// -- NetworkManager owns which profiles exist and which are active, and a +// stored copy would just be restored over whatever the daemon knows. +// +// The design requirement, learned the hard way: activating a profile whose +// server is unreachable must never leave the machine stranded. The helper +// bounds activation and rolls a failed attempt back down; this service only +// reports what actually happened. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-vpn" + + // [{name, uuid, kind, active, timestamp}] + property var connections: [] + property bool scanned: false + property bool busy: false + property string lastError: "" + + readonly property bool available: root.connections.length > 0 + readonly property var activeConnections: root.connections.filter(connection => connection.active) + readonly property bool anyActive: root.activeConnections.length > 0 + + // What the toggle's sublabel shows: the one active name, or how many. + readonly property string activeSummary: { + if (root.activeConnections.length === 0) + return ""; + if (root.activeConnections.length === 1) + return root.activeConnections[0].name; + return root.activeConnections.length + " active"; + } + + function refresh(): void { + if (!query.running) + query.running = true; + } + + function setActive(uuid: string, on: bool): void { + if (root.busy) + return; + root.busy = true; + root.lastError = ""; + apply.command = [root.helperPath, on ? "up" : "down", uuid]; + apply.running = true; + } + + // The main toggle: anything active goes down; nothing active brings up the + // profile most recently used, which is GNOME's behavior and almost always + // what was meant on a machine with more than one profile. + function toggle(): void { + if (root.anyActive) { + root.setActive(root.activeConnections[0].uuid, false); + return; + } + const preferred = root.connections.reduce((best, candidate) => + best === null || candidate.timestamp > best.timestamp ? candidate : best, null); + if (preferred !== null) + root.setActive(preferred.uuid, true); + } + + Process { + id: query + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.connections = Array.isArray(parsed.connections) ? parsed.connections : []; + if (String(parsed.error ?? "") !== "") + root.lastError = String(parsed.error); + } catch (error) { + root.connections = []; + root.lastError = "Could not read the VPN helper's output."; + console.warn("Vpn: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: apply + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + // Re-read rather than assuming: activation can fail, and the helper + // may have rolled a failed attempt back down. + onExited: { + root.busy = false; + root.refresh(); + } + } + + // NetworkManager state can change under us -- nmcli in a terminal, a + // connection dropping, another device editing profiles. `nmcli monitor` + // emits a line per event; the refresh is coalesced because one action + // often produces several lines in a burst. + Process { + id: monitor + command: ["nmcli", "monitor"] + running: true + stdout: SplitParser { + onRead: refreshDebounce.restart() + } + // If NetworkManager restarts, the monitor exits; come back gently + // rather than spinning against a daemon that is still down. + onExited: monitorRestart.restart() + } + + Timer { + id: refreshDebounce + interval: 400 + onTriggered: root.refresh() + } + + Timer { + id: monitorRestart + interval: 3000 + onTriggered: monitor.running = true + } + + Component.onCompleted: root.refresh() +} diff --git a/config/dot/quickshell/vpn-harness.qml b/config/dot/quickshell/vpn-harness.qml new file mode 100644 index 0000000..08242a2 --- /dev/null +++ b/config/dot/quickshell/vpn-harness.qml @@ -0,0 +1,30 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.services + +// Drives services/Vpn.qml for tests/quickshell/vpn-contract. The contract +// stands a stub nmcli on PATH, so everything the service believes comes from +// fixtures and everything it does is recorded — no real tunnel is touched. + +ShellRoot { + IpcHandler { + target: "vpn-test" + + function status(): string { + return JSON.stringify({ + scanned: Vpn.scanned, + available: Vpn.available, + anyActive: Vpn.anyActive, + activeSummary: Vpn.activeSummary, + busy: Vpn.busy, + lastError: Vpn.lastError, + connections: Vpn.connections + }); + } + function refresh(): void { Vpn.refresh(); } + function toggle(): void { Vpn.toggle(); } + function setActive(uuid: string, on: bool): void { Vpn.setActive(uuid, on); } + } +} diff --git a/tests/quickshell/declared-assets-contract b/tests/quickshell/declared-assets-contract index febbff4..642a81a 100755 --- a/tests/quickshell/declared-assets-contract +++ b/tests/quickshell/declared-assets-contract @@ -148,6 +148,7 @@ done qml_package() { case "$1" in hyprctl) printf 'hyprland' ;; + nmcli) printf 'NetworkManager' ;; wl-copy|wl-paste) printf 'wl-clipboard' ;; xdg-open) printf 'xdg-utils' ;; pw-dump|pw-play) printf 'pipewire-utils' ;; diff --git a/tests/quickshell/vpn-contract b/tests/quickshell/vpn-contract new file mode 100755 index 0000000..9bb7380 --- /dev/null +++ b/tests/quickshell/vpn-contract @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +# The VPN toggle, end to end minus NetworkManager. +# +# The property under test is the one that motivated the feature: a person +# turned on a tunnel whose server was unreachable and had no way back short of +# nmcli in a terminal. So beyond parsing and wiring, this pins the safety +# behavior — a failed activation is rolled back down and reported, never left +# half-up as a black-hole default route. +# +# nmcli is a stateful stub on PATH: fixtures decide what profiles exist, an +# `active` file tracks what is up, and every invocation is recorded. The real +# helper and the real service run against it, so the parse, the recency pick, +# and the rollback are all the shipped code paths. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/vpn-harness.qml" +service="$repo_dir/config/dot/quickshell/services/Vpn.qml" +helper="$repo_dir/config/dot/quickshell/scripts/panama-vpn" +panel="$repo_dir/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" +vpn_list="$repo_dir/config/dot/quickshell/modules/quicksettings/VpnList.qml" +cluster="$repo_dir/config/dot/quickshell/modules/bar/StatusCluster.qml" + +fail() { + printf 'vpn contract: %s\n' "$1" >&2 + exit 1 +} + +# ── Wiring that must not silently disappear ────────────────────────────────── + +rg -Fq 'visible: Vpn.available' "$panel" \ + || fail 'the VPN tile is not gated on a profile existing' +rg -Fq 'onToggled: Vpn.toggle()' "$panel" \ + || fail 'the VPN tile does not drive Vpn.toggle()' +rg -Fq 'root.expand("vpn")' "$panel" \ + || fail 'the VPN tile cannot open its detail list' +rg -Fq 'VpnList {' "$panel" \ + || fail 'the VPN detail list is not mounted in the panel' +rg -Fq 'visible: Vpn.anyActive' "$cluster" \ + || fail 'the bar glyph is not gated on an active tunnel' +rg -Fq 'onClicked: Vpn.setActive(modelData.uuid, !modelData.active)' "$vpn_list" \ + || fail 'a profile row does not flip that profile' +rg -Fq 'visible: Vpn.lastError !== ""' "$vpn_list" \ + || fail 'activation failures have nowhere to surface' +# The bound and the rollback are what make the toggle safe on a dead server. +rg -Fq 'nmcli -w 25 connection up uuid' "$helper" \ + || fail 'activation is unbounded -- a dead server hangs the toggle for 90s' +rg -q 'nmcli connection down uuid .* 2>&1\|\|nmcli connection down uuid' "$helper" \ + || rg -Fq 'nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true' "$helper" \ + || fail 'a failed activation is not rolled back down' +rg -Fq '"nmcli", "monitor"' "$service" \ + || fail 'outside changes to NetworkManager state are never noticed' + +# ── The stub NetworkManager ────────────────────────────────────────────────── + +stub_dir="$(mktemp -d)" +state_dir="$(mktemp -d)" +config_home="$(mktemp -d)" +: >"$state_dir/active" +: >"$state_dir/log" + +cat >"$stub_dir/nmcli" <>"\$state/log" +case "\$*" in + "monitor") + exec sleep 45 ;; + "-t -f NAME,UUID,TYPE,TIMESTAMP connection show") + printf 'Home:uuid-wg-home:wireguard:200\n' + printf 'Office\\\\: Berlin:uuid-vpn-office:vpn:100\n' + printf "Gib's iPhone:uuid-wifi:802-11-wireless:300\n" ;; + "-t -f UUID connection show --active") + cat "\$state/active" ;; + "-w 25 connection up uuid "*) + uuid="\${!#}" + if [[ -e "\$state/fail-up" ]]; then + echo "Error: Connection activation failed: the server did not respond." >&2 + exit 4 + fi + echo "\$uuid" >>"\$state/active" ;; + "connection down uuid "*) + uuid="\${!#}" + grep -v "^\$uuid\$" "\$state/active" >"\$state/active.next" || true + mv "\$state/active.next" "\$state/active" ;; + *) + echo "stub nmcli: unexpected: \$*" >&2 + exit 9 ;; +esac +STUB +chmod +x "$stub_dir/nmcli" + +stop_harness() { + [[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true + rm -rf "$stub_dir" "$state_dir" "$config_home" +} +trap stop_harness EXIT + +run() { XDG_CONFIG_HOME="$config_home" PATH="$stub_dir:$PATH" qs -p "$harness" "$@"; } +status() { run ipc call vpn-test status; } + +PATH="$stub_dir:$PATH" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null +for _ in $(seq 1 40); do + run ipc show 2>/dev/null | rg -q '^target vpn-test$' && break + sleep 0.1 +done +run ipc show 2>/dev/null | rg -q '^target vpn-test$' || fail 'test IPC target did not start' +harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')" + +settle() { + local want="$1" tries="${2:-50}" + for _ in $(seq 1 "$tries"); do + if jq -e "$want" <<<"$(status)" >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + return 1 +} + +# ── Only VPN kinds are listed, names unescape, recency is read ─────────────── + +settle '.scanned and (.busy | not)' || fail 'the service never finished its first scan' +listing="$(status)" +jq -e '.connections | length == 2' <<<"$listing" >/dev/null \ + || fail "the wifi profile leaked into the VPN list: $listing" +jq -e '.connections | map(.name) | sort == ["Home", "Office: Berlin"]' <<<"$listing" >/dev/null \ + || fail "names did not parse (escaped colon?): $listing" +jq -e '.available and (.anyActive | not)' <<<"$listing" >/dev/null \ + || fail "profiles exist but the tile would not show: $listing" + +# ── Toggle up picks the most recently used profile ─────────────────────────── + +run ipc call vpn-test toggle >/dev/null +settle '.anyActive and (.busy | not)' || fail 'toggling up never activated anything' +jq -e '.activeSummary == "Home"' <<<"$(status)" >/dev/null \ + || fail "toggle did not pick the most recently used profile: $(status)" +rg -Fq -- '-w 25 connection up uuid uuid-wg-home' "$state_dir/log" \ + || fail 'the most recently used uuid was not the one activated' + +# ── Toggle down takes the active tunnel down ───────────────────────────────── + +run ipc call vpn-test toggle >/dev/null +settle '(.anyActive | not) and (.busy | not)' || fail 'toggling down left the tunnel up' +rg -Fq 'connection down uuid uuid-wg-home' "$state_dir/log" \ + || fail 'deactivation never reached nmcli' + +# ── A dead server: bounded, rolled back, and reported ──────────────────────── + +touch "$state_dir/fail-up" +run ipc call vpn-test setActive uuid-vpn-office true >/dev/null +settle '(.busy | not) and .lastError != ""' || fail 'a failed activation reported nothing' +settle '.anyActive | not' 5 || fail 'a failed activation stayed half-up' +rg -Fq 'connection down uuid uuid-vpn-office' "$state_dir/log" \ + || fail 'a failed activation was not rolled back down' + +exit 0