summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/controlcenter/Session.qml1
-rw-r--r--modules/controlcenter/network/NetworkSettings.qml74
-rw-r--r--modules/controlcenter/network/NetworkingPane.qml92
-rw-r--r--modules/controlcenter/network/VpnDetails.qml367
-rw-r--r--modules/controlcenter/network/VpnList.qml646
-rw-r--r--modules/controlcenter/network/VpnSettings.qml232
-rw-r--r--modules/controlcenter/state/VpnState.qml5
-rw-r--r--modules/utilities/cards/Toggles.qml2
8 files changed, 1402 insertions, 17 deletions
diff --git a/modules/controlcenter/Session.qml b/modules/controlcenter/Session.qml
index 5c4bb05..e77cd34 100644
--- a/modules/controlcenter/Session.qml
+++ b/modules/controlcenter/Session.qml
@@ -15,6 +15,7 @@ QtObject {
readonly property NetworkState network: NetworkState {}
readonly property EthernetState ethernet: EthernetState {}
readonly property LauncherState launcher: LauncherState {}
+ readonly property VpnState vpn: VpnState {}
onActiveChanged: activeIndex = Math.max(0, panes.indexOf(active))
onActiveIndexChanged: if (panes[activeIndex]) active = panes[activeIndex]
diff --git a/modules/controlcenter/network/NetworkSettings.qml b/modules/controlcenter/network/NetworkSettings.qml
index 22e07cb..04746af 100644
--- a/modules/controlcenter/network/NetworkSettings.qml
+++ b/modules/controlcenter/network/NetworkSettings.qml
@@ -4,10 +4,12 @@ import ".."
import "../components"
import qs.components
import qs.components.controls
+import qs.components.containers
import qs.components.effects
import qs.services
import qs.config
import QtQuick
+import QtQuick.Controls
import QtQuick.Layouts
ColumnLayout {
@@ -61,6 +63,45 @@ ColumnLayout {
SectionHeader {
Layout.topMargin: Appearance.spacing.large
+ title: qsTr("VPN")
+ description: qsTr("VPN provider settings")
+ visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0
+ }
+
+ SectionContainer {
+ visible: Config.utilities.vpn.enabled || Config.utilities.vpn.provider.length > 0
+
+ ToggleRow {
+ label: qsTr("VPN enabled")
+ checked: Config.utilities.vpn.enabled
+ toggle.onToggled: {
+ Config.utilities.vpn.enabled = checked;
+ Config.save();
+ }
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Providers")
+ value: qsTr("%1").arg(Config.utilities.vpn.provider.length)
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ text: qsTr("⚙ Manage VPN Providers")
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+
+ onClicked: {
+ vpnSettingsDialog.open();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
title: qsTr("Current connection")
description: qsTr("Active network connection information")
}
@@ -94,5 +135,38 @@ ColumnLayout {
value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A")
}
}
+
+ Popup {
+ id: vpnSettingsDialog
+
+ parent: Overlay.overlay
+ anchors.centerIn: parent
+ width: Math.min(600, parent.width - Appearance.padding.large * 2)
+ height: Math.min(700, parent.height - Appearance.padding.large * 2)
+
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ background: StyledRect {
+ color: Colours.palette.m3surface
+ radius: Appearance.rounding.large
+ }
+
+ StyledFlickable {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 1.5
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: vpnSettingsContent.height
+ clip: true
+
+ VpnSettings {
+ id: vpnSettingsContent
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
}
diff --git a/modules/controlcenter/network/NetworkingPane.qml b/modules/controlcenter/network/NetworkingPane.qml
index a87a16f..23e795e 100644
--- a/modules/controlcenter/network/NetworkingPane.qml
+++ b/modules/controlcenter/network/NetworkingPane.qml
@@ -112,6 +112,24 @@ Item {
}
CollapsibleSection {
+ id: vpnListSection
+
+ Layout.fillWidth: true
+ title: qsTr("VPN")
+ expanded: true
+
+ Loader {
+ Layout.fillWidth: true
+ sourceComponent: Component {
+ VpnList {
+ session: root.session
+ showHeader: false
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
id: ethernetListSection
Layout.fillWidth: true
@@ -154,14 +172,16 @@ Item {
Item {
id: rightPaneItem
- property var ethernetPane: root.session.ethernet.active
- property var wirelessPane: root.session.network.active
- property var pane: ethernetPane || wirelessPane
- property string paneId: ethernetPane ? ("eth:" + (ethernetPane.interface || "")) : (wirelessPane ? ("wifi:" + (wirelessPane.ssid || wirelessPane.bssid || "")) : "settings")
+ property var vpnPane: root.session && root.session.vpn ? root.session.vpn.active : null
+ property var ethernetPane: root.session && root.session.ethernet ? root.session.ethernet.active : null
+ property var wirelessPane: root.session && root.session.network ? root.session.network.active : null
+ property var pane: vpnPane || ethernetPane || wirelessPane
+ property string paneId: vpnPane ? ("vpn:" + (vpnPane.name || "")) : (ethernetPane ? ("eth:" + (ethernetPane.interface || "")) : (wirelessPane ? ("wifi:" + (wirelessPane.ssid || wirelessPane.bssid || "")) : "settings"))
property Component targetComponent: settingsComponent
property Component nextComponent: settingsComponent
function getComponentForPane() {
+ if (vpnPane) return vpnDetailsComponent;
if (ethernetPane) return ethernetDetailsComponent;
if (wirelessPane) return wirelessDetailsComponent;
return settingsComponent;
@@ -173,28 +193,44 @@ Item {
}
Connections {
- target: root.session.ethernet
+ target: root.session && root.session.vpn ? root.session.vpn : null
+ enabled: target !== null
+
function onActiveChanged() {
- // Clear wireless when ethernet is selected
- if (root.session.ethernet.active && root.session.network.active) {
- root.session.network.active = null;
- return; // Let the network.onActiveChanged handle the update
+ // Clear others when VPN is selected
+ if (root.session && root.session.vpn && root.session.vpn.active) {
+ if (root.session.ethernet && root.session.ethernet.active) root.session.ethernet.active = null;
+ if (root.session.network && root.session.network.active) root.session.network.active = null;
}
rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
- // paneId will automatically update via property binding
}
}
Connections {
- target: root.session.network
+ target: root.session && root.session.ethernet ? root.session.ethernet : null
+ enabled: target !== null
+
function onActiveChanged() {
- // Clear ethernet when wireless is selected
- if (root.session.network.active && root.session.ethernet.active) {
- root.session.ethernet.active = null;
- return; // Let the ethernet.onActiveChanged handle the update
+ // Clear others when ethernet is selected
+ if (root.session && root.session.ethernet && root.session.ethernet.active) {
+ if (root.session.vpn && root.session.vpn.active) root.session.vpn.active = null;
+ if (root.session.network && root.session.network.active) root.session.network.active = null;
+ }
+ rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
+ }
+ }
+
+ Connections {
+ target: root.session && root.session.network ? root.session.network : null
+ enabled: target !== null
+
+ function onActiveChanged() {
+ // Clear others when wireless is selected
+ if (root.session && root.session.network && root.session.network.active) {
+ if (root.session.vpn && root.session.vpn.active) root.session.vpn.active = null;
+ if (root.session.ethernet && root.session.ethernet.active) root.session.ethernet.active = null;
}
rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
- // paneId will automatically update via property binding
}
}
@@ -208,6 +244,7 @@ Item {
transformOrigin: Item.Center
clip: false
+ asynchronous: true
sourceComponent: rightPaneItem.targetComponent
}
@@ -296,6 +333,29 @@ Item {
}
}
+ Component {
+ id: vpnDetailsComponent
+
+ StyledFlickable {
+ id: vpnFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: vpnDetailsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: vpnFlickable
+ }
+
+ VpnDetails {
+ id: vpnDetailsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
WirelessPasswordDialog {
anchors.fill: parent
session: root.session
diff --git a/modules/controlcenter/network/VpnDetails.qml b/modules/controlcenter/network/VpnDetails.qml
new file mode 100644
index 0000000..76a9b17
--- /dev/null
+++ b/modules/controlcenter/network/VpnDetails.qml
@@ -0,0 +1,367 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Qt5Compat.GraphicalEffects
+
+DeviceDetails {
+ id: root
+
+ required property Session session
+ readonly property var vpnProvider: root.session.vpn.active
+ readonly property bool providerEnabled: {
+ if (!vpnProvider || vpnProvider.index === undefined) return false;
+ const provider = Config.utilities.vpn.provider[vpnProvider.index];
+ return provider && typeof provider === "object" && provider.enabled === true;
+ }
+
+ device: vpnProvider
+
+ headerComponent: Component {
+ ConnectionHeader {
+ icon: "vpn_key"
+ title: root.vpnProvider?.displayName ?? qsTr("Unknown")
+ }
+ }
+
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection status")
+ description: qsTr("VPN connection settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Enable this provider")
+ checked: root.providerEnabled
+ toggle.onToggled: {
+ if (!root.vpnProvider) return;
+ const providers = [];
+ const index = root.vpnProvider.index;
+
+ // Copy providers and update enabled state
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ const p = Config.utilities.vpn.provider[i];
+ if (typeof p === "object") {
+ const newProvider = {
+ name: p.name,
+ displayName: p.displayName,
+ interface: p.interface
+ };
+
+ if (checked) {
+ // Enable this one, disable others
+ newProvider.enabled = (i === index);
+ } else {
+ // Just disable this one
+ newProvider.enabled = (i === index) ? false : (p.enabled !== false);
+ }
+
+ providers.push(newProvider);
+ } else {
+ providers.push(p);
+ }
+ }
+
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ visible: root.providerEnabled
+ enabled: !VPN.connecting
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ text: VPN.connected ? qsTr("Disconnect") : qsTr("Connect")
+
+ onClicked: {
+ VPN.toggle();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Edit Provider")
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+
+ onClicked: {
+ editVpnDialog.editIndex = root.vpnProvider.index;
+ editVpnDialog.providerName = root.vpnProvider.name;
+ editVpnDialog.displayName = root.vpnProvider.displayName;
+ editVpnDialog.interfaceName = root.vpnProvider.interface;
+ editVpnDialog.open();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Delete Provider")
+ inactiveColour: Colours.palette.m3errorContainer
+ inactiveOnColour: Colours.palette.m3onErrorContainer
+
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i !== root.vpnProvider.index) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ root.session.vpn.active = null;
+ }
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Provider details")
+ description: qsTr("VPN provider information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Provider")
+ value: root.vpnProvider?.name ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Display name")
+ value: root.vpnProvider?.displayName ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Interface")
+ value: root.vpnProvider?.interface || qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Status")
+ value: {
+ if (!root.providerEnabled) return qsTr("Disabled");
+ if (VPN.connecting) return qsTr("Connecting...");
+ if (VPN.connected) return qsTr("Connected");
+ return qsTr("Enabled (Not connected)");
+ }
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Enabled")
+ value: root.providerEnabled ? qsTr("Yes") : qsTr("No")
+ }
+ }
+ }
+ }
+ ]
+
+ // Edit VPN Dialog
+ Popup {
+ id: editVpnDialog
+
+ property int editIndex: -1
+ property string providerName: ""
+ property string displayName: ""
+ property string interfaceName: ""
+
+ parent: Overlay.overlay
+ anchors.centerIn: parent
+ width: Math.min(400, parent.width - Appearance.padding.large * 2)
+ padding: Appearance.padding.large * 1.5
+
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ opacity: 0
+ scale: 0.7
+
+ enter: Transition {
+ ParallelAnimation {
+ NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Appearance.anim.durations.expressiveFastSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial }
+ NumberAnimation { property: "scale"; from: 0.7; to: 1; duration: Appearance.anim.durations.expressiveFastSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial }
+ }
+ }
+
+ exit: Transition {
+ ParallelAnimation {
+ NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Appearance.anim.durations.expressiveFastSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial }
+ NumberAnimation { property: "scale"; from: 1; to: 0.7; duration: Appearance.anim.durations.expressiveFastSpatial; easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial }
+ }
+ }
+
+ function closeWithAnimation(): void {
+ close();
+ }
+
+ Overlay.modal: Rectangle {
+ color: Qt.rgba(0, 0, 0, 0.4 * editVpnDialog.opacity)
+ }
+
+
+ background: StyledRect {
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.large
+
+ layer.enabled: true
+ layer.effect: DropShadow {
+ color: Qt.rgba(0, 0, 0, 0.3)
+ radius: 16
+ samples: 33
+ verticalOffset: 4
+ }
+ }
+
+ contentItem: ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Edit VPN Provider")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Display Name")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ StyledTextField {
+ id: displayNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: editVpnDialog.displayName
+ onTextChanged: editVpnDialog.displayName = text
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Interface (e.g., wg0, torguard)")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ StyledTextField {
+ id: interfaceNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: editVpnDialog.interfaceName
+ onTextChanged: editVpnDialog.interfaceName = text
+ }
+ }
+ }
+
+ Item { Layout.preferredHeight: Appearance.spacing.normal }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: editVpnDialog.closeWithAnimation()
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Save")
+ enabled: editVpnDialog.interfaceName.length > 0
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ const providers = [];
+ const oldProvider = Config.utilities.vpn.provider[editVpnDialog.editIndex];
+ const wasEnabled = typeof oldProvider === "object" ? (oldProvider.enabled !== false) : true;
+
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i === editVpnDialog.editIndex) {
+ providers.push({
+ name: editVpnDialog.providerName,
+ displayName: editVpnDialog.displayName || editVpnDialog.interfaceName,
+ interface: editVpnDialog.interfaceName,
+ enabled: wasEnabled
+ });
+ } else {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ editVpnDialog.closeWithAnimation();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/network/VpnList.qml b/modules/controlcenter/network/VpnList.qml
new file mode 100644
index 0000000..665f8cc
--- /dev/null
+++ b/modules/controlcenter/network/VpnList.qml
@@ -0,0 +1,646 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Qt5Compat.GraphicalEffects
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+ property bool showHeader: true
+ property int pendingSwitchIndex: -1
+
+ spacing: Appearance.spacing.normal
+
+ Connections {
+ target: VPN
+ function onConnectedChanged() {
+ if (!VPN.connected && root.pendingSwitchIndex >= 0) {
+ const targetIndex = root.pendingSwitchIndex;
+ root.pendingSwitchIndex = -1;
+
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ const p = Config.utilities.vpn.provider[i];
+ if (typeof p === "object") {
+ const newProvider = {
+ name: p.name,
+ displayName: p.displayName,
+ interface: p.interface,
+ enabled: (i === targetIndex)
+ };
+ providers.push(newProvider);
+ } else {
+ providers.push(p);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+
+ Qt.callLater(function() {
+ VPN.toggle();
+ });
+ }
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add VPN Provider")
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ vpnDialog.showProviderSelection();
+ }
+ }
+
+ ListView {
+ id: listView
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: contentHeight
+
+ interactive: false
+ spacing: Appearance.spacing.smaller
+
+ model: ScriptModel {
+ values: Config.utilities.vpn.provider.map((provider, index) => {
+ const isObject = typeof provider === "object";
+ const name = isObject ? (provider.name || "custom") : String(provider);
+ const displayName = isObject ? (provider.displayName || name) : name;
+ const iface = isObject ? (provider.interface || "") : "";
+ const enabled = isObject ? (provider.enabled === true) : false;
+
+ return {
+ index: index,
+ name: name,
+ displayName: displayName,
+ interface: iface,
+ provider: provider,
+ enabled: enabled
+ };
+ })
+ }
+
+ delegate: Component {
+ StyledRect {
+ required property var modelData
+ required property int index
+
+ width: ListView.view ? ListView.view.width : undefined
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (root.session && root.session.vpn && root.session.vpn.active === modelData) ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ if (root.session && root.session.vpn) {
+ root.session.vpn.active = modelData;
+ }
+ }
+ }
+
+ RowLayout {
+ id: rowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: modelData.enabled && VPN.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: modelData.enabled && VPN.connected ? "vpn_key" : "vpn_key_off"
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.enabled && VPN.connected ? 1 : 0
+ color: modelData.enabled && VPN.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.displayName || qsTr("Unknown")
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: {
+ if (modelData.enabled && VPN.connected) return qsTr("Connected");
+ if (modelData.enabled && VPN.connecting) return qsTr("Connecting...");
+ if (modelData.enabled) return qsTr("Enabled");
+ return qsTr("Disabled");
+ }
+ color: modelData.enabled ? (VPN.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface) : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: modelData.enabled && VPN.connected ? 500 : 400
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, VPN.connected && modelData.enabled ? 1 : 0)
+
+ StateLayer {
+ enabled: !VPN.connecting
+ function onClicked(): void {
+ const clickedIndex = modelData.index;
+
+ if (modelData.enabled) {
+ VPN.toggle();
+ } else {
+ if (VPN.connected) {
+ root.pendingSwitchIndex = clickedIndex;
+ VPN.toggle();
+ } else {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ const p = Config.utilities.vpn.provider[i];
+ if (typeof p === "object") {
+ const newProvider = {
+ name: p.name,
+ displayName: p.displayName,
+ interface: p.interface,
+ enabled: (i === clickedIndex)
+ };
+ providers.push(newProvider);
+ } else {
+ providers.push(p);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+
+ Qt.callLater(function() {
+ VPN.toggle();
+ });
+ }
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ text: VPN.connected && modelData.enabled ? "link_off" : "link"
+ color: VPN.connected && modelData.enabled ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: deleteIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: "transparent"
+
+ StateLayer {
+ function onClicked(): void {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i !== modelData.index) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ MaterialIcon {
+ id: deleteIcon
+
+ anchors.centerIn: parent
+ text: "delete"
+ color: Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+
+ Popup {
+ id: vpnDialog
+
+ property string currentState: "selection"
+ property int editIndex: -1
+ property string providerName: ""
+ property string displayName: ""
+ property string interfaceName: ""
+
+ parent: Overlay.overlay
+ x: Math.round((parent.width - width) / 2)
+ y: Math.round((parent.height - height) / 2)
+ implicitWidth: Math.min(400, parent.width - Appearance.padding.large * 2)
+ padding: Appearance.padding.large * 1.5
+
+ modal: true
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ opacity: 0
+ scale: 0.7
+
+ enter: Transition {
+ ParallelAnimation {
+ NumberAnimation { property: "opacity"; from: 0; to: 1; duration: Appearance.anim.durations.normal; easing.bezierCurve: Appearance.anim.curves.emphasized }
+ NumberAnimation { property: "scale"; from: 0.7; to: 1; duration: Appearance.anim.durations.normal; easing.bezierCurve: Appearance.anim.curves.emphasized }
+ }
+ }
+
+ exit: Transition {
+ ParallelAnimation {
+ NumberAnimation { property: "opacity"; from: 1; to: 0; duration: Appearance.anim.durations.small; easing.bezierCurve: Appearance.anim.curves.emphasized }
+ NumberAnimation { property: "scale"; from: 1; to: 0.7; duration: Appearance.anim.durations.small; easing.bezierCurve: Appearance.anim.curves.emphasized }
+ }
+ }
+
+ function showProviderSelection(): void {
+ currentState = "selection";
+ open();
+ }
+
+ function closeWithAnimation(): void {
+ close();
+ }
+
+ function showAddForm(providerType: string, defaultDisplayName: string): void {
+ editIndex = -1;
+ providerName = providerType;
+ displayName = defaultDisplayName;
+ interfaceName = "";
+
+ if (currentState === "selection") {
+ transitionToForm.start();
+ } else {
+ currentState = "form";
+ isClosing = false;
+ open();
+ }
+ }
+
+ function showEditForm(index: int): void {
+ const provider = Config.utilities.vpn.provider[index];
+ const isObject = typeof provider === "object";
+
+ editIndex = index;
+ providerName = isObject ? (provider.name || "custom") : String(provider);
+ displayName = isObject ? (provider.displayName || providerName) : providerName;
+ interfaceName = isObject ? (provider.interface || "") : "";
+
+ currentState = "form";
+ open();
+ }
+
+ Overlay.modal: Rectangle {
+ color: Qt.rgba(0, 0, 0, 0.4 * vpnDialog.opacity)
+ }
+
+ onClosed: {
+ currentState = "selection";
+ }
+
+ SequentialAnimation {
+ id: transitionToForm
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: selectionContent
+ property: "opacity"
+ to: 0
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ ScriptAction {
+ script: {
+ vpnDialog.currentState = "form";
+ }
+ }
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: formContent
+ property: "opacity"
+ to: 1
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ background: StyledRect {
+ color: Colours.palette.m3surfaceContainerHigh
+ radius: Appearance.rounding.large
+
+ layer.enabled: true
+ layer.effect: DropShadow {
+ color: Qt.rgba(0, 0, 0, 0.3)
+ radius: 16
+ samples: 33
+ verticalOffset: 4
+ }
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+ }
+
+ contentItem: Item {
+ implicitHeight: vpnDialog.currentState === "selection" ? selectionContent.implicitHeight : formContent.implicitHeight
+
+ Behavior on implicitHeight {
+ NumberAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ ColumnLayout {
+ id: selectionContent
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.normal
+ visible: vpnDialog.currentState === "selection"
+ opacity: vpnDialog.currentState === "selection" ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ StyledText {
+ text: qsTr("Add VPN Provider")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Choose a provider to add")
+ wrapMode: Text.WordWrap
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Item { Layout.preferredHeight: Appearance.spacing.small }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("NetBird")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push({ name: "netbird", displayName: "NetBird", interface: "wt0" });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Tailscale")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push({ name: "tailscale", displayName: "Tailscale", interface: "tailscale0" });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Cloudflare WARP")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ const providers = [];
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push({ name: "warp", displayName: "Cloudflare WARP", interface: "CloudflareWARP" });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("WireGuard (Custom)")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: {
+ vpnDialog.showAddForm("wireguard", "WireGuard");
+ }
+ }
+
+ Item { Layout.preferredHeight: Appearance.spacing.small }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ onClicked: vpnDialog.closeWithAnimation()
+ }
+ }
+
+ ColumnLayout {
+ id: formContent
+
+ anchors.fill: parent
+ spacing: Appearance.spacing.normal
+ visible: vpnDialog.currentState === "form"
+ opacity: vpnDialog.currentState === "form" ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.emphasized
+ }
+ }
+
+ StyledText {
+ text: vpnDialog.editIndex >= 0 ? qsTr("Edit VPN Provider") : qsTr("Add %1 VPN").arg(vpnDialog.displayName)
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Display Name")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ StyledTextField {
+ id: displayNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: vpnDialog.displayName
+ onTextChanged: vpnDialog.displayName = text
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller / 2
+
+ StyledText {
+ text: qsTr("Interface (e.g., wg0, torguard)")
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: 40
+ color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ StyledTextField {
+ id: interfaceNameField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignLeft
+ text: vpnDialog.interfaceName
+ onTextChanged: vpnDialog.interfaceName = text
+ }
+ }
+ }
+
+ Item { Layout.preferredHeight: Appearance.spacing.normal }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Cancel")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+ onClicked: vpnDialog.closeWithAnimation()
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("Save")
+ enabled: vpnDialog.interfaceName.length > 0
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ const providers = [];
+ const newProvider = {
+ name: vpnDialog.providerName,
+ displayName: vpnDialog.displayName || vpnDialog.interfaceName,
+ interface: vpnDialog.interfaceName
+ };
+
+ if (vpnDialog.editIndex >= 0) {
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ if (i === vpnDialog.editIndex) {
+ providers.push(newProvider);
+ } else {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ }
+ } else {
+ for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
+ providers.push(Config.utilities.vpn.provider[i]);
+ }
+ providers.push(newProvider);
+ }
+
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ vpnDialog.closeWithAnimation();
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/network/VpnSettings.qml b/modules/controlcenter/network/VpnSettings.qml
new file mode 100644
index 0000000..7387ddc
--- /dev/null
+++ b/modules/controlcenter/network/VpnSettings.qml
@@ -0,0 +1,232 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "vpn_key"
+ title: qsTr("VPN Settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("General")
+ description: qsTr("VPN configuration")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("VPN enabled")
+ checked: Config.utilities.vpn.enabled
+ toggle.onToggled: {
+ Config.utilities.vpn.enabled = checked;
+ Config.save();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Providers")
+ description: qsTr("Manage VPN providers")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ListView {
+ Layout.fillWidth: true
+ Layout.preferredHeight: contentHeight
+
+ interactive: false
+ spacing: Appearance.spacing.smaller
+
+ model: ScriptModel {
+ values: Config.utilities.vpn.provider.map((provider, index) => {
+ const isObject = typeof provider === "object";
+ const name = isObject ? (provider.name || "custom") : String(provider);
+ const displayName = isObject ? (provider.displayName || name) : name;
+ const iface = isObject ? (provider.interface || "") : "";
+
+ return {
+ index: index,
+ name: name,
+ displayName: displayName,
+ interface: iface,
+ provider: provider,
+ isActive: index === 0
+ };
+ })
+ }
+
+ delegate: Component {
+ StyledRect {
+ required property var modelData
+ required property int index
+
+ width: ListView.view ? ListView.view.width : undefined
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Appearance.rounding.normal
+
+ RowLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: modelData.isActive ? "vpn_key" : "vpn_key_off"
+ font.pointSize: Appearance.font.size.large
+ color: modelData.isActive ? Colours.palette.m3primary : Colours.palette.m3outline
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+
+ StyledText {
+ text: modelData.displayName
+ font.weight: modelData.isActive ? 500 : 400
+ }
+
+ StyledText {
+ text: qsTr("%1 • %2").arg(modelData.name).arg(modelData.interface || qsTr("No interface"))
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3outline
+ }
+ }
+
+ IconButton {
+ icon: modelData.isActive ? "arrow_downward" : "arrow_upward"
+ visible: !modelData.isActive || Config.utilities.vpn.provider.length > 1
+ onClicked: {
+ if (modelData.isActive && index < Config.utilities.vpn.provider.length - 1) {
+ // Move down
+ const providers = [...Config.utilities.vpn.provider];
+ const temp = providers[index];
+ providers[index] = providers[index + 1];
+ providers[index + 1] = temp;
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ } else if (!modelData.isActive) {
+ // Make active (move to top)
+ const providers = [...Config.utilities.vpn.provider];
+ const provider = providers.splice(index, 1)[0];
+ providers.unshift(provider);
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+ }
+
+ IconButton {
+ icon: "delete"
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.splice(index, 1);
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+ }
+
+ implicitHeight: 60
+ }
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("+ Add Provider")
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+
+ onClicked: {
+ addProviderDialog.open();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Quick Add")
+ description: qsTr("Add common VPN providers")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.smaller
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add NetBird")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.push({
+ name: "netbird",
+ displayName: "NetBird",
+ interface: "wt0"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add Tailscale")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.push({
+ name: "tailscale",
+ displayName: "Tailscale",
+ interface: "tailscale0"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ text: qsTr("+ Add Cloudflare WARP")
+ inactiveColour: Colours.tPalette.m3surfaceContainerHigh
+ inactiveOnColour: Colours.palette.m3onSurface
+
+ onClicked: {
+ const providers = [...Config.utilities.vpn.provider];
+ providers.push({
+ name: "warp",
+ displayName: "Cloudflare WARP",
+ interface: "CloudflareWARP"
+ });
+ Config.utilities.vpn.provider = providers;
+ Config.save();
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/state/VpnState.qml b/modules/controlcenter/state/VpnState.qml
new file mode 100644
index 0000000..aa911f1
--- /dev/null
+++ b/modules/controlcenter/state/VpnState.qml
@@ -0,0 +1,5 @@
+import QtQuick
+
+QtObject {
+ property var active: null
+}
diff --git a/modules/utilities/cards/Toggles.qml b/modules/utilities/cards/Toggles.qml
index 51e991e..3cb61e6 100644
--- a/modules/utilities/cards/Toggles.qml
+++ b/modules/utilities/cards/Toggles.qml
@@ -88,7 +88,7 @@ StyledRect {
icon: "vpn_key"
checked: VPN.connected
enabled: !VPN.connecting
- visible: VPN.enabled
+ visible: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
onClicked: VPN.toggle()
}