diff --git a/OrganizadorLabstream.spec b/OrganizadorLabstream.spec index cab7250..497ca8b 100644 --- a/OrganizadorLabstream.spec +++ b/OrganizadorLabstream.spec @@ -3,7 +3,7 @@ from PyInstaller.utils.hooks import collect_all datas = [] binaries = [] -hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador'] +hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador', 'firma'] tmp_ret = collect_all('webview') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('clr_loader') diff --git a/README.md b/README.md index a6c6209..d5d1da4 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,17 @@ a soltar la carpeta el escaneo anterior sigue ahí). No deja desconectar mientra se analiza, se le aplica el plan o tiene conversiones pendientes. Endpoint `POST /api/raiz/quitar {raiz, datos}`; queda anotado en la bitácora como `raiz_quitada`. +1.6.2: el manifiesto de actualización va firmado. `firma.py` (RSA-2048 sin dependencias) firma +versión+archivo+sha256+tamaño al empaquetar con la clave privada de `%USERPROFILE%\.labstream\organizador-firma.json` +(haz copia de seguridad: sin ella no se pueden publicar versiones nuevas); la pública está incrustada en +`actualizador.py` y el .exe no instala nada que no cuadre con ella, aunque alguien cambiara el archivo en el +servidor. Los .exe anteriores (≤ 1.6.1) ignoran la firma y siguen actualizándose. La barra de tareas muestra el +tiempo restante estimado (≈ 4 min) a partir del avance. Corregido: tras aplicar el plan, «Moviendo» se quedaba +en la barra de tareas para siempre y bloqueaba desconectar la carpeta. Pruebas automáticas en `probar.py` +(reglas, firma, y un recorrido completo escanear → plan → aplicar → deshacer → desconectar sobre una copia +temporal): `py -3.12 probar.py`. Rendimiento medido sobre el inventario real (54 mil archivos): las consultas +con LIKE tardan menos de 10 ms; no hacía falta optimizar. + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/actualizador.py b/actualizador.py index 9f30d89..8ec7581 100644 --- a/actualizador.py +++ b/actualizador.py @@ -13,6 +13,7 @@ Lo que se exige del manifiesto para instalar (si no se cumple, no se instala y s - 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). """ @@ -28,6 +29,7 @@ import tempfile import threading import subprocess import urllib.error +import firma import urllib.request from urllib.parse import urljoin, urlparse @@ -55,6 +57,10 @@ def _tupla(v): 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 {} @@ -73,6 +79,9 @@ def _validar(m, url_manifiesto): 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}, "" diff --git a/empaquetar.py b/empaquetar.py index 1f46ee8..e723e7b 100644 --- a/empaquetar.py +++ b/empaquetar.py @@ -38,7 +38,7 @@ for d in ("build", "dist"): subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed", "--name", "OrganizadorLabstream", "--icon", os.path.join("static", "organilab.ico"), "--hidden-import", "resolve_bridge", "--hidden-import", "reglas", - "--hidden-import", "asistente", "--hidden-import", "actualizador", + "--hidden-import", "asistente", "--hidden-import", "actualizador", "--hidden-import", "firma", "--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet", "--exclude-module", "tkinter", "organizador.py"]) dest = os.path.join("dist", "OrganizadorLabstream") @@ -118,6 +118,18 @@ manifiesto = { "app": {"archivo": app_zip, "size": os.path.getsize(app_zip), "sha256": sha(app_zip), "url": f"/api/herramientas/{app_zip}"}, "notes": notas, } +import firma +if os.environ.get("FIRMA") == "no": + print("AVISO: manifiesto sin firmar (FIRMA=no); los .exe 1.6.2+ no lo aceptarán") +else: + if not os.path.exists(firma.RUTA_PRIVADA): + sys.exit(f"falta la clave de firma en {firma.RUTA_PRIVADA}: corre py -3.12 firma.py generar (o FIRMA=no para saltarlo)") + _priv = firma.cargar_privada() + manifiesto["firma"] = {"alg": "rsa2048-pkcs1v15-sha256", "valor": firma.firmar(firma.texto_manifiesto(manifiesto), _priv)} + import actualizador + if not firma.verificar(firma.texto_manifiesto(manifiesto), manifiesto["firma"]["valor"], actualizador.CLAVE_PUBLICA): + sys.exit("la clave privada no corresponde a CLAVE_PUBLICA de actualizador.py; el manifiesto no serviría") + print("manifiesto firmado") ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json") if os.path.isdir(os.path.dirname(ruta_os)): previo = {} diff --git a/firma.py b/firma.py new file mode 100644 index 0000000..51da1ea --- /dev/null +++ b/firma.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +r""" +Firma del manifiesto de actualización (RSA-2048, PKCS#1 v1.5, SHA-256) sin dependencias. + +La clave privada vive fuera del proyecto, en %USERPROFILE%\.labstream\organizador-firma.json, y solo la +usa empaquetar.py al publicar. La clave pública va incrustada en actualizador.py: un .exe instalado solo +acepta manifiestos firmados con ella, así que aunque alguien pudiera cambiar el archivo en el servidor, +no podría hacer que la app instale un paquete que no salió de aquí. + + py -3.12 firma.py generar crea la clave (si no existe) y muestra la pública para pegarla + py -3.12 firma.py probar firma y verifica un texto de prueba +""" +import os +import sys +import json +import hashlib +import secrets + +RUTA_PRIVADA = os.path.join(os.path.expanduser("~"), ".labstream", "organizador-firma.json") +BITS = 2048 +E = 65537 +# DigestInfo de SHA-256 (RFC 8017, apéndice B.1) +_DI_SHA256 = bytes.fromhex("3031300d060960864801650304020105000420") + + +def _es_primo(n, rondas=40): + if n < 2: + return False + for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37): + if n % p == 0: + return n == p + d, r = n - 1, 0 + while d % 2 == 0: + d //= 2 + r += 1 + for _ in range(rondas): + a = secrets.randbelow(n - 3) + 2 + x = pow(a, d, n) + if x in (1, n - 1): + continue + for _ in range(r - 1): + x = pow(x, 2, n) + if x == n - 1: + break + else: + return False + return True + + +def _primo(bits): + while True: + c = secrets.randbits(bits) | (1 << (bits - 1)) | 1 + if _es_primo(c): + return c + + +def generar(ruta=RUTA_PRIVADA): + """Crea la pareja de claves y guarda la privada en `ruta`. Devuelve la pública {n, e}.""" + if os.path.exists(ruta): + raise FileExistsError(f"ya hay una clave en {ruta}; no se sobrescribe") + while True: + p, q = _primo(BITS // 2), _primo(BITS // 2) + n = p * q + phi = (p - 1) * (q - 1) + if p != q and n.bit_length() == BITS and phi % E: + break + d = pow(E, -1, phi) + os.makedirs(os.path.dirname(ruta), exist_ok=True) + with open(ruta, "w", encoding="utf-8") as fh: + json.dump({"n": n, "e": E, "d": d}, fh) + try: + os.chmod(ruta, 0o600) + except OSError: + pass + return {"n": n, "e": E} + + +def cargar_privada(ruta=RUTA_PRIVADA): + with open(ruta, encoding="utf-8") as fh: + return json.load(fh) + + +def _em(mensaje, k): + """EMSA-PKCS1-v1_5: 00 01 FF..FF 00 DigestInfo || hash""" + t = _DI_SHA256 + hashlib.sha256(mensaje).digest() + return b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t + + +def firmar(mensaje, priv): + """Devuelve la firma en hex del mensaje (bytes).""" + k = (priv["n"].bit_length() + 7) // 8 + m = int.from_bytes(_em(mensaje, k), "big") + return pow(m, priv["d"], priv["n"]).to_bytes(k, "big").hex() + + +def verificar(mensaje, firma_hex, pub): + """True si la firma corresponde al mensaje y a la clave pública {n, e}.""" + try: + k = (pub["n"].bit_length() + 7) // 8 + s = bytes.fromhex(firma_hex) + if len(s) != k: + return False + m = pow(int.from_bytes(s, "big"), pub["e"], pub["n"]).to_bytes(k, "big") + return secrets.compare_digest(m, _em(mensaje, k)) + except Exception: # noqa + return False + + +def texto_manifiesto(m): + """Lo que se firma: los datos del manifiesto que deciden qué se instala. Cambiar cualquiera invalida la firma.""" + app = m.get("app") or {} + return "\n".join(["organilab-manifiesto-v1", str(m.get("version", "")), str(app.get("archivo", "")), + str(app.get("sha256", "")).lower(), str(app.get("size", ""))]).encode("utf-8") + + +if __name__ == "__main__": + orden = sys.argv[1] if len(sys.argv) > 1 else "" + if orden == "generar": + if os.path.exists(RUTA_PRIVADA): + pub = {k: cargar_privada()[k] for k in ("n", "e")} + print("ya existía la clave en", RUTA_PRIVADA) + else: + print("generando clave RSA-2048 (unos segundos)…") + pub = generar() + print("clave privada guardada en", RUTA_PRIVADA, "— haz copia de seguridad: sin ella no se pueden publicar versiones nuevas") + print("\nCLAVE_PUBLICA para actualizador.py:") + print(f'CLAVE_PUBLICA = {{"e": {pub["e"]}, "n": {pub["n"]}}}') + elif orden == "probar": + priv = cargar_privada() + pub = {"n": priv["n"], "e": priv["e"]} + f = firmar(b"hola", priv) + print("firma ok:", verificar(b"hola", f, pub), "· alterada:", verificar(b"holA", f, pub)) + else: + print(__doc__) diff --git a/manifiesto.json b/manifiesto.json index ba4f74e..989123a 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,15 +1,19 @@ { "nombre": "OrganiLAB", - "version": "1.6.1", - "builtAt": "2026-09-15T19:21:15.000Z", - "archivo": "OrganizadorLabstream-portatil-1.6.1.zip", - "size": 198619195, - "sha256": "913cd3f1a7b04b38606b8b42c6cb6bc1b495b873771212ccbfa407b4fad60d14", + "version": "1.6.2", + "builtAt": "2026-09-15T19:46:00.000Z", + "archivo": "OrganizadorLabstream-portatil-1.6.2.zip", + "size": 198625969, + "sha256": "48ef016966c45d53f6d1d934a6e7bf735c0b20e46413c487ffdd2b4b04b3ef89", "app": { - "archivo": "OrganizadorLabstream-app-1.6.1.zip", - "size": 14799458, - "sha256": "c722527fb16d6e139d82a268fc6fadf79a14f48eb0a134ac2240086c24f18487", - "url": "/api/herramientas/OrganizadorLabstream-app-1.6.1.zip" + "archivo": "OrganizadorLabstream-app-1.6.2.zip", + "size": 14806232, + "sha256": "f45a61a47ffd678f76e6e184fefff6f2cceb860a6b82f8d305b823cebbf2b853", + "url": "/api/herramientas/OrganizadorLabstream-app-1.6.2.zip" }, - "notes": "" + "notes": "", + "firma": { + "alg": "rsa2048-pkcs1v15-sha256", + "valor": "013a3ac0886f655e22e2a30913be8a54880e43ec5663dcda8537a220a014438998533b1d440b84e33e7393e8d544089c215d7af3acb27390471ffa728da1d4f452cffad7534c51041f40695152431086b165348219fba51de93b1ae0403a3b748fc1e0022a707797868825d934d21d676debc6cfa8b54d6e7f4b0bd5c83b6ed6a9fe1f60bcee5cc9e5aa06710e120c2bbcdbcd59356e9931aed4c3206815b8c99746940be562a196144c9e22c4e0aa17ea43852c79a9b6e23076ff18c89c76971946aed10e05ca29aaedc95f3e0d39ffa71f020125d7bf272d9a5f9ef8b5f6b6e5cbb4930e12b4c598b6343bd779c1d0ead18aad70eaa442badc303ad22bbca3" + } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index d229f31..db1cd70 100644 --- a/organizador.py +++ b/organizador.py @@ -33,7 +33,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "1.6.1" +VERSION = "1.6.2" CONGELADO = getattr(sys, "frozen", False) SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller @@ -967,6 +967,7 @@ def aplicar(raiz, filas): hechas += 1 except Exception as ex: # noqa errores.append(f"{f.get('origen_rel')}: {ex}") + APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo» # carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos) for f in filas: base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"]) diff --git a/probar.py b/probar.py new file mode 100644 index 0000000..6f4fb7d --- /dev/null +++ b/probar.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +r""" +Pruebas automáticas de OrganiLAB. Se corren con: py -3.12 probar.py + + - reglas: detección de cámara, mes, año, basura y proxies + - firma: firmar/verificar y rechazo de manifiestos alterados o sin firmar + - servidor (extremo a extremo): copia el programa a una carpeta temporal, lo arranca en un puerto libre, + registra una carpeta de ensayo, la escanea, calcula el plan, lo aplica, lo deshace, y la desconecta. + No toca datos\ ni el inventario real: todo ocurre en la carpeta temporal. +""" +import os +import sys +import json +import time +import shutil +import socket +import tempfile +import unittest +import subprocess +import urllib.parse +import urllib.error +import urllib.request + +AQUI = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, AQUI) +import reglas as R # noqa: E402 +import firma # noqa: E402 + + +class Reglas(unittest.TestCase): + def test_firma_camara(self): + self.assertEqual(R.firma_archivo("C0001.MP4"), "SONY") + self.assertEqual(R.firma_archivo("C0001M01.XML"), "SONY") + self.assertEqual(R.firma_archivo("20260301_labstream0001.mp4"), "SONY") + self.assertIsNone(R.firma_archivo("informe final.docx")) + + def test_mes(self): + self.assertEqual(R.detectar_mes("09- Septiembre"), (9, False)) + self.assertEqual(R.detectar_mes("02-marzo"), (3, True)) # gana el nombre, con conflicto + self.assertEqual(R.detectar_mes("MAYO"), (5, False)) + self.assertEqual(R.detectar_mes("3"), (3, True)) # número solo: confianza media + self.assertEqual(R.detectar_mes("13"), (None, False)) + self.assertEqual(R.detectar_mes("Brigard"), (None, False)) + + def test_anio(self): + self.assertEqual(R.detectar_anio("2026"), 2026) + self.assertIsNone(R.detectar_anio("2026 - agosto")) + + def test_basura_y_proxies(self): + self.assertTrue(R.es_basura_archivo("Thumbs.db")) + self.assertTrue(R.es_basura_archivo(".DS_Store")) + self.assertFalse(R.es_basura_archivo("C0001.MP4")) + self.assertTrue(R.es_proxy_dir("Proxy")) + self.assertFalse(R.es_proxy_dir("Entregas")) + + +class Firma(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp(prefix="organilab-firma-") + cls.pub = firma.generar(os.path.join(cls.tmp, "clave.json")) + cls.priv = firma.cargar_privada(os.path.join(cls.tmp, "clave.json")) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def test_firmar_verificar(self): + f = firma.firmar(b"hola", self.priv) + self.assertTrue(firma.verificar(b"hola", f, self.pub)) + self.assertFalse(firma.verificar(b"holA", f, self.pub)) + self.assertFalse(firma.verificar(b"hola", f[:-2] + "00", self.pub)) + self.assertFalse(firma.verificar(b"hola", "zz", self.pub)) + + def test_manifiesto(self): + import actualizador + m = {"version": "9.9.9", "app": {"archivo": "OrganizadorLabstream-app-9.9.9.zip", "sha256": "a" * 64, "size": 10, "url": "/api/herramientas/x.zip"}} + url = "https://os.labstreamsas.com/herramientas/organizador.json" + d, motivo = actualizador._validar(m, url) + self.assertIsNone(d) + self.assertIn("firmado", motivo) + m["firma"] = {"valor": firma.firmar(firma.texto_manifiesto(m), self.priv)} + # firmado con OTRA clave (la de prueba): tampoco vale para la clave real + self.assertIsNone(actualizador._validar(m, url)[0]) + viejo = actualizador.CLAVE_PUBLICA + actualizador.CLAVE_PUBLICA = self.pub + try: + self.assertIsNotNone(actualizador._validar(m, url)[0]) + m["app"]["sha256"] = "b" * 64 + self.assertIsNone(actualizador._validar(m, url)[0]) + finally: + actualizador.CLAVE_PUBLICA = viejo + + +def _puerto_libre(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + s.close() + return p + + +class Servidor(unittest.TestCase): + """Extremo a extremo sobre una copia temporal del programa.""" + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp(prefix="organilab-e2e-") + cls.app = os.path.join(cls.tmp, "app") + os.makedirs(cls.app) + for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py"): + shutil.copy(os.path.join(AQUI, f), cls.app) + shutil.copytree(os.path.join(AQUI, "static"), os.path.join(cls.app, "static")) + # carpeta de ensayo: cámara Sony, basura y un proxy + cls.raiz = os.path.join(cls.tmp, "Cliente Prueba") + for rel, tam in (("Rodaje/C0001.MP4", 3_000_000), ("Rodaje/C0001M01.XML", 500), ("Rodaje/Thumbs.db", 100), + ("Rodaje/Proxy/C0001.MOV", 200_000), ("Entregas/Video final.mp4", 800_000), ("notas.txt", 50)): + p = os.path.join(cls.raiz, rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as fh: + fh.write(os.urandom(min(tam, 4096)) * max(1, tam // 4096)) + cls.puerto = _puerto_libre() + env = dict(os.environ, ORGANIZADOR_PUERTO=str(cls.puerto), PYTHONIOENCODING="utf-8") + cls.log = open(os.path.join(cls.tmp, "servidor.log"), "w", encoding="utf-8") + cls.proc = subprocess.Popen([sys.executable, "organizador.py", "--sin-navegador"], cwd=cls.app, env=env, stdout=cls.log, stderr=subprocess.STDOUT) + cls.base = f"http://127.0.0.1:{cls.puerto}" + for _ in range(60): + try: + urllib.request.urlopen(cls.base + "/api/estado", timeout=1) + break + except Exception: # noqa + time.sleep(0.25) + else: + raise RuntimeError("el servidor no arrancó: " + open(cls.log.name, encoding="utf-8", errors="replace").read()[-800:]) + + @classmethod + def tearDownClass(cls): + cls.proc.kill() + cls.proc.wait() + cls.log.close() + shutil.rmtree(cls.tmp, ignore_errors=True) + + def get(self, p): + return json.loads(urllib.request.urlopen(self.base + p, timeout=30).read()) + + def post(self, p, d): + rq = urllib.request.Request(self.base + p, json.dumps(d).encode(), {"Content-Type": "application/json", "Origin": self.base}) + try: + r = urllib.request.urlopen(rq, timeout=60) + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + def esperar_escaneo(self): + for _ in range(200): + r = next((x for x in self.get("/api/estado")["raices"] if x["ruta"].lower() == self.raiz.lower()), None) + if r and r["escaneo"] and not (r["progreso"] and r["progreso"]["estado"] == "corriendo"): + return r + time.sleep(0.1) + self.fail("el escaneo no terminó") + + def _arbol(self): + out = [] + for d, _, fs in os.walk(self.raiz): + for f in fs: + out.append(os.path.relpath(os.path.join(d, f), self.raiz).lower()) + return sorted(out) + + def test_recorrido(self): + q = "?raiz=" + urllib.parse.quote(self.raiz) + # peticiones que no son JSON: fuera + rq = urllib.request.Request(self.base + "/api/raices", b"{}", {"Content-Type": "text/plain"}) + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(rq) + self.assertEqual(cm.exception.code, 403) + # 1. registrar y escanear + self.assertEqual(self.post("/api/raices", {"raices": [{"ruta": self.raiz, "cliente": "Cliente Prueba", "tipo": "contenido"}]})[0], 200) + self.assertEqual(self.post("/api/escanear", {"raiz": self.raiz})[0], 200) + r = self.esperar_escaneo() + self.assertEqual(r["escaneo"]["n"], 6) + mapa = self.get("/api/mapa" + q) + self.assertTrue(mapa["escaneado"]) + self.assertGreaterEqual(mapa["basura"]["n"], 1) + self.assertGreaterEqual(mapa["proxies"]["n"], 1) + # 2. plan: nada sale de la carpeta del cliente + plan = self.get("/api/plan" + q) + self.assertTrue(plan["filas"], "el plan no propuso nada") + for f in plan["filas"]: + self.assertTrue(os.path.normpath(f["destino"]).lower().startswith(os.path.normpath(self.raiz).lower()), f["destino"]) + ids = [f["id"] for f in plan["filas"]] + # destino fuera de la carpeta: rechazado + c, d = self.post("/api/aplicar", {"raiz": self.raiz, "ids": ids, "destinos": {str(ids[0]): "..\\fuera"}}) + self.assertEqual(c, 400) + self.assertIn("fuera", d["error"]) + # 3. aplicar: el árbol cambia pero conserva los mismos archivos, y aparece _Para borrar + antes = self._arbol() + c, res = self.post("/api/aplicar", {"raiz": self.raiz, "ids": ids}) + self.assertEqual(c, 200) + self.assertEqual(res["errores"], []) + self.assertGreater(res["movimientos"], 0) + despues = self._arbol() + self.assertNotEqual(antes, despues) + self.assertEqual(sorted(os.path.basename(p) for p in antes), sorted(os.path.basename(p) for p in despues)) + self.assertTrue(any("_para borrar" in p for p in despues)) + # 4. deshacer deja el árbol exacto + c, res = self.post("/api/deshacer", {"lote": res["lote"]}) + self.assertEqual(c, 200) + self.assertEqual(antes, self._arbol()) + # 5. desconectar con datos; la segunda vez ya no existe + c, d = self.post("/api/raiz/quitar", {"raiz": self.raiz, "datos": True}) + self.assertEqual(c, 200) + self.assertEqual(self.get("/api/estado")["raices"], []) + self.assertEqual(self.post("/api/raiz/quitar", {"raiz": self.raiz})[0], 409) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/static/index.html b/static/index.html index a9ffdf7..1dbc061 100644 --- a/static/index.html +++ b/static/index.html @@ -228,6 +228,7 @@ .tarea .b i{display:block;height:100%;background:var(--acc);min-width:2px;transition:width .3s} .tarea .b.ind i{width:30%!important;animation:ind 1.2s infinite linear} .tarea .pct{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap} + .tarea .pct small{display:block;font-size:11px;color:var(--mut);font-weight:500;text-align:right} .tarea button{padding:3px 8px;font-size:11.5px} /* avisos */ #avisos{position:fixed;right:18px;bottom:60px;display:flex;flex-direction:column;gap:8px;z-index:30;max-width:420px} @@ -558,10 +559,22 @@ function tareasDe(E){ const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct}); return T; } +// tiempo restante estimado: se recuerda cuándo se vio cada tarea por primera vez y a qué % iba; con eso se extrapola +const ETA = {}; +function etaDe(t){ + if (t.pct == null) return ''; + const k = t.q + '|' + (t.raiz || ''); const ahora = Date.now(); + const h = ETA[k] && ETA[k].pct <= t.pct ? ETA[k] : (ETA[k] = {t0: ahora, pct: t.pct}); + const avance = t.pct - h.pct, seg = (ahora - h.t0) / 1000; + if (avance < 2 || seg < 8) return ''; + const resta = seg * (100 - t.pct) / avance; + return resta < 60 ? '≈ ' + Math.max(5, Math.round(resta / 5) * 5) + ' s' : resta < 3600 ? '≈ ' + Math.round(resta / 60) + ' min' : '≈ ' + (resta / 3600).toFixed(1) + ' h'; +} function pintarTareas(){ const T = tareasDe(ESTADO); + const vivas = new Set(T.map(t => t.q + '|' + (t.raiz || ''))); Object.keys(ETA).forEach(k => { if (!vivas.has(k)) delete ETA[k]; }); const caja = $('#tareas'); caja.classList.toggle('on', T.length > 0); - caja.innerHTML = 'En curso' + T.map((t, i) => `
${esc(t.q)}
${esc(t.d)}
${t.pct != null ? `${t.pct} %` : ''}${t.cancelar ? `` : ''}
`).join(''); + caja.innerHTML = 'En curso' + T.map((t, i) => `
${esc(t.q)}
${esc(t.d)}
${t.pct != null ? `${t.pct} %${etaDe(t)}` : ''}${t.cancelar ? `` : ''}
`).join(''); $$('#tareas button').forEach(b => b.onclick = () => accion(b, async () => { await T[+b.dataset.i].cancelar(); await cargarEstado(); })); }