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'; // ── constants ──────────────────────────────────────────────────────────────── const SERVICE_NAME = 'nebula@jallen-nebula.service'; const IFACE_NAME = 'nebula.jallen-n'; const POLL_INTERVAL_SECS = 5; const LOG_PREFIX = '[nebula-vpn]'; // ── helpers ────────────────────────────────────────────────────────────────── function log(msg) { console.log(`${LOG_PREFIX} ${msg}`); } 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, ); const result = { success: ok && exitCode === 0, 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; } catch (e) { logErr(`runCommand exception for [${argv.join(' ')}]:`, e); return {success: false, stdout: '', stderr: ''}; } } /** 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`); 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}`); 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); 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}`); runCommand(['systemctl', action, SERVICE_NAME]); } // ── indicator ──────────────────────────────────────────────────────────────── const NebulaIndicator = GObject.registerClass( class NebulaIndicator extends PanelMenu.Button { _init(extensionPath) { log('NebulaIndicator._init: start'); super._init(0.0, 'Nebula VPN Status'); 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, }); this._label = new St.Label({ text: '…', y_align: Clutter.ActorAlign.CENTER, style: 'margin-left: 4px; margin-right: 2px;', }); hbox.add_child(this._icon); hbox.add_child(this._label); this.add_child(hbox); // ── top-level status ── this._statusItem = new PopupMenu.PopupMenuItem('', {reactive: false}); this._statusItem.label.set_style('font-weight: bold;'); this.menu.addMenuItem(this._statusItem); this._ipv4Item = new PopupMenu.PopupMenuItem('', {reactive: false}); this.menu.addMenuItem(this._ipv4Item); this._ipv6Item = new PopupMenu.PopupMenuItem('', {reactive: false}); this.menu.addMenuItem(this._ipv6Item); 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); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); const refreshItem = new PopupMenu.PopupMenuItem('Refresh'); refreshItem.connect('activate', () => this._refresh()); this.menu.addMenuItem(refreshItem); // ── 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})`); } // ── private ────────────────────────────────────────────────────────────── _refresh() { log('_refresh: checking service and interface'); this._active = isServiceActive(); const iface = getIfaceInfo(); 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, stats, uptime) { log(`_updateUI: active=${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._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.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() { const target = !this._active; log(`_onToggle: toggling to ${target ? 'active' : 'inactive'}`); setServiceActive(target); // Optimistic update while systemd races this._active = target; 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'); this._refresh(); return GLib.SOURCE_REMOVE; }); } // ── cleanup ─────────────────────────────────────────────────────────────── destroy() { log('NebulaIndicator.destroy'); if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; } super.destroy(); } }); // ── extension lifecycle ─────────────────────────────────────────────────────── 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'); } disable() { log('disable: destroying indicator'); this._indicator?.destroy(); this._indicator = null; } }