diff options
| author | Robin Seger <pixelkhaos@gmail.com> | 2026-01-20 14:12:08 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-21 00:12:08 +1100 |
| commit | 2ddc367e4e12c13fc9499550fab62772408a6b47 (patch) | |
| tree | 2ec14d426fa26dbcb7ca5e0c075a1d87e7a252e3 /modules/controlcenter | |
| parent | bar/statusicons: allow disabling wifi icon when ethernet is active (#1107) (diff) | |
| download | caelestia-shell-2ddc367e4e12c13fc9499550fab62772408a6b47.tar.gz caelestia-shell-2ddc367e4e12c13fc9499550fab62772408a6b47.tar.bz2 caelestia-shell-2ddc367e4e12c13fc9499550fab62772408a6b47.zip | |
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
Diffstat (limited to 'modules/controlcenter')
| -rw-r--r-- | modules/controlcenter/Session.qml | 1 | ||||
| -rw-r--r-- | modules/controlcenter/network/NetworkSettings.qml | 74 | ||||
| -rw-r--r-- | modules/controlcenter/network/NetworkingPane.qml | 92 | ||||
| -rw-r--r-- | modules/controlcenter/network/VpnDetails.qml | 367 | ||||
| -rw-r--r-- | modules/controlcenter/network/VpnList.qml | 646 | ||||
| -rw-r--r-- | modules/controlcenter/network/VpnSettings.qml | 232 | ||||
| -rw-r--r-- | modules/controlcenter/state/VpnState.qml | 5 |
7 files changed, 1401 insertions, 16 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 +} |