diff --git a/OrganizadorLabstream.spec b/OrganizadorLabstream.spec deleted file mode 100644 index 497ca8b..0000000 --- a/OrganizadorLabstream.spec +++ /dev/null @@ -1,56 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_all - -datas = [] -binaries = [] -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') -datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] -tmp_ret = collect_all('pythonnet') -datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] - - -a = Analysis( - ['organizador.py'], - pathex=[], - binaries=binaries, - datas=datas, - hiddenimports=hiddenimports, - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=['tkinter'], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name='OrganizadorLabstream', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=['static/organilab.ico'], -) -coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='OrganizadorLabstream', -) diff --git a/README.md b/README.md index 70485e1..e26ff51 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,24 @@ # Organizador Labstream -App local (Python, sin dependencias) para **inventariar, mapear, ordenar y optimizar** el material de los -clientes de Labstream repartido por los discos del estudio. +Programa de Windows (Python, sin dependencias) para **inventariar, mapear, ordenar y optimizar** el material +de los clientes de Labstream repartido por los discos del estudio. Abre su propia ventana, se instala en el +menú Inicio y todo ocurre en este equipo. ``` -Iniciar.bat ← doble clic. Abre http://127.0.0.1:8765 en el navegador -organizador.py ← servidor + inventario + plan + cola de conversión +organizador.py ← inventario + plan + cola de conversión, y el servidor local con que habla la interfaz +ventana.py ← la ventana de Windows del programa (marco Win32 + WebView2, solo ctypes) +instalar.py ← accesos directos en Inicio y Escritorio, y la línea en Configuración › Aplicaciones +arranque.py ← cómo arranca el paquete: su .exe es el Python firmado, y esto le dice qué hacer reglas.py ← cómo reconoce cámaras, carpetas, basura y las plantillas de estructura resolve_bridge.py ← convierte BRAW a H.265 10 bits usando DaVinci Resolve Studio +actualizador.py ← busca versión nueva en Labstream OS (solo si se le pide) y la instala; firma.py la verifica static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Mapa · Convertidor · Respaldar · Historial · Ajustes) static/manrope.woff2 ← la tipografía, para no pedirle nada a nadie fuera de este equipo +WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl +empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips +probar.py ← 17 pruebas sin abrir nada · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad +Iniciar.bat ← desde el código, con consola (para ver lo que imprime) ``` Nunca borra nada. Todo lo que sobra va a `_Para borrar` dentro de la misma raíz y cada movimiento queda en la @@ -170,8 +178,10 @@ muestra en el mapa para decidirlo. ## 6. Uso (interfaz 1.6) -1. `Iniciar.bat` (o `py -3.12 organizador.py`). Requiere Python 3.10+, ffmpeg/ffprobe en el PATH y, para BRAW, - DaVinci Resolve Studio abierto con scripting externo en *Local*. +1. Se abre desde el menú Inicio o el Escritorio (o con doble clic en `OrganizadorLabstream.exe`, en la carpeta + del programa). El paquete trae Python y ffmpeg dentro; para BRAW hace falta DaVinci Resolve Studio abierto + con scripting externo en *Local*. Desde el código: `py -3.12 organizador.py`, con Python 3.10+ y + ffmpeg/ffprobe en el PATH. 2. Suelta la carpeta del cliente en la barra lateral (o pega la ruta). Se registra y se escanea sola. 3. La vista **Carpeta** es el recorrido: cinco pasos con estado (hecho, en curso, listo, pendiente, bloqueado) y un bloque «Siguiente» que dice qué toca y ofrece un solo botón principal. El **Asistente paso a paso** @@ -219,9 +229,14 @@ y nunca pisa un MP4 que ya exista; las raíces de unidad (`N:\`) ya se consultan ### Publicar una versión + py -3.12 probar.py && py -3.12 prueba_funcional.py && py -3.12 prueba_ventana.py py -3.12 empaquetar.py # construye dist\, los dos zips y manifiesto.json (NOTAS="..." para las notas) + py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream # el paquete, arrancado por su .exe bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS +`empaquetar.py` no deja salir un paquete con un binario sin firmar ni uno cuyo motor no arranque. Con +`SOLO_LOCAL=si` construye sin tocar el manifiesto del OS (para ensayar). + Cuando el OS sirve el manifiesto nuevo, cada copia instalada lo ve al abrirse (o al pulsar «Actualizar ahora»), descarga el zip «app» (15 MB), comprueba el sha256 y se sustituye sola sin tocar `datos\` ni `ffmpeg\`. @@ -590,6 +605,88 @@ septiembre y ese mismo día aplicó dos lotes sobre ella). Pero la pregunta dest los valores de fábrica y se guardaba encima, y las carpetas registradas y los clientes desaparecían sin decir nada. Ahora se aparta en `config.json.roto-` y se avisa en consola. Con prueba. +3.0.0, OrganiLAB pasa a ser un programa de Windows. Munka pidió que dejara de ser una aplicación web. Ventana +propia tenía desde hacía tiempo, pero prestada (pywebview sobre .NET), y lo que se repartía era un .exe de +PyInstaller sin firma que Smart App Control le bloqueaba en su propio equipo (15 y 25 de septiembre); desde +entonces lo abría por un acceso directo a Python. Cambian tres cosas: + +**La ventana es de Windows y es nuestra** (`ventana.py`, solo ctypes). Un marco Win32 de verdad —clase +registrada, bucle de mensajes, icono, barra de título del color de la interfaz— y dentro WebView2, el +componente con que Windows pinta HTML, manejado por COM directamente. Fuera pywebview, pythonnet y +clr_loader, con sus tres binarios sin firmar (el día que Smart App Control parase uno, el programa acababa +en el navegador sin decir nada). Lo único que viaja y no es de Python es `WebView2Loader.dll`, de Microsoft +y firmada. Los IID y las posiciones de las tablas virtuales no están escritos de memoria: salen del +ensamblado `Microsoft.Web.WebView2.Core.dll`, que declara cada interfaz con sus métodos en orden. Con la +ventana vienen cosas que una página no puede tener: + +- identidad en la barra de tareas (`Labstream.OrganiLAB`): es OrganiLAB, no «Python», y si se ancla vuelve + a abrir el programa; +- una sola copia abierta por carpeta de datos (un candado con nombre): abrirlo otra vez trae la ventana al + frente. Antes la segunda copia abría el navegador y, peor, daba por huérfanas las conversiones que la + primera tenía en marcha (`recuperar_huerfanas` corría antes de mirar si ya había otra); +- si el puerto 8765 lo tiene otro programa, toma uno libre en vez de abrir la página de ese otro; +- el selector de carpetas es el de Windows, modal sobre la ventana (antes, un PowerShell aparte que tardaba + un par de segundos y podía salir por detrás); +- las carpetas soltadas llegan con su ruta completa (`postMessageWithAdditionalObjects` → `ICoreWebView2File`); +- avance en el botón de la barra de tareas, y un parpadeo al terminar algo largo si se está en otra cosa; +- recuerda dónde y de qué tamaño se dejó (`datos\ventana.json`); +- cerrar con trabajo en marcha avisa de qué se está haciendo; la ventana se quita de la vista, el trabajo + sigue y el programa se cierra solo al acabar (abrirlo otra vez devuelve esa misma ventana); +- nada de navegador: sin menú de «Atrás / Recargar / Inspeccionar» (solo sale al escribir o con texto + seleccionado), sin F5, sin zoom, sin barra de estado, sin página de error de Edge y sin permisos; un + enlace hacia fuera se abre en el navegador del equipo, no dentro. + +Si un Windows no tiene WebView2 (un Windows 10 sin Edge al día), la interfaz se abre en el navegador como +antes y el programa se va solo cuando esa página lleva tres minutos sin preguntar. Y si el motor se cae a +media sesión, la ventana lo monta de nuevo sola (probado matándolo a propósito). + +**El .exe vuelve a abrir con Smart App Control** (`empaquetar.py`, `arranque.py`). El paquete ya no lleva +ningún binario propio. `OrganizadorLabstream.exe` es `pythonw.exe` —el intérprete, firmado por la Python +Software Foundation— con otro nombre, junto a su `python312.dll`, la librería estándar en un zip y los +módulos de extensión, todos firmados. Python importa `sitecustomize` al arrancar, y ese módulo +(`arranque.py`) es quien lanza el programa cuando al .exe no se le pasa un guion; con un `.py`, `-c` o `-m` +sigue siendo un intérprete normal, y así corre el puente de Resolve. Al empaquetar se le pregunta a Windows +por la firma de cada binario (`WinVerifyTrust`) y se arranca el motor del paquete: si algo no está firmado +o no arranca, no hay paquete. Probado aquí con Smart App Control bloqueando y todos los archivos marcados +como bajados de internet: abre con doble clic. El paquete de actualización baja de 15 a 12 MB. Las copias +de la 2.x dan el salto con su actualizador de siempre (el zip trae un `OrganizadorLabstream.exe`, que es lo +que exige); la carpeta `_internal` de PyInstaller que queda atrás la borra la 3.0 al arrancar. Ensayado +sobre una copia de la instalación de Munka con sus datos: abre, conserva su carpeta registrada, sus 19 +clientes y sus ajustes. Y la actualización automática, de punta a punta con el formato nuevo: una 3.0.1 de +ensayo, firmada con la clave de verdad y servida en local, se descarga, se verifica, cierra el programa, +copia y lo vuelve a abrir en la versión nueva, con el .exe todavía firmado. + +Lo que no tiene arreglo sin un certificado de firma propio: en el Explorador el .exe sale con el icono y el +nombre de Python (en Inicio, en el Escritorio y en la barra de tareas sale con los suyos), y los dos `.bat` +de arrastrar, si el zip viene de internet, Smart App Control los para; arrastrar la carpeta sobre el propio +.exe hace lo mismo. + +**Se instala como un programa** (`instalar.py`). Acceso directo en el menú Inicio y en el Escritorio, con +la misma identidad que la ventana para que el acceso anclado y la ventana abierta sean un solo botón, y su +línea en Configuración › Aplicaciones. No copia nada ni pide permisos de administrador: da de alta el +programa donde está. La primera vez que se abre un paquete en un equipo lo ofrece, una sola vez; también +`OrganizadorLabstream.exe instalar` / `desinstalar`. Quitarlo borra los accesos y la línea, nunca la carpeta +ni los datos. Tras una actualización pone al día la versión que enseña Windows sin reponer un acceso que +alguien quitó. + +Lo que sigue igual por dentro: las pantallas son las mismas (HTML) y hablan con el programa por un servidor +que solo escucha en 127.0.0.1 y rechaza lo que no venga de su propia página. Rehacer las siete pantallas +con controles clásicos de Windows sería otro programa. + +**La red.** El motor arranca con las tareas de fondo apagadas, sin resolver ningún nombre de fuera de este +equipo y con un proxy que no existe: la interfaz no puede cargar nada externo ni por descuido. Medido un +minuto, proceso por proceso: el programa, cero conexiones hacia fuera; el motor de Edge, una, al arrancar, +a Microsoft 365 (`substrate.office.com`). Es su gestor de identidad (`oneauth.dll`) consultando el perfil +de la cuenta de Windows: lo hace el componente de Windows en cualquier programa que lo use —y lo hacía ya +con la ventana anterior—, no pasa por el proxy y ninguno de los interruptores probados (siete +combinaciones) lo apaga. OrganiLAB no manda ni recibe nada por ahí. De paso: usado por consola, el paquete +consultaba si había versión nueva aunque las actualizaciones automáticas estuvieran apagadas; ya no. + +Pruebas: `probar.py` sube de 10 a 17 (accesos directos, alta y baja en Windows contra una clave de ensayo, +una sola copia, puerto ocupado, Windows sin WebView2, las tablas de la ventana, el arranque del paquete); +`prueba_funcional.py` sigue en 26 de 26; y `prueba_ventana.py`, nueva, abre la ventana de verdad y +comprueba 17 cosas desde el código y 8 más arrancando el paquete por su .exe. + 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/WebView2Loader.dll b/WebView2Loader.dll new file mode 100644 index 0000000..11f58ff Binary files /dev/null and b/WebView2Loader.dll differ diff --git a/actualizador.py b/actualizador.py index ee36db8..592615d 100644 --- a/actualizador.py +++ b/actualizador.py @@ -167,7 +167,7 @@ def _descargar_y_aplicar(relanzar_args): return False if not O.CONGELADO: ESTADO["estado"] = "error" - ESTADO["error"] = "la actualización automática solo funciona con el .exe empaquetado" + 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: diff --git a/arranque.py b/arranque.py new file mode 100644 index 0000000..78a2bcd --- /dev/null +++ b/arranque.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +r""" +El arranque del paquete. Dentro del paquete este archivo se llama runtime\sitecustomize.py, que es el +módulo que Python importa él solo cada vez que arranca. + +OrganizadorLabstream.exe es pythonw.exe —el intérprete de Python, firmado por la Python Software +Foundation— con otro nombre. Por eso Windows lo deja abrir con Smart App Control encendido, que es +justo lo que no pasaba con el .exe de PyInstaller: aquel era un binario propio, sin firma, y Windows lo +bloqueaba. Aquí no hay ningún binario propio: el motor es el de Python y el programa es su código. + +Un intérprete al que no se le pasa nada no hace nada; este módulo es quien le dice qué hacer: + + sin argumentos (doble clic) abre OrganiLAB + con una carpeta o un comando el programa por consola (asistente, stock, instalar…) + con un guion .py, -c o -m intérprete normal: lo usan el puente de Resolve y las pruebas +""" +import os +import sys + + +def _avisar(base, traza): + """Sin consola, un fallo al arrancar sería una ventana que no aparece y nada más: se deja escrito y se dice.""" + ruta = os.path.join(base, "datos", "error-de-arranque.txt") + try: + os.makedirs(os.path.dirname(ruta), exist_ok=True) + with open(ruta, "w", encoding="utf-8") as fh: + fh.write(traza) + except OSError: + ruta = "" + try: + import ctypes + ctypes.windll.user32.MessageBoxW(None, "OrganiLAB no pudo arrancar.\n\n" + traza.strip().splitlines()[-1][:300] + + (f"\n\nEl detalle quedó en:\n{ruta}" if ruta else ""), "OrganiLAB", 0x30) + except Exception: # noqa + pass + + +def _arrancar(): + a0 = sys.argv[0] if sys.argv else "" + if a0 in ("-c", "-m") or (a0.lower().endswith((".py", ".pyw")) and os.path.isfile(a0)): + return # lo están usando de intérprete + base = os.path.dirname(os.path.abspath(sys.executable)) + if not os.path.isfile(os.path.join(base, "organizador.py")): + return # este motor no está dentro de un paquete de OrganiLAB + sys.argv = [sys.executable] + [a for a in sys.argv if a] + codigo = 0 + try: + import organizador + organizador.main() + except SystemExit as ex: + codigo = ex.code if isinstance(ex.code, int) else (0 if ex.code is None else 1) + except KeyboardInterrupt: + codigo = 130 + except BaseException: # noqa + import traceback + codigo = 1 + _avisar(base, traceback.format_exc()) + for salida in (sys.stdout, sys.stderr): + try: + salida.flush() + except Exception: # noqa + pass + # Python sigue arrancando por debajo de este módulo: si se volviera, pasaría a su modo interactivo. + # Se sale aquí, sin más ceremonia; para entonces el programa ya cerró lo que tenía abierto. + os._exit(codigo) + + +_arrancar() diff --git a/empaquetar.py b/empaquetar.py index e723e7b..3eb7742 100644 --- a/empaquetar.py +++ b/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-.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")) diff --git a/instalar.py b/instalar.py new file mode 100644 index 0000000..4be9a56 --- /dev/null +++ b/instalar.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +r""" +OrganiLAB como un programa más de Windows: su acceso directo en el menú Inicio y en el Escritorio, y su +línea en Configuración › Aplicaciones para quitarlo. + +No copia nada: el programa se queda en la carpeta donde está y esto solo lo da de alta, todo dentro del +usuario y sin pedir permisos de administrador. Quitarlo borra los accesos directos y la línea de +Aplicaciones; la carpeta del programa y los datos no se tocan. + +Los accesos directos llevan la misma identidad que la ventana (ventana.IDENTIDAD). Así el acceso anclado +a la barra de tareas y la ventana abierta son, para Windows, la misma cosa: un solo botón, con su icono. + + OrganizadorLabstream.exe instalar (--si para no enseñar ningún mensaje) + OrganizadorLabstream.exe desinstalar +""" +import os +import sys +import ctypes +import winreg +from ctypes import wintypes as W + +import ventana as V + +NOMBRE = "OrganiLAB" +DESCRIPCION = "OrganiLAB · ordena y aligera el material de video" +CLAVE = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB" +EN_INICIO = "{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}" # la carpeta Programas del menú Inicio +EN_ESCRITORIO = "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}" # el Escritorio (puede estar en OneDrive) +CLSID_ACCESO, IID_ACCESO = "{00021401-0000-0000-C000-000000000046}", "{000214F9-0000-0000-C000-000000000046}" +IID_EN_DISCO = "{0000010B-0000-0000-C000-000000000046}" # IPersistFile + + +def orden(base): + """(ejecutable, argumentos) con que se abre el programa que vive en `base`. En el paquete es su propio + .exe; corriendo desde el código, el Python sin consola de este equipo.""" + exe = os.path.join(base, "OrganizadorLabstream.exe") + if os.path.isfile(exe): + return exe, "" + pyw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe") + return (pyw if os.path.isfile(pyw) else sys.executable), f'"{os.path.join(base, "organizador.py")}"' + + +def carpeta_de(cual): + """Dónde tiene este usuario el menú Inicio o el Escritorio.""" + D = V._dll() + f = D.s.SHGetKnownFolderPath + f.argtypes, f.restype = [ctypes.POINTER(V.GUID), W.DWORD, W.HANDLE, V.PPVOID], ctypes.c_long + p = ctypes.c_void_p() + if f(ctypes.byref(V._guid(cual)), 0, None, ctypes.byref(p)) < 0 or not p.value: + raise OSError("Windows no dijo dónde está esa carpeta") + try: + return ctypes.wstring_at(p.value) + finally: + D.o.CoTaskMemFree(p) + + +def _enlace(): + D = V._dll() + D.o.CoInitializeEx(None, 2) + p = ctypes.c_void_p() + hr = D.o.CoCreateInstance(ctypes.byref(V._guid(CLSID_ACCESO)), None, 1, ctypes.byref(V._guid(IID_ACCESO)), ctypes.byref(p)) + if hr < 0 or not p.value: + raise OSError(f"Windows no dejó preparar el acceso directo (0x{hr & 0xFFFFFFFF:08X})") + return p.value + + +def acceso_directo(ruta, exe, args, carpeta, icono, descripcion=DESCRIPCION): + """Escribe un acceso directo (.lnk) con la identidad de OrganiLAB.""" + enlace = _enlace() + try: + V._m(enlace, 20, exe, tipos=(W.LPCWSTR,)) # SetPath + V._m(enlace, 11, args, tipos=(W.LPCWSTR,)) # SetArguments + V._m(enlace, 9, carpeta, tipos=(W.LPCWSTR,)) # SetWorkingDirectory + V._m(enlace, 17, icono, 0, tipos=(W.LPCWSTR, ctypes.c_int)) # SetIconLocation + V._m(enlace, 7, descripcion, tipos=(W.LPCWSTR,)) # SetDescription + props = V._qi(enlace, V.IID_PROPIEDADES) + if props: + clave = V.PROPERTYKEY(V._guid(V.FMT_APPUSERMODEL), 5) + bufer = ctypes.create_unicode_buffer(V.IDENTIDAD) + pv = V.PROPVARIANT(31, 0, 0, 0, ctypes.cast(bufer, ctypes.c_void_p).value, None) + V._m(props, 6, ctypes.byref(clave), ctypes.byref(pv), tipos=(ctypes.POINTER(V.PROPERTYKEY), ctypes.POINTER(V.PROPVARIANT))) + V._m(props, 7) + V._soltar(props) + disco = V._qi(enlace, IID_EN_DISCO) + if not disco: + raise OSError("Windows no dejó guardar el acceso directo") + try: + hr = V._m(disco, 6, ruta, 1, tipos=(W.LPCWSTR, W.BOOL)) # Save + if hr < 0: + raise OSError(f"no se pudo escribir {ruta} (0x{hr & 0xFFFFFFFF:08X})") + finally: + V._soltar(disco) + finally: + V._soltar(enlace) + return ruta + + +def leer_acceso(ruta): + """Lo que dice un acceso directo: a qué apunta, con qué argumentos, desde dónde y con qué identidad.""" + enlace = _enlace() + try: + disco = V._qi(enlace, IID_EN_DISCO) + try: + if V._m(disco, 5, ruta, 0, tipos=(W.LPCWSTR, W.DWORD)) < 0: # Load + return None + finally: + V._soltar(disco) + b = ctypes.create_unicode_buffer(1024) + out = {} + V._m(enlace, 3, b, 1024, None, 0, tipos=(W.LPWSTR, ctypes.c_int, ctypes.c_void_p, W.DWORD)) # GetPath + out["exe"] = b.value + V._m(enlace, 10, b, 1024, tipos=(W.LPWSTR, ctypes.c_int)) # GetArguments + out["args"] = b.value + V._m(enlace, 8, b, 1024, tipos=(W.LPWSTR, ctypes.c_int)) # GetWorkingDirectory + out["carpeta"] = b.value + indice = ctypes.c_int() + V._m(enlace, 16, b, 1024, ctypes.byref(indice), tipos=(W.LPWSTR, ctypes.c_int, ctypes.POINTER(ctypes.c_int))) # GetIconLocation + out["icono"] = b.value + out["identidad"] = "" + props = V._qi(enlace, V.IID_PROPIEDADES) + if props: + clave = V.PROPERTYKEY(V._guid(V.FMT_APPUSERMODEL), 5) + pv = V.PROPVARIANT() + if V._m(props, 5, ctypes.byref(clave), ctypes.byref(pv), tipos=(ctypes.POINTER(V.PROPERTYKEY), ctypes.POINTER(V.PROPVARIANT))) >= 0 and pv.vt == 31 and pv.valor: + out["identidad"] = ctypes.wstring_at(pv.valor) + V._dll().o.CoTaskMemFree(pv.valor) + V._soltar(props) + return out + finally: + V._soltar(enlace) + + +def _accesos(): + """Las dos rutas donde van los accesos directos: (menú Inicio, Escritorio).""" + return os.path.join(carpeta_de(EN_INICIO), NOMBRE + ".lnk"), os.path.join(carpeta_de(EN_ESCRITORIO), NOMBRE + ".lnk") + + +def _peso_kb(base): + total = 0 + for raiz, dirs, files in os.walk(base): + dirs[:] = [d for d in dirs if not (raiz == base and d in ("datos", "actualizacion", "__pycache__"))] + for f in files: + try: + total += os.path.getsize(os.path.join(raiz, f)) + except OSError: + pass + return total // 1024 + + +def instalado(): + """Lo que Windows tiene apuntado de OrganiLAB ({"base", "version"}), o None si no está dado de alta.""" + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, CLAVE) as k: + return {"base": winreg.QueryValueEx(k, "InstallLocation")[0], "version": winreg.QueryValueEx(k, "DisplayVersion")[0]} + except OSError: + return None + + +def instalado_aqui(base): + i = instalado() + return bool(i) and os.path.normcase(os.path.normpath(i["base"])) == os.path.normcase(os.path.normpath(base)) + + +def instalar(base, version, solo_los_que_hay=False): + """Da de alta el programa de `base`. Con `solo_los_que_hay` no repone un acceso directo que alguien + quitó a propósito: solo pone al día los que siguen ahí (es lo que se hace tras cada actualización).""" + exe, args = orden(base) + icono = os.path.join(base, "static", "organilab.ico") + hechos = [] + for ruta in _accesos(): + if solo_los_que_hay and not os.path.exists(ruta): + continue + hechos.append(acceso_directo(ruta, exe, args, base, icono)) + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, CLAVE) as k: + for nombre, valor in (("DisplayName", NOMBRE), ("DisplayVersion", version), ("Publisher", "Labstream"), + ("DisplayIcon", icono), ("InstallLocation", base), + ("UninstallString", f'"{exe}" {args} desinstalar'.replace(" ", " "))): + winreg.SetValueEx(k, nombre, 0, winreg.REG_SZ, valor) + for nombre, valor in (("NoModify", 1), ("NoRepair", 1), ("EstimatedSize", _peso_kb(base))): + winreg.SetValueEx(k, nombre, 0, winreg.REG_DWORD, valor) + return {"base": base, "accesos": hechos, "orden": f'"{exe}" {args}'.strip()} + + +def al_dia(base, version): + """Tras una actualización: si este programa está dado de alta, Windows tiene que enseñar la versión + nueva y los accesos directos tienen que apuntar a lo que ahora lo abre.""" + i = instalado() + if not i or not instalado_aqui(base): + return False + exe, args = orden(base) + viejos = [a for a in map(leer_acceso, (r for r in _accesos() if os.path.exists(r))) if a] + if i["version"] == version and all(os.path.normcase(a["exe"]) == os.path.normcase(exe) and a["args"] == args for a in viejos): + return False + instalar(base, version, solo_los_que_hay=True) + return True + + +def desinstalar(base=None): + """Quita los accesos directos y la línea de Aplicaciones. No borra ni el programa ni los datos.""" + quitados = [] + for ruta in _accesos(): + if os.path.exists(ruta): + a = leer_acceso(ruta) or {} + # solo se quita el acceso que apunta a esta copia: otro OrganiLAB en otra carpeta conserva el suyo + if base and a.get("carpeta") and os.path.normcase(os.path.normpath(a["carpeta"])) != os.path.normcase(os.path.normpath(base)): + continue + os.remove(ruta) + quitados.append(ruta) + if not base or instalado_aqui(base): + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, CLAVE) + except OSError: + pass + return {"accesos": quitados} + + +def principal(que, base, version, silencioso=False): + """Los comandos «instalar» y «desinstalar». Hablan con cuadros de mensaje: no hace falta consola.""" + u = V._dll().u + + def decir(texto, botones=V.MB_OK | V.MB_ICONINFORMATION): + return V.IDOK if silencioso else u.MessageBoxW(None, texto, NOMBRE, botones) + try: + if que == "instalar": + instalar(base, version) + decir("OrganiLAB ya está en el menú Inicio y en el Escritorio.\n\n" + "Para quitarlo: Configuración › Aplicaciones › Aplicaciones instaladas.") + return 0 + if not silencioso and decir("¿Quitar OrganiLAB del menú Inicio, del Escritorio y de la lista de aplicaciones?\n\n" + "La carpeta del programa y tus datos (inventario, historial y ajustes) no se borran.", + V.MB_YESNO | V.MB_ICONQUESTION) != V.IDYES: + return 1 + desinstalar(base) + decir(f"Quitado. La carpeta del programa sigue en:\n\n{base}\n\nSi ya no la quieres, bórrala a mano.") + return 0 + except Exception as ex: # noqa + decir(f"No se pudo: {ex}", V.MB_OK | V.MB_ICONWARNING) + return 1 diff --git a/manifiesto.json b/manifiesto.json index 215d4f8..77ed66e 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "2.6.2", - "builtAt": "2026-09-25T13:05:26.000Z", - "archivo": "OrganizadorLabstream-portatil-2.6.2.zip", - "size": 198766962, - "sha256": "a9fc3a1ad72a0473cda1c163400ff6ef542105c04075da823cb2c75344754006", + "version": "3.0.0", + "builtAt": "2026-10-04T14:50:25.000Z", + "archivo": "OrganizadorLabstream-portatil-3.0.0.zip", + "size": 195378394, + "sha256": "98c43f1f06865f233e98d4b4b8f2cae82e1386def7b38673ce5a4cd01bc850dd", "app": { - "archivo": "OrganizadorLabstream-app-2.6.2.zip", - "size": 14947203, - "sha256": "97573e047f879249fb7770db9ba4a480b0341cea889e56c0a368858e2d3ca38b", - "url": "/api/herramientas/OrganizadorLabstream-app-2.6.2.zip" + "archivo": "OrganizadorLabstream-app-3.0.0.zip", + "size": 11565815, + "sha256": "09d9024a41370bdd07dbbd3fe1110822c0f51ce4987f62cf9dd185ad9aa3e82e", + "url": "/api/herramientas/OrganizadorLabstream-app-3.0.0.zip" }, - "notes": "", + "notes": "OrganiLAB pasa a ser un programa de Windows: ventana propia, se instala en el menú Inicio y el Escritorio, y el .exe vuelve a abrir con Smart App Control encendido.", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "886666421a2acccc37e09fcf52c8275ce1f5a7f524bc359e1d0b93a993fe10b59394b8996964a3733c2f710d871e3426eb584e13071800d92ce24fd22eb4a8f007ffb36055eb0bf72ed6cd40791c360eef9b922bde682f97e881647b31c371dd0c1112da4d4bc7e6bcb456220726e7c2736294c538a6c586ac5b09241ece81909b01843090f3044f93d8116df26aeccfca12c4aad3f2cb131397504a6a1558bc21f5113b064438b9fbdad52e1a4a7d8844af523b5986da66d644d377c7b3bc8f6cf6d55cc925db9652d366944ef544a0ef083dbd92fcd8eefcc0f988c04e097bb28adf6de063dd1a51f7e4fc543b3be6a505f2d254394b8cef0d4672397058d2" + "valor": "6456976cf21259a5ae91b5ba5cc8074b552cfec0a389f8b9c89c7af962841cdb523f8f39b7143281d7d965079ee0471fc8b7f14c79636d2cc443315131a3fc53ec6a22ff87b1c49d806e4a795c2365b83f8236a523a1349730b90300eff68cc84ad32f58e663798e77301024f870e094c1fa112b994d55f719c1cdf8c22b0375f6eb24276bfa9837d755ef07c71e9b701df073fd4c94cf3ddccc6e5a2203e48458398beb4f38d2d68ebab2810a0ac56add4b9294321cb4eb3083f1e2d717bd2b2a6b638cadb8140e48f4d46e3e0a13686b462477bb8e9a3923f7a178c5e326f65a8ae90ff68949ac6a5bd2cf5758a53dd79779c79b4f67414f0251aae3f9566e" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 125b0a5..bc1bf3e 100644 --- a/organizador.py +++ b/organizador.py @@ -2,10 +2,12 @@ """ Organizador Labstream — inventario, mapa, orden y optimización del material. -Solo librería estándar de Python (3.10+). Arranca un servidor local y abre la -interfaz en el navegador: +Solo librería estándar de Python (3.10+). Es un programa de Windows: abre su +propia ventana (ventana.py) y la interfaz habla con este módulo por un servidor +que solo escucha en este equipo. - py -3.12 organizador.py (o doble clic en Iniciar.bat) + OrganizadorLabstream.exe (el paquete: lo arma empaquetar.py) + py -3.12 organizador.py (desde el código) Nunca borra nada: lo que sobra va a _Para borrar dentro de la misma raíz y cada movimiento queda en datos/bitacora.jsonl para poder deshacerlo. @@ -37,11 +39,14 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "2.6.2" +VERSION = "3.0.0" -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 -BASE = os.path.dirname(os.path.abspath(sys.executable if CONGELADO else __file__)) +BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) +# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es +# el intérprete de Python tal como lo firma la Python Software Foundation, con otro nombre (arranque.py +# cuenta cómo). Hasta la 2.x era un .exe de PyInstaller, sin firma, y Smart App Control lo bloqueaba. +CONGELADO = bool(getattr(sys, "frozen", False)) or os.path.normcase(os.path.dirname(os.path.abspath(sys.executable))) == os.path.normcase(BASE) +SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras DATOS = os.path.join(BASE, "datos") os.makedirs(DATOS, exist_ok=True) DB_PATH = os.path.join(DATOS, "inventario.sqlite") @@ -2239,11 +2244,8 @@ def correr_resolve(trabajos): lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json") with open(lote, "w", encoding="utf-8") as fh: json.dump(jobs, fh, ensure_ascii=False) - if CONGELADO and not o.get("python_resolve"): - cmd = [sys.executable, "--puente", lote] # el mismo .exe hace de puente - else: - py = o.get("python_resolve") or sys.executable - cmd = [py, os.path.join(BASE, "resolve_bridge.py"), lote] + # el puente corre con el mismo motor que el programa (en el paquete, su propio .exe haciendo de intérprete) + cmd = [o.get("python_resolve") or sys.executable, os.path.join(BASE, "resolve_bridge.py"), lote] try: p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) except OSError as ex: @@ -2889,13 +2891,20 @@ def _clave(ruta): _ELIGIENDO = threading.Lock() +VENTANA = None # la ventana del programa (ventana.Ventana) mientras está abierta +ULTIMA_PETICION = [0.0] # cuándo preguntó algo la interfaz por última vez def elegir_carpeta(): - """Abre el selector de carpetas de Windows (en un PowerShell aparte, para no bloquear el servidor) y devuelve la ruta o None.""" + """Abre el selector de carpetas de Windows y devuelve la ruta o None. Con la ventana del programa abierta + es el suyo, modal sobre ella; si la interfaz va en el navegador, uno aparte en PowerShell.""" if not _ELIGIENDO.acquire(blocking=False): raise ValueError("ya hay un selector de carpetas abierto") try: + v = VENTANA + if v is not None: + ruta = v.elegir_carpeta("Elige la carpeta del cliente") + return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None ps = ("Add-Type -AssemblyName System.Windows.Forms; " "$d = New-Object System.Windows.Forms.FolderBrowserDialog; " "$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; " @@ -3777,6 +3786,7 @@ class Manejador(BaseHTTPRequestHandler): return True def do_GET(self): + ULTIMA_PETICION[0] = time.time() if self.path.startswith("/api/") and not self._es_local(): return self._json({"error": "petición no permitida"}, 403) u = urlparse(self.path) @@ -3917,6 +3927,7 @@ class Manejador(BaseHTTPRequestHandler): return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500) def do_POST(self): + ULTIMA_PETICION[0] = time.time() u = urlparse(self.path) ruta = u.path if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"): @@ -4120,7 +4131,8 @@ class Manejador(BaseHTTPRequestHandler): AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando - OrganizadorLabstream.exe abre la interfaz en el navegador + OrganizadorLabstream.exe abre OrganiLAB en su ventana + OrganizadorLabstream.exe instalar lo pone en el menú Inicio y en el Escritorio (desinstalar lo quita) OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar) @@ -4344,20 +4356,20 @@ def _asegurar_consola(): SOLTADOS = {"ts": 0, "rutas": []} +SESION_PATH = os.path.join(DATOS, "sesion.json") # qué copia tiene abiertos estos datos, y en qué puerto atiende +_CANDADO = [] # el de «una sola copia abierta»; vive lo que el proceso +SILENCIO_NAVEGADOR = 180 # segundos sin que la página pregunte para dar el navegador por cerrado -def _al_soltar(e): - try: - rutas = [f.get("pywebviewFullPath") for f in (e.get("dataTransfer") or {}).get("files", []) if f.get("pywebviewFullPath")] - except Exception: # noqa - rutas = [] +def _al_soltar(rutas): + """Lo que se acaba de soltar en la ventana, con su ruta completa: la ventana la sabe, la interfaz no.""" if rutas: SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas]) def soltado(nombre, espera=2.5): - """Ruta completa de lo que se acaba de soltar en la ventana (llega por el puente WebView2). Espera un poco - porque el drop del navegador y el aviso del puente no llegan en el mismo orden.""" + """Ruta completa de lo que se acaba de soltar en la ventana. Espera un poco porque el aviso de la ventana + y la pregunta de la interfaz no llegan siempre en el mismo orden.""" fin = time.time() + espera while time.time() < fin: if time.time() - SOLTADOS["ts"] < 8: @@ -4368,46 +4380,181 @@ def soltado(nombre, espera=2.5): return None -def _ventana(url): - """Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador.""" - try: - import webview - except Exception: # noqa - webbrowser.open(url) - return False - try: - v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff") +def en_marcha(): + """Qué está haciendo el programa ahora mismo, dicho en llano, o "" si no hace nada. Mientras haya algo, + cerrar la ventana no lo corta.""" + que = actualizador.tarea_en_marcha() + if not que and APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]: + que = "se están moviendo archivos" + return que - def _al_cargar(): - # WebView2 entrega la ruta completa de lo que se suelta solo por este puente (el navegador no la da): - # se guarda y la interfaz la pide en /api/soltado en cuanto ve el drop. - try: - v.dom.document.events.drop += _al_soltar - except Exception as ex: # noqa - print("sin puente de arrastre:", ex) - v.events.loaded += _al_cargar - webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana")) + +def avance(): + """Cuánto lleva lo que está en marcha: de 0 a 1, -1 si no se puede saber y None si no hay nada. Es lo que + se ve en el botón de la barra de tareas.""" + if COLA["activa"]: + try: + con = db() + espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0] + t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None + con.close() + except sqlite3.Error: + return -1 + total = COLA["hechos"] + (1 if t else 0) + espera + return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1 + if RESPALDO.get("estado") == "corriendo": + return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1 + if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]: + return APLICANDO["hechas"] / APLICANDO["total"] + if asistente.ESTADO.get("activo"): + return asistente.ESTADO["pct"] / 100 if asistente.ESTADO.get("total") else -1 + for a in list(ANALISIS.values()): + if (a or {}).get("estado") == "corriendo": + return a["hechos"] / a["total"] if a.get("total") else -1 + if any((p or {}).get("estado") == "corriendo" for p in list(PROGRESO.values())): + return -1 + if actualizador.ESTADO.get("estado") == "descargando": + return (actualizador.ESTADO.get("pct") or 0) / 100 + return None + + +def _marca(): + """Un número que identifica esta instalación por su carpeta de datos.""" + return (zlib.crc32(os.path.normcase(os.path.abspath(DATOS)).encode("utf-8")) & 0x7FFFFFFF) or 1 + + +def _soy_la_unica(): + """False si ya hay otra copia del programa abierta sobre estos mismos datos: dos a la vez se pisarían el + inventario, y la segunda daría por huérfanas las conversiones que la primera tiene en marcha.""" + if os.name != "nt": return True - except Exception as ex: # noqa - print("Sin WebView2; abro el navegador:", ex) - webbrowser.open(url) + k = ctypes.WinDLL("kernel32", use_last_error=True) + k.CreateMutexW.argtypes, k.CreateMutexW.restype = [ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p], ctypes.c_void_p + h = k.CreateMutexW(None, False, f"OrganiLAB-{_marca():08x}") + if h and ctypes.get_last_error() == 183: # ya existía return False + _CANDADO.append(h) + return True + + +def _limpiar_restos(): + """Lo que dejó el .exe de PyInstaller de las versiones hasta la 2.x: su carpeta _internal, que ya no usa + nadie (el motor de ahora va en la carpeta runtime).""" + viejo = os.path.join(BASE, "_internal") + if CONGELADO and not getattr(sys, "frozen", False) and os.path.isfile(os.path.join(viejo, "base_library.zip")): + shutil.rmtree(viejo, ignore_errors=True) + + +def _vigilar(v, fin): + """Mientras la ventana existe: lleva el avance al botón de la barra de tareas, avisa cuando termina algo + largo y, si la ventana se cerró con trabajo pendiente, cierra el programa en cuanto acaba.""" + antes, desde = None, 0.0 + while not fin.wait(1.0): + try: + f = avance() + except Exception: # noqa + f = None + if f is None: + if antes is not None: + v.progreso(None) + if time.time() - desde > 20 and not v.oculta: + v.avisar() + if v.oculta and not en_marcha(): + v.cerrar() + else: + if antes is None: + desde = time.time() + if not v.oculta: + v.progreso(f) + antes = f + + +def _recien_abierta(v): + """Con la interfaz ya a la vista: si el programa está dado de alta en Windows, pone al día su versión y + sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece darlo de alta.""" + try: + import ventana + import instalar + if instalar.al_dia(BASE, VERSION): + return + oferta = CONFIG.get("instalacion") or {} + if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE): + return + CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez + guardar_json(CONFIG_PATH, CONFIG) + time.sleep(1.5) + if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n" + "No copia nada: el programa se queda en esta carpeta. Se quita desde Configuración › Aplicaciones.", + ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES: + instalar.instalar(BASE, VERSION) + v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.") + except Exception as ex: # noqa + print("no se pudo dar de alta en Windows:", ex) + + +def _ventana(url): + """Abre la ventana del programa y no vuelve hasta que se cierra. Devuelve False si este Windows no puede + abrirla (le falta WebView2): quien llama abre entonces la interfaz en el navegador.""" + global VENTANA + try: + import ventana + import instalar + ok, detalle = ventana.disponible(BASE) + if not ok: + print("Sin ventana propia:", detalle) + return False + v = ventana.Ventana(url, titulo="OrganiLAB", icono=os.path.join(BASE, "static", "organilab.ico"), + perfil=os.path.join(DATOS, "ventana"), memoria=os.path.join(DATOS, "ventana.json"), + base=BASE, marca=_marca(), relanzar=instalar.orden(BASE), + depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR"))) + v.al_cerrar, v.al_soltar = en_marcha, _al_soltar + v.al_abrir = lambda: _recien_abierta(v) + fin = threading.Event() + threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start() + VENTANA = v + try: + abierta = v.mostrar() + finally: + VENTANA = None + fin.set() + if not abierta: + print("La ventana no llegó a abrirse:", v.fallo) + return abierta + except Exception as ex: # noqa + print("Sin ventana propia:", ex) + return False + + +def _atender_navegador(): + """Sin ventana no hay nada que cerrar: el programa sigue mientras la página del navegador pregunte (lo hace + cada pocos segundos) y se va cuando lleva tres minutos callada y no queda trabajo en marcha.""" + ULTIMA_PETICION[0] = time.time() + try: + while time.time() - ULTIMA_PETICION[0] < SILENCIO_NAVEGADOR or en_marcha(): + time.sleep(2) + except KeyboardInterrupt: + pass def main(): + global PUERTO args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")] + if args_tmp and args_tmp[0].lower() in ("instalar", "desinstalar") and "--puente" not in sys.argv: + import instalar # hablan con cuadros de mensaje: no hace falta consola + sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv)) if args_tmp and "--puente" not in sys.argv: _asegurar_consola() try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: # noqa pass - if "--puente" in sys.argv: # modo puente con Resolve (lo usa el propio .exe) + if "--puente" in sys.argv: # puente con Resolve, como lo llamaban los .exe hasta la 2.x import resolve_bridge sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1])) args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")] if args: - if CONGELADO and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?"): + if (CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True + and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?")): actualizador.comprobar_en_segundo_plano() codigo = cli(args) d = actualizador.ESTADO.get("disponible") @@ -4419,6 +4566,16 @@ def main(): except EOFError: pass sys.exit(codigo) + sin_ventana = "--sin-navegador" in sys.argv or "--navegador" in sys.argv + if not _soy_la_unica(): + import ventana + if not sin_ventana and ventana.traer_al_frente(_marca()): + return # ya estaba abierto: se enseña esa ventana y nada más + otra = cargar_json(SESION_PATH, {}).get("puerto") + print("Ya hay un OrganiLAB abierto con estos datos" + (f", en el puerto {otra}." if otra else ".")) + if otra and "--sin-navegador" not in sys.argv: + webbrowser.open(f"http://127.0.0.1:{otra}/") # esa copia no tiene ventana: va en el navegador + return h = herramientas() print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"]) print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)") @@ -4428,34 +4585,46 @@ def main(): try: srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador) except OSError: - print(f"Ya hay un Organizador abierto en el puerto {PUERTO}; abro esa ventana.") - webbrowser.open(f"http://127.0.0.1:{PUERTO}/") - return + if os.environ.get("ORGANIZADOR_PUERTO"): + print(f"El puerto {PUERTO} está ocupado por otro programa.") + sys.exit(1) + # el puerto de siempre lo tiene otro programa: se toma uno libre (a la ventana le da igual cuál sea) + srv = ThreadingHTTPServer(("127.0.0.1", 0), Manejador) + PUERTO = srv.server_address[1] url = f"http://127.0.0.1:{PUERTO}/" + guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": PUERTO}) if sys.stdout: - print(f"OrganiLAB {VERSION} en {url} (Ctrl+C para cerrar)") + print(f"OrganiLAB {VERSION} en {url}") if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True: actualizador.comprobar_en_segundo_plano() - if "--sin-navegador" in sys.argv: # solo el servidor (desarrollo / vista previa) + try: + if "--sin-navegador" in sys.argv: # solo el servidor (pruebas y desarrollo) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + return + hilo = threading.Thread(target=srv.serve_forever, daemon=True) + hilo.start() + threading.Thread(target=_limpiar_restos, daemon=True).start() + if "--navegador" in sys.argv: + webbrowser.open(url) + try: + hilo.join() + except KeyboardInterrupt: + pass + return + if not _ventana(url): + webbrowser.open(url) + _atender_navegador() + while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir + time.sleep(1) + srv.shutdown() + finally: try: - srv.serve_forever() - except KeyboardInterrupt: + os.remove(SESION_PATH) + except OSError: pass - return - hilo = threading.Thread(target=srv.serve_forever, daemon=True) - hilo.start() - if "--navegador" in sys.argv: - webbrowser.open(url) - try: - hilo.join() - except KeyboardInterrupt: - pass - return - # ventana propia: al cerrarla se cierra el programa (salvo que haya trabajo en marcha) - _ventana(url) - while COLA["activa"] or RESPALDO.get("estado") == "corriendo" or asistente.ESTADO.get("activo"): - time.sleep(1) - srv.shutdown() if __name__ == "__main__": diff --git a/probar.py b/probar.py index cd5e022..397a031 100644 --- a/probar.py +++ b/probar.py @@ -90,6 +90,131 @@ class Config(unittest.TestCase): shutil.rmtree(tmp, ignore_errors=True) +@unittest.skipUnless(os.name == "nt", "la ventana y los accesos directos son de Windows") +class Windows(unittest.TestCase): + def test_tablas_de_la_ventana(self): + """Todos los identificadores de interfaz de ventana.py están bien escritos, y preguntar si hay + WebView2 no revienta en ningún equipo (la respuesta depende del equipo).""" + import ventana + ids = [v for k, v in vars(ventana).items() if k.startswith(("IID_", "CLSID_", "FMT_")) and isinstance(v, str)] + self.assertGreater(len(ids), 20) + for g in ids: + self.assertEqual(len(bytes(ventana._guid(g))), 16) + ok, detalle = ventana.disponible(AQUI) + self.assertIsInstance(ok, bool) + self.assertTrue(detalle) + self.assertFalse(ventana.traer_al_frente(123456789)) # nadie abierto con esa marca + + def test_acceso_directo(self): + """El acceso directo que se escribe dice lo que se le pidió, identidad de OrganiLAB incluida: sin + ella, la barra de tareas trataría el acceso anclado y la ventana abierta como dos programas.""" + import ventana + import instalar + tmp = tempfile.mkdtemp(prefix="organilab-lnk-") + try: + exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "notepad.exe") + ruta = instalar.acceso_directo(os.path.join(tmp, "OrganiLAB.lnk"), exe, '"una carpeta\\organizador.py"', tmp, + os.path.join(AQUI, "static", "organilab.ico")) + a = instalar.leer_acceso(ruta) + self.assertEqual(os.path.normcase(a["exe"]), os.path.normcase(exe)) + self.assertEqual(a["args"], '"una carpeta\\organizador.py"') + self.assertEqual(os.path.normcase(a["carpeta"]), os.path.normcase(tmp)) + self.assertTrue(a["icono"].lower().endswith("organilab.ico")) + self.assertEqual(a["identidad"], ventana.IDENTIDAD) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_instalar_y_quitar(self): + """Dar de alta el programa en Windows y quitarlo, de punta a punta pero sin tocar el menú Inicio ni el + Escritorio de verdad: los accesos van a una carpeta temporal y la línea de Aplicaciones a una clave de ensayo.""" + import winreg + import instalar + tmp = tempfile.mkdtemp(prefix="organilab-alta-") + base = os.path.join(tmp, "programa") + os.makedirs(os.path.join(base, "static")) + inicio, escritorio = os.path.join(tmp, "inicio", "OrganiLAB.lnk"), os.path.join(tmp, "escritorio", "OrganiLAB.lnk") + os.makedirs(os.path.dirname(inicio)) + os.makedirs(os.path.dirname(escritorio)) + viejos = instalar._accesos, instalar.CLAVE + instalar._accesos, instalar.CLAVE = (lambda: (inicio, escritorio)), r"Software\OrganiLAB-ensayo\Alta" + try: + self.assertIsNone(instalar.instalado()) + r = instalar.instalar(base, "3.0.0") + self.assertEqual(sorted(r["accesos"]), sorted([inicio, escritorio])) + self.assertTrue(instalar.instalado_aqui(base)) + self.assertFalse(instalar.instalado_aqui(tmp)) + self.assertEqual(os.path.normcase(instalar.leer_acceso(inicio)["carpeta"]), os.path.normcase(base)) + # tras una actualización: sube la versión que enseña Windows, y no repone el acceso que alguien quitó + self.assertFalse(instalar.al_dia(base, "3.0.0")) + os.remove(escritorio) + self.assertTrue(instalar.al_dia(base, "3.0.1")) + self.assertEqual(instalar.instalado()["version"], "3.0.1") + self.assertFalse(os.path.exists(escritorio)) + # quitarlo desde otra copia del programa no toca lo de esta; desde esta, sí + self.assertEqual(instalar.desinstalar(tmp)["accesos"], []) + self.assertTrue(instalar.instalado_aqui(base)) + self.assertEqual(instalar.desinstalar(base)["accesos"], [inicio]) + self.assertIsNone(instalar.instalado()) + finally: + for clave in (instalar.CLAVE, r"Software\OrganiLAB-ensayo"): + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, clave) + except OSError: + pass + instalar._accesos, instalar.CLAVE = viejos + shutil.rmtree(tmp, ignore_errors=True) + + def test_sin_ventana_y_con_el_puerto_ocupado(self): + """Dos imprevistos a la vez. El puerto de siempre lo tiene otro programa: se toma uno libre en vez de + abrir la página de ese otro. Y este Windows no puede abrir la ventana (aquí, porque falta el cargador + de WebView2): la interfaz va al navegador y el programa se va solo cuando esa página deja de preguntar.""" + tmp = tempfile.mkdtemp(prefix="organilab-imprevistos-") + tapon = socket.socket() + tapon.bind(("127.0.0.1", 0)) + tapon.listen(1) + ocupado = tapon.getsockname()[1] + try: + for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"): + shutil.copy(os.path.join(AQUI, f), tmp) + shutil.copytree(os.path.join(AQUI, "static"), os.path.join(tmp, "static")) + guion = ("import sys; sys.argv = ['organizador.py']; import organizador as O; " + f"O.PUERTO = {ocupado}; O.SILENCIO_NAVEGADOR = 2; " + "O.webbrowser.open = lambda u: print('ABRE', u, flush=True); O.main(); print('FIN', O.PUERTO, flush=True)") + env = {k: v for k, v in os.environ.items() if k != "ORGANIZADOR_PUERTO"} + env["PYTHONIOENCODING"] = "utf-8" + p = subprocess.run([sys.executable, "-c", guion], cwd=tmp, env=env, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=90) + self.assertEqual(p.returncode, 0, p.stderr[-600:]) + abre = [l for l in p.stdout.splitlines() if l.startswith("ABRE ")] + self.assertEqual(len(abre), 1, p.stdout[-600:]) + self.assertNotIn(f":{ocupado}/", abre[0]) + self.assertIn("Sin ventana propia", p.stdout) + self.assertTrue(p.stdout.strip().splitlines()[-1].startswith("FIN ")) + self.assertFalse(os.path.exists(os.path.join(tmp, "datos", "sesion.json"))) + finally: + tapon.close() + shutil.rmtree(tmp, ignore_errors=True) + + def test_orden_de_arranque(self): + """Desde el código el programa se abre con el Python sin consola; en el paquete, con su propio .exe.""" + import instalar + tmp = tempfile.mkdtemp(prefix="organilab-orden-") + try: + exe, args = instalar.orden(tmp) + self.assertTrue(exe.lower().endswith(("pythonw.exe", "python.exe"))) + self.assertEqual(args, '"%s"' % os.path.join(tmp, "organizador.py")) + open(os.path.join(tmp, "OrganizadorLabstream.exe"), "wb").close() + self.assertEqual(instalar.orden(tmp), (os.path.join(tmp, "OrganizadorLabstream.exe"), "")) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_arranque_no_estorba(self): + """arranque.py es el sitecustomize del paquete. Fuera de un paquete (este Python no vive junto a un + organizador.py) y usado como intérprete normal, no hace nada: ni lanza el programa ni corta el proceso.""" + p = subprocess.run([sys.executable, "-c", "import arranque; print('sigue')"], cwd=AQUI, capture_output=True, text=True, timeout=60) + self.assertEqual(p.stdout.strip(), "sigue") + + class Firma(unittest.TestCase): @classmethod def setUpClass(cls): @@ -143,7 +268,7 @@ class Servidor(unittest.TestCase): 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"): + for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.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 @@ -201,6 +326,19 @@ class Servidor(unittest.TestCase): out.append(os.path.relpath(os.path.join(d, f), self.raiz).lower()) return sorted(out) + def test_una_sola_copia(self): + """Abrir el programa otra vez sobre los mismos datos no levanta una segunda copia (se pisarían el + inventario, y la segunda daría por huérfanas las conversiones de la primera): se va, y dice por qué.""" + with open(os.path.join(self.app, "datos", "sesion.json"), encoding="utf-8") as fh: + self.assertEqual(json.load(fh)["puerto"], self.puerto) + env = dict(os.environ, ORGANIZADOR_PUERTO=str(_puerto_libre()), PYTHONIOENCODING="utf-8") + p = subprocess.run([sys.executable, "organizador.py", "--sin-navegador"], cwd=self.app, env=env, capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=60) + self.assertEqual(p.returncode, 0) + self.assertIn("Ya hay un OrganiLAB abierto", p.stdout) + self.assertIn(str(self.puerto), p.stdout) + self.assertEqual(self.get("/api/estado")["version"], self.get("/api/actualizacion")["version"]) # la primera sigue atendiendo + def test_recorrido(self): q = "?raiz=" + urllib.parse.quote(self.raiz) # 0. una instalación nueva abre en blanco: ninguna carpeta puesta de fábrica diff --git a/prueba_funcional.py b/prueba_funcional.py index c5ed9d7..c9485d6 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -19,7 +19,7 @@ AQUI = os.path.dirname(os.path.abspath(__file__)) TMP = tempfile.mkdtemp(prefix='organilab-smoke-') APP = os.path.join(TMP, 'app') os.makedirs(APP) -for f in ('organizador.py', 'reglas.py', 'asistente.py', 'actualizador.py', 'resolve_bridge.py', 'firma.py'): +for f in ('organizador.py', 'reglas.py', 'asistente.py', 'actualizador.py', 'resolve_bridge.py', 'firma.py', 'ventana.py', 'instalar.py'): shutil.copy(os.path.join(AQUI, f), APP) shutil.copytree(os.path.join(AQUI, 'static'), os.path.join(APP, 'static')) RAIZ = os.path.join(TMP, 'Cliente Smoke') diff --git a/prueba_ventana.py b/prueba_ventana.py new file mode 100644 index 0000000..c19c5e1 --- /dev/null +++ b/prueba_ventana.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +r""" +Prueba de la ventana, con la ventana de verdad. probar.py y prueba_funcional.py ejercitan el programa +sin abrirlo; esto abre OrganiLAB sobre una copia temporal (programa y datos aparte, en otro puerto) y +comprueba desde otro hilo lo que solo se puede comprobar con la ventana delante: + + py -3.12 prueba_ventana.py el programa desde el código + py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream el paquete, arrancado por su .exe + … --captura ruta.png deja una imagen de la ventana entera + +Durante medio minuto se ve una ventana que se abre, se mueve sola y se cierra. No hay que tocar nada. +Sale con código 0 si todo pasó. +""" +import os +import sys +import json +import time +import zlib +import shutil +import socket +import struct +import ctypes +import tempfile +import threading +import subprocess +import urllib.request +from ctypes import wintypes as W + +AQUI = os.path.dirname(os.path.abspath(__file__)) +MODULOS = ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", + "ventana.py", "instalar.py", "WebView2Loader.dll") +RES = [] # (nombre, pasó, detalle) +U = ctypes.WinDLL("user32") +G = ctypes.WinDLL("gdi32") +ENUM = ctypes.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM) +for _n, _a, _r in (("GetWindow", [W.HWND, W.UINT], W.HWND), ("GetClassNameW", [W.HWND, W.LPWSTR, ctypes.c_int], ctypes.c_int), + ("GetWindowTextW", [W.HWND, W.LPWSTR, ctypes.c_int], ctypes.c_int), ("IsWindowVisible", [W.HWND], W.BOOL), + ("EnumWindows", [ENUM, W.LPARAM], W.BOOL), ("EnumChildWindows", [W.HWND, ENUM, W.LPARAM], W.BOOL), + ("SendMessageW", [W.HWND, W.UINT, W.WPARAM, ctypes.c_void_p], ctypes.c_ssize_t), + ("PostMessageW", [W.HWND, W.UINT, W.WPARAM, W.LPARAM], W.BOOL), ("GetDlgItem", [W.HWND, ctypes.c_int], W.HWND), + ("GetWindowDC", [W.HWND], W.HANDLE), ("ReleaseDC", [W.HWND, W.HANDLE], ctypes.c_int), + ("PrintWindow", [W.HWND, W.HANDLE, W.UINT], W.BOOL), ("GetWindowRect", [W.HWND, ctypes.POINTER(W.RECT)], W.BOOL)): + getattr(U, _n).argtypes, getattr(U, _n).restype = _a, _r +for _n, _a, _r in (("CreateCompatibleDC", [W.HANDLE], W.HANDLE), ("CreateCompatibleBitmap", [W.HANDLE, ctypes.c_int, ctypes.c_int], W.HANDLE), + ("SelectObject", [W.HANDLE, W.HANDLE], W.HANDLE), ("DeleteObject", [W.HANDLE], W.BOOL), ("DeleteDC", [W.HANDLE], W.BOOL), + ("GetDIBits", [W.HANDLE, W.HANDLE, W.UINT, W.UINT, ctypes.c_void_p, ctypes.c_void_p, W.UINT], ctypes.c_int)): + getattr(G, _n).argtypes, getattr(G, _n).restype = _a, _r +WM_CLOSE, WM_COMMAND, WM_SETTEXT, WM_GETICON = 0x0010, 0x0111, 0x000C, 0x007F + + +def anota(nombre, ok, detalle=""): + RES.append((nombre, bool(ok), str(detalle))) + print((" bien " if ok else " MAL ") + nombre + (f" · {detalle}" if detalle else ""), flush=True) + + +def espera(condicion, plazo=15.0, paso=0.1): + fin = time.time() + plazo + while time.time() < fin: + r = condicion() + if r: + return r + time.sleep(paso) + return condicion() + + +def puerto_libre(): + s = socket.socket() + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + s.close() + return p + + +def clase(h): + b = ctypes.create_unicode_buffer(200) + U.GetClassNameW(h, b, 200) + return b.value + + +def texto(h): + b = ctypes.create_unicode_buffer(2000) + U.GetWindowTextW(h, b, 2000) + return b.value + + +def hijos(h): + out = [] + U.EnumChildWindows(h, ENUM(lambda x, _: out.append(x) or True), 0) + return out + + +def dialogo_de(hwnd): + """El cuadro de diálogo de Windows abierto sobre esa ventana, si hay alguno.""" + out = [] + U.EnumWindows(ENUM(lambda x, _: (out.append(x) if clase(x) == "#32770" and U.GetWindow(x, 4) == hwnd and U.IsWindowVisible(x) else None) or True), 0) + return out[0] if out else None + + +def marco(hwnd, ruta): + """La ventana entera (barra de título incluida), tal como la pinta Windows, a un PNG.""" + r = W.RECT() + U.GetWindowRect(hwnd, ctypes.byref(r)) + ancho, alto = r.right - r.left, r.bottom - r.top + dc = U.GetWindowDC(hwnd) + mem = G.CreateCompatibleDC(dc) + bmp = G.CreateCompatibleBitmap(dc, ancho, alto) + G.SelectObject(mem, bmp) + U.PrintWindow(hwnd, mem, 2) + cab = ctypes.create_string_buffer(struct.pack("I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff) + with open(ruta, "wb") as fh: + fh.write(b"\x89PNG\r\n\x1a\n" + trozo(b"IHDR", struct.pack(">IIBBBBB", ancho, alto, 8, 2, 0, 0, 0)) + + trozo(b"IDAT", zlib.compress(bytes(filas), 6)) + trozo(b"IEND", b"")) + return ancho, alto + + +def cliente_de_ensayo(tmp): + raiz = os.path.join(tmp, "Cliente de ensayo") + for rel in ("Rodaje/C0001.MP4", "Entregas/Video final.mp4", "notas.txt"): + p = os.path.join(raiz, rel) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as fh: + fh.write(os.urandom(2048)) + return raiz + + +def marca_de(datos): + return (zlib.crc32(os.path.normcase(os.path.abspath(datos)).encode("utf-8")) & 0x7FFFFFFF) or 1 + + +def captura_pedida(): + return sys.argv[sys.argv.index("--captura") + 1] if "--captura" in sys.argv else "" + + +# -------------------------------------------------------------------------- +# desde el código: el programa corre en este mismo proceso y se le puede preguntar todo +# -------------------------------------------------------------------------- + +def desde_el_codigo(tmp): + app = os.path.join(tmp, "app") + os.makedirs(app) + for f in MODULOS: + shutil.copy(os.path.join(AQUI, f), app) + shutil.copytree(os.path.join(AQUI, "static"), os.path.join(app, "static")) + carpeta = cliente_de_ensayo(tmp) + puerto = puerto_libre() + os.environ["ORGANIZADOR_PUERTO"] = str(puerto) + sys.path.insert(0, app) + sys.argv = [os.path.join(app, "organizador.py")] + import organizador as O + import ventana as V + base = f"http://127.0.0.1:{puerto}" + + def pedir(ruta, datos=None): + rq = urllib.request.Request(base + ruta, None if datos is None else json.dumps(datos).encode(), + {"Content-Type": "application/json", "Origin": base}) + return json.loads(urllib.request.urlopen(rq, timeout=90).read()) + + def otra_copia(): + return subprocess.run([sys.executable, os.path.join(app, "organizador.py")], cwd=app, timeout=40, + capture_output=True, text=True, encoding="utf-8", errors="replace") + + def pruebas(): + v = espera(lambda: O.VENTANA if O.VENTANA is not None and O.VENTANA.lista else None, 60) + anota("la ventana abre y carga la interfaz", v is not None, "" if v else "no cargó en 60 s") + if v is None: + os._exit(1) + time.sleep(1.5) + h = v.hwnd + anota("es una ventana de Windows propia, con su título y su icono", + clase(h) == V.CLASE and texto(h) == "OrganiLAB" and U.SendMessageW(h, WM_GETICON, 1, None) != 0, + f"clase {clase(h)} · «{texto(h)}»") + anota("dentro va la interfaz del programa", v.evaluar("ESTADO.version") == O.VERSION, v.evaluar("ESTADO.version")) + + def identidad(): + almacen, out = ctypes.c_void_p(), {} + V._dll().s.SHGetPropertyStoreForWindow(h, ctypes.byref(V._guid(V.IID_PROPIEDADES)), ctypes.byref(almacen)) + for nombre, pid in (("id", 5), ("orden", 2), ("nombre", 4), ("icono", 3)): + clave, pv = V.PROPERTYKEY(V._guid(V.FMT_APPUSERMODEL), pid), V.PROPVARIANT() + V._m(almacen.value, 5, ctypes.byref(clave), ctypes.byref(pv), tipos=(ctypes.POINTER(V.PROPERTYKEY), ctypes.POINTER(V.PROPVARIANT))) + out[nombre] = ctypes.wstring_at(pv.valor) if pv.vt == 31 and pv.valor else "" + V._soltar(almacen.value) + return out + quien = v.en_ui(identidad) + anota("la barra de tareas la conoce como OrganiLAB y sabe cómo volver a abrirla", + quien["id"] == V.IDENTIDAD and "organizador.py" in quien["orden"] and quien["nombre"] == "OrganiLAB" and quien["icono"].endswith("organilab.ico"), + f"{quien['id']} · {quien['orden']}") + + def ajustes(): + aj = ctypes.c_void_p() + V._m(v._wv, 3, ctypes.byref(aj), tipos=(V.PPVOID,)) + a3 = V._qi(aj.value, V.IID_AJUSTES3) + r = {"barra de estado": V._entero(aj.value, 9), "herramientas de desarrollo": V._entero(aj.value, 11), + "zoom": V._entero(aj.value, 17), "página de error": V._entero(aj.value, 19), "teclas de navegador": V._entero(a3, 23)} + V._soltar(a3), V._soltar(aj.value) + return r + aj = v.en_ui(ajustes) + anota("lo que es de navegador está apagado", not any(aj.values()), ", ".join(k for k, x in aj.items() if x) or "todo apagado") + anota("la interfaz sabe que está en la ventana", v.evaluar("document.documentElement.classList.contains('nativo')") is True) + + # el avance en la barra de tareas + v.progreso(0.5) + anota("pone avance en el botón de la barra de tareas", espera(lambda: v.en_ui(lambda: bool(v._barra)), 5)) + v.progreso(None) + if captura_pedida(): + anota("captura de la ventana", True, "%d×%d px en %s" % (*marco(h, captura_pedida()), captura_pedida())) + + # el selector de carpetas de Windows, pedido como lo pide la interfaz + caja = {} + threading.Thread(target=lambda: caja.update(r=pedir("/api/elegir-carpeta", {})), daemon=True).start() + d = espera(lambda: dialogo_de(h), 15) + if d: + time.sleep(1.0) + campo = U.GetDlgItem(d, 1152) # el campo «Carpeta:» + if campo: + U.SendMessageW(campo, WM_SETTEXT, 0, ctypes.c_wchar_p(carpeta)) + time.sleep(0.3) + U.SendMessageW(d, WM_COMMAND, 1, U.GetDlgItem(d, 1)) + # con una ruta escrita, el primer «Elegir carpeta» entra en ella y el segundo la elige + if not espera(lambda: "r" in caja, 3): + U.SendMessageW(d, WM_COMMAND, 1, U.GetDlgItem(d, 1)) + if not espera(lambda: "r" in caja, 8): + U.PostMessageW(d, WM_CLOSE, 0, 0) + espera(lambda: "r" in caja, 8) + elegida = (caja.get("r") or {}).get("ruta") + anota("el selector de carpetas de Windows se abre sobre la ventana y devuelve la carpeta", + bool(d) and elegida and os.path.normcase(elegida) == os.path.normcase(carpeta), + elegida or ("se abrió pero no devolvió carpeta" if d else "no se abrió")) + + # soltar una carpeta: el motor entrega los archivos y la ventana lee su ruta completa + def cdp(metodo, parametros): + hecho = threading.Event() + p, sosten = V._objeto("{5C4889F0-5EF6-4C5A-952C-D8F1B92D0574}", (ctypes.c_long, W.LPCWSTR), lambda hr, r: hecho.set(), fijo=False) + v.en_ui(lambda: V._m(v._wv, 36, metodo, json.dumps(parametros), p, tipos=(W.LPCWSTR, W.LPCWSTR, V.PVOID))) + hecho.wait(5) + return sosten + x, y = v.evaluar("[Math.round(innerWidth * 0.6), Math.round(innerHeight * 0.4)]") + carga = {"items": [], "files": [carpeta], "dragOperationsMask": 1} + for tipo in ("dragEnter", "dragOver", "drop"): + cdp("Input.dispatchDragEvent", {"type": tipo, "x": x, "y": y, "data": carga}) + llego = espera(lambda: [r for r in O.SOLTADOS["rutas"] if os.path.normcase(r) == os.path.normcase(carpeta)], 8) + anota("una carpeta soltada llega con su ruta completa", bool(llego), (llego or ["no llegó"])[0]) + propuesta = espera(lambda: v.evaluar("document.querySelector('#modal').classList.contains('on') && !!document.querySelector('#nuevoCliente')"), 8) + anota("y la interfaz propone añadirla", propuesta is True, v.evaluar("document.querySelector('#modalTitulo').textContent")) + v.evaluar("document.querySelector('#modal').classList.remove('on')") + + # una sola copia abierta + p = otra_copia() + anota("abrirlo otra vez no abre una segunda copia", p.returncode == 0 and len(V._ventanas(O._marca())) == 1, + f"código {p.returncode} · {len(V._ventanas(O._marca()))} ventana(s)") + + # cerrar con trabajo en marcha: avisa, se oculta, vuelve si se abre otra vez y se cierra sola al acabar + O.APLICANDO.update(total=10, hechas=3, raiz="ensayo") + U.PostMessageW(h, WM_CLOSE, 0, 0) + d = espera(lambda: dialogo_de(h), 8) + aviso = " ".join(texto(x) for x in hijos(d)) if d else "" + anota("al cerrar con trabajo en marcha, avisa de qué está haciendo", "moviendo archivos" in aviso, aviso[:90]) + if d: + U.PostMessageW(d, WM_COMMAND, 1, 0) + anota("la ventana se quita de la vista y el programa sigue", espera(lambda: v.oculta and not U.IsWindowVisible(h), 8)) + otra_copia() + anota("abrirlo otra vez devuelve esa misma ventana", espera(lambda: not v.oculta and U.IsWindowVisible(h), 8)) + U.PostMessageW(h, WM_CLOSE, 0, 0) + d = espera(lambda: dialogo_de(h), 8) + if d: + U.PostMessageW(d, WM_COMMAND, 1, 0) + espera(lambda: v.oculta, 8) + O.APLICANDO.update(hechas=10) # el trabajo termina: el programa tiene que cerrarse solo + + fin, forzado = threading.Event(), [] + + def con_red(): + try: + pruebas() + except Exception as ex: # noqa + anota("la prueba llegó hasta el final", False, repr(ex)) + O.APLICANDO.update(hechas=O.APLICANDO.get("total", 0)) + if not fin.wait(10) and O.VENTANA is not None: # no se cerró solo: se cierra a la fuerza para no quedarse aquí + forzado.append(1) + O.VENTANA.cerrar() + + threading.Thread(target=con_red, daemon=True).start() + t0 = time.time() + O.main() + fin.set() + anota("al terminar el trabajo, el programa se cierra solo", not forzado, f"{time.time() - t0:.0f} s en total") + datos = os.path.join(app, "datos") + try: + sitio = json.load(open(os.path.join(datos, "ventana.json"), encoding="utf-8")) + except Exception: # noqa + sitio = {} + anota("recuerda dónde y de qué tamaño quedó", sitio.get("ancho", 0) > 200, json.dumps(sitio)) + anota("no deja rastro de la sesión", not os.path.exists(os.path.join(datos, "sesion.json"))) + + +# -------------------------------------------------------------------------- +# el paquete: se arranca por su .exe, como lo arrancaría una persona, y se mira desde fuera +# -------------------------------------------------------------------------- + +def el_paquete(tmp, origen): + sys.path.insert(0, AQUI) + import ventana as V + app = os.path.join(tmp, "OrganizadorLabstream") + shutil.copytree(origen, app, ignore=shutil.ignore_patterns("ffmpeg", "datos", "actualizacion", "__pycache__")) + exe = os.path.join(app, "OrganizadorLabstream.exe") + puerto = puerto_libre() + entorno = dict(os.environ, ORGANIZADOR_PUERTO=str(puerto)) + base = f"http://127.0.0.1:{puerto}" + p = subprocess.run([exe, "-B", "-c", "import sys; print(sys.version_info[:2])"], capture_output=True, text=True, timeout=60, stdin=subprocess.DEVNULL) + anota("el .exe del paquete arranca en este Windows", p.returncode == 0, (p.stdout or p.stderr).strip()[:80]) + proc = subprocess.Popen([exe], cwd=tmp, env=entorno) + + def estado(): + try: + return json.loads(urllib.request.urlopen(base + "/api/estado", timeout=2).read()) + except Exception: # noqa + return None + e = espera(estado, 40, 0.3) + anota("doble clic en el .exe abre el programa", bool(e), f"versión {e['version']}" if e else "no respondió en 40 s") + if not e: + proc.kill() + return + anota("corre con el motor de su propia carpeta", e.get("congelado") is True) + marca = marca_de(os.path.join(app, "datos")) + hs = espera(lambda: V._ventanas(marca), 30) + h = hs[0] if hs else None + anota("con su ventana de Windows", bool(h) and texto(h) == "OrganiLAB" and bool(U.IsWindowVisible(h)), f"clase {clase(h)}" if h else "sin ventana") + if h: + time.sleep(4) + if captura_pedida(): + anota("captura de la ventana", True, "%d×%d px en %s" % (*marco(h, captura_pedida()), captura_pedida())) + subprocess.run([exe], cwd=tmp, env=entorno, timeout=40) + anota("abrirlo otra vez no abre una segunda copia", len(V._ventanas(marca)) == 1, f"{len(V._ventanas(marca))} ventana(s)") + U.PostMessageW(h, WM_CLOSE, 0, 0) + try: + proc.wait(25) + except subprocess.TimeoutExpired: + proc.kill() + anota("al cerrar la ventana se cierra el programa", proc.returncode == 0, f"código {proc.returncode}") + anota("y deja de atender", estado() is None) + p = subprocess.run([exe, "ayuda"], capture_output=True, timeout=60, stdin=subprocess.DEVNULL, env=entorno) + anota("por consola responde a sus comandos", p.returncode == 0 and b"asistente" in p.stdout) + + +def main(): + guardia = threading.Timer(300, lambda: os._exit(3)) # pase lo que pase, no se queda colgada + guardia.daemon = True + guardia.start() + tmp = tempfile.mkdtemp(prefix="organilab-ventana-") + try: + if "--paquete" in sys.argv: + el_paquete(tmp, os.path.abspath(sys.argv[sys.argv.index("--paquete") + 1])) + else: + desde_el_codigo(tmp) + finally: + shutil.rmtree(tmp, ignore_errors=True) + mal = [n for n, ok, _ in RES if not ok] + print(f"\n{len(RES) - len(mal)} de {len(RES)} comprobaciones pasan" + (": fallan " + "; ".join(mal) if mal else "")) + return 1 if mal else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/index.html b/static/index.html index f8f209f..f27ef71 100644 --- a/static/index.html +++ b/static/index.html @@ -26,6 +26,9 @@ button:disabled{opacity:.45;cursor:default;border-color:var(--line)!important;background:var(--panel2)!important;color:var(--mut)!important} button .spin{margin-right:6px} .por-que{font-size:12px;color:var(--mut);margin-left:2px} + /* dentro de la ventana del programa, lo que es marco (menú, botones, títulos) no se selecciona ni se arrastra como en una página */ + .nativo aside,.nativo nav,.nativo #tareas,.nativo button,.nativo .tabs,.nativo h1,.nativo h2,.nativo h3,.nativo label{-webkit-user-select:none;user-select:none} + .nativo img,.nativo a,.nativo svg{-webkit-user-drag:none} /* ── lateral ── */ aside{grid-row:1/3;border-right:1px solid var(--line);padding:20px 16px 16px;display:flex;flex-direction:column;gap:4px;position:sticky;top:0;height:100vh;overflow:auto} .marca{display:flex;align-items:center;gap:10px;margin-bottom:16px} @@ -1671,9 +1674,9 @@ async function seguirActualizacion(){ ACT_SIGUIENDO = true; const caja = $('#actualizacionCaja'); caja.style.display = ''; irA('carpeta'); for (;;) { let a; try { a = await api('/api/actualizacion'); } catch (e) { a = null; } - if (!a) { caja.innerHTML = '

