"""Provider-agnostic update checker service.""" import asyncio import logging from typing import Any, Optional from .release_provider import ReleaseProvider from .websocket_manager import ws_manager logger = logging.getLogger(__name__) def _parse_version(version: str) -> tuple[int, ...]: """Parse a version string into a comparable tuple. Handles versions like "1.0.0", "1.2.3", ignoring non-numeric suffixes. """ parts: list[int] = [] for part in version.split("."): digits = "" for ch in part: if ch.isdigit(): digits += ch else: break if digits: parts.append(int(digits)) return tuple(parts) class UpdateChecker: """Periodically checks for new releases using a ReleaseProvider.""" def __init__(self, provider: ReleaseProvider, current_version: str) -> None: self._provider = provider self._current_version = current_version self._current_parsed = _parse_version(current_version) self._task: Optional[asyncio.Task] = None self._cached_update: Optional[dict[str, Any]] = None @property def cached_update(self) -> Optional[dict[str, Any]]: """Return the cached update info, or None if up-to-date.""" return self._cached_update async def check_for_update(self) -> Optional[dict[str, Any]]: """Check for a newer release. Returns: Dict with current/latest/url if an update exists, None otherwise. """ release = await self._provider.get_latest_release() if release is None: return None latest_parsed = _parse_version(release.version) if latest_parsed <= self._current_parsed: return None return { "current": self._current_version, "latest": release.version, "url": release.url, } async def start(self, interval: int) -> None: """Start periodic update checking. Checks immediately on start, then every `interval` seconds. """ if self._task is not None: return self._task = asyncio.create_task(self._check_loop(interval)) logger.info("Update checker started (interval: %ds)", interval) async def stop(self) -> None: """Stop periodic update checking.""" if self._task is not None: self._task.cancel() try: await self._task except asyncio.CancelledError: pass self._task = None logger.info("Update checker stopped") async def _check_loop(self, interval: int) -> None: """Background loop that checks for updates periodically.""" # Initial check with a small delay to let the server finish starting await asyncio.sleep(5) while True: try: update = await self.check_for_update() if update and update != self._cached_update: self._cached_update = update logger.info( "New version available: %s → %s (%s)", update["current"], update["latest"], update["url"], ) await ws_manager.broadcast( {"type": "update_available", "data": update} ) elif update is None and self._cached_update is not None: # Version was updated (or release removed) — clear cache self._cached_update = None except asyncio.CancelledError: break except Exception as e: logger.warning("Update check failed: %s", e) try: await asyncio.sleep(interval) except asyncio.CancelledError: break