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
135 lines
4.6 KiB
QML
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()
|
|
}
|