"""Launch and track the four AR Bridge applications.""" from __future__ import annotations import subprocess import sys import time from pathlib import Path from typing import Optional from PySide6.QtCore import QObject, QTimer, Signal from display_manager.config import AppExeConfig, DisplayManagerConfig from display_manager import win32_utils class AppHandle: """Tracks one running application.""" def __init__(self, app_id: str, proc: subprocess.Popen[bytes]) -> None: self.app_id = app_id self.proc = proc self.hwnd: Optional[int] = None self._hwnd_found_at: float = 0.0 @property def pid(self) -> int: return self.proc.pid def find_hwnd(self, title_hint: str = "") -> int | None: """Try to locate the main window HWND (may not be available immediately).""" hwnd = win32_utils.find_window_by_pid(self.pid) if hwnd is None and title_hint: hwnd = win32_utils.find_window_by_title_hint(title_hint) if hwnd is not None: self.hwnd = hwnd self._hwnd_found_at = time.monotonic() return hwnd def is_running(self) -> bool: return self.proc.poll() is None def is_window_alive(self) -> bool: return self.hwnd is not None and win32_utils.is_window_alive(self.hwnd) class ProcessManager(QObject): """Manages the lifecycle of all registered apps. Emits ``app_state_changed`` whenever an app starts or stops. """ app_state_changed = Signal(str, bool) # (app_id, is_running) _POLL_MS = 3_000 # check process health every 3 s def __init__(self, config: DisplayManagerConfig, parent: QObject | None = None) -> None: super().__init__(parent) self._config = config self._handles: dict[str, AppHandle] = {} self._timer = QTimer(self) self._timer.timeout.connect(self._poll) self._timer.start(self._POLL_MS) # ---------------------------------------------------------------- Public def launch_if_configured(self, app_id: str) -> bool: """Launch *app_id* if it has a valid exe and is not already running.""" if app_id in self._handles and self._handles[app_id].is_running(): return True exe_cfg = self._config.apps.get(app_id) if not exe_cfg or not exe_cfg.exe or not Path(exe_cfg.exe).exists(): return False return self._launch(app_id, exe_cfg) def launch_all(self) -> None: """Attempt to launch every configured app.""" for app_id in self._config.apps: self.launch_if_configured(app_id) def bring_to_screen(self, app_id: str, x: int, y: int, w: int, h: int) -> bool: """Move *app_id*'s window to the given screen rect. Returns False if not running.""" handle = self._handles.get(app_id) if handle is None or not handle.is_running(): return False title_hint = self._config.apps.get(app_id, AppExeConfig()).title_hint hwnd = handle.find_hwnd(title_hint) if hwnd is None: return False win32_utils.move_and_maximize(hwnd, x, y, w, h) return True def is_running(self, app_id: str) -> bool: h = self._handles.get(app_id) return h is not None and h.is_running() def running_ids(self) -> set[str]: return {aid for aid, h in self._handles.items() if h.is_running()} # ---------------------------------------------------------------- Private def _launch(self, app_id: str, exe_cfg: AppExeConfig) -> bool: try: cmd = [exe_cfg.exe] + exe_cfg.args proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=( subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 ), ) self._handles[app_id] = AppHandle(app_id, proc) self.app_state_changed.emit(app_id, True) return True except Exception: return False def _poll(self) -> None: for app_id, handle in list(self._handles.items()): if not handle.is_running(): del self._handles[app_id] self.app_state_changed.emit(app_id, False)