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
|
#!/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,
|
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
|
non-zero exit on total failure. Used by services/location.py (and runnable
|
||||||
|
|
@ -8,8 +13,11 @@ standalone for testing).
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
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"
|
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu" / "location.json"
|
||||||
TTL = 1800 # seconds
|
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 = [
|
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"),
|
lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"),
|
||||||
"country": d.get("country"), "ip": d.get("query")}),
|
"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"),
|
lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"),
|
||||||
"country": d.get("country_name"), "ip": d.get("ip")}),
|
"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]),
|
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")}),
|
"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}))
|
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:
|
def _fetch(url: str) -> dict:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "astal-menu/1.0"})
|
req = urllib.request.Request(url, headers={"User-Agent": "astal-menu/1.0"})
|
||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||||
|
|
@ -58,18 +100,23 @@ def main() -> int:
|
||||||
if hit:
|
if hit:
|
||||||
print(json.dumps(hit))
|
print(json.dumps(hit))
|
||||||
return 0
|
return 0
|
||||||
for name, url, parse in PROVIDERS:
|
|
||||||
try:
|
hop = _public_hop_ip()
|
||||||
data = parse(_fetch(url))
|
# Try the traced public hop first, then fall back to our own public IP.
|
||||||
data["source"] = name
|
candidates = ([hop] if hop else []) + [""]
|
||||||
if data.get("lat") is None or data.get("lon") is None:
|
for ip in candidates:
|
||||||
raise ValueError("no coordinates")
|
for name, url_of, parse in PROVIDERS:
|
||||||
_store(data)
|
try:
|
||||||
print(json.dumps(data))
|
data = parse(_fetch(url_of(ip)))
|
||||||
return 0
|
if data.get("lat") is None or data.get("lon") is None:
|
||||||
except Exception as exc: # noqa: BLE001 — try the next provider
|
raise ValueError("no coordinates")
|
||||||
print(f"{name}: {exc}", file=sys.stderr)
|
data["source"] = f"{name} via {ip}" if ip else name
|
||||||
print("all geolocation providers failed", file=sys.stderr)
|
_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
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,9 +185,10 @@ class _BluetoothView(Gtk.Box):
|
||||||
|
|
||||||
|
|
||||||
def build(ctx: ModuleContext) -> ModuleInstance:
|
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)
|
compact = _BluetoothView(ctx, full=False)
|
||||||
expanded = _BluetoothView(ctx, full=True)
|
expanded = _BluetoothView(ctx, full=True)
|
||||||
ctx.on_settings_changed(lambda: (compact._refresh(), expanded._refresh()))
|
|
||||||
return ModuleInstance(compact=compact, expanded=expanded)
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
"""Location quad: a static OpenStreetMap image centred on the device's IP-geolocated
|
"""Location quad: a static OpenStreetMap image centred on the device's IP-geolocated
|
||||||
position, with a marker.
|
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
|
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
|
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
|
shows the same blank map), while tile downloads themselves work fine. The Weather
|
||||||
shared LocationService, which the Weather quad also uses.
|
quad consumes the same LocationService.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -60,8 +65,21 @@ class _MapView(Gtk.Box):
|
||||||
self.picture.set_filename(self._out)
|
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:
|
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)
|
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)
|
expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True)
|
||||||
return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False)
|
return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False)
|
||||||
|
|
|
||||||
|
|
@ -295,14 +295,12 @@ class _Expanded(Gtk.Box):
|
||||||
def _ignore(*_a) -> None:
|
def _ignore(*_a) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_settings_changed(self) -> None:
|
|
||||||
self._build_pages()
|
|
||||||
|
|
||||||
|
|
||||||
def build(ctx: ModuleContext) -> ModuleInstance:
|
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)
|
compact = _Compact(ctx)
|
||||||
expanded = _Expanded(ctx)
|
expanded = _Expanded(ctx)
|
||||||
ctx.on_settings_changed(expanded.on_settings_changed)
|
|
||||||
return ModuleInstance(compact=compact, expanded=expanded)
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,10 @@ class _WeatherView(Gtk.Box):
|
||||||
|
|
||||||
|
|
||||||
def build(ctx: ModuleContext) -> ModuleInstance:
|
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
|
compact = _WeatherView(ctx, opts="0") # current conditions only
|
||||||
expanded = _WeatherView(ctx, opts="") # full 3-day forecast
|
expanded = _WeatherView(ctx, opts="") # full 3-day forecast
|
||||||
ctx.on_settings_changed(lambda: (compact.refresh(), expanded.refresh()))
|
|
||||||
return ModuleInstance(compact=compact, expanded=expanded)
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- window / letterbox --------------------------------------------- */
|
/* ---- 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 {
|
#menu-window,
|
||||||
|
.panel,
|
||||||
|
#panel-root {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
#backdrop {
|
#backdrop {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class QuadCard(Gtk.Box):
|
||||||
self._body_holder.set_vexpand(True)
|
self._body_holder.set_vexpand(True)
|
||||||
self.append(self._body_holder)
|
self.append(self._body_holder)
|
||||||
|
|
||||||
|
self._features: dict[str, bool] = {}
|
||||||
self._rebuild_body()
|
self._rebuild_body()
|
||||||
|
|
||||||
# -- header ------------------------------------------------------------
|
# -- header ------------------------------------------------------------
|
||||||
|
|
@ -105,6 +106,7 @@ class QuadCard(Gtk.Box):
|
||||||
|
|
||||||
def _rebuild_body(self) -> None:
|
def _rebuild_body(self) -> None:
|
||||||
self._clear_body()
|
self._clear_body()
|
||||||
|
self._features = self._feature_snapshot()
|
||||||
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
|
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
inst = self.spec.build(self.ctx)
|
inst = self.spec.build(self.ctx)
|
||||||
|
|
@ -115,6 +117,10 @@ class QuadCard(Gtk.Box):
|
||||||
self._expand_btn.set_sensitive(False)
|
self._expand_btn.set_sensitive(False)
|
||||||
self._body_holder.append(self._disabled_placeholder())
|
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:
|
def _disabled_placeholder(self) -> Gtk.Widget:
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
|
||||||
valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
|
valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER)
|
||||||
|
|
@ -129,10 +135,14 @@ class QuadCard(Gtk.Box):
|
||||||
|
|
||||||
# -- lifecycle ---------------------------------------------------------
|
# -- lifecycle ---------------------------------------------------------
|
||||||
def on_settings_changed(self) -> None:
|
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)
|
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
|
||||||
has_module = self.instance is not None
|
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()
|
self._rebuild_body()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,8 @@ hl.on("hyprland.start", function()
|
||||||
hl.exec_cmd("chamel")
|
hl.exec_cmd("chamel")
|
||||||
hl.exec_cmd("ydotoold")
|
hl.exec_cmd("ydotoold")
|
||||||
hl.exec_cmd("opendeck")
|
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)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ HYPRLUA_PACKAGES=(
|
||||||
libshumate # GTK4 OpenStreetMap widget (Location quad)
|
libshumate # GTK4 OpenStreetMap widget (Location quad)
|
||||||
bluez-utils # bluetoothctl + bluez CLI (Bluetooth quad fallbacks)
|
bluez-utils # bluetoothctl + bluez CLI (Bluetooth quad fallbacks)
|
||||||
iproute2 curl jq # ip/ss, HTTP fetches, JSON (Network/Weather backends)
|
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
|
# Build toolchain required for EWW (Rust) and AUR package compilation
|
||||||
python cmake meson cpio pkgconf ruby-pkg-config
|
python cmake meson cpio pkgconf ruby-pkg-config
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue