diff --git a/Modules/Settings/TopBarTab.qml b/Modules/Settings/TopBarTab.qml index 002d642c..0ea9b86a 100644 --- a/Modules/Settings/TopBarTab.qml +++ b/Modules/Settings/TopBarTab.qml @@ -114,6 +114,12 @@ Item { "description": "Battery level and power management", "icon": "battery_std", "enabled": true + }, { + "id": "vpn", + "text": "VPN", + "description": "VPN status and quick connect", + "icon": "vpn_lock", + "enabled": true }, { "id": "idleInhibitor", "text": "Idle Inhibitor", diff --git a/Modules/TopBar/TopBar.qml b/Modules/TopBar/TopBar.qml index 13a12d9f..4cef86e8 100644 --- a/Modules/TopBar/TopBar.qml +++ b/Modules/TopBar/TopBar.qml @@ -339,6 +339,8 @@ PanelWindow { return DgopService.dgopAvailable case "keyboard_layout_name": return true + case "vpn": + return true default: return false } @@ -390,6 +392,8 @@ PanelWindow { return networkComponent case "keyboard_layout_name": return keyboardLayoutNameComponent + case "vpn": + return vpnComponent default: return null } @@ -1022,6 +1026,35 @@ PanelWindow { } } + Component { + id: vpnComponent + + Vpn { + widgetHeight: root.widgetHeight + barHeight: root.effectiveBarHeight + section: { + if (parent && parent.parent === leftSection) + return "left" + if (parent && parent.parent === rightSection) + return "right" + if (parent && parent.parent === centerSection) + return "center" + return "right" + } + popupTarget: { + vpnPopoutLoader.active = true + return vpnPopoutLoader.item + } + parentScreen: root.screen + onToggleVpnPopup: { + vpnPopoutLoader.active = true + if (vpnPopoutLoader.item) { + vpnPopoutLoader.item.toggle() + } + } + } + } + Component { id: controlCenterButtonComponent diff --git a/Modules/TopBar/Vpn.qml b/Modules/TopBar/Vpn.qml new file mode 100644 index 00000000..ce929484 --- /dev/null +++ b/Modules/TopBar/Vpn.qml @@ -0,0 +1,94 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + // Passed in by TopBar + property int widgetHeight: 28 + property int barHeight: 32 + property string section: "right" + property var popupTarget: null + property var parentScreen: null + + signal toggleVpnPopup() + + readonly property real horizontalPadding: SettingsData.topBarNoBackground ? 0 : Math.max(Theme.spacingXS, Theme.spacingS * (widgetHeight / 30)) + + width: Theme.iconSize + horizontalPadding * 2 + height: widgetHeight + radius: SettingsData.topBarNoBackground ? 0 : Theme.cornerRadius + color: { + if (SettingsData.topBarNoBackground) return "transparent" + const base = clickArea.containsMouse || (popupTarget && popupTarget.shouldBeVisible) ? Theme.primaryPressed : Theme.secondaryHover + return Qt.rgba(base.r, base.g, base.b, base.a * Theme.widgetTransparency) + } + + DankIcon { + id: icon + name: VpnService.isBusy ? "sync" : (VpnService.connected ? "vpn_lock" : "vpn_key_off") + size: Theme.iconSize - 6 + color: VpnService.connected ? Theme.primary : Theme.surfaceText + anchors.centerIn: parent + + RotationAnimation on rotation { + running: VpnService.isBusy + loops: Animation.Infinite + from: 0 + to: 360 + duration: 900 + } + } + + MouseArea { + id: clickArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: { + if (popupTarget && popupTarget.setTriggerPosition) { + var globalPos = mapToGlobal(0, 0) + var currentScreen = parentScreen || Screen + var screenX = currentScreen.x || 0 + var relativeX = globalPos.x - screenX + popupTarget.setTriggerPosition(relativeX, barHeight + Theme.spacingXS, width, section, currentScreen) + } + root.toggleVpnPopup() + } + } + + Rectangle { + id: tooltip + width: Math.max(120, tooltipText.contentWidth + Theme.spacingM * 2) + height: tooltipText.contentHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainer + border.color: Theme.surfaceVariantAlpha + border.width: 1 + visible: clickArea.containsMouse && !(popupTarget && popupTarget.shouldBeVisible) + anchors.bottom: parent.top + anchors.bottomMargin: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + opacity: clickArea.containsMouse ? 1 : 0 + + Text { + id: tooltipText + anchors.centerIn: parent + text: { + if (!VpnService.connected) return "VPN Disconnected" + const names = VpnService.activeNames || [] + if (names.length <= 1) return "VPN Connected • " + (names[0] || "") + return "VPN Connected • " + names[0] + " +" + (names.length - 1) + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + } + + Behavior on opacity { + NumberAnimation { duration: Theme.shortDuration; easing.type: Theme.standardEasing } + } + } +} diff --git a/Modules/TopBar/VpnPopout.qml b/Modules/TopBar/VpnPopout.qml new file mode 100644 index 00000000..446fbce5 --- /dev/null +++ b/Modules/TopBar/VpnPopout.qml @@ -0,0 +1,304 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Widgets +import qs.Common +import qs.Services +// No external details import; content inlined for consistency + +DankPopout { + id: root + + property string triggerSection: "right" + property var triggerScreen: null + + function setTriggerPosition(x, y, width, section, screen) { + triggerX = x + triggerY = y + triggerWidth = width + triggerSection = section + triggerScreen = screen + } + + popupWidth: 360 + popupHeight: Math.min(Screen.height - 100, contentLoader.item ? contentLoader.item.implicitHeight : 260) + triggerX: Screen.width - 380 - Theme.spacingL + triggerY: Theme.barHeight - 4 + SettingsData.topBarSpacing + Theme.spacingS + triggerWidth: 70 + positioning: "center" + WlrLayershell.namespace: "quickshell-vpn" + screen: triggerScreen + shouldBeVisible: false + visible: shouldBeVisible + + content: Component { + Rectangle { + id: content + implicitHeight: contentColumn.height + Theme.spacingL * 2 + color: Theme.popupBackground() + radius: Theme.cornerRadius + border.color: Theme.outlineMedium + border.width: 1 + antialiasing: true + smooth: true + focus: true + + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Escape) { + root.close() + event.accepted = true + } + } + + // Outer subtle shadow rings to match BatteryPopout + Rectangle { + anchors.fill: parent + anchors.margins: -3 + color: "transparent" + radius: parent.radius + 3 + border.color: Qt.rgba(0, 0, 0, 0.05) + border.width: 1 + z: -3 + } + + Rectangle { + anchors.fill: parent + anchors.margins: -2 + color: "transparent" + radius: parent.radius + 2 + border.color: Theme.shadowMedium + border.width: 1 + z: -2 + } + + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: Theme.outlineStrong + border.width: 1 + radius: parent.radius + z: -1 + } + + Column { + id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Item { + width: parent.width + height: 32 + StyledText { + text: "VPN Connections" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + // Close button (matches BatteryPopout) + Rectangle { + width: 32 + height: 32 + radius: 16 + color: closeArea.containsMouse ? Theme.errorHover : "transparent" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + anchors.centerIn: parent + name: "close" + size: Theme.iconSize - 4 + color: closeArea.containsMouse ? Theme.error : Theme.surfaceText + } + + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: root.close() + } + } + } + + // Inlined VPN details + Rectangle { + id: vpnDetail + width: parent.width + implicitHeight: detailsColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceContainerHigh.r, Theme.surfaceContainerHigh.g, Theme.surfaceContainerHigh.b, Theme.getContentBackgroundAlpha() * 0.6) + border.color: Theme.outlineStrong + border.width: 1 + clip: true + + Column { + id: detailsColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + RowLayout { + spacing: Theme.spacingS + width: parent.width + + StyledText { + text: { + if (!VpnService.connected) return "Active: None" + const names = VpnService.activeNames || [] + if (names.length <= 1) return "Active: " + (names[0] || "VPN") + return "Active: " + names[0] + " +" + (names.length - 1) + } + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + } + + Item { Layout.fillWidth: true; height: 1 } + + // Removed Quick Connect for clarity + Item { width: 1; height: 1 } + + // Disconnect all (shown only when any active) + Rectangle { + height: 28 + radius: 14 + color: discAllArea.containsMouse ? Theme.errorHover : Theme.surfaceLight + visible: VpnService.connected + width: 130 + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + border.width: 1 + border.color: Theme.outlineLight + + Row { + anchors.centerIn: parent + spacing: Theme.spacingXS + DankIcon { name: "link_off"; size: Theme.fontSizeSmall; color: Theme.surfaceText } + StyledText { text: "Disconnect"; font.pixelSize: Theme.fontSizeSmall; color: Theme.surfaceText; font.weight: Font.Medium } + } + + MouseArea { + id: discAllArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: VpnService.disconnectAllActive() + } + } + } + + Rectangle { height: 1; width: parent.width; color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) } + + DankFlickable { + width: parent.width + height: 160 + contentHeight: listCol.height + clip: true + + Column { + id: listCol + width: parent.width + spacing: Theme.spacingXS + + Item { + width: parent.width + height: VpnService.profiles.length === 0 ? 120 : 0 + visible: height > 0 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + DankIcon { name: "playlist_remove"; size: 36; color: Theme.surfaceVariantText; anchors.horizontalCenter: parent.horizontalCenter } + StyledText { text: "No VPN profiles found"; font.pixelSize: Theme.fontSizeMedium; color: Theme.surfaceVariantText; anchors.horizontalCenter: parent.horizontalCenter } + StyledText { text: "Add a VPN in NetworkManager"; font.pixelSize: Theme.fontSizeSmall; color: Theme.surfaceVariantText; anchors.horizontalCenter: parent.horizontalCenter } + } + } + + Repeater { + model: VpnService.profiles + delegate: Rectangle { + required property var modelData + width: parent ? parent.width : 300 + height: 50 + radius: Theme.cornerRadius + color: rowArea.containsMouse ? Theme.primaryHoverLight : (VpnService.isActiveUuid(modelData.uuid) ? Theme.primaryPressed : Theme.surfaceLight) + border.width: VpnService.isActiveUuid(modelData.uuid) ? 2 : 1 + border.color: VpnService.isActiveUuid(modelData.uuid) ? Theme.primary : Theme.outlineLight + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + DankIcon { + name: VpnService.isActiveUuid(modelData.uuid) ? "vpn_lock" : "vpn_key_off" + size: Theme.iconSize - 4 + color: VpnService.isActiveUuid(modelData.uuid) ? Theme.primary : Theme.surfaceText + Layout.alignment: Qt.AlignVCenter + } + + Column { + spacing: 2 + Layout.alignment: Qt.AlignVCenter + + StyledText { + text: modelData.name + font.pixelSize: Theme.fontSizeMedium + color: VpnService.isActiveUuid(modelData.uuid) ? Theme.primary : Theme.surfaceText + } + + StyledText { + text: { + if (modelData.type === "wireguard") return "WireGuard" + var svc = modelData.serviceType || "" + if (svc.indexOf("openvpn") !== -1) return "OpenVPN" + if (svc.indexOf("wireguard") !== -1) return "WireGuard (plugin)" + if (svc.indexOf("openconnect") !== -1) return "OpenConnect" + if (svc.indexOf("fortissl") !== -1 || svc.indexOf("forti") !== -1) return "Fortinet" + if (svc.indexOf("strongswan") !== -1) return "IPsec (strongSwan)" + if (svc.indexOf("libreswan") !== -1) return "IPsec (Libreswan)" + if (svc.indexOf("l2tp") !== -1) return "L2TP/IPsec" + if (svc.indexOf("pptp") !== -1) return "PPTP" + if (svc.indexOf("vpnc") !== -1) return "Cisco (vpnc)" + if (svc.indexOf("sstp") !== -1) return "SSTP" + if (svc) { + var parts = svc.split('.') + return parts[parts.length-1] + } + return "VPN" + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + } + } + Item { Layout.fillWidth: true; height: 1 } + } + + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: VpnService.toggle(modelData.uuid) + } + } + } + + Item { height: 1; width: 1 } + } + } + } + } + } + } + } +} diff --git a/Services/VpnService.qml b/Services/VpnService.qml new file mode 100644 index 00000000..8ac67e8f --- /dev/null +++ b/Services/VpnService.qml @@ -0,0 +1,240 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +// Minimal VPN controller backed by NetworkManager (nmcli + D-Bus monitor) +Singleton { + id: root + + // State + property bool available: true + property bool isBusy: false + property string errorMessage: "" + + // Profiles discovered on the system + // [{ name, uuid, type }] + property var profiles: [] + + // Allow multiple active VPNs (set true to allow concurrent connections) + // Default: allow multiple, to align with NetworkManager capability + property bool singleActive: false + + // Active VPN connections (may be multiple) + // Full list and convenience projections + property var activeConnections: [] // [{ name, uuid, device, state }] + property var activeUuids: [] + property var activeNames: [] + // Back-compat single values (first active if present) + property string activeUuid: activeUuids.length > 0 ? activeUuids[0] : "" + property string activeName: activeNames.length > 0 ? activeNames[0] : "" + property string activeDevice: activeConnections.length > 0 ? (activeConnections[0].device || "") : "" + property string activeState: activeConnections.length > 0 ? (activeConnections[0].state || "") : "" + property bool connected: activeUuids.length > 0 + + // Use implicit property notify signals (profilesChanged, activeUuidChanged, etc.) + + Component.onCompleted: initialize() + + function initialize() { + // Start monitoring NetworkManager for changes + nmMonitor.running = true + refreshAll() + } + + function refreshAll() { + listProfiles() + refreshActive() + } + + // Monitor NetworkManager changes and refresh on activity + Process { + id: nmMonitor + command: ["gdbus", "monitor", "--system", "--dest", "org.freedesktop.NetworkManager"] + running: false + + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { + if (line.includes("ActiveConnection") || line.includes("PropertiesChanged") || line.includes("StateChanged")) { + refreshAll() + } + } + } + } + + // Query all VPN profiles + function listProfiles() { + getProfiles.running = true + } + + Process { + id: getProfiles + command: ["bash", "-lc", "nmcli -t -f NAME,UUID,TYPE connection show | while IFS=: read -r name uuid type; do case \"$type\" in vpn) svc=$(nmcli -g vpn.service-type connection show uuid \"$uuid\" 2>/dev/null); echo \"$name:$uuid:$type:$svc\" ;; wireguard) echo \"$name:$uuid:$type:\" ;; *) : ;; esac; done"] + running: false + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().length ? text.trim().split('\n') : [] + const out = [] + for (const line of lines) { + const parts = line.split(':') + if (parts.length >= 3 && (parts[2] === "vpn" || parts[2] === "wireguard")) { + const svc = parts.length >= 4 ? parts[3] : "" + out.push({ name: parts[0], uuid: parts[1], type: parts[2], serviceType: svc }) + } + } + root.profiles = out + } + } + } + + // Query active VPN connection + function refreshActive() { + getActive.running = true + } + + Process { + id: getActive + command: ["nmcli", "-t", "-f", "NAME,UUID,TYPE,DEVICE,STATE", "connection", "show", "--active"] + running: false + stdout: StdioCollector { + onStreamFinished: { + const lines = text.trim().length ? text.trim().split('\n') : [] + let act = [] + for (const line of lines) { + const parts = line.split(':') + if (parts.length >= 5 && (parts[2] === "vpn" || parts[2] === "wireguard")) { + act.push({ name: parts[0], uuid: parts[1], device: parts[3], state: parts[4] }) + } + } + root.activeConnections = act + root.activeUuids = act.map(a => a.uuid).filter(u => !!u) + root.activeNames = act.map(a => a.name).filter(n => !!n) + } + } + } + + function isActiveUuid(uuid) { + return root.activeUuids && root.activeUuids.indexOf(uuid) !== -1 + } + + function _looksLikeUuid(s) { + // Very loose check for UUID pattern + return s && s.indexOf('-') !== -1 && s.length >= 8 + } + + function connect(uuidOrName) { + if (root.isBusy) return + root.isBusy = true + root.errorMessage = "" + if (root.singleActive) { + // Bring down all active VPNs, then bring up the requested one + const isUuid = _looksLikeUuid(uuidOrName) + const escaped = ('' + uuidOrName).replace(/'/g, "'\\''") + const upCmd = isUuid ? `nmcli connection up uuid '${escaped}'` : `nmcli connection up id '${escaped}'` + const script = `set -e\n` + + `nmcli -t -f UUID,TYPE connection show --active | awk -F: '$2 ~ /^(vpn|wireguard)$/ {print $1}' | while read u; do [ -n \"$u\" ] && nmcli connection down uuid \"$u\" || true; done\n` + + upCmd + `\n` + vpnSwitch.command = ["bash", "-lc", script] + vpnSwitch.running = true + } else { + if (_looksLikeUuid(uuidOrName)) { + vpnUp.command = ["nmcli", "connection", "up", "uuid", uuidOrName] + } else { + vpnUp.command = ["nmcli", "connection", "up", "id", uuidOrName] + } + vpnUp.running = true + } + } + + function disconnect(uuidOrName) { + if (root.isBusy) return + root.isBusy = true + root.errorMessage = "" + if (_looksLikeUuid(uuidOrName)) { + vpnDown.command = ["nmcli", "connection", "down", "uuid", uuidOrName] + } else { + vpnDown.command = ["nmcli", "connection", "down", "id", uuidOrName] + } + vpnDown.running = true + } + + function toggle(uuid) { + if (uuid) { + if (isActiveUuid(uuid)) disconnect(uuid) + else connect(uuid) + return + } + if (root.profiles.length > 0) { + connect(root.profiles[0].uuid) + } + } + + Process { + id: vpnUp + running: false + stdout: StdioCollector { + onStreamFinished: { + root.isBusy = false + if (!text.toLowerCase().includes("successfully")) { + root.errorMessage = text.trim() + } + refreshAll() + } + } + onExited: exitCode => { + root.isBusy = false + if (exitCode !== 0 && root.errorMessage === "") { + root.errorMessage = "Failed to connect VPN" + } + } + } + + Process { + id: vpnDown + running: false + stdout: StdioCollector { + onStreamFinished: { + root.isBusy = false + if (!text.toLowerCase().includes("deactivated") && !text.toLowerCase().includes("successfully")) { + root.errorMessage = text.trim() + } + refreshAll() + } + } + onExited: exitCode => { + root.isBusy = false + if (exitCode !== 0 && root.errorMessage === "") { + root.errorMessage = "Failed to disconnect VPN" + } + } + } + + function disconnectAllActive() { + if (root.isBusy) return + root.isBusy = true + const script = `nmcli -t -f UUID,TYPE connection show --active | awk -F: '$2 ~ /^(vpn|wireguard)$/ {print $1}' | while read u; do [ -n \"$u\" ] && nmcli connection down uuid \"$u\" || true; done` + vpnSwitch.command = ["bash", "-lc", script] + vpnSwitch.running = true + } + + // Sequenced down/up using a single shell for exclusive switch + Process { + id: vpnSwitch + running: false + stdout: StdioCollector { + onStreamFinished: { + root.isBusy = false + refreshAll() + } + } + onExited: exitCode => { + root.isBusy = false + if (exitCode !== 0 && root.errorMessage === "") { + root.errorMessage = "Failed to switch VPN" + } + } + } +} diff --git a/shell.qml b/shell.qml index 0c769cb2..72da03d7 100644 --- a/shell.qml +++ b/shell.qml @@ -176,6 +176,17 @@ ShellRoot { } + LazyLoader { + id: vpnPopoutLoader + + active: false + + VpnPopout { + id: vpnPopout + } + + } + LazyLoader { id: powerMenuLoader