controlcenter: added VPN settings & management (#1095)

* feat: add VPN settings and management UI

- Add VPN configuration UI
- Update VPN toggle visibility to check enabled providers

* controlcenter: VPN modal transitions & cleanup

* controlcenter: VPN modal styling

* controlcenter: VPN modal scrim

* controlcenter: VPN modal padding

* controlcenter: VPN modal enter & exit behaviour
This commit is contained in:
Robin Seger 2026-01-20 14:12:08 +01:00 committed by GitHub
parent d50f6080ec
commit 2ddc367e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1410 additions and 21 deletions

View file

@ -629,12 +629,13 @@ default, you must create it manually.
"nowPlaying": false "nowPlaying": false
}, },
"vpn": { "vpn": {
"enabled": false, "enabled": true,
"provider": [ "provider": [
{ {
"name": "wireguard", "name": "wireguard",
"interface": "your-connection-name", "interface": "your-connection-name",
"displayName": "Wireguard (Your VPN)" "displayName": "Wireguard (Your VPN)",
"enabled": false
} }
] ]
} }

View file

@ -15,6 +15,7 @@ QtObject {
readonly property NetworkState network: NetworkState {} readonly property NetworkState network: NetworkState {}
readonly property EthernetState ethernet: EthernetState {} readonly property EthernetState ethernet: EthernetState {}
readonly property LauncherState launcher: LauncherState {} readonly property LauncherState launcher: LauncherState {}
readonly property VpnState vpn: VpnState {}
onActiveChanged: activeIndex = Math.max(0, panes.indexOf(active)) onActiveChanged: activeIndex = Math.max(0, panes.indexOf(active))
onActiveIndexChanged: if (panes[activeIndex]) active = panes[activeIndex] onActiveIndexChanged: if (panes[activeIndex]) active = panes[activeIndex]

View file

@ -4,10 +4,12 @@ import ".."
import "../components" import "../components"
import qs.components import qs.components
import qs.components.controls import qs.components.controls
import qs.components.containers
import qs.components.effects import qs.components.effects
import qs.services import qs.services
import qs.config import qs.config
import QtQuick import QtQuick
import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
ColumnLayout { ColumnLayout {
@ -59,6 +61,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 { SectionHeader {
Layout.topMargin: Appearance.spacing.large Layout.topMargin: Appearance.spacing.large
title: qsTr("Current connection") title: qsTr("Current connection")
@ -94,5 +135,38 @@ ColumnLayout {
value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A") 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
}
}
}
} }

View file

@ -111,6 +111,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 { CollapsibleSection {
id: ethernetListSection id: ethernetListSection
@ -154,14 +172,16 @@ Item {
Item { Item {
id: rightPaneItem id: rightPaneItem
property var ethernetPane: root.session.ethernet.active property var vpnPane: root.session && root.session.vpn ? root.session.vpn.active : null
property var wirelessPane: root.session.network.active property var ethernetPane: root.session && root.session.ethernet ? root.session.ethernet.active : null
property var pane: ethernetPane || wirelessPane property var wirelessPane: root.session && root.session.network ? root.session.network.active : null
property string paneId: ethernetPane ? ("eth:" + (ethernetPane.interface || "")) : (wirelessPane ? ("wifi:" + (wirelessPane.ssid || wirelessPane.bssid || "")) : "settings") 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 targetComponent: settingsComponent
property Component nextComponent: settingsComponent property Component nextComponent: settingsComponent
function getComponentForPane() { function getComponentForPane() {
if (vpnPane) return vpnDetailsComponent;
if (ethernetPane) return ethernetDetailsComponent; if (ethernetPane) return ethernetDetailsComponent;
if (wirelessPane) return wirelessDetailsComponent; if (wirelessPane) return wirelessDetailsComponent;
return settingsComponent; return settingsComponent;
@ -173,28 +193,44 @@ Item {
} }
Connections { Connections {
target: root.session.ethernet target: root.session && root.session.vpn ? root.session.vpn : null
enabled: target !== null
function onActiveChanged() { function onActiveChanged() {
// Clear wireless when ethernet is selected // Clear others when VPN is selected
if (root.session.ethernet.active && root.session.network.active) { if (root.session && root.session.vpn && root.session.vpn.active) {
root.session.network.active = null; if (root.session.ethernet && root.session.ethernet.active) root.session.ethernet.active = null;
return; // Let the network.onActiveChanged handle the update if (root.session.network && root.session.network.active) root.session.network.active = null;
} }
rightPaneItem.nextComponent = rightPaneItem.getComponentForPane(); rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
// paneId will automatically update via property binding
} }
} }
Connections { Connections {
target: root.session.network target: root.session && root.session.ethernet ? root.session.ethernet : null
enabled: target !== null
function onActiveChanged() { function onActiveChanged() {
// Clear ethernet when wireless is selected // Clear others when ethernet is selected
if (root.session.network.active && root.session.ethernet.active) { if (root.session && root.session.ethernet && root.session.ethernet.active) {
root.session.ethernet.active = null; if (root.session.vpn && root.session.vpn.active) root.session.vpn.active = null;
return; // Let the ethernet.onActiveChanged handle the update 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(); rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
// paneId will automatically update via property binding
} }
} }
@ -208,6 +244,7 @@ Item {
transformOrigin: Item.Center transformOrigin: Item.Center
clip: false clip: false
asynchronous: true
sourceComponent: rightPaneItem.targetComponent 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 { WirelessPasswordDialog {
anchors.fill: parent anchors.fill: parent
session: root.session session: root.session

View file

@ -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();
}
}
}
}
}
}

View file

@ -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();
}
}
}
}
}
}
}

View file

@ -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();
}
}
}
}

View file

@ -0,0 +1,5 @@
import QtQuick
QtObject {
property var active: null
}

View file

@ -88,7 +88,7 @@ StyledRect {
icon: "vpn_key" icon: "vpn_key"
checked: VPN.connected checked: VPN.connected
enabled: !VPN.connecting enabled: !VPN.connecting
visible: VPN.enabled visible: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
onClicked: VPN.toggle() onClicked: VPN.toggle()
} }

View file

@ -12,8 +12,11 @@ Singleton {
property bool connected: false property bool connected: false
readonly property bool connecting: connectProc.running || disconnectProc.running readonly property bool connecting: connectProc.running || disconnectProc.running
readonly property bool enabled: Config.utilities.vpn.enabled readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
readonly property var providerInput: (Config.utilities.vpn.provider && Config.utilities.vpn.provider.length > 0) ? Config.utilities.vpn.provider[0] : "wireguard" readonly property var providerInput: {
const enabledProvider = Config.utilities.vpn.provider.find(p => typeof p === "object" ? (p.enabled === true) : false);
return enabledProvider || "wireguard";
}
readonly property bool isCustomProvider: typeof providerInput === "object" readonly property bool isCustomProvider: typeof providerInput === "object"
readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput) readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput)
readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : "" readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : ""