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_01XUWCXM4KhjRkwheaA3X7bPfeat/astal-menu
parent
3d75182b54
commit
277f04c65d
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue