- instalar.py: instalar un paquete lo copia a la carpeta de programas del usuario (%LOCALAPPDATA%\Programs\OrganiLAB) con sus datos y su ffmpeg, lo da de alta desde ahí y lo vuelve a abrir desde su sitio. Del origen no se borra nada: queda instalado-en.txt, que además hace que abrir esa copia abra la instalada. Un destino ocupado no se pisa, y si ya hay un OrganiLAB instalado lo dice en vez de poner otro encima. - static/index.html: vista «Manual», con cada función del programa, qué hace y cómo lo hace (41 entradas, un apartado por pestaña). - organizador.py: ServidorLocal pide el puerto en exclusiva. Con «reutilizar dirección», en Windows dos copias escuchaban a la vez en el mismo puerto y se contestaban la una por la otra sin que nada fallara a la vista. - Pruebas: probar.py 19, prueba_funcional.py 26 de 26, prueba_ventana.py 18 desde el código y 17 con el paquete, incluida la instalación entera en un equipo de ensayo (ORGANILAB_ENSAYO) que no toca el Windows de verdad. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
257 líneas
15 KiB
Python
257 líneas
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
r"""
|
||
Construye el paquete portátil y el de actualización.
|
||
Uso: py -3.12 empaquetar.py [ruta\a\ffmpeg\bin]
|
||
Resultado:
|
||
dist\OrganizadorLabstream\ carpeta completa (con ffmpeg)
|
||
OrganizadorLabstream-portatil-<v>.zip primera instalación (con ffmpeg, ~190 MB)
|
||
OrganizadorLabstream-app-<v>.zip actualización (sin ffmpeg ni datos, ~15 MB)
|
||
..\labstream-os\public\herramientas\organizador.json manifiesto para el OS (versión, tamaños, sha256)
|
||
Después: subir los dos zips al NAS (storage/herramientas/) y desplegar el OS.
|
||
|
||
El paquete no lleva ningún binario propio. OrganizadorLabstream.exe es pythonw.exe con otro nombre
|
||
(arranque.py cuenta cómo arranca el programa), y todo lo demás que se ejecuta lo firma quien lo hizo:
|
||
la Python Software Foundation o Microsoft. El programa es su código, a la vista. Hasta la 2.x se
|
||
congelaba con PyInstaller y salía un .exe sin firma que Smart App Control bloqueaba.
|
||
|
||
Antes de dar el paquete por bueno se le pregunta a Windows por la firma de cada binario (la misma
|
||
comprobación que hace al abrirlo) y se arranca el motor del paquete: si algo falla, no hay paquete.
|
||
"""
|
||
import os
|
||
import re
|
||
import sys
|
||
import glob
|
||
import json
|
||
import uuid
|
||
import ctypes
|
||
import shutil
|
||
import hashlib
|
||
import zipfile
|
||
import datetime
|
||
import tempfile
|
||
import py_compile
|
||
import subprocess
|
||
from ctypes import wintypes as W
|
||
|
||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||
os.chdir(AQUI)
|
||
VERSION = re.search(r'^VERSION = "([^"]+)"', open("organizador.py", encoding="utf-8").read(), re.M).group(1)
|
||
print("versión", VERSION)
|
||
|
||
MODULOS = ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py",
|
||
"ventana.py", "instalar.py")
|
||
PY = sys.base_prefix # el Python con el que se empaqueta
|
||
MOTOR = f"python{sys.version_info.major}{sys.version_info.minor}" # python312
|
||
# de la librería estándar no viaja lo que el programa no usa ni va a usar
|
||
SIN_LIB = {"site-packages", "test", "idlelib", "tkinter", "turtledemo", "ensurepip", "venv", "lib2to3", "pydoc_data", "__pycache__"}
|
||
SIN_DLL = ("_tkinter", "tcl", "tk8", "zlib1", "_test", "_ctypes_test", "xxlimited")
|
||
|
||
ffbin = sys.argv[1] if len(sys.argv) > 1 else ""
|
||
if not ffbin:
|
||
for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")):
|
||
ffbin = c
|
||
if not ffbin and shutil.which("ffmpeg"):
|
||
ffbin = os.path.dirname(shutil.which("ffmpeg"))
|
||
print("ffmpeg desde:", ffbin or "(no encontrado: el zip irá sin ffmpeg)")
|
||
|
||
|
||
def firmado(ruta):
|
||
"""True si Windows da por buena la firma del archivo."""
|
||
class Archivo(ctypes.Structure):
|
||
_fields_ = [("cbStruct", W.DWORD), ("pcwszFilePath", W.LPCWSTR), ("hFile", W.HANDLE), ("pgKnownSubject", ctypes.c_void_p)]
|
||
|
||
class Datos(ctypes.Structure):
|
||
_fields_ = [("cbStruct", W.DWORD), ("pPolicyCallbackData", ctypes.c_void_p), ("pSIPClientData", ctypes.c_void_p),
|
||
("dwUIChoice", W.DWORD), ("fdwRevocationChecks", W.DWORD), ("dwUnionChoice", W.DWORD),
|
||
("pFile", ctypes.POINTER(Archivo)), ("dwStateAction", W.DWORD), ("hWVTStateData", W.HANDLE),
|
||
("pwszURLReference", W.LPCWSTR), ("dwProvFlags", W.DWORD), ("dwUIContext", W.DWORD),
|
||
("pSignatureSettings", ctypes.c_void_p)]
|
||
a = Archivo(ctypes.sizeof(Archivo), ruta, None, None)
|
||
d = Datos()
|
||
d.cbStruct, d.dwUIChoice, d.dwUnionChoice, d.pFile, d.dwProvFlags = ctypes.sizeof(Datos), 2, 1, ctypes.pointer(a), 0x1000
|
||
accion = (ctypes.c_ubyte * 16)(*uuid.UUID("{00AAC56B-CD44-11D0-8CC2-00C04FC295EE}").bytes_le) # «comprueba la firma»
|
||
f = ctypes.WinDLL("wintrust").WinVerifyTrust
|
||
f.argtypes, f.restype = [W.HWND, ctypes.c_void_p, ctypes.c_void_p], ctypes.c_long
|
||
return f(None, ctypes.byref(accion), ctypes.byref(d)) == 0
|
||
|
||
|
||
shutil.rmtree("build", ignore_errors=True) # restos de cuando se empaquetaba con PyInstaller
|
||
shutil.rmtree("dist", ignore_errors=True)
|
||
dest = os.path.join("dist", "OrganizadorLabstream")
|
||
motor = os.path.join(dest, "runtime")
|
||
os.makedirs(motor)
|
||
|
||
# ── el motor: el Python de este equipo, tal cual lo instala python.org ──
|
||
shutil.copy2(os.path.join(PY, "pythonw.exe"), os.path.join(dest, "OrganizadorLabstream.exe"))
|
||
for f in (MOTOR + ".dll", "vcruntime140.dll", "vcruntime140_1.dll"):
|
||
if os.path.exists(os.path.join(PY, f)):
|
||
shutil.copy2(os.path.join(PY, f), dest)
|
||
for f in sorted(os.listdir(os.path.join(PY, "DLLs"))):
|
||
if f.lower().endswith((".pyd", ".dll")) and not f.lower().startswith(SIN_DLL):
|
||
shutil.copy2(os.path.join(PY, "DLLs", f), motor)
|
||
# la librería estándar, compilada y en un zip (como la distribución «embeddable» de python.org)
|
||
lib = os.path.join(PY, "Lib")
|
||
with tempfile.TemporaryDirectory() as tmp, zipfile.ZipFile(os.path.join(motor, MOTOR + ".zip"), "w", zipfile.ZIP_DEFLATED) as z:
|
||
n = 0
|
||
for raiz, dirs, files in os.walk(lib):
|
||
dirs[:] = sorted(d for d in dirs if d not in SIN_LIB)
|
||
for f in sorted(files):
|
||
if not f.endswith(".py"):
|
||
continue
|
||
rel = os.path.relpath(os.path.join(raiz, f), lib).replace(os.sep, "/")
|
||
pyc = os.path.join(tmp, "m.pyc")
|
||
py_compile.compile(os.path.join(raiz, f), cfile=pyc, dfile=rel, doraise=True,
|
||
invalidation_mode=py_compile.PycInvalidationMode.UNCHECKED_HASH)
|
||
z.write(pyc, rel + "c")
|
||
n += 1
|
||
print(f"motor: Python {sys.version.split()[0]} · {n} módulos de la librería estándar")
|
||
# qué carpetas mira el motor, y nada más (ni el registro ni las variables de entorno de este equipo)
|
||
with open(os.path.join(dest, MOTOR + "._pth"), "w", encoding="ascii") as fh:
|
||
fh.write(f"runtime\\{MOTOR}.zip\nruntime\n.\nimport site\n")
|
||
shutil.copy("arranque.py", os.path.join(motor, "sitecustomize.py"))
|
||
if os.path.exists(os.path.join(PY, "LICENSE.txt")):
|
||
shutil.copy(os.path.join(PY, "LICENSE.txt"), os.path.join(motor, "LICENCIA-Python.txt"))
|
||
|
||
# ── el programa ──
|
||
for f in MODULOS:
|
||
shutil.copy(f, dest)
|
||
shutil.copy("WebView2Loader.dll", dest)
|
||
shutil.copytree("static", os.path.join(dest, "static"))
|
||
|
||
# ── comprobaciones: todo firmado, y el motor del paquete arranca ──
|
||
binarios = [os.path.join(r, f) for r, _, fs in os.walk(dest) for f in fs if f.lower().endswith((".exe", ".dll", ".pyd"))]
|
||
sin_firma = [os.path.relpath(b, dest) for b in binarios if not firmado(os.path.abspath(b))]
|
||
if sin_firma:
|
||
sys.exit("estos binarios no pasan la comprobación de firma de Windows; no se empaqueta: " + ", ".join(sin_firma))
|
||
print(f"firmas: {len(binarios)} binarios, todos firmados")
|
||
exe = os.path.abspath(os.path.join(dest, "OrganizadorLabstream.exe"))
|
||
p = subprocess.run([exe, "-B", "-c", "import sqlite3, ssl, ctypes, hashlib, zipfile, http.server, urllib.request, winreg; "
|
||
"import reglas, firma, ventana, instalar; print(ventana.disponible('.')[0])"],
|
||
cwd=dest, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||
if p.returncode != 0 or p.stdout.strip() not in ("True", "False"):
|
||
sys.exit(f"el motor del paquete no arranca (código {p.returncode}): {p.stderr.strip() or p.stdout.strip()}")
|
||
p = subprocess.run([exe, "ayuda"], cwd=dest, capture_output=True, timeout=120, stdin=subprocess.DEVNULL)
|
||
if p.returncode != 0 or b"OrganiLAB" not in p.stdout:
|
||
sys.exit(f"el paquete no responde a «ayuda» (código {p.returncode}): {p.stderr.decode('utf-8', 'replace').strip()}")
|
||
for resto in ("__pycache__", "datos"): # lo que dejó el arranque de prueba
|
||
shutil.rmtree(os.path.join(dest, resto), ignore_errors=True)
|
||
print("arranque: el motor del paquete carga sus módulos y el programa responde")
|
||
|
||
# ── lo que acompaña al programa: datos de partida, instrucciones y los .bat de arrastrar ──
|
||
os.makedirs(os.path.join(dest, "datos"), exist_ok=True)
|
||
for f in ("config.json", "diccionario.json", "plantillas.json"):
|
||
src = os.path.join("datos", f)
|
||
if os.path.exists(src):
|
||
shutil.copy(src, os.path.join(dest, "datos", f))
|
||
# la config del paquete sale limpia: sin el recorrido ya visto, sin raíces de prueba y sin lo que es de este equipo
|
||
cfg_p = os.path.join(dest, "datos", "config.json")
|
||
if os.path.exists(cfg_p):
|
||
cfg = json.load(open(cfg_p, encoding="utf-8"))
|
||
for k in ("tour_visto", "instalacion"):
|
||
cfg.pop(k, None)
|
||
cfg["raices"] = [] # el paquete sale SIN carpetas: cada equipo añade las suyas y se escanean al añadirlas
|
||
json.dump(cfg, open(cfg_p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||
shutil.copy("README.md", dest)
|
||
BAT = ('@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\nif "%~1"=="" (\r\n echo Arrastra una carpeta de cliente encima de este archivo.\r\n pause\r\n exit /b\r\n)\r\n'
|
||
'"%~dp0OrganizadorLabstream.exe" {cmd} "%~1"\r\n')
|
||
with open(os.path.join(dest, "Organizar carpeta (arrastra aqui).bat"), "w", encoding="cp1252") as fh:
|
||
fh.write(BAT.format(cmd="asistente"))
|
||
with open(os.path.join(dest, "Apartar stock Envato viejo (arrastra aqui).bat"), "w", encoding="cp1252") as fh:
|
||
fh.write(BAT.format(cmd="stock"))
|
||
with open(os.path.join(dest, "LEEME.txt"), "w", encoding="utf-8") as fh:
|
||
fh.write(f"OrganiLAB · Organizador Labstream {VERSION}\n\n"
|
||
"ABRIRLO: doble clic en OrganizadorLabstream.exe. Se abre en su propia ventana, como cualquier programa de Windows.\n"
|
||
"La primera vez pregunta si lo instalas: se copia a tu carpeta de programas, con sus datos, y queda en el menú\n"
|
||
"Inicio y en el Escritorio. Desde entonces se abre desde ahí, y esta carpeta ya no hace falta: se puede borrar.\n"
|
||
"Se quita en Configuración › Aplicaciones. También: OrganizadorLabstream.exe instalar / desinstalar.\n\n"
|
||
"MANUAL: dentro del programa, arriba a la derecha, «Manual»: cada función, qué hace y cómo lo hace.\n\n"
|
||
"En el Explorador el .exe sale con el icono de Python: es el motor que lo mueve, firmado por la Python Software\n"
|
||
"Foundation, y por eso Windows lo deja abrir también con Smart App Control encendido. En Inicio, en el Escritorio\n"
|
||
"y en la barra de tareas sale con su icono.\n\n"
|
||
"ORDENAR UNA CARPETA SIN ABRIR EL PROGRAMA: arrastra la carpeta del cliente encima de «Organizar carpeta (arrastra aqui).bat»\n"
|
||
"(o del propio .exe). Se escanea con barra de progreso, sale el diagnóstico y el asistente pregunta paso a paso.\n"
|
||
"Por consola: OrganizadorLabstream.exe asistente \"D:\\Clientes\\Carpeta\" [--simular] [--si]\n"
|
||
"Más comandos: OrganizadorLabstream.exe ayuda\n\n"
|
||
"1. No hace falta instalar nada: Python y ffmpeg van dentro.\n"
|
||
"2. Todo ocurre en este equipo. El programa no sale a la red salvo que le pidas buscar una versión nueva.\n"
|
||
"3. Para convertir BRAW hace falta DaVinci Resolve Studio abierto en un proyecto.\n"
|
||
"4. La app NUNCA borra: lo que sobra va a _Para borrar y lo repetido a subcarpetas Duplicados, para que una persona lo revise.\n"
|
||
"5. Todo lo que aprende y la configuración queda en la carpeta datos\\ (llévala contigo si cambias de PC).\n")
|
||
with open(os.path.join(motor, "AVISOS.txt"), "w", encoding="utf-8") as fh:
|
||
fh.write("Lo que viaja en este paquete y no es de Labstream:\n\n"
|
||
f"- Python {sys.version.split()[0]} (OrganizadorLabstream.exe, {MOTOR}.dll y esta carpeta): Python Software Foundation.\n"
|
||
" Licencia en LICENCIA-Python.txt.\n"
|
||
"- WebView2Loader.dll: Microsoft Corporation. Se distribuye con el programa según la licencia del SDK de WebView2.\n"
|
||
"- vcruntime140*.dll: Microsoft Corporation (Visual C++ Redistributable).\n"
|
||
"- ffmpeg\\ (solo en el paquete completo): ver ffmpeg\\LICENSE.txt.\n")
|
||
|
||
# ── paquete APP (actualización): todo menos ffmpeg y datos ──
|
||
app_zip = f"OrganizadorLabstream-app-{VERSION}.zip"
|
||
with zipfile.ZipFile(app_zip, "w", zipfile.ZIP_DEFLATED) as z:
|
||
for raiz, dirs, files in os.walk(dest):
|
||
rel_raiz = os.path.relpath(raiz, dest)
|
||
primero = rel_raiz.split(os.sep)[0]
|
||
if primero in ("ffmpeg", "datos"):
|
||
continue
|
||
for f in files:
|
||
p = os.path.join(raiz, f)
|
||
z.write(p, "OrganizadorLabstream/" + os.path.relpath(p, dest).replace(os.sep, "/"))
|
||
|
||
# ── paquete COMPLETO (primera instalación): con ffmpeg ──
|
||
if ffbin:
|
||
os.makedirs(os.path.join(dest, "ffmpeg"), exist_ok=True)
|
||
for exe_ff in ("ffmpeg.exe", "ffprobe.exe"):
|
||
shutil.copy(os.path.join(ffbin, exe_ff), os.path.join(dest, "ffmpeg", exe_ff))
|
||
lic = os.path.join(os.path.dirname(ffbin), "LICENSE")
|
||
if os.path.exists(lic):
|
||
shutil.copy(lic, os.path.join(dest, "ffmpeg", "LICENSE.txt"))
|
||
for viejo in glob.glob("OrganizadorLabstream-portatil*.zip") + [z for z in glob.glob("OrganizadorLabstream-app-*.zip") if z != app_zip]:
|
||
os.remove(viejo)
|
||
full_zip = f"OrganizadorLabstream-portatil-{VERSION}.zip"
|
||
shutil.make_archive(full_zip[:-4], "zip", "dist", "OrganizadorLabstream")
|
||
|
||
|
||
def sha(p):
|
||
h = hashlib.sha256()
|
||
with open(p, "rb") as fh:
|
||
for b in iter(lambda: fh.read(1 << 20), b""):
|
||
h.update(b)
|
||
return h.hexdigest()
|
||
|
||
|
||
notas = os.environ.get("NOTAS", "")
|
||
manifiesto = {
|
||
"nombre": "OrganiLAB",
|
||
"version": VERSION,
|
||
"builtAt": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
||
"archivo": full_zip, "size": os.path.getsize(full_zip), "sha256": sha(full_zip),
|
||
"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)) and os.environ.get("SOLO_LOCAL") != "si":
|
||
previo = {}
|
||
try:
|
||
previo = json.load(open(ruta_os, encoding="utf-8"))
|
||
except Exception: # noqa
|
||
pass
|
||
if not notas:
|
||
manifiesto["notes"] = previo.get("notes", "") if previo.get("version") == VERSION else ""
|
||
json.dump(manifiesto, open(ruta_os, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||
print("manifiesto escrito en", ruta_os)
|
||
json.dump(manifiesto, open("manifiesto.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||
print(f"listo: {full_zip} ({os.path.getsize(full_zip) / 1e6:.0f} MB) · {app_zip} ({os.path.getsize(app_zip) / 1e6:.0f} MB)")
|
||
print("Sube los dos zips a storage/herramientas/ del NAS y despliega el OS.")
|