Files
labstream-organizador/actualizador.py
Labstream 8b482af652 1.6.2: manifiesto firmado, tiempo estimado en tareas, pruebas automáticas
- firma.py: RSA-2048 PKCS#1 v1.5 SHA-256 sin dependencias; empaquetar.py firma
  el manifiesto y actualizador.py exige la firma (CLAVE_PUBLICA incrustada).
- Barra de tareas: tiempo restante estimado a partir del avance.
- Corregido: APLICANDO nunca llegaba al total y «Moviendo» quedaba fijo,
  bloqueando desconectar la carpeta.
- probar.py: reglas, firma y recorrido completo sobre una copia temporal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 14:48:47 -05:00

313 líneas
15 KiB
Python

# -*- 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", True)
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"
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 con el .exe empaquetado"
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