diff --git a/main.py b/main.py index a0bfd0a..28ca945 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,6 @@ from settings import SettingsManager # type: ignore from steam_utils import ( - get_local_ip, detect_platform, get_user_home, expand_path, @@ -132,7 +131,10 @@ async def _main(self): if success: # Use the actual port assigned by the OS self.mdns_service = MDNSService( - self.agent_id, self.agent_name, self.ws_server.actual_port, PLUGIN_VERSION + self.agent_id, self.agent_name, self.ws_server.actual_port, PLUGIN_VERSION, + on_register=self._on_mdns_register, + on_unregister=self._on_mdns_unregister, + on_waiting=self._on_mdns_waiting, ) self.mdns_service.start() else: @@ -157,8 +159,12 @@ async def _unload(self): # Maximum events to keep in queue to prevent memory/disk bloat MAX_QUEUE_SIZE = 50 - async def notify_frontend(self, event: str, data: dict): - """Send event to frontend via queue (critical) or overwrite (progress).""" + def _emit_event(self, event: str, data: dict) -> None: + """Push an event to the frontend. Safe to call from any thread. + + The settings layer is synchronous, so this works from background + threads (e.g. the mDNS watcher) without touching the asyncio loop. + """ decky.logger.info(f"Frontend event: {event} - {data}") if event in self.QUEUED_EVENTS: queue = self.settings.getSetting(f"_queue_{event}", []) or [] @@ -173,6 +179,24 @@ async def notify_frontend(self, event: str, data: dict): "data": data, }) + async def notify_frontend(self, event: str, data: dict): + """Async wrapper for _emit_event, used by asyncio handlers.""" + self._emit_event(event, data) + + # ── mDNS lifecycle callbacks (invoked from the watcher thread) ─────────── + + def _on_mdns_register(self, ip: str, port: int) -> None: + """Watcher reports a successful (re-)registration: ask the UI to refresh.""" + self._emit_event("mdns_registered", {"ip": ip, "port": port}) + + def _on_mdns_unregister(self) -> None: + """Watcher reports the service was torn down: ask the UI to refresh.""" + self._emit_event("mdns_unregistered", {}) + + def _on_mdns_waiting(self) -> None: + """Watcher reports it cannot find a usable IP yet: surface a toast.""" + self._emit_event("mdns_waiting", {}) + # ── Frontend API methods ───────────────────────────────────────────────── async def get_setting(self, key: str, default): @@ -193,7 +217,10 @@ async def set_enabled(self, enabled=False): if not self.mdns_service: # Use the actual port assigned by the OS self.mdns_service = MDNSService( - self.agent_id, self.agent_name, self.ws_server.actual_port, PLUGIN_VERSION + self.agent_id, self.agent_name, self.ws_server.actual_port, PLUGIN_VERSION, + on_register=self._on_mdns_register, + on_unregister=self._on_mdns_unregister, + on_waiting=self._on_mdns_waiting, ) self.mdns_service.start() else: @@ -217,8 +244,8 @@ async def get_status(self): "installPath": self.install_path, "platform": detect_platform(), "version": PLUGIN_VERSION, - "port": self.ws_server.actual_port, - "ip": get_local_ip(), + "port": self.ws_server.actual_port if self.mdns_service else None, + "ip": self.mdns_service.announced_ip if self.mdns_service else None, "telemetryEnabled": self.settings.getSetting("telemetry_enabled", False), "telemetryInterval": self.settings.getSetting("telemetry_interval", 2), "consoleLogEnabled": self.settings.getSetting("console_log_enabled", False), diff --git a/mdns_service.py b/mdns_service.py index a5e9624..6fa8a84 100644 --- a/mdns_service.py +++ b/mdns_service.py @@ -1,9 +1,16 @@ """ mDNS/DNS-SD service advertisement for Hub discovery. + +Owns a background watcher that: + - Waits for a usable LAN IP before registering (boot-time race safe). + - Re-registers if the local IP changes (suspend/resume, AP roaming, DHCP renewal). + +The watcher only exists while the service is started; stop() halts it cleanly. """ -import os -from typing import Optional +import socket +import threading +from typing import Callable, Optional import decky # type: ignore @@ -11,73 +18,218 @@ MDNS_SERVICE_TYPE = "_capydeploy._tcp.local." +# Steady-state poll interval: how often to check for IP changes once +# we have a registration. Low enough to react to suspend/roaming, high +# enough to keep CPU/wakeups negligible. +_WATCH_INTERVAL_SEC = 5.0 -class MDNSService: - """Advertises the agent via mDNS/DNS-SD for Hub discovery.""" +# Retry interval while waiting for the network to come up (boot race). +# Kept short so the user-visible wait after toggle-on stays under ~3s +# in the common case where the network is already up. +_RETRY_INTERVAL_SEC = 1.0 + +# How long to give the background thread to exit on stop(). +_STOP_JOIN_TIMEOUT_SEC = 2.0 - def __init__(self, agent_id: str, agent_name: str, port: int, version: str): + +class MDNSService: + """Advertises the agent via mDNS/DNS-SD for Hub discovery. + + Lifecycle: + start() -> spawns a watcher thread that registers when an IP is available + and re-registers on IP changes. + stop() -> signals the watcher to exit, unregisters the service. + + Resource use is zero while stopped: no thread, no zeroconf object. + """ + + def __init__( + self, + agent_id: str, + agent_name: str, + port: int, + version: str, + on_register: Optional[Callable[[str, int], None]] = None, + on_unregister: Optional[Callable[[], None]] = None, + on_waiting: Optional[Callable[[], None]] = None, + ): self.agent_id = agent_id self.agent_name = agent_name self.port = port self.version = version - self.zeroconf = None - self.service_info = None - self._thread = None - - def _register_in_thread(self): - """Register mDNS service in a separate thread to avoid asyncio conflicts.""" - import socket - - try: - from zeroconf import Zeroconf, ServiceInfo - - hostname = socket.gethostname() - local_ip = get_local_ip() - platform = detect_platform() - - properties = { - b"id": self.agent_id.encode(), - b"name": self.agent_name.encode(), - b"platform": platform.encode(), - b"version": self.version.encode(), - } - - self.service_info = ServiceInfo( - MDNS_SERVICE_TYPE, - f"{self.agent_id}.{MDNS_SERVICE_TYPE}", - addresses=[socket.inet_aton(local_ip)], - port=self.port, - properties=properties, - server=f"{hostname}.local.", - ) - - self.zeroconf = Zeroconf() - self.zeroconf.register_service(self.service_info) - decky.logger.info( - f"mDNS service registered: {self.agent_id}._capydeploy._tcp.local on {local_ip}:{self.port}" - ) - - except Exception as e: - import traceback - - decky.logger.error(f"Failed to start mDNS in thread: {e} - {traceback.format_exc()}") - - def start(self): - """Start advertising via mDNS.""" - import threading - - decky.logger.info(f"mDNS will advertise on {get_local_ip()}:{self.port}") - self._thread = threading.Thread(target=self._register_in_thread, daemon=True) - self._thread.start() - - def stop(self): - """Stop advertising.""" - try: - if self.zeroconf and self.service_info: - self.zeroconf.unregister_service(self.service_info) - self.zeroconf.close() - self.zeroconf = None - self.service_info = None - decky.logger.info("mDNS service stopped") - except Exception as e: - decky.logger.error(f"Failed to stop mDNS: {e}") + + self._zeroconf = None + self._service_info = None + self._current_ip: Optional[str] = None + + # Notified after a successful (re-)register, after a teardown, or + # the first time the watcher fails to find a usable IP. Invoked + # from the watcher thread; callbacks must be thread-safe. + self._on_register = on_register + self._on_unregister = on_unregister + self._on_waiting = on_waiting + + # Tracks whether on_waiting has already fired in the current + # "no IP yet" stretch. Reset on a successful register so that a + # later loss of network can fire it again. + self._notified_waiting = False + + self._stop_event = threading.Event() + self._worker: Optional[threading.Thread] = None + # Guards _zeroconf, _service_info, _current_ip across the + # watcher thread and the caller thread (stop()). + self._lock = threading.Lock() + + # ── Public API ─────────────────────────────────────────────────────────── + + def start(self) -> None: + """Start the watcher. Idempotent: safe to call when already running.""" + if self._worker is not None and self._worker.is_alive(): + return + self._stop_event.clear() + self._worker = threading.Thread( + target=self._run, + name="capydeploy-mdns-watcher", + daemon=True, + ) + self._worker.start() + decky.logger.info( + f"mDNS watcher started for {self.agent_id} on port {self.port}" + ) + + def stop(self) -> None: + """Signal the watcher to stop and unregister the service. Idempotent.""" + self._stop_event.set() + worker = self._worker + if worker is not None: + worker.join(timeout=_STOP_JOIN_TIMEOUT_SEC) + self._worker = None + with self._lock: + self._unregister_locked() + decky.logger.info("mDNS watcher stopped") + + @property + def announced_ip(self) -> Optional[str]: + """The IP currently being announced via mDNS, or None if not registered.""" + with self._lock: + return self._current_ip + + # ── Worker ─────────────────────────────────────────────────────────────── + + def _run(self) -> None: + """Watch for a usable IP and (re-)register on changes. + + Tries immediately on entry — no startup delay — so toggle-on + with the network already up is registered as fast as zeroconf + allows. Falls back to a short retry while waiting for the + network at cold boot, then settles into a longer interval to + watch for IP changes (suspend/resume, AP roaming). + """ + while not self._stop_event.is_set(): + ip = get_local_ip() + + if ip is None: + # Network not ready — retry quickly. Zero registration + # attempts while we don't have a usable IP. + # Notify the UI exactly once per "no IP" stretch so the + # toast doesn't repeat every second while we wait. + if not self._notified_waiting and self._on_waiting is not None: + self._notified_waiting = True + try: + self._on_waiting() + except Exception as e: + decky.logger.error(f"on_waiting callback failed: {e}") + self._stop_event.wait(timeout=_RETRY_INTERVAL_SEC) + continue + + with self._lock: + if self._stop_event.is_set(): + return + # Re-register on either: + # - IP changed (AP roaming, DHCP renewal, multi-NIC switch) + # - We previously emitted mdns_waiting (network was down). + # Even if the IP is unchanged, the zeroconf service may + # have been left in an inconsistent state by the network + # drop, and the user is waiting for a "ready" confirmation. + needs_reregister = ip != self._current_ip or self._notified_waiting + if needs_reregister: + if self._current_ip is not None: + if ip != self._current_ip: + decky.logger.info( + f"mDNS IP change: {self._current_ip} -> {ip}" + ) + else: + decky.logger.info( + f"mDNS recovering after network drop on {ip}" + ) + self._unregister_locked() + try: + self._register_locked(ip) + except Exception as e: + decky.logger.error(f"mDNS register failed: {e}") + # Leave _current_ip None so we retry on next tick. + + self._stop_event.wait(timeout=_WATCH_INTERVAL_SEC) + + # ── Registration (must hold _lock) ─────────────────────────────────────── + + def _register_locked(self, ip: str) -> None: + from zeroconf import Zeroconf, ServiceInfo + + hostname = socket.gethostname() + platform = detect_platform() + + properties = { + b"id": self.agent_id.encode(), + b"name": self.agent_name.encode(), + b"platform": platform.encode(), + b"version": self.version.encode(), + } + + service_info = ServiceInfo( + MDNS_SERVICE_TYPE, + f"{self.agent_id}.{MDNS_SERVICE_TYPE}", + addresses=[socket.inet_aton(ip)], + port=self.port, + properties=properties, + server=f"{hostname}.local.", + ) + + zeroconf = Zeroconf() + zeroconf.register_service(service_info) + + self._zeroconf = zeroconf + self._service_info = service_info + self._current_ip = ip + + decky.logger.info( + f"mDNS service registered: {self.agent_id}._capydeploy._tcp.local on {ip}:{self.port}" + ) + + # Reset the waiting flag so that a future network loss can + # re-notify the UI. + self._notified_waiting = False + + if self._on_register is not None: + try: + self._on_register(ip, self.port) + except Exception as e: + decky.logger.error(f"on_register callback failed: {e}") + + def _unregister_locked(self) -> None: + was_registered = self._zeroconf is not None and self._service_info is not None + if was_registered: + try: + self._zeroconf.unregister_service(self._service_info) + self._zeroconf.close() + except Exception as e: + decky.logger.error(f"Failed to unregister mDNS: {e}") + self._zeroconf = None + self._service_info = None + self._current_ip = None + + if was_registered and self._on_unregister is not None: + try: + self._on_unregister() + except Exception as e: + decky.logger.error(f"on_unregister callback failed: {e}") diff --git a/src/components/StatusPanel.tsx b/src/components/StatusPanel.tsx index ecde081..c634a0e 100644 --- a/src/components/StatusPanel.tsx +++ b/src/components/StatusPanel.tsx @@ -51,8 +51,8 @@ interface StatusPanelProps { agentName: string; platform: string; version: string; - port: number; - ip: string; + port: number | null; + ip: string | null; installPath: string; onRefresh: () => void; telemetryEnabled: boolean; @@ -90,7 +90,6 @@ const StatusPanel: VFC = ({ // Collapsible section states (persisted across panel close/open) const [statusExpanded, toggleStatus] = usePanelState("status"); const [infoExpanded, toggleInfo] = usePanelState("info"); - const [networkExpanded, toggleNetwork] = usePanelState("network"); const [telemetryExpanded, toggleTelemetry] = usePanelState("telemetry", false); const [consoleLogExpanded, toggleConsoleLog] = usePanelState("consolelog", false); const handleCycleInterval = () => { @@ -179,6 +178,27 @@ const StatusPanel: VFC = ({ )} + {ip && port ? ( + <> + + }> + {ip} + + + + + {port} + + + + ) : ( + + }> + Waiting for network… + + + )} + {lockoutRemaining > 0 && ( = ({ )} - {enabled && ( -
-
- {networkExpanded ? : } - Network -
- {networkExpanded && ( - - - }> - {port} - - - - - - {ip} - - - - )} -
- )} - {enabled && (
diff --git a/src/eventPoller.tsx b/src/eventPoller.tsx index 4d79aa9..71b6f3c 100644 --- a/src/eventPoller.tsx +++ b/src/eventPoller.tsx @@ -456,6 +456,54 @@ async function pollAllEvents() { } } while (hubDisconnected?.data); + // ── mDNS lifecycle (watcher tells us when IP/port becomes valid or goes away) ── + // + // Drain all three events together. If multiple are pending (e.g. the + // poller was paused while the panel was closed), only the toast for + // the *most recent* transition is shown — older state toasts are + // suppressed so the user doesn't see a "Waiting" toast after the + // service is already ready. + const [mdnsRegistered, mdnsUnregistered, mdnsWaiting] = await Promise.all([ + call<[string], { timestamp: number; data: { ip?: string; port?: number } } | null>( + "get_event", "mdns_registered" + ), + call<[string], { timestamp: number; data: object } | null>( + "get_event", "mdns_unregistered" + ), + call<[string], { timestamp: number; data: object } | null>( + "get_event", "mdns_waiting" + ), + ]); + + const mdnsTransitions = [ + mdnsRegistered ? { kind: "registered" as const, ts: mdnsRegistered.timestamp, data: mdnsRegistered.data } : null, + mdnsUnregistered ? { kind: "unregistered" as const, ts: mdnsUnregistered.timestamp, data: mdnsUnregistered.data } : null, + mdnsWaiting ? { kind: "waiting" as const, ts: mdnsWaiting.timestamp, data: mdnsWaiting.data } : null, + ].filter((t): t is NonNullable => t !== null); + + if (mdnsTransitions.length > 0) { + _uiCallbacks.onRefreshStatus?.(); + // Pick the most recent transition; that is the current state of the world. + const latest = mdnsTransitions.reduce((a, b) => (b.ts > a.ts ? b : a)); + if (latest.kind === "registered") { + const { ip, port } = latest.data as { ip?: string; port?: number }; + if (ip && port) { + brandToast({ + title: "CapyDeploy ready", + body: `Listening at ${ip}:${port}`, + }); + } + } else if (latest.kind === "waiting") { + brandToast({ + title: "CapyDeploy", + body: "Waiting for network…", + }); + } + // 'unregistered' is silent: it only fires when toggling off or + // before a re-register on IP change — both of which are immediately + // followed by another transition that carries the user-facing toast. + } + // ── Server error (queue-based, show modal) ── let serverError; diff --git a/src/hooks/useAgent.ts b/src/hooks/useAgent.ts index f85a77d..c923907 100644 --- a/src/hooks/useAgent.ts +++ b/src/hooks/useAgent.ts @@ -14,8 +14,8 @@ export interface AgentStatus { installPath: string; platform: string; version: string; - port: number; - ip: string; + port: number | null; + ip: string | null; telemetryEnabled: boolean; telemetryInterval: number; consoleLogEnabled: boolean; diff --git a/src/index.tsx b/src/index.tsx index db2ec35..d104e25 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -105,8 +105,8 @@ const CapyDeployPanel: VFC = () => { agentName={status?.agentName ?? "CapyDeploy Agent"} platform={status?.platform ?? "linux"} version={status?.version ?? "0.1.0"} - port={status?.port ?? 9999} - ip={status?.ip ?? "127.0.0.1"} + port={status?.port ?? null} + ip={status?.ip ?? null} installPath={status?.installPath ?? "~/Games"} onRefresh={refreshStatus} telemetryEnabled={status?.telemetryEnabled ?? false} diff --git a/steam_utils.py b/steam_utils.py index e245996..7a4f66c 100644 --- a/steam_utils.py +++ b/steam_utils.py @@ -10,18 +10,49 @@ import decky # type: ignore -def get_local_ip() -> str: - """Get the local non-loopback IP address.""" +def _is_usable_ipv4(ip: str) -> bool: + """True if the IP is a non-loopback, non-link-local IPv4 address.""" + if not ip or "." not in ip: + return False + if ip.startswith("127.") or ip.startswith("169.254."): + return False + return True + + +def get_local_ip() -> Optional[str]: + """Get the local non-loopback IPv4 address. + + Returns None when the network is not ready (no usable address yet), + so callers can decide whether to retry or fall back. Note: callers + that historically expected a string fallback (e.g. "127.0.0.1") + must be updated. + """ import socket + # Primary: UDP-connect trick reveals the interface that would route + # outbound traffic. Fast and accurate when the network is up. try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(1.0) s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] s.close() - return ip + if _is_usable_ipv4(ip): + return ip except Exception: - return "127.0.0.1" + pass + + # Fallback: enumerate addresses bound to the hostname. + try: + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None, socket.AF_INET): + ip = info[4][0] + if _is_usable_ipv4(ip): + return ip + except Exception: + pass + + return None def detect_platform() -> str: