From 277f04c65d66e1a747ff66de8185d197aa528600 Mon Sep 17 00:00:00 2001 From: The_miro Date: Sun, 5 Jul 2026 17:02:40 +0200 Subject: [PATCH] fix(astal-menu): live-apply widget settings, float modules, IP-locate via traceroute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget settings popover toggles now take effect immediately. QuadCard only rebuilt a card when its Enabled state flipped, so per-module feature toggles (Wi-Fi, discovery, routes, CLI art, …) were persisted but never applied. The card now rebuilds whenever any of its own feature values change, and the incomplete partial per-module refresh callbacks (which also leaked a dead subscription per rebuild) are dropped in favour of that single path. Make the backmost surface transparent so each module floats on its own drawn border/background: force `window`/`.background`/`.panel` transparent, since a plain `.menu-window` rule did not override the GTK theme's solid window node. Locate via IP now works and uses traceroute: geolocate.py traceroutes to 1.1.1.1, takes the first globally-routable hop (the ISP egress) and resolves it through a public geolocation API, falling back to self-IP when traceroute is missing or finds no public hop. The ip_locate toggle gates the lookup entirely (placeholder when off). Adds `traceroute` to the hyprlua package list. Autostart: `sleep 1 && hyprctl reload` after all spawns so layer-shell clients settle on the final config. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP --- .../hyprlua/astal-menu/backend/geolocate.py | 79 +++++++++++++++---- .../hyprlua/astal-menu/modules/bluetooth.py | 3 +- .../hyprlua/astal-menu/modules/location.py | 24 +++++- .../hyprlua/astal-menu/modules/network.py | 6 +- .../hyprlua/astal-menu/modules/weather.py | 3 +- .../hyprlua/astal-menu/style/style.css | 11 ++- desktopenvs/hyprlua/astal-menu/ui/quadcard.py | 14 +++- desktopenvs/hyprlua/hypr/usr/autostart.lua | 4 + setup/modules/Desktop-Environments/hyprlua.sh | 1 + 9 files changed, 117 insertions(+), 28 deletions(-) diff --git a/desktopenvs/hyprlua/astal-menu/backend/geolocate.py b/desktopenvs/hyprlua/astal-menu/backend/geolocate.py index 0b4cf15..d48dfa4 100755 --- a/desktopenvs/hyprlua/astal-menu/backend/geolocate.py +++ b/desktopenvs/hyprlua/astal-menu/backend/geolocate.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""IP geolocation with a provider fallback chain, cached with a TTL. +"""IP geolocation, cached with a TTL. + +Discovers the public IP by tracerouting to 1.1.1.1 and taking the first +globally-routable hop (the ISP egress nearest the user), then resolves that IP to a +location through a public geolocation API. Falls back to locating this host's own +public IP when traceroute is unavailable or yields no public hop. Prints JSON: {lat, lon, city, country, ip, source}. Diagnostics go to stderr, non-zero exit on total failure. Used by services/location.py (and runnable @@ -8,8 +13,11 @@ standalone for testing). from __future__ import annotations +import ipaddress import json import os +import re +import subprocess import sys import time import urllib.request @@ -18,14 +26,22 @@ from pathlib import Path CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu" / "location.json" TTL = 1800 # seconds +TARGET = "1.1.1.1" +_IPV4 = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b") + +# Geolocation providers. Each builds a URL for a given IP; an empty IP asks the +# provider to resolve the caller's own public address (the self-IP fallback). PROVIDERS = [ - ("ip-api", "http://ip-api.com/json/?fields=lat,lon,city,country,query", + ("ip-api", + lambda ip: f"http://ip-api.com/json/{ip}?fields=lat,lon,city,country,query", lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"), "country": d.get("country"), "ip": d.get("query")}), - ("ipapi.co", "https://ipapi.co/json/", + ("ipapi.co", + lambda ip: f"https://ipapi.co/{ip}/json/" if ip else "https://ipapi.co/json/", lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"), "country": d.get("country_name"), "ip": d.get("ip")}), - ("ipinfo", "https://ipinfo.io/json", + ("ipinfo", + lambda ip: f"https://ipinfo.io/{ip}/json" if ip else "https://ipinfo.io/json", lambda d: {"lat": float(d["loc"].split(",")[0]), "lon": float(d["loc"].split(",")[1]), "city": d.get("city"), "country": d.get("country"), "ip": d.get("ip")}), ] @@ -46,6 +62,32 @@ def _store(data: dict) -> None: CACHE.write_text(json.dumps({"_ts": time.time(), "data": data})) +def _public_hop_ip() -> str | None: + """First globally-routable hop on the path to 1.1.1.1 — i.e. the ISP egress + closest to the user. The private/CGNAT hops before it and the anycast target + itself (Cloudflare, useless for locating the user) are skipped.""" + try: + proc = subprocess.run( + ["traceroute", "-n", "-q", "1", "-w", "2", "-m", "12", TARGET], + capture_output=True, text=True, timeout=40, + ) + except (FileNotFoundError, subprocess.SubprocessError) as exc: + print(f"traceroute: {exc}", file=sys.stderr) + return None + for line in proc.stdout.splitlines(): + if line.lower().startswith("traceroute to"): + continue # header line names the target; not a hop + for ip in _IPV4.findall(line): + if ip == TARGET: + continue + try: + if ipaddress.ip_address(ip).is_global: + return ip + except ValueError: + continue + return None + + def _fetch(url: str) -> dict: req = urllib.request.Request(url, headers={"User-Agent": "astal-menu/1.0"}) with urllib.request.urlopen(req, timeout=8) as resp: @@ -58,18 +100,23 @@ def main() -> int: if hit: print(json.dumps(hit)) return 0 - for name, url, parse in PROVIDERS: - try: - data = parse(_fetch(url)) - data["source"] = name - if data.get("lat") is None or data.get("lon") is None: - raise ValueError("no coordinates") - _store(data) - print(json.dumps(data)) - return 0 - except Exception as exc: # noqa: BLE001 — try the next provider - print(f"{name}: {exc}", file=sys.stderr) - print("all geolocation providers failed", file=sys.stderr) + + hop = _public_hop_ip() + # Try the traced public hop first, then fall back to our own public IP. + candidates = ([hop] if hop else []) + [""] + for ip in candidates: + for name, url_of, parse in PROVIDERS: + try: + data = parse(_fetch(url_of(ip))) + if data.get("lat") is None or data.get("lon") is None: + raise ValueError("no coordinates") + data["source"] = f"{name} via {ip}" if ip else name + _store(data) + print(json.dumps(data)) + return 0 + except Exception as exc: # noqa: BLE001 — try the next provider/candidate + print(f"{name}({ip or 'self'}): {exc}", file=sys.stderr) + print("all geolocation attempts failed", file=sys.stderr) return 1 diff --git a/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py b/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py index ece351a..3736c9c 100644 --- a/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py +++ b/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py @@ -185,9 +185,10 @@ class _BluetoothView(Gtk.Box): def build(ctx: ModuleContext) -> ModuleInstance: + # Feature toggles (discovery/history) rebuild the whole card via QuadCard, so + # both views are recreated with the current features — no partial refresh here. compact = _BluetoothView(ctx, full=False) expanded = _BluetoothView(ctx, full=True) - ctx.on_settings_changed(lambda: (compact._refresh(), expanded._refresh())) return ModuleInstance(compact=compact, expanded=expanded) diff --git a/desktopenvs/hyprlua/astal-menu/modules/location.py b/desktopenvs/hyprlua/astal-menu/modules/location.py index 2da6686..5d1ac84 100644 --- a/desktopenvs/hyprlua/astal-menu/modules/location.py +++ b/desktopenvs/hyprlua/astal-menu/modules/location.py @@ -1,10 +1,15 @@ """Location quad: a static OpenStreetMap image centred on the device's IP-geolocated position, with a marker. +The position comes from the shared LocationService (backend/geolocate.py), which +traceroutes to 1.1.1.1, takes the first public hop (the ISP egress) and resolves it +through a public geolocation API. The "Locate via IP" feature toggle gates that +lookup entirely; when off, this quad shows a placeholder instead. + We render a static map (backend/staticmap.py) rather than an interactive libshumate map: Shumate does not paint tiles in this environment (the official shumate-demo -shows the same blank map), while tile downloads themselves work fine. Consumes the -shared LocationService, which the Weather quad also uses. +shows the same blank map), while tile downloads themselves work fine. The Weather +quad consumes the same LocationService. """ from __future__ import annotations @@ -60,8 +65,21 @@ class _MapView(Gtk.Box): self.picture.set_filename(self._out) +def _placeholder() -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, + valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) + box.add_css_class("map-view") + box.append(Gtk.Label(label="IP location is off")) + return box + + def build(ctx: ModuleContext) -> ModuleInstance: - ctx.services.location.get() # kick off geolocation if not started + # "Locate via IP" gates geolocation entirely: when off we neither traceroute nor + # hit any geolocation API, and just show a placeholder. Toggling it rebuilds the + # card (via QuadCard), so flipping it back on re-triggers the lookup. + if not ctx.feature("ip_locate", True): + return ModuleInstance(compact=_placeholder(), expanded=None) + ctx.services.location.get() # kick off geolocation (traceroute → API) if not started compact = _MapView(ctx, zoom=12, size=(640, 340), tag="compact", show_info=False) expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True) return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False) diff --git a/desktopenvs/hyprlua/astal-menu/modules/network.py b/desktopenvs/hyprlua/astal-menu/modules/network.py index a9983bd..609ce6a 100644 --- a/desktopenvs/hyprlua/astal-menu/modules/network.py +++ b/desktopenvs/hyprlua/astal-menu/modules/network.py @@ -295,14 +295,12 @@ class _Expanded(Gtk.Box): def _ignore(*_a) -> None: pass - def on_settings_changed(self) -> None: - self._build_pages() - def build(ctx: ModuleContext) -> ModuleInstance: + # Feature toggles rebuild the whole card via QuadCard, so _Expanded is recreated + # with the enabled pages — no in-place page rebuild needed here. compact = _Compact(ctx) expanded = _Expanded(ctx) - ctx.on_settings_changed(expanded.on_settings_changed) return ModuleInstance(compact=compact, expanded=expanded) diff --git a/desktopenvs/hyprlua/astal-menu/modules/weather.py b/desktopenvs/hyprlua/astal-menu/modules/weather.py index 4851202..2ebe8bd 100644 --- a/desktopenvs/hyprlua/astal-menu/modules/weather.py +++ b/desktopenvs/hyprlua/astal-menu/modules/weather.py @@ -56,9 +56,10 @@ class _WeatherView(Gtk.Box): def build(ctx: ModuleContext) -> ModuleInstance: + # The ascii_art toggle rebuilds the whole card via QuadCard, so both views are + # recreated and re-fetch with the current format — no partial refresh here. compact = _WeatherView(ctx, opts="0") # current conditions only expanded = _WeatherView(ctx, opts="") # full 3-day forecast - ctx.on_settings_changed(lambda: (compact.refresh(), expanded.refresh())) return ModuleInstance(compact=compact, expanded=expanded) diff --git a/desktopenvs/hyprlua/astal-menu/style/style.css b/desktopenvs/hyprlua/astal-menu/style/style.css index d86b6a1..e677f85 100644 --- a/desktopenvs/hyprlua/astal-menu/style/style.css +++ b/desktopenvs/hyprlua/astal-menu/style/style.css @@ -8,9 +8,18 @@ } /* ---- window / letterbox --------------------------------------------- */ +/* The backmost surface must be fully transparent so each module floats on its own + * drawn border/background. The bare `window` (and its default `.background` node) + * is included because the GTK theme paints a solid colour there that a plain + * `.menu-window` rule does not always override. */ +window, +window.background, .menu-window, -#menu-window { +#menu-window, +.panel, +#panel-root { background: transparent; + background-color: transparent; } #backdrop { diff --git a/desktopenvs/hyprlua/astal-menu/ui/quadcard.py b/desktopenvs/hyprlua/astal-menu/ui/quadcard.py index 5ceb0bd..b138107 100644 --- a/desktopenvs/hyprlua/astal-menu/ui/quadcard.py +++ b/desktopenvs/hyprlua/astal-menu/ui/quadcard.py @@ -34,6 +34,7 @@ class QuadCard(Gtk.Box): self._body_holder.set_vexpand(True) self.append(self._body_holder) + self._features: dict[str, bool] = {} self._rebuild_body() # -- header ------------------------------------------------------------ @@ -105,6 +106,7 @@ class QuadCard(Gtk.Box): def _rebuild_body(self) -> None: self._clear_body() + self._features = self._feature_snapshot() enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled) if enabled: inst = self.spec.build(self.ctx) @@ -115,6 +117,10 @@ class QuadCard(Gtk.Box): self._expand_btn.set_sensitive(False) self._body_holder.append(self._disabled_placeholder()) + def _feature_snapshot(self) -> dict[str, bool]: + return {f.id: self.settings.feature(self.spec.id, f.id, f.default) + for f in self.spec.features} + def _disabled_placeholder(self) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) @@ -129,10 +135,14 @@ class QuadCard(Gtk.Box): # -- lifecycle --------------------------------------------------------- def on_settings_changed(self) -> None: - """Called by the grid when settings.json changes; rebuild if enablement flipped.""" + """Called by the grid when settings.json changes; rebuild if this quad's + enablement flipped or any of its feature toggles changed, so every switch in + the settings popover takes effect live (the popover is only reachable while + the grid is collapsed, so no expanded view is ever reparented mid-rebuild).""" enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled) has_module = self.instance is not None - if enabled != has_module: + features_changed = enabled and self._feature_snapshot() != self._features + if enabled != has_module or features_changed: self._rebuild_body() @property diff --git a/desktopenvs/hyprlua/hypr/usr/autostart.lua b/desktopenvs/hyprlua/hypr/usr/autostart.lua index 8c65043..0625ad7 100644 --- a/desktopenvs/hyprlua/hypr/usr/autostart.lua +++ b/desktopenvs/hyprlua/hypr/usr/autostart.lua @@ -24,4 +24,8 @@ hl.on("hyprland.start", function() hl.exec_cmd("chamel") hl.exec_cmd("ydotoold") hl.exec_cmd("opendeck") + + -- Reload once everything has spawned so layer-shell clients (bar, astal-menu) + -- pick up the final monitor/config state. + hl.exec_cmd("sleep 1 && hyprctl reload") end) diff --git a/setup/modules/Desktop-Environments/hyprlua.sh b/setup/modules/Desktop-Environments/hyprlua.sh index b285c4d..bb54786 100755 --- a/setup/modules/Desktop-Environments/hyprlua.sh +++ b/setup/modules/Desktop-Environments/hyprlua.sh @@ -80,6 +80,7 @@ HYPRLUA_PACKAGES=( libshumate # GTK4 OpenStreetMap widget (Location quad) bluez-utils # bluetoothctl + bluez CLI (Bluetooth quad fallbacks) iproute2 curl jq # ip/ss, HTTP fetches, JSON (Network/Weather backends) + traceroute # path trace to 1.1.1.1 for the Location quad's IP geolocation # Build toolchain required for EWW (Rust) and AUR package compilation python cmake meson cpio pkgconf ruby-pkg-config