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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user