Reiniciando…

El programa se está sustituyendo por la versión nueva. La página se recargará sola.

'; await new Promise(r => setTimeout(r, 2000)); try { const e = await fetch('/api/estado'); if (e.ok) location.reload(); } catch (e) {} continue; } + if (!a) { caja.innerHTML = '

Reiniciando…

El programa se está sustituyendo por la versión nueva y vuelve a abrirse solo.

'; await new Promise(r => setTimeout(r, 2000)); try { const e = await fetch('/api/estado'); if (e.ok) location.reload(); } catch (e) {} continue; } if (a.estado === 'error') { caja.innerHTML = `

No se pudo actualizar

${esc(a.error)}

`; ACT_SIGUIENDO = false; return; } - if (a.estado === 'reiniciando' || a.aplicando) { caja.innerHTML = '

Instalando…

El programa se cierra y vuelve a abrirse con la versión nueva. La página se recargará sola.

'; await new Promise(r => setTimeout(r, 2000)); continue; } + if (a.estado === 'reiniciando' || a.aplicando) { caja.innerHTML = '

Instalando…

El programa se cierra y vuelve a abrirse solo con la versión nueva.

'; await new Promise(r => setTimeout(r, 2000)); continue; } caja.innerHTML = `

${a.estado === 'verificando' ? 'Verificando' : 'Descargando'} la versión ${esc(a.disponible ? a.disponible.version : '')}

