From 5c098e6eb0708d5dfd2fdaad5dd172cabed3950f Mon Sep 17 00:00:00 2001 From: Lobinux Date: Mon, 27 Apr 2026 13:44:04 -0300 Subject: [PATCH 1/4] fix(mdns): wait for network at boot and re-register on IP change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mDNS service was registered once at plugin load with whatever get_local_ip() returned at that moment. On cold boot, the wifi/network stack is not ready yet, so get_local_ip() returned 127.0.0.1 (loopback) and the service was advertised against that — invisible from any other host on the LAN. Toggling the plugin off/on was the only workaround, because it recreated the MDNSService with a fresh IP. Verified across three boots in production logs: 09:51:22 mDNS will advertise on 127.0.0.1:34173 12:07:16 mDNS will advertise on 127.0.0.1:37617 12:10:38 mDNS will advertise on 127.0.0.1:39469 Changes: steam_utils.py — get_local_ip() now returns Optional[str]: - Adds settimeout(1) to the UDP-connect probe so it fails fast. - Falls back to enumerating addresses bound to the hostname. - Returns None when no usable IPv4 is available, so callers can decide whether to retry or wait. mdns_service.py — owns a watcher thread: - On start(), spawns a daemon thread that waits for a usable IP before registering, then polls every 5s and re-registers on IP change (handles suspend/resume, AP roaming, DHCP renewal). - On stop(), signals the watcher and unregisters cleanly. - announced_ip property exposes the IP currently being advertised. - Thread safety via internal lock. - Zero resources while stopped — toggle off destroys the service. main.py — get_status() reports reality: - port and ip now come from MDNSService (what is actually being announced) instead of a fresh get_local_ip() call. Returns None when mDNS is not active so the UI can render an explicit 'waiting for network' state instead of a misleading address. Frontend (StatusPanel.tsx, useAgent.ts, index.tsx): - Network section removed; IP and Port live inside the Status section, gated by mDNS having registered. - When ip/port are null, shows 'Waiting for network…' instead of fake fallback values. - AgentStatus types updated to allow null. Closes #28 Closes #29 --- main.py | 5 +- mdns_service.py | 217 +++++++++++++++++++++++---------- src/components/StatusPanel.tsx | 50 ++++---- src/hooks/useAgent.ts | 4 +- src/index.tsx | 4 +- steam_utils.py | 39 +++++- 6 files changed, 218 insertions(+), 101 deletions(-) diff --git a/main.py b/main.py index a0bfd0a..0aa00c5 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, @@ -217,8 +216,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..39fdf57 100644 --- a/mdns_service.py +++ b/mdns_service.py @@ -1,8 +1,15 @@ """ 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 +import socket +import threading from typing import Optional import decky # type: ignore @@ -11,73 +18,157 @@ MDNS_SERVICE_TYPE = "_capydeploy._tcp.local." +# Polling interval for the IP watcher. Low enough to react quickly to +# network changes, high enough to keep CPU/wakeups negligible. +_WATCH_INTERVAL_SEC = 5.0 + +# Initial backoff while waiting for the network to come up at boot. +_INITIAL_WAIT_SEC = 2.0 + +# How long to give the background thread to exit on stop(). +_STOP_JOIN_TIMEOUT_SEC = 2.0 + class MDNSService: - """Advertises the agent via mDNS/DNS-SD for Hub discovery.""" + """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): 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 + + 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.""" + # Initial wait gives the wifi/network stack a chance to come up + # before the first probe — avoids a noisy log on cold boot. + self._stop_event.wait(timeout=_INITIAL_WAIT_SEC) + + while not self._stop_event.is_set(): + ip = get_local_ip() + + if ip is None: + # Network not ready — wait and retry. No registration attempt + # while we don't have a usable IP. + self._stop_event.wait(timeout=_WATCH_INTERVAL_SEC) + continue + + with self._lock: + if self._stop_event.is_set(): + return + if ip != self._current_ip: + # IP changed (or first registration). Tear down old + # registration before bringing up the new one so listeners + # see a clean Update event. + if self._current_ip is not None: + decky.logger.info( + f"mDNS IP change: {self._current_ip} -> {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}" + ) + + def _unregister_locked(self) -> None: + if self._zeroconf is not None and self._service_info is not None: + 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 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/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: From ebdcdc05778456b0be2103557124a34e01d83e82 Mon Sep 17 00:00:00 2001 From: Lobinux Date: Mon, 27 Apr 2026 14:49:26 -0300 Subject: [PATCH 2/4] feat(mdns): event-driven lifecycle callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MDNSService now invokes optional on_register and on_unregister callbacks when the watcher transitions between states. The Plugin wires these into a sync _emit_event helper that pushes refresh events through the existing settings-based bus. This lets the frontend react immediately to backend-side mDNS state changes without polling get_status — the same pattern already used for hub_connected, hub_disconnected, and pairing events. - mdns_service.py: callbacks fire from the watcher thread; sync settings I/O makes them safe to call without the asyncio loop. - main.py: notify_frontend now delegates to a thread-safe _emit_event method. Two new handlers (_on_mdns_register and _on_mdns_unregister) emit mdns_registered / mdns_unregistered events with the actual ip/port that zeroconf announced. - eventPoller.tsx: both events trigger onRefreshStatus so the Status panel re-fetches on transition. --- main.py | 30 ++++++++++++++++++++++++++---- mdns_service.py | 32 +++++++++++++++++++++++++++++--- src/eventPoller.tsx | 18 ++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 0aa00c5..aec6eab 100644 --- a/main.py +++ b/main.py @@ -131,7 +131,9 @@ 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, ) self.mdns_service.start() else: @@ -156,8 +158,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 [] @@ -172,6 +178,20 @@ 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", {}) + # ── Frontend API methods ───────────────────────────────────────────────── async def get_setting(self, key: str, default): @@ -192,7 +212,9 @@ 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, ) self.mdns_service.start() else: diff --git a/mdns_service.py b/mdns_service.py index 39fdf57..e928ca7 100644 --- a/mdns_service.py +++ b/mdns_service.py @@ -10,7 +10,7 @@ import socket import threading -from typing import Optional +from typing import Callable, Optional import decky # type: ignore @@ -40,7 +40,15 @@ class MDNSService: Resource use is zero while stopped: no thread, no zeroconf object. """ - def __init__(self, agent_id: str, agent_name: str, port: int, version: str): + 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, + ): self.agent_id = agent_id self.agent_name = agent_name self.port = port @@ -50,6 +58,11 @@ def __init__(self, agent_id: str, agent_name: str, port: int, version: str): self._service_info = None self._current_ip: Optional[str] = None + # Notified after a successful (re-)register or after a teardown. + # Invoked from the watcher thread; callbacks must be thread-safe. + self._on_register = on_register + self._on_unregister = on_unregister + self._stop_event = threading.Event() self._worker: Optional[threading.Thread] = None # Guards _zeroconf, _service_info, _current_ip across the @@ -162,8 +175,15 @@ def _register_locked(self, ip: str) -> None: f"mDNS service registered: {self.agent_id}._capydeploy._tcp.local on {ip}:{self.port}" ) + 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: - if self._zeroconf is not None and self._service_info is not 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() @@ -172,3 +192,9 @@ def _unregister_locked(self) -> None: 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/eventPoller.tsx b/src/eventPoller.tsx index 4d79aa9..b691cd3 100644 --- a/src/eventPoller.tsx +++ b/src/eventPoller.tsx @@ -456,6 +456,24 @@ async function pollAllEvents() { } } while (hubDisconnected?.data); + // ── mDNS lifecycle (watcher reports register / unregister transitions) ── + + const mdnsRegistered = await call<[string], { timestamp: number; data: object } | null>( + "get_event", + "mdns_registered" + ); + if (mdnsRegistered?.data) { + _uiCallbacks.onRefreshStatus?.(); + } + + const mdnsUnregistered = await call<[string], { timestamp: number; data: object } | null>( + "get_event", + "mdns_unregistered" + ); + if (mdnsUnregistered?.data) { + _uiCallbacks.onRefreshStatus?.(); + } + // ── Server error (queue-based, show modal) ── let serverError; From 87464389c250e594720ba1decaf806ff3b01ad49 Mon Sep 17 00:00:00 2001 From: Lobinux Date: Mon, 27 Apr 2026 14:50:47 -0300 Subject: [PATCH 3/4] feat(ui): toast notifications for mdns transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mDNS watcher now signals a third lifecycle event (on_waiting) the first time it cannot find a usable IP. The frontend turns the event stream into branded toasts so the user gets explicit feedback for both 'still waiting for the network' and 'service is ready at this address'. If multiple transitions are pending in a single poll cycle (typical right after the panel opens), only the toast for the most recent one is shown — picked by comparing event timestamps — so a stale 'Waiting' never overwrites a fresh 'Ready' on screen. Also drops the 2-second initial wait at watcher start: it was a boot-race safety margin that costs latency when the network is already up. The same retry loop with a 1-second backoff handles the cold-boot case without the unconditional delay. - mdns_service.py: on_waiting callback fires once per 'no IP' stretch; flag resets on a successful register so a future loss of network re-arms it. _INITIAL_WAIT_SEC removed in favour of _RETRY_INTERVAL_SEC = 1.0. - main.py: _on_mdns_waiting handler emits 'mdns_waiting' event; wired into both MDNSService construction sites. - eventPoller.tsx: drains all three mDNS events in one Promise.all, ranks them by timestamp, fires the latest one as a toast while still triggering a refresh for any change. --- main.py | 6 +++++ mdns_service.py | 54 +++++++++++++++++++++++++++++----------- src/eventPoller.tsx | 60 +++++++++++++++++++++++++++++++++------------ 3 files changed, 91 insertions(+), 29 deletions(-) diff --git a/main.py b/main.py index aec6eab..28ca945 100644 --- a/main.py +++ b/main.py @@ -134,6 +134,7 @@ async def _main(self): 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: @@ -192,6 +193,10 @@ 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): @@ -215,6 +220,7 @@ async def set_enabled(self, enabled=False): 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: diff --git a/mdns_service.py b/mdns_service.py index e928ca7..ab3615c 100644 --- a/mdns_service.py +++ b/mdns_service.py @@ -18,12 +18,15 @@ MDNS_SERVICE_TYPE = "_capydeploy._tcp.local." -# Polling interval for the IP watcher. Low enough to react quickly to -# network changes, high enough to keep CPU/wakeups negligible. +# 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 -# Initial backoff while waiting for the network to come up at boot. -_INITIAL_WAIT_SEC = 2.0 +# 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 @@ -48,6 +51,7 @@ def __init__( 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 @@ -58,10 +62,17 @@ def __init__( self._service_info = None self._current_ip: Optional[str] = None - # Notified after a successful (re-)register or after a teardown. - # Invoked from the watcher thread; callbacks must be thread-safe. + # 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 @@ -106,18 +117,29 @@ def announced_ip(self) -> Optional[str]: # ── Worker ─────────────────────────────────────────────────────────────── def _run(self) -> None: - """Watch for a usable IP and (re-)register on changes.""" - # Initial wait gives the wifi/network stack a chance to come up - # before the first probe — avoids a noisy log on cold boot. - self._stop_event.wait(timeout=_INITIAL_WAIT_SEC) - + """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 — wait and retry. No registration attempt - # while we don't have a usable IP. - self._stop_event.wait(timeout=_WATCH_INTERVAL_SEC) + # 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: @@ -175,6 +197,10 @@ def _register_locked(self, ip: str) -> None: 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) diff --git a/src/eventPoller.tsx b/src/eventPoller.tsx index b691cd3..71b6f3c 100644 --- a/src/eventPoller.tsx +++ b/src/eventPoller.tsx @@ -456,22 +456,52 @@ async function pollAllEvents() { } } while (hubDisconnected?.data); - // ── mDNS lifecycle (watcher reports register / unregister transitions) ── - - const mdnsRegistered = await call<[string], { timestamp: number; data: object } | null>( - "get_event", - "mdns_registered" - ); - if (mdnsRegistered?.data) { - _uiCallbacks.onRefreshStatus?.(); - } - - const mdnsUnregistered = await call<[string], { timestamp: number; data: object } | null>( - "get_event", - "mdns_unregistered" - ); - if (mdnsUnregistered?.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) ── From 28fcbea72b9fbafb3d9206091488fddefd46704e Mon Sep 17 00:00:00 2001 From: Lobinux Date: Mon, 27 Apr 2026 14:51:43 -0300 Subject: [PATCH 4/4] fix(mdns): re-register after network recovery (suspend/wake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, if the Steam Deck suspended and woke with the same LAN IP, the watcher would observe ip == self._current_ip on its next tick and skip the re-register. The zeroconf service was already broken by the network drop, but the watcher had no way to know, and the user saw a 'Waiting for network…' toast that was never followed by a 'CapyDeploy ready' one. Toggling the plugin off and on was the only way to recover. Use the _notified_waiting flag as the missing signal: if the watcher emitted mdns_waiting at any point, we know the network went down even if it came back on the same IP, so we tear down zeroconf and re-register. The flag clears on success, so steady state stays a no-op tick. Verified on a real Steam Deck across suspend/wake cycles — full recovery in ~2.7s with the Hub auto-reconnecting via the ServiceUpdated event. --- mdns_service.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/mdns_service.py b/mdns_service.py index ab3615c..6fa8a84 100644 --- a/mdns_service.py +++ b/mdns_service.py @@ -145,14 +145,23 @@ def _run(self) -> None: with self._lock: if self._stop_event.is_set(): return - if ip != self._current_ip: - # IP changed (or first registration). Tear down old - # registration before bringing up the new one so listeners - # see a clean Update event. + # 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: - decky.logger.info( - f"mDNS IP change: {self._current_ip} -> {ip}" - ) + 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)