diff --git a/packages/system/gnome-nebula-vpn/extension/extension.js b/packages/system/gnome-nebula-vpn/extension/extension.js index 83d66aa..1ab0dee 100644 --- a/packages/system/gnome-nebula-vpn/extension/extension.js +++ b/packages/system/gnome-nebula-vpn/extension/extension.js @@ -2,20 +2,20 @@ import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; import St from 'gi://St'; -import Clutter from 'gi://Clutter'; import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; -import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js'; + +const QuickSettingsMenu = Main.panel.statusArea.quickSettings; // ── constants ──────────────────────────────────────────────────────────────── -const SERVICE_NAME = 'nebula@jallen-nebula.service'; -const IFACE_NAME = 'nebula.jallen-n'; - +const SERVICE_NAME = 'nebula@jallen-nebula.service'; +const IFACE_NAME = 'jallen-nebula'; const POLL_INTERVAL_SECS = 5; -const LOG_PREFIX = '[nebula-vpn]'; +const LOG_PREFIX = '[nebula-vpn]'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -27,24 +27,16 @@ function logErr(msg, err) { console.error(`${LOG_PREFIX} ${msg}`, err ?? ''); } -/** - * Run a command and return { success, stdout, stderr }. - * The command is passed as an argv array (no shell expansion). - */ function runCommand(argv) { log(`runCommand: ${argv.join(' ')}`); try { const [ok, stdout, stderr, exitCode] = GLib.spawn_sync( - null, - argv, - null, - GLib.SpawnFlags.SEARCH_PATH, - null, + null, argv, null, GLib.SpawnFlags.SEARCH_PATH, null, ); const result = { success: ok && exitCode === 0, - stdout: ok ? new TextDecoder().decode(stdout).trim() : '', - stderr: ok ? new TextDecoder().decode(stderr).trim() : '', + stdout: ok ? new TextDecoder().decode(stdout).trim() : '', + stderr: ok ? new TextDecoder().decode(stderr).trim() : '', }; log(`runCommand result: success=${result.success} exitCode=${exitCode} stdout="${result.stdout}" stderr="${result.stderr}"`); return result; @@ -54,42 +46,29 @@ function runCommand(argv) { } } -/** Return true if the systemd service is currently active. */ function isServiceActive() { const r = runCommand(['systemctl', 'is-active', '--quiet', SERVICE_NAME]); log(`isServiceActive: ${r.success}`); return r.success; } -/** - * Get interface addresses for IFACE_NAME. - * Returns { found, ipv4, ipv6 } where ipv4/ipv6 are strings or null. - */ function getIfaceInfo() { - // `ip -brief addr show ` output: - // jallen-nebula UP 10.1.1.3/24 fe80::…/64 const r = runCommand(['ip', '-brief', 'addr', 'show', IFACE_NAME]); if (!r.success || r.stdout === '') { - log(`getIfaceInfo: interface not found or no output`); + log('getIfaceInfo: not found'); return {found: false, ipv4: null, ipv6: null}; } - 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; - log(`getIfaceInfo: found=true state=${parts[1]} ipv4=${ipv4} ipv6=${ipv6}`); + log(`getIfaceInfo: state=${parts[1]} ipv4=${ipv4} ipv6=${ipv6}`); return {found: true, ipv4, ipv6}; } -/** - * 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); + const [ok, contents] = Gio.File.new_for_path(path).load_contents(null); if (!ok) return null; const val = parseInt(new TextDecoder().decode(contents).trim(), 10); return isNaN(val) ? null : val; @@ -98,10 +77,6 @@ function readSysfs(path) { } } -/** - * 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`); @@ -110,81 +85,58 @@ function getIfaceStats() { 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`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${(bytes / 1024 ** 3).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); - + const elapsedSecs = Math.floor( + (GLib.get_monotonic_time() - parseInt(match[1], 10)) / 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}`); + log(`setServiceActive: ${action}`); runCommand(['systemctl', action, SERVICE_NAME]); } -// ── indicator ──────────────────────────────────────────────────────────────── +// ── Quick Settings toggle tile ──────────────────────────────────────────────── -const NebulaIndicator = GObject.registerClass( -class NebulaIndicator extends PanelMenu.Button { - _init(extensionPath) { - log('NebulaIndicator._init: start'); - super._init(0.0, 'Nebula VPN Status'); +const NebulaToggle = GObject.registerClass( +class NebulaToggle extends QuickSettings.QuickMenuToggle { - this._extensionPath = extensionPath; - - // ── panel icon + label ── - const hbox = new St.BoxLayout({style_class: 'panel-status-menu-box'}); - - this._icon = new St.Icon({ - style_class: 'system-status-icon', - icon_size: 16, + _init() { + log('NebulaToggle._init'); + super._init({ + title: 'Nebula VPN', + iconName: 'network-vpn-symbolic', + toggleMode: true, }); - this._label = new St.Label({ - text: '…', - y_align: Clutter.ActorAlign.CENTER, - style: 'margin-left: 4px; margin-right: 2px;', - }); + // Header shown at the top of the expanded menu panel + this.menu.setHeader('network-vpn-symbolic', 'Nebula VPN', IFACE_NAME); - hbox.add_child(this._icon); - hbox.add_child(this._label); - this.add_child(hbox); + // ── menu body ────────────────────────────────────────────── - // ── top-level status ── this._statusItem = new PopupMenu.PopupMenuItem('', {reactive: false}); this._statusItem.label.set_style('font-weight: bold;'); this.menu.addMenuItem(this._statusItem); @@ -197,49 +149,38 @@ class NebulaIndicator extends PanelMenu.Button { this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - // ── "Network" expandable submenu ── + // "Network" expandable submenu this._networkSubmenu = new PopupMenu.PopupSubMenuMenuItem('Network'); - this._uptimeItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._rxItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._txItem = 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); - - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - const refreshItem = new PopupMenu.PopupMenuItem('Refresh'); refreshItem.connect('activate', () => this._refresh()); this.menu.addMenuItem(refreshItem); - // ── initial state + polling ── + // ── wire up the toggle click ─────────────────────────────── + this.connect('clicked', () => this._onToggle()); + + // ── initial state + polling ──────────────────────────────── this._active = false; - log('NebulaIndicator._init: running initial refresh'); this._refresh(); this._timerId = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, POLL_INTERVAL_SECS, () => { this._refresh(); return GLib.SOURCE_CONTINUE; }, ); - log(`NebulaIndicator._init: poll timer registered (id=${this._timerId})`); + log(`NebulaToggle._init: poll timer id=${this._timerId}`); } - // ── private ────────────────────────────────────────────────────────────── - _refresh() { - log('_refresh: checking service and interface'); + log('_refresh: polling'); this._active = isServiceActive(); const iface = getIfaceInfo(); const stats = getIfaceStats(); @@ -250,40 +191,38 @@ class NebulaIndicator extends PanelMenu.Button { _updateUI(iface, stats, uptime) { log(`_updateUI: active=${this._active}`); + + // Sync the toggle checked state without re-triggering the click handler + this.set({checked: this._active}); + if (this._active) { - this._icon.icon_name = 'network-vpn-symbolic'; - 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.subtitle = iface.ipv4 ?? 'connected'; + 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._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)}`; + 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.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.subtitle = null; + this._statusItem.label.text = 'Status: Disconnected'; + this._ipv4Item.visible = false; + this._ipv6Item.visible = false; this._networkSubmenu.visible = false; } - // label is always "VPN" - this._label.text = 'VPN'; } _onToggle() { const target = !this._active; - log(`_onToggle: toggling to ${target ? 'active' : 'inactive'}`); + log(`_onToggle: → ${target ? 'start' : 'stop'}`); setServiceActive(target); - // Optimistic update while systemd races + // Optimistic update this._active = target; this._updateUI(getIfaceInfo(), getIfaceStats(), null); - // Re-poll shortly after to confirm real state + // Confirm after systemd settles GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { log('_onToggle: confirmation poll'); this._refresh(); @@ -291,10 +230,8 @@ class NebulaIndicator extends PanelMenu.Button { }); } - // ── cleanup ─────────────────────────────────────────────────────────────── - destroy() { - log('NebulaIndicator.destroy'); + log('NebulaToggle.destroy'); if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; @@ -303,19 +240,54 @@ class NebulaIndicator extends PanelMenu.Button { } }); -// ── extension lifecycle ─────────────────────────────────────────────────────── +// ── System indicator (lives in the Quick Settings panel) ───────────────────── + +const NebulaIndicator = GObject.registerClass( +class NebulaIndicator extends QuickSettings.SystemIndicator { + + _init() { + log('NebulaIndicator._init'); + super._init(); + + // Small icon shown in the top bar when the VPN is active + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'network-vpn-symbolic'; + this._indicator.visible = false; + + const toggle = new NebulaToggle(); + + // Keep the top-bar indicator icon in sync with toggle state + toggle.connect('notify::checked', () => { + this._indicator.visible = toggle.checked; + }); + + this.quickSettingsItems.push(toggle); + + // Insert into the Quick Settings menu — same call GSConnect uses + QuickSettingsMenu.addExternalIndicator(this); + log('NebulaIndicator._init: added to QuickSettings'); + } + + destroy() { + log('NebulaIndicator.destroy'); + this.quickSettingsItems.forEach(item => item.destroy()); + super.destroy(); + } +}); + +// ── Extension lifecycle ─────────────────────────────────────────────────────── + +let nebulaIndicator = null; export default class NebulaVpnExtension extends Extension { enable() { - log('enable: creating indicator'); - this._indicator = new NebulaIndicator(this.path); - Main.panel.addToStatusArea(this.uuid, this._indicator); - log('enable: indicator added to panel'); + log('enable'); + nebulaIndicator = new NebulaIndicator(); } disable() { - log('disable: destroying indicator'); - this._indicator?.destroy(); - this._indicator = null; + log('disable'); + nebulaIndicator?.destroy(); + nebulaIndicator = null; } }