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 {
glyph: {
if (root.muted || root.volume <= 0)
@@ -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"
@@ -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
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
+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); }
}
}