diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index 7a0231e..1d5106d 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -19,9 +19,10 @@ import "../../config" // text status only — parsed defensively since its exact wording isn't // guaranteed across versions. // - Home (UniFi): the UniFi gateway's built-in WireGuard server, driven -// through a NetworkManager profile via `nmcli` (no sudo needed). Import -// the client .conf UniFi generates with scripts/ImportUnifiWireguard.sh, -// which names the profile to match homeWgConnection below. +// through a NetworkManager profile via `nmcli` (no sudo needed). The +// row's "Add config" button imports the client .conf UniFi generates +// (via scripts/ImportUnifiWireguard.sh --pick), which names the profile +// to match homeWgConnection below. Item { id: root implicitWidth: label.implicitWidth + 4 @@ -64,6 +65,7 @@ Item { property string mullvadActionStatus: "" property string protonActionStatus: "" property string homeWgActionStatus: "" + property bool homeWgImporting: homeWgImportProc.running // --- Mullvad advanced options (auto-connect, relay location, multihop) -- // ProtonVPN's CLI is plain-text-only and far less predictable across @@ -335,6 +337,16 @@ Item { else root.connectProton() } + // Opens a file chooser for the UniFi client .conf and imports it as + // homeWgConnection, replacing any existing profile. The chooser takes + // focus, which clears the focus grab and closes the panel — so the + // import reopens it when done to show the result. + function importHomeWgConfig() { + if (homeWgImportProc.running || root.homeWgState !== "disconnected") return + root.homeWgActionStatus = "" + homeWgImportProc.running = true + } + function toggleHomeWg() { if (homeWgConnectProc.running || homeWgDisconnectProc.running) return if (root.homeWgState === "connected") root.disconnectHomeWg() @@ -606,6 +618,23 @@ Item { } } + Process { + id: homeWgImportProc + command: ["bash", Quickshell.shellPath("scripts/ImportUnifiWireguard.sh"), + "--pick", root.homeWgConnection, "--replace"] + stdout: StdioCollector {} + stderr: StdioCollector { id: homeWgImportErr } + onExited: (exitCode, exitStatus) => { + // 3 = chooser cancelled — nothing to report. + if (exitCode === 0) root.homeWgActionStatus = "Config imported" + else if (exitCode !== 3) root.homeWgActionStatus = root.shortLine(homeWgImportErr.text || "Import failed") + if (root.homeWgActionStatus !== "") homeWgActionStatusTimer.restart() + root.refreshHomeWg() + panel.visible = true + grab.active = true + } + } + // --- Shared timers ----------------------------------------------------- Timer { @@ -1188,11 +1217,57 @@ Item { state: root.homeWgState detailText: root.homeWgDetail installed: root.homeWgInstalled - missingText: "Not set up — run ImportUnifiWireguard.sh" + missingText: "Not set up" onToggle: root.toggleHomeWg() } StatusLine { message: root.homeWgActionStatus } + + // Imports (or replaces) the UniFi client .conf through a + // file chooser. Hidden while connected — replacing + // deletes the profile, which would drop the tunnel. + Item { + width: parent.width + implicitHeight: importRow.implicitHeight + 8 + visible: root.homeWgState === "disconnected" + + Row { + id: importRow + anchors.left: parent.left + anchors.leftMargin: 34 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + opacity: root.homeWgImporting ? 0.5 : 1 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.homeWgInstalled ? "sync" : "upload_file" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 13 + color: importMouse.containsMouse ? Colors.foreground : Colors.primary + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.homeWgImporting ? "Choosing file…" + : root.homeWgInstalled ? "Replace config" : "Add config" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + font.underline: importMouse.containsMouse + color: importMouse.containsMouse ? Colors.foreground : Colors.primary + } + } + + MouseArea { + id: importMouse + anchors.fill: importRow + anchors.margins: -4 + enabled: !root.homeWgImporting + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.importHomeWgConfig() + } + } } Text { diff --git a/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh b/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh index 90a22a0..9032fdc 100755 --- a/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh +++ b/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh @@ -163,7 +163,15 @@ fi if nmcli -g connection.type connection show unifi-home >/dev/null 2>&1; then pass "NetworkManager profile 'unifi-home' exists — used by VpnIndicator.qml (Home/UniFi row)" else - warn "no NetworkManager profile 'unifi-home' — import one with scripts/ImportUnifiWireguard.sh (optional)" + warn "no NetworkManager profile 'unifi-home' — import one from the VPN panel's 'Add config' button (optional)" +fi + +# The 'Add config' button's file chooser (scripts/PickFile.py) talks to +# xdg-desktop-portal over D-Bus via PyGObject. +if python3 -c "from gi.repository import Gio" >/dev/null 2>&1; then + pass "PyGObject available — used by PickFile.py (VPN 'Add config' file chooser)" +else + warn "PyGObject (python-gobject) missing — the VPN panel's 'Add config' button can't open a file chooser" fi # --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) ------- diff --git a/dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh b/dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh index bc39205..97bfc60 100755 --- a/dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh +++ b/dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh @@ -7,6 +7,10 @@ # WireGuard server → Add Client → Download. # # Usage: ./ImportUnifiWireguard.sh [profile-name] [--replace] +# ./ImportUnifiWireguard.sh --pick [profile-name] [--replace] +# +# --pick opens a file chooser (PickFile.py) instead of taking a path — this +# is what the VPN panel's "Add config" button runs. Exits 3 if cancelled. # # NetworkManager names both the profile and the interface after the file's # basename, so the config is copied to .conf first (interface @@ -17,17 +21,28 @@ set -euo pipefail replace=0 +pick=0 args=() for a in "$@"; do case "$a" in --replace) replace=1 ;; - -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --pick) pick=1 ;; + -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) args+=("$a") ;; esac done -conf="${args[0]:-}" -name="${args[1]:-unifi-home}" +if [ $pick -eq 1 ]; then + conf="" + name="${args[0]:-unifi-home}" + rc=0 + conf=$("$(dirname "$0")/PickFile.py" "Select UniFi WireGuard config" "*.conf") || rc=$? + if [ $rc -eq 1 ]; then echo "Cancelled" >&2; exit 3; fi + if [ $rc -ne 0 ]; then echo "Couldn't open a file chooser" >&2; exit 1; fi +else + conf="${args[0]:-}" + name="${args[1]:-unifi-home}" +fi if [ -z "$conf" ] || [ ! -f "$conf" ]; then echo "Usage: $0 [profile-name] [--replace]" >&2 @@ -51,6 +66,11 @@ tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT install -m 600 "$conf" "$tmpdir/$name.conf" +if ! grep -qi '^\[Interface\]' "$tmpdir/$name.conf" || ! grep -qi '^\[Peer\]' "$tmpdir/$name.conf"; then + echo "$(basename "$conf") doesn't look like a WireGuard config" >&2 + exit 1 +fi + nmcli connection import type wireguard file "$tmpdir/$name.conf" # Only ever brought up deliberately from the bar — never on boot or on # joining a network, where it'd silently fight Tailscale/Mullvad/ProtonVPN. diff --git a/dotfiles/.config/quickshell/scripts/PickFile.py b/dotfiles/.config/quickshell/scripts/PickFile.py new file mode 100755 index 0000000..2170dd2 --- /dev/null +++ b/dotfiles/.config/quickshell/scripts/PickFile.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# Opens the xdg-desktop-portal file chooser and prints the picked file's +# path. Quickshell has no usable file dialog of its own (QtQuick.Dialogs' +# fallback renders inside the calling window, i.e. the tiny bar popup), and +# no zenity/kdialog is installed — the portal goes through +# xdg-desktop-portal-gtk, which is. +# +# Usage: PickFile.py [title] [glob ...] +# Exits 0 and prints the path on a pick, 1 on cancel, 2 on error. + +import os +import sys +from urllib.parse import unquote, urlparse + +from gi.repository import Gio, GLib + +title = sys.argv[1] if len(sys.argv) > 1 else "Open File" +globs = sys.argv[2:] + +try: + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) +except GLib.Error as e: + print(f"No session bus: {e.message}", file=sys.stderr) + sys.exit(2) + +# The portal answers on a Request object whose path is derived from our +# unique bus name and handle_token, so subscribe to it before calling — +# otherwise a fast response could arrive before we're listening. +token = f"quickshell_{os.getpid()}" +sender = bus.get_unique_name().lstrip(":").replace(".", "_") +handle = f"/org/freedesktop/portal/desktop/request/{sender}/{token}" + +loop = GLib.MainLoop() +result = {"code": 2, "uris": []} + + +def on_response(_conn, _sender, _path, _iface, _signal, params): + code, results = params.unpack() + result["code"] = code + result["uris"] = results.get("uris", []) + loop.quit() + + +bus.signal_subscribe("org.freedesktop.portal.Desktop", "org.freedesktop.portal.Request", + "Response", handle, None, Gio.DBusSignalFlags.NONE, on_response) + +options = { + "handle_token": GLib.Variant("s", token), + "modal": GLib.Variant("b", True), +} +filters = [] +if globs: + filters.append((", ".join(globs), [(0, g) for g in globs])) +filters.append(("All files", [(0, "*")])) +options["filters"] = GLib.Variant("a(sa(us))", filters) +downloads = os.path.expanduser("~/Downloads") +if os.path.isdir(downloads): + options["current_folder"] = GLib.Variant("ay", downloads.encode() + b"\0") + +try: + bus.call_sync("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.FileChooser", "OpenFile", + GLib.Variant("(ssa{sv})", ("", title, options)), + GLib.VariantType("(o)"), Gio.DBusCallFlags.NONE, -1, None) +except GLib.Error as e: + print(f"File chooser portal unavailable: {e.message}", file=sys.stderr) + sys.exit(2) + +loop.run() + +# Response codes: 0 = picked, 1 = cancelled, 2 = other failure. +if result["code"] != 0 or not result["uris"]: + sys.exit(1 if result["code"] == 1 else 2) +print(unquote(urlparse(result["uris"][0]).path))