feat(installer): J6412 USB installer + AR Electronics license activation server
installer/:
- build_usb.py: dev tool — builds Flutter + AR-ECDIS, assembles USB pendrive
image with serial.key, autorun.inf, and START_INSTALLER.bat
- serial_generator.py: generates AR-XXXX-XXXX-XXXX serial numbers (48-bit
entropy), logs to CSV for CRM integration
- src/license.py: hardware fingerprint (Windows MachineGuid + primary MAC),
serial validation, online activation POST, local cache with 30-day offline
grace period
- src/sysconfig.py: HKCU autostart registry entries, .lnk shortcuts (desktop
+ Start Menu via WScript.Shell), firewall rules (netsh), COM port detection
- src/install.py: tkinter installer GUI — 9 sequential steps with per-step
progress indicators, threaded execution, error dialogs, and silent mode
license_server/ (FastAPI service — deploy to arelectronics.com VPS):
- POST /api/v1/activate: first activation accepted; same-HW re-activation
refreshes heartbeat; different-HW rejected with 409
- GET /api/v1/validate/{serial}: heartbeat endpoint to refresh offline cache
- Admin endpoints (X-Admin-Key): issue, list, revoke licenses
- SQLAlchemy models: License (serial registry) + Activation (per-machine rows)
- SQLite default, PostgreSQL-ready via DATABASE_URL env var
AR_electronics — AR-Autopilot Project
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# installer/build_usb.py — Build a USB pendrive installer image
|
||||
# =============================================================================
|
||||
#
|
||||
# Developer tool that:
|
||||
# 1. Builds the Flutter Windows release (optional — skip with --no-flutter)
|
||||
# 2. Copies AR-ECDIS and AR-Autopilot binaries into dist/packages/
|
||||
# 3. Generates a fresh serial number and writes serial.key
|
||||
# 4. Creates autorun.inf and START_INSTALLER.bat
|
||||
#
|
||||
# Output: dist/ directory ready to be copied to a USB pendrive.
|
||||
#
|
||||
# Prerequisites (on the build machine):
|
||||
# - Flutter SDK in PATH (for AR-Autopilot Display)
|
||||
# - Python 3.11+
|
||||
# - AR-ECDIS webecdis cloned next to AR-Autopilot (or set --ecdis-dir)
|
||||
# - PyInstaller (pip install pyinstaller) — for AR-ECDIS .exe packaging
|
||||
#
|
||||
# Usage:
|
||||
# cd installer
|
||||
# python build_usb.py --vessel "BUQUE NORTE" --csv ../serials_log.csv
|
||||
# python build_usb.py --no-flutter --no-ecdis # quick test build
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DISPLAY_DIR = REPO_ROOT / "display"
|
||||
INSTALLER_SRC = Path(__file__).resolve().parent / "src"
|
||||
DIST_DIR = Path(__file__).resolve().parent / "dist"
|
||||
|
||||
# Default location for the AR-ECDIS repo (sibling of AR-Autopilot)
|
||||
DEFAULT_ECDIS = REPO_ROOT.parent / "AR ECDIS" / "webecdis"
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path | None = None, check: bool = True):
|
||||
print(f" $ {' '.join(str(c) for c in cmd)}")
|
||||
subprocess.run(cmd, cwd=cwd, check=check)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1 — Flutter Windows build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_flutter(flutter_cmd: str = "flutter") -> Path:
|
||||
"""Build AR-Autopilot Display for Windows and return the build output dir."""
|
||||
print("\n[1/5] Building Flutter (Windows release)…")
|
||||
run([flutter_cmd, "build", "windows", "--release"], cwd=DISPLAY_DIR)
|
||||
build_out = DISPLAY_DIR / "build" / "windows" / "x64" / "runner" / "Release"
|
||||
if not build_out.exists():
|
||||
raise FileNotFoundError(f"Flutter build output not found: {build_out}")
|
||||
print(f" Output: {build_out}")
|
||||
return build_out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2 — AR-ECDIS PyInstaller build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_ecdis(ecdis_dir: Path) -> Path:
|
||||
"""Package AR-ECDIS with PyInstaller and return the dist directory."""
|
||||
print("\n[2/5] Building AR-ECDIS (PyInstaller)…")
|
||||
if not ecdis_dir.exists():
|
||||
print(f" AR-ECDIS dir not found ({ecdis_dir}) — skipping.")
|
||||
return Path()
|
||||
|
||||
main_py = ecdis_dir / "main.py"
|
||||
if not main_py.exists():
|
||||
print(f" AR-ECDIS main.py not found — skipping.")
|
||||
return Path()
|
||||
|
||||
run(
|
||||
[
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--onedir",
|
||||
"--name", "AR-ECDIS",
|
||||
"--windowed",
|
||||
"--clean",
|
||||
str(main_py),
|
||||
],
|
||||
cwd=ecdis_dir,
|
||||
check=False, # non-fatal — missing PyInstaller is warned, not fatal
|
||||
)
|
||||
out = ecdis_dir / "dist" / "AR-ECDIS"
|
||||
if out.exists():
|
||||
print(f" Output: {out}")
|
||||
else:
|
||||
print(" PyInstaller output not found — AR-ECDIS skipped.")
|
||||
out = Path()
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3 — Assemble dist/ tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def assemble_dist(
|
||||
flutter_build: Path,
|
||||
ecdis_build: Path,
|
||||
serial: str,
|
||||
) -> None:
|
||||
print("\n[3/5] Assembling USB installer tree…")
|
||||
|
||||
# Clean previous dist
|
||||
if DIST_DIR.exists():
|
||||
shutil.rmtree(DIST_DIR)
|
||||
DIST_DIR.mkdir(parents=True)
|
||||
|
||||
# Copy installer source files
|
||||
pkg_installer = DIST_DIR
|
||||
shutil.copytree(INSTALLER_SRC, pkg_installer / "src")
|
||||
|
||||
# Place the serial key
|
||||
(DIST_DIR / "serial.key").write_text(serial, encoding="utf-8")
|
||||
|
||||
# Copy app packages
|
||||
packages = DIST_DIR / "packages"
|
||||
packages.mkdir()
|
||||
|
||||
if flutter_build.exists():
|
||||
dest = packages / "AR-Autopilot"
|
||||
shutil.copytree(flutter_build, dest)
|
||||
print(f" AR-Autopilot → packages/AR-Autopilot/")
|
||||
|
||||
if ecdis_build.exists():
|
||||
dest = packages / "AR-ECDIS"
|
||||
shutil.copytree(ecdis_build, dest)
|
||||
print(f" AR-ECDIS → packages/AR-ECDIS/")
|
||||
|
||||
print(f" Serial key → serial.key ({serial})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4 — Write autorun + launcher batch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_autorun() -> None:
|
||||
print("\n[4/5] Writing autorun.inf and START_INSTALLER.bat…")
|
||||
|
||||
autorun = (
|
||||
"[autorun]\n"
|
||||
"label=AR Electronics Installer\n"
|
||||
"open=START_INSTALLER.bat\n"
|
||||
"icon=src\\install.py,0\n"
|
||||
)
|
||||
(DIST_DIR / "autorun.inf").write_text(autorun, encoding="utf-8")
|
||||
|
||||
batch = (
|
||||
"@echo off\n"
|
||||
"title AR Electronics — Instalador J6412\n"
|
||||
'echo Iniciando instalador AR Electronics...\n'
|
||||
'cd /d "%~dp0"\n'
|
||||
"python src\\install.py\n"
|
||||
"if errorlevel 1 (\n"
|
||||
" echo.\n"
|
||||
" echo ERROR: La instalacion fallo.\n"
|
||||
" pause\n"
|
||||
")\n"
|
||||
)
|
||||
(DIST_DIR / "START_INSTALLER.bat").write_text(batch, encoding="utf-8")
|
||||
|
||||
# README for field technicians
|
||||
readme = (
|
||||
"=== AR Electronics — Instalador J6412 ===\n\n"
|
||||
"1. Conecte este pendrive al mini PC J6412.\n"
|
||||
"2. Abra el explorador de archivos y ejecute START_INSTALLER.bat.\n"
|
||||
" (Si Windows pregunta, elija 'Más información' → 'Ejecutar de todas formas'.)\n"
|
||||
"3. El instalador solicitará permisos de administrador — acepte.\n"
|
||||
"4. Pulse INSTALAR y espere a que finalice.\n"
|
||||
"5. Reinicie el equipo.\n\n"
|
||||
"El sistema requiere conexión a internet para la activación de la licencia.\n\n"
|
||||
"Soporte: soporte@arelectronics.com\n"
|
||||
)
|
||||
(DIST_DIR / "LEAME.txt").write_text(readme, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5 — Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_summary(serial: str) -> None:
|
||||
print("\n[5/5] Build complete.")
|
||||
print(f"\n Directorio de salida : {DIST_DIR}")
|
||||
print(f" Número de serie : {serial}")
|
||||
size_mb = sum(f.stat().st_size for f in DIST_DIR.rglob("*") if f.is_file()) / 1e6
|
||||
print(f" Tamaño total : {size_mb:.1f} MB")
|
||||
print("\n Copie todo el contenido de dist/ al pendrive USB.")
|
||||
print(" Asegúrese de que el pendrive tenga al menos 2 GB de espacio.\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AR Electronics — USB installer builder"
|
||||
)
|
||||
parser.add_argument("--vessel", default="",
|
||||
help="Vessel name to tag the serial number with")
|
||||
parser.add_argument("--csv",
|
||||
help="Path to serials CSV log (created or appended)")
|
||||
parser.add_argument("--ecdis-dir", type=Path, default=DEFAULT_ECDIS,
|
||||
help=f"Path to AR-ECDIS webecdis directory (default: {DEFAULT_ECDIS})")
|
||||
parser.add_argument("--flutter", default="flutter",
|
||||
help="flutter command (default: 'flutter')")
|
||||
parser.add_argument("--no-flutter", action="store_true",
|
||||
help="Skip Flutter build (use existing build output)")
|
||||
parser.add_argument("--no-ecdis", action="store_true",
|
||||
help="Skip AR-ECDIS PyInstaller build")
|
||||
parser.add_argument("--serial",
|
||||
help="Use an existing serial number instead of generating one")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print(" AR Electronics — USB Installer Builder")
|
||||
print("=" * 60)
|
||||
|
||||
# Resolve serial
|
||||
if args.serial:
|
||||
serial = args.serial
|
||||
print(f"\n Using existing serial: {serial}")
|
||||
else:
|
||||
# Import here to avoid circular issues if running as a module
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from serial_generator import generate_batch, write_csv # noqa: PLC0415
|
||||
|
||||
records = generate_batch(1, vessel=args.vessel)
|
||||
serial = records[0]["serial"]
|
||||
print(f"\n Generated serial: {serial}")
|
||||
if args.csv:
|
||||
write_csv(records, args.csv)
|
||||
|
||||
# Flutter build
|
||||
if args.no_flutter:
|
||||
flutter_build = DISPLAY_DIR / "build" / "windows" / "x64" / "runner" / "Release"
|
||||
print(f"\n[1/5] Skipping Flutter build — using {flutter_build}")
|
||||
else:
|
||||
flutter_build = build_flutter(args.flutter)
|
||||
|
||||
# AR-ECDIS build
|
||||
if args.no_ecdis:
|
||||
ecdis_build = Path()
|
||||
print("\n[2/5] Skipping AR-ECDIS build.")
|
||||
else:
|
||||
ecdis_build = build_ecdis(args.ecdis_dir)
|
||||
|
||||
# Assemble
|
||||
assemble_dist(flutter_build, ecdis_build, serial)
|
||||
write_autorun()
|
||||
print_summary(serial)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
# =============================================================================
|
||||
# installer/serial_generator.py — AR Electronics serial number generator
|
||||
# =============================================================================
|
||||
#
|
||||
# Developer tool. Generates a batch of unique serial numbers and optionally
|
||||
# writes them to a CSV log for the AR Electronics CRM.
|
||||
#
|
||||
# Format: AR-XXXX-XXXX-XXXX (hex groups, 48 bits of entropy ≈ 281 trillion)
|
||||
#
|
||||
# Usage:
|
||||
# python serial_generator.py 10 # generate 10 serials
|
||||
# python serial_generator.py 10 --csv serials.csv
|
||||
# python serial_generator.py 1 --vessel "MY YACHT NAME" --csv serials.csv
|
||||
# =============================================================================
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def generate_serial() -> str:
|
||||
"""Generate a single AR-XXXX-XXXX-XXXX serial number."""
|
||||
raw = secrets.token_hex(6).upper() # 6 bytes = 12 hex chars = 3 × 4
|
||||
return f"AR-{raw[0:4]}-{raw[4:8]}-{raw[8:12]}"
|
||||
|
||||
|
||||
def generate_batch(count: int, vessel: str = "") -> list[dict]:
|
||||
serials = []
|
||||
seen: set[str] = set()
|
||||
|
||||
while len(serials) < count:
|
||||
serial = generate_serial()
|
||||
if serial in seen:
|
||||
continue # collision (astronomically unlikely)
|
||||
seen.add(serial)
|
||||
serials.append(
|
||||
{
|
||||
"serial": serial,
|
||||
"vessel": vessel,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "unactivated",
|
||||
}
|
||||
)
|
||||
|
||||
return serials
|
||||
|
||||
|
||||
def write_key_file(serial: str, output_path: str) -> None:
|
||||
"""Write a single serial number to a serial.key file."""
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(serial)
|
||||
print(f" → {output_path}")
|
||||
|
||||
|
||||
def write_csv(records: list[dict], csv_path: str) -> None:
|
||||
"""Append records to a CSV log (creates file if missing)."""
|
||||
file_exists = os.path.exists(csv_path)
|
||||
with open(csv_path, "a", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["serial", "vessel", "created_at", "status"])
|
||||
if not file_exists:
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
print(f"\nAnexado a CSV: {csv_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AR Electronics — Generador de números de serie"
|
||||
)
|
||||
parser.add_argument("count", type=int, nargs="?", default=1,
|
||||
help="Cantidad de seriales a generar (default: 1)")
|
||||
parser.add_argument("--vessel", default="",
|
||||
help="Nombre del buque para asignar al lote")
|
||||
parser.add_argument("--csv",
|
||||
help="Ruta al archivo CSV de registro (se crea o se añade)")
|
||||
parser.add_argument("--key-dir",
|
||||
help="Directorio donde escribir archivos serial.key individuales")
|
||||
args = parser.parse_args()
|
||||
|
||||
records = generate_batch(args.count, vessel=args.vessel)
|
||||
|
||||
print(f"\nSeriales generados ({args.count}):\n")
|
||||
for rec in records:
|
||||
vessel_info = f" [{rec['vessel']}]" if rec["vessel"] else ""
|
||||
print(f" {rec['serial']}{vessel_info}")
|
||||
|
||||
if args.csv:
|
||||
write_csv(records, args.csv)
|
||||
|
||||
if args.key_dir:
|
||||
os.makedirs(args.key_dir, exist_ok=True)
|
||||
for i, rec in enumerate(records):
|
||||
filename = f"serial_{i+1:03d}.key" if len(records) > 1 else "serial.key"
|
||||
write_key_file(rec["serial"], os.path.join(args.key_dir, filename))
|
||||
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,426 @@
|
||||
# =============================================================================
|
||||
# installer/src/install.py — AR Electronics J6412 installer
|
||||
# =============================================================================
|
||||
#
|
||||
# Tkinter GUI installer that:
|
||||
# 1. Validates the bundled serial number
|
||||
# 2. Installs AR-ECDIS and AR-Autopilot Display to Program Files
|
||||
# 3. Activates the license online
|
||||
# 4. Configures Windows autostart, shortcuts, and firewall rules
|
||||
#
|
||||
# Usage:
|
||||
# python install.py — interactive GUI mode
|
||||
# python install.py --silent — headless mode (for testing / scripted deploy)
|
||||
#
|
||||
# This file lives in the root of the USB pendrive alongside serial.key and
|
||||
# the packages/ directory. Run as Administrator for full functionality.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
# ── Resolve installer root (directory containing this script) ─────────────────
|
||||
INSTALLER_DIR = Path(__file__).parent
|
||||
PACKAGES_DIR = INSTALLER_DIR / "packages"
|
||||
|
||||
APP_VERSION = "0.4.0"
|
||||
|
||||
# ── Late imports from sibling modules ─────────────────────────────────────────
|
||||
sys.path.insert(0, str(INSTALLER_DIR))
|
||||
from license import activate_online, read_serial, ActivationError # noqa: E402
|
||||
from sysconfig import ( # noqa: E402
|
||||
ECDIS_DIR, AUTOPILOT_DIR,
|
||||
ensure_install_dirs, configure_autostart,
|
||||
create_shortcuts, add_firewall_rules, list_com_ports,
|
||||
is_admin, require_admin,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installation steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STEPS = [
|
||||
("Verificar serial", "_step_verify_serial"),
|
||||
("Crear directorios", "_step_create_dirs"),
|
||||
("Instalar AR-ECDIS", "_step_install_ecdis"),
|
||||
("Instalar AR-Autopilot", "_step_install_autopilot"),
|
||||
("Activar licencia", "_step_activate_license"),
|
||||
("Configurar inicio", "_step_configure_autostart"),
|
||||
("Accesos directos", "_step_create_shortcuts"),
|
||||
("Reglas de firewall", "_step_firewall"),
|
||||
("Finalizar", "_step_finalize"),
|
||||
]
|
||||
|
||||
|
||||
class Installer:
|
||||
"""Core installation logic — UI-independent."""
|
||||
|
||||
def __init__(self, log_fn=print):
|
||||
self._log = log_fn
|
||||
self.serial: str | None = None
|
||||
|
||||
# ── Step implementations ─────────────────────────────────────────────────
|
||||
|
||||
def _step_verify_serial(self):
|
||||
self._log("Leyendo número de serie…")
|
||||
self.serial = read_serial(INSTALLER_DIR)
|
||||
self._log(f"Serial: {self.serial}")
|
||||
|
||||
def _step_create_dirs(self):
|
||||
self._log("Creando directorios en Program Files…")
|
||||
ensure_install_dirs()
|
||||
|
||||
def _step_install_ecdis(self):
|
||||
src = PACKAGES_DIR / "AR-ECDIS"
|
||||
if not src.exists():
|
||||
self._log("Paquete AR-ECDIS no encontrado — omitiendo.")
|
||||
return
|
||||
self._log(f"Copiando AR-ECDIS → {ECDIS_DIR}")
|
||||
if ECDIS_DIR.exists():
|
||||
shutil.rmtree(ECDIS_DIR)
|
||||
shutil.copytree(src, ECDIS_DIR)
|
||||
self._log("AR-ECDIS instalado correctamente.")
|
||||
|
||||
def _step_install_autopilot(self):
|
||||
src = PACKAGES_DIR / "AR-Autopilot"
|
||||
if not src.exists():
|
||||
self._log("Paquete AR-Autopilot no encontrado — omitiendo.")
|
||||
return
|
||||
self._log(f"Copiando AR-Autopilot → {AUTOPILOT_DIR}")
|
||||
if AUTOPILOT_DIR.exists():
|
||||
shutil.rmtree(AUTOPILOT_DIR)
|
||||
shutil.copytree(src, AUTOPILOT_DIR)
|
||||
self._log("AR-Autopilot instalado correctamente.")
|
||||
|
||||
def _step_activate_license(self):
|
||||
if self.serial is None:
|
||||
raise RuntimeError("Serial no disponible para activación.")
|
||||
self._log(f"Activando licencia en servidor AR Electronics…")
|
||||
result = activate_online(self.serial, app_version=APP_VERSION)
|
||||
self._log(
|
||||
f"Licencia activada — ID: {result.activation_id[:8]}…\n"
|
||||
f" Slot: {result.vessel_slot} | {result.licensed_to}"
|
||||
)
|
||||
|
||||
def _step_configure_autostart(self):
|
||||
self._log("Configurando inicio automático con Windows…")
|
||||
configure_autostart()
|
||||
|
||||
def _step_create_shortcuts(self):
|
||||
self._log("Creando accesos directos…")
|
||||
create_shortcuts()
|
||||
|
||||
def _step_firewall(self):
|
||||
if not is_admin():
|
||||
self._log("Sin privilegios de administrador — omitiendo reglas de firewall.")
|
||||
return
|
||||
self._log("Añadiendo reglas de firewall…")
|
||||
add_firewall_rules()
|
||||
|
||||
def _step_finalize(self):
|
||||
ports = list_com_ports()
|
||||
if ports:
|
||||
self._log(f"Puertos COM detectados: {', '.join(ports)}")
|
||||
self._log(
|
||||
"Conecte el concentrador y configure los puertos en\n"
|
||||
"AR-Autopilot → Ajustes → Puertos COM."
|
||||
)
|
||||
else:
|
||||
self._log("No se detectaron puertos COM — conecte el concentrador USB.")
|
||||
self._log("Instalación completada con éxito.")
|
||||
|
||||
# ── Public runner ────────────────────────────────────────────────────────
|
||||
|
||||
def run_all(self, progress_cb=None):
|
||||
"""
|
||||
Execute all steps sequentially.
|
||||
|
||||
:param progress_cb: optional callable(step_index, step_name, success, error)
|
||||
"""
|
||||
for idx, (name, method_name) in enumerate(STEPS):
|
||||
method = getattr(self, method_name)
|
||||
try:
|
||||
method()
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, True, None)
|
||||
except ActivationError as exc:
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, False, exc)
|
||||
raise
|
||||
except Exception as exc:
|
||||
if progress_cb:
|
||||
progress_cb(idx, name, False, exc)
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tkinter GUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRAND_NAVY = "#0D1B2A"
|
||||
BRAND_BLUE = "#2563EB"
|
||||
BRAND_GLOW = "#60B8FF"
|
||||
BRAND_TEXT = "#E2E8F0"
|
||||
BRAND_MUTED = "#8899AA"
|
||||
BRAND_GREEN = "#22C55E"
|
||||
BRAND_RED = "#EF4444"
|
||||
|
||||
|
||||
class InstallerWindow:
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("AR Electronics — Instalador J6412")
|
||||
self.root.configure(bg=BRAND_NAVY)
|
||||
self.root.resizable(False, False)
|
||||
|
||||
# Centre on screen
|
||||
w, h = 560, 520
|
||||
sw = self.root.winfo_screenwidth()
|
||||
sh = self.root.winfo_screenheight()
|
||||
self.root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}")
|
||||
|
||||
self._build_ui()
|
||||
self._installer = Installer(log_fn=self._append_log)
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
# Header
|
||||
hdr = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
hdr.pack(fill="x", padx=0, pady=0)
|
||||
|
||||
tk.Label(
|
||||
hdr,
|
||||
text="AR Electronics",
|
||||
font=("Segoe UI", 18, "bold"),
|
||||
fg=BRAND_GLOW,
|
||||
bg=BRAND_NAVY,
|
||||
).pack(pady=(20, 0))
|
||||
|
||||
tk.Label(
|
||||
hdr,
|
||||
text="Instalador para J6412 Mini PC",
|
||||
font=("Segoe UI", 11),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
).pack(pady=(0, 12))
|
||||
|
||||
sep = tk.Frame(self.root, height=1, bg=BRAND_BLUE)
|
||||
sep.pack(fill="x", padx=20)
|
||||
|
||||
# Steps frame
|
||||
self._step_vars: list[tk.StringVar] = []
|
||||
steps_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
steps_frame.pack(fill="x", padx=30, pady=14)
|
||||
|
||||
for _, (name, _) in enumerate(STEPS):
|
||||
var = tk.StringVar(value=f" ○ {name}")
|
||||
lbl = tk.Label(
|
||||
steps_frame,
|
||||
textvariable=var,
|
||||
font=("Consolas", 10),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
anchor="w",
|
||||
)
|
||||
lbl.pack(fill="x", pady=1)
|
||||
self._step_vars.append(var)
|
||||
|
||||
self._step_labels = steps_frame.winfo_children()
|
||||
|
||||
# Progress bar
|
||||
pb_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
pb_frame.pack(fill="x", padx=30, pady=(0, 8))
|
||||
|
||||
style = ttk.Style()
|
||||
style.theme_use("clam")
|
||||
style.configure(
|
||||
"AR.Horizontal.TProgressbar",
|
||||
troughcolor=BRAND_NAVY,
|
||||
bordercolor=BRAND_BLUE,
|
||||
background=BRAND_GLOW,
|
||||
lightcolor=BRAND_GLOW,
|
||||
darkcolor=BRAND_BLUE,
|
||||
)
|
||||
self._progress = ttk.Progressbar(
|
||||
pb_frame,
|
||||
style="AR.Horizontal.TProgressbar",
|
||||
maximum=len(STEPS),
|
||||
length=500,
|
||||
)
|
||||
self._progress.pack(fill="x")
|
||||
|
||||
# Log text
|
||||
log_frame = tk.Frame(self.root, bg="#0A1520")
|
||||
log_frame.pack(fill="both", expand=True, padx=20, pady=(0, 12))
|
||||
|
||||
self._log_text = tk.Text(
|
||||
log_frame,
|
||||
height=7,
|
||||
font=("Consolas", 9),
|
||||
bg="#0A1520",
|
||||
fg=BRAND_MUTED,
|
||||
relief="flat",
|
||||
state="disabled",
|
||||
wrap="word",
|
||||
)
|
||||
self._log_text.pack(fill="both", expand=True, padx=8, pady=6)
|
||||
|
||||
# Buttons
|
||||
btn_frame = tk.Frame(self.root, bg=BRAND_NAVY)
|
||||
btn_frame.pack(fill="x", padx=20, pady=(0, 20))
|
||||
|
||||
self._install_btn = tk.Button(
|
||||
btn_frame,
|
||||
text="INSTALAR",
|
||||
font=("Segoe UI", 10, "bold"),
|
||||
bg=BRAND_BLUE,
|
||||
fg="white",
|
||||
activebackground=BRAND_GLOW,
|
||||
relief="flat",
|
||||
padx=24,
|
||||
pady=8,
|
||||
cursor="hand2",
|
||||
command=self._start_install,
|
||||
)
|
||||
self._install_btn.pack(side="left")
|
||||
|
||||
self._cancel_btn = tk.Button(
|
||||
btn_frame,
|
||||
text="Cancelar",
|
||||
font=("Segoe UI", 10),
|
||||
bg=BRAND_NAVY,
|
||||
fg=BRAND_MUTED,
|
||||
activeforeground=BRAND_TEXT,
|
||||
relief="flat",
|
||||
padx=16,
|
||||
pady=8,
|
||||
cursor="hand2",
|
||||
command=self.root.destroy,
|
||||
)
|
||||
self._cancel_btn.pack(side="left", padx=(10, 0))
|
||||
|
||||
self._status_lbl = tk.Label(
|
||||
btn_frame,
|
||||
text="",
|
||||
font=("Segoe UI", 9),
|
||||
fg=BRAND_MUTED,
|
||||
bg=BRAND_NAVY,
|
||||
)
|
||||
self._status_lbl.pack(side="right")
|
||||
|
||||
# ── Install thread ───────────────────────────────────────────────────────
|
||||
|
||||
def _start_install(self):
|
||||
self._install_btn.configure(state="disabled")
|
||||
self._cancel_btn.configure(state="disabled")
|
||||
threading.Thread(target=self._run_install, daemon=True).start()
|
||||
|
||||
def _run_install(self):
|
||||
try:
|
||||
self._installer.run_all(progress_cb=self._on_step)
|
||||
self.root.after(0, self._on_success)
|
||||
except Exception as exc:
|
||||
self.root.after(0, self._on_failure, str(exc))
|
||||
|
||||
def _on_step(self, idx: int, name: str, success: bool, error):
|
||||
def update():
|
||||
if success:
|
||||
self._step_vars[idx].set(f" ✓ {name}")
|
||||
self._step_labels[idx].configure(fg=BRAND_GREEN)
|
||||
else:
|
||||
self._step_vars[idx].set(f" ✗ {name}")
|
||||
self._step_labels[idx].configure(fg=BRAND_RED)
|
||||
self._progress["value"] = idx + 1
|
||||
|
||||
self.root.after(0, update)
|
||||
|
||||
def _on_success(self):
|
||||
self._status_lbl.configure(text="Instalación completada", fg=BRAND_GREEN)
|
||||
self._cancel_btn.configure(state="normal", text="Cerrar")
|
||||
messagebox.showinfo(
|
||||
"AR Electronics",
|
||||
"Instalación completada con éxito.\n\n"
|
||||
"AR-ECDIS y AR-Autopilot están listos.\n"
|
||||
"Reinicie el equipo para activar el inicio automático.",
|
||||
)
|
||||
|
||||
def _on_failure(self, msg: str):
|
||||
self._status_lbl.configure(text="Error en la instalación", fg=BRAND_RED)
|
||||
self._install_btn.configure(state="normal")
|
||||
self._cancel_btn.configure(state="normal")
|
||||
messagebox.showerror(
|
||||
"Error de instalación",
|
||||
f"La instalación no se completó:\n\n{msg}\n\n"
|
||||
"Verifique la conexión a internet y vuelva a intentarlo.\n"
|
||||
"Si el problema persiste, contacte a AR Electronics.",
|
||||
)
|
||||
|
||||
# ── Log output ───────────────────────────────────────────────────────────
|
||||
|
||||
def _append_log(self, text: str):
|
||||
def _do():
|
||||
self._log_text.configure(state="normal")
|
||||
self._log_text.insert("end", text + "\n")
|
||||
self._log_text.see("end")
|
||||
self._log_text.configure(state="disabled")
|
||||
|
||||
self.root.after(0, _do)
|
||||
|
||||
# ── Main loop ────────────────────────────────────────────────────────────
|
||||
|
||||
def run(self):
|
||||
self.root.mainloop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Silent / headless mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_silent():
|
||||
installer = Installer()
|
||||
errors = []
|
||||
|
||||
def cb(idx, name, success, error):
|
||||
icon = "✓" if success else "✗"
|
||||
print(f" [{icon}] {name}")
|
||||
if error:
|
||||
errors.append(str(error))
|
||||
|
||||
try:
|
||||
installer.run_all(progress_cb=cb)
|
||||
print("\nInstalación completada con éxito.")
|
||||
except Exception as exc:
|
||||
print(f"\nERROR: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="AR Electronics J6412 Installer")
|
||||
parser.add_argument(
|
||||
"--silent", action="store_true", help="Run without GUI (headless mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-admin-check", action="store_true", help="Skip UAC elevation request"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.no_admin_check:
|
||||
require_admin()
|
||||
|
||||
if args.silent:
|
||||
run_silent()
|
||||
else:
|
||||
InstallerWindow().run()
|
||||
@@ -0,0 +1,246 @@
|
||||
# =============================================================================
|
||||
# installer/src/license.py — Serial-number activation client
|
||||
# =============================================================================
|
||||
#
|
||||
# Reads the serial number bundled with this installer package (serial.key),
|
||||
# collects a hardware fingerprint (Windows Machine GUID + primary MAC address),
|
||||
# POSTs to the AR Electronics license server, and caches the activation token
|
||||
# locally in %APPDATA%\AR Electronics\license.json.
|
||||
#
|
||||
# Offline grace period: 30 days without contacting the server.
|
||||
# Duplicate activations on a different machine are rejected server-side.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
import winreg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVER_BASE_URL = "https://license.arelectronics.com"
|
||||
ACTIVATE_ROUTE = "/api/v1/activate"
|
||||
VALIDATE_ROUTE = "/api/v1/validate"
|
||||
OFFLINE_GRACE = 30 # days allowed without server contact
|
||||
APP_DATA_DIR = Path.home() / "AppData" / "Roaming" / "AR Electronics"
|
||||
LICENSE_CACHE = APP_DATA_DIR / "license.json"
|
||||
SERIAL_FILE_NAME = "serial.key" # placed next to install.py by build_usb.py
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware fingerprint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _machine_guid() -> str:
|
||||
"""Read the Windows Machine GUID from the registry (stable across reboots)."""
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Cryptography",
|
||||
)
|
||||
value, _ = winreg.QueryValueEx(key, "MachineGuid")
|
||||
winreg.CloseKey(key)
|
||||
return value
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _primary_mac() -> str:
|
||||
"""Return the MAC address of the adapter used for the default route."""
|
||||
try:
|
||||
# Connect to an external address (no data sent) to discover default adapter
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# Find the MAC for this IP using ipconfig output
|
||||
result = subprocess.run(
|
||||
["ipconfig", "/all"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
blocks = result.stdout.split("\n\n")
|
||||
for block in blocks:
|
||||
if ip in block:
|
||||
m = re.search(
|
||||
r"Physical Address[.\s]+:\s*([0-9A-F-]{17})", block, re.IGNORECASE
|
||||
)
|
||||
if m:
|
||||
return m.group(1).replace("-", ":").upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback — uuid.getnode() uses whichever adapter Python finds first
|
||||
raw = uuid.getnode()
|
||||
return ":".join(f"{(raw >> (i * 8)) & 0xFF:02X}" for i in range(5, -1, -1))
|
||||
|
||||
|
||||
def hardware_fingerprint() -> str:
|
||||
"""Stable, anonymised hardware fingerprint for this machine."""
|
||||
raw = f"{_machine_guid()}|{_primary_mac()}|{platform.node()}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serial number helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_serial(installer_dir: Path) -> str:
|
||||
"""
|
||||
Read the serial number from ``serial.key`` next to the installer.
|
||||
|
||||
Raises FileNotFoundError if the file is missing, ValueError if malformed.
|
||||
"""
|
||||
serial_path = installer_dir / SERIAL_FILE_NAME
|
||||
if not serial_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Serial key file not found: {serial_path}\n"
|
||||
"This installer package may be incomplete."
|
||||
)
|
||||
serial = serial_path.read_text(encoding="utf-8").strip()
|
||||
if not _valid_serial_format(serial):
|
||||
raise ValueError(f"Malformed serial number: {serial!r}")
|
||||
return serial
|
||||
|
||||
|
||||
def _valid_serial_format(serial: str) -> bool:
|
||||
"""Validate format: AR-XXXX-XXXX-XXXX (hex groups)."""
|
||||
pattern = r"^AR-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$"
|
||||
return bool(re.match(pattern, serial, re.IGNORECASE))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActivationError(Exception):
|
||||
"""Raised when activation is refused or fails."""
|
||||
|
||||
|
||||
class ActivationResult:
|
||||
def __init__(self, data: dict):
|
||||
self.activation_id: str = data["activation_id"]
|
||||
self.vessel_slot: int = data.get("vessel_slot", 1)
|
||||
self.licensed_to: str = data.get("licensed_to", "")
|
||||
self.activated_at: str = data.get("activated_at", "")
|
||||
self.expires_at: str | None = data.get("expires_at")
|
||||
|
||||
|
||||
def activate_online(serial: str, app_version: str = "0.4.0") -> ActivationResult:
|
||||
"""
|
||||
POST activation request to the AR Electronics license server.
|
||||
|
||||
On success caches the response in LICENSE_CACHE.
|
||||
Raises ActivationError on any refusal or connection problem.
|
||||
"""
|
||||
hw_id = hardware_fingerprint()
|
||||
payload = {
|
||||
"serial": serial,
|
||||
"hardware_id": hw_id,
|
||||
"app_version": app_version,
|
||||
"platform": platform.system(),
|
||||
"hostname": platform.node(),
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
SERVER_BASE_URL + ACTIVATE_ROUTE,
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
except requests.ConnectionError:
|
||||
raise ActivationError("No se pudo conectar con el servidor de licencias.\n"
|
||||
"Verifique la conexión a internet e intente de nuevo.")
|
||||
except requests.Timeout:
|
||||
raise ActivationError("El servidor de licencias no respondió (timeout 15 s).")
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = ActivationResult(resp.json())
|
||||
_cache_activation(serial, hw_id, resp.json())
|
||||
return result
|
||||
|
||||
# Server returned an error
|
||||
try:
|
||||
msg = resp.json().get("detail", resp.text)
|
||||
except Exception:
|
||||
msg = resp.text
|
||||
raise ActivationError(f"Activación rechazada ({resp.status_code}): {msg}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cache_activation(serial: str, hardware_id: str, server_data: dict) -> None:
|
||||
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache = {
|
||||
"serial": serial,
|
||||
"hardware_id": hardware_id,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"server_data": server_data,
|
||||
}
|
||||
LICENSE_CACHE.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_cached_license() -> dict | None:
|
||||
"""Return cached license dict or None if missing / expired."""
|
||||
if not LICENSE_CACHE.exists():
|
||||
return None
|
||||
try:
|
||||
cache = json.loads(LICENSE_CACHE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
# Validate hardware fingerprint matches this machine
|
||||
if cache.get("hardware_id") != hardware_fingerprint():
|
||||
return None
|
||||
|
||||
# Check offline grace period
|
||||
cached_at = datetime.fromisoformat(cache["cached_at"])
|
||||
if datetime.now(timezone.utc) - cached_at > timedelta(days=OFFLINE_GRACE):
|
||||
return None # Cache expired — must re-validate online
|
||||
|
||||
return cache
|
||||
|
||||
|
||||
def is_activated() -> bool:
|
||||
"""Quick check: is this machine currently activated (cache or online)?"""
|
||||
cache = load_cached_license()
|
||||
if cache is not None:
|
||||
return True
|
||||
# Try a fast online validate
|
||||
hw_id = hardware_fingerprint()
|
||||
serial = _serial_from_cache()
|
||||
if serial is None:
|
||||
return False
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{SERVER_BASE_URL}{VALIDATE_ROUTE}/{serial}",
|
||||
params={"hardware_id": hw_id},
|
||||
timeout=8,
|
||||
)
|
||||
if resp.status_code == 200 and resp.json().get("active"):
|
||||
_cache_activation(serial, hw_id, resp.json())
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _serial_from_cache() -> str | None:
|
||||
if not LICENSE_CACHE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(LICENSE_CACHE.read_text(encoding="utf-8")).get("serial")
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,201 @@
|
||||
# =============================================================================
|
||||
# installer/src/sysconfig.py — Windows system configuration helpers
|
||||
# =============================================================================
|
||||
#
|
||||
# All functions target Windows 10/11 (tested on J6412 with Windows 10 IoT).
|
||||
# Requires the installer to be run as Administrator for firewall and registry
|
||||
# operations; shortcuts and HKCU run-key work without elevation.
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import winreg
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INSTALL_ROOT = Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "AR Electronics"
|
||||
ECDIS_DIR = INSTALL_ROOT / "AR-ECDIS"
|
||||
AUTOPILOT_DIR = INSTALL_ROOT / "AR-Autopilot"
|
||||
APPDATA_DIR = Path.home() / "AppData" / "Roaming" / "AR Electronics"
|
||||
START_MENU = Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "AR Electronics"
|
||||
|
||||
|
||||
def ensure_install_dirs() -> None:
|
||||
"""Create install directory tree (requires admin)."""
|
||||
for d in (INSTALL_ROOT, ECDIS_DIR, AUTOPILOT_DIR, APPDATA_DIR, START_MENU):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-start (HKCU — no admin required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AUTORUN_KEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
|
||||
_AR_ECDIS_VALUE = "AR-ECDIS"
|
||||
_AR_AUTOPILOT_VALUE = "AR-Autopilot"
|
||||
|
||||
|
||||
def register_autostart(name: str, exe_path: Path, args: str = "") -> None:
|
||||
"""
|
||||
Add an entry to HKCU Run so the app starts with Windows.
|
||||
|
||||
Does NOT require administrator rights (current-user key).
|
||||
"""
|
||||
cmd = f'"{exe_path}" {args}'.strip()
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
_AUTORUN_KEY,
|
||||
access=winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.SetValueEx(key, name, 0, winreg.REG_SZ, cmd)
|
||||
winreg.CloseKey(key)
|
||||
|
||||
|
||||
def unregister_autostart(name: str) -> None:
|
||||
"""Remove an auto-start entry (best-effort, ignores missing keys)."""
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
_AUTORUN_KEY,
|
||||
access=winreg.KEY_SET_VALUE,
|
||||
)
|
||||
winreg.DeleteValue(key, name)
|
||||
winreg.CloseKey(key)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def configure_autostart() -> None:
|
||||
"""Register both AR Electronics apps in the Windows autostart."""
|
||||
ecdis_exe = ECDIS_DIR / "AR-ECDIS.exe"
|
||||
autopilot_exe = AUTOPILOT_DIR / "ar_autopilot_display.exe"
|
||||
|
||||
if ecdis_exe.exists():
|
||||
register_autostart(_AR_ECDIS_VALUE, ecdis_exe)
|
||||
|
||||
if autopilot_exe.exists():
|
||||
register_autostart(_AR_AUTOPILOT_VALUE, autopilot_exe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop and Start Menu shortcuts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_shortcut(target: Path, shortcut_path: Path, icon: Path | None = None) -> None:
|
||||
"""
|
||||
Create a .lnk shortcut using PowerShell WScript.Shell.
|
||||
|
||||
No admin required; works on standard user accounts.
|
||||
"""
|
||||
icon_str = f'$s.IconLocation = "{icon}"' if icon else ""
|
||||
script = (
|
||||
f'$s=(New-Object -COM WScript.Shell).CreateShortcut("{shortcut_path}");'
|
||||
f'$s.TargetPath="{target}";'
|
||||
f'{icon_str};'
|
||||
f'$s.Save()'
|
||||
)
|
||||
subprocess.run(["powershell", "-Command", script], check=True, capture_output=True)
|
||||
|
||||
|
||||
def create_shortcuts() -> None:
|
||||
"""Create Start Menu and Desktop shortcuts for both apps."""
|
||||
START_MENU.mkdir(parents=True, exist_ok=True)
|
||||
desktop = Path.home() / "Desktop"
|
||||
|
||||
apps = [
|
||||
("AR-ECDIS", ECDIS_DIR / "AR-ECDIS.exe"),
|
||||
("AR-Autopilot", AUTOPILOT_DIR / "ar_autopilot_display.exe"),
|
||||
]
|
||||
for name, exe in apps:
|
||||
if not exe.exists():
|
||||
continue
|
||||
lnk = f"{name}.lnk"
|
||||
_make_shortcut(exe, START_MENU / lnk, icon=exe)
|
||||
_make_shortcut(exe, desktop / lnk, icon=exe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows Firewall rules (requires admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_firewall_rules() -> None:
|
||||
"""
|
||||
Allow inbound TCP on ports used by AR Electronics services.
|
||||
|
||||
Requires the installer to be running as Administrator.
|
||||
"""
|
||||
rules = [
|
||||
("AR-ECDIS Web", "TCP", "8080"), # AR-ECDIS FastAPI backend
|
||||
("AR-ECDIS WebSocket","TCP", "8080"),
|
||||
]
|
||||
for name, proto, port in rules:
|
||||
subprocess.run(
|
||||
[
|
||||
"netsh", "advfirewall", "firewall", "add", "rule",
|
||||
f"name={name}",
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
f"protocol={proto}",
|
||||
f"localport={port}",
|
||||
],
|
||||
check=False, # non-fatal if rule already exists
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COM port detection (informational — no forced assignment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_com_ports() -> list[str]:
|
||||
"""
|
||||
Return list of detected COM ports on the system.
|
||||
|
||||
On Windows this queries the registry rather than requiring PySerial.
|
||||
"""
|
||||
ports: list[str] = []
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"HARDWARE\DEVICEMAP\SERIALCOMM",
|
||||
)
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
_, port_name, _ = winreg.EnumValue(key, i)
|
||||
ports.append(port_name)
|
||||
i += 1
|
||||
except OSError:
|
||||
break
|
||||
winreg.CloseKey(key)
|
||||
except OSError:
|
||||
pass
|
||||
return sorted(ports)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Administrator check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_admin() -> bool:
|
||||
"""Return True if the current process has administrator privileges."""
|
||||
try:
|
||||
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
def require_admin() -> None:
|
||||
"""Re-launch this process with UAC elevation if not already admin."""
|
||||
if not is_admin():
|
||||
ctypes.windll.shell32.ShellExecuteW(
|
||||
None, "runas", sys.executable, " ".join(sys.argv), None, 1
|
||||
)
|
||||
sys.exit(0)
|
||||
Reference in New Issue
Block a user