diff --git a/openvpn3/BarWidget.qml b/openvpn3/BarWidget.qml new file mode 100644 index 000000000..19c8ff251 --- /dev/null +++ b/openvpn3/BarWidget.qml @@ -0,0 +1,120 @@ +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 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" + if (!root.hasConfigs) return "shield-off" + return "shield" + } + + readonly property string pillText: { + 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") + } + + 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 + 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" + 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..fdc61ffc1 --- /dev/null +++ b/openvpn3/VpnListItem.qml @@ -0,0 +1,331 @@ +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 + anchors.margins: -Style.marginXS + 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 000000000..0e1c3c9b1 Binary files /dev/null and b/openvpn3/preview.png differ