diff --git a/openadapt/app/tray.py b/openadapt/app/tray.py index 805a39b60..6240a2f97 100644 --- a/openadapt/app/tray.py +++ b/openadapt/app/tray.py @@ -3,6 +3,21 @@ usage: `python -m openadapt.app.tray` or `poetry run app` """ +import sys +import asyncio +import os + +# Fix for Windows ProactorEventLoop +if sys.platform == "win32": + try: + if isinstance( + asyncio.get_event_loop_policy(), asyncio.WindowsProactorEventLoopPolicy + ): + # Use WindowsSelectorEventLoopPolicy instead + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + except Exception as e: + print(f"Failed to set event loop policy: {e}") + from datetime import datetime from functools import partial from pprint import pformat @@ -10,8 +25,6 @@ from typing import Any, Callable import inspect import multiprocessing -import os -import sys from pyqttoast import Toast, ToastButtonAlignment, ToastIcon, ToastPosition, ToastPreset from PySide6.QtCore import QMargins, QObject, QSize, Qt, QThread, Signal @@ -33,7 +46,7 @@ QWidget, ) -from openadapt.app import quick_record, stop_record, FPATH +from openadapt.app import FPATH, quick_record, stop_record from openadapt.app.dashboard.run import cleanup as cleanup_dashboard from openadapt.app.dashboard.run import run as run_dashboard from openadapt.build_utils import is_running_from_executable @@ -109,8 +122,9 @@ class SystemTrayIcon: def __init__(self) -> None: """Initialize the system tray icon.""" - self.app = QApplication([]) - + self.app = QApplication.instance() + if not self.app: + self.app = QApplication([]) if sys.platform == "darwin": # hide Dock icon while allowing focus on dialogs # (must come after QApplication()) diff --git a/openadapt/entrypoint.py b/openadapt/entrypoint.py index 1d05a5cdc..de8f64f6f 100644 --- a/openadapt/entrypoint.py +++ b/openadapt/entrypoint.py @@ -1,30 +1,138 @@ """Entrypoint for OpenAdapt.""" import multiprocessing +import sys +import requests -if __name__ == "__main__": - # This needs to be called before any code that uses multiprocessing - multiprocessing.freeze_support() +from PySide6.QtCore import QObject, QTimer, Signal +from PySide6.QtWidgets import QApplication from openadapt.build_utils import redirect_stdout_stderr -from openadapt.error_reporting import configure_error_reporting +from openadapt.config import config from openadapt.custom_logger import logger +from openadapt.splash_screen import LoadingScreen -def run_openadapt() -> None: - """Run OpenAdapt.""" - with redirect_stdout_stderr(): +class LoadingManager(QObject): + """Manages the loading stages and progress updates.""" + + progress_updated = Signal(int, str) + loading_complete = Signal() + + def __init__(self, splash_screen: LoadingScreen, app: QApplication) -> None: + """Initialize the loading manager.""" + super().__init__() + self.splash = splash_screen + self.app = app + self.progress_updated.connect(self._update_progress) + self.loading_complete.connect(self._on_loading_complete) + self.dashboard_check_attempts = 0 + self.is_ready = False + self.dashboard_timer = None + + def _update_progress(self, value: int, message: str) -> None: + """Update progress bar and process events.""" + if not self.is_ready: + self.splash.update_progress(value) + self.splash.update_status(message) + self.app.processEvents() + logger.debug(f"Progress: {value}% - {message}") + + def _on_loading_complete(self) -> None: + """Handle completion of loading sequence.""" + self.is_ready = True + if self.dashboard_timer: + self.dashboard_timer.stop() + QTimer.singleShot(500, self.splash.hide) + QTimer.singleShot(600, self.app.quit) + + def check_dashboard(self) -> None: + """Check if dashboard is responsive and handle loading completion.""" try: - from openadapt.alembic.context_loader import load_alembic_context - from openadapt.app import tray + url = f"http://localhost:{config.DASHBOARD_CLIENT_PORT}" + response = requests.get(url, timeout=1) + if response.status_code == 200: + self.progress_updated.emit(100, "Ready!") + self.loading_complete.emit() + return + except requests.RequestException: + pass + + def start_dashboard_monitoring(self) -> None: + """Start dashboard monitoring using Qt timer.""" + self.dashboard_timer = QTimer(self) + self.dashboard_timer.timeout.connect(self.check_dashboard) + self.dashboard_timer.start(100) # Check every 100ms + + def start_loading_sequence(self) -> bool: + """Execute the loading sequence with visible progress updates.""" + try: + # Initial setup - 0% + self.progress_updated.emit(0, "Initializing...") + + # Configuration - 20% + self.progress_updated.emit(20, "Loading configuration...") from openadapt.config import print_config print_config() + + # Error reporting setup - 40% + self.progress_updated.emit(40, "Configuring error reporting...") + from openadapt.error_reporting import configure_error_reporting + configure_error_reporting() + + # Database context - 60% + self.progress_updated.emit(60, "Loading database context...") + from openadapt.alembic.context_loader import load_alembic_context + load_alembic_context() - tray._run() + + # System tray setup - 80% + self.progress_updated.emit(80, "Starting system tray...") + + return True + + except Exception as e: + logger.error(f"Loading sequence failed: {e}") + return False + + +def run_tray() -> None: + """Run the openadapt tray.""" + from openadapt.app import tray + + tray._run() + + +def run_openadapt() -> None: + """Run OpenAdapt.""" + with redirect_stdout_stderr(): + try: + app = QApplication(sys.argv) + splash = LoadingScreen() + splash.show() + + loading_manager = LoadingManager(splash, app) + + if not loading_manager.start_loading_sequence(): + raise Exception("Loading sequence failed") + + tray_process = multiprocessing.Process(target=run_tray, daemon=False) + tray_process.start() + + if not tray_process.is_alive(): + raise Exception("Tray process failed to start") + + loading_manager.start_dashboard_monitoring() + + app.exec() + except Exception as exc: logger.exception(exc) + if "tray_process" in locals() and tray_process and tray_process.is_alive(): + tray_process.terminate() + sys.exit(1) if __name__ == "__main__": diff --git a/openadapt/splash_screen.py b/openadapt/splash_screen.py new file mode 100644 index 000000000..add3446b2 --- /dev/null +++ b/openadapt/splash_screen.py @@ -0,0 +1,91 @@ +"""Modern minimal splash screen for OpenAdapt with improved responsiveness.""" + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QColor, QPixmap +from PySide6.QtWidgets import QApplication, QLabel, QProgressBar, QSplashScreen + + +class LoadingScreen(QSplashScreen): + """A minimal splash screen for.""" + + def __init__(self) -> None: + """Initialize the loading screen.""" + pixmap = QPixmap(400, 100) + pixmap.fill(QColor(0, 0, 0, 180)) + + super().__init__(pixmap) + + self.title_label = QLabel("OpenAdapt", self) + self.title_label.setGeometry(0, 15, 400, 30) + self.title_label.setAlignment(Qt.AlignCenter) + self.title_label.setStyleSheet( + """ + QLabel { + color: #FFFFFF; + font-family: Arial; + font-size: 20px; + font-weight: bold; + } + """ + ) + + self.progress_bar = QProgressBar(self) + self.progress_bar.setGeometry(50, 55, 300, 6) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(False) + + self.status_label = QLabel(self) + self.status_label.setGeometry(0, 70, 400, 20) + self.status_label.setAlignment(Qt.AlignCenter) + + self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) + + self.progress_bar.setStyleSheet( + """ + QProgressBar { + border: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + } + QProgressBar::chunk { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #2196F3, stop:1 #64B5F6); + border-radius: 3px; + } + """ + ) + + self.status_label.setStyleSheet( + """ + QLabel { + color: #CCCCCC; + font-size: 11px; + font-family: Arial; + } + """ + ) + + def update_status(self, message: str) -> None: + """Update the status message displayed on the splash screen.""" + self.status_label.setText(message) + self.repaint() + QApplication.processEvents() + + def update_progress(self, value: int) -> None: + """Update progress value with immediate visual feedback.""" + value = max(0, min(100, value)) + # for smooth progress updates + QTimer.singleShot(0, lambda: self._do_update(value)) + + def _do_update(self, value: int) -> None: + """Internal method to perform the actual progress update.""" + self.progress_bar.setValue(value) + self.repaint() + QApplication.processEvents() + + def show(self) -> None: + """Show the splash screen.""" + super().show() + self.raise_() + QApplication.processEvents()