From 665557e2fd3dbfa1337e45374407331ebed3b904 Mon Sep 17 00:00:00 2001 From: "minh.trinh" Date: Sat, 18 Jul 2026 15:01:13 +0700 Subject: [PATCH 1/5] feat(openvpn3): add OpenVPN3 VPN management plugin --- openvpn3/BarWidget.qml | 90 ++++++ openvpn3/ControlCenterWidget.qml | 90 ++++++ openvpn3/Main.qml | 494 ++++++++++++++++++++++++++++++ openvpn3/OpenVpnIcon.qml | 53 ++++ openvpn3/Panel.qml | 505 +++++++++++++++++++++++++++++++ openvpn3/README.md | 49 +++ openvpn3/Settings.qml | 131 ++++++++ openvpn3/VpnListItem.qml | 330 ++++++++++++++++++++ openvpn3/i18n/en.json | 127 ++++++++ openvpn3/i18n/vi.json | 127 ++++++++ openvpn3/icons/openvpn.svg | 5 + openvpn3/manifest.json | 32 ++ openvpn3/preview.png | Bin 0 -> 34569 bytes 13 files changed, 2033 insertions(+) create mode 100644 openvpn3/BarWidget.qml create mode 100644 openvpn3/ControlCenterWidget.qml create mode 100644 openvpn3/Main.qml create mode 100644 openvpn3/OpenVpnIcon.qml create mode 100644 openvpn3/Panel.qml create mode 100644 openvpn3/README.md create mode 100644 openvpn3/Settings.qml create mode 100644 openvpn3/VpnListItem.qml create mode 100644 openvpn3/i18n/en.json create mode 100644 openvpn3/i18n/vi.json create mode 100644 openvpn3/icons/openvpn.svg create mode 100644 openvpn3/manifest.json create mode 100644 openvpn3/preview.png diff --git a/openvpn3/BarWidget.qml b/openvpn3/BarWidget.qml new file mode 100644 index 000000000..cca7a70dd --- /dev/null +++ b/openvpn3/BarWidget.qml @@ -0,0 +1,90 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets +import qs.Modules.Bar.Extras + +Item { + id: root + + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property var main: pluginApi?.mainInstance ?? ({}) + + readonly property real connectedCount: root.main.connectedCount ?? 0 + readonly property bool isLoading: root.main.isLoading ?? false + readonly property int configCount: (root.main.configList ?? []).length + readonly property bool hasConfigs: root.configCount > 0 + readonly property string connectedColor: root.cfg.connectedColor ?? defaults.connectedColor ?? "primary" + readonly property string disconnectedColor: root.cfg.disconnectedColor ?? defaults.disconnectedColor ?? "none" + readonly property string displayMode: root.cfg.displayMode ?? defaults.displayMode ?? "onhover" + readonly property bool hideWhenInactive: root.cfg.hideWhenInactive ?? defaults.hideWhenInactive ?? false + readonly property bool isInactive: root.connectedCount === 0 && !root.isLoading + + readonly property bool isHidden: root.hideWhenInactive && root.isInactive + + readonly property string pillIcon: { + if (root.isLoading) return "shield-check" + if (root.connectedCount > 0) return "shield-lock" + if (!root.hasConfigs) return "shield-off" + return "shield" + } + + readonly property string pillText: { + if (root.connectedCount > 0) return root.connectedCount + " " + pluginApi?.tr("bar.active") + if (root.isLoading) return pluginApi?.tr("bar.connecting") + if (!root.hasConfigs) return pluginApi?.tr("bar.noConfigs") + return pluginApi?.tr("bar.disconnected") + } + + opacity: root.isHidden ? 0.0 : 1.0 + implicitWidth: root.isHidden ? 0 : pill.width + implicitHeight: root.isHidden ? 0 : pill.height + + NPopupContextMenu { + id: contextMenu + + model: [{ + "label": pluginApi?.tr("menu.settings"), + "action": "plugin-settings", + "icon": "settings" + }] + onTriggered: (action) => { + contextMenu.close() + PanelService.closeContextMenu(screen) + if (action === "plugin-settings") + BarService.openPluginSettings(screen, pluginApi.manifest) + } + } + + BarPill { + id: pill + + screen: root.screen + oppositeDirection: BarService.getPillDirection(root) + autoHide: false + text: root.pillText + icon: root.pillIcon + customIconColor: Color.resolveColorKeyOptional(root.connectedCount > 0 ? root.connectedColor : root.disconnectedColor) + customTextColor: Color.resolveColorKeyOptional(root.connectedCount > 0 ? root.connectedColor : root.disconnectedColor) + forceOpen: root.displayMode === "alwaysShow" + forceClose: root.displayMode === "alwaysHide" + + onClicked: { + if (pluginApi) + pluginApi.togglePanel(root.screen, root) + } + onRightClicked: { + PanelService.showContextMenu(contextMenu, root, screen) + } + } +} \ No newline at end of file diff --git a/openvpn3/ControlCenterWidget.qml b/openvpn3/ControlCenterWidget.qml new file mode 100644 index 000000000..ac5145719 --- /dev/null +++ b/openvpn3/ControlCenterWidget.qml @@ -0,0 +1,90 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +// Custom control-center button so we can use the OpenVPN brand icon +// (NIconButtonHot only accepts built-in icon names) +Item { + id: root + + property ShellScreen screen + property var pluginApi: null + + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property var main: pluginApi?.mainInstance ?? ({}) + readonly property real connectedCount: main.connectedCount ?? 0 + readonly property bool isLoading: main.isLoading ?? false + readonly property int configCount: (main.configList ?? []).length + readonly property bool hasConfigs: configCount > 0 + readonly property bool isConnected: connectedCount > 0 + readonly property var sessionList: main.sessionList ?? [] + readonly property string connectedColor: cfg.connectedColor ?? defaults.connectedColor ?? "primary" + readonly property string disconnectedColor: cfg.disconnectedColor ?? defaults.disconnectedColor ?? "none" + + readonly property color iconColor: { + const key = isConnected ? connectedColor : disconnectedColor + if (!key || key === "none") + return mouseArea.containsMouse ? Color.mOnHover : Color.mPrimary + return Color.resolveColorKeyOptional(key) ?? Color.mPrimary + } + + function tipText() { + if (isLoading) + return pluginApi?.tr("bar.connecting") + if (!isConnected) { + if (!hasConfigs) + return pluginApi?.tr("bar.noConfigs") + return pluginApi?.tr("bar.disconnected") + } + + const list = sessionList + if (!list || list.length === 0) + return pluginApi?.tr("bar.tooltipDisconnected") + + const lines = [] + for (let i = 0; i < list.length; i++) { + const s = list[i] + const name = (s?.name && s.name.length > 0) ? s.name : (s?.sessionPath || "session") + let statusLabel = pluginApi?.tr("status.connected") + if (s?.isPaused) + statusLabel = pluginApi?.tr("status.paused") + else if (s?.status && s.status.length > 0) + statusLabel = s.status + lines.push(name) + } + return lines.join("\n") + } + + implicitWidth: Style.baseWidgetSize + implicitHeight: Style.baseWidgetSize + + Rectangle { + anchors.fill: parent + radius: Style.radiusM + color: mouseArea.containsMouse ? Color.mHover : "transparent" + + OpenVpnIcon { + anchors.centerIn: parent + pointSize: Style.fontSizeXXL + applyUiScale: false + color: root.iconColor + opacity: root.isLoading ? 0.5 : 1.0 + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + TooltipService.hide() + pluginApi?.togglePanel(screen, root) + } + onEntered: TooltipService.show(root, root.tipText()) + onExited: TooltipService.hide() + } +} diff --git a/openvpn3/Main.qml b/openvpn3/Main.qml new file mode 100644 index 000000000..96accb4b0 --- /dev/null +++ b/openvpn3/Main.qml @@ -0,0 +1,494 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Services.UI + +QtObject { + id: root + + property var pluginSettings: pluginApi?.pluginSettings ?? ({}) + + readonly property var toast: ToastService + readonly property bool showNotifications: pluginSettings.showNotifications ?? true + readonly property int pollInterval: (pluginSettings.pollInterval ?? 5) * 1000 + + property var pluginApi: null + + property var configList: [] + property var sessionList: [] + property var configDetails: ({}) + property var sessionStats: ({}) + property var configDump: ({}) + property var sessionLogs: [] + property bool logStreamActive: false + readonly property int maxLogs: 100 + property real connectedCount: 0 + readonly property bool isLoading: Object.keys(root._pending).length > 0 + readonly property bool panelOpen: pluginApi?.panelOpenScreen != null + + property var _pending: ({}) + + // ==================== Two-tier polling ==================== + // Light timer: configs + sessions only (keeps bar widget updated) + property var _lightTimer: Timer { + interval: root.pollInterval + running: true + repeat: true + onTriggered: root.refresh() + } + + // Heavy timer: stats + details + logs (only when panel is open) + property var _heavyTimer: Timer { + interval: Math.max(root.pollInterval * 3, 15000) + running: root.panelOpen + repeat: true + onTriggered: root.refreshFull() + } + + property bool _heavyRefreshPending: false + + // When panel opens, immediately do a full refresh + onPanelOpenChanged: { + if (panelOpen) { + refreshFull() + if (sessionList.length > 0 && logStreamActive) { + _syncLogStream() + } + } else { + // Panel closed: stop log stream + if (_logProc.running) { + _logProc.running = false + sessionLogs = [] + } + } + } + + property var _configLines: [] + property var _sessionLines: [] + property var _showLines: [] + property var _dumpLines: [] + property var _statsLines: [] + property int _showIndex: -1 + property int _statsIndex: -1 + + // --- Helpers --- + function formatBytes(bytes) { + if (bytes < 1024) return bytes + " B" + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB" + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + " MB" + return (bytes / 1073741824).toFixed(2) + " GB" + } + + function getSessionStats(sessionPath) { + return sessionStats[sessionPath] || null + } + + function getConfigDump(configPath) { + return configDump[configPath] || null + } + + // ==================== Configs list ==================== + property var _configProc: Process { + command: ["openvpn3", "configs-list", "--json"] + running: true + + stdout: SplitParser { + onRead: (line) => { root._configLines.push(line) } + } + + onExited: (exitCode) => { + if (exitCode === 0) { + try { + const raw = JSON.parse(root._configLines.join("")) + const parsed = [] + for (const path in raw) { + const entry = raw[path] + parsed.push({ path: path, name: entry.name || path }) + } + root.configList = parsed + } catch (e) { + root.configList = [] + } + } else { + root.configList = [] + } + root._configLines = [] + root._sessionProc.running = true + } + } + + // ==================== Sessions list ==================== + property var _sessionProc: Process { + command: ["openvpn3", "sessions-list"] + running: false + + stdout: SplitParser { + onRead: (line) => { root._sessionLines.push(line) } + } + + onExited: (exitCode) => { + if (exitCode === 0) { + const parsed = [] + let current = null + for (const line of root._sessionLines) { + const pathMatch = line.match(/Path:\s*(.+)$/) + if (pathMatch) { + current = { sessionPath: pathMatch[1].trim(), configPath: "", name: "", status: "", isPaused: false } + } + const nameMatch = line.match(/Config name:\s*(.+)$/) + if (nameMatch && current) { + current.name = nameMatch[1].trim() + for (const c of root.configList) { + if (c.name === current.name) { + current.configPath = c.path + break + } + } + } + const statusMatch = line.match(/Status:\s*(.+)$/) + if (statusMatch && current) { + current.status = statusMatch[1].trim() + current.isPaused = current.status.toLowerCase().includes("paused") + parsed.push(current) + current = null + } + } + root.sessionList = parsed + } else { + root.sessionList = [] + } + root._sessionLines = [] + root.connectedCount = root.sessionList.length + + // Only run heavy queries when explicitly requested + if (root._heavyRefreshPending) { + root._heavyRefreshPending = false + root._queryPersistentDetails() + root._querySessionStats() + root._syncLogStream() + } + } + } + + // ==================== Persistent details ==================== + property var _showProc: Process { + property string targetPath: "" + running: false + + stdout: SplitParser { + onRead: (line) => { root._showLines.push(line) } + } + + onExited: (exitCode) => { + if (exitCode === 0) { + let persistent = false + for (const line of root._showLines) { + if (line.includes("Persistent config:")) { + persistent = line.includes("Yes") + break + } + } + const details = Object.assign({}, root.configDetails) + details[targetPath] = { persistent: persistent } + root.configDetails = details + } + root._showLines = [] + root._showIndex++ + root._queryNextPersistent() + } + } + + function _queryPersistentDetails() { + _showIndex = 0 + _queryNextPersistent() + } + + function _queryNextPersistent() { + if (_showIndex >= configList.length) return + _showLines = [] + _showProc.targetPath = configList[_showIndex].path + _showProc.command = ["openvpn3", "config-manage", "--path", _showProc.targetPath, "--show"] + _showProc.running = true + } + + function isConfigPersistent(configPath) { + const d = configDetails[configPath] + return d ? d.persistent : false + } + + // ==================== Session Stats ==================== + property var _statsProc: Process { + property string targetSession: "" + running: false + + stdout: SplitParser { + onRead: (line) => { root._statsLines.push(line) } + } + + onExited: (exitCode) => { + if (exitCode === 0) { + try { + const raw = JSON.parse(root._statsLines.join("")) + const stats = Object.assign({}, root.sessionStats) + stats[targetSession] = { + bytesIn: raw.BYTES_IN || 0, + bytesOut: raw.BYTES_OUT || 0, + packetsIn: raw.PACKETS_IN || 0, + packetsOut: raw.PACKETS_OUT || 0, + nReconnect: raw.N_RECONNECT || 0 + } + root.sessionStats = stats + } catch (e) {} + } + root._statsLines = [] + root._statsIndex++ + root._queryNextStats() + } + } + + function _querySessionStats() { + _statsIndex = 0 + _queryNextStats() + } + + function _queryNextStats() { + if (_statsIndex >= sessionList.length) return + _statsLines = [] + const path = sessionList[_statsIndex].sessionPath + _statsProc.running = false + _statsProc.targetSession = path + _statsProc.command = ["openvpn3", "session-stats", "--json", "--path", path] + _statsProc.running = true + } + + // ==================== Config Dump ==================== + property var _dumpProc: Process { + property string targetConfig: "" + running: false + + stdout: SplitParser { + onRead: (line) => { root._dumpLines.push(line) } + } + + onExited: (exitCode) => { + if (exitCode === 0) { + try { + const raw = JSON.parse(root._dumpLines.join("")) + const profile = (raw.profile && raw.profile[0]) || {} + const remote = (profile.remote && profile.remote[0]) || [] + const dumps = Object.assign({}, root.configDump) + dumps[targetConfig] = { + server: remote[0] || "N/A", + port: remote[1] || "N/A", + protocol: remote[2] || "N/A", + cipher: (profile.cipher && profile.cipher[0]) || "N/A", + username: (profile.USERNAME && profile.USERNAME[0]) || "N/A", + device: (profile.dev && profile.dev[0]) || "N/A" + } + root.configDump = dumps + } catch (e) {} + } + root._dumpLines = [] + } + } + + function loadConfigDetails(configPath) { + if (configDump[configPath]) return + _dumpLines = [] + _dumpProc.targetConfig = configPath + _dumpProc.command = ["openvpn3", "config-dump", "--json", "--path", _dumpProc.targetConfig] + _dumpProc.running = true + } + + // ==================== Restart ==================== + property var _restartProc: Process { + property string targetSession: "" + onExited: (exitCode) => { + if (exitCode !== 0 && root.showNotifications) + toast.showError(pluginApi?.tr("errors.restart")) + root._pending = {} + root.refresh() + } + } + + function restartSession(sessionPath) { + _restartProc.targetSession = sessionPath + _restartProc.command = ["openvpn3", "session-manage", "--path", sessionPath, "--restart"] + _restartProc.running = true + } + + // ==================== Connect ==================== + property var _connectProc: Process { + property string targetConfig: "" + onExited: (exitCode) => { + if (exitCode !== 0 && root.showNotifications) + toast.showError(pluginApi?.tr("errors.connect")) + root._pending = {} + root.refresh() + } + } + + // ==================== Disconnect ==================== + property var _disconnectProc: Process { + property string targetPath: "" + onExited: (exitCode) => { + if (exitCode !== 0 && root.showNotifications) + toast.showError(pluginApi?.tr("errors.disconnect")) + root._pending = {} + root.refresh() + } + } + + // ==================== Import ==================== + property var _importProc: Process { + property string importFilePath: "" + property string importName: "" + property bool importPersistent: true + command: ["openvpn3", "config-import"] + onExited: (exitCode) => { + if (exitCode !== 0 && root.showNotifications) + toast.showError(pluginApi?.tr("errors.import")) + root._pending = {} + root.refresh() + } + } + + // ==================== Rename ==================== + property var _renameProc: Process { + property string renameOldPath: "" + property string renameNewName: "" + onExited: (exitCode) => { + if (exitCode !== 0 && root.showNotifications) + toast.showError(pluginApi?.tr("errors.rename")) + root._pending = {} + root.refresh() + } + } + + // ==================== Delete ==================== + property var _deleteProc: Process { + property string targetPath: "" + onExited: (exitCode) => { + if (exitCode !== 0 && root.showNotifications) + toast.showError(pluginApi?.tr("errors.delete")) + root._pending = {} + root.refresh() + } + } + + // ==================== Log Stream ==================== + property var _logProc: Process { + property string targetSession: "" + running: false + + stdout: SplitParser { + onRead: (line) => { + if (line.trim() === "") return + var logs = root.sessionLogs.slice() + logs.unshift({ raw: line.trim() }) + if (logs.length > root.maxLogs) + logs = logs.slice(0, root.maxLogs) + root.sessionLogs = logs + } + } + } + + function _syncLogStream() { + if (logStreamActive && sessionList.length > 0 && panelOpen) { + if (_logProc.targetSession !== sessionList[0].sessionPath || !_logProc.running) { + _logProc.running = false + _logProc.targetSession = sessionList[0].sessionPath + _logProc.command = ["openvpn3", "log", "--session-path", _logProc.targetSession] + _logProc.running = true + sessionLogs = [] + } + } else { + if (_logProc.running) { + _logProc.running = false + sessionLogs = [] + } + } + } + + function clearLogs() { + sessionLogs = [] + } + + // ==================== Public functions ==================== + + // Light refresh: configs + sessions only (for bar widget) + function refresh() { + _configProc.running = true + } + + // Full refresh: configs + sessions + stats + details + logs (for panel) + function refreshFull() { + _heavyRefreshPending = true + _configProc.running = true + } + + function connectTo(configPath) { + const p = Object.assign({}, _pending) + p[configPath] = "connect" + _pending = p + _connectProc.targetConfig = configPath + _connectProc.command = ["openvpn3", "session-start", "--config-path", configPath] + _connectProc.running = true + } + + function disconnectFrom(sessionPath) { + const p = Object.assign({}, _pending) + p[sessionPath] = "disconnect" + _pending = p + _disconnectProc.targetPath = sessionPath + _disconnectProc.command = ["openvpn3", "session-manage", "--path", sessionPath, "--disconnect"] + _disconnectProc.running = true + } + + function importConfig(filePath, name, persistent) { + const p = Object.assign({}, _pending) + p["import"] = "import" + _pending = p + _importProc.importFilePath = filePath + _importProc.importName = name + _importProc.importPersistent = persistent + var cmd = ["openvpn3", "config-import", "--config", filePath, "--name", name] + if (persistent) cmd.push("--persistent") + _importProc.command = cmd + _importProc.running = true + } + + function renameConfig(configPath, newName) { + const p = Object.assign({}, _pending) + p[configPath] = "rename" + _pending = p + _renameProc.renameOldPath = configPath + _renameProc.renameNewName = newName + _renameProc.command = ["openvpn3", "config-manage", "--path", configPath, "--rename", newName] + _renameProc.running = true + } + + function deleteConfig(configPath) { + const p = Object.assign({}, _pending) + p[configPath] = "delete" + _pending = p + _deleteProc.targetPath = configPath + _deleteProc.command = ["openvpn3", "config-remove", "--path", configPath, "--force"] + _deleteProc.running = true + } + + function isPending(path) { + return path in _pending + } + + function isSessionActive(configPath) { + for (const s of sessionList) { + if (s.configPath === configPath) return true + } + return false + } + + Component.onCompleted: { + Logger.i("OpenVPN3", "Started") + } +} diff --git a/openvpn3/OpenVpnIcon.qml b/openvpn3/OpenVpnIcon.qml new file mode 100644 index 000000000..6e19ddd4a --- /dev/null +++ b/openvpn3/OpenVpnIcon.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Effects +import qs.Commons + +Item { + id: root + + property real pointSize: Style.fontSizeL + property bool applyUiScale: true + property color color: Color.mOnSurface + // Diagonal strike when disconnected (keep off on tiny bar icons) + property bool crossed: false + + // Whole-pixel size avoids half-pixel blur / MultiEffect shimmer + readonly property int px: Math.max(1, Math.round(applyUiScale ? root.pointSize * Style.uiScaleRatio : root.pointSize)) + + implicitWidth: px + implicitHeight: px + width: px + height: px + clip: true + + Image { + id: iconImage + anchors.centerIn: parent + width: root.px + height: root.px + source: Qt.resolvedUrl("icons/openvpn.svg") + sourceSize: Qt.size(root.px * 2, root.px * 2) + fillMode: Image.PreserveAspectFit + smooth: true + asynchronous: false + + layer.enabled: true + layer.effect: MultiEffect { + colorization: 1.0 + colorizationColor: root.color + } + } + + // Thin, clipped strike — only for larger sizes (panel / control center) + Rectangle { + visible: root.crossed && root.px >= 16 + anchors.centerIn: parent + width: parent.width * 1.05 + height: Math.max(1, Math.round(parent.height * 0.08)) + radius: height / 2 + color: root.color + rotation: -45 + opacity: 0.85 + antialiasing: true + } +} diff --git a/openvpn3/Panel.qml b/openvpn3/Panel.qml new file mode 100644 index 000000000..09b8eac7e --- /dev/null +++ b/openvpn3/Panel.qml @@ -0,0 +1,505 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets +import qs.Services.UI + +Item { + id: root + + property var pluginApi: null + property ShellScreen screen + readonly property var geometryPlaceholder: panelContainer + readonly property bool allowAttach: true + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property var main: pluginApi?.mainInstance ?? null + readonly property var configList: main?.configList ?? [] + readonly property var sessionList: main?.sessionList ?? [] + property real contentPreferredWidth: Math.round(400 * Style.uiScaleRatio) + property real contentPreferredHeight: Math.min(600, mainColumn.implicitHeight + Style.marginL * 2) + + property bool showImport: false + property bool showLogs: false + property string importFilePath: "" + property string importName: "" + property bool importPersistent: cfg.defaultPersistent ?? defaults.defaultPersistent ?? true + + Component.onCompleted: { + if (main) { + main.logStreamActive = true + main.refreshFull() + } + } + + Component.onDestruction: { + if (main) main.logStreamActive = false + } + + Rectangle { + id: panelContainer + anchors.fill: parent + color: "transparent" + + ColumnLayout { + id: mainColumn + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + // --- Header --- + NBox { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(header.implicitHeight + Style.marginM * 2 + 1) + + ColumnLayout { + id: header + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + OpenVpnIcon { + Layout.alignment: Qt.AlignVCenter + pointSize: Style.fontSizeXXL + applyUiScale: false + color: Color.mPrimary + } + + NLabel { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + label: pluginApi?.tr("panel.title") + } + + NIconButton { + Layout.alignment: Qt.AlignVCenter + icon: "plus" + tooltipText: pluginApi?.tr("panel.importConfig") + baseSize: Style.baseWidgetSize * 0.8 + enabled: true + onClicked: root.showImport = !root.showImport + } + + NIconButton { + Layout.alignment: Qt.AlignVCenter + icon: "file-text" + tooltipText: pluginApi?.tr("panel.logs") + baseSize: Style.baseWidgetSize * 0.8 + colorBg: root.showLogs ? Qt.alpha(Color.mPrimary, 0.2) : "transparent" + onClicked: { + root.showLogs = !root.showLogs + if (main) main.logStreamActive = root.showLogs + } + } + + NIconButton { + Layout.alignment: Qt.AlignVCenter + icon: "refresh" + tooltipText: pluginApi?.tr("panel.refresh") + baseSize: Style.baseWidgetSize * 0.8 + enabled: true + onClicked: { + if (main) main.refresh() + } + } + + NIconButton { + Layout.alignment: Qt.AlignVCenter + icon: "close" + tooltipText: pluginApi?.tr("panel.close") + baseSize: Style.baseWidgetSize * 0.8 + onClicked: pluginApi.closePanel(pluginApi.panelOpenScreen) + } + } + } + } + + // --- Import Section --- + NBox { + Layout.fillWidth: true + visible: root.showImport + Layout.preferredHeight: Math.round(importColumn.implicitHeight + Style.marginM * 2 + 1) + + ColumnLayout { + id: importColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + NLabel { + label: pluginApi?.tr("import.title") + Layout.fillWidth: true + } + + NTextInput { + id: importNameInput + Layout.fillWidth: true + label: pluginApi?.tr("import.nameLabel") + placeholderText: pluginApi?.tr("import.namePlaceholder") + text: "" + onTextChanged: root.importName = text + } + + NTextInputButton { + id: importFileInput + Layout.fillWidth: true + label: pluginApi?.tr("import.fileLabel") + placeholderText: pluginApi?.tr("import.filePlaceholder") + text: "" + buttonIcon: "filepicker-folder" + buttonTooltip: pluginApi?.tr("tooltip.browse") + onInputTextChanged: (text) => root.importFilePath = text + onButtonClicked: filePicker.open() + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NText { + text: pluginApi?.tr("import.persistent") + pointSize: Style.fontSizeS + color: Color.mOnSurface + Layout.fillWidth: true + } + + NToggle { + checked: root.importPersistent + onToggled: root.importPersistent = checked + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NButton { + text: pluginApi?.tr("import.importBtn") + icon: "plus" + enabled: root.importFilePath.trim() !== "" && root.importName.trim() !== "" + onClicked: { + if (main) main.importConfig(root.importFilePath.trim(), root.importName.trim(), root.importPersistent) + root.showImport = false + root.importFilePath = "" + root.importName = "" + importNameInput.text = "" + importFileInput.text = "" + root.importPersistent = cfg.defaultPersistent ?? defaults.defaultPersistent ?? true + } + } + + NButton { + text: pluginApi?.tr("import.cancel") + outlined: true + onClicked: { + root.showImport = false + root.importFilePath = "" + root.importName = "" + importNameInput.text = "" + importFileInput.text = "" + root.importPersistent = cfg.defaultPersistent ?? defaults.defaultPersistent ?? true + } + } + } + } + } + + // --- Main content --- + NScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + reserveScrollbarSpace: false + + ColumnLayout { + anchors.fill: parent + spacing: Style.marginM + + // --- Connected section --- + NBox { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(activeColumn.implicitHeight + Style.marginXL) + visible: root.sessionList.length > 0 + + ColumnLayout { + id: activeColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + NLabel { + label: pluginApi?.tr("status.connected") + Layout.fillWidth: true + Layout.leftMargin: Style.marginS + } + + Repeater { + model: root.sessionList + + VpnListItem { + pluginApi: root.pluginApi + name: modelData.name + configPath: modelData.configPath + sessionPath: modelData.sessionPath + isConnected: true + isLoading: main?.isPending(modelData.sessionPath) ?? false + isPersistent: main?.configDetails[modelData.configPath]?.persistent ?? false + isPaused: modelData.isPaused ?? false + stats: main && main.sessionStats ? main.sessionStats[modelData.sessionPath] ?? null : null + onButtonClicked: { + if (!main) return + main.disconnectFrom(modelData.sessionPath) + } + onRenameRequested: (path, newName) => { + if (!main) return + main.renameConfig(path, newName) + } + onDeleteRequested: (path) => { + if (!main) return + main.deleteConfig(path) + } + onRestartRequested: (sessionPath) => { + if (!main) return + main.restartSession(sessionPath) + } + } + } + } + } + + // --- Disconnected section --- + NBox { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(inactiveColumn.implicitHeight + Style.marginXL) + visible: { + let count = 0 + for (const c of root.configList) { + if (!main?.isSessionActive(c.path)) + count++ + } + return count > 0 + } + + ColumnLayout { + id: inactiveColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + NLabel { + label: pluginApi?.tr("status.disconnected") + Layout.fillWidth: true + Layout.leftMargin: Style.marginS + } + + Repeater { + model: { + const inactive = [] + for (const c of root.configList) { + if (!main?.isSessionActive(c.path)) + inactive.push(c) + } + return inactive + } + + VpnListItem { + pluginApi: root.pluginApi + name: modelData.name + configPath: modelData.path + sessionPath: "" + isConnected: false + isLoading: main?.isPending(modelData.path) ?? false + isPersistent: main?.configDetails[modelData.path]?.persistent ?? false + details: main?.configDump[modelData.path] ?? null + onButtonClicked: { + if (!main) return + main.connectTo(modelData.path) + } + onRenameRequested: (path, newName) => { + if (!main) return + main.renameConfig(path, newName) + } + onDeleteRequested: (path) => { + if (!main) return + main.deleteConfig(path) + } + onShowDetailsRequested: (path) => { + if (!main) return + main.loadConfigDetails(path) + } + } + } + } + } + + // --- Empty state --- + NBox { + visible: root.configList.length < 1 + Layout.fillWidth: true + Layout.preferredHeight: Math.round(emptyColumn.implicitHeight + Style.marginM * 2 + 1) + + ColumnLayout { + id: emptyColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginL + + Item { Layout.fillHeight: true } + + OpenVpnIcon { + pointSize: Style.fontSizeXXL + applyUiScale: false + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: pluginApi?.tr("panel.noConfigs") + pointSize: Style.fontSizeL + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: pluginApi?.tr("panel.clickToImport") + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NButton { + text: pluginApi?.tr("panel.refresh") + icon: "refresh" + Layout.alignment: Qt.AlignHCenter + onClicked: { + if (main) main.refresh() + } + } + + Item { Layout.fillHeight: true } + } + } + + // --- Logs section --- + NBox { + visible: root.showLogs + Layout.fillWidth: true + Layout.preferredHeight: Math.round(logsColumn.implicitHeight + Style.marginM * 2 + 1) + + ColumnLayout { + id: logsColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: "file-text" + pointSize: Style.fontSizeM + color: Color.mOnSurfaceVariant + } + + NText { + text: pluginApi?.tr("panel.logs") + pointSize: Style.fontSizeS + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + } + + NBox { Layout.fillWidth: true } + + NText { + text: (main?.sessionLogs?.length ?? 0) + " " + pluginApi?.tr("panel.lines") + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + } + + NIconButton { + icon: "trash" + tooltipText: pluginApi?.tr("panel.clearLogs") + baseSize: Style.baseWidgetSize * 0.6 + onClicked: { + if (main) main.clearLogs() + } + } + } + + NScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredHeight: 200 + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + reserveScrollbarSpace: false + + Column { + width: parent.width + spacing: Style.marginXS + + Repeater { + model: main?.sessionLogs ?? [] + + delegate: NText { + width: parent ? parent.width : 0 + text: modelData.raw + pointSize: Style.fontSizeXXS + color: _logColor(modelData.raw) + wrapMode: Text.Wrap + font.family: "monospace" + } + } + } + } + } + } + } + } + } + } + + function _logColor(raw) { + if (raw.match(/ERR|ERROR|FAIL|FATAL/i)) return Color.mError + if (raw.match(/WARN/i)) return Color.mSurfaceVariant + if (raw.match(/INIT|SUCCESS|CONNECTED/i)) return Color.mPrimary + return Color.mOnSurfaceVariant + } + + Connections { + target: root + function onImportFilePathChanged() { + if (importFileInput) importFileInput.text = root.importFilePath + } + function onImportNameChanged() { + if (importNameInput) importNameInput.text = root.importName + } + } + + NFilePicker { + id: filePicker + title: pluginApi?.tr("import.title") + selectionMode: "files" + nameFilters: ["*.ovpn", "*.conf"] + initialPath: Quickshell.env("HOME") || "/home" + onAccepted: (paths) => { + if (paths.length > 0) { + root.importFilePath = paths[0] + if (root.importName.trim() === "") { + const fileName = paths[0].split("/").pop() + const baseName = fileName.replace(/\.(ovpn|conf)$/, "") + root.importName = baseName + importNameInput.text = baseName + } + } + } + } +} \ No newline at end of file diff --git a/openvpn3/README.md b/openvpn3/README.md new file mode 100644 index 000000000..ed436eb15 --- /dev/null +++ b/openvpn3/README.md @@ -0,0 +1,49 @@ +# OpenVPN3 Plugin + +Manage OpenVPN3 VPN connections from the Noctalia bar and control center. + +## Features + +- **OpenVPN brand icon** — Custom OpenVPN logo on the bar, control center, and panel (colorized by theme) +- **Real-time status** — Icon color indicates connection state +- **One-click connect/disconnect** — Click to toggle, with loading spinner inside button +- **Session stats** — View download/upload bytes, packets, and reconnect count +- **Restart sessions** — Hover over active sessions to restart +- **Config details** — Double-click disconnected items to see server/port/protocol +- **Log streaming** — Stream VPN logs in real-time +- **Import configs** — Import .ovpn files with custom names and persistence toggle +- **Rename/Delete** — Manage configs with confirmation flows +- **Persistent vs Temporary** — Lock icon for persistent, clock icon for temporary configs +- **Hide when inactive** — Optionally hide the bar widget when VPN is disconnected +- **Two-tier polling** — Light polling keeps the bar updated; heavy polling (stats, logs) only runs when panel is open + +## Requirements + +- `openvpn3` CLI installed and in PATH +- OpenVPN3 D-Bus service running + +## Configuration + +| Setting | Description | Default | +|---------|-------------|---------| +| Display Mode | When to show label (always, onhover, never) | onhover | +| Connected Color | Icon color when connected | primary | +| Disconnected Color | Icon color when disconnected | none | +| Poll Interval | Status check interval (seconds) | 5 | +| Show Notifications | Toast on errors | true | +| Default Persistent | Default for new imports | true | +| Hide When Inactive | Hide bar widget when VPN is disconnected | false | + +## Usage + +1. Add `"plugin:openvpn3"` to your bar widgets in `settings.json` +2. Click the shield icon to open the VPN panel +3. Click a config to connect/disconnect +4. Right-click or double-click for more options + +## Tags + +- Bar +- Panel +- Network +- Utility \ No newline at end of file diff --git a/openvpn3/Settings.qml b/openvpn3/Settings.qml new file mode 100644 index 000000000..43f985828 --- /dev/null +++ b/openvpn3/Settings.qml @@ -0,0 +1,131 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + spacing: Style.marginL + + property var pluginApi: null + readonly property var cfg: pluginApi?.pluginSettings || ({}) + readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + + property string editDisplayMode: cfg.displayMode ?? defaults.displayMode ?? "onhover" + property string editConnectedColor: cfg.connectedColor ?? defaults.connectedColor ?? "primary" + property string editDisconnectedColor: cfg.disconnectedColor ?? defaults.disconnectedColor ?? "none" + property int editPollInterval: cfg.pollInterval ?? defaults.pollInterval ?? 5 + property bool editShowNotifications: cfg.showNotifications ?? defaults.showNotifications ?? true + property bool editDefaultPersistent: cfg.defaultPersistent ?? defaults.defaultPersistent ?? true + property bool editHideWhenInactive: cfg.hideWhenInactive ?? defaults.hideWhenInactive ?? false + + readonly property var displayModeModel: [{ + "key": "onhover", + "name": pluginApi?.tr("settings.displayMode.onhover") + }, { + "key": "alwaysShow", + "name": pluginApi?.tr("settings.displayMode.alwaysShow") + }, { + "key": "alwaysHide", + "name": pluginApi?.tr("settings.displayMode.alwaysHide") + }] + + readonly property var pollIntervalModel: [{ + "key": "2", + "name": pluginApi?.tr("settings.pollInterval.2s") + }, { + "key": "5", + "name": pluginApi?.tr("settings.pollInterval.5s") + }, { + "key": "10", + "name": pluginApi?.tr("settings.pollInterval.10s") + }, { + "key": "30", + "name": pluginApi?.tr("settings.pollInterval.30s") + }] + + function saveSettings() { + pluginApi.pluginSettings.displayMode = root.editDisplayMode + pluginApi.pluginSettings.connectedColor = root.editConnectedColor + pluginApi.pluginSettings.disconnectedColor = root.editDisconnectedColor + pluginApi.pluginSettings.pollInterval = root.editPollInterval + pluginApi.pluginSettings.showNotifications = root.editShowNotifications + pluginApi.pluginSettings.defaultPersistent = root.editDefaultPersistent + pluginApi.pluginSettings.hideWhenInactive = root.editHideWhenInactive + pluginApi.saveSettings() + Logger.i("OpenVPN3", "Settings saved") + } + + NLabel { + label: pluginApi?.tr("settings.bar.label") + description: pluginApi?.tr("settings.bar.description") + Layout.fillWidth: true + } + + NComboBox { + label: pluginApi?.tr("settings.displayMode.label") + description: pluginApi?.tr("settings.displayMode.description") + minimumWidth: 200 + model: root.displayModeModel + currentKey: root.editDisplayMode + onSelected: (key) => root.editDisplayMode = key + } + + NLabel { + label: pluginApi?.tr("settings.colors.label") + description: pluginApi?.tr("settings.colors.description") + Layout.fillWidth: true + } + + NColorChoice { + label: pluginApi?.tr("settings.colors.connected.label") + description: pluginApi?.tr("settings.colors.connected.description") + currentKey: root.editConnectedColor + onSelected: (key) => root.editConnectedColor = key + } + + NColorChoice { + label: pluginApi?.tr("settings.colors.disconnected.label") + description: pluginApi?.tr("settings.colors.disconnected.description") + currentKey: root.editDisconnectedColor + onSelected: (key) => root.editDisconnectedColor = key + } + + NLabel { + label: pluginApi?.tr("settings.behavior.label") + description: pluginApi?.tr("settings.behavior.description") + Layout.fillWidth: true + } + + NComboBox { + label: pluginApi?.tr("settings.pollInterval.label") + description: pluginApi?.tr("settings.pollInterval.description") + minimumWidth: 200 + model: root.pollIntervalModel + currentKey: String(root.editPollInterval) + defaultValue: "5" + onSelected: (key) => root.editPollInterval = parseInt(key) + } + + NToggle { + label: pluginApi?.tr("settings.showNotifications.label") + description: pluginApi?.tr("settings.showNotifications.description") + checked: root.editShowNotifications + onToggled: root.editShowNotifications = checked + } + + NToggle { + label: pluginApi?.tr("settings.defaultPersistent.label") + description: pluginApi?.tr("settings.defaultPersistent.description") + checked: root.editDefaultPersistent + onToggled: root.editDefaultPersistent = checked + } + + NToggle { + label: pluginApi?.tr("settings.hideWhenInactive.label") + description: pluginApi?.tr("settings.hideWhenInactive.description") + checked: root.editHideWhenInactive + onToggled: root.editHideWhenInactive = checked + } +} \ No newline at end of file diff --git a/openvpn3/VpnListItem.qml b/openvpn3/VpnListItem.qml new file mode 100644 index 000000000..3fa5c2b5d --- /dev/null +++ b/openvpn3/VpnListItem.qml @@ -0,0 +1,330 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Widgets +import qs.Services.UI + +NBox { + id: root + + property string name: "" + property string configPath: "" + property string sessionPath: "" + property bool isConnected: false + property bool isLoading: false + property bool isPersistent: false + property bool isPaused: false + property var stats: null + property var details: null + property bool showDetails: false + + signal buttonClicked + signal renameRequested(string configPath, string currentName) + signal deleteRequested(string configPath) + signal restartRequested(string sessionPath) + signal showDetailsRequested(string configPath) + + property bool editing: false + property bool confirmingDelete: false + property string editName: name + property bool hovered: false + property var pluginApi: null + + Layout.fillWidth: true + Layout.leftMargin: Style.marginXS + Layout.rightMargin: Style.marginXS + implicitHeight: Math.round(netColumn.implicitHeight + Style.marginXL) + + color: root.isConnected ? Qt.alpha(Color.mPrimary, 0.15) : Color.mSurface + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onContainsMouseChanged: root.hovered = containsMouse + acceptedButtons: Qt.NoButton + } + + ColumnLayout { + id: netColumn + width: parent.width - Style.marginXL + x: Style.marginM + y: Style.marginM + spacing: Style.marginS + + // --- Main row --- + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + Layout.alignment: Qt.AlignVCenter + icon: root.isConnected ? (root.isPaused ? "shield-pause" : "shield-lock") : "shield" + pointSize: Style.fontSizeXXL + color: root.isConnected ? Color.mPrimary : Color.mOnSurface + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.minimumWidth: 0 + spacing: Style.marginXS + + NText { + visible: !root.editing + text: root.name + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + } + + NTextInput { + id: renameInput + visible: root.editing + Layout.fillWidth: true + text: root.editName + placeholderText: pluginApi?.tr("import.namePlaceholder") + onAccepted: root._confirmRename() + } + + RowLayout { + Layout.alignment: Qt.AlignLeft + spacing: Style.marginXS + + NText { + text: root.isConnected ? (root.isPaused ? pluginApi?.tr("status.paused") : pluginApi?.tr("status.connected")) : pluginApi?.tr("status.disconnected") + pointSize: Style.fontSizeXXS + color: root.isConnected ? Color.mPrimary : Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignLeft + } + + NIcon { + visible: !root.isConnected && !root.isPersistent + Layout.alignment: Qt.AlignVCenter + icon: "clock" + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + onEntered: TooltipService.show(parent, pluginApi?.tr("tooltip.temporary")) + onExited: TooltipService.hide() + } + } + } + } + + RowLayout { + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + spacing: Style.marginS + + // Hover action buttons + NIconButton { + visible: root.isConnected && !root.editing && !root.confirmingDelete && root.hovered + icon: "refresh" + tooltipText: pluginApi?.tr("actions.restart") + baseSize: Style.baseWidgetSize * 0.7 + onClicked: root.restartRequested(root.sessionPath) + } + + NIconButton { + visible: !root.isConnected && !root.editing && !root.confirmingDelete && root.hovered + icon: "pencil" + tooltipText: pluginApi?.tr("actions.rename") + baseSize: Style.baseWidgetSize * 0.7 + onClicked: { + root.editing = true + renameInput.text = root.name + renameInput.forceActiveFocus() + } + } + + NIconButton { + visible: !root.isConnected && !root.editing && !root.confirmingDelete && root.hovered + icon: "trash" + tooltipText: pluginApi?.tr("actions.delete") + baseSize: Style.baseWidgetSize * 0.7 + colorBg: Qt.alpha(Color.mError, 0.15) + onClicked: root.confirmingDelete = true + } + + // Rename confirm/cancel + NIconButton { + visible: root.editing + icon: "check" + tooltipText: pluginApi?.tr("actions.confirm") + baseSize: Style.baseWidgetSize * 0.7 + colorBg: Qt.alpha(Color.mPrimary, 0.2) + onClicked: root._confirmRename() + } + + NIconButton { + visible: root.editing + icon: "x" + tooltipText: pluginApi?.tr("actions.cancel") + baseSize: Style.baseWidgetSize * 0.7 + onClicked: { + root.editing = false + renameInput.text = root.name + } + } + + // Delete confirm/cancel + NIconButton { + visible: root.confirmingDelete + icon: "trash" + tooltipText: pluginApi?.tr("actions.confirmDelete") + baseSize: Style.baseWidgetSize * 0.7 + colorBg: Qt.alpha(Color.mError, 0.3) + colorFg: Color.mError + onClicked: { + root.confirmingDelete = false + root.deleteRequested(root.configPath) + } + } + + NIconButton { + visible: root.confirmingDelete + icon: "x" + tooltipText: pluginApi?.tr("actions.cancel") + baseSize: Style.baseWidgetSize * 0.7 + onClicked: root.confirmingDelete = false + } + + // Disconnect button + NButton { + visible: root.isConnected + text: pluginApi?.tr("actions.disconnect") + outlined: !hovered + fontSize: Style.fontSizeS + backgroundColor: Color.mError + onClicked: root.buttonClicked() + } + + // Connect button — text stays, spinner overlays when loading + NButton { + visible: !root.isConnected && !root.editing + text: pluginApi?.tr("actions.connect") + outlined: !hovered + fontSize: Style.fontSizeS + enabled: !root.isLoading + onClicked: root.buttonClicked() + + NBusyIndicator { + anchors.centerIn: parent + visible: root.isLoading + running: visible + color: Color.mPrimary + size: Style.baseWidgetSize * 0.4 + } + } + } + } + + // --- Stats row (connected sessions only) --- + RowLayout { + visible: root.isConnected && root.stats !== null + spacing: Style.marginM + Layout.fillWidth: true + Layout.leftMargin: Style.fontSizeXXL + Style.marginS // indent under title column + + NText { + text: pluginApi?.tr("stats.download") + " " + (root.stats ? formatBytes(root.stats.bytesIn) : "0 B") + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignLeft + } + + NText { + text: pluginApi?.tr("stats.upload") + " " + (root.stats ? formatBytes(root.stats.bytesOut) : "0 B") + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignLeft + } + + NText { + text: pluginApi?.tr("stats.packets") + " " + (root.stats ? root.stats.packetsIn + "/" + root.stats.packetsOut : "0/0") + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignLeft + } + + NText { + visible: root.stats && root.stats.nReconnect > 0 + text: pluginApi?.tr("stats.reconnect") + " " + (root.stats ? root.stats.nReconnect : 0) + pointSize: Style.fontSizeXXS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignLeft + } + + Item { Layout.fillWidth: true } + } + + // --- Config details row (disconnected, double-click to expand) --- + ColumnLayout { + visible: root.showDetails && !root.isConnected && root.details !== null + spacing: Style.marginXS + Layout.fillWidth: true + + Repeater { + model: [ + { label: pluginApi?.tr("details.server"), value: root.details ? root.details.server : "" }, + { label: pluginApi?.tr("details.port"), value: root.details ? root.details.port : "" }, + { label: pluginApi?.tr("details.protocol"), value: root.details ? root.details.protocol : "" }, + { label: pluginApi?.tr("details.cipher"), value: root.details ? root.details.cipher : "" }, + { label: pluginApi?.tr("details.device"), value: root.details ? root.details.device : "" }, + { label: pluginApi?.tr("details.username"), value: root.details ? root.details.username : "" } + ] + delegate: RowLayout { + Layout.fillWidth: true + NText { + text: modelData.label + ":" + pointSize: Style.fontSizeXXS + font.weight: Style.fontWeightMedium + color: Color.mOnSurfaceVariant + Layout.preferredWidth: 80 + } + NText { + text: modelData.value + pointSize: Style.fontSizeXXS + color: Color.mOnSurface + Layout.fillWidth: true + } + } + } + } + } + + function formatBytes(bytes) { + if (bytes < 1024) return bytes + " B" + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB" + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + " MB" + return (bytes / 1073741824).toFixed(2) + " GB" + } + + function _confirmRename() { + const newName = renameInput.text.trim() + if (newName !== "" && newName !== root.name) { + root.renameRequested(root.configPath, newName) + } + root.editing = false + } + + MouseArea { + anchors.fill: parent + visible: !root.isConnected && !root.editing && !root.confirmingDelete + acceptedButtons: Qt.LeftButton + onDoubleClicked: { + root.showDetails = !root.showDetails + if (root.showDetails) root.showDetailsRequested(root.configPath) + } + z: -1 + } +} \ No newline at end of file diff --git a/openvpn3/i18n/en.json b/openvpn3/i18n/en.json new file mode 100644 index 000000000..4d9b67b93 --- /dev/null +++ b/openvpn3/i18n/en.json @@ -0,0 +1,127 @@ +{ + "plugin": { + "name": "OpenVPN3", + "tooltip": "OpenVPN3 VPN" + }, + "bar": { + "active": "active", + "connecting": "Connecting...", + "noConfigs": "No Configs", + "disconnected": "VPN Off" + }, + "panel": { + "title": "OpenVPN3", + "importConfig": "Import Config", + "logs": "Logs", + "refresh": "Refresh", + "close": "Close", + "noConfigs": "No OpenVPN3 configurations found", + "clickToImport": "Click + to import a config file", + "lines": "lines", + "clearLogs": "Clear logs" + }, + "import": { + "title": "Import Config", + "nameLabel": "Config Name", + "namePlaceholder": "Config name", + "fileLabel": "Config File", + "filePlaceholder": "Select .ovpn file", + "persistent": "Persistent", + "importBtn": "Import", + "cancel": "Cancel" + }, + "status": { + "connected": "Connected", + "connecting": "Connecting...", + "disconnected": "Disconnected", + "paused": "Paused" + }, + "actions": { + "connect": "Connect", + "disconnect": "Disconnect", + "restart": "Restart", + "rename": "Rename", + "delete": "Delete", + "confirm": "Confirm", + "cancel": "Cancel", + "confirmDelete": "Confirm delete" + }, + "stats": { + "download": "↓", + "upload": "↑", + "packets": "PKT:", + "reconnect": "Re:" + }, + "details": { + "server": "Server", + "port": "Port", + "protocol": "Protocol", + "cipher": "Cipher", + "device": "Device", + "username": "Username" + }, + "settings": { + "bar": { + "label": "Bar", + "description": "Appearance in the status bar" + }, + "displayMode": { + "label": "Display Mode", + "description": "When to show the VPN status in the bar", + "onhover": "On Hover", + "alwaysShow": "Always Show", + "alwaysHide": "Always Hide" + }, + "colors": { + "label": "Colors", + "description": "Customize icon and text colors", + "connected": { + "label": "Connected", + "description": "Color when VPN is active" + }, + "disconnected": { + "label": "Disconnected", + "description": "Color when VPN is inactive" + } + }, + "behavior": { + "label": "Behavior", + "description": "Polling and notification settings" + }, + "pollInterval": { + "label": "Poll Interval", + "description": "How often to check VPN status", + "2s": "2s (Fast)", + "5s": "5s (Default)", + "10s": "10s (Balanced)", + "30s": "30s (Power Save)" + }, + "showNotifications": { + "label": "Show Notifications", + "description": "Show toast on connection errors" + }, + "defaultPersistent": { + "label": "Default Persistent", + "description": "Default value for persistent when importing configs" + }, + "hideWhenInactive": { + "label": "Hide When Inactive", + "description": "Hide the bar widget when VPN is disconnected" + } + }, + "menu": { + "settings": "Settings" + }, + "errors": { + "restart": "Failed to restart", + "connect": "Failed to connect", + "disconnect": "Failed to disconnect", + "import": "Failed to import config", + "rename": "Failed to rename config", + "delete": "Failed to delete config" + }, + "tooltip": { + "temporary": "Temporary (removed on restart)", + "browse": "Browse" + } +} diff --git a/openvpn3/i18n/vi.json b/openvpn3/i18n/vi.json new file mode 100644 index 000000000..b358a925b --- /dev/null +++ b/openvpn3/i18n/vi.json @@ -0,0 +1,127 @@ +{ + "plugin": { + "name": "OpenVPN3", + "tooltip": "VPN OpenVPN3" + }, + "bar": { + "active": "active", + "connecting": "Đang kết nối...", + "noConfigs": "Không có cấu hình", + "disconnected": "VPN tắt" + }, + "panel": { + "title": "OpenVPN3", + "importConfig": "Nhập cấu hình", + "logs": "Logs", + "refresh": "Làm mới", + "close": "Đóng", + "noConfigs": "Không tìm thấy cấu hình OpenVPN3 nào", + "clickToImport": "Nhấn + để nhập file cấu hình", + "lines": "dòng", + "clearLogs": "Xóa logs" + }, + "import": { + "title": "Nhập cấu hình", + "nameLabel": "Tên cấu hình", + "namePlaceholder": "Tên cấu hình", + "fileLabel": "File cấu hình", + "filePlaceholder": "Chọn file .ovpn", + "persistent": "Lưu cấu hình", + "importBtn": "Nhập", + "cancel": "Hủy" + }, + "status": { + "connected": "Đã kết nối", + "connecting": "Đang kết nối...", + "disconnected": "Ngắt kết nối", + "paused": "Tạm dừng" + }, + "actions": { + "connect": "Kết nối", + "disconnect": "Ngắt kết nối", + "restart": "Khởi động lại", + "rename": "Đổi tên", + "delete": "Xóa", + "confirm": "Xác nhận", + "cancel": "Hủy", + "confirmDelete": "Xác nhận xóa" + }, + "stats": { + "download": "↓", + "upload": "↑", + "packets": "Gói:", + "reconnect": "Kết nối lại:" + }, + "details": { + "server": "Máy chủ", + "port": "Cổng", + "protocol": "Giao thức", + "cipher": "Mã hóa", + "device": "Thiết bị", + "username": "Tên người dùng" + }, + "settings": { + "bar": { + "label": "Thanh trạng thái", + "description": "Hiển thị trên thanh trạng thái" + }, + "displayMode": { + "label": "Chế độ hiển thị", + "description": "Khi nào hiển thị trạng thái VPN trên thanh", + "onhover": "Khi di chuột", + "alwaysShow": "Luôn hiển thị", + "alwaysHide": "Luôn ẩn" + }, + "colors": { + "label": "Màu sắc", + "description": "Tùy chỉnh màu biểu tượng và chữ", + "connected": { + "label": "Đã kết nối", + "description": "Màu khi VPN đang hoạt động" + }, + "disconnected": { + "label": "Ngắt kết nối", + "description": "Màu khi VPN không hoạt động" + } + }, + "behavior": { + "label": "Hành vi", + "description": "Cài đặt kiểm tra và thông báo" + }, + "pollInterval": { + "label": "Tần suất kiểm tra", + "description": "Kiểm tra trạng thái VPN bao lâu một lần", + "2s": "2s (Nhanh)", + "5s": "5s (Mặc định)", + "10s": "10s (Cân bằng)", + "30s": "30s (Tiết kiệm điện)" + }, + "showNotifications": { + "label": "Hiển thị thông báo", + "description": "Hiển thị thông báo khi gặp lỗi kết nối" + }, + "defaultPersistent": { + "label": "Lưu trú mặc định", + "description": "Giá trị mặc định cho lưu trú khi nhập cấu hình" + }, + "hideWhenInactive": { + "label": "Ẩn khi không hoạt động", + "description": "Ẩn widget trên thanh khi VPN ngắt kết nối" + } + }, + "menu": { + "settings": "Cài đặt" + }, + "errors": { + "restart": "Không thể khởi động lại", + "connect": "Không thể kết nối", + "disconnect": "Không thể ngắt kết nối", + "import": "Không thể nhập cấu hình", + "rename": "Không thể đổi tên cấu hình", + "delete": "Không thể xóa cấu hình" + }, + "tooltip": { + "temporary": "Tạm thời (bị xóa khi khởi động lại)", + "browse": "Duyệt" + } +} diff --git a/openvpn3/icons/openvpn.svg b/openvpn3/icons/openvpn.svg new file mode 100644 index 000000000..1516c29c9 --- /dev/null +++ b/openvpn3/icons/openvpn.svg @@ -0,0 +1,5 @@ + + OpenVPN + + diff --git a/openvpn3/manifest.json b/openvpn3/manifest.json new file mode 100644 index 000000000..ca62b0d4a --- /dev/null +++ b/openvpn3/manifest.json @@ -0,0 +1,32 @@ +{ + "id": "openvpn3", + "name": "OpenVPN3", + "version": "1.0.0", + "minNoctaliaVersion": "4.4.1", + "author": "minh", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Manage OpenVPN3 VPN connections from the bar and control center", + "tags": ["Bar", "Panel", "Network", "Utility"], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "controlCenterWidget": "ControlCenterWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "displayMode": "onhover", + "connectedColor": "primary", + "disconnectedColor": "none", + "pollInterval": 5, + "showNotifications": true, + "defaultPersistent": true, + "hideWhenInactive": false + } + } +} \ No newline at end of file diff --git a/openvpn3/preview.png b/openvpn3/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..0e1c3c9b1f2617247a518716adf04fd92795592c GIT binary patch literal 34569 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 z Date: Mon, 20 Jul 2026 11:16:17 +0700 Subject: [PATCH 2/5] feat(openvpn3): add bar tooltip and smart pill text - Show config name directly when 1 session active, count when multiple - Add hover tooltip listing all connected config names - Use BarPill's built-in tooltipText for native tooltip support --- openvpn3/BarWidget.qml | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/openvpn3/BarWidget.qml b/openvpn3/BarWidget.qml index cca7a70dd..19c8ff251 100644 --- a/openvpn3/BarWidget.qml +++ b/openvpn3/BarWidget.qml @@ -32,6 +32,31 @@ Item { readonly property bool isHidden: root.hideWhenInactive && root.isInactive + readonly property var sessionList: root.main.sessionList ?? [] + + readonly property string connectedName: { + if (root.connectedCount !== 1 || root.sessionList.length === 0) return "" + const name = root.sessionList[0]?.name + return (name && name.length > 0) ? name : "" + } + + function _tooltipText() { + if (root.isLoading) + return pluginApi?.tr("bar.connecting") + if (root.connectedCount > 0) { + const lines = [] + for (let i = 0; i < root.sessionList.length; i++) { + const s = root.sessionList[i] + const name = (s?.name && s.name.length > 0) ? s.name : (s?.sessionPath || "session") + lines.push(name) + } + return lines.join("\n") + } + if (!root.hasConfigs) + return pluginApi?.tr("bar.noConfigs") + return pluginApi?.tr("bar.disconnected") + } + readonly property string pillIcon: { if (root.isLoading) return "shield-check" if (root.connectedCount > 0) return "shield-lock" @@ -40,7 +65,11 @@ Item { } readonly property string pillText: { - if (root.connectedCount > 0) return root.connectedCount + " " + pluginApi?.tr("bar.active") + if (root.connectedCount > 0) { + if (root.connectedCount === 1 && root.connectedName.length > 0) + return root.connectedName + return root.connectedCount + " " + pluginApi?.tr("bar.active") + } if (root.isLoading) return pluginApi?.tr("bar.connecting") if (!root.hasConfigs) return pluginApi?.tr("bar.noConfigs") return pluginApi?.tr("bar.disconnected") @@ -74,6 +103,7 @@ Item { autoHide: false text: root.pillText icon: root.pillIcon + tooltipText: root._tooltipText() customIconColor: Color.resolveColorKeyOptional(root.connectedCount > 0 ? root.connectedColor : root.disconnectedColor) customTextColor: Color.resolveColorKeyOptional(root.connectedCount > 0 ? root.connectedColor : root.disconnectedColor) forceOpen: root.displayMode === "alwaysShow" From 7bc684feb1ef752498908de5e26311025ef3f83c Mon Sep 17 00:00:00 2001 From: "minh.trinh" Date: Mon, 20 Jul 2026 11:32:49 +0700 Subject: [PATCH 3/5] fix(openvpn3): prevent hover flicker on action icons Add negative margin to VpnListItem hover MouseArea so containsMouse doesn't briefly flip false when moving between the item body and the action buttons. --- openvpn3/VpnListItem.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/openvpn3/VpnListItem.qml b/openvpn3/VpnListItem.qml index 3fa5c2b5d..fdc61ffc1 100644 --- a/openvpn3/VpnListItem.qml +++ b/openvpn3/VpnListItem.qml @@ -40,6 +40,7 @@ NBox { MouseArea { anchors.fill: parent + anchors.margins: -Style.marginXS hoverEnabled: true onContainsMouseChanged: root.hovered = containsMouse acceptedButtons: Qt.NoButton From 10380d981a1024f12bc0afea9c8714ab75757cde Mon Sep 17 00:00:00 2001 From: "minh.trinh" Date: Mon, 20 Jul 2026 11:37:31 +0700 Subject: [PATCH 4/5] fix(openvpn3): fix flickering hover action icons Use opacity instead of visible for hover-dependent buttons. The NIconButton internal MouseArea was stealing hover from the parent MouseArea, causing containsMouse to toggle and icons to flicker. Also remove the unnecessary negative margins hack. --- openvpn3/VpnListItem.qml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openvpn3/VpnListItem.qml b/openvpn3/VpnListItem.qml index fdc61ffc1..6ed57e802 100644 --- a/openvpn3/VpnListItem.qml +++ b/openvpn3/VpnListItem.qml @@ -40,7 +40,6 @@ NBox { MouseArea { anchors.fill: parent - anchors.margins: -Style.marginXS hoverEnabled: true onContainsMouseChanged: root.hovered = containsMouse acceptedButtons: Qt.NoButton @@ -128,7 +127,8 @@ NBox { // Hover action buttons NIconButton { - visible: root.isConnected && !root.editing && !root.confirmingDelete && root.hovered + visible: root.isConnected && !root.editing && !root.confirmingDelete + opacity: root.hovered ? 1 : 0 icon: "refresh" tooltipText: pluginApi?.tr("actions.restart") baseSize: Style.baseWidgetSize * 0.7 @@ -136,7 +136,8 @@ NBox { } NIconButton { - visible: !root.isConnected && !root.editing && !root.confirmingDelete && root.hovered + visible: !root.isConnected && !root.editing && !root.confirmingDelete + opacity: root.hovered ? 1 : 0 icon: "pencil" tooltipText: pluginApi?.tr("actions.rename") baseSize: Style.baseWidgetSize * 0.7 @@ -148,7 +149,8 @@ NBox { } NIconButton { - visible: !root.isConnected && !root.editing && !root.confirmingDelete && root.hovered + visible: !root.isConnected && !root.editing && !root.confirmingDelete + opacity: root.hovered ? 1 : 0 icon: "trash" tooltipText: pluginApi?.tr("actions.delete") baseSize: Style.baseWidgetSize * 0.7 From d399293014d8c064615ebce6227ed813bfb05b19 Mon Sep 17 00:00:00 2001 From: "minh.trinh" Date: Mon, 20 Jul 2026 11:48:40 +0700 Subject: [PATCH 5/5] Revert "fix(openvpn3): fix flickering hover action icons" This reverts commit 10380d981a1024f12bc0afea9c8714ab75757cde. --- openvpn3/VpnListItem.qml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openvpn3/VpnListItem.qml b/openvpn3/VpnListItem.qml index 6ed57e802..fdc61ffc1 100644 --- a/openvpn3/VpnListItem.qml +++ b/openvpn3/VpnListItem.qml @@ -40,6 +40,7 @@ NBox { MouseArea { anchors.fill: parent + anchors.margins: -Style.marginXS hoverEnabled: true onContainsMouseChanged: root.hovered = containsMouse acceptedButtons: Qt.NoButton @@ -127,8 +128,7 @@ NBox { // Hover action buttons NIconButton { - visible: root.isConnected && !root.editing && !root.confirmingDelete - opacity: root.hovered ? 1 : 0 + visible: root.isConnected && !root.editing && !root.confirmingDelete && root.hovered icon: "refresh" tooltipText: pluginApi?.tr("actions.restart") baseSize: Style.baseWidgetSize * 0.7 @@ -136,8 +136,7 @@ NBox { } NIconButton { - visible: !root.isConnected && !root.editing && !root.confirmingDelete - opacity: root.hovered ? 1 : 0 + visible: !root.isConnected && !root.editing && !root.confirmingDelete && root.hovered icon: "pencil" tooltipText: pluginApi?.tr("actions.rename") baseSize: Style.baseWidgetSize * 0.7 @@ -149,8 +148,7 @@ NBox { } NIconButton { - visible: !root.isConnected && !root.editing && !root.confirmingDelete - opacity: root.hovered ? 1 : 0 + visible: !root.isConnected && !root.editing && !root.confirmingDelete && root.hovered icon: "trash" tooltipText: pluginApi?.tr("actions.delete") baseSize: Style.baseWidgetSize * 0.7