"""Installer widget — "Instalar J6412" tab in AR-Autopilot Studio. Lets the integrator build a USB pendrive installer image without leaving the Studio: 1. Enter vessel name + generate (or paste) a serial number. 2. Choose which apps to bundle (AR-ECDIS, AR-Autopilot Display). 3. Click "Build USB Image" → runs installer/build_usb.py in a worker thread. 4. Watch the live log. When done, open the dist/ folder. RBAC: only Engineer and Super Admin can build installers. """ from __future__ import annotations import os import secrets import shlex import subprocess import sys from pathlib import Path from PySide6.QtCore import QObject, Qt, QThread, Signal from PySide6.QtWidgets import ( QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, ) from arautopilot.core.rbac import Capability from arautopilot.studio.session import Session REPO_ROOT = Path(__file__).resolve().parents[2] INSTALLER_DIR = REPO_ROOT / "installer" DIST_DIR = INSTALLER_DIR / "dist" BUILD_SCRIPT = INSTALLER_DIR / "build_usb.py" # --------------------------------------------------------------------------- # Serial generator (inline — no dependency on installer/serial_generator.py) # --------------------------------------------------------------------------- def _generate_serial() -> str: raw = secrets.token_hex(6).upper() return f"AR-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}" # --------------------------------------------------------------------------- # Worker thread # --------------------------------------------------------------------------- class _BuildWorker(QObject): line = Signal(str) finished = Signal(int) # exit code error = Signal(str) def __init__(self, argv: list[str]) -> None: super().__init__() self._argv = argv self._proc: subprocess.Popen | None = None self._cancelled = False def run(self) -> None: try: env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" self._proc = subprocess.Popen( self._argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, env=env, ) assert self._proc.stdout for raw in self._proc.stdout: if self._cancelled: break self.line.emit(raw.rstrip("\n")) code = self._proc.wait() self.finished.emit(code) except FileNotFoundError as exc: self.error.emit(f"No se encontró el ejecutable: {exc}") except Exception as exc: # noqa: BLE001 self.error.emit(str(exc)) def cancel(self) -> None: self._cancelled = True if self._proc: try: self._proc.terminate() except Exception: pass # --------------------------------------------------------------------------- # Widget # --------------------------------------------------------------------------- class InstallerWidget(QWidget): """'Instalar J6412' tab content.""" def __init__(self, session: Session, parent: QWidget | None = None) -> None: super().__init__(parent) self._session = session self._thread: QThread | None = None self._worker: _BuildWorker | None = None self._build_ui() # ── UI ─────────────────────────────────────────────────────────────────── def _build_ui(self) -> None: root = QVBoxLayout(self) root.setContentsMargins(12, 12, 12, 12) root.setSpacing(12) # Header hdr = QLabel( "" "INSTALAR EN J6412
" "" "Genera un pendrive USB con las apps AR Electronics y la licencia del buque." ) hdr.setTextFormat(Qt.TextFormat.RichText) # RichText hdr.setWordWrap(True) root.addWidget(hdr) # ── Config group ───────────────────────────────────────────────────── cfg = QGroupBox("Configuración del paquete") form = QVBoxLayout(cfg) # Vessel name vessel_row = QHBoxLayout() vessel_row.addWidget(QLabel("Nombre del buque:")) self._vessel_edit = QLineEdit() self._vessel_edit.setPlaceholderText("e.g. M/Y PACIFICO") vessel_row.addWidget(self._vessel_edit, 1) form.addLayout(vessel_row) # Serial number serial_row = QHBoxLayout() serial_row.addWidget(QLabel("Número de serie:")) self._serial_edit = QLineEdit() self._serial_edit.setPlaceholderText("AR-XXXX-XXXX-XXXX") self._serial_edit.setMaximumWidth(220) gen_btn = QPushButton("Generar") gen_btn.setToolTip("Genera un nuevo número de serie aleatorio") gen_btn.clicked.connect(self._on_generate_serial) serial_row.addWidget(self._serial_edit) serial_row.addWidget(gen_btn) serial_row.addStretch(1) form.addLayout(serial_row) # CSV log csv_row = QHBoxLayout() csv_row.addWidget(QLabel("Registro CSV:")) self._csv_edit = QLineEdit() self._csv_edit.setPlaceholderText("Opcional — ruta al archivo serials.csv") browse_btn = QPushButton("…") browse_btn.setFixedWidth(30) browse_btn.clicked.connect(self._on_browse_csv) csv_row.addWidget(self._csv_edit, 1) csv_row.addWidget(browse_btn) form.addLayout(csv_row) # App checkboxes app_row = QHBoxLayout() self._chk_autopilot = QCheckBox("AR-Autopilot Display (Flutter)") self._chk_autopilot.setChecked(True) self._chk_ecdis = QCheckBox("AR-ECDIS") self._chk_ecdis.setChecked(True) self._chk_no_flutter = QCheckBox("Omitir compilación Flutter (usar build existente)") app_row.addWidget(self._chk_autopilot) app_row.addWidget(self._chk_ecdis) app_row.addStretch(1) form.addLayout(app_row) form.addWidget(self._chk_no_flutter) root.addWidget(cfg) # ── Action row ─────────────────────────────────────────────────────── act = QHBoxLayout() self._build_btn = QPushButton("▶ Build USB Image") self._build_btn.setObjectName("primary") self._build_btn.setToolTip("Compila e empaqueta el instalador USB") self._build_btn.clicked.connect(self._on_build) act.addWidget(self._build_btn) self._cancel_btn = QPushButton("Cancelar") self._cancel_btn.setEnabled(False) self._cancel_btn.clicked.connect(self._on_cancel) act.addWidget(self._cancel_btn) self._open_btn = QPushButton("Abrir dist/") self._open_btn.setEnabled(False) self._open_btn.setToolTip(f"Abre la carpeta: {DIST_DIR}") self._open_btn.clicked.connect(self._on_open_dist) act.addWidget(self._open_btn) act.addStretch(1) self._status_lbl = QLabel("") act.addWidget(self._status_lbl) root.addLayout(act) # ── Log ────────────────────────────────────────────────────────────── self._log = QPlainTextEdit() self._log.setReadOnly(True) self._log.setStyleSheet( "background:#0A1520; color:#C9D1D9; font-family:Consolas,'Cascadia Mono',monospace; font-size:10px;" ) self._log.setPlaceholderText("El log del proceso de build aparecerá aquí…") root.addWidget(self._log, 1) # RBAC gate can_build = self._session.can(Capability.EDIT_COMMISSIONING) if not can_build: self._build_btn.setEnabled(False) self._build_btn.setToolTip("Requiere rol Engineer o Super Admin.") self._vessel_edit.setEnabled(False) self._serial_edit.setEnabled(False) self._chk_autopilot.setEnabled(False) self._chk_ecdis.setEnabled(False) self._chk_no_flutter.setEnabled(False) root.insertWidget(0, QLabel( "⚠ Tu rol no permite construir instaladores. " "Inicia sesión como Engineer o Super Admin." )) # ── Handlers ────────────────────────────────────────────────────────────── def _on_generate_serial(self) -> None: self._serial_edit.setText(_generate_serial()) def _on_browse_csv(self) -> None: path, _ = QFileDialog.getSaveFileName( self, "Registro de seriales", str(Path.home() / "serials.csv"), "CSV (*.csv);;Todos los archivos (*)" ) if path: self._csv_edit.setText(path) def _on_build(self) -> None: vessel = self._vessel_edit.text().strip() serial = self._serial_edit.text().strip().upper() if not vessel: QMessageBox.warning(self, "Falta dato", "Introduce el nombre del buque.") return if not serial: serial = _generate_serial() self._serial_edit.setText(serial) self._log.appendPlainText(f"[auto] Serial generado: {serial}") if not BUILD_SCRIPT.exists(): QMessageBox.critical( self, "Script no encontrado", f"No se encontró:\n{BUILD_SCRIPT}\n\nVerifica que el repo esté completo." ) return argv = [sys.executable, str(BUILD_SCRIPT), "--vessel", vessel, "--serial", serial] if not self._chk_autopilot.isChecked() or self._chk_no_flutter.isChecked(): argv.append("--no-flutter") if not self._chk_ecdis.isChecked(): argv.append("--no-ecdis") csv = self._csv_edit.text().strip() if csv: argv += ["--csv", csv] self._log.clear() self._log.appendPlainText(f"$ {' '.join(shlex.quote(a) for a in argv)}\n") self._set_running(True) self._status_lbl.setText("Building…") self._thread = QThread(self) self._worker = _BuildWorker(argv) self._worker.moveToThread(self._thread) self._thread.started.connect(self._worker.run) self._worker.line.connect(self._log.appendPlainText) self._worker.finished.connect(self._on_build_done) self._worker.error.connect(self._on_build_error) self._worker.finished.connect(self._thread.quit) self._worker.error.connect(self._thread.quit) self._thread.finished.connect(self._cleanup_thread) self._thread.start() def _on_cancel(self) -> None: if self._worker: self._worker.cancel() self._log.appendPlainText("\n[cancelado por el operador]") def _on_open_dist(self) -> None: if DIST_DIR.exists(): os.startfile(str(DIST_DIR)) # Windows Explorer else: QMessageBox.information(self, "Sin carpeta", f"No existe:\n{DIST_DIR}") def _on_build_done(self, code: int) -> None: if code == 0: self._status_lbl.setText("✓ Build completado") self._open_btn.setEnabled(True) self._log.appendPlainText( f"\n✓ Pendrive listo en:\n{DIST_DIR}\n" "Copie TODO el contenido de dist/ al pendrive USB." ) else: self._status_lbl.setText(f"✗ Error (exit {code})") self._set_running(False) def _on_build_error(self, msg: str) -> None: self._log.appendPlainText(f"\n[ERROR] {msg}") self._status_lbl.setText("✗ Error") self._set_running(False) def _set_running(self, running: bool) -> None: can_build = self._session.can(Capability.EDIT_COMMISSIONING) self._build_btn.setEnabled(not running and can_build) self._cancel_btn.setEnabled(running) def _cleanup_thread(self) -> None: if self._thread: self._thread.deleteLater() self._thread = None if self._worker: self._worker.deleteLater() self._worker = None