- 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>
313 líneas
15 KiB
Python
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
|