pragma ComponentBehavior: Bound import ".." import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell import Caelestia.Config import qs.components import qs.components.controls import qs.components.effects import qs.services ColumnLayout { id: root required property Session session property bool showHeader: true property int pendingSwitchIndex: -1 spacing: Tokens.spacing.medium Connections { function onConnectedChanged() { if (!VPN.connected && root.pendingSwitchIndex >= 0) { const targetIndex = root.pendingSwitchIndex; root.pendingSwitchIndex = -1; const providers = []; for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { const p = GlobalConfig.utilities.vpn.provider[i]; if (typeof p === "object") { const newProvider = { name: p.name, displayName: p.displayName, interface: p.interface, enabled: (i === targetIndex) }; if (p.connectCmd && p.connectCmd.length > 0) { newProvider.connectCmd = p.connectCmd; } if (p.disconnectCmd && p.disconnectCmd.length > 0) { newProvider.disconnectCmd = p.disconnectCmd; } providers.push(newProvider); } else { providers.push(p); } } GlobalConfig.utilities.vpn.provider = providers; Qt.callLater(function () { VPN.toggle(); }); } } target: VPN } 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: Tokens.spacing.medium model: ScriptModel { values: GlobalConfig.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 implicitHeight: rowLayout.implicitHeight + Tokens.padding.medium * 2 color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (root.session && root.session.vpn && root.session.vpn.active === modelData) ? Colours.tPalette.m3surfaceContainer.a : 0) radius: Tokens.rounding.large StateLayer { onClicked: { 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: Tokens.padding.medium spacing: Tokens.spacing.medium StyledRect { implicitWidth: implicitHeight implicitHeight: icon.implicitHeight + Tokens.padding.medium * 2 radius: Tokens.rounding.large 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" fontStyle: Tokens.font.icon.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: Tokens.spacing.medium StyledText { Layout.fillWidth: true text: { if (!modelData.enabled) return qsTr("Disabled"); if (VPN.connecting) return qsTr("Connecting..."); switch (VPN.status.state) { case "connected": return qsTr("Connected"); case "disconnected": return qsTr("Enabled"); case "connecting": return qsTr("Connecting..."); case "needs-auth": return qsTr("Auth required"); case "error": return qsTr("Error"); default: return qsTr("Enabled"); } } color: { if (!modelData.enabled) return Colours.palette.m3outline; if (VPN.status.state === "connected") return Colours.palette.m3primary; if (VPN.status.state === "error") return Colours.palette.m3error; if (VPN.status.state === "needs-auth") return Colours.palette.m3tertiary; return Colours.palette.m3onSurface; } font: Tokens.font.body.builders.small.weight(modelData.enabled && VPN.connected ? Font.Medium : Font.Normal).build() elide: Text.ElideRight } } } StyledRect { implicitWidth: implicitHeight implicitHeight: connectIcon.implicitHeight + Tokens.padding.large radius: Tokens.rounding.full color: Qt.alpha(Colours.palette.m3primaryContainer, VPN.connected && modelData.enabled ? 1 : 0) StateLayer { onClicked: { 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 < GlobalConfig.utilities.vpn.provider.length; i++) { const p = GlobalConfig.utilities.vpn.provider[i]; if (typeof p === "object") { const newProvider = { name: p.name, displayName: p.displayName, interface: p.interface, enabled: (i === clickedIndex) }; if (p.connectCmd && p.connectCmd.length > 0) { newProvider.connectCmd = p.connectCmd; } if (p.disconnectCmd && p.disconnectCmd.length > 0) { newProvider.disconnectCmd = p.disconnectCmd; } providers.push(newProvider); } else { providers.push(p); } } GlobalConfig.utilities.vpn.provider = providers; Qt.callLater(function () { VPN.toggle(); }); } } } enabled: !VPN.connecting } 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 + Tokens.padding.large radius: Tokens.rounding.full color: "transparent" StateLayer { onClicked: { const providers = []; for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { if (i !== modelData.index) { const p = GlobalConfig.utilities.vpn.provider[i]; const reconstructed = { name: p.name, displayName: p.displayName, interface: p.interface, enabled: p.enabled }; if (p.connectCmd && p.connectCmd.length > 0) { reconstructed.connectCmd = p.connectCmd; } if (p.disconnectCmd && p.disconnectCmd.length > 0) { reconstructed.disconnectCmd = p.disconnectCmd; } providers.push(reconstructed); } } GlobalConfig.utilities.vpn.provider = providers; } } MaterialIcon { id: deleteIcon anchors.centerIn: parent text: "delete" color: Colours.palette.m3onSurface } } } } } } Popup { id: vpnDialog property string currentState: "selection" property int editIndex: -1 property string providerName: "" property string displayName: "" property string interfaceName: "" property string connectCmd: "" property string disconnectCmd: "" function showProviderSelection(): void { currentState = "selection"; open(); } function closeWithAnimation(): void { close(); } function showAddForm(providerType: string, defaultDisplayName: string): void { editIndex = -1; providerName = providerType; displayName = defaultDisplayName; interfaceName = ""; connectCmd = ""; disconnectCmd = ""; if (currentState === "selection") { transitionToForm.start(); } else { currentState = "form"; isClosing = false; open(); } } function showEditForm(index: int): void { const provider = GlobalConfig.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 || "") : ""; connectCmd = isObject && provider.connectCmd ? provider.connectCmd.join(" ") : ""; disconnectCmd = isObject && provider.disconnectCmd ? provider.disconnectCmd.join(" ") : ""; currentState = "form"; open(); } parent: Overlay.overlay x: Math.round((parent.width - width) / 2) y: Math.round((parent.height - height) / 2) implicitWidth: Math.min(400, parent.width - Tokens.padding.extraLargeIncreased) padding: Tokens.padding.large * 1.5 modal: true closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside opacity: 0 scale: 0.7 enter: Transition { ParallelAnimation { Anim { property: "opacity" from: 0 to: 1 type: Anim.Emphasized } Anim { property: "scale" from: 0.7 to: 1 type: Anim.Emphasized } } } exit: Transition { ParallelAnimation { Anim { property: "opacity" from: 1 to: 0 type: Anim.EmphasizedSmall } Anim { property: "scale" from: 1 to: 0.7 type: Anim.EmphasizedSmall } } } Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.4 * vpnDialog.opacity) } onClosed: { currentState = "selection"; } background: StyledRect { color: Colours.palette.m3surfaceContainerHigh radius: Tokens.rounding.extraLarge Elevation { anchors.fill: parent radius: parent.radius level: 3 z: -1 } Behavior on implicitHeight { Anim { type: Anim.Emphasized } } } contentItem: Item { implicitHeight: vpnDialog.currentState === "selection" ? selectionContent.implicitHeight : formContent.implicitHeight Behavior on implicitHeight { Anim { type: Anim.Emphasized } } ColumnLayout { id: selectionContent anchors.fill: parent spacing: Tokens.spacing.medium visible: vpnDialog.currentState === "selection" opacity: vpnDialog.currentState === "selection" ? 1 : 0 Behavior on opacity { Anim { type: Anim.EmphasizedSmall } } StyledText { text: qsTr("Add VPN Provider") font: Tokens.font.body.builders.large.weight(Font.Medium).build() } StyledText { Layout.fillWidth: true text: qsTr("Choose a provider to add") wrapMode: Text.WordWrap color: Colours.palette.m3outline font: Tokens.font.body.small } TextButton { Layout.topMargin: Tokens.spacing.medium Layout.fillWidth: true text: qsTr("NetBird") inactiveColour: Colours.tPalette.m3surfaceContainerHigh inactiveOnColour: Colours.palette.m3onSurface onClicked: { const providers = []; for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push({ name: "netbird", displayName: "NetBird", interface: "wt0" }); GlobalConfig.utilities.vpn.provider = providers; 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 < GlobalConfig.utilities.vpn.provider.length; i++) { providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push({ name: "tailscale", displayName: "Tailscale", interface: "tailscale0" }); GlobalConfig.utilities.vpn.provider = providers; 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 < GlobalConfig.utilities.vpn.provider.length; i++) { providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push({ name: "warp", displayName: "Cloudflare WARP", interface: "CloudflareWARP" }); GlobalConfig.utilities.vpn.provider = providers; vpnDialog.closeWithAnimation(); } } TextButton { Layout.fillWidth: true text: qsTr("WireGuard") inactiveColour: Colours.tPalette.m3surfaceContainerHigh inactiveOnColour: Colours.palette.m3onSurface onClicked: { vpnDialog.showAddForm("wireguard", "WireGuard"); } } TextButton { Layout.fillWidth: true text: qsTr("Custom") inactiveColour: Colours.tPalette.m3surfaceContainerHigh inactiveOnColour: Colours.palette.m3onSurface onClicked: { vpnDialog.showAddForm("custom", "Custom VPN"); } } TextButton { Layout.topMargin: Tokens.spacing.medium Layout.fillWidth: true text: qsTr("Cancel") inactiveColour: Colours.palette.m3secondaryContainer inactiveOnColour: Colours.palette.m3onSecondaryContainer onClicked: vpnDialog.closeWithAnimation() } } ColumnLayout { id: formContent anchors.fill: parent spacing: Tokens.spacing.medium visible: vpnDialog.currentState === "form" opacity: vpnDialog.currentState === "form" ? 1 : 0 Behavior on opacity { Anim { type: Anim.EmphasizedSmall } } StyledText { text: vpnDialog.editIndex >= 0 ? qsTr("Edit VPN Provider") : qsTr("Add %1 VPN").arg(vpnDialog.displayName) font: Tokens.font.body.builders.large.weight(Font.Medium).build() } ColumnLayout { Layout.fillWidth: true spacing: Tokens.spacing.medium / 2 StyledText { text: qsTr("Display Name") font: Tokens.font.body.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: Tokens.rounding.medium 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 - Tokens.padding.medium horizontalAlignment: TextInput.AlignLeft text: vpnDialog.displayName onTextChanged: vpnDialog.displayName = text } } } ColumnLayout { Layout.fillWidth: true spacing: Tokens.spacing.medium / 2 StyledText { text: qsTr("Interface (e.g., wg0, torguard)") font: Tokens.font.body.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: Tokens.rounding.medium 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 - Tokens.padding.medium horizontalAlignment: TextInput.AlignLeft text: vpnDialog.interfaceName onTextChanged: vpnDialog.interfaceName = text } } } ColumnLayout { Layout.fillWidth: true spacing: Tokens.spacing.medium / 2 visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom") StyledText { text: qsTr("Connect Command (e.g., wg-quick up wg0)") font: Tokens.font.body.small color: Colours.palette.m3onSurfaceVariant } StyledRect { Layout.fillWidth: true implicitHeight: 40 color: connectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) radius: Tokens.rounding.medium border.width: 1 border.color: connectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) Behavior on color { CAnim {} } Behavior on border.color { CAnim {} } StyledTextField { id: connectCmdField anchors.centerIn: parent width: parent.width - Tokens.padding.medium horizontalAlignment: TextInput.AlignLeft text: vpnDialog.connectCmd onTextChanged: vpnDialog.connectCmd = text } } } ColumnLayout { Layout.fillWidth: true spacing: Tokens.spacing.medium / 2 visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom") StyledText { text: qsTr("Disconnect Command (e.g., wg-quick down wg0)") font: Tokens.font.body.small color: Colours.palette.m3onSurfaceVariant } StyledRect { Layout.fillWidth: true implicitHeight: 40 color: disconnectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2) radius: Tokens.rounding.medium border.width: 1 border.color: disconnectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3) Behavior on color { CAnim {} } Behavior on border.color { CAnim {} } StyledTextField { id: disconnectCmdField anchors.centerIn: parent width: parent.width - Tokens.padding.medium horizontalAlignment: TextInput.AlignLeft text: vpnDialog.disconnectCmd onTextChanged: vpnDialog.disconnectCmd = text } } } RowLayout { Layout.topMargin: Tokens.spacing.medium Layout.fillWidth: true spacing: Tokens.spacing.medium 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: { const hasCommands = vpnDialog.connectCmd.length > 0 || vpnDialog.disconnectCmd.length > 0; if (hasCommands) { return vpnDialog.interfaceName.length > 0 && vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0; } return vpnDialog.interfaceName.length > 0; } inactiveColour: Colours.palette.m3primaryContainer inactiveOnColour: Colours.palette.m3onPrimaryContainer onClicked: { const providers = []; const hasCommands = vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0; const newProvider = { displayName: vpnDialog.displayName || vpnDialog.interfaceName, enabled: false, interface: vpnDialog.interfaceName, name: vpnDialog.providerName }; if (hasCommands) { newProvider.connectCmd = vpnDialog.connectCmd.split(" ").filter(s => s.length > 0); newProvider.disconnectCmd = vpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0); } if (vpnDialog.editIndex >= 0) { const oldProvider = GlobalConfig.utilities.vpn.provider[vpnDialog.editIndex]; if (typeof oldProvider === "object" && oldProvider.enabled !== undefined) { newProvider.enabled = oldProvider.enabled; } for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { if (i === vpnDialog.editIndex) { providers.push(newProvider); } else { providers.push(GlobalConfig.utilities.vpn.provider[i]); } } } else { for (let i = 0; i < GlobalConfig.utilities.vpn.provider.length; i++) { providers.push(GlobalConfig.utilities.vpn.provider[i]); } providers.push(newProvider); } GlobalConfig.utilities.vpn.provider = providers; vpnDialog.closeWithAnimation(); } } } } } SequentialAnimation { id: transitionToForm ParallelAnimation { Anim { target: selectionContent property: "opacity" to: 0 type: Anim.EmphasizedSmall } } ScriptAction { script: { vpnDialog.currentState = "form"; } } ParallelAnimation { Anim { target: formContent property: "opacity" to: 1 type: Anim.EmphasizedSmall } } } } }