Give the VPN a toggle, an indicator, and a way back

Turning on a WireGuard profile whose server was unreachable used to cost the
whole network stack, and the only way out was nmcli typed into a terminal.
Quickshell's Networking module has no VPN surface, so this arrives as the one
sanctioned nmcli exception: a helper that lists, raises and lowers profiles,
a service that watches NetworkManager for changes made anywhere, a quick
settings tile (left-click toggles the most recently used profile, right-click
picks among them), and a bar glyph while a tunnel is up.

The safety property is in the helper, where it cannot be skipped: activation
waits a bounded 25 seconds, and a failure is rolled back down and reported
instead of leaving a black-hole default route. The contract pins exactly that,
against a stateful stub NetworkManager.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
This commit is contained in:
Gabriel Brown
2026-08-23 10:32:17 -04:00
parent d865cb74a1
commit 36fdd4e076
9 changed files with 528 additions and 2 deletions
@@ -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 { StatusGlyph {
glyph: { glyph: {
if (root.muted || root.volume <= 0) if (root.muted || root.volume <= 0)
@@ -21,8 +21,8 @@ Item {
implicitWidth: Theme.controlCenterWidth implicitWidth: Theme.controlCenterWidth
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is // "" | "wifi" | "vpn" | "bluetooth" | "sink" | "source". Only one detail
// open at a time, so the panel never grows past the screen. // list is open at a time, so the panel never grows past the screen.
property string expandedSection: "" property string expandedSection: ""
function expand(name: string): void { function expand(name: string): void {
@@ -128,6 +128,25 @@ Item {
onToggled: Connectivity.setWired(!Connectivity.wiredOn) 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 { Toggle {
width: root.cellWidth width: root.cellWidth
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic" 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 { Section {
width: content.width width: content.width
expanded: root.expandedSection === "bluetooth" expanded: root.expandedSection === "bluetooth"
@@ -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
}
}
}
@@ -16,5 +16,6 @@ RecentExchange 1.0 RecentExchange.qml
RowButton 1.0 RowButton.qml RowButton 1.0 RowButton.qml
ScrollColumn 1.0 ScrollColumn.qml ScrollColumn 1.0 ScrollColumn.qml
Section 1.0 Section.qml Section 1.0 Section.qml
VpnList 1.0 VpnList.qml
WifiList 1.0 WifiList.qml WifiList 1.0 WifiList.qml
PowerProfileList 1.0 PowerProfileList.qml PowerProfileList 1.0 PowerProfileList.qml
+83
View File
@@ -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 <uuid>
# panama-vpn down <uuid>
#
# `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 <uuid> | down <uuid>' >&2; exit 1 ;;
esac
+134
View File
@@ -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()
}
+30
View File
@@ -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); }
}
}
@@ -148,6 +148,7 @@ done
qml_package() { qml_package() {
case "$1" in case "$1" in
hyprctl) printf 'hyprland' ;; hyprctl) printf 'hyprland' ;;
nmcli) printf 'NetworkManager' ;;
wl-copy|wl-paste) printf 'wl-clipboard' ;; wl-copy|wl-paste) printf 'wl-clipboard' ;;
xdg-open) printf 'xdg-utils' ;; xdg-open) printf 'xdg-utils' ;;
pw-dump|pw-play) printf 'pipewire-utils' ;; pw-dump|pw-play) printf 'pipewire-utils' ;;
+159
View File
@@ -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" <<STUB
#!/usr/bin/env bash
state="$state_dir"
echo "\$*" >>"\$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