feat: VPN toggle (#689)

* feat: configurable VPN toggle for Wireguard

- Added VPN service for wg-quick
- Added VPN toggle to utilities quick toggles
- Configuration in UtilitiesConfig (enabled, connectionName)

* fix:  monitoring and toasts

- Using nmcli monitor for state detection instead of polling
- Added VPN toast notifications

* fix: use polkit

* feat: multi-provider VPN support

- Added support for netbird and tailscale providers
- Universal interface detection using ip link show
- Provider-specific privilege handling (pkexec only for wireguard)
- Updated README with VPN configuration examples

* feat: less hardcoded, configurable providers

* removed comments

* code style changes

* reorganize signal handler
This commit is contained in:
Robin Seger 2025-10-14 07:05:15 +02:00 committed by GitHub
parent f78bc5adcb
commit fe4ebb79b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 203 additions and 1 deletions

View file

@ -589,7 +589,18 @@ default, you must create it manually.
"dndChanged": true,
"gameModeChanged": true,
"kbLayoutChanged": true,
"numLockChanged": true
"numLockChanged": true,
"vpnChanged": true,
},
"vpn": {
"enabled": false,
"provider": [
{
"name": "wireguard",
"interface": "your-connection-name",
"displayName": "Wireguard (Your VPN)"
}
]
}
}
}

View file

@ -6,6 +6,7 @@ JsonObject {
property Sizes sizes: Sizes {}
property Toasts toasts: Toasts {}
property Vpn vpn: Vpn {}
component Sizes: JsonObject {
property int width: 430
@ -22,5 +23,11 @@ JsonObject {
property bool capsLockChanged: true
property bool numLockChanged: true
property bool kbLayoutChanged: true
property bool vpnChanged: true
}
component Vpn: JsonObject {
property bool enabled: false
property list<var> provider: ["netbird"]
}
}

View file

@ -84,6 +84,14 @@ StyledRect {
checked: Notifs.dnd
onClicked: Notifs.dnd = !Notifs.dnd
}
Toggle {
icon: "vpn_key"
checked: VPN.connected
enabled: !VPN.connecting
visible: VPN.enabled
onClicked: VPN.toggle()
}
}
}

176
services/VPN.qml Normal file
View file

@ -0,0 +1,176 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import Caelestia
Singleton {
id: root
property bool connected: false
readonly property bool connecting: connectProc.running || disconnectProc.running
readonly property bool enabled: Config.utilities.vpn.enabled
readonly property var providerInput: (Config.utilities.vpn.provider && Config.utilities.vpn.provider.length > 0) ? Config.utilities.vpn.provider[0] : "wireguard"
readonly property bool isCustomProvider: typeof providerInput === "object"
readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput)
readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : ""
readonly property var currentConfig: {
const name = providerName;
const iface = interfaceName;
const defaults = getBuiltinDefaults(name, iface);
if (isCustomProvider) {
const custom = providerInput;
return {
connectCmd: custom.connectCmd || defaults.connectCmd,
disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd,
interface: custom.interface || defaults.interface,
displayName: custom.displayName || defaults.displayName
};
}
return defaults;
}
function getBuiltinDefaults(name, iface) {
const builtins = {
"wireguard": {
connectCmd: ["pkexec", "wg-quick", "up", iface],
disconnectCmd: ["pkexec", "wg-quick", "down", iface],
interface: iface,
displayName: iface
},
"warp": {
connectCmd: ["warp-cli", "connect"],
disconnectCmd: ["warp-cli", "disconnect"],
interface: "CloudflareWARP",
displayName: "Warp"
},
"netbird": {
connectCmd: ["netbird", "up"],
disconnectCmd: ["netbird", "down"],
interface: "wt0",
displayName: "NetBird"
},
"tailscale": {
connectCmd: ["tailscale", "up"],
disconnectCmd: ["tailscale", "down"],
interface: "tailscale0",
displayName: "Tailscale"
}
};
return builtins[name] || {
connectCmd: [name, "up"],
disconnectCmd: [name, "down"],
interface: iface || name,
displayName: name
};
}
function connect(): void {
if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) {
connectProc.exec(root.currentConfig.connectCmd);
}
}
function disconnect(): void {
if (connected && !connecting && root.currentConfig && root.currentConfig.disconnectCmd) {
disconnectProc.exec(root.currentConfig.disconnectCmd);
}
}
function toggle(): void {
if (connected) {
disconnect();
} else {
connect();
}
}
function checkStatus(): void {
if (root.enabled) {
statusProc.running = true;
}
}
onConnectedChanged: {
if (!Config.utilities.toasts.vpnChanged)
return;
const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN";
if (connected) {
Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key");
} else {
Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off");
}
}
Component.onCompleted: root.enabled && statusCheckTimer.start()
Process {
id: nmMonitor
running: root.enabled
command: ["nmcli", "monitor"]
stdout: SplitParser {
onRead: statusCheckTimer.restart()
}
}
Process {
id: statusProc
command: ["ip", "link", "show"]
environment: ({
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8"
})
stdout: StdioCollector {
onStreamFinished: {
const iface = root.currentConfig ? root.currentConfig.interface : "";
root.connected = iface && text.includes(iface + ":");
}
}
}
Process {
id: connectProc
onExited: statusCheckTimer.start()
stderr: StdioCollector {
onStreamFinished: {
const error = text.trim();
if (error && !error.includes("[#]") && !error.includes("already exists")) {
console.warn("VPN connection error:", error);
} else if (error.includes("already exists")) {
root.connected = true;
}
}
}
}
Process {
id: disconnectProc
onExited: statusCheckTimer.start()
stderr: StdioCollector {
onStreamFinished: {
const error = text.trim();
if (error && !error.includes("[#]")) {
console.warn("VPN disconnection error:", error);
}
}
}
}
Timer {
id: statusCheckTimer
interval: 500
onTriggered: root.checkStatus()
}
}