Feature: Add config import button for home UniFi VPN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) -------
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
# WireGuard server → Add Client → Download.
|
||||
#
|
||||
# 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
|
||||
# basename, so the config is copied to <profile-name>.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
|
||||
|
||||
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 <client.conf> [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.
|
||||
|
||||
74
dotfiles/.config/quickshell/scripts/PickFile.py
Executable file
74
dotfiles/.config/quickshell/scripts/PickFile.py
Executable 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))
|
||||
Reference in New Issue
Block a user