"""Punto de entrada principal de la aplicación.""" import argparse import os import sys import traceback from pathlib import Path ROOT_DIR = Path(__file__).parent.resolve() if not getattr(sys, "frozen", False): sys.path.insert(0, str(ROOT_DIR)) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="CloudRestoreAS") parser.add_argument( "--minimized", action="store_true", help="Inicia minimizado en la bandeja del sistema", ) parser.add_argument( "--start-engine", action="store_true", help="Inicia el motor de restauración automáticamente", ) parser.add_argument( "--headless", action="store_true", help="Fuerza el modo sin interfaz (Qt offscreen) para servidores sin display", ) parser.add_argument( "--version", action="store_true", help="Imprime la versión, plataforma y arquitectura, y termina", ) return parser.parse_args(argv) def _attach_windows_console() -> None: """ El .exe se compila con console=False (app de ventana), así que sin consola propia stdout va al vacío. Cuando se invoca desde una terminal, se engancha a la consola del proceso padre para que --version sea legible. En Linux no aplica. """ if sys.platform != "win32": return try: import ctypes attach_parent_process = -1 if not ctypes.windll.kernel32.AttachConsole(attach_parent_process): return # sin consola del padre (doble clic): no hay dónde escribir sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1) sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1) except (OSError, AttributeError): # Sin consola disponible; el instalador remoto verifica por config/.version. pass def _print_version() -> int: """ Imprime la versión sin arrancar Qt ni el bootstrap: --version es una consulta. El instalador remoto del PANEL prefiere leer config/.version (que el bootstrap escribe), porque en Windows este stdout depende de haber consola del padre. """ from app import __version__ from app.constants import APP_ARCH, APP_PLATFORM _attach_windows_console() try: sys.stdout.write(f"CloudRestoreAS {__version__} ({APP_PLATFORM}/{APP_ARCH})\n") sys.stdout.flush() except OSError: pass return 0 def _ensure_qt_platform(headless: bool = False) -> str: """ Selecciona el plugin de plataforma Qt. `--headless` fuerza 'offscreen' en TODAS las plataformas. Antes se salía de inmediato en Windows, así que la bandera no hacía nada ahí: la tarea programada ONSTART —que corre como SYSTEM, en la sesión 0, sin escritorio interactivo— arrancaba con el plugin 'windows' e intentaba crear una ventana real. Ese es el motivo de que el agente no levantara tras instalarse en Windows mientras en Linux, donde el unit fija QT_QPA_PLATFORM=offscreen, sí. El plugin va embebido en el binario de las dos plataformas (qoffscreen.dll / libqoffscreen.so). La autodetección por DISPLAY/WAYLAND_DISPLAY sigue siendo solo de Linux: es donde su ausencia significa "no hay servidor gráfico". En Windows y macOS no existen esas variables y tomarlas como señal mandaría a offscreen a cualquiera que abra la app con doble clic. No toca nada si el usuario ya fijó QT_QPA_PLATFORM: una elección explícita manda sobre todo lo demás. Devuelve la plataforma en uso ("offscreen") o "" si se deja el default. """ if os.environ.get("QT_QPA_PLATFORM"): return os.environ["QT_QPA_PLATFORM"] if headless: os.environ["QT_QPA_PLATFORM"] = "offscreen" return "offscreen" if sys.platform in ("win32", "darwin"): return "" has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) if not has_display: os.environ["QT_QPA_PLATFORM"] = "offscreen" return "offscreen" return "" def _crash_log_targets() -> list[Path]: """Ubicaciones candidatas para el crash log, de más a menos accesible.""" targets: list[Path] = [] try: targets.append(Path(sys.executable).resolve().parent / "CloudRestoreAS-crash.log") except Exception: pass base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or os.environ.get("HOME") if base: targets.append(Path(base) / "CloudRestoreAS" / "crash.log") import tempfile targets.append(Path(tempfile.gettempdir()) / "CloudRestoreAS-crash.log") return targets def _write_crash_log(text: str) -> Path | None: """Escribe el detalle del crash en la primera ubicación escribible.""" for path in _crash_log_targets(): try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") return path except Exception: continue return None def _show_fatal(exc: BaseException) -> None: """Hace VISIBLE un fallo de arranque: crash log + diálogo (o stderr).""" tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) detail = f"CloudRestoreAS no pudo iniciar.\n\n{type(exc).__name__}: {exc}\n\n{tb}" crash_path = _write_crash_log(detail) try: sys.stderr.write(detail) except Exception: pass try: # Sin display, forzar offscreen para que crear la QApplication no falle # también aquí (el diálogo no se verá, pero el crash log ya quedó escrito). _ensure_qt_platform() from PySide6.QtWidgets import QApplication, QMessageBox app = QApplication.instance() or QApplication(sys.argv) box = QMessageBox() box.setIcon(QMessageBox.Icon.Critical) box.setWindowTitle("CloudRestoreAS — Error al iniciar") box.setText("La aplicación no pudo iniciar.") info = str(exc) or type(exc).__name__ if crash_path: info += f"\n\nDetalle guardado en:\n{crash_path}" box.setInformativeText(info) box.setDetailedText(tb) box.exec() except Exception: # Sin GUI disponible: el crash log y stderr ya quedaron escritos. pass def _run() -> int: """Arranque real. Los imports están diferidos para que main() capture cualquier fallo.""" # Bootstrap y ODBC antes de importar módulos que usan pyodbc. from app.config.bootstrap import ensure_runtime_layout from app.config.odbc_setup import configure_odbc_environment ensure_runtime_layout() configure_odbc_environment() from PySide6.QtWidgets import QApplication from PySide6.QtCore import Qt from app.config import get_launch_options, initialize_env, is_panel_configured from app.ui.main_window import MainWindow from app.ui.tray_assets import load_tray_icon from app.utils.logger import app_logger args = parse_args() qt_platform = _ensure_qt_platform(headless=args.headless) launch = get_launch_options() initialize_env() panel_ok = is_panel_configured() start_engine = args.start_engine or launch.start_engine from app import __version__ app_logger.info("=" * 80) # La versión va en la PRIMERA línea del arranque a propósito: es la forma de comprobar qué # binario está corriendo de verdad sin depender del sello ni del reporte al panel, mirando # solo config/logs. Sin esto, verificar una actualización obligaba a creerse lo que dijera # otro sistema. app_logger.info(f"CloudRestoreAS {__version__} - Iniciando aplicación") app_logger.info(f"Directorio app: {ROOT_DIR}") if qt_platform == "offscreen": app_logger.info("Modo: sin display → plataforma Qt 'offscreen' (motor headless)") else: app_logger.info("Modo: ventana siempre visible; cerrar minimiza a bandeja") if start_engine: app_logger.info("Modo: motor auto-inicio") if not panel_ok: app_logger.warning( "Servicio PANEL_BASES_ANEXO24 no configurado en config/.env — " "complete CLOUDRESTORE_PANEL_* para enrutamiento automático." ) app_logger.info("=" * 80) app = QApplication(sys.argv) app.setApplicationName("CloudRestoreAS") app.setOrganizationName("Aduanasoft") app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling) app.setQuitOnLastWindowClosed(False) app.setWindowIcon(load_tray_icon()) window = MainWindow( start_engine=start_engine, panel_configured=panel_ok, ) # Con display, la ventana SIEMPRE se muestra al iniciar; cerrar (X) la manda a la bandeja. # En offscreen no hay a quién mostrarla, y pedirlo igual solo da trabajo al plugin y ruido en # el log. El motor no depende de esto: arranca por su propio temporizador en MainWindow. if qt_platform == "offscreen": app_logger.info("Sin interfaz: la ventana no se muestra (motor headless)") else: window.show() window.activateWindow() window.raise_() app_logger.info("Ventana principal mostrada") exit_code = app.exec() app_logger.info(f"Aplicación finalizada con código: {exit_code}") return exit_code def main(): """Punto de entrada: ejecuta _run() y hace visible cualquier fallo de arranque.""" # --version se atiende antes del bootstrap y de importar Qt/pyodbc: es una consulta # barata que el instalador remoto usa para verificar el binario recién desplegado. if "--version" in sys.argv[1:]: sys.exit(_print_version()) try: sys.exit(_run()) except SystemExit: raise except BaseException as exc: # capturamos TODO en el arranque: nunca morir en silencio _show_fatal(exc) sys.exit(1) if __name__ == "__main__": main()