Files
labstream-organizador/actualizador.py
Labstream 252b1fd00e 3.0.0: OrganiLAB pasa a ser un programa de Windows
- ventana.py: la ventana es un marco Win32 propio con WebView2 dentro,
  manejado por COM con ctypes. Fuera pywebview, pythonnet y clr_loader y
  sus binarios sin firmar. Identidad en la barra de tareas, una sola copia
  abierta por carpeta de datos, selector de carpetas de Windows, rutas
  completas al soltar, avance en la barra de tareas, memoria de posición,
  aviso al cerrar con trabajo en marcha, y nada de navegador (menú, F5,
  zoom, permisos). Si el motor se cae, se monta de nuevo solo.
- arranque.py y empaquetar.py: el paquete ya no lleva binario propio.
  OrganizadorLabstream.exe es pythonw.exe (firmado por la PSF) con otro
  nombre y arranca el programa desde sitecustomize; abre con Smart App
  Control encendido. Fuera PyInstaller. Al empaquetar se comprueba la firma
  de cada binario con Windows y se arranca el motor del paquete.
- instalar.py: accesos directos en Inicio y Escritorio con la identidad de
  la ventana, y la línea en Configuración › Aplicaciones. Se ofrece una vez,
  la primera que se abre un paquete en un equipo.
- Dos copias sobre los mismos datos ya no se pisan: la segunda trae la
  ventana al frente (antes abría el navegador y daba por huérfanas las
  conversiones en marcha de la primera). Si el puerto 8765 está ocupado
  por otro programa, se toma uno libre.
- Por consola, el paquete ya no consulta si hay versión nueva con las
  actualizaciones automáticas apagadas.
- Pruebas: probar.py 17, prueba_funcional.py 26 de 26 y prueba_ventana.py
  (nueva), que abre la ventana de verdad: 17 comprobaciones desde el código
  y 8 arrancando el paquete por su .exe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 09:54:31 -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", 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"
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