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:
@@ -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
|
||||||
|
|||||||
Executable
+83
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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' ;;
|
||||||
|
|||||||
Executable
+159
@@ -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
|
||||||
Reference in New Issue
Block a user