Files
AR-Autopilot/display_manager/process_manager.py
T
alro65 42b2eec2e1 feat: AR Display Manager — multi-monitor floating app switcher
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
2026-05-24 21:48:27 -04:00

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)