#!/usr/bin/env python3 """NexusOS network popup daemon — toggle via SIGUSR1, instant open.""" # PyGObject (gi.repository) is dynamically generated and its API is Optional-heavy # (e.g. Gdk.Display.get_default() is typed Display|None). These are fine at runtime, # so silence the type-checker noise for this GTK desktop script. # pyright: reportMissingModuleSource=false, reportOptionalMemberAccess=false, reportArgumentType=false, reportCallIssue=false, reportAttributeAccessIssue=false import os, sys, signal, threading, subprocess from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gi gi.require_version('Gtk', '3.0') gi.require_version('Gdk', '3.0') gi.require_version('GdkPixbuf', '2.0') from gi.repository import Gtk, Gdk, GLib, GdkPixbuf from nexus_menu_base import apply_css, get_panel_bottom, get_mouse_position PID_FILE = "/tmp/nexus-network-popup.pid" VISIBLE_FILE = "/tmp/nexus-network-popup-visible" POPUP_CSS = b""" list { background-color: transparent; } list row { background-color: transparent; padding: 0; border: none; } list row:hover { background-color: rgba(140,198,63,0.12); } list row:selected { background-color: rgba(140,198,63,0.18); } .net-header { padding: 10px 12px; } .net-ssid { font-weight: bold; } .net-ip { color: #a8a8a8; font-size: 0.85em; } .net-check { color: #8cc63f; font-weight: bold; } .net-lock { color: #a8a8a8; } .net-row-box { padding: 7px 8px; } .footer-btn { padding: 6px 12px; border-radius: 0; border: none; background-color: transparent; color: #a8a8a8; } .footer-btn:hover { background-color: rgba(140,198,63,0.12); color: #f2f2f2; } .vpn-row { padding: 8px 12px; } .vpn-label { font-size: 0.9em; } .vpn-status { color: #a8a8a8; font-size: 0.8em; } switch { background-color: #3a3d41; border-radius: 14px; border: 1px solid #4d3461; min-width: 42px; min-height: 22px; } switch:checked { background-color: #8cc63f; border-color: #6ba62a; } switch slider { background-color: #f2f2f2; border-radius: 50%; min-width: 16px; min-height: 16px; margin: 2px; } """ def nmcli(*args): try: return subprocess.check_output( ["nmcli", "-t", "--escape", "no"] + list(args), text=True, stderr=subprocess.DEVNULL ).strip() except Exception: return "" # This file lives at /bin/panel/network-popup.py, so the repo root is # three parents up (panel -> bin -> repo). Using only two parents pointed # ICON_DIR at the nonexistent bin/assets/panel-icons, so every signal-icon # load failed and each SSID row fell back to a "?" label. ICON_DIR = str(Path(__file__).resolve().parent.parent.parent / "assets" / "panel-icons") def signal_icon(sig): if sig >= 80: return f"{ICON_DIR}/network-wireless-signal-excellent.png" if sig >= 60: return f"{ICON_DIR}/network-wireless-signal-good.png" if sig >= 40: return f"{ICON_DIR}/network-wireless-signal-ok.png" if sig >= 20: return f"{ICON_DIR}/network-wireless-signal-weak.png" return f"{ICON_DIR}/network-wireless-signal-none.png" class NetworkPopup: def __init__(self): self.visible = False self._click_x = None self.vpn_switch = None self.vpn_status_lbl = None self._build_window() def _on_sig(s, f): # Capture mouse position NOW (signal handler runs in main thread) try: self._click_x = get_mouse_position()[0] except Exception: self._click_x = None GLib.idle_add(self.toggle) signal.signal(signal.SIGUSR1, _on_sig) with open(PID_FILE, 'w') as f: f.write(str(os.getpid())) def _build_window(self): self.win = Gtk.Window(type=Gtk.WindowType.POPUP) self.win.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU) self.win.set_decorated(False) self.win.set_skip_taskbar_hint(True) self.win.set_keep_above(True) self.win.set_default_size(190, -1) self.win.connect('key-press-event', lambda w, e: self.hide() if e.keyval == Gdk.KEY_Escape else None) def on_button_press(w, event): wx, wy = w.get_position() ww, wh = w.get_allocated_width(), w.get_allocated_height() if (int(event.x_root) < wx or int(event.x_root) >= wx + ww or int(event.y_root) < wy or int(event.y_root) >= wy + wh): self.hide() return False self.win.connect('button-press-event', on_button_press) def on_map(w, _): gdk_win = w.get_window() if gdk_win: Gdk.Display.get_default().get_default_seat().grab( gdk_win, Gdk.SeatCapabilities.ALL, True, None, None, None) return False self.win.connect('map-event', on_map) def on_unmap(w, _): Gdk.Display.get_default().get_default_seat().ungrab() self.win.connect('unmap-event', on_unmap) def toggle(self): if self.visible: self.hide() else: self.show() def show(self): for child in self.win.get_children(): self.win.remove(child) self.win.add(self._build_content()) self.win.show_all() self._position() self.win.present() self.visible = True open(VISIBLE_FILE, 'w').close() def hide(self): self.win.hide() self.visible = False try: os.remove(VISIBLE_FILE) except FileNotFoundError: pass def _position(self): mx = self._click_x if self._click_x is not None else get_mouse_position()[0] screen = Gdk.Screen.get_default() sw = screen.get_width() w = 190 panel_bottom = get_panel_bottom() x = max(4, min(mx - w // 2, sw - w - 4)) self.win.move(x, panel_bottom + 2) def _build_content(self): outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # ── Header (current connection) ────────────────────────────── self.header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self.header.get_style_context().add_class("net-header") spin = Gtk.Spinner(); spin.start() self.header.pack_start(spin, False, False, 0) self.header.pack_start(Gtk.Label(label="Loading…"), True, True, 0) outer.pack_start(self.header, False, False, 0) outer.pack_start(Gtk.Separator(), False, False, 0) # ── Network list ───────────────────────────────────────────── self.listbox = Gtk.ListBox() self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) self.listbox.connect('row-activated', self._on_row_activated) sw = Gtk.ScrolledWindow() sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) sw.set_min_content_height(280) sw.set_max_content_height(480) sw.set_propagate_natural_height(True) sw.add(self.listbox) outer.pack_start(sw, True, True, 0) outer.pack_start(Gtk.Separator(), False, False, 0) # ── VPN (WireGuard) toggle ──────────────────────────────────── vpn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) vpn_row.get_style_context().add_class("vpn-row") vpn_icon = Gtk.Label(label="🔒") vpn_row.pack_start(vpn_icon, False, False, 0) vpn_text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vpn_name = Gtk.Label(label="WireGuard", xalign=0.0) vpn_name.get_style_context().add_class("vpn-label") self.vpn_status_lbl = Gtk.Label(label="Checking…", xalign=0.0) self.vpn_status_lbl.get_style_context().add_class("vpn-status") vpn_text.pack_start(vpn_name, False, False, 0) vpn_text.pack_start(self.vpn_status_lbl, False, False, 0) vpn_row.pack_start(vpn_text, True, True, 0) self.vpn_switch = Gtk.Switch() self.vpn_switch.set_valign(Gtk.Align.CENTER) self._vpn_handler = self.vpn_switch.connect('state-set', self._on_vpn_toggle) vpn_row.pack_start(self.vpn_switch, False, False, 0) # Set switch state immediately from sysfs — no nmcli round-trip needed vpn_up = os.path.exists('/sys/class/net/wgs_client') self.vpn_switch.handler_block(self._vpn_handler) self.vpn_switch.set_active(vpn_up) self.vpn_switch.handler_unblock(self._vpn_handler) self.vpn_status_lbl.set_text("Connected" if vpn_up else "Disconnected") outer.pack_start(vpn_row, False, False, 0) outer.pack_start(Gtk.Separator(), False, False, 0) # ── Footer ─────────────────────────────────────────────────── btn = Gtk.Button(label="Network Settings") btn.get_style_context().add_class("footer-btn") btn.set_relief(Gtk.ReliefStyle.NONE) btn.connect('clicked', lambda _: (self.hide(), subprocess.Popen(['nm-connection-editor']))) outer.pack_start(btn, False, False, 0) threading.Thread(target=self._fetch, daemon=True).start() return outer def _fetch(self): conn_type, device, current_ssid, ip = "", "", "", "" devs = nmcli("-f", "TYPE,STATE,DEVICE", "dev") for line in devs.splitlines(): parts = line.split(":") if len(parts) >= 2 and parts[1] == "connected": conn_type = parts[0] device = parts[2] if len(parts) > 2 else "" break if conn_type == "wifi": raw = nmcli("-f", "ACTIVE,SSID,SIGNAL", "dev", "wifi") for line in raw.splitlines(): parts = line.split(":") if parts[0] == "yes" and len(parts) >= 2: current_ssid = parts[1] break ip = (nmcli("-f", "IP4.ADDRESS", "dev", "show") .split("\n")[0].split(":")[-1].split("/")[0].strip()) elif conn_type == "ethernet": ip = (nmcli("-f", "IP4.ADDRESS", "dev", "show") .split("\n")[0].split(":")[-1].split("/")[0].strip()) raw = nmcli("-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list", "--rescan", "no") seen, networks = set(), [] for line in raw.splitlines(): parts = line.split(":") if len(parts) < 2: continue ssid = parts[0].strip() if not ssid or ssid in seen: continue seen.add(ssid) try: sig = int(parts[1]) except: sig = 0 sec = parts[2].strip() if len(parts) > 2 else "Open" in_use = (parts[3].strip() == "*") if len(parts) > 3 else False networks.append((ssid, sig, sec, in_use)) networks.sort(key=lambda r: (not r[3], -r[1])) GLib.idle_add(self._populate, conn_type, device, current_ssid, ip, networks[:20]) def _populate(self, conn_type, device, current_ssid, ip, networks): # Rebuild header for child in self.header.get_children(): self.header.remove(child) if conn_type == "wifi" and current_ssid: vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) lbl_ssid = Gtk.Label(label=current_ssid, xalign=0.0) lbl_ssid.get_style_context().add_class("net-ssid") lbl_ip = Gtk.Label(label=ip or "no IP", xalign=0.0) lbl_ip.get_style_context().add_class("net-ip") vbox.pack_start(lbl_ssid, False, False, 0) vbox.pack_start(lbl_ip, False, False, 0) self.header.pack_start(vbox, True, True, 0) btn_dis = Gtk.Button(label="Disconnect") btn_dis.get_style_context().add_class("footer-btn") btn_dis.set_relief(Gtk.ReliefStyle.NONE) btn_dis.connect('clicked', lambda _, d=device: ( self.hide(), subprocess.Popen(["bash", "-c", f"nmcli dev disconnect {d}; notify-send Network Disconnected"]) )) self.header.pack_start(btn_dis, False, False, 0) elif conn_type == "ethernet": lbl = Gtk.Label(label=f"Wired {ip or ''}", xalign=0.0) self.header.pack_start(lbl, True, True, 0) else: lbl = Gtk.Label(label="Not connected", xalign=0.0) lbl.get_style_context().add_class("net-ip") self.header.pack_start(lbl, True, True, 0) self.header.show_all() for ssid, sig, sec, in_use in networks: row = Gtk.ListBoxRow() row.ssid = ssid row.sec = sec row.in_use = in_use box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) box.get_style_context().add_class("net-row-box") try: pb = GdkPixbuf.Pixbuf.new_from_file_at_size(signal_icon(sig), 16, 16) img = Gtk.Image.new_from_pixbuf(pb) except Exception: img = Gtk.Label(label="?") box.pack_start(img, False, False, 0) ssid_lbl = Gtk.Label(label=ssid, xalign=0.0) ssid_lbl.set_ellipsize(3) if in_use: ssid_lbl.get_style_context().add_class("net-ssid") box.pack_start(ssid_lbl, True, True, 0) if sec and sec.lower() not in ("open", "--", ""): lock = Gtk.Label(label="") lock.get_style_context().add_class("net-lock") box.pack_start(lock, False, False, 0) if in_use: chk = Gtk.Label(label="✓") chk.get_style_context().add_class("net-check") box.pack_start(chk, False, False, 0) row.add(box) self.listbox.add(row) self.listbox.show_all() # Re-position now that height is known GLib.idle_add(self._position) def _on_vpn_toggle(self, switch, state): threading.Thread(target=self._do_vpn_toggle, args=(state,), daemon=True).start() return False # let GTK move the switch immediately; revert on failure def _do_vpn_toggle(self, enable): action = "up" if enable else "down" try: subprocess.check_output( ["nmcli", "connection", action, "wgs_client"], stderr=subprocess.STDOUT, text=True ) label = "Connected" if enable else "Disconnected" subprocess.Popen(["notify-send", "WireGuard", label]) GLib.idle_add(self._apply_vpn_state, enable, label) except subprocess.CalledProcessError as e: msg = (e.output.strip().split("\n")[-1] if e.output else "Unknown error") subprocess.Popen(["notify-send", "-u", "critical", "WireGuard", f"Failed: {msg}"]) # Revert switch on failure GLib.idle_add(self._apply_vpn_state, not enable, "Connected" if not enable else "Disconnected") def _apply_vpn_state(self, active, label): if self.vpn_switch: self.vpn_switch.handler_block(self._vpn_handler) self.vpn_switch.set_active(active) self.vpn_switch.handler_unblock(self._vpn_handler) if self.vpn_status_lbl: self.vpn_status_lbl.set_text(label) def _on_row_activated(self, lb, row): if row.in_use: return self.hide() if row.sec and row.sec.lower() not in ("open", "--", ""): self._show_password_prompt(row.ssid) else: self._do_connect(row.ssid) def _do_connect(self, ssid, password=None): def run(): cmd = ["nmcli", "dev", "wifi", "connect", ssid] if password: cmd += ["password", password] try: subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True) subprocess.Popen(["notify-send", "Network", f"Connected to {ssid}"]) except subprocess.CalledProcessError as e: msg = (e.output.strip().split("\n")[-1] if e.output else "Unknown error") subprocess.Popen(["notify-send", "-u", "critical", "Network", f"Failed to connect to {ssid}:\n{msg}"]) subprocess.Popen(["notify-send", "Network", f"Connecting to {ssid}…"]) threading.Thread(target=run, daemon=True).start() def _show_password_prompt(self, ssid): dlg = Gtk.Window() dlg.set_title(f"Connect to {ssid}") dlg.set_default_size(300, -1) dlg.set_position(Gtk.WindowPosition.CENTER) dlg.set_keep_above(True) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_top(16); box.set_margin_bottom(16) box.set_margin_start(16); box.set_margin_end(16) lbl = Gtk.Label(xalign=0.0) lbl.set_markup(f'Password for "{ssid}"') entry = Gtk.Entry() entry.set_visibility(False) entry.set_placeholder_text("Enter password…") btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) btn_box.set_halign(Gtk.Align.END) btn_cancel = Gtk.Button(label="Cancel") btn_connect = Gtk.Button(label="Connect") btn_box.pack_start(btn_cancel, False, False, 0) btn_box.pack_start(btn_connect, False, False, 0) box.pack_start(lbl, False, False, 0) box.pack_start(entry, False, False, 0) box.pack_start(btn_box, False, False, 0) dlg.add(box) def on_confirm(_): pw = entry.get_text() dlg.destroy() if pw: self._do_connect(ssid, pw) entry.connect("activate", on_confirm) btn_connect.connect("clicked", on_confirm) btn_cancel.connect("clicked", lambda _: dlg.destroy()) dlg.connect("key-press-event", lambda w, e: w.destroy() if e.keyval == Gdk.KEY_Escape else None) dlg.show_all() dlg.present() def main(): # If already running, toggle it and exit try: with open(PID_FILE) as f: pid = int(f.read().strip()) os.kill(pid, signal.SIGUSR1) sys.exit(0) except (FileNotFoundError, ProcessLookupError, ValueError): pass provider = Gtk.CssProvider() provider.load_from_data(POPUP_CSS) Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) apply_css() NetworkPopup() Gtk.main() if __name__ == "__main__": main()