From 309e224a72f0f59b921d001bdb235faabc4b5801 Mon Sep 17 00:00:00 2001 From: mjallen18 Date: Mon, 23 Mar 2026 17:42:47 -0500 Subject: [PATCH] test --- .../gnome-nebula-vpn/extension/extension.js | 128 +++++++++++++++--- 1 file changed, 109 insertions(+), 19 deletions(-) diff --git a/packages/system/gnome-nebula-vpn/extension/extension.js b/packages/system/gnome-nebula-vpn/extension/extension.js index 0199861..83d66aa 100644 --- a/packages/system/gnome-nebula-vpn/extension/extension.js +++ b/packages/system/gnome-nebula-vpn/extension/extension.js @@ -35,9 +35,9 @@ function runCommand(argv) { log(`runCommand: ${argv.join(' ')}`); try { const [ok, stdout, stderr, exitCode] = GLib.spawn_sync( - null, // working dir + null, argv, - null, // inherit env + null, GLib.SpawnFlags.SEARCH_PATH, null, ); @@ -62,7 +62,7 @@ function isServiceActive() { } /** - * Get interface info for IFACE_NAME. + * Get interface addresses for IFACE_NAME. * Returns { found, ipv4, ipv6 } where ipv4/ipv6 are strings or null. */ function getIfaceInfo() { @@ -74,16 +74,82 @@ function getIfaceInfo() { return {found: false, ipv4: null, ipv6: null}; } - const parts = r.stdout.split(/\s+/); + const parts = r.stdout.split(/\s+/); // parts[0] = iface, parts[1] = state, parts[2..] = addresses - const addrs = parts.slice(2); - const ipv4 = addrs.find(a => /^\d+\.\d+\.\d+\.\d+\//.test(a)) ?? null; - const ipv6 = addrs.find(a => a.includes(':') && !a.startsWith('fe80')) ?? null; + const addrs = parts.slice(2); + const ipv4 = addrs.find(a => /^\d+\.\d+\.\d+\.\d+\//.test(a)) ?? null; + const ipv6 = addrs.find(a => a.includes(':') && !a.startsWith('fe80')) ?? null; log(`getIfaceInfo: found=true state=${parts[1]} ipv4=${ipv4} ipv6=${ipv6}`); return {found: true, ipv4, ipv6}; } -/** Start or stop the systemd service (requires polkit / passwordless sudo). */ +/** + * Read a single integer from a sysfs file, returning null on failure. + */ +function readSysfs(path) { + try { + const f = Gio.File.new_for_path(path); + const [ok, contents] = f.load_contents(null); + if (!ok) return null; + const val = parseInt(new TextDecoder().decode(contents).trim(), 10); + return isNaN(val) ? null : val; + } catch (_e) { + return null; + } +} + +/** + * Get TX/RX byte counters for IFACE_NAME from sysfs. + * Returns { rx, tx } in bytes, or null values if unavailable. + */ +function getIfaceStats() { + const base = `/sys/class/net/${IFACE_NAME}/statistics`; + const rx = readSysfs(`${base}/rx_bytes`); + const tx = readSysfs(`${base}/tx_bytes`); + log(`getIfaceStats: rx=${rx} tx=${tx}`); + return {rx, tx}; +} + +/** Format bytes into a human-readable string. */ +function formatBytes(bytes) { + if (bytes === null) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +/** + * Get the service ActiveEnterTimestamp from systemctl show. + * Returns a human-readable uptime string, or null. + */ +function getServiceUptime() { + const r = runCommand([ + 'systemctl', 'show', SERVICE_NAME, + '--property=ActiveEnterTimestampMonotonic', + ]); + if (!r.success) return null; + + // Output: ActiveEnterTimestampMonotonic=1234567890 + const match = r.stdout.match(/=(\d+)/); + if (!match) return null; + + const startMonoUs = parseInt(match[1], 10); + const nowMonoUs = GLib.get_monotonic_time(); + const elapsedSecs = Math.floor((nowMonoUs - startMonoUs) / 1_000_000); + + if (elapsedSecs < 0) return null; + + const h = Math.floor(elapsedSecs / 3600); + const m = Math.floor((elapsedSecs % 3600) / 60); + const s = elapsedSecs % 60; + + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +/** Start or stop the systemd service (requires polkit). */ function setServiceActive(enable) { const action = enable ? 'start' : 'stop'; log(`setServiceActive: ${action} ${SERVICE_NAME}`); @@ -118,7 +184,7 @@ class NebulaIndicator extends PanelMenu.Button { hbox.add_child(this._label); this.add_child(hbox); - // ── dropdown menu ── + // ── top-level status ── this._statusItem = new PopupMenu.PopupMenuItem('', {reactive: false}); this._statusItem.label.set_style('font-weight: bold;'); this.menu.addMenuItem(this._statusItem); @@ -131,6 +197,23 @@ class NebulaIndicator extends PanelMenu.Button { this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + // ── "Network" expandable submenu ── + this._networkSubmenu = new PopupMenu.PopupSubMenuMenuItem('Network'); + + this._uptimeItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._networkSubmenu.menu.addMenuItem(this._uptimeItem); + + this._rxItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._networkSubmenu.menu.addMenuItem(this._rxItem); + + this._txItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._networkSubmenu.menu.addMenuItem(this._txItem); + + this.menu.addMenuItem(this._networkSubmenu); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + // ── actions ── this._toggleItem = new PopupMenu.PopupMenuItem(''); this._toggleItem.connect('activate', () => this._onToggle()); this.menu.addMenuItem(this._toggleItem); @@ -159,31 +242,38 @@ class NebulaIndicator extends PanelMenu.Button { log('_refresh: checking service and interface'); this._active = isServiceActive(); const iface = getIfaceInfo(); - log(`_refresh: active=${this._active} iface=${JSON.stringify(iface)}`); - this._updateUI(iface); + const stats = getIfaceStats(); + const uptime = this._active ? getServiceUptime() : null; + log(`_refresh: active=${this._active} iface=${JSON.stringify(iface)} stats=${JSON.stringify(stats)} uptime=${uptime}`); + this._updateUI(iface, stats, uptime); } - _updateUI(iface) { + _updateUI(iface, stats, uptime) { log(`_updateUI: active=${this._active}`); if (this._active) { this._icon.icon_name = 'network-vpn-symbolic'; - this._label.text = 'VPN'; - this._label.style = 'margin-left: 4px; margin-right: 2px; color: #57e389;'; // green - this._statusItem.label.text = `Status: Connected`; + this._label.style = 'margin-left: 4px; margin-right: 2px; color: #57e389;'; + this._statusItem.label.text = 'Status: Connected'; this._ipv4Item.label.text = iface.ipv4 ? `IPv4: ${iface.ipv4}` : 'IPv4: —'; this._ipv6Item.label.text = iface.ipv6 ? `IPv6: ${iface.ipv6}` : 'IPv6: —'; this._ipv4Item.visible = true; this._ipv6Item.visible = true; this._toggleItem.label.text = 'Disconnect'; + this._networkSubmenu.visible = true; + this._uptimeItem.label.text = `Uptime: ${uptime ?? '—'}`; + this._rxItem.label.text = `RX: ${formatBytes(stats.rx)}`; + this._txItem.label.text = `TX: ${formatBytes(stats.tx)}`; } else { this._icon.icon_name = 'network-vpn-disabled-symbolic'; - this._label.text = 'VPN'; - this._label.style = 'margin-left: 4px; margin-right: 2px; color: #c01c28;'; // red - this._statusItem.label.text = `Status: Disconnected`; + this._label.style = 'margin-left: 4px; margin-right: 2px; color: #c01c28;'; + this._statusItem.label.text = 'Status: Disconnected'; this._ipv4Item.visible = false; this._ipv6Item.visible = false; this._toggleItem.label.text = 'Connect'; + this._networkSubmenu.visible = false; } + // label is always "VPN" + this._label.text = 'VPN'; } _onToggle() { @@ -192,7 +282,7 @@ class NebulaIndicator extends PanelMenu.Button { setServiceActive(target); // Optimistic update while systemd races this._active = target; - this._updateUI(getIfaceInfo()); + this._updateUI(getIfaceInfo(), getIfaceStats(), null); // Re-poll shortly after to confirm real state GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { log('_onToggle: confirmation poll');