${(a.bytes / 1e6).toFixed(0)} / ${(a.total / 1e6).toFixed(0)} MB${a.error ? ' · ' + esc(a.error) : ''}

`; await new Promise(r => setTimeout(r, 700)); } diff --git a/ventana.py b/ventana.py new file mode 100644 index 0000000..2348400 --- /dev/null +++ b/ventana.py @@ -0,0 +1,972 @@ +# -*- coding: utf-8 -*- +r""" +La ventana de OrganiLAB: un programa de Windows con su marco, su icono y su sitio en la barra de tareas. + +El marco es Win32 de verdad (RegisterClassEx, CreateWindowEx y un bucle de mensajes); lo de dentro lo +pinta WebView2, el componente que trae Windows para eso. Todo va con ctypes sobre DLL del sistema: sin +pywebview, sin pythonnet y sin .NET, que eran tres dependencias con binarios sin firmar. Smart App +Control puede parar un binario sin firmar el día menos pensado, y entonces el programa acababa abierto +en el navegador. + +Lo único que no es de Windows ni de Python es WebView2Loader.dll (160 KB, firmada por Microsoft): es +la entrada documentada a WebView2 y viaja con el programa. + +Los IID y las posiciones en las tablas virtuales no están escritos de memoria: salen del ensamblado +Microsoft.Web.WebView2.Core.dll, que declara cada interfaz COM con sus métodos en orden. + +Qué da de más respecto a una página en un marco: + - identidad propia en la barra de tareas (se puede anclar y vuelve a abrir el programa, no «Python») + - una sola copia abierta: lanzarlo otra vez trae esta ventana al frente + - recuerda dónde y de qué tamaño se dejó + - las carpetas que se sueltan llegan con su ruta completa + - el selector de carpetas de Windows, modal sobre la ventana + - avance en el botón de la barra de tareas, y un parpadeo al terminar si se está en otra cosa + - nada de navegador: sin menú de «Atrás / Recargar / Inspeccionar», sin F5, sin zoom, sin permisos +""" +import os +import sys +import json +import queue +import ctypes +import threading +from ctypes import wintypes as W + +LRESULT = ctypes.c_ssize_t +WNDPROC = ctypes.WINFUNCTYPE(LRESULT, W.HWND, W.UINT, W.WPARAM, W.LPARAM) +PVOID = ctypes.c_void_p +PPVOID = ctypes.POINTER(ctypes.c_void_p) +S_OK, E_NOINTERFACE, E_FAIL = 0, -2147467262, -2147467259 + +WM_DESTROY, WM_MOVE, WM_SIZE, WM_SETFOCUS, WM_CLOSE = 0x0002, 0x0003, 0x0005, 0x0007, 0x0010 +WM_GETMINMAXINFO, WM_SETICON, WM_TIMER, WM_DPICHANGED, WM_APP = 0x0024, 0x0080, 0x0113, 0x02E0, 0x8000 +MSG_LLAMAR = WM_APP + 1 # hay llamadas de otros hilos esperando en la cola +SW_HIDE, SW_SHOWNORMAL, SW_SHOWMINIMIZED, SW_SHOWMAXIMIZED, SW_SHOW, SW_RESTORE = 0, 1, 2, 3, 5, 9 +WS_OVERLAPPEDWINDOW, SIZE_MINIMIZED = 0x00CF0000, 1 +MB_OK, MB_OKCANCEL, MB_YESNO, MB_ICONWARNING, MB_ICONINFORMATION, MB_ICONQUESTION = 0x0, 0x1, 0x4, 0x30, 0x40, 0x20 +IDOK, IDYES = 1, 6 +T_GUARDIA, T_REINTENTO = 1, 2 # temporizadores de la ventana + +CLASE = "OrganiLAB.Ventana" +PROP_MARCA = "OrganiLAB.datos" # qué instalación es (un número que sale de su carpeta de datos) +IDENTIDAD = "Labstream.OrganiLAB" # AppUserModelID: la misma en la ventana y en los accesos directos + +IID_IUNKNOWN = "{00000000-0000-0000-C000-000000000046}" +IID_ENTORNO_LISTO = "{4E8A3389-C9D8-4BD2-B6B5-124FEE6CC14D}" +IID_CONTROL_LISTO = "{6C4819F3-C9B7-4260-8127-C9F5BDE7F68C}" +IID_NAVEGACION_EMPIEZA = "{9ADBE429-F36D-432B-9DDC-F8881FBD76E3}" +IID_NAVEGACION_TERMINA = "{D33A35BF-1C49-4F98-93AB-006E0533FE1C}" +IID_VENTANA_NUEVA = "{D4C185FE-C81C-4989-97AF-2D3FA7AB5651}" +IID_MENSAJE = "{57213F19-00E6-49FA-8E07-898EA01ECBD2}" +IID_TITULO = "{F5F2B923-953E-4042-9F95-F3A118E1AFD4}" +IID_PERMISO = "{15E1C6A3-C72A-4DF3-91D7-D097FBEC6BFD}" +IID_PROCESO_CAIDO = "{79E0AEA4-990B-42D9-AA1D-0FCC2E5BC7F1}" +IID_GUION_LISTO = "{49511172-CC67-4BCA-9923-137112F4C4CC}" +IID_CAPTURA_LISTA = "{697E05E9-3D8F-45FA-96F4-8FFE1EDEDAF5}" +IID_CONTROL2 = "{C979903E-D4CA-4228-92EB-47EE3FA96EAB}" +IID_AJUSTES3 = "{FDB5AB74-AF33-4854-84F0-0A631DEB5EBA}" +IID_AJUSTES4 = "{CB56846C-4168-4D53-B04F-03B6D6796FF2}" +IID_AJUSTES5 = "{183E7052-1D03-43A0-AB99-98E043B66B39}" +IID_AJUSTES6 = "{11CB3ACD-9BC8-43B8-83BF-F40753714F87}" +IID_AJUSTES8 = "{9E6B0E8F-86AD-4E81-8147-A9B5EDB68650}" +IID_MENSAJE_ARGS2 = "{06FC7AB7-C90C-4297-9389-33CA01CF6D5E}" +IID_ARCHIVO = "{F2C19559-6BC1-4583-A757-90021BE9AFEC}" +CLSID_BARRA, IID_BARRA3 = "{56FDF344-FD6D-11D0-958A-006097C9A090}", "{EA1AFB91-9E28-4B86-90E9-9E9F8A5EEFAF}" +CLSID_ABRIR, IID_ABRIR = "{DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7}", "{D57C7288-D4AD-4768-BE02-9D969532D960}" +IID_PROPIEDADES = "{886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99}" +FMT_APPUSERMODEL = "{9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}" + +# El motor no sale a la red. Lo primero le quita las tareas de fondo (componentes, estadísticas, pings); +# lo segundo es el cerrojo: fuera de este equipo no resuelve ningún nombre, y lo que aun así quisiera salir +# va a un proxy que no existe. La interfaz se sirve desde 127.0.0.1, que nunca pasa por el proxy. +ARGUMENTOS = ('--disable-background-networking --disable-component-update --disable-domain-reliability --no-pings ' + '--proxy-server=127.0.0.1:9 --host-resolver-rules="MAP * ~NOTFOUND, EXCLUDE 127.0.0.1"') + +# Se inyecta antes que la página. Las carpetas soltadas viajan al anfitrión, que es quien puede leer su +# ruta (el motor no se la da a la página). Y el menú del navegador solo sale donde sirve: al escribir o +# con texto seleccionado. +GUION = r""" +(function () { + if (window.__organilab) return; + window.__organilab = true; + document.addEventListener('drop', function (e) { + try { + var fs = e.dataTransfer && e.dataTransfer.files; + if (fs && fs.length) window.chrome.webview.postMessageWithAdditionalObjects('soltado', fs); + } catch (_) {} + }, true); + document.addEventListener('contextmenu', function (e) { + var t = e.target; + var campo = t && t.closest && t.closest('input, textarea, [contenteditable=""], [contenteditable="true"]'); + if (!campo && !String(window.getSelection())) e.preventDefault(); + }); + document.addEventListener('DOMContentLoaded', function () { document.documentElement.classList.add('nativo'); }); +})(); +""" + + +class GUID(ctypes.Structure): + _fields_ = [("a", ctypes.c_uint32), ("b", ctypes.c_uint16), ("c", ctypes.c_uint16), ("d", ctypes.c_ubyte * 8)] + + +class WNDCLASSEXW(ctypes.Structure): + _fields_ = [("cbSize", W.UINT), ("style", W.UINT), ("lpfnWndProc", WNDPROC), ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), ("hInstance", W.HINSTANCE), ("hIcon", W.HANDLE), ("hCursor", W.HANDLE), + ("hbrBackground", W.HANDLE), ("lpszMenuName", W.LPCWSTR), ("lpszClassName", W.LPCWSTR), ("hIconSm", W.HANDLE)] + + +class WINDOWPLACEMENT(ctypes.Structure): + _fields_ = [("length", W.UINT), ("flags", W.UINT), ("showCmd", W.UINT), ("ptMin", W.POINT), ("ptMax", W.POINT), ("normal", W.RECT)] + + +class MINMAXINFO(ctypes.Structure): + _fields_ = [("reservado", W.POINT), ("maximo", W.POINT), ("posicion", W.POINT), ("minimo", W.POINT), ("tope", W.POINT)] + + +class MONITORINFO(ctypes.Structure): + _fields_ = [("cbSize", W.DWORD), ("monitor", W.RECT), ("trabajo", W.RECT), ("dwFlags", W.DWORD)] + + +class FLASHWINFO(ctypes.Structure): + _fields_ = [("cbSize", W.UINT), ("hwnd", W.HWND), ("dwFlags", W.DWORD), ("uCount", W.UINT), ("dwTimeout", W.DWORD)] + + +class PROPERTYKEY(ctypes.Structure): + _fields_ = [("fmtid", GUID), ("pid", W.DWORD)] + + +class PROPVARIANT(ctypes.Structure): + _fields_ = [("vt", ctypes.c_ushort), ("r1", ctypes.c_ushort), ("r2", ctypes.c_ushort), ("r3", ctypes.c_ushort), + ("valor", ctypes.c_void_p), ("resto", ctypes.c_void_p)] + + +class _Dll: + """Las DLL del sistema, con sus firmas puestas: en 64 bits un manejador no cabe en el int que ctypes + supone si no se le dice otra cosa.""" + def __init__(self): + u = self.u = ctypes.WinDLL("user32", use_last_error=True) + self.k = ctypes.WinDLL("kernel32", use_last_error=True) + self.g = ctypes.WinDLL("gdi32") + self.o = ctypes.WinDLL("ole32") + self.s = ctypes.WinDLL("shell32") + self.d = ctypes.WinDLL("dwmapi") + self.w = ctypes.WinDLL("shlwapi") + H, I, B = W.HWND, ctypes.c_int, W.BOOL + for nombre, args, res in ( + ("DefWindowProcW", [H, W.UINT, W.WPARAM, W.LPARAM], LRESULT), + ("RegisterClassExW", [ctypes.POINTER(WNDCLASSEXW)], W.ATOM), + ("CreateWindowExW", [W.DWORD, W.LPCWSTR, W.LPCWSTR, W.DWORD, I, I, I, I, H, W.HMENU, W.HINSTANCE, PVOID], H), + ("DestroyWindow", [H], B), ("ShowWindow", [H, I], B), ("ShowWindowAsync", [H, I], B), ("UpdateWindow", [H], B), + ("GetMessageW", [ctypes.POINTER(W.MSG), H, W.UINT, W.UINT], I), + ("TranslateMessage", [ctypes.POINTER(W.MSG)], B), ("DispatchMessageW", [ctypes.POINTER(W.MSG)], LRESULT), + ("PostMessageW", [H, W.UINT, W.WPARAM, W.LPARAM], B), ("SendMessageW", [H, W.UINT, W.WPARAM, W.LPARAM], LRESULT), + ("PostQuitMessage", [I], None), ("GetClientRect", [H, ctypes.POINTER(W.RECT)], B), + ("LoadImageW", [W.HINSTANCE, W.LPCWSTR, W.UINT, I, I, W.UINT], W.HANDLE), ("LoadCursorW", [W.HINSTANCE, PVOID], W.HANDLE), + ("SetWindowTextW", [H, W.LPCWSTR], B), ("GetWindowPlacement", [H, ctypes.POINTER(WINDOWPLACEMENT)], B), + ("SetWindowPlacement", [H, ctypes.POINTER(WINDOWPLACEMENT)], B), + ("MonitorFromRect", [ctypes.POINTER(W.RECT), W.DWORD], W.HANDLE), ("GetMonitorInfoW", [W.HANDLE, ctypes.POINTER(MONITORINFO)], B), + ("SetWindowPos", [H, H, I, I, I, I, W.UINT], B), ("SetTimer", [H, ctypes.c_size_t, W.UINT, PVOID], ctypes.c_size_t), + ("KillTimer", [H, ctypes.c_size_t], B), ("MessageBoxW", [H, W.LPCWSTR, W.LPCWSTR, W.UINT], I), + ("FindWindowExW", [H, H, W.LPCWSTR, W.LPCWSTR], H), ("SetForegroundWindow", [H], B), ("GetForegroundWindow", [], H), + ("IsIconic", [H], B), ("IsWindowVisible", [H], B), ("IsWindow", [H], B), ("FlashWindowEx", [ctypes.POINTER(FLASHWINFO)], B), + ("RegisterWindowMessageW", [W.LPCWSTR], W.UINT), ("SetPropW", [H, W.LPCWSTR, W.HANDLE], B), ("GetPropW", [H, W.LPCWSTR], W.HANDLE), + ("GetWindowThreadProcessId", [H, ctypes.POINTER(W.DWORD)], W.DWORD), ("AllowSetForegroundWindow", [W.DWORD], B), + ): + f = getattr(u, nombre) + f.argtypes, f.restype = args, res + try: # no existen antes de Windows 10 1607 + u.GetDpiForWindow.argtypes, u.GetDpiForWindow.restype = [H], W.UINT + u.GetDpiForSystem.argtypes, u.GetDpiForSystem.restype = [], W.UINT + u.SetProcessDpiAwarenessContext.argtypes, u.SetProcessDpiAwarenessContext.restype = [PVOID], B + except AttributeError: + pass + self.k.GetModuleHandleW.argtypes, self.k.GetModuleHandleW.restype = [W.LPCWSTR], W.HMODULE + self.g.CreateSolidBrush.argtypes, self.g.CreateSolidBrush.restype = [W.DWORD], W.HANDLE + o = self.o + o.CoInitializeEx.argtypes, o.CoInitializeEx.restype = [PVOID, W.DWORD], ctypes.c_long + o.CoTaskMemFree.argtypes, o.CoTaskMemFree.restype = [PVOID], None + o.CLSIDFromString.argtypes, o.CLSIDFromString.restype = [W.LPCWSTR, ctypes.POINTER(GUID)], ctypes.c_long + o.CoCreateInstance.argtypes = [ctypes.POINTER(GUID), PVOID, W.DWORD, ctypes.POINTER(GUID), PPVOID] + o.CoCreateInstance.restype = ctypes.c_long + s = self.s + s.SetCurrentProcessExplicitAppUserModelID.argtypes, s.SetCurrentProcessExplicitAppUserModelID.restype = [W.LPCWSTR], ctypes.c_long + s.SHGetPropertyStoreForWindow.argtypes, s.SHGetPropertyStoreForWindow.restype = [H, ctypes.POINTER(GUID), PPVOID], ctypes.c_long + s.ShellExecuteW.argtypes, s.ShellExecuteW.restype = [H, W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, I], W.HINSTANCE + self.d.DwmSetWindowAttribute.argtypes, self.d.DwmSetWindowAttribute.restype = [H, W.DWORD, PVOID, W.DWORD], ctypes.c_long + self.w.SHCreateStreamOnFileEx.argtypes = [W.LPCWSTR, W.DWORD, W.DWORD, B, PVOID, PPVOID] + self.w.SHCreateStreamOnFileEx.restype = ctypes.c_long + + +_DLL = None + + +def _dll(): + global _DLL + if _DLL is None: + _DLL = _Dll() + return _DLL + + +def _log(*partes): + """Lo que le pasa a la ventana, a la consola si la hay (con pythonw no la hay y no pasa nada).""" + try: + if sys.stdout: + print("[ventana]", *partes, flush=True) + except Exception: # noqa + pass + + +# -------------------------------------------------------------------------- +# COM a mano +# -------------------------------------------------------------------------- + +def _guid(texto): + g = GUID() + if _dll().o.CLSIDFromString(texto, ctypes.byref(g)) != 0: + raise ValueError(f"GUID mal escrito: {texto}") + return g + + +_PROTOS = {} + + +def _m(p, indice, *args, tipos=(), res=ctypes.c_long): + """Llama al método que ocupa la posición `indice` en la tabla virtual del objeto COM `p`.""" + clave = (res,) + tuple(tipos) + proto = _PROTOS.get(clave) + if proto is None: + proto = _PROTOS[clave] = ctypes.WINFUNCTYPE(res, PVOID, *tipos) + tabla = ctypes.cast(ctypes.cast(p, PPVOID)[0], PPVOID) + return proto(tabla[indice])(p, *args) + + +def _mas(p): + _m(p, 1, res=ctypes.c_ulong) # AddRef + + +def _soltar(p): + if p: + _m(p, 2, res=ctypes.c_ulong) # Release + + +def _qi(p, iid): + """QueryInterface: el mismo objeto visto como otra interfaz, o None si no la tiene.""" + out = ctypes.c_void_p() + hr = _m(p, 0, ctypes.byref(_guid(iid)), ctypes.byref(out), tipos=(ctypes.POINTER(GUID), PPVOID)) + return out.value if hr >= 0 and out.value else None + + +def _texto(p, indice, *args, tipos=()): + """Una propiedad de texto: el objeto reserva la cadena y aquí se copia y se libera.""" + s = ctypes.c_void_p() + hr = _m(p, indice, *args, ctypes.byref(s), tipos=tuple(tipos) + (PPVOID,)) + if hr < 0 or not s.value: + return "" + try: + return ctypes.wstring_at(s.value) + finally: + _dll().o.CoTaskMemFree(s) + + +def _entero(p, indice, tipo=ctypes.c_int): + v = tipo() + return v.value if _m(p, indice, ctypes.byref(v), tipos=(ctypes.POINTER(tipo),)) >= 0 else 0 + + +_VIVOS = [] # lo que Windows tiene apuntado no puede caer en manos del recolector + + +def _objeto(iid, firma, funcion, fijo=True): + """Un objeto COM mínimo hecho en Python: IUnknown y un solo método, Invoke, que es lo que piden todos + los avisos de WebView2. `firma` son los tipos de los argumentos de Invoke detrás del propio objeto. + Devuelve (puntero, lo que hay que conservar para que siga vivo).""" + aceptados = {bytes(_guid(iid)), bytes(_guid(IID_IUNKNOWN))} + + def consulta(este, riid, salida): + if riid and bytes(riid.contents) in aceptados: + salida[0] = este + return S_OK + if salida: + salida[0] = None + return E_NOINTERFACE + + def cuenta(este): + return 1 # no se destruye solo: lo sujeta Python + + def invocar(este, *args): + try: + r = funcion(*args) + return S_OK if r is None else r + except Exception as ex: # noqa una excepción no puede cruzar a Windows + _log("fallo en un aviso:", repr(ex)) + return E_FAIL + + funcs = [ctypes.WINFUNCTYPE(ctypes.c_long, PVOID, ctypes.POINTER(GUID), PPVOID)(consulta), + ctypes.WINFUNCTYPE(ctypes.c_ulong, PVOID)(cuenta), + ctypes.WINFUNCTYPE(ctypes.c_ulong, PVOID)(cuenta), + ctypes.WINFUNCTYPE(ctypes.c_long, PVOID, *firma)(invocar)] + tabla = (ctypes.c_void_p * len(funcs))(*[ctypes.cast(f, ctypes.c_void_p).value for f in funcs]) + obj = (ctypes.c_void_p * 1)(ctypes.addressof(tabla)) + sosten = (obj, tabla, funcs) + if fijo: + _VIVOS.append(sosten) + return ctypes.addressof(obj), sosten + + +# -------------------------------------------------------------------------- +# Lo que se puede preguntar sin abrir nada +# -------------------------------------------------------------------------- + +def cargador(base): + """Dónde está WebView2Loader.dll: junto al programa.""" + for carpeta in (base, os.path.dirname(os.path.abspath(__file__))): + p = os.path.join(carpeta, "WebView2Loader.dll") + if os.path.isfile(p): + return p + return "" + + +def disponible(base): + """(True, versión de WebView2) si se puede abrir la ventana; (False, por qué no) si no.""" + if os.name != "nt" or ctypes.sizeof(ctypes.c_void_p) != 8: + return False, "la ventana propia solo existe en Windows de 64 bits" + dll = cargador(base) + if not dll: + return False, "falta WebView2Loader.dll junto al programa" + try: + f = ctypes.WinDLL(dll).GetAvailableCoreWebView2BrowserVersionString + f.argtypes, f.restype = [W.LPCWSTR, PPVOID], ctypes.c_long + p = ctypes.c_void_p() + if f(None, ctypes.byref(p)) < 0 or not p.value: + return False, "este Windows no tiene WebView2 (viene con Windows 11 y con Edge)" + try: + return True, ctypes.wstring_at(p.value) + finally: + _dll().o.CoTaskMemFree(p) + except OSError as ex: + return False, f"no se pudo cargar WebView2Loader.dll: {ex}" + + +def _ventanas(marca): + """Las ventanas abiertas en este equipo de la instalación con esa marca (visibles u ocultas).""" + u, h, out = _dll().u, None, [] + while True: + h = u.FindWindowExW(None, h, CLASE, None) + if not h: + return out + if (u.GetPropW(h, PROP_MARCA) or 0) == marca: + out.append(h) + + +def traer_al_frente(marca): + """Si esa instalación de OrganiLAB ya tiene su ventana abierta, la enseña y devuelve True.""" + if os.name != "nt": + return False + u = _dll().u + for h in _ventanas(marca): + pid = W.DWORD() + u.GetWindowThreadProcessId(h, ctypes.byref(pid)) + u.AllowSetForegroundWindow(pid.value) + u.PostMessageW(h, u.RegisterWindowMessageW("OrganiLAB.Mostrar"), 0, 0) # si estaba oculta, vuelve + u.ShowWindowAsync(h, SW_RESTORE if u.IsIconic(h) else SW_SHOW) + u.SetForegroundWindow(h) + return True + return False + + +# -------------------------------------------------------------------------- +# La ventana +# -------------------------------------------------------------------------- + +class Ventana: + """Una ventana de Windows con la interfaz dentro. `mostrar()` no vuelve hasta que se cierra. + + Avisos que puede poner quien la crea (todos opcionales): + al_cerrar() -> texto de lo que hay en marcha, o "" si se puede cerrar sin más + al_soltar(rutas) -> rutas completas de lo que se soltó encima + al_abrir() -> la interfaz ya está a la vista (una sola vez) + """ + + def __init__(self, url, titulo="OrganiLAB", icono="", perfil="", memoria="", base="", marca=0, + tamano=(1280, 820), minimo=(960, 640), fondo=(255, 255, 255), relanzar=("", ""), depurar=False): + self.url, self.titulo, self.icono, self.perfil, self.memoria = url, titulo, icono, perfil, memoria + self.base, self.marca, self.tamano, self.minimo, self.fondo = base, marca, tamano, minimo, fondo + self.relanzar, self.depurar = relanzar, depurar + self.al_cerrar = self.al_soltar = self.al_abrir = None + self.hwnd = None + self.oculta = False # cerrada a la vista, pero viva hasta que termine el trabajo + self.lista = False # la interfaz está cargada y a la vista + self.abierta = False # llegó a cargar alguna vez + self.fallo = "" # por qué no se pudo abrir, si no se pudo + self._hilo = None + self._cola = queue.Queue() + self._entorno = self._control = self._wv = self._barra = None + self._reintentos = self._caidas = 0 + self._guiones = {} + self._origen = url.split("/", 3)[0] + "//" + url.split("/", 3)[2] if "://" in url else url + self._proc = WNDPROC(self._mensaje) + self._msg_mostrar = 0 + ev = (PVOID, PVOID) + self._h_entorno = _objeto(IID_ENTORNO_LISTO, (ctypes.c_long, PVOID), self._entorno_listo)[0] + self._h_control = _objeto(IID_CONTROL_LISTO, (ctypes.c_long, PVOID), self._control_listo)[0] + self._h_empieza = _objeto(IID_NAVEGACION_EMPIEZA, ev, self._navegacion_empieza)[0] + self._h_termina = _objeto(IID_NAVEGACION_TERMINA, ev, self._navegacion_termina)[0] + self._h_nueva = _objeto(IID_VENTANA_NUEVA, ev, self._ventana_nueva)[0] + self._h_mensaje = _objeto(IID_MENSAJE, ev, self._mensaje_web)[0] + self._h_titulo = _objeto(IID_TITULO, ev, self._titulo_cambia)[0] + self._h_permiso = _objeto(IID_PERMISO, ev, self._permiso)[0] + self._h_caido = _objeto(IID_PROCESO_CAIDO, ev, self._proceso_caido)[0] + + # ---- abrir y cerrar ---- + + def mostrar(self): + """Abre la ventana y atiende sus mensajes hasta que se cierra. Devuelve False si la interfaz no + llegó a cargar (y entonces quien llama decide qué hacer: `fallo` dice por qué).""" + D = _dll() + u = D.u + self._hilo = threading.get_ident() + try: + u.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # nítida en cualquier monitor + except Exception: # noqa + pass + D.o.CoInitializeEx(None, 2) # un apartamento: lo exige WebView2 + D.s.SetCurrentProcessExplicitAppUserModelID(IDENTIDAD) + self._msg_mostrar = u.RegisterWindowMessageW("OrganiLAB.Mostrar") + inst = D.k.GetModuleHandleW(None) + escala = self._escala() + grande = u.LoadImageW(None, self.icono, 1, int(32 * escala), int(32 * escala), 0x10) if self.icono else None + chico = u.LoadImageW(None, self.icono, 1, int(16 * escala), int(16 * escala), 0x10) if self.icono else None + clase = WNDCLASSEXW() + clase.cbSize = ctypes.sizeof(WNDCLASSEXW) + clase.lpfnWndProc = self._proc + clase.hInstance = inst + clase.hIcon, clase.hIconSm = grande, chico + clase.hCursor = u.LoadCursorW(None, ctypes.c_void_p(32512)) + r, g, b = self.fondo + clase.hbrBackground = D.g.CreateSolidBrush(r | g << 8 | b << 16) + clase.lpszClassName = CLASE + u.RegisterClassExW(ctypes.byref(clase)) # si ya estaba registrada, vale la que hay + self.hwnd = u.CreateWindowExW(0, CLASE, self.titulo, WS_OVERLAPPEDWINDOW, 0, 0, 100, 100, None, None, inst, None) + if not self.hwnd: + self.fallo = f"Windows no creó la ventana (error {ctypes.get_last_error()})" + return False + u.SetPropW(self.hwnd, PROP_MARCA, self.marca) + if grande: + u.SendMessageW(self.hwnd, WM_SETICON, 1, grande) + u.SendMessageW(self.hwnd, WM_SETICON, 0, chico) + color = ctypes.c_uint32(r | g << 8 | b << 16) # la barra de título, del color de la interfaz (Windows 11) + D.d.DwmSetWindowAttribute(self.hwnd, 35, ctypes.byref(color), 4) + self._identidad() + self._colocar() + try: + self._crear_motor() + except Exception as ex: # noqa + self.fallo = str(ex) + u.DestroyWindow(self.hwnd) + u.SetTimer(self.hwnd, T_GUARDIA, 30000, None) # si en 30 s no hay interfaz, algo va mal + msg = W.MSG() + while u.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: + u.TranslateMessage(ctypes.byref(msg)) + u.DispatchMessageW(ctypes.byref(msg)) + self._vaciar_cola() + for p in (self._barra, self._wv, self._control, self._entorno): + try: + _soltar(p) + except Exception: # noqa + pass + self._barra = self._wv = self._control = self._entorno = None + self.hwnd = None + return self.abierta + + def cerrar(self): + """Cierra de verdad, sin preguntar (desde cualquier hilo).""" + if self.hwnd: + self.en_ui(self._destruir, esperar=False) + + def _destruir(self): + self._recordar_sitio() + if self._control: + try: + _m(self._control, 24) # Close: el motor suelta la ventana + except Exception: # noqa + pass + _dll().u.DestroyWindow(self.hwnd) + + def _pedir_cierre(self): + que = "" + try: + que = self.al_cerrar() if self.al_cerrar else "" + except Exception as ex: # noqa + _log("al_cerrar falló:", repr(ex)) + if not que: + return self._destruir() + u = _dll().u + r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras la ventana, el trabajo sigue hasta " + "terminar y después el programa se cierra solo. Para verlo, vuelve a abrir OrganiLAB.", + self.titulo, MB_OKCANCEL | MB_ICONINFORMATION) + if r != IDOK: + return + self._recordar_sitio() + self.oculta = True + self.progreso(None) + u.ShowWindow(self.hwnd, SW_HIDE) + + def _volver(self): + """La abrieron otra vez mientras trabajaba oculta: vuelve a la vista.""" + u = _dll().u + self.oculta = False + u.ShowWindow(self.hwnd, SW_RESTORE if u.IsIconic(self.hwnd) else SW_SHOW) + u.SetForegroundWindow(self.hwnd) + + def _fallar(self, motivo): + _log("no se pudo abrir la interfaz:", motivo) + self.fallo = motivo + if self.hwnd: + _dll().u.DestroyWindow(self.hwnd) + return S_OK + + # ---- el marco ---- + + def _escala(self): + u = _dll().u + try: + return (u.GetDpiForWindow(self.hwnd) if self.hwnd else u.GetDpiForSystem()) / 96.0 + except Exception: # noqa Windows anteriores a la 1607 + return 1.0 + + def _identidad(self): + """Con esto la barra de tareas sabe que la ventana es OrganiLAB (no «Python») y, si se ancla, qué + tiene que volver a lanzar.""" + exe, args = self.relanzar + if not exe: + return + D = _dll() + almacen = ctypes.c_void_p() + if D.s.SHGetPropertyStoreForWindow(self.hwnd, ctypes.byref(_guid(IID_PROPIEDADES)), ctypes.byref(almacen)) < 0: + return + try: + orden = f'"{exe}" {args}'.strip() + for pid, valor in ((5, IDENTIDAD), (2, orden), (4, self.titulo), (3, self.icono)): + if not valor: + continue + clave = PROPERTYKEY(_guid(FMT_APPUSERMODEL), pid) + bufer = ctypes.create_unicode_buffer(valor) + pv = PROPVARIANT(31, 0, 0, 0, ctypes.cast(bufer, ctypes.c_void_p).value, None) # VT_LPWSTR + _m(almacen, 6, ctypes.byref(clave), ctypes.byref(pv), tipos=(ctypes.POINTER(PROPERTYKEY), ctypes.POINTER(PROPVARIANT))) + _m(almacen, 7) # Commit + finally: + _soltar(almacen.value) + + def _colocar(self): + """Donde se dejó la última vez; la primera, centrada. Si aquel monitor ya no está, también centrada.""" + u = _dll().u + try: + with open(self.memoria, "r", encoding="utf-8") as fh: + m = json.load(fh) + sitio = W.RECT(int(m["x"]), int(m["y"]), int(m["x"]) + int(m["ancho"]), int(m["y"]) + int(m["alto"])) + if sitio.right - sitio.left < 200 or sitio.bottom - sitio.top < 150 or not u.MonitorFromRect(ctypes.byref(sitio), 0): + raise ValueError("fuera de pantalla") + modo = SW_SHOWMAXIMIZED if m.get("maximizada") else SW_SHOWNORMAL + except Exception: # noqa sin memoria, ilegible o de otro monitor + escala = self._escala() + ancho, alto = int(self.tamano[0] * escala), int(self.tamano[1] * escala) + info = MONITORINFO() + info.cbSize = ctypes.sizeof(MONITORINFO) + u.GetMonitorInfoW(u.MonitorFromRect(ctypes.byref(W.RECT(0, 0, 1, 1)), 1), ctypes.byref(info)) + t = info.trabajo + ancho, alto = min(ancho, t.right - t.left), min(alto, t.bottom - t.top) + x, y = t.left + (t.right - t.left - ancho) // 2, t.top + (t.bottom - t.top - alto) // 2 + sitio, modo = W.RECT(x, y, x + ancho, y + alto), SW_SHOWNORMAL + wp = WINDOWPLACEMENT() + wp.length = ctypes.sizeof(WINDOWPLACEMENT) + wp.showCmd, wp.normal = modo, sitio + u.SetWindowPlacement(self.hwnd, ctypes.byref(wp)) + u.UpdateWindow(self.hwnd) + + def _recordar_sitio(self): + if not self.memoria or not self.hwnd: + return + wp = WINDOWPLACEMENT() + wp.length = ctypes.sizeof(WINDOWPLACEMENT) + if not _dll().u.GetWindowPlacement(self.hwnd, ctypes.byref(wp)): + return + n = wp.normal + try: + with open(self.memoria, "w", encoding="utf-8") as fh: + json.dump({"x": n.left, "y": n.top, "ancho": n.right - n.left, "alto": n.bottom - n.top, + "maximizada": wp.showCmd == SW_SHOWMAXIMIZED}, fh) + except OSError: + pass + + def _acomodar(self): + """La interfaz ocupa todo el interior de la ventana.""" + if not self._control: + return + r = W.RECT() + _dll().u.GetClientRect(self.hwnd, ctypes.byref(r)) + _m(self._control, 6, ctypes.byref(r), tipos=(ctypes.POINTER(W.RECT),)) # put_Bounds + + def _mensaje(self, hwnd, msg, wp, lp): + u = _dll().u + try: + if msg == WM_SIZE: + if self._control: + _m(self._control, 4, 0 if wp == SIZE_MINIMIZED else 1, tipos=(ctypes.c_int,)) # minimizada no pinta + self._acomodar() + return 0 + if msg == WM_MOVE: + if self._control: + _m(self._control, 23) # los menús de la interfaz siguen a la ventana + return 0 + if msg == WM_SETFOCUS: + if self._control: + _m(self._control, 12, 0, tipos=(ctypes.c_int,)) # el teclado, a la interfaz + return 0 + if msg == WM_GETMINMAXINFO: + info = ctypes.cast(lp, ctypes.POINTER(MINMAXINFO)).contents + escala = self._escala() + info.minimo.x, info.minimo.y = int(self.minimo[0] * escala), int(self.minimo[1] * escala) + return 0 + if msg == WM_DPICHANGED: + r = ctypes.cast(lp, ctypes.POINTER(W.RECT)).contents + u.SetWindowPos(hwnd, None, r.left, r.top, r.right - r.left, r.bottom - r.top, 0x0014) + return 0 + if msg == MSG_LLAMAR: + self._vaciar_cola(correr=True) + return 0 + if msg == self._msg_mostrar and msg: + self._volver() + return 0 + if msg == WM_TIMER: + if wp == T_GUARDIA: + u.KillTimer(hwnd, T_GUARDIA) + if not self.lista: + self._fallar("la interfaz no cargó en 30 segundos") + elif wp == T_REINTENTO: + u.KillTimer(hwnd, T_REINTENTO) + if self._wv: + _m(self._wv, 5, self.url, tipos=(W.LPCWSTR,)) + return 0 + if msg == WM_CLOSE: + self._pedir_cierre() + return 0 + if msg == WM_DESTROY: + u.PostQuitMessage(0) + return 0 + except Exception as ex: # noqa + _log("fallo atendiendo el mensaje", hex(msg), repr(ex)) + return u.DefWindowProcW(hwnd, msg, wp, lp) + + # ---- el motor (WebView2) ---- + + def _crear_motor(self): + dll = cargador(self.base) + if not dll: + raise RuntimeError("falta WebView2Loader.dll junto al programa") + crear = ctypes.WinDLL(dll).CreateCoreWebView2EnvironmentWithOptions + crear.argtypes, crear.restype = [W.LPCWSTR, W.LPCWSTR, PVOID, PVOID], ctypes.c_long + os.makedirs(self.perfil, exist_ok=True) + os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = ARGUMENTOS + hr = crear(None, self.perfil, None, self._h_entorno) + if hr < 0: + raise RuntimeError(f"WebView2 no arrancó (0x{hr & 0xFFFFFFFF:08X})") + + def _entorno_listo(self, hr, entorno): + os.environ.pop("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", None) # era solo para este arranque + if hr < 0 or not entorno: + return self._fallar(f"WebView2 no se pudo iniciar (0x{hr & 0xFFFFFFFF:08X})") + _mas(entorno) + self._entorno = entorno + hr = _m(entorno, 3, self.hwnd, self._h_control, tipos=(W.HWND, PVOID)) + if hr < 0: + return self._fallar(f"WebView2 no creó la vista (0x{hr & 0xFFFFFFFF:08X})") + + def _control_listo(self, hr, control): + if hr < 0 or not control: + return self._fallar(f"WebView2 no creó la vista (0x{hr & 0xFFFFFFFF:08X})") + _mas(control) + self._control = control + wv = ctypes.c_void_p() + _m(control, 25, ctypes.byref(wv), tipos=(PPVOID,)) + self._wv = wv.value + c2 = _qi(control, IID_CONTROL2) + if c2: # mientras carga, el fondo de la interfaz y no un blanco de navegador + r, g, b = self.fondo + _m(c2, 27, ctypes.c_uint32(255 | r << 8 | g << 16 | b << 24), tipos=(ctypes.c_uint32,)) + _soltar(c2) + self._ajustar() + self._acomodar() + ficha = ctypes.c_int64() + for indice, aviso in ((7, self._h_empieza), (15, self._h_termina), (23, self._h_permiso), (25, self._h_caido), + (34, self._h_mensaje), (44, self._h_nueva), (46, self._h_titulo)): + _m(self._wv, indice, aviso, ctypes.byref(ficha), tipos=(PVOID, ctypes.POINTER(ctypes.c_int64))) + _m(self._wv, 27, GUION, None, tipos=(W.LPCWSTR, PVOID)) + _m(self._wv, 5, self.url, tipos=(W.LPCWSTR,)) # Navigate + + def _ajustar(self): + """Fuera lo que es de un navegador y no de un programa.""" + aj = ctypes.c_void_p() + _m(self._wv, 3, ctypes.byref(aj), tipos=(PPVOID,)) + aj = aj.value + + def pon(obj, indice, valor): + _m(obj, indice, 1 if valor else 0, tipos=(ctypes.c_int,)) + pon(aj, 10, False) # la barra de estado con la dirección de cada enlace + pon(aj, 12, self.depurar) # las herramientas de desarrollo + pon(aj, 18, False) # el zoom con Ctrl + rueda + pon(aj, 20, False) # la página de error de Edge + for iid, pares in ((IID_AJUSTES3, ((24, self.depurar),)), # F5, Ctrl+P, Ctrl+F, F12… + (IID_AJUSTES4, ((26, False), (28, False))), # guardar contraseñas y autorrellenar + (IID_AJUSTES5, ((30, False),)), # el zoom de pellizco + (IID_AJUSTES6, ((32, False),)), # atrás y adelante deslizando + (IID_AJUSTES8, ((36, False),))): # consultar a SmartScreen cada dirección + q = _qi(aj, iid) + if q: + for indice, valor in pares: + pon(q, indice, valor) + _soltar(q) + _soltar(aj) + + def _es_nuestra(self, uri): + return uri == self._origen or uri.startswith((self._origen + "/", "about:", "data:")) + + def _navegacion_empieza(self, _wv, args): + uri = _texto(args, 3) + if self._es_nuestra(uri): + return + _m(args, 8, 1, tipos=(ctypes.c_int,)) # un enlace hacia fuera no se abre aquí… + self._abrir_fuera(uri) + + def _ventana_nueva(self, _wv, args): + _m(args, 6, 1, tipos=(ctypes.c_int,)) # …ni en una ventana de navegador sin marco + self._abrir_fuera(_texto(args, 3)) + + def _abrir_fuera(self, uri): + if uri.lower().startswith(("http://", "https://", "mailto:")): + _dll().s.ShellExecuteW(self.hwnd, "open", uri, None, None, SW_SHOWNORMAL) + + def _navegacion_termina(self, _wv, args): + u = _dll().u + if _entero(args, 3): + if not self.lista: + self.lista = True + self._reintentos = 0 + u.KillTimer(self.hwnd, T_GUARDIA) + _m(self._control, 12, 0, tipos=(ctypes.c_int,)) + if not self.abierta: + self.abierta = True + if self.al_abrir: + threading.Thread(target=self.al_abrir, daemon=True).start() + return + if self.lista: + return # una recarga que falló: la página ya sabe reintentar + self._reintentos += 1 + if self._reintentos > 20: + return self._fallar("la interfaz no respondió") + u.SetTimer(self.hwnd, T_REINTENTO, 400, None) + + def _titulo_cambia(self, _wv, _args): + t = _texto(self._wv, 48) + if t: + _dll().u.SetWindowTextW(self.hwnd, t) + + def _permiso(self, _wv, args): + _m(args, 7, 2, tipos=(ctypes.c_int,)) # cámara, micrófono, ubicación…: no, sin preguntar + + def _proceso_caido(self, _wv, args): + clase = _entero(args, 3) + _log("un proceso del motor se cayó, clase", clase) + if clase == 1: # el que pinta: se recarga y sigue + _m(self._wv, 31) + elif clase == 0: # el motor entero: se monta otro + self._caidas += 1 + self._luego(self._remontar) + + def _remontar(self): + for p in (self._wv, self._control, self._entorno): + try: + _soltar(p) + except Exception: # noqa + pass + self._wv = self._control = self._entorno = None + self.lista = False + if self._caidas > 3: + _dll().u.MessageBoxW(self.hwnd, "La ventana de OrganiLAB se cerró por un problema de Windows. Lo que estuviera " + "en marcha sigue; vuelve a abrir el programa para verlo.", self.titulo, MB_OK | MB_ICONWARNING) + return _dll().u.DestroyWindow(self.hwnd) + try: + self._crear_motor() + _dll().u.SetTimer(self.hwnd, T_GUARDIA, 30000, None) + except Exception as ex: # noqa + self._fallar(str(ex)) + + def _mensaje_web(self, _wv, args): + if _texto(args, 5) != "soltado": + return + a2 = _qi(args, IID_MENSAJE_ARGS2) + if not a2: + return + rutas, cosas = [], ctypes.c_void_p() + try: + if _m(a2, 6, ctypes.byref(cosas), tipos=(PPVOID,)) < 0 or not cosas.value: + return + for i in range(_entero(cosas.value, 3, ctypes.c_uint32)): + cosa = ctypes.c_void_p() + if _m(cosas.value, 4, i, ctypes.byref(cosa), tipos=(ctypes.c_uint32, PPVOID)) < 0 or not cosa.value: + continue + archivo = _qi(cosa.value, IID_ARCHIVO) + if archivo: + ruta = _texto(archivo, 3) + if ruta: + rutas.append(ruta) + _soltar(archivo) + _soltar(cosa.value) + finally: + _soltar(cosas.value) + _soltar(a2) + if rutas and self.al_soltar: + self.al_soltar(rutas) + + # ---- lo que se le puede pedir desde cualquier hilo ---- + + def en_ui(self, funcion, esperar=True, plazo=None): + """Corre `funcion` en el hilo de la ventana (Windows y COM solo dejan tocarla desde ahí).""" + if threading.get_ident() == self._hilo: + return funcion() + caja = {"listo": threading.Event(), "r": None, "ex": None} + self._cola.put((funcion, caja)) + if not self.hwnd or not _dll().u.PostMessageW(self.hwnd, MSG_LLAMAR, 0, 0): + return None + if not esperar: + return None + caja["listo"].wait(plazo) + if caja["ex"]: + raise caja["ex"] + return caja["r"] + + def _luego(self, funcion): + """Como en_ui, pero siempre después: nunca dentro del aviso que se está atendiendo.""" + self._cola.put((funcion, {"listo": threading.Event(), "r": None, "ex": None})) + _dll().u.PostMessageW(self.hwnd, MSG_LLAMAR, 0, 0) + + def _vaciar_cola(self, correr=False): + while True: + try: + funcion, caja = self._cola.get_nowait() + except queue.Empty: + return + try: + if correr: + caja["r"] = funcion() + except Exception as ex: # noqa + caja["ex"] = ex + finally: + caja["listo"].set() + + def mensaje(self, texto, botones=MB_OK | MB_ICONINFORMATION): + """Un cuadro de mensaje de Windows sobre la ventana. Devuelve el botón pulsado.""" + return self.en_ui(lambda: _dll().u.MessageBoxW(self.hwnd, texto, self.titulo, botones)) + + def elegir_carpeta(self, titulo): + """El selector de carpetas de Windows, modal sobre la ventana. Devuelve la ruta o None.""" + return self.en_ui(lambda: self._elegir_carpeta(titulo)) + + def _elegir_carpeta(self, titulo): + D = _dll() + d = ctypes.c_void_p() + if D.o.CoCreateInstance(ctypes.byref(_guid(CLSID_ABRIR)), None, 1, ctypes.byref(_guid(IID_ABRIR)), ctypes.byref(d)) < 0: + return None + d = d.value + try: + op = _entero(d, 10, W.DWORD) # GetOptions + _m(d, 9, op | 0x20 | 0x40 | 0x800 | 0x8, tipos=(W.DWORD,)) # carpetas, del disco, que existan, sin cambiar de directorio + _m(d, 17, titulo, tipos=(W.LPCWSTR,)) + _m(d, 18, "Elegir carpeta", tipos=(W.LPCWSTR,)) + if _m(d, 3, self.hwnd, tipos=(W.HWND,)) < 0: # Show; cancelar también es «menor que cero» + return None + item = ctypes.c_void_p() + if _m(d, 20, ctypes.byref(item), tipos=(PPVOID,)) < 0 or not item.value: + return None + try: + return _texto(item.value, 5, 0x80058000, tipos=(ctypes.c_uint32,)) or None # la ruta en disco + finally: + _soltar(item.value) + finally: + _soltar(d) + + def progreso(self, fraccion): + """El avance en el botón de la barra de tareas: 0..1, -1 si no se sabe cuánto falta, None para quitarlo.""" + self.en_ui(lambda: self._progreso(fraccion), esperar=False) + + def _progreso(self, fraccion): + D = _dll() + if not self._barra: + if fraccion is None: + return + b = ctypes.c_void_p() + if D.o.CoCreateInstance(ctypes.byref(_guid(CLSID_BARRA)), None, 1, ctypes.byref(_guid(IID_BARRA3)), ctypes.byref(b)) < 0: + return + self._barra = b.value + _m(self._barra, 3) # HrInit + if fraccion is None: + _m(self._barra, 10, self.hwnd, 0, tipos=(W.HWND, ctypes.c_int)) + elif fraccion < 0: + _m(self._barra, 10, self.hwnd, 1, tipos=(W.HWND, ctypes.c_int)) + else: + _m(self._barra, 10, self.hwnd, 2, tipos=(W.HWND, ctypes.c_int)) + _m(self._barra, 9, self.hwnd, int(max(0.0, min(1.0, fraccion)) * 1000), 1000, + tipos=(W.HWND, ctypes.c_ulonglong, ctypes.c_ulonglong)) + + def avisar(self): + """Parpadea en la barra de tareas si se está mirando otra cosa (terminó algo largo).""" + def parpadeo(): + u = _dll().u + if u.GetForegroundWindow() == self.hwnd: + return + f = FLASHWINFO(ctypes.sizeof(FLASHWINFO), self.hwnd, 0x2 | 0xC, 0, 0) # el botón, hasta que se mire + u.FlashWindowEx(ctypes.byref(f)) + self.en_ui(parpadeo, esperar=False) + + def evaluar(self, codigo, plazo=10): + """Corre JavaScript en la interfaz y devuelve su resultado (lo que quepa en JSON). Desde otro hilo: + la respuesta llega por el bucle de mensajes, y el de la ventana no puede esperarse a sí mismo.""" + caja = {"listo": threading.Event(), "r": None} + + def hecho(hr, resultado): + caja["r"] = resultado if hr >= 0 else None + self._guiones.pop(id(caja), None) + caja["listo"].set() + + def lanzar(): + p, sosten = _objeto(IID_GUION_LISTO, (ctypes.c_long, W.LPCWSTR), hecho, fijo=False) + self._guiones[id(caja)] = sosten + if not self._wv or _m(self._wv, 29, codigo, p, tipos=(W.LPCWSTR, PVOID)) < 0: + hecho(-1, None) + self.en_ui(lanzar, esperar=False) + caja["listo"].wait(plazo) + try: + return json.loads(caja["r"]) if caja["r"] else None + except ValueError: + return None + + def capturar(self, ruta, plazo=15): + """Guarda en un PNG lo que la interfaz tiene pintado ahora. Devuelve True si se guardó.""" + caja = {"listo": threading.Event(), "ok": False} + + def lanzar(): + flujo = ctypes.c_void_p() + if _dll().w.SHCreateStreamOnFileEx(ruta, 0x1011, 0x80, True, None, ctypes.byref(flujo)) < 0 or not flujo.value: + return caja["listo"].set() + + def hecho(hr): + _m(flujo.value, 8, 0, tipos=(W.DWORD,)) # Commit + _soltar(flujo.value) + caja["ok"] = hr >= 0 + self._guiones.pop(id(caja), None) + caja["listo"].set() + p, sosten = _objeto(IID_CAPTURA_LISTA, (ctypes.c_long,), hecho, fijo=False) + self._guiones[id(caja)] = sosten + if not self._wv or _m(self._wv, 30, 0, flujo.value, p, tipos=(ctypes.c_int, PVOID, PVOID)) < 0: + hecho(-1) + self.en_ui(lanzar, esperar=False) + caja["listo"].wait(plazo) + return caja["ok"]