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>
Este commit está contenido en:
150
empaquetar.py
150
empaquetar.py
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
Construye la versión portátil y los paquetes de actualización.
|
||||
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)
|
||||
@@ -8,23 +8,44 @@ Resultado:
|
||||
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")):
|
||||
@@ -33,26 +54,101 @@ if not ffbin:
|
||||
ffbin = os.path.dirname(shutil.which("ffmpeg"))
|
||||
print("ffmpeg desde:", ffbin or "(no encontrado: el zip irá sin ffmpeg)")
|
||||
|
||||
for d in ("build", "dist"):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
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", "firma",
|
||||
"--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet",
|
||||
"--exclude-module", "tkinter", "organizador.py"])
|
||||
|
||||
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 ni raíces de prueba
|
||||
# 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"))
|
||||
cfg.pop("tour_visto", None)
|
||||
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)
|
||||
@@ -63,17 +159,29 @@ with open(os.path.join(dest, "Organizar carpeta (arrastra aqui).bat"), "w", enco
|
||||
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} (portátil)\n\n"
|
||||
"ORDENAR UNA CARPETA: arrastra la carpeta del cliente encima de «Organizar carpeta (arrastra aqui).bat»\n"
|
||||
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 quieres en el menú Inicio y en el Escritorio; desde entonces se abre desde ahí\n"
|
||||
"(y se quita en Configuración › Aplicaciones). También: OrganizadorLabstream.exe instalar / desinstalar.\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. Doble clic en OrganizadorLabstream.exe abre OrganiLAB en su propia ventana (sin navegador).\n"
|
||||
"2. No hace falta instalar nada: Python y ffmpeg van dentro.\n3. Para convertir BRAW hace falta DaVinci Resolve Studio abierto en un proyecto.\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"
|
||||
"6. Se actualiza solo desde Labstream OS (os.labstreamsas.com): avisa cuando hay versión nueva.\n"
|
||||
"Si Windows SmartScreen avisa, «Más información» → «Ejecutar de todas formas».\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"
|
||||
@@ -90,8 +198,8 @@ with zipfile.ZipFile(app_zip, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
# ── paquete COMPLETO (primera instalación): con ffmpeg ──
|
||||
if ffbin:
|
||||
os.makedirs(os.path.join(dest, "ffmpeg"), exist_ok=True)
|
||||
for exe in ("ffmpeg.exe", "ffprobe.exe"):
|
||||
shutil.copy(os.path.join(ffbin, exe), os.path.join(dest, "ffmpeg", exe))
|
||||
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"))
|
||||
@@ -131,7 +239,7 @@ else:
|
||||
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)):
|
||||
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"))
|
||||
|
||||
Referencia en una nueva incidencia
Block a user