Primer commit del proyecto. Incluye el rediseño 1.6 (riel de pasos en la barra lateral), el actualizador con reintentos y respaldo, publicar.sh y la nueva opción de desconectar carpetas (1.6.1). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
304 líneas
14 KiB
Python
304 líneas
14 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)
|
|
|
|
`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 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)))
|
|
|
|
|
|
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"
|
|
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
|