3 Commits
Author SHA1 Message Date
Gabriel Brown 12f6b2a310 Let the contracts assert properties, not the machine they were written on
The first run of the suite on a laptop found five contracts asserting the
desktop instead of the code. settings-system pinned DP-2 at 4500x3000 in
XRGB2101010; it now asks Hyprland what is actually primary. ssh-keys hardcoded
id_ed25519; it now uses whichever key exists. switcher's live half stepped a
session with one window, which step() deliberately refuses. displays raced the
service's revert readback -- the compositor looks restored while verification
still holds busy, so an immediate apply was refused with its error already
cleared; the harness now exposes settled and the contract waits for it.

declared-dependencies gets an OPTIONAL list for docker: the aliases serve
machines that run Docker deliberately, Panama's runtime is rootless podman,
and a missing docker fails by naming the command, which is loud enough.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
Gabriel Brown 6510fdda0f Reach DDC on GPUs that are not "VGA", and stop warning about an empty dock
ddcutil's udev rule grants the seated user the GPU's i2c buses only when the
PCI class is 0x030000. An AMD iGPU that is not the primary boot display says
0x038000, so on the Framework every DDC bus stayed root-only. Ship the same
grant for the class the hardware actually reports; change-settings installs it.

And two conflations in the probe: an undocked laptop reported its normal state
as an error, and doctor collapsed every error into "No accessible DDC/CI bus".
Nothing external connected is now a clean empty -- doctor's unconfigured path
-- and a real failure surfaces the probe's own words, because an unreadable
bus and a monitor with DDC/CI off in its menu have different fixes.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
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
18 changed files with 615 additions and 12 deletions
@@ -0,0 +1,9 @@
# External monitor brightness (DDC/CI) on GPUs that are not "VGA".
#
# ddcutil ships 60-ddcutil-i2c.rules, which grants the seated user access to
# the GPU's i2c buses -- but only when the GPU's PCI class is 0x030000 (VGA
# compatible controller). An AMD iGPU that is not the primary boot display
# enumerates as 0x038000 (Display controller) instead, so every DDC bus it
# exposes stays root-only and the Brightness service reads EACCES. Same grant,
# broadened to the class that hardware actually reports.
SUBSYSTEM=="i2c-dev", KERNEL=="i2c-[0-9]*", ATTRS{class}=="0x038000", TAG+="uaccess"
+8 -1
View File
@@ -32,7 +32,14 @@ ShellRoot {
canConfirm: Displays.canConfirm,
secondsLeft: Displays.secondsLeft,
lastError: Displays.lastError,
overridden: monitor ? Displays.isOverridden(monitor.name) : false
overridden: monitor ? Displays.isOverridden(monitor.name) : false,
// The compositor being visually restored is not the service
// being done: revert verification keeps its own readback
// running for a few ticks, and busy blocks a new apply until
// it settles. A caller that only watched `awaiting` raced
// this and got a refusal with no error text.
settled: !Displays.busy && !Displays.revertVerificationActive
&& !Displays.awaitingConfirmation
});
}
@@ -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
@@ -93,7 +93,7 @@ bus_for_connector() {
cmd_list() {
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
local rows=() connector bus value path
local rows=() externals=0 connector bus value path
for path in "$DRM_ROOT"/card*-*; do
[[ -e "$path/ddc" ]] || continue
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
@@ -102,6 +102,14 @@ cmd_list() {
connector="$(basename "$path")"
connector="${connector#card*-}"
# Internal panels (eDP, LVDS, DSI) use the backlight, never DDC/CI.
# Counting only external connectors lets the empty result below say
# whether anything is even plugged in.
case "$connector" in
eDP-*|LVDS-*|DSI-*) ;;
*) externals=$((externals + 1)) ;;
esac
bus="$(bus_for_connector "$path")"
[[ "$bus" =~ ^[0-9]+$ ]] || continue
@@ -119,6 +127,13 @@ cmd_list() {
done
if [[ ${#rows[@]} -eq 0 ]]; then
# Nothing external is connected at all: the normal state of an
# undocked laptop, not a problem to warn about. The error path is
# reserved for a monitor that is present but will not talk.
if (( externals == 0 )); then
printf '{"displays":[],"error":""}\n'
return 0
fi
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
fi
+4 -1
View File
@@ -533,7 +533,10 @@ def check_brightness(config: DoctorConfig) -> Check:
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "DDC/CI probe returned an invalid result.", instructions)
if error:
return Check("input.brightness", "input-media", "External monitor brightness", "warning", "No accessible DDC/CI bus.", instructions)
# The probe says what is actually wrong -- an unreadable bus and a
# monitor with DDC/CI switched off in its menu are different problems
# with different fixes, and one hardcoded string here hid that.
return Check("input.brightness", "input-media", "External monitor brightness", "warning", error.rstrip(".") + ".", instructions)
if not displays:
return Check("input.brightness", "input-media", "External monitor brightness", "unconfigured", "No DDC/CI display is configured.")
return Check("input.brightness", "input-media", "External monitor brightness", "ok", f"{len(displays)} DDC/CI display{'s' if len(displays) != 1 else ''} available.")
+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); }
}
}
@@ -148,6 +148,7 @@ done
qml_package() {
case "$1" in
hyprctl) printf 'hyprland' ;;
nmcli) printf 'NetworkManager' ;;
wl-copy|wl-paste) printf 'wl-clipboard' ;;
xdg-open) printf 'xdg-utils' ;;
pw-dump|pw-play) printf 'pipewire-utils' ;;
@@ -47,6 +47,15 @@ SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-in
# Anything added here needs a matching install block and a stated reason.
SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$'
# Tools an alias may lean on without Panama installing them anywhere. The
# docker aliases serve the machines that run Docker by deliberate choice;
# Panama's container runtime is rootless podman (development-packages), and
# declaring docker in a list would put a second container daemon on every
# fresh machine to keep five aliases company. Where docker is absent the
# aliases fail by naming the missing command, which is the honest outcome.
# Anything added here needs that same property: absence must be loud.
OPTIONAL='^(docker)$'
# jq programs are quoted arguments, but the scanner is line-based and cannot
# tell a filter from a command. `not` is a jq builtin appearing inside one.
JQ_BUILTINS='^(not|empty|error|env|input|inputs)$'
@@ -105,6 +114,7 @@ while read -r script; do
[[ "$cmd" =~ $BASELINE ]] && continue
[[ "$cmd" =~ $SESSION ]] && continue
[[ "$cmd" =~ $SELF_INSTALLED ]] && continue
[[ "$cmd" =~ $OPTIONAL ]] && continue
[[ "$cmd" =~ $JQ_BUILTINS ]] && continue
pkg="$(package_for "$cmd")"
+9 -1
View File
@@ -309,7 +309,10 @@ target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.2
run ipc call displays-test revertChange >/dev/null
immediate_reverted=false
for _ in $(seq 1 60); do
if display_is_restored && [[ "$(status | jq -r .awaiting)" == "false" ]]; then
# settled, not awaiting: the compositor can look restored while the
# service's revert verification is still reading back, and an apply in
# that window is refused as busy. See the settled field in the harness.
if display_is_restored && [[ "$(status | jq -r .settled)" == "true" ]]; then
immediate_reverted=true
break
fi
@@ -345,6 +348,11 @@ done
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
# ── A confirmed change is what writes ────────────────────────────────────────
# Wait out the revert readback before applying again -- same race as above.
for _ in $(seq 1 40); do
[[ "$(status | jq -r .settled)" == "true" ]] && break
sleep 0.2
done
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|| fail 'the confirmed-change fixture could not apply'
+13 -2
View File
@@ -41,8 +41,19 @@ for _ in $(seq 1 40); do
fi
sleep 0.1
done
jq -e '.monitorName == "DP-2" and .width == 4500 and .height == 3000 and .scale == 1.5 and .format == "XRGB2101010"' <<<"$status" >/dev/null \
|| fail "live monitor data was not normalized: $status"
# Against the live compositor, not a named machine: this once asserted DP-2 at
# 4500x3000 in XRGB2101010, which pinned the desktop it was written on and
# could never pass on a laptop's eDP-1. The property is that SystemSettings
# mirrors whatever monitor is actually primary, normalized -- so ask Hyprland
# what that is.
live_monitor="$(hyprctl -j monitors | jq -c '.[0]')"
jq -e --argjson live "$live_monitor" '
.monitorName == $live.name
and .width == $live.width
and .height == $live.height
and ((.scale - $live.scale) | fabs) < 0.001
and (.format | length) > 0' <<<"$status" >/dev/null \
|| fail "live monitor data was not normalized: $status (compositor: $live_monitor)"
[[ "$(qs_for_harness ipc call settings-system-test panelAllowed '__definitely_not_a_panel__' | jq -r .)" == "false" ]] \
|| fail 'unsupported GNOME panel was accepted'
+10 -1
View File
@@ -96,9 +96,18 @@ grep -q 'durableRemoval' "$helper" \
kind="$(printf '%s' "$state" | field "['agent'].get('kind','')")"
if [[ "$kind" == "gnome-keyring" ]]; then
reason="$(printf '%s' "$("$helper" agent-remove "$HOME/.ssh/id_ed25519")" | field "['error']")"
# Whichever key this machine actually has. This used to hardcode
# id_ed25519, which asserted the author's machine: any other key name
# earned "That key no longer exists" instead of the refusal under test.
# No key at all means the property cannot be exercised here, not that it
# failed.
real_key="$(compgen -G "$HOME/.ssh/id_*.pub" | head -1)"
real_key="${real_key%.pub}"
if [[ -n "$real_key" ]]; then
reason="$(printf '%s' "$("$helper" agent-remove "$real_key")" | field "['error']")"
[[ "$reason" == *"does not stick"* ]] \
|| fail "removal against a keyring agent was not refused with its reason (got: $reason)"
fi
grep -q 'does not stick' "$page" \
|| fail 'the page does not say that removing a key from this agent has no effect'
fi
+6 -1
View File
@@ -73,7 +73,12 @@ grep -q 'WlrKeyboardFocus.None' "$window" \
# ── Live ────────────────────────────────────────────────────────────────────
if command -v qs >/dev/null 2>&1 && qs ipc call switcher cancel >/dev/null 2>&1; then
# Stepping refuses to open with fewer than two windows (see step() in the
# state service) -- that is designed behavior, not a failure, so a session
# with one window skips the live half rather than failing against it.
open_windows="$(hyprctl -j clients 2>/dev/null | jq length 2>/dev/null || echo 0)"
if command -v qs >/dev/null 2>&1 && (( open_windows >= 2 )) \
&& qs ipc call switcher cancel >/dev/null 2>&1; then
qs ipc call switcher next >/dev/null 2>&1
sleep 0.6
mapped="$(hyprctl layers -j 2>/dev/null | grep -c 'qs-switcher' || true)"
+159
View File
@@ -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