upd ext
This commit is contained in:
@@ -2,20 +2,20 @@ import GLib from 'gi://GLib';
|
|||||||
import Gio from 'gi://Gio';
|
import Gio from 'gi://Gio';
|
||||||
import GObject from 'gi://GObject';
|
import GObject from 'gi://GObject';
|
||||||
import St from 'gi://St';
|
import St from 'gi://St';
|
||||||
import Clutter from 'gi://Clutter';
|
|
||||||
|
|
||||||
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
|
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||||
import * as Main from 'resource:///org/gnome/shell/ui/main.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 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 ────────────────────────────────────────────────────────────────
|
// ── constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SERVICE_NAME = 'nebula@jallen-nebula.service';
|
const SERVICE_NAME = 'nebula@jallen-nebula.service';
|
||||||
const IFACE_NAME = 'nebula.jallen-n';
|
const IFACE_NAME = 'jallen-nebula';
|
||||||
|
|
||||||
const POLL_INTERVAL_SECS = 5;
|
const POLL_INTERVAL_SECS = 5;
|
||||||
const LOG_PREFIX = '[nebula-vpn]';
|
const LOG_PREFIX = '[nebula-vpn]';
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -27,24 +27,16 @@ function logErr(msg, err) {
|
|||||||
console.error(`${LOG_PREFIX} ${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) {
|
function runCommand(argv) {
|
||||||
log(`runCommand: ${argv.join(' ')}`);
|
log(`runCommand: ${argv.join(' ')}`);
|
||||||
try {
|
try {
|
||||||
const [ok, stdout, stderr, exitCode] = GLib.spawn_sync(
|
const [ok, stdout, stderr, exitCode] = GLib.spawn_sync(
|
||||||
null,
|
null, argv, null, GLib.SpawnFlags.SEARCH_PATH, null,
|
||||||
argv,
|
|
||||||
null,
|
|
||||||
GLib.SpawnFlags.SEARCH_PATH,
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
const result = {
|
const result = {
|
||||||
success: ok && exitCode === 0,
|
success: ok && exitCode === 0,
|
||||||
stdout: ok ? new TextDecoder().decode(stdout).trim() : '',
|
stdout: ok ? new TextDecoder().decode(stdout).trim() : '',
|
||||||
stderr: ok ? new TextDecoder().decode(stderr).trim() : '',
|
stderr: ok ? new TextDecoder().decode(stderr).trim() : '',
|
||||||
};
|
};
|
||||||
log(`runCommand result: success=${result.success} exitCode=${exitCode} stdout="${result.stdout}" stderr="${result.stderr}"`);
|
log(`runCommand result: success=${result.success} exitCode=${exitCode} stdout="${result.stdout}" stderr="${result.stderr}"`);
|
||||||
return result;
|
return result;
|
||||||
@@ -54,42 +46,29 @@ function runCommand(argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return true if the systemd service is currently active. */
|
|
||||||
function isServiceActive() {
|
function isServiceActive() {
|
||||||
const r = runCommand(['systemctl', 'is-active', '--quiet', SERVICE_NAME]);
|
const r = runCommand(['systemctl', 'is-active', '--quiet', SERVICE_NAME]);
|
||||||
log(`isServiceActive: ${r.success}`);
|
log(`isServiceActive: ${r.success}`);
|
||||||
return r.success;
|
return r.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get interface addresses for IFACE_NAME.
|
|
||||||
* Returns { found, ipv4, ipv6 } where ipv4/ipv6 are strings or null.
|
|
||||||
*/
|
|
||||||
function getIfaceInfo() {
|
function getIfaceInfo() {
|
||||||
// `ip -brief addr show <iface>` output:
|
|
||||||
// jallen-nebula UP 10.1.1.3/24 fe80::…/64
|
|
||||||
const r = runCommand(['ip', '-brief', 'addr', 'show', IFACE_NAME]);
|
const r = runCommand(['ip', '-brief', 'addr', 'show', IFACE_NAME]);
|
||||||
if (!r.success || r.stdout === '') {
|
if (!r.success || r.stdout === '') {
|
||||||
log(`getIfaceInfo: interface not found or no output`);
|
log('getIfaceInfo: not found');
|
||||||
return {found: false, ipv4: null, ipv6: null};
|
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 addrs = parts.slice(2);
|
||||||
const ipv4 = addrs.find(a => /^\d+\.\d+\.\d+\.\d+\//.test(a)) ?? null;
|
const ipv4 = addrs.find(a => /^\d+\.\d+\.\d+\.\d+\//.test(a)) ?? null;
|
||||||
const ipv6 = addrs.find(a => a.includes(':') && !a.startsWith('fe80')) ?? 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};
|
return {found: true, ipv4, ipv6};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a single integer from a sysfs file, returning null on failure.
|
|
||||||
*/
|
|
||||||
function readSysfs(path) {
|
function readSysfs(path) {
|
||||||
try {
|
try {
|
||||||
const f = Gio.File.new_for_path(path);
|
const [ok, contents] = Gio.File.new_for_path(path).load_contents(null);
|
||||||
const [ok, contents] = f.load_contents(null);
|
|
||||||
if (!ok) return null;
|
if (!ok) return null;
|
||||||
const val = parseInt(new TextDecoder().decode(contents).trim(), 10);
|
const val = parseInt(new TextDecoder().decode(contents).trim(), 10);
|
||||||
return isNaN(val) ? null : val;
|
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() {
|
function getIfaceStats() {
|
||||||
const base = `/sys/class/net/${IFACE_NAME}/statistics`;
|
const base = `/sys/class/net/${IFACE_NAME}/statistics`;
|
||||||
const rx = readSysfs(`${base}/rx_bytes`);
|
const rx = readSysfs(`${base}/rx_bytes`);
|
||||||
@@ -110,81 +85,58 @@ function getIfaceStats() {
|
|||||||
return {rx, tx};
|
return {rx, tx};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format bytes into a human-readable string. */
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
if (bytes === null) return '—';
|
if (bytes === null) return '—';
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the service ActiveEnterTimestamp from systemctl show.
|
|
||||||
* Returns a human-readable uptime string, or null.
|
|
||||||
*/
|
|
||||||
function getServiceUptime() {
|
function getServiceUptime() {
|
||||||
const r = runCommand([
|
const r = runCommand([
|
||||||
'systemctl', 'show', SERVICE_NAME,
|
'systemctl', 'show', SERVICE_NAME,
|
||||||
'--property=ActiveEnterTimestampMonotonic',
|
'--property=ActiveEnterTimestampMonotonic',
|
||||||
]);
|
]);
|
||||||
if (!r.success) return null;
|
if (!r.success) return null;
|
||||||
|
|
||||||
// Output: ActiveEnterTimestampMonotonic=1234567890
|
|
||||||
const match = r.stdout.match(/=(\d+)/);
|
const match = r.stdout.match(/=(\d+)/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
const elapsedSecs = Math.floor(
|
||||||
const startMonoUs = parseInt(match[1], 10);
|
(GLib.get_monotonic_time() - parseInt(match[1], 10)) / 1_000_000
|
||||||
const nowMonoUs = GLib.get_monotonic_time();
|
);
|
||||||
const elapsedSecs = Math.floor((nowMonoUs - startMonoUs) / 1_000_000);
|
|
||||||
|
|
||||||
if (elapsedSecs < 0) return null;
|
if (elapsedSecs < 0) return null;
|
||||||
|
|
||||||
const h = Math.floor(elapsedSecs / 3600);
|
const h = Math.floor(elapsedSecs / 3600);
|
||||||
const m = Math.floor((elapsedSecs % 3600) / 60);
|
const m = Math.floor((elapsedSecs % 3600) / 60);
|
||||||
const s = elapsedSecs % 60;
|
const s = elapsedSecs % 60;
|
||||||
|
|
||||||
if (h > 0) return `${h}h ${m}m`;
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
if (m > 0) return `${m}m ${s}s`;
|
if (m > 0) return `${m}m ${s}s`;
|
||||||
return `${s}s`;
|
return `${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start or stop the systemd service (requires polkit). */
|
|
||||||
function setServiceActive(enable) {
|
function setServiceActive(enable) {
|
||||||
const action = enable ? 'start' : 'stop';
|
const action = enable ? 'start' : 'stop';
|
||||||
log(`setServiceActive: ${action} ${SERVICE_NAME}`);
|
log(`setServiceActive: ${action}`);
|
||||||
runCommand(['systemctl', action, SERVICE_NAME]);
|
runCommand(['systemctl', action, SERVICE_NAME]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── indicator ────────────────────────────────────────────────────────────────
|
// ── Quick Settings toggle tile ────────────────────────────────────────────────
|
||||||
|
|
||||||
const NebulaIndicator = GObject.registerClass(
|
const NebulaToggle = GObject.registerClass(
|
||||||
class NebulaIndicator extends PanelMenu.Button {
|
class NebulaToggle extends QuickSettings.QuickMenuToggle {
|
||||||
_init(extensionPath) {
|
|
||||||
log('NebulaIndicator._init: start');
|
|
||||||
super._init(0.0, 'Nebula VPN Status');
|
|
||||||
|
|
||||||
this._extensionPath = extensionPath;
|
_init() {
|
||||||
|
log('NebulaToggle._init');
|
||||||
// ── panel icon + label ──
|
super._init({
|
||||||
const hbox = new St.BoxLayout({style_class: 'panel-status-menu-box'});
|
title: 'Nebula VPN',
|
||||||
|
iconName: 'network-vpn-symbolic',
|
||||||
this._icon = new St.Icon({
|
toggleMode: true,
|
||||||
style_class: 'system-status-icon',
|
|
||||||
icon_size: 16,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this._label = new St.Label({
|
// Header shown at the top of the expanded menu panel
|
||||||
text: '…',
|
this.menu.setHeader('network-vpn-symbolic', 'Nebula VPN', IFACE_NAME);
|
||||||
y_align: Clutter.ActorAlign.CENTER,
|
|
||||||
style: 'margin-left: 4px; margin-right: 2px;',
|
|
||||||
});
|
|
||||||
|
|
||||||
hbox.add_child(this._icon);
|
// ── menu body ──────────────────────────────────────────────
|
||||||
hbox.add_child(this._label);
|
|
||||||
this.add_child(hbox);
|
|
||||||
|
|
||||||
// ── top-level status ──
|
|
||||||
this._statusItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
this._statusItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
||||||
this._statusItem.label.set_style('font-weight: bold;');
|
this._statusItem.label.set_style('font-weight: bold;');
|
||||||
this.menu.addMenuItem(this._statusItem);
|
this.menu.addMenuItem(this._statusItem);
|
||||||
@@ -197,49 +149,38 @@ class NebulaIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||||
|
|
||||||
// ── "Network" expandable submenu ──
|
// "Network" expandable submenu
|
||||||
this._networkSubmenu = new PopupMenu.PopupSubMenuMenuItem('Network');
|
this._networkSubmenu = new PopupMenu.PopupSubMenuMenuItem('Network');
|
||||||
|
|
||||||
this._uptimeItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
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._uptimeItem);
|
||||||
|
|
||||||
this._rxItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
|
||||||
this._networkSubmenu.menu.addMenuItem(this._rxItem);
|
this._networkSubmenu.menu.addMenuItem(this._rxItem);
|
||||||
|
|
||||||
this._txItem = new PopupMenu.PopupMenuItem('', {reactive: false});
|
|
||||||
this._networkSubmenu.menu.addMenuItem(this._txItem);
|
this._networkSubmenu.menu.addMenuItem(this._txItem);
|
||||||
|
|
||||||
this.menu.addMenuItem(this._networkSubmenu);
|
this.menu.addMenuItem(this._networkSubmenu);
|
||||||
|
|
||||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
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');
|
const refreshItem = new PopupMenu.PopupMenuItem('Refresh');
|
||||||
refreshItem.connect('activate', () => this._refresh());
|
refreshItem.connect('activate', () => this._refresh());
|
||||||
this.menu.addMenuItem(refreshItem);
|
this.menu.addMenuItem(refreshItem);
|
||||||
|
|
||||||
// ── initial state + polling ──
|
// ── wire up the toggle click ───────────────────────────────
|
||||||
|
this.connect('clicked', () => this._onToggle());
|
||||||
|
|
||||||
|
// ── initial state + polling ────────────────────────────────
|
||||||
this._active = false;
|
this._active = false;
|
||||||
log('NebulaIndicator._init: running initial refresh');
|
|
||||||
this._refresh();
|
this._refresh();
|
||||||
this._timerId = GLib.timeout_add_seconds(
|
this._timerId = GLib.timeout_add_seconds(
|
||||||
GLib.PRIORITY_DEFAULT,
|
GLib.PRIORITY_DEFAULT,
|
||||||
POLL_INTERVAL_SECS,
|
POLL_INTERVAL_SECS,
|
||||||
() => { this._refresh(); return GLib.SOURCE_CONTINUE; },
|
() => { 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() {
|
_refresh() {
|
||||||
log('_refresh: checking service and interface');
|
log('_refresh: polling');
|
||||||
this._active = isServiceActive();
|
this._active = isServiceActive();
|
||||||
const iface = getIfaceInfo();
|
const iface = getIfaceInfo();
|
||||||
const stats = getIfaceStats();
|
const stats = getIfaceStats();
|
||||||
@@ -250,40 +191,38 @@ class NebulaIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
_updateUI(iface, stats, uptime) {
|
_updateUI(iface, stats, uptime) {
|
||||||
log(`_updateUI: active=${this._active}`);
|
log(`_updateUI: active=${this._active}`);
|
||||||
|
|
||||||
|
// Sync the toggle checked state without re-triggering the click handler
|
||||||
|
this.set({checked: this._active});
|
||||||
|
|
||||||
if (this._active) {
|
if (this._active) {
|
||||||
this._icon.icon_name = 'network-vpn-symbolic';
|
this.subtitle = iface.ipv4 ?? 'connected';
|
||||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #57e389;';
|
this._statusItem.label.text = 'Status: Connected';
|
||||||
this._statusItem.label.text = 'Status: Connected';
|
this._ipv4Item.label.text = iface.ipv4 ? `IPv4: ${iface.ipv4}` : 'IPv4: —';
|
||||||
this._ipv4Item.label.text = iface.ipv4 ? `IPv4: ${iface.ipv4}` : 'IPv4: —';
|
this._ipv6Item.label.text = iface.ipv6 ? `IPv6: ${iface.ipv6}` : 'IPv6: —';
|
||||||
this._ipv6Item.label.text = iface.ipv6 ? `IPv6: ${iface.ipv6}` : 'IPv6: —';
|
this._ipv4Item.visible = true;
|
||||||
this._ipv4Item.visible = true;
|
this._ipv6Item.visible = true;
|
||||||
this._ipv6Item.visible = true;
|
|
||||||
this._toggleItem.label.text = 'Disconnect';
|
|
||||||
this._networkSubmenu.visible = true;
|
this._networkSubmenu.visible = true;
|
||||||
this._uptimeItem.label.text = `Uptime: ${uptime ?? '—'}`;
|
this._uptimeItem.label.text = `Uptime: ${uptime ?? '—'}`;
|
||||||
this._rxItem.label.text = `RX: ${formatBytes(stats.rx)}`;
|
this._rxItem.label.text = `RX: ${formatBytes(stats.rx)}`;
|
||||||
this._txItem.label.text = `TX: ${formatBytes(stats.tx)}`;
|
this._txItem.label.text = `TX: ${formatBytes(stats.tx)}`;
|
||||||
} else {
|
} else {
|
||||||
this._icon.icon_name = 'network-vpn-disabled-symbolic';
|
this.subtitle = null;
|
||||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #c01c28;';
|
this._statusItem.label.text = 'Status: Disconnected';
|
||||||
this._statusItem.label.text = 'Status: Disconnected';
|
this._ipv4Item.visible = false;
|
||||||
this._ipv4Item.visible = false;
|
this._ipv6Item.visible = false;
|
||||||
this._ipv6Item.visible = false;
|
|
||||||
this._toggleItem.label.text = 'Connect';
|
|
||||||
this._networkSubmenu.visible = false;
|
this._networkSubmenu.visible = false;
|
||||||
}
|
}
|
||||||
// label is always "VPN"
|
|
||||||
this._label.text = 'VPN';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onToggle() {
|
_onToggle() {
|
||||||
const target = !this._active;
|
const target = !this._active;
|
||||||
log(`_onToggle: toggling to ${target ? 'active' : 'inactive'}`);
|
log(`_onToggle: → ${target ? 'start' : 'stop'}`);
|
||||||
setServiceActive(target);
|
setServiceActive(target);
|
||||||
// Optimistic update while systemd races
|
// Optimistic update
|
||||||
this._active = target;
|
this._active = target;
|
||||||
this._updateUI(getIfaceInfo(), getIfaceStats(), null);
|
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, () => {
|
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => {
|
||||||
log('_onToggle: confirmation poll');
|
log('_onToggle: confirmation poll');
|
||||||
this._refresh();
|
this._refresh();
|
||||||
@@ -291,10 +230,8 @@ class NebulaIndicator extends PanelMenu.Button {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── cleanup ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
log('NebulaIndicator.destroy');
|
log('NebulaToggle.destroy');
|
||||||
if (this._timerId) {
|
if (this._timerId) {
|
||||||
GLib.source_remove(this._timerId);
|
GLib.source_remove(this._timerId);
|
||||||
this._timerId = null;
|
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 {
|
export default class NebulaVpnExtension extends Extension {
|
||||||
enable() {
|
enable() {
|
||||||
log('enable: creating indicator');
|
log('enable');
|
||||||
this._indicator = new NebulaIndicator(this.path);
|
nebulaIndicator = new NebulaIndicator();
|
||||||
Main.panel.addToStatusArea(this.uuid, this._indicator);
|
|
||||||
log('enable: indicator added to panel');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
log('disable: destroying indicator');
|
log('disable');
|
||||||
this._indicator?.destroy();
|
nebulaIndicator?.destroy();
|
||||||
this._indicator = null;
|
nebulaIndicator = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user