install.sh recibió la maquinaria de actualización segura y install.ps1 nunca recibió el equivalente. La asimetría se notaba en producción: actualizar desde el PANEL dejaba el servidor sin agente. - -UpdateInPlace: actualiza conservando la tarea y la configuración, sin correr el bootstrap (una segunda instancia purga el Temp\ de la que está viva). - No se interrumpe una restauración en curso: sale con 75 (EX_TEMPFAIL), que el PANEL traduce a "reintenta luego". Windows no tenía esta guarda y una reinstalación a destiempo dejaba el respaldo vetado y la base en SINGLE_USER. - Respaldo del binario anterior y reversión automática si el nuevo no arranca. - Rearranque garantizado en TODOS los modos: la detención corría siempre, pero solo -Service volvía a arrancar algo. - Espera de liberación del .exe de 5s a 30s con reintentos de la copia. - Se distingue "no es administrador" de "es administrador con el token filtrado por UAC", que es lo que recibe una sesión de OpenSSH. Se veían idénticos y el remedio es el opuesto. Y --headless deja de ser un no-op en Windows: _ensure_qt_platform() salía de inmediato en win32, así que la tarea ONSTART arrancaba como SYSTEM en la sesión 0 con el plugin Qt 'windows' intentando crear una ventana real. En Linux el unit fija QT_QPA_PLATFORM=offscreen por fuera, y esa asimetría escondió el defecto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
256 lines
9.3 KiB
Python
256 lines
9.3 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
|
|
|
|
app_logger.info("=" * 80)
|
|
app_logger.info("CloudRestoreAS - 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()
|