import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; import St from 'gi://St'; import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.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 = 'nebula0'; 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 ?? ''); } 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: ''}; } } function isServiceActive() { const r = runCommand(['systemctl', 'is-active', '--quiet', SERVICE_NAME]); log(`isServiceActive: ${r.success}`); return r.success; } function getIfaceInfo() { const r = runCommand(['ip', '-brief', 'addr', 'show', IFACE_NAME]); if (!r.success || r.stdout === '') { log('getIfaceInfo: not found'); return {found: false, ipv4: null, ipv6: null}; } const parts = r.stdout.split(/\s+/); 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: state=${parts[1]} ipv4=${ipv4} ipv6=${ipv6}`); return {found: true, ipv4, ipv6}; } function readSysfs(path) { try { 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; } catch (_e) { return null; } } 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}; } function formatBytes(bytes) { if (bytes === null) return '—'; if (bytes < 1024) return `${bytes} B`; 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`; } function getServiceUptime() { const r = runCommand([ 'systemctl', 'show', SERVICE_NAME, '--property=ActiveEnterTimestampMonotonic', ]); if (!r.success) return null; const match = r.stdout.match(/=(\d+)/); if (!match) return null; 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`; } function setServiceActive(enable) { const action = enable ? 'start' : 'stop'; log(`setServiceActive: ${action}`); runCommand(['systemctl', action, SERVICE_NAME]); } // ── Quick Settings toggle tile ──────────────────────────────────────────────── const NebulaToggle = GObject.registerClass( class NebulaToggle extends QuickSettings.QuickMenuToggle { _init() { log('NebulaToggle._init'); super._init({ title: 'Nebula VPN', iconName: 'network-vpn-symbolic', toggleMode: true, }); // Header shown at the top of the expanded menu panel this.menu.setHeader('network-vpn-symbolic', 'Nebula VPN', 'jallen-nebula'); // ── menu body ────────────────────────────────────────────── 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._rxItem = new PopupMenu.PopupMenuItem('', {reactive: false}); this._txItem = new PopupMenu.PopupMenuItem('', {reactive: false}); this._networkSubmenu.menu.addMenuItem(this._uptimeItem); this._networkSubmenu.menu.addMenuItem(this._rxItem); this._networkSubmenu.menu.addMenuItem(this._txItem); this.menu.addMenuItem(this._networkSubmenu); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); const refreshItem = new PopupMenu.PopupMenuItem('Refresh'); refreshItem.connect('activate', () => this._refresh()); this.menu.addMenuItem(refreshItem); // ── wire up the toggle click ─────────────────────────────── this.connect('clicked', () => this._onToggle()); // ── initial state + polling ──────────────────────────────── this._active = false; this._refresh(); this._timerId = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, POLL_INTERVAL_SECS, () => { this._refresh(); return GLib.SOURCE_CONTINUE; }, ); log(`NebulaToggle._init: poll timer id=${this._timerId}`); } _refresh() { log('_refresh: polling'); 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}`); // Sync the toggle checked state without re-triggering the click handler this.set({checked: this._active}); if (this._active) { 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)}`; } else { this.subtitle = null; this._statusItem.label.text = 'Status: Disconnected'; this._ipv4Item.visible = false; this._ipv6Item.visible = false; this._networkSubmenu.visible = false; } } _onToggle() { const target = !this._active; log(`_onToggle: → ${target ? 'start' : 'stop'}`); setServiceActive(target); // Optimistic update this._active = target; this._updateUI(getIfaceInfo(), getIfaceStats(), null); // Confirm after systemd settles GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { log('_onToggle: confirmation poll'); this._refresh(); return GLib.SOURCE_REMOVE; }); } destroy() { log('NebulaToggle.destroy'); if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; } super.destroy(); } }); // ── 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'); nebulaIndicator = new NebulaIndicator(); } disable() { log('disable'); nebulaIndicator?.destroy(); nebulaIndicator = null; } }