fix(astal-menu): live-apply widget settings, float modules, IP-locate via traceroute

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-05 17:02:40 +02:00
parent 3d75182b54
commit 277f04c65d
9 changed files with 117 additions and 28 deletions

View File

@ -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:
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))
data["source"] = name
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
print(f"{name}: {exc}", file=sys.stderr)
print("all geolocation providers failed", file=sys.stderr)
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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -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