Files
CloudRecoveryAS/runner.py
hreyes 9214a1feab fix(bootstrap): escribir el sello de version lo primero, y hacerla visible en el log
Actualizar a 1.1.4 fallaba con "no escribio config\.version tras la
actualizacion" aunque el binario nuevo estuviera instalado y corriendo desde la
ruta correcta. La causa era el ORDEN dentro de ensure_runtime_layout(): el sello
iba al final, detras del re-despliegue de las deps embebidas (7-Zip y ODBC). Al
cambiar de version esas deps se re-copian ENTERAS, asi que el sello quedaba por
detras de esa copia y del desempaquetado del onefile de ~254 MB con el antivirus
escaneando cada archivo. El PANEL se rendia esperandolo y daba por fallida una
actualizacion que iba bien.

- El sello se escribe lo primero, en cuanto existen las carpetas. Es tambien mas
  honesto sobre lo que significa —"que binario esta corriendo"—, que es cierto
  desde que el proceso arranca. El sello de DEPS sigue yendo al final, donde su
  comentario explica por que: si la copia falla a medias, el proximo arranque
  reintenta en vez de quedar marcado como al dia.
- La version va en la PRIMERA linea del log de arranque. Permite comprobar que
  binario corre de verdad mirando solo config/logs, sin depender del sello ni del
  reporte al panel: verificar una actualizacion deja de obligar a creerse lo que
  diga otro sistema.

La prueba nueva observa el estado del sello EN EL MOMENTO en que empieza la copia
de deps, no al final, que es la unica forma de fijar el orden. Comprobado que
muerde: devolviendo el sello al final falla con `assert None == '1.1.5'`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:11:36 -06:00

262 lines
9.7 KiB
Python

"""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()