42b2eec2e1
Adds the AR Display Manager daemon (Task #9): - display_manager/ package with 8 modules: - app_registry.py : static metadata for the 4 bridge apps - config.py : JSON-persisted config + per-screen layout store - win32_utils.py : ctypes wrappers (EnumWindows, SetWindowPos, ShowWindow) - process_manager.py: launch + track app processes, HWND lookup - floating_button.py: always-on-top 52×52 px AR button per monitor with draggable placement + custom-painted popup - display_manager.py: orchestrator (QScreens → buttons → app placement + system tray with Settings/Launch/Quit) - settings_dialog.py: modal dialog for exe paths, button position, and Windows autostart toggle - autostart.py : HKCU Run registry read/write helpers - display_manager_main.py at repo root: launcher script - Studio Overview tab: "Lanzar Display Manager" button - pyproject.toml: display-manager optional dependency group (PySide6) Layout persistence: ~/.ar-autopilot/display_manager/layout.json Config: ~/.ar-autopilot/display_manager/config.json Supports up to 4 monitors (2 HDMI native + 2 USB DisplayLink). Double-click tray icon to toggle button visibility. AR_electronics — AR-Autopilot Project
125 lines
4.3 KiB
Python
125 lines
4.3 KiB
Python
"""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)
|