"""World-map visualization for proxy chains. Renders hop arcs over the bundled neon world map image. Falls back to a drawn landmass map if the image file is absent. """ from __future__ import annotations import ipaddress import logging import math import socket from dataclasses import dataclass from pathlib import Path from typing import Literal from urllib.parse import urlparse from PIL import Image, ImageDraw, ImageFilter, ImageFont try: _RESAMPLE = Image.Resampling.LANCZOS except AttributeError: _RESAMPLE = Image.LANCZOS # type: ignore[attr-defined] from .config import normalize_proxy_url, redact_proxy_url from .exit_intel import ExitIntel, fetch_ip_geo log = logging.getLogger(__name__) ChainStatus = Literal["healthy", "connecting", "dead", "idle"] # Theme palette _BG = "#070713" _GRID = "#1a1f3a" _LAND = "#12182e" _LAND_EDGE = "#1e2a4a" _LINE_HEALTHY = "#00e5ff" _LINE_CONNECTING = "#ffd000" _LINE_DEAD = "#ff2a6d" _LINE_IDLE = "#3052ff" _YOU = "#00ff9c" _HOP = "#00e5ff" _EXIT = "#ffd000" _LABEL = "#eaf2ff" _LABEL_DIM = "#7a8aab" _UNKNOWN = "#2a2f4a" def _map_img_path() -> Path: """Locate world_map.png whether running from source or a frozen PyInstaller bundle.""" import sys if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): return Path(sys._MEIPASS) / "proxy_chain_manager" / "world_map.png" return Path(__file__).resolve().parent / "world_map.png" _cached_map: Image.Image | None = None def _load_world_map(width: int, height: int) -> Image.Image | None: """Return the neon world map resized to (width × height), or None if unavailable.""" global _cached_map try: if _cached_map is None: _cached_map = Image.open(_map_img_path()).convert("RGB") return _cached_map.resize((width, height), _RESAMPLE) except Exception as exc: log.debug("world_map load failed: %s", exc) return None @dataclass(frozen=True) class MapPoint: role: Literal["you", "hop", "exit"] label: str lat: float lon: float detail: str = "" hop_index: int = -1 def extract_hop_host(proxy_url: str) -> str | None: """Return hostname or IP from a proxy URL.""" p = urlparse(normalize_proxy_url(proxy_url)) host = (p.hostname or "").strip() return host or None def resolve_host_ip(host: str, timeout_seconds: float = 4.0) -> str | None: """Resolve host to IPv4/IPv6 string. Never raises.""" h = (host or "").strip() if not h: return None try: ipaddress.ip_address(h) return h except ValueError: pass try: prev = socket.getdefaulttimeout() socket.setdefaulttimeout(max(1.0, float(timeout_seconds))) try: infos = socket.getaddrinfo(h, None, type=socket.SOCK_STREAM) finally: socket.setdefaulttimeout(prev) for info in infos: addr = info[4][0] try: ipaddress.ip_address(addr) return addr except ValueError: continue except Exception as e: log.debug("resolve_host_ip(%s): %s", h, e) return None def _loc_label(intel: ExitIntel) -> str: bits = [b for b in (intel.city, intel.region, intel.country) if b] return ", ".join(bits) if bits else intel.country_code or intel.ip or "—" def _intel_to_point( role: Literal["you", "hop", "exit"], label: str, intel: ExitIntel, *, hop_index: int = -1, ) -> MapPoint | None: if not intel.ok or intel.lat is None or intel.lon is None: return None return MapPoint( role=role, label=label, lat=float(intel.lat), lon=float(intel.lon), detail=_loc_label(intel), hop_index=hop_index, ) def build_chain_map_points( hops: list[str], *, direct_ip: str = "", exit_ip: str = "", exit_intel: ExitIntel | None = None, timeout_seconds: float = 8.0, ) -> list[MapPoint]: """Resolve geo for YOU → hop₁ → … → EXIT. Never raises.""" points: list[MapPoint] = [] origin_ip = (direct_ip or "").strip() if origin_ip and origin_ip != "—": you = _intel_to_point("you", "YOU", fetch_ip_geo(origin_ip, timeout_seconds)) if you: points.append(you) for i, hop in enumerate(hops): host = extract_hop_host(hop) if not host: continue ip = resolve_host_ip(host, timeout_seconds=min(4.0, timeout_seconds)) if not ip: continue intel = fetch_ip_geo(ip, timeout_seconds) pt = _intel_to_point("hop", f"H{i + 1}", intel, hop_index=i) if pt: points.append(pt) exit_str = (exit_ip or "").strip() if exit_str and exit_str != "—": if ( exit_intel and exit_intel.ok and exit_intel.ip == exit_str and exit_intel.lat is not None and exit_intel.lon is not None ): pt = _intel_to_point("exit", "EXIT", exit_intel) else: pt = _intel_to_point("exit", "EXIT", fetch_ip_geo(exit_str, timeout_seconds)) if pt: points.append(pt) return points def _project(lat: float, lon: float, width: int, height: int, margin: int) -> tuple[float, float]: inner_w = max(1, width - margin * 2) inner_h = max(1, height - margin * 2) x = margin + (float(lon) + 180.0) / 360.0 * inner_w y = margin + (90.0 - float(lat)) / 180.0 * inner_h return x, y def _great_circle_points( lat1: float, lon1: float, lat2: float, lon2: float, steps: int = 32, ) -> list[tuple[float, float]]: """Interpolate along a great circle for curved hop arcs.""" phi1, lam1 = math.radians(lat1), math.radians(lon1) phi2, lam2 = math.radians(lat2), math.radians(lon2) d = 2 * math.asin( math.sqrt( math.sin((phi2 - phi1) / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin((lam2 - lam1) / 2) ** 2 ) ) if d < 1e-9: return [(lat1, lon1), (lat2, lon2)] out: list[tuple[float, float]] = [] for i in range(steps + 1): f = i / steps a = math.sin((1 - f) * d) / math.sin(d) b = math.sin(f * d) / math.sin(d) x = a * math.cos(phi1) * math.cos(lam1) + b * math.cos(phi2) * math.cos(lam2) y = a * math.cos(phi1) * math.sin(lam1) + b * math.cos(phi2) * math.sin(lam2) z = a * math.sin(phi1) + b * math.sin(phi2) out.append(( math.degrees(math.atan2(z, math.sqrt(x * x + y * y))), math.degrees(math.atan2(y, x)), )) return out def _line_color(status: ChainStatus) -> tuple[int, int, int]: table = { "healthy": (0, 229, 255), "connecting": (255, 208, 0), "dead": (255, 42, 109), "idle": (48, 82, 255), } return table.get(status, (48, 82, 255)) def _node_color(role: str) -> tuple[int, int, int]: table = {"you": (0, 255, 156), "hop": (0, 229, 255), "exit": (255, 208, 0)} return table.get(role, (0, 229, 255)) def _hex_to_rgb(h: str) -> tuple[int, int, int]: h = h.lstrip("#") return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) def _draw_glow_line( draw: ImageDraw.ImageDraw, pts: list[tuple[float, float]], color: tuple[int, int, int], ) -> None: """Draw neon glow arc: fat dim outer halo → bright core line.""" if len(pts) < 2: return r, g, b = color # outer halo — wider, dim halo = (r // 4, g // 4, b // 4) draw.line(pts, fill=halo, width=7) # mid glow mid = (r // 2, g // 2, b // 2) draw.line(pts, fill=mid, width=4) # bright core draw.line(pts, fill=color, width=2) def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: for name in ("segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"): try: return ImageFont.truetype(name, size) except OSError: continue return ImageFont.load_default() def _draw_landmasses(draw: ImageDraw.ImageDraw, width: int, height: int, margin: int) -> None: """Fallback minimal continent silhouettes.""" blobs: list[list[tuple[float, float]]] = [ [(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)], [(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)], [(-10, 72), (30, 70), (40, 55), (35, 30), (20, 5), (-5, 5), (-18, 28), (-10, 72)], [(15, 35), (35, 32), (50, 12), (42, -5), (18, -35), (15, 35)], [(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)], [(115, -12), (135, -12), (150, -25), (145, -38), (115, -38), (115, -12)], ] for poly in blobs: pts = [_project(lat, lon, width, height, margin) for lat, lon in poly] draw.polygon(pts, fill=_LAND, outline=_LAND_EDGE) def render_chain_map( points: list[MapPoint], *, width: int = 960, height: int = 220, status: ChainStatus = "idle", margin: int = 12, ) -> Image.Image: """Render chain hops over the neon world map (or a drawn fallback).""" w = max(320, width) h = max(120, height) # ── background ────────────────────────────────────────────────────────── world = _load_world_map(w, h) if world is not None: # Slightly darken so overlaid lines pop bg = Image.new("RGB", (w, h), (0, 0, 0)) img = Image.blend(world, bg, alpha=0.22) else: img = Image.new("RGB", (w, h), _BG) draw_bg = ImageDraw.Draw(img) _draw_landmasses(draw_bg, w, h, margin) # grid for lat in range(-60, 91, 30): y = _project(lat, 0, w, h, margin)[1] draw_bg.line([(margin, y), (w - margin, y)], fill=_GRID, width=1) for lon in range(-150, 181, 30): x = _project(0, lon, w, h, margin)[0] draw_bg.line([(x, margin), (x, h - margin)], fill=_GRID, width=1) draw = ImageDraw.Draw(img) if len(points) < 2: font = _load_font(12) msg = "Start the chain to plot hops on the map." if not points else "Need 2+ geo points to draw path." # drop shadow draw.text((margin + 1, h // 2 - 7), msg, fill=(0, 0, 0), font=font) draw.text((margin, h // 2 - 8), msg, fill=_hex_to_rgb(_LABEL_DIM), font=font) return img line_rgb = _line_color(status) projected = [_project(p.lat, p.lon, w, h, margin) for p in points] # ── arc lines between consecutive hops ───────────────────────────────── for i in range(len(projected) - 1): arc = _great_circle_points( points[i].lat, points[i].lon, points[i + 1].lat, points[i + 1].lon, ) arc_xy = [_project(lat, lon, w, h, margin) for lat, lon in arc] _draw_glow_line(draw, arc_xy, line_rgb) # ── nodes ─────────────────────────────────────────────────────────────── label_font = _load_font(11) detail_font = _load_font(9) for pt, (x, y) in zip(points, projected): nc = _node_color(pt.role) r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5) # outer glow ring glow = (nc[0] // 3, nc[1] // 3, nc[2] // 3) draw.ellipse((x - r - 4, y - r - 4, x + r + 4, y + r + 4), fill=glow) # filled node with dark center draw.ellipse((x - r, y - r, x + r, y + r), fill=nc) draw.ellipse((x - r + 2, y - r + 2, x + r - 2, y + r - 2), fill=(5, 5, 20)) tx, ty = int(x + r + 5), int(y - 8) # text drop-shadow draw.text((tx + 1, ty + 1), pt.label, fill=(0, 0, 0), font=label_font) draw.text((tx, ty), pt.label, fill=_hex_to_rgb(_LABEL), font=label_font) if pt.detail: draw.text((tx, ty + 12), pt.detail[:30], fill=_hex_to_rgb(_LABEL_DIM), font=detail_font) return img def resize_map_display(pil_img: Image.Image, width: int, height: int) -> Image.Image: """Resize rendered map for the GUI widget.""" return pil_img.resize((max(320, int(width)), max(120, int(height))), _RESAMPLE)