pragma Singleton pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Io // Minimal VPN controller backed by NetworkManager (nmcli + D-Bus monitor) Singleton { id: root property int refCount: 0 onRefCountChanged: { console.log("VpnService: refCount changed to", refCount) if (refCount > 0 && !nmMonitor.running) { console.log("VpnService: Starting nmMonitor") nmMonitor.running = true refreshAll() } else if (refCount === 0 && nmMonitor.running) { console.log("VpnService: Stopping nmMonitor") nmMonitor.running = false } } // State property bool available: true property bool isBusy: false property string errorMessage: "" // Profiles discovered on the system // [{ name, uuid, type }] property var profiles: [] // Allow multiple active VPNs (set true to allow concurrent connections) // Default: allow multiple, to align with NetworkManager capability property bool singleActive: false // Active VPN connections (may be multiple) // Full list and convenience projections property var activeConnections: [] // [{ name, uuid, device, state }] property var activeUuids: [] property var activeNames: [] // Back-compat single values (first active if present) property string activeUuid: activeUuids.length > 0 ? activeUuids[0] : "" property string activeName: activeNames.length > 0 ? activeNames[0] : "" property string activeDevice: activeConnections.length > 0 ? (activeConnections[0].device || "") : "" property string activeState: activeConnections.length > 0 ? (activeConnections[0].state || "") : "" property bool connected: activeUuids.length > 0 // Use implicit property notify signals (profilesChanged, activeUuidChanged, etc.) function refreshAll() { listProfiles() refreshActive() } // Monitor NetworkManager changes and refresh on activity Process { id: nmMonitor command: ["gdbus", "monitor", "--system", "--dest", "org.freedesktop.NetworkManager"] running: false stdout: SplitParser { splitMarker: "\n" onRead: line => { if (line.includes("ActiveConnection") || line.includes("PropertiesChanged") || line.includes("StateChanged")) { refreshAll() } } } } // Query all VPN profiles function listProfiles() { getProfiles.running = true } Process { id: getProfiles command: ["bash", "-lc", "nmcli -t -f NAME,UUID,TYPE connection show | while IFS=: read -r name uuid type; do case \"$type\" in vpn) svc=$(nmcli -g vpn.service-type connection show uuid \"$uuid\" 2>/dev/null); echo \"$name:$uuid:$type:$svc\" ;; wireguard) echo \"$name:$uuid:$type:\" ;; *) : ;; esac; done"] running: false stdout: StdioCollector { onStreamFinished: { const lines = text.trim().length ? text.trim().split('\n') : [] const out = [] for (const line of lines) { const parts = line.split(':') if (parts.length >= 3 && (parts[2] === "vpn" || parts[2] === "wireguard")) { const svc = parts.length >= 4 ? parts[3] : "" out.push({ name: parts[0], uuid: parts[1], type: parts[2], serviceType: svc }) } } root.profiles = out } } } // Query active VPN connection function refreshActive() { getActive.running = true } Process { id: getActive command: ["nmcli", "-t", "-f", "NAME,UUID,TYPE,DEVICE,STATE", "connection", "show", "--active"] running: false stdout: StdioCollector { onStreamFinished: { const lines = text.trim().length ? text.trim().split('\n') : [] let act = [] for (const line of lines) { const parts = line.split(':') if (parts.length >= 5 && (parts[2] === "vpn" || parts[2] === "wireguard")) { act.push({ name: parts[0], uuid: parts[1], device: parts[3], state: parts[4] }) } } root.activeConnections = act root.activeUuids = act.map(a => a.uuid).filter(u => !!u) root.activeNames = act.map(a => a.name).filter(n => !!n) } } } function isActiveUuid(uuid) { return root.activeUuids && root.activeUuids.indexOf(uuid) !== -1 } function _looksLikeUuid(s) { // Very loose check for UUID pattern return s && s.indexOf('-') !== -1 && s.length >= 8 } function connect(uuidOrName) { if (root.isBusy) return root.isBusy = true root.errorMessage = "" if (root.singleActive) { // Bring down all active VPNs, then bring up the requested one const isUuid = _looksLikeUuid(uuidOrName) const escaped = ('' + uuidOrName).replace(/'/g, "'\\''") const upCmd = isUuid ? `nmcli connection up uuid '${escaped}'` : `nmcli connection up id '${escaped}'` const script = `set -e\n` + `nmcli -t -f UUID,TYPE connection show --active | awk -F: '$2 ~ /^(vpn|wireguard)$/ {print $1}' | while read u; do [ -n \"$u\" ] && nmcli connection down uuid \"$u\" || true; done\n` + upCmd + `\n` vpnSwitch.command = ["bash", "-lc", script] vpnSwitch.running = true } else { if (_looksLikeUuid(uuidOrName)) { vpnUp.command = ["nmcli", "connection", "up", "uuid", uuidOrName] } else { vpnUp.command = ["nmcli", "connection", "up", "id", uuidOrName] } vpnUp.running = true } } function disconnect(uuidOrName) { if (root.isBusy) return root.isBusy = true root.errorMessage = "" if (_looksLikeUuid(uuidOrName)) { vpnDown.command = ["nmcli", "connection", "down", "uuid", uuidOrName] } else { vpnDown.command = ["nmcli", "connection", "down", "id", uuidOrName] } vpnDown.running = true } function toggle(uuid) { if (uuid) { if (isActiveUuid(uuid)) disconnect(uuid) else connect(uuid) return } if (root.profiles.length > 0) { connect(root.profiles[0].uuid) } } Process { id: vpnUp running: false stdout: StdioCollector { onStreamFinished: { root.isBusy = false if (!text.toLowerCase().includes("successfully")) { root.errorMessage = text.trim() } refreshAll() } } onExited: exitCode => { root.isBusy = false if (exitCode !== 0 && root.errorMessage === "") { root.errorMessage = "Failed to connect VPN" } } } Process { id: vpnDown running: false stdout: StdioCollector { onStreamFinished: { root.isBusy = false if (!text.toLowerCase().includes("deactivated") && !text.toLowerCase().includes("successfully")) { root.errorMessage = text.trim() } refreshAll() } } onExited: exitCode => { root.isBusy = false if (exitCode !== 0 && root.errorMessage === "") { root.errorMessage = "Failed to disconnect VPN" } } } function disconnectAllActive() { if (root.isBusy) return root.isBusy = true const script = `nmcli -t -f UUID,TYPE connection show --active | awk -F: '$2 ~ /^(vpn|wireguard)$/ {print $1}' | while read u; do [ -n \"$u\" ] && nmcli connection down uuid \"$u\" || true; done` vpnSwitch.command = ["bash", "-lc", script] vpnSwitch.running = true } // Sequenced down/up using a single shell for exclusive switch Process { id: vpnSwitch running: false stdout: StdioCollector { onStreamFinished: { root.isBusy = false refreshAll() } } onExited: exitCode => { root.isBusy = false if (exitCode !== 0 && root.errorMessage === "") { root.errorMessage = "Failed to switch VPN" } } } }