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>
262 lines
9.7 KiB
Python
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()
|