ext
This commit is contained in:
@@ -33,6 +33,7 @@ in
|
||||
gnomeExtensions.boatman-winboat-monitor
|
||||
papirus-icon-theme
|
||||
pop-gtk-theme
|
||||
pkgs.mjallen.gnome-nebula-vpn
|
||||
];
|
||||
|
||||
dconf = {
|
||||
@@ -68,6 +69,7 @@ in
|
||||
"dash-to-dock@micxgx.gmail.com"
|
||||
"BingWallpaper@ineffable-gmail.com"
|
||||
"gsconnect@andyholmes.github.io"
|
||||
"nebula-vpn-status@mjallen"
|
||||
];
|
||||
"org/gnome/shell/extensions/bingwallpaper" = {
|
||||
override-lockscreen-blur = true;
|
||||
|
||||
@@ -89,6 +89,21 @@ let
|
||||
moduleConfig = {
|
||||
environment.systemPackages = with pkgs; [ nebula ];
|
||||
|
||||
# Allow users in the wheel group to start/stop the nebula service without
|
||||
# a password prompt (used by the GNOME panel extension toggle).
|
||||
security.polkit.extraConfig = ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "nebula@${cfg.networkName}.service" &&
|
||||
(action.lookup("verb") == "start" || action.lookup("verb") == "stop") &&
|
||||
subject.local == true &&
|
||||
subject.active == true &&
|
||||
subject.isInGroup("wheel")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
|
||||
services.nebula.networks.${cfg.networkName} = {
|
||||
enable = true;
|
||||
enableReload = true;
|
||||
|
||||
29
packages/system/gnome-nebula-vpn/default.nix
Normal file
29
packages/system/gnome-nebula-vpn/default.nix
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
stdenvNoCC,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "gnome-nebula-vpn";
|
||||
version = "1.0.0";
|
||||
|
||||
src = ./extension;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
uuid="nebula-vpn-status@mjallen"
|
||||
dest="$out/share/gnome-shell/extensions/$uuid"
|
||||
mkdir -p "$dest"
|
||||
cp -r . "$dest/"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "GNOME Shell extension showing Nebula VPN status with interface info and a toggle";
|
||||
license = lib.licenses.mit;
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
203
packages/system/gnome-nebula-vpn/extension/extension.js
Normal file
203
packages/system/gnome-nebula-vpn/extension/extension.js
Normal file
@@ -0,0 +1,203 @@
|
||||
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 = 'jallen-nebula';
|
||||
|
||||
const POLL_INTERVAL_SECS = 5;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run a command and return { success, stdout, stderr }.
|
||||
* The command is passed as an argv array (no shell expansion).
|
||||
*/
|
||||
function runCommand(argv) {
|
||||
try {
|
||||
const [ok, stdout, stderr, exitCode] = GLib.spawn_sync(
|
||||
null, // working dir
|
||||
argv,
|
||||
null, // inherit env
|
||||
GLib.SpawnFlags.SEARCH_PATH,
|
||||
null,
|
||||
);
|
||||
return {
|
||||
success: ok && exitCode === 0,
|
||||
stdout: ok ? new TextDecoder().decode(stdout).trim() : '',
|
||||
stderr: ok ? new TextDecoder().decode(stderr).trim() : '',
|
||||
};
|
||||
} catch (_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,
|
||||
]);
|
||||
return r.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interface info for IFACE_NAME.
|
||||
* Returns { found, ipv4, ipv6 } where ipv4/ipv6 are strings or null.
|
||||
*/
|
||||
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]);
|
||||
if (!r.success || r.stdout === '') {
|
||||
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;
|
||||
return {found: true, ipv4, ipv6};
|
||||
}
|
||||
|
||||
/** Start or stop the systemd service (requires polkit / passwordless sudo). */
|
||||
function setServiceActive(enable) {
|
||||
const action = enable ? 'start' : 'stop';
|
||||
runCommand(['systemctl', action, SERVICE_NAME]);
|
||||
}
|
||||
|
||||
// ── indicator ────────────────────────────────────────────────────────────────
|
||||
|
||||
const NebulaIndicator = GObject.registerClass(
|
||||
class NebulaIndicator extends PanelMenu.Button {
|
||||
_init(extensionPath) {
|
||||
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);
|
||||
|
||||
// ── dropdown menu ──
|
||||
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());
|
||||
|
||||
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;
|
||||
this._refresh();
|
||||
this._timerId = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
POLL_INTERVAL_SECS,
|
||||
() => { this._refresh(); return GLib.SOURCE_CONTINUE; },
|
||||
);
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
_refresh() {
|
||||
this._active = isServiceActive();
|
||||
const iface = getIfaceInfo();
|
||||
this._updateUI(iface);
|
||||
}
|
||||
|
||||
_updateUI(iface) {
|
||||
if (this._active) {
|
||||
this._icon.icon_name = 'network-vpn-symbolic';
|
||||
this._label.text = 'VPN';
|
||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #57e389;'; // green
|
||||
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';
|
||||
} else {
|
||||
this._icon.icon_name = 'network-vpn-disabled-symbolic';
|
||||
this._label.text = 'VPN';
|
||||
this._label.style = 'margin-left: 4px; margin-right: 2px; color: #c01c28;'; // red
|
||||
this._statusItem.label.text = `Status: Disconnected`;
|
||||
this._ipv4Item.visible = false;
|
||||
this._ipv6Item.visible = false;
|
||||
this._toggleItem.label.text = 'Connect';
|
||||
}
|
||||
}
|
||||
|
||||
_onToggle() {
|
||||
setServiceActive(!this._active);
|
||||
// Optimistic update while systemd races
|
||||
this._active = !this._active;
|
||||
this._updateUI(getIfaceInfo());
|
||||
// Re-poll shortly after to confirm real state
|
||||
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => {
|
||||
this._refresh();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
// ── cleanup ───────────────────────────────────────────────────────────────
|
||||
|
||||
destroy() {
|
||||
if (this._timerId) {
|
||||
GLib.source_remove(this._timerId);
|
||||
this._timerId = null;
|
||||
}
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// ── extension lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
export default class NebulaVpnExtension extends Extension {
|
||||
enable() {
|
||||
this._indicator = new NebulaIndicator(this.path);
|
||||
Main.panel.addToStatusArea(this.uuid, this._indicator);
|
||||
}
|
||||
|
||||
disable() {
|
||||
this._indicator?.destroy();
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
7
packages/system/gnome-nebula-vpn/extension/metadata.json
Normal file
7
packages/system/gnome-nebula-vpn/extension/metadata.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Nebula VPN Status",
|
||||
"description": "Shows the status of the Nebula VPN in the GNOME panel with interface info and a toggle.",
|
||||
"uuid": "nebula-vpn-status@mjallen",
|
||||
"shell-version": ["45", "46", "47", "48"],
|
||||
"version": 1
|
||||
}
|
||||
Reference in New Issue
Block a user