From 685ad569a3d362c1b02cd2d26862059889232c9b Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:53:34 +1100 Subject: [PATCH 1/6] background: fix desktop clock 12h format --- modules/background/DesktopClock.qml | 23 ++++++++++++++--------- modules/dashboard/dash/DateTime.qml | 8 +++----- modules/lock/Center.qml | 7 +++---- services/Time.qml | 8 ++++++++ 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/modules/background/DesktopClock.qml b/modules/background/DesktopClock.qml index a6b05c2ec..77fe447fe 100644 --- a/modules/background/DesktopClock.qml +++ b/modules/background/DesktopClock.qml @@ -79,7 +79,7 @@ Item { spacing: Appearance.spacing.small StyledText { - text: Time.format(Config.services.useTwelveHourClock ? "hh" : "HH") + text: Time.hourStr font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale font.weight: Font.Bold color: root.safePrimary @@ -94,19 +94,24 @@ Item { } StyledText { - text: Time.format("mm") + text: Time.minuteStr font.pointSize: Appearance.font.size.extraLarge * 3 * root.scale font.weight: Font.Bold color: root.safeSecondary } - StyledText { - visible: Config.services.useTwelveHourClock - text: Time.format("A") - font.pointSize: Appearance.font.size.large * root.scale - color: root.safeSecondary + Loader { Layout.alignment: Qt.AlignTop Layout.topMargin: Appearance.padding.large * 1.4 * root.scale + + active: Config.services.useTwelveHourClock + visible: active + + sourceComponent: StyledText { + text: Time.amPmStr + font.pointSize: Appearance.font.size.large * root.scale + color: root.safeSecondary + } } } @@ -155,10 +160,10 @@ Item { easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial } } - + Behavior on implicitWidth { Anim { duration: Appearance.anim.durations.small } } -} \ No newline at end of file +} diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml index d80acd443..e74044883 100644 --- a/modules/dashboard/dash/DateTime.qml +++ b/modules/dashboard/dash/DateTime.qml @@ -9,8 +9,6 @@ import QtQuick.Layouts Item { id: root - readonly property list timeComponents: Time.format(Config.services.useTwelveHourClock ? "hh:mm:A" : "hh:mm").split(":") - anchors.top: parent.top anchors.bottom: parent.bottom implicitWidth: Config.dashboard.sizes.dateTimeWidth @@ -24,7 +22,7 @@ Item { StyledText { Layout.bottomMargin: -(font.pointSize * 0.4) Layout.alignment: Qt.AlignHCenter - text: root.timeComponents[0] + text: Time.hourStr color: Colours.palette.m3secondary font.pointSize: Appearance.font.size.extraLarge font.family: Appearance.font.family.clock @@ -42,7 +40,7 @@ Item { StyledText { Layout.topMargin: -(font.pointSize * 0.4) Layout.alignment: Qt.AlignHCenter - text: root.timeComponents[1] + text: Time.minuteStr color: Colours.palette.m3secondary font.pointSize: Appearance.font.size.extraLarge font.family: Appearance.font.family.clock @@ -56,7 +54,7 @@ Item { visible: active sourceComponent: StyledText { - text: root.timeComponents[2] ?? "" + text: Time.amPmStr color: Colours.palette.m3primary font.pointSize: Appearance.font.size.large font.family: Appearance.font.family.clock diff --git a/modules/lock/Center.qml b/modules/lock/Center.qml index e37207ac5..19cf9d290 100644 --- a/modules/lock/Center.qml +++ b/modules/lock/Center.qml @@ -13,7 +13,6 @@ ColumnLayout { id: root required property var lock - readonly property list timeComponents: Time.format(Config.services.useTwelveHourClock ? "hh:mm:A" : "hh:mm").split(":") readonly property real centerScale: Math.min(1, (lock.screen?.height ?? 1440) / 1440) readonly property int centerWidth: Config.lock.sizes.centerWidth * centerScale @@ -29,7 +28,7 @@ ColumnLayout { StyledText { Layout.alignment: Qt.AlignVCenter - text: root.timeComponents[0] + text: Time.hourStr color: Colours.palette.m3secondary font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) font.family: Appearance.font.family.clock @@ -47,7 +46,7 @@ ColumnLayout { StyledText { Layout.alignment: Qt.AlignVCenter - text: root.timeComponents[1] + text: Time.minuteStr color: Colours.palette.m3secondary font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) font.family: Appearance.font.family.clock @@ -62,7 +61,7 @@ ColumnLayout { visible: active sourceComponent: StyledText { - text: root.timeComponents[2] ?? "" + text: Time.amPmStr color: Colours.palette.m3primary font.pointSize: Math.floor(Appearance.font.size.extraLarge * 2 * root.centerScale) font.family: Appearance.font.family.clock diff --git a/services/Time.qml b/services/Time.qml index c4b391362..a07d9ef8e 100644 --- a/services/Time.qml +++ b/services/Time.qml @@ -1,6 +1,8 @@ pragma Singleton +import qs.config import Quickshell +import QtQuick Singleton { property alias enabled: clock.enabled @@ -9,6 +11,12 @@ Singleton { readonly property int minutes: clock.minutes readonly property int seconds: clock.seconds + readonly property string timeStr: format(Config.services.useTwelveHourClock ? "hh:mm:A" : "hh:mm") + readonly property list timeComponents: timeStr.split(":") + readonly property string hourStr: timeComponents[0] ?? "" + readonly property string minuteStr: timeComponents[1] ?? "" + readonly property string amPmStr: timeComponents[2] ?? "" + function format(fmt: string): string { return Qt.formatDateTime(clock.date, fmt); } From d50f6080ec55e87ff7bc600ebb66194119a0af7d Mon Sep 17 00:00:00 2001 From: Evertiro Date: Tue, 20 Jan 2026 06:37:45 -0600 Subject: [PATCH 2/6] bar/statusicons: allow disabling wifi icon when ethernet is active (#1107) Signed-off-by: Dan Griffiths --- config/BarConfig.qml | 1 + config/Config.qml | 1 + modules/bar/components/StatusIcons.qml | 2 +- modules/controlcenter/taskbar/TaskbarPane.qml | 20 ++++++++++++++----- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/config/BarConfig.qml b/config/BarConfig.qml index 8226d9ee5..cf33fd21d 100644 --- a/config/BarConfig.qml +++ b/config/BarConfig.qml @@ -96,6 +96,7 @@ JsonObject { property bool showMicrophone: false property bool showKbLayout: false property bool showNetwork: true + property bool showWifi: true property bool showBluetooth: true property bool showBattery: true property bool showLockStatus: true diff --git a/config/Config.qml b/config/Config.qml index 45717d16e..10a2068f3 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -201,6 +201,7 @@ Singleton { showMicrophone: bar.status.showMicrophone, showKbLayout: bar.status.showKbLayout, showNetwork: bar.status.showNetwork, + showWifi: bar.status.showWifi, showBluetooth: bar.status.showBluetooth, showBattery: bar.status.showBattery, showLockStatus: bar.status.showLockStatus diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml index 74e6d004b..442bd2cd1 100644 --- a/modules/bar/components/StatusIcons.qml +++ b/modules/bar/components/StatusIcons.qml @@ -143,7 +143,7 @@ StyledRect { // Network icon WrappedLoader { name: "network" - active: Config.bar.status.showNetwork + active: Config.bar.status.showNetwork && (! Nmcli.activeEthernet || Config.bar.status.showWifi) sourceComponent: MaterialIcon { animate: true diff --git a/modules/controlcenter/taskbar/TaskbarPane.qml b/modules/controlcenter/taskbar/TaskbarPane.qml index efd4a761f..917b73a4f 100644 --- a/modules/controlcenter/taskbar/TaskbarPane.qml +++ b/modules/controlcenter/taskbar/TaskbarPane.qml @@ -27,6 +27,7 @@ Item { property bool showMicrophone: Config.bar.status.showMicrophone ?? true property bool showKbLayout: Config.bar.status.showKbLayout ?? false property bool showNetwork: Config.bar.status.showNetwork ?? true + property bool showWifi: Config.bar.status.showWifi ?? true property bool showBluetooth: Config.bar.status.showBluetooth ?? true property bool showBattery: Config.bar.status.showBattery ?? true property bool showLockStatus: Config.bar.status.showLockStatus ?? true @@ -69,6 +70,7 @@ Item { Config.bar.status.showMicrophone = root.showMicrophone; Config.bar.status.showKbLayout = root.showKbLayout; Config.bar.status.showNetwork = root.showNetwork; + Config.bar.status.showWifi = root.showWifi; Config.bar.status.showBluetooth = root.showBluetooth; Config.bar.status.showBattery = root.showBattery; Config.bar.status.showLockStatus = root.showLockStatus; @@ -176,7 +178,7 @@ Item { ConnectedButtonGroup { rootItem: root - + options: [ { label: qsTr("Speakers"), @@ -210,6 +212,14 @@ Item { root.saveConfig(); } }, + { + label: qsTr("Wifi"), + propertyName: "showWifi", + onToggled: function(checked) { + root.showWifi = checked; + root.saveConfig(); + } + }, { label: qsTr("Bluetooth"), propertyName: "showBluetooth", @@ -437,7 +447,7 @@ Item { ConnectedButtonGroup { rootItem: root - + options: [ { label: qsTr("Workspaces"), @@ -525,7 +535,7 @@ Item { SliderInput { Layout.fillWidth: true - + label: qsTr("Drag threshold") value: root.dragThreshold from: 0 @@ -534,7 +544,7 @@ Item { validator: IntValidator { bottom: 0; top: 100 } formatValueFunction: (val) => Math.round(val).toString() parseValueFunction: (text) => parseInt(text) - + onValueModified: (newValue) => { root.dragThreshold = Math.round(newValue); root.saveConfig(); @@ -598,7 +608,7 @@ Item { ConnectedButtonGroup { rootItem: root - + options: [ { label: qsTr("Background"), From 2ddc367e4e12c13fc9499550fab62772408a6b47 Mon Sep 17 00:00:00 2001 From: Robin Seger Date: Tue, 20 Jan 2026 14:12:08 +0100 Subject: [PATCH 3/6] 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 --- README.md | 5 +- modules/controlcenter/Session.qml | 1 + .../controlcenter/network/NetworkSettings.qml | 74 ++ .../controlcenter/network/NetworkingPane.qml | 92 ++- modules/controlcenter/network/VpnDetails.qml | 367 ++++++++++ modules/controlcenter/network/VpnList.qml | 646 ++++++++++++++++++ modules/controlcenter/network/VpnSettings.qml | 232 +++++++ modules/controlcenter/state/VpnState.qml | 5 + modules/utilities/cards/Toggles.qml | 2 +- services/VPN.qml | 7 +- 10 files changed, 1410 insertions(+), 21 deletions(-) create mode 100644 modules/controlcenter/network/VpnDetails.qml create mode 100644 modules/controlcenter/network/VpnList.qml create mode 100644 modules/controlcenter/network/VpnSettings.qml create mode 100644 modules/controlcenter/state/VpnState.qml diff --git a/README.md b/README.md index 92bfabdca..bd261014d 100644 --- a/README.md +++ b/README.md @@ -629,12 +629,13 @@ default, you must create it manually. "nowPlaying": false }, "vpn": { - "enabled": false, + "enabled": true, "provider": [ { "name": "wireguard", "interface": "your-connection-name", - "displayName": "Wireguard (Your VPN)" + "displayName": "Wireguard (Your VPN)", + "enabled": false } ] } diff --git a/modules/controlcenter/Session.qml b/modules/controlcenter/Session.qml index 5c4bb05a9..e77cd3456 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 22e07cbd3..04746afa5 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 { @@ -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 { Layout.topMargin: Appearance.spacing.large title: qsTr("Current connection") @@ -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 a87a16f98..23e795e98 100644 --- a/modules/controlcenter/network/NetworkingPane.qml +++ b/modules/controlcenter/network/NetworkingPane.qml @@ -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 { id: ethernetListSection @@ -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 000000000..76a9b1700 --- /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 000000000..665f8ccc1 --- /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 000000000..7387ddc17 --- /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 000000000..aa911f112 --- /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 51e991ee0..3cb61e66e 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() } diff --git a/services/VPN.qml b/services/VPN.qml index 412bda45b..431c8ec8e 100644 --- a/services/VPN.qml +++ b/services/VPN.qml @@ -12,8 +12,11 @@ Singleton { property bool connected: false readonly property bool connecting: connectProc.running || disconnectProc.running - readonly property bool enabled: Config.utilities.vpn.enabled - readonly property var providerInput: (Config.utilities.vpn.provider && Config.utilities.vpn.provider.length > 0) ? Config.utilities.vpn.provider[0] : "wireguard" + readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false) + 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 string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput) readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : "" From fd263a2de1f7a3d2db7d54918ed95add625817c6 Mon Sep 17 00:00:00 2001 From: AleksElixir <71710534+AleksElixir@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:20:38 +0200 Subject: [PATCH 4/6] SubActions --- modules/launcher/services/Actions.qml | 3 ++ modules/launcher/services/SubActions.qml | 65 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 modules/launcher/services/SubActions.qml diff --git a/modules/launcher/services/Actions.qml b/modules/launcher/services/Actions.qml index 5c1cb6bb8..9771e43b4 100644 --- a/modules/launcher/services/Actions.qml +++ b/modules/launcher/services/Actions.qml @@ -43,6 +43,9 @@ Searcher { } else if (command[0] === "setMode" && command.length > 1) { list.visibilities.launcher = false; Colours.setMode(command[1]); + } else if (command[0] === "openGroup") { + SubActions.setGroup(modelData); + list.search.text = `${Config.launcher.actionPrefix}sub `; } else { list.visibilities.launcher = false; Quickshell.execDetached(command); diff --git a/modules/launcher/services/SubActions.qml b/modules/launcher/services/SubActions.qml new file mode 100644 index 000000000..93bd9299f --- /dev/null +++ b/modules/launcher/services/SubActions.qml @@ -0,0 +1,65 @@ +pragma Singleton + +import ".." +import qs.config +import qs.utils +import Quickshell +import QtQuick + +Searcher { + id: root + + property var currentGroup: null + + function transformSearch(search: string): string { + const prefix = Config.launcher.actionPrefix; + const subPrefix = `${prefix}sub `; + if (search.startsWith(subPrefix)) + return search.slice(subPrefix.length); + return ""; + } + + function setGroup(groupObj: var): void { + currentGroup = groupObj; + } + + list: variants.instances + useFuzzy: Config.launcher.useFuzzy.actions + + Variants { + id: variants + + model: (root.currentGroup?.children ?? []).filter(a => + (a.enabled ?? true) && (Config.launcher.enableDangerousActions || !(a.dangerous ?? false)) + ) + + SubAction { } + } + + component SubAction: QtObject { + required property var modelData + + readonly property string name: modelData.name ?? qsTr("Unnamed") + readonly property string desc: modelData.description ?? qsTr("No description") + readonly property string icon: modelData.icon ?? "help_outline" + readonly property list command: modelData.command ?? [] + readonly property bool enabled: modelData.enabled ?? true + readonly property bool dangerous: modelData.dangerous ?? false + + function onClicked(list: AppList): void { + if (command.length === 0) + return; + + if (command[0] === "autocomplete" && command.length > 1) { + list.search.text = `${Config.launcher.actionPrefix}${command[1]} `; + } else if (command[0] === "setMode" && command.length > 1) { + list.visibilities.launcher = false; + Colours.setMode(command[1]); + } else { + list.visibilities.launcher = false; + Quickshell.execDetached(command); + } + } + } +} + From c05928cfda853ca27d84aa69eb5c2809f1fd2bee Mon Sep 17 00:00:00 2001 From: AleksElixir <71710534+AleksElixir@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:21:12 +0200 Subject: [PATCH 5/6] SubFolders --- modules/launcher/AppList.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/launcher/AppList.qml b/modules/launcher/AppList.qml index 7f7b843a9..13cf3f650 100644 --- a/modules/launcher/AppList.qml +++ b/modules/launcher/AppList.qml @@ -51,7 +51,11 @@ StyledListView { state: { const text = search.text; const prefix = Config.launcher.actionPrefix; + if (text.startsWith(prefix)) { + if (text.startsWith(`${prefix}sub `)) + return "subactions"; + for (const action of ["calc", "scheme", "variant"]) if (text.startsWith(`${prefix}${action} `)) return action; @@ -68,6 +72,14 @@ StyledListView { } states: [ + State { + name: "subactions" + + PropertyChanges { + model.values: SubActions.query(search.text) + root.delegate: actionItem + } + }, State { name: "apps" From 30ecd8bdc9a8f4c3d0ba31925930f44bc0bdbd66 Mon Sep 17 00:00:00 2001 From: AleksElixir <71710534+AleksElixir@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:26:30 +0200 Subject: [PATCH 6/6] Update README.md Added subaction example to README --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index bd261014d..756fd1bcb 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,28 @@ default, you must create it manually. "launcher": { "actionPrefix": ">", "actions": [ + { + "name": "Example subaction", + "icon": "tune", + "description": "example of a subaction", + "command": ["openGroup", "example"], + "enabled": true, + "dangerous": false, + "children": [ + { + "name": "action1", + "icon": "settings", + "description": "example of action1", + "command": [""] + }, + { + "name": "action2", + "icon": "settings", + "description": "example of action2", + "command": [""] + } + ] + }, { "name": "Calculator", "icon": "calculate",