Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
from settings import SettingsManager # type: ignore

from steam_utils import (
get_local_ip,
detect_platform,
get_user_home,
expand_path,
Expand Down Expand Up @@ -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:
Expand All @@ -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 []
Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -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),
Expand Down
284 changes: 218 additions & 66 deletions mdns_service.py
Original file line number Diff line number Diff line change
@@ -1,83 +1,235 @@
"""
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

from steam_utils import get_local_ip, detect_platform

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}")
Loading
Loading