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
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user