Feature: Add config import button for home UniFi VPN

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 20:45:12 +01:00
parent bf86ffe258
commit 19e7d3f673
4 changed files with 185 additions and 8 deletions

View File

@@ -19,9 +19,10 @@ import "../../config"
// text status only — parsed defensively since its exact wording isn't // text status only — parsed defensively since its exact wording isn't
// guaranteed across versions. // guaranteed across versions.
// - Home (UniFi): the UniFi gateway's built-in WireGuard server, driven // - Home (UniFi): the UniFi gateway's built-in WireGuard server, driven
// through a NetworkManager profile via `nmcli` (no sudo needed). Import // through a NetworkManager profile via `nmcli` (no sudo needed). The
// the client .conf UniFi generates with scripts/ImportUnifiWireguard.sh, // row's "Add config" button imports the client .conf UniFi generates
// which names the profile to match homeWgConnection below. // (via scripts/ImportUnifiWireguard.sh --pick), which names the profile
// to match homeWgConnection below.
Item { Item {
id: root id: root
implicitWidth: label.implicitWidth + 4 implicitWidth: label.implicitWidth + 4
@@ -64,6 +65,7 @@ Item {
property string mullvadActionStatus: "" property string mullvadActionStatus: ""
property string protonActionStatus: "" property string protonActionStatus: ""
property string homeWgActionStatus: "" property string homeWgActionStatus: ""
property bool homeWgImporting: homeWgImportProc.running
// --- Mullvad advanced options (auto-connect, relay location, multihop) -- // --- Mullvad advanced options (auto-connect, relay location, multihop) --
// ProtonVPN's CLI is plain-text-only and far less predictable across // ProtonVPN's CLI is plain-text-only and far less predictable across
@@ -335,6 +337,16 @@ Item {
else root.connectProton() 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() { function toggleHomeWg() {
if (homeWgConnectProc.running || homeWgDisconnectProc.running) return if (homeWgConnectProc.running || homeWgDisconnectProc.running) return
if (root.homeWgState === "connected") root.disconnectHomeWg() 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 ----------------------------------------------------- // --- Shared timers -----------------------------------------------------
Timer { Timer {
@@ -1188,11 +1217,57 @@ Item {
state: root.homeWgState state: root.homeWgState
detailText: root.homeWgDetail detailText: root.homeWgDetail
installed: root.homeWgInstalled installed: root.homeWgInstalled
missingText: "Not set up — run ImportUnifiWireguard.sh" missingText: "Not set up"
onToggle: root.toggleHomeWg() onToggle: root.toggleHomeWg()
} }
StatusLine { message: root.homeWgActionStatus } 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 { Text {

View File

@@ -163,7 +163,15 @@ fi
if nmcli -g connection.type connection show unifi-home >/dev/null 2>&1; then 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)" pass "NetworkManager profile 'unifi-home' exists — used by VpnIndicator.qml (Home/UniFi row)"
else 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 fi
# --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) ------- # --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) -------

View File

@@ -7,6 +7,10 @@
# WireGuard server → Add Client → Download. # WireGuard server → Add Client → Download.
# #
# Usage: ./ImportUnifiWireguard.sh <client.conf> [profile-name] [--replace] # Usage: ./ImportUnifiWireguard.sh <client.conf> [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 # NetworkManager names both the profile and the interface after the file's
# basename, so the config is copied to <profile-name>.conf first (interface # basename, so the config is copied to <profile-name>.conf first (interface
@@ -17,17 +21,28 @@
set -euo pipefail set -euo pipefail
replace=0 replace=0
pick=0
args=() args=()
for a in "$@"; do for a in "$@"; do
case "$a" in case "$a" in
--replace) replace=1 ;; --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") ;; *) args+=("$a") ;;
esac esac
done done
conf="${args[0]:-}" if [ $pick -eq 1 ]; then
name="${args[1]:-unifi-home}" 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 if [ -z "$conf" ] || [ ! -f "$conf" ]; then
echo "Usage: $0 <client.conf> [profile-name] [--replace]" >&2 echo "Usage: $0 <client.conf> [profile-name] [--replace]" >&2
@@ -51,6 +66,11 @@ tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT trap 'rm -rf "$tmpdir"' EXIT
install -m 600 "$conf" "$tmpdir/$name.conf" 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" nmcli connection import type wireguard file "$tmpdir/$name.conf"
# Only ever brought up deliberately from the bar — never on boot or on # Only ever brought up deliberately from the bar — never on boot or on
# joining a network, where it'd silently fight Tailscale/Mullvad/ProtonVPN. # joining a network, where it'd silently fight Tailscale/Mullvad/ProtonVPN.

View File

@@ -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))