Files
nix-config/packages/system/gnome-nebula-vpn/extension/extension.js
mjallen18 01d1086580 nebula
2026-03-23 17:49:38 -05:00

294 lines
11 KiB
JavaScript

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;
}
}