# -*- coding: utf-8 -*- r""" Actualización automática desde Labstream OS. El OS publica public/herramientas/organizador.json (versión, archivo del paquete «app», tamaño, sha256). Este módulo lo consulta, descarga el zip nuevo a BASE\actualizacion\, comprueba el sha256, lo descomprime y deja un actualizar.bat que, cuando el .exe se cierra, guarda una copia de la versión actual, copia la nueva encima (sin tocar datos\ ni ffmpeg\), vuelve atrás si algo falla, y reabre el programa. Lo que se exige del manifiesto para instalar (si no se cumple, no se instala y se explica por qué): - versión con forma 1.2.3 - nombre de archivo simple (sin rutas) terminado en .zip - sha256 de 64 hex, obligatorio - URL de descarga con el mismo esquema y host que el manifiesto (nada de saltar a otro servidor) - firma RSA (firma.py) de versión+archivo+sha256+tamaño que cuadre con CLAVE_PUBLICA; sin ella no se instala `O` es el módulo organizador (lo fija él al arrancar). """ import os import re import sys import json import time import shutil import hashlib import zipfile import tempfile import threading import subprocess import urllib.error import firma import urllib.request from urllib.parse import urljoin, urlparse O = None URL_DEFECTO = "https://os.labstreamsas.com/herramientas/organizador.json" ESTADO = {"version": "", "comprobado": 0, "disponible": None, "estado": "", "pct": 0, "bytes": 0, "total": 0, "error": "", "aplicando": False} _LOCK = threading.Lock() RE_VERSION = re.compile(r"^\d{1,4}(\.\d{1,5}){0,3}$") RE_SHA = re.compile(r"^[0-9a-fA-F]{64}$") def _cfg(): c = O.CONFIG.setdefault("actualizaciones", {}) c.setdefault("url", URL_DEFECTO) c.setdefault("auto", False) # apagado salvo que alguien lo encienda en Ajustes return c def _tupla(v): out = [] for p in str(v or "0").split("."): num = "".join(ch for ch in p if ch.isdigit()) out.append(int(num) if num else 0) return tuple(out + [0] * (3 - len(out))) # clave pública de firma.py (la privada está en ~/.labstream/organizador-firma.json, solo la usa empaquetar.py) CLAVE_PUBLICA = {"e": 65537, "n": 24937087096150856258549531047458881410156319770314378325709810834727102787144078753904837107336360100277476122470417827694103702029600632424402930901393600571590411504459673905624547394933513534401412673838815719634257887193733377640612491708305947101493071063533297310409777772800911393696346540036769491297235656643827279260544972034028016485933509783117840560074417501599224256143123720254264555615803987537224119534643367425486525306755766385726396897033934795259582227391903282650010470084464660818450797096588538639818043343189296266160938215570507865913766129394056803324311835125960516203968300455854004216307} def _validar(m, url_manifiesto): """Devuelve (dict disponible, "") o (None, motivo). Solo acepta manifiestos bien formados.""" app = m.get("app") or {} version = str(m.get("version") or "") if not RE_VERSION.match(version): return None, f"versión con formato raro en el manifiesto: {version!r}" archivo = str(app.get("archivo") or "") if not archivo or archivo != os.path.basename(archivo) or not archivo.lower().endswith(".zip") or "/" in archivo or "\\" in archivo: return None, f"nombre de archivo no válido en el manifiesto: {archivo!r}" sha = str(app.get("sha256") or "") if not RE_SHA.match(sha): return None, "el manifiesto no trae el sha256 del paquete; no se instala nada sin él" url = urljoin(url_manifiesto, app.get("url") or f"/api/herramientas/{archivo}") a, b = urlparse(url_manifiesto), urlparse(url) if (a.scheme, a.netloc.lower()) != (b.scheme, b.netloc.lower()): return None, f"la descarga apunta a otro servidor ({b.netloc}); solo se acepta {a.netloc}" if a.scheme != "https" and not a.netloc.split(":")[0] in ("127.0.0.1", "localhost"): return None, "el manifiesto debe servirse por https" f = m.get("firma") or {} if not isinstance(f, dict) or not firma.verificar(firma.texto_manifiesto(m), str(f.get("valor") or ""), CLAVE_PUBLICA): return None, "el manifiesto no está firmado con la clave de Labstream (o se modificó); no se instala nada" return {"version": version, "notes": str(m.get("notes", ""))[:2000], "archivo": archivo, "size": int(app.get("size") or 0), "sha256": sha.lower(), "url": url}, "" def comprobar(timeout=8): """Lee el manifiesto del OS. Devuelve el dict de la versión disponible o None.""" ESTADO["version"] = O.VERSION url = _cfg()["url"] try: req = urllib.request.Request(url, headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}", "Cache-Control": "no-cache"}) with urllib.request.urlopen(req, timeout=timeout) as r: m = json.loads(r.read(1 << 20).decode("utf-8")) ESTADO["error"] = "" except Exception as ex: # noqa ESTADO["error"] = f"no se pudo consultar {url}: {ex}" ESTADO["comprobado"] = time.time() return None ESTADO["comprobado"] = time.time() d, motivo = _validar(m, url) if d and _tupla(d["version"]) > _tupla(O.VERSION): ESTADO["disponible"] = d else: ESTADO["disponible"] = None if not d and _tupla(m.get("version")) > _tupla(O.VERSION): ESTADO["error"] = "hay una versión publicada pero el manifiesto no pasa las comprobaciones: " + motivo return ESTADO["disponible"] def comprobar_en_segundo_plano(): threading.Thread(target=comprobar, daemon=True).start() def _sha256(ruta): h = hashlib.sha256() with open(ruta, "rb") as fh: for b in iter(lambda: fh.read(1 << 20), b""): h.update(b) return h.hexdigest() def tarea_en_marcha(): """Texto de la tarea que impide actualizar ahora, o '' si se puede.""" if O.COLA.get("activa"): return "hay una conversión en marcha" if O.RESPALDO.get("estado") == "corriendo": return "hay una copia de respaldo en marcha" if O.asistente.ESTADO.get("activo"): return "el asistente está trabajando" if any((p or {}).get("estado") == "corriendo" for p in O.PROGRESO.values()): return "hay un escaneo en marcha" if any((a or {}).get("estado") == "corriendo" for a in O.ANALISIS.values()): return "se están leyendo clips" if getattr(O, "TRAER", {}).get("estado") in ("copiando", "ordenando"): return "se está trayendo material de una tarjeta" if getattr(O, "AUTO", {}).get("activo"): return "el orden automático está en marcha" return "" def _bat(s): """escapa lo que cmd interpretaría dentro del .bat""" return str(s).replace("%", "%%") def descargar_y_aplicar(en_hilo=False, relanzar_args=()): """Descarga el paquete, lo verifica, lo descomprime y deja el .bat listo; luego cierra el programa.""" if en_hilo: if _LOCK.locked(): ESTADO["error"] = "ya hay una actualización en curso" return False threading.Thread(target=descargar_y_aplicar, args=(False, relanzar_args), daemon=True).start() return True if not _LOCK.acquire(blocking=False): ESTADO["error"] = "ya hay una actualización en curso" return False try: return _descargar_y_aplicar(relanzar_args) finally: if ESTADO["estado"] != "reiniciando": _LOCK.release() def _descargar_y_aplicar(relanzar_args): d = ESTADO.get("disponible") or comprobar() if not d: ESTADO["estado"] = "al día" return False if not O.CONGELADO: ESTADO["estado"] = "error" ESTADO["error"] = "la actualización automática solo funciona en el paquete (OrganizadorLabstream.exe); desde el código se actualiza con git" return False ocupado = tarea_en_marcha() if ocupado: ESTADO["estado"] = "error" ESTADO["error"] = f"no se actualiza ahora: {ocupado}. Espera a que termine." return False carpeta = os.path.join(O.BASE, "actualizacion") try: shutil.rmtree(carpeta, ignore_errors=True) os.makedirs(carpeta, exist_ok=True) destino = os.path.join(carpeta, os.path.basename(d["archivo"])) ESTADO.update(estado="descargando", pct=0, bytes=0, total=d.get("size", 0), error="") req = urllib.request.Request(d["url"], headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}"}) tope = max(int(d.get("size") or 0) * 2, 64 << 20) # nunca más del doble de lo anunciado r = None for intento in range(1, 7): try: r = urllib.request.urlopen(req, timeout=30) break except urllib.error.HTTPError as ex: # 502/503/504: el OS se está redesplegando (pasa unos minutos cada vez que se publica algo) if ex.code not in (502, 503, 504) or intento == 6: raise RuntimeError("el servidor de Labstream OS está reiniciándose; prueba de nuevo en un par de minutos" if ex.code in (502, 503, 504) else str(ex)) ESTADO["error"] = f"el servidor está ocupado (HTTP {ex.code}); reintento {intento} de 6 en 20 s…" time.sleep(20) except urllib.error.URLError as ex: if intento == 6: raise RuntimeError(f"no se pudo conectar con Labstream OS: {ex.reason}") ESTADO["error"] = f"sin conexión con el servidor; reintento {intento} de 6 en 20 s…" time.sleep(20) ESTADO["error"] = "" with r, open(destino, "wb") as fh: total = int(r.headers.get("Content-Length") or d.get("size") or 0) ESTADO["total"] = total leidos = 0 while True: b = r.read(1 << 18) if not b: break fh.write(b) leidos += len(b) if leidos > tope: raise RuntimeError("la descarga es mucho más grande de lo anunciado; se cancela") ESTADO["bytes"] = leidos ESTADO["pct"] = int(leidos * 100 / total) if total else 0 ESTADO["estado"] = "verificando" if _sha256(destino).lower() != d["sha256"]: raise RuntimeError("el archivo descargado no coincide con el sha256 publicado; no se instala") nueva = os.path.join(carpeta, "nueva") os.makedirs(nueva) with zipfile.ZipFile(destino) as z: nombres = z.namelist() # el zip lleva la carpeta OrganizadorLabstream/ como raíz: se quita ese primer nivel raiz = nombres[0].split("/")[0] + "/" if nombres and "/" in nombres[0] and all(n.startswith(nombres[0].split("/")[0] + "/") for n in nombres) else "" for n in nombres: rel = n[len(raiz):] if not rel or rel.endswith("/"): continue dest = os.path.normpath(os.path.join(nueva, rel)) if not dest.startswith(os.path.normpath(nueva) + os.sep): continue os.makedirs(os.path.dirname(dest), exist_ok=True) with z.open(n) as src, open(dest, "wb") as out: shutil.copyfileobj(src, out) if not os.path.exists(os.path.join(nueva, "OrganizadorLabstream.exe")): raise RuntimeError("el paquete no trae OrganizadorLabstream.exe") exe = os.path.join(O.BASE, "OrganizadorLabstream.exe") previa = os.path.join(carpeta, "previa") # relanzar_args None = no volver a abrir (modo consola); tupla = abrir con esos argumentos args = " ".join(f'"{_bat(a)}"' for a in (relanzar_args or ())) linea_start = f'start "" "{_bat(exe)}" {args}\r\n' if relanzar_args is not None else f"echo Listo: version {d['version']} instalada.\r\n" # el .bat vive FUERA de la carpeta que va a borrar; UTF-8 con chcp 65001 para rutas con acentos bat = os.path.join(tempfile.gettempdir(), "organizador_actualizar.bat") excl = "/XD datos ffmpeg actualizacion" with open(bat, "w", encoding="utf-8", newline="") as fh: fh.write("@echo off\r\n" "chcp 65001 >nul\r\n" f"title Actualizando OrganiLAB a {d['version']}\r\n" "echo Esperando a que se cierre OrganiLAB...\r\n" ":espera\r\n" f"tasklist /FI \"PID eq {os.getpid()}\" 2>nul | find \"{os.getpid()}\" >nul && (timeout /t 1 /nobreak >nul & goto espera)\r\n" ":espera2\r\n" "tasklist /FI \"IMAGENAME eq OrganizadorLabstream.exe\" 2>nul | find /I \"OrganizadorLabstream.exe\" >nul && (echo Cierra las otras ventanas de OrganiLAB para seguir... & timeout /t 2 /nobreak >nul & goto espera2)\r\n" "echo Guardando copia de la version actual...\r\n" f"robocopy \"{_bat(O.BASE)}\" \"{_bat(previa)}\" /E /R:2 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n" "if %ERRORLEVEL% GEQ 8 (echo ERROR: no se pudo guardar la copia de seguridad. No se cambia nada. & pause & exit /b 1)\r\n" f"echo Instalando la version {d['version']}...\r\n" f"robocopy \"{_bat(nueva)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n" "if %ERRORLEVEL% GEQ 8 (\r\n" " echo ERROR al copiar la version nueva. Volviendo a la anterior...\r\n" f" robocopy \"{_bat(previa)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n" " echo Restaurada la version anterior. Avisa a Labstream.\r\n" " pause & exit /b 1\r\n" ")\r\n" + linea_start + f"rmdir /s /q \"{_bat(carpeta)}\" >nul 2>&1\r\n" "timeout /t 2 /nobreak >nul\r\n" "(goto) 2>nul & del \"%~f0\"\r\n") ESTADO["estado"] = "reiniciando" ESTADO["aplicando"] = True # con su PROPIA consola: sin ella, timeout/tasklist se quedan colgados y la copia nunca ocurre flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) subprocess.Popen(["cmd", "/c", bat], creationflags=flags, close_fds=True, cwd=tempfile.gettempdir()) threading.Timer(1.5, lambda: os._exit(0)).start() return True except Exception as ex: # noqa ESTADO["estado"] = "error" ESTADO["error"] = str(ex) ESTADO["aplicando"] = False return False def cli(args): print(f"Versión instalada: {O.VERSION}. Consultando {_cfg()['url']} …") d = comprobar() if ESTADO["error"]: print("No se pudo comprobar:", ESTADO["error"]) return 1 if not d: print("Ya tienes la última versión.") return 0 print(f"Hay versión nueva: {d['version']} ({(d.get('size') or 0) / 1e6:.0f} MB)") if d.get("notes"): print(" " + d["notes"]) if "--si" not in args and not O._confirmar("¿Descargar e instalar ahora? El programa se cerrará y volverá a abrirse", False): return 0 hilo = threading.Thread(target=descargar_y_aplicar, args=(False, None), daemon=True) hilo.start() ultimo = -1 while hilo.is_alive(): if ESTADO["estado"] == "descargando" and ESTADO["pct"] != ultimo: ultimo = ESTADO["pct"] sys.stdout.write(f"\r descargando {ultimo:3d}% {ESTADO['bytes'] / 1e6:.0f}/{ESTADO['total'] / 1e6:.0f} MB") sys.stdout.flush() time.sleep(0.2) print() if ESTADO["estado"] == "error": print("ERROR:", ESTADO["error"]) return 1 print("Instalando… se abre una ventana «Actualizando» que termina sola.", flush=True) time.sleep(3) return 0