"""Network quad. Compact: Wi-Fi enable + primary connection/IP (AstalNetwork + backend/network.sh). Expanded: a small NetworkManager control panel over backend/nm.py, as four tabs — Adapters : each device foldout with editable IPv4/IPv6 (DHCP toggle, address, subnet-mask <-> CIDR, gateway); applied via `nmcli connection modify/up` Routes : the kernel routing table (read-only; editing kernel routes needs root) VLAN : list / create / activate / delete VLAN connections DNS : effective servers per device + a per-connection override table Every tab is an independently toggle-able feature. """ from __future__ import annotations import ipaddress import sys import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk # noqa: E402 from lib.proc import run_json from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec from paths import BACKEND_DIR _NET = str(BACKEND_DIR / "network.sh") _NM = str(BACKEND_DIR / "nm.py") def _nm(args: list[str], cb) -> None: """Run a backend/nm.py subcommand expecting JSON on stdout.""" run_json([sys.executable, _NM, *args], cb) def _primary_ip(data) -> str: if not isinstance(data, list): return "—" for iface in data: if iface.get("ifname") == "lo": continue for a in iface.get("addr_info", []): if a.get("family") == "inet": return f"{a['local']}/{a.get('prefixlen', '')} ({iface.get('ifname')})" return "—" # -- subnet mask <-> CIDR prefix ------------------------------------------ def _mask_to_prefix(mask: str) -> int | None: try: return ipaddress.IPv4Network(f"0.0.0.0/{mask.strip()}").prefixlen except (ipaddress.NetmaskValueError, ValueError): return None def _prefix_to_mask(prefix: int) -> str: return str(ipaddress.IPv4Network(f"0.0.0.0/{prefix}").netmask) class _Compact(Gtk.Box): def __init__(self, ctx: ModuleContext): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) self.add_css_class("net-view") self.ctx = ctx self.net = ctx.services.network row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) self._primary = Gtk.Label(label="…", xalign=0.0, hexpand=True) row.append(Gtk.Label(label="")) # nf wifi row.append(self._primary) wifi = self.net.get_wifi() if self.net else None self._wifi_switch = Gtk.Switch(active=bool(wifi and wifi.get_enabled()), valign=Gtk.Align.CENTER) self._wifi_switch.set_tooltip_text("Wi-Fi") self._wifi_switch.connect("state-set", self._on_wifi_toggle) row.append(self._wifi_switch) self.append(row) self._ip = Gtk.Label(label="", xalign=0.0) self._ip.add_css_class("net-ip") self.append(self._ip) if self.net: self.net.connect("notify::primary", lambda *_: self.refresh()) if wifi: wifi.connect("notify::ssid", lambda *_: self.refresh()) self.refresh() def _on_wifi_toggle(self, _sw, value: bool) -> bool: wifi = self.net.get_wifi() if self.net else None if wifi: wifi.set_enabled(value) return False def refresh(self) -> None: wifi = self.net.get_wifi() if self.net else None if wifi and wifi.get_active_access_point(): self._primary.set_text(f"{wifi.get_ssid() or '?'} · {wifi.get_strength()}%") elif self.net and self.net.get_wired() and self.net.get_wired().get_internet(): self._primary.set_text("Wired") else: self._primary.set_text("Disconnected") run_json([_NET, "ip"], lambda ok, d: self._ip.set_text(_primary_ip(d) if ok else "—")) # -- shared little widgets ------------------------------------------------- def _clear(box: Gtk.Box) -> None: child = box.get_first_child() while child: box.remove(child) child = box.get_first_child() def _field(placeholder: str, text: str = "") -> Gtk.Entry: e = Gtk.Entry(placeholder_text=placeholder, text=text, hexpand=True) e.add_css_class("net-entry") return e def _labeled(label: str, widget: Gtk.Widget) -> Gtk.Widget: row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) lbl = Gtk.Label(label=label, xalign=0.0) lbl.set_size_request(90, -1) row.append(lbl) row.append(widget) return row def _pill(label: str, cb) -> Gtk.Button: b = Gtk.Button(label=label) b.add_css_class("quad-action") b.connect("clicked", lambda *_: cb()) return b # -- IPv4 / IPv6 editor for one connection -------------------------------- class _IPSection(Gtk.Box): """One address family's editor. IPv4 exposes a linked subnet-mask/CIDR pair; IPv6 exposes a prefix-length field. Fields are only sensitive in Manual mode.""" def __init__(self, con: str, family: str, fam_data: dict, on_result): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.con = con self.family = family self._on_result = on_result self.add_css_class("net-view") title = "IPv4" if family == "ipv4" else "IPv6" head = Gtk.CenterBox() head.set_start_widget(Gtk.Label(label=title, xalign=0.0)) auto = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) auto.append(Gtk.Label(label="Automatic")) self._dhcp = Gtk.Switch(active=(fam_data.get("method", "auto") != "manual"), valign=Gtk.Align.CENTER) self._dhcp.connect("state-set", self._on_mode) auto.append(self._dhcp) head.set_end_widget(auto) self.append(head) addr, prefix = self._split(fam_data.get("addresses", [])) self._addr = _field("Address", addr) self._gw = _field("Gateway", fam_data.get("gateway", "")) self._fields = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self._fields.append(_labeled("Address", self._addr)) if family == "ipv4": self._guard = False self._mask = _field("255.255.255.0", _prefix_to_mask(prefix) if prefix else "") self._cidr = _field("/24", f"/{prefix}" if prefix else "") self._cidr.set_size_request(70, -1) self._mask.connect("changed", self._sync_from_mask) self._cidr.connect("changed", self._sync_from_cidr) mask_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) mask_row.append(self._mask) mask_row.append(self._cidr) self._fields.append(_labeled("Netmask", mask_row)) else: self._prefix = _field("64", str(prefix) if prefix else "") self._fields.append(_labeled("Prefix", self._prefix)) self._fields.append(_labeled("Gateway", self._gw)) apply = Gtk.Button(label=f"Apply {title}") apply.add_css_class("enable-btn") apply.connect("clicked", lambda *_: self._apply()) self._fields.append(apply) self.append(self._fields) self._set_manual_enabled(not self._dhcp.get_active()) def _set_manual_enabled(self, on: bool) -> None: # The reset GTK theme doesn't dim insensitive widgets, so fade the manual # fields explicitly when DHCP/Automatic is active. self._fields.set_sensitive(on) self._fields.set_opacity(1.0 if on else 0.45) @staticmethod def _split(addresses: list[str]) -> tuple[str, int | None]: if not addresses: return "", None addr, _, prefix = addresses[0].partition("/") try: return addr, int(prefix) except ValueError: return addr, None def _on_mode(self, _sw, auto: bool) -> bool: self._set_manual_enabled(not auto) return False # keep the dotted mask and /CIDR fields in lock-step (IPv4 only) def _sync_from_mask(self, _e) -> None: if self._guard: return p = _mask_to_prefix(self._mask.get_text()) if p is not None: self._guard = True self._cidr.set_text(f"/{p}") self._guard = False def _sync_from_cidr(self, _e) -> None: if self._guard: return raw = self._cidr.get_text().lstrip("/").strip() if raw.isdigit() and 0 <= int(raw) <= 32: self._guard = True self._mask.set_text(_prefix_to_mask(int(raw))) self._guard = False def _prefixlen(self) -> str: if self.family == "ipv4": raw = self._cidr.get_text().lstrip("/").strip() if raw.isdigit(): return raw p = _mask_to_prefix(self._mask.get_text()) return str(p) if p is not None else "" return self._prefix.get_text().strip() def _apply(self) -> None: if self._dhcp.get_active(): args = ["set-ip", self.con, self.family, "auto"] else: addr, plen = self._addr.get_text().strip(), self._prefixlen() cidr = f"{addr}/{plen}" if addr and plen else addr args = ["set-ip", self.con, self.family, "manual", cidr, self._gw.get_text().strip(), ""] _nm(args, lambda ok, d: self._on_result( ok and isinstance(d, dict) and d.get("ok"), (d.get("error") if isinstance(d, dict) else "") or "")) class _AdaptersPage(Gtk.Box): def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) self._status = Gtk.Label(xalign=0.0) self._status.add_css_class("net-ip") self._status.set_visible(False) self.append(self._status) self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) scroller.set_child(self._list) self.append(scroller) self._reload() def _reload(self) -> None: _nm(["adapters"], self._populate) def _populate(self, ok: bool, adapters) -> None: _clear(self._list) if not ok or not isinstance(adapters, list) or not adapters: self._list.append(Gtk.Label(label="No manageable adapters", xalign=0.0)) return for a in adapters: self._list.append(self._adapter_row(a)) def _adapter_row(self, a: dict) -> Gtk.Widget: icon = "" if a.get("type") == "wifi" else "" # nf wifi / ethernet exp = Gtk.Expander( label=f"{icon} {a['device']} · {a.get('connection') or '—'} [{a.get('state')}]") exp.add_css_class("net-adapter") body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) body.set_margin_top(8) con = a.get("connection") if not con: body.append(Gtk.Label(label="No connection profile", xalign=0.0)) else: loading = Gtk.Label(label="Loading…", xalign=0.0) body.append(loading) _nm(["ipconfig", con], lambda ok, d, b=body, l=loading, c=con: self._fill(ok, d, b, l, c)) exp.set_child(body) return exp def _fill(self, ok, data, body, loading, con) -> None: body.remove(loading) if not ok or not isinstance(data, dict): body.append(Gtk.Label(label="Could not read config", xalign=0.0)) return result = Gtk.Label(xalign=0.0) result.set_visible(False) def on_result(good: bool, err: str) -> None: result.set_text("Applied" if good else f"Failed: {err or 'error'}") result.set_visible(True) body.append(_IPSection(con, "ipv4", data.get("ipv4", {}), on_result)) body.append(Gtk.Separator()) body.append(_IPSection(con, "ipv6", data.get("ipv6", {}), on_result)) body.append(result) class _RoutesPage(Gtk.Box): def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4) header = Gtk.CenterBox() header.set_start_widget(Gtk.Label(label="Kernel routing table", xalign=0.0)) header.set_end_widget(_pill(" Refresh", self._reload)) self.append(header) self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) scroller.set_child(self._list) self.append(scroller) self._reload() def _reload(self) -> None: _nm(["routes"], self._populate) def _populate(self, ok: bool, routes) -> None: _clear(self._list) if not ok or not isinstance(routes, list) or not routes: self._list.append(Gtk.Label(label="No routes", xalign=0.0)) return for r in routes: self._list.append(Gtk.Label(label=self._fmt(r), xalign=0.0, selectable=True, wrap=True)) @staticmethod def _fmt(r: dict) -> str: dst = r.get("dst", "?") via = f" via {r['gateway']}" if r.get("gateway") else "" dev = f" dev {r['dev']}" if r.get("dev") else "" metric = f" metric {r['metric']}" if r.get("metric") is not None else "" proto = f" ({r['proto']})" if r.get("proto") else "" return f"{dst}{via}{dev}{metric}{proto}" class _VlanPage(Gtk.Box): def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) self._adapters: list[dict] = [] add = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) self._parent = Gtk.DropDown.new_from_strings(["…"]) self._vid = _field("VLAN ID") self._vid.set_size_request(90, -1) add.append(self._parent) add.append(self._vid) add.append(_pill("Add", self._add)) self.append(_labeled("New VLAN", add)) self._status = Gtk.Label(xalign=0.0) self._status.add_css_class("net-ip") self._status.set_visible(False) self.append(self._status) self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) scroller.set_child(self._list) self.append(scroller) _nm(["adapters"], self._set_adapters) self._reload() def _set_adapters(self, ok, adapters) -> None: self._adapters = adapters if ok and isinstance(adapters, list) else [] names = [a["device"] for a in self._adapters] or ["(no devices)"] self._parent.set_model(Gtk.StringList.new(names)) def _reload(self) -> None: _nm(["vlans"], self._populate) def _populate(self, ok, vlans) -> None: _clear(self._list) if not ok or not isinstance(vlans, list) or not vlans: self._list.append(Gtk.Label(label="No VLANs configured", xalign=0.0)) return for v in vlans: self._list.append(self._vlan_row(v)) def _vlan_row(self, v: dict) -> Gtk.Widget: row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) row.add_css_class("net-row") state = "active" if v.get("active") else "inactive" row.append(Gtk.Label(label=f"{v['name']} · {v.get('device') or '—'} [{state}]", xalign=0.0, hexpand=True)) if not v.get("active"): row.append(_pill("Up", lambda n=v["name"]: self._act("vlan-up", n))) row.append(_pill("Delete", lambda n=v["name"]: self._act("vlan-del", n))) return row def _add(self) -> None: idx = self._parent.get_selected() vid = self._vid.get_text().strip() if idx < 0 or idx >= len(self._adapters) or not vid.isdigit(): self._flash("Pick a parent device and numeric VLAN ID") return parent = self._adapters[idx]["device"] _nm(["vlan-add", parent, vid], self._after_write) def _act(self, cmd: str, name: str) -> None: _nm([cmd, name], self._after_write) def _after_write(self, ok, d) -> None: good = ok and isinstance(d, dict) and d.get("ok") self._flash("Done" if good else f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}") self._reload() def _flash(self, msg: str) -> None: self._status.set_text(msg) self._status.set_visible(True) class _DnsPage(Gtk.Box): def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) self._adapters: list[dict] = [] self.append(Gtk.Label(label="Effective servers", xalign=0.0)) self._effective = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) self.append(self._effective) self.append(Gtk.Separator()) self.append(Gtk.Label(label="Override", xalign=0.0)) self._conn = Gtk.DropDown.new_from_strings(["…"]) self.append(_labeled("Connection", self._conn)) self._servers = _field("1.1.1.1 8.8.8.8") self.append(_labeled("Servers", self._servers)) ignore = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) ignore.append(Gtk.Label(label="Ignore automatic", hexpand=True, xalign=0.0)) self._ignore = Gtk.Switch(active=True, valign=Gtk.Align.CENTER) ignore.append(self._ignore) self.append(ignore) self.append(_pill("Apply DNS", self._apply)) self._status = Gtk.Label(xalign=0.0) self._status.add_css_class("net-ip") self._status.set_visible(False) self.append(self._status) _nm(["adapters"], self._set_adapters) _nm(["dns"], self._show_effective) def _set_adapters(self, ok, adapters) -> None: self._adapters = [a for a in (adapters or []) if a.get("connection")] \ if ok and isinstance(adapters, list) else [] names = [f"{a['connection']} ({a['device']})" for a in self._adapters] or ["(none)"] self._conn.set_model(Gtk.StringList.new(names)) def _show_effective(self, ok, data) -> None: _clear(self._effective) if not ok or not isinstance(data, list) or not data: self._effective.append(Gtk.Label(label="—", xalign=0.0)) return for d in data: self._effective.append(Gtk.Label( label=f"{d['device']}: {', '.join(d.get('servers', []))}", xalign=0.0, selectable=True)) def _apply(self) -> None: idx = self._conn.get_selected() if idx < 0 or idx >= len(self._adapters): self._flash("Pick a connection") return con = self._adapters[idx]["connection"] servers = self._servers.get_text().strip() ign = "yes" if self._ignore.get_active() else "no" _nm(["set-dns", con, "ipv4", servers, ign], self._after) def _after(self, ok, d) -> None: good = ok and isinstance(d, dict) and d.get("ok") self._flash("Applied" if good else f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}") _nm(["dns"], self._show_effective) def _flash(self, msg: str) -> None: self._status.set_text(msg) self._status.set_visible(True) class _Expanded(Gtk.Box): def __init__(self, ctx: ModuleContext): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) self.add_css_class("net-view") self.ctx = ctx self.stack = Gtk.Stack(vexpand=True) switcher = Gtk.StackSwitcher(stack=self.stack) switcher.add_css_class("net-switcher") self.append(switcher) self.append(self.stack) f = ctx.feature if f("adapters", True): self.stack.add_titled(_AdaptersPage(), "adapters", "Adapters") if f("routes", True): self.stack.add_titled(_RoutesPage(), "routes", "Routes") if f("vlan", True): self.stack.add_titled(_VlanPage(), "vlan", "VLAN") if f("dns", True): self.stack.add_titled(_DnsPage(), "dns", "DNS") def build(ctx: ModuleContext) -> ModuleInstance: # Feature toggles rebuild the whole card via QuadCard, so _Expanded is recreated # with the enabled tabs — no in-place page rebuild needed here. compact = _Compact(ctx) expanded = _Expanded(ctx) return ModuleInstance(compact=compact, expanded=expanded) SPEC = ModuleSpec( id="network", title="Network", icon="", # nf-md-lan build=build, default_enabled=True, features=[Feature("adapters", "Adapters", True), Feature("routes", "Routes", True), Feature("vlan", "VLAN", True), Feature("dns", "DNS", True)], )