1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-06 05:25:41 -05:00

Add NetworkManager VPN integration: VpnService + Control Center detail; move to TopBar VPN widget with popout; fix logs and parsing; default top bar item 'vpn' added; minor layout fixes

This commit is contained in:
Jon Rogers
2025-08-30 13:57:47 -04:00
parent e55c97185a
commit 952e5604d9
8 changed files with 595 additions and 1 deletions

View File

@@ -55,7 +55,7 @@ Singleton {
property int mediaSize: 1
property var topBarLeftWidgets: ["launcherButton", "workspaceSwitcher", "focusedWindow"]
property var topBarCenterWidgets: ["music", "clock", "weather"]
property var topBarRightWidgets: ["systemTray", "clipboard", "cpuUsage", "memUsage", "notificationButton", "battery", "controlCenterButton"]
property var topBarRightWidgets: ["systemTray", "clipboard", "cpuUsage", "memUsage", "notificationButton", "battery", "vpn", "controlCenterButton"]
property alias topBarLeftWidgetsModel: leftWidgetsModel
property alias topBarCenterWidgetsModel: centerWidgetsModel
property alias topBarRightWidgetsModel: rightWidgetsModel

View File

@@ -0,0 +1,162 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
implicitHeight: 220
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, Theme.getContentBackgroundAlpha() * 0.6)
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
Row {
spacing: Theme.spacingS
width: parent.width
StyledText {
text: VpnService.connected ? ("Active: " + (VpnService.activeName || "VPN")) : "Active: None"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
Item { width: 10; height: 1 }
Rectangle {
height: 28
radius: 14
color: toggleMouse.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
visible: VpnService.connected || VpnService.profiles.length > 0
width: 80
Row {
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon { name: VpnService.connected ? "link_off" : "link"; size: Theme.fontSizeSmall; color: Theme.surfaceText }
StyledText { text: VpnService.connected ? "Disconnect" : "Connect"; font.pixelSize: Theme.fontSizeSmall; color: Theme.surfaceText; font.weight: Font.Medium }
}
MouseArea {
id: toggleMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: VpnService.toggle()
}
}
}
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
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: 40
radius: Theme.cornerRadius
color: rowMouse.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
border.width: 1
border.color: modelData.uuid === VpnService.activeUuid ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
DankIcon {
name: modelData.uuid === VpnService.activeUuid ? "vpn_lock" : "vpn_key_off"
size: Theme.iconSize - 4
color: modelData.uuid === VpnService.activeUuid ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData.name
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Item { width: 1; height: 1 }
Rectangle {
height: 28
radius: 14
color: actMouse.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
width: 100
StyledText {
anchors.centerIn: parent
text: modelData.uuid === VpnService.activeUuid ? "Disconnect" : "Connect"
font.pixelSize: Theme.fontSizeSmall
color: modelData.uuid === VpnService.activeUuid ? Theme.error : Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
id: actMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (modelData.uuid === VpnService.activeUuid) {
VpnService.disconnect(modelData.uuid)
} else {
VpnService.connect(modelData.uuid)
}
}
}
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
}
}
Item { height: 1; width: 1 }
}
}
}
}

View File

@@ -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",

View File

@@ -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

106
Modules/TopBar/Vpn.qml Normal file
View File

@@ -0,0 +1,106 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Item {
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()
width: Math.max(24, contentRow.implicitWidth + Theme.spacingXS * 2)
height: widgetHeight
Row {
id: contentRow
anchors.fill: parent
anchors.margins: 0
spacing: Theme.spacingXS
Rectangle {
anchors.fill: parent
radius: 6
color: "transparent"
}
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.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
running: VpnService.isBusy
loops: Animation.Infinite
from: 0
to: 360
duration: 900
}
}
Text {
id: label
text: VpnService.connected ? (VpnService.activeName || "VPN") : "VPN"
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: VpnService.connected ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
visible: true
elide: Text.ElideRight
}
}
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: VpnService.connected ? ("VPN Connected • " + (VpnService.activeName || "")) : "VPN Disconnected"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
}
Behavior on opacity {
NumberAnimation { duration: Theme.shortDuration; easing.type: Theme.standardEasing }
}
}
}

View File

@@ -0,0 +1,81 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Widgets
import qs.Common
import qs.Services
import qs.Modules.ControlCenter.Details 1.0 as Details
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: 420
popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 260
triggerX: Screen.width - 400 - 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
}
}
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: 28
StyledText {
text: "VPN"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Details.VpnDetail {
width: parent.width
}
}
}
}
}

195
Services/VpnService.qml Normal file
View File

@@ -0,0 +1,195 @@
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: []
// Active VPN connection (if any)
property string activeUuid: ""
property string activeName: ""
property string activeDevice: ""
property string activeState: "" // activating, activated, deactivating
property bool connected: activeUuid !== "" && activeState === "activated"
// 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: ["nmcli", "-t", "-f", "NAME,UUID,TYPE", "connection", "show"]
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")) {
out.push({ name: parts[0], uuid: parts[1], type: parts[2] })
}
}
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 found = false
for (const line of lines) {
const parts = line.split(':')
if (parts.length >= 5 && (parts[2] === "vpn" || parts[2] === "wireguard")) {
root.activeName = parts[0]
root.activeUuid = parts[1]
root.activeDevice = parts[3]
root.activeState = parts[4]
found = true
break
}
}
if (!found) {
root.activeName = ""
root.activeUuid = ""
root.activeDevice = ""
root.activeState = ""
}
}
}
}
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 (_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 (root.activeUuid && (uuid === undefined || uuid === root.activeUuid)) {
disconnect(root.activeUuid)
} else if (uuid) {
connect(uuid)
} else 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"
}
}
}
}

View File

@@ -176,6 +176,17 @@ ShellRoot {
}
LazyLoader {
id: vpnPopoutLoader
active: false
VpnPopout {
id: vpnPopout
}
}
LazyLoader {
id: powerMenuLoader