Files
Panama/config/dot/quickshell/services/Vpn.qml
T
Gabriel Brown 36fdd4e076 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
2026-08-23 10:32:17 -04:00

135 lines
4.6 KiB
QML

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