3.0.0: OrganiLAB pasa a ser un programa de Windows

- ventana.py: la ventana es un marco Win32 propio con WebView2 dentro,
  manejado por COM con ctypes. Fuera pywebview, pythonnet y clr_loader y
  sus binarios sin firmar. Identidad en la barra de tareas, una sola copia
  abierta por carpeta de datos, selector de carpetas de Windows, rutas
  completas al soltar, avance en la barra de tareas, memoria de posición,
  aviso al cerrar con trabajo en marcha, y nada de navegador (menú, F5,
  zoom, permisos). Si el motor se cae, se monta de nuevo solo.
- arranque.py y empaquetar.py: el paquete ya no lleva binario propio.
  OrganizadorLabstream.exe es pythonw.exe (firmado por la PSF) con otro
  nombre y arranca el programa desde sitecustomize; abre con Smart App
  Control encendido. Fuera PyInstaller. Al empaquetar se comprueba la firma
  de cada binario con Windows y se arranca el motor del paquete.
- instalar.py: accesos directos en Inicio y Escritorio con la identidad de
  la ventana, y la línea en Configuración › Aplicaciones. Se ofrece una vez,
  la primera que se abre un paquete en un equipo.
- Dos copias sobre los mismos datos ya no se pisan: la segunda trae la
  ventana al frente (antes abría el navegador y daba por huérfanas las
  conversiones en marcha de la primera). Si el puerto 8765 está ocupado
  por otro programa, se toma uno libre.
- Por consola, el paquete ya no consulta si hay versión nueva con las
  actualizaciones automáticas apagadas.
- Pruebas: probar.py 17, prueba_funcional.py 26 de 26 y prueba_ventana.py
  (nueva), que abre la ventana de verdad: 17 comprobaciones desde el código
  y 8 arrancando el paquete por su .exe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-04 09:54:31 -05:00
padre 2daffc175a
commit 252b1fd00e
Se han modificado 14 ficheros con 2275 adiciones y 165 borrados

Ver fichero

@@ -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',
)

109
README.md
Ver fichero

@@ -1,16 +1,24 @@
# Organizador Labstream # Organizador Labstream
App local (Python, sin dependencias) para **inventariar, mapear, ordenar y optimizar** el material de los Programa de Windows (Python, sin dependencias) para **inventariar, mapear, ordenar y optimizar** el material
clientes de Labstream repartido por los discos del estudio. 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 ← inventario + plan + cola de conversión, y el servidor local con que habla la interfaz
organizador.py ← servidor + inventario + plan + cola de conversión 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 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 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/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 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 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 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) ## 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, 1. Se abre desde el menú Inicio o el Escritorio (o con doble clic en `OrganizadorLabstream.exe`, en la carpeta
DaVinci Resolve Studio abierto con scripting externo en *Local*. 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. 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) 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** 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 ### 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 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 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»), 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\`. 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 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-<fecha>` y se avisa en consola. Con prueba. decir nada. Ahora se aparta en `config.json.roto-<fecha>` 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 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ó 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: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

BIN
WebView2Loader.dll Archivo normal

Archivo binario no mostrado.

Ver fichero

@@ -167,7 +167,7 @@ def _descargar_y_aplicar(relanzar_args):
return False return False
if not O.CONGELADO: if not O.CONGELADO:
ESTADO["estado"] = "error" 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 return False
ocupado = tarea_en_marcha() ocupado = tarea_en_marcha()
if ocupado: if ocupado:

68
arranque.py Archivo normal
Ver fichero

@@ -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()

Ver fichero

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
r""" 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] Uso: py -3.12 empaquetar.py [ruta\a\ffmpeg\bin]
Resultado: Resultado:
dist\OrganizadorLabstream\ carpeta completa (con ffmpeg) dist\OrganizadorLabstream\ carpeta completa (con ffmpeg)
@@ -8,23 +8,44 @@ Resultado:
OrganizadorLabstream-app-<v>.zip actualización (sin ffmpeg ni datos, ~15 MB) OrganizadorLabstream-app-<v>.zip actualización (sin ffmpeg ni datos, ~15 MB)
..\labstream-os\public\herramientas\organizador.json manifiesto para el OS (versión, tamaños, sha256) ..\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. 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 os
import re import re
import sys import sys
import glob import glob
import json import json
import uuid
import ctypes
import shutil import shutil
import hashlib import hashlib
import zipfile import zipfile
import datetime import datetime
import tempfile
import py_compile
import subprocess import subprocess
from ctypes import wintypes as W
AQUI = os.path.dirname(os.path.abspath(__file__)) AQUI = os.path.dirname(os.path.abspath(__file__))
os.chdir(AQUI) os.chdir(AQUI)
VERSION = re.search(r'^VERSION = "([^"]+)"', open("organizador.py", encoding="utf-8").read(), re.M).group(1) VERSION = re.search(r'^VERSION = "([^"]+)"', open("organizador.py", encoding="utf-8").read(), re.M).group(1)
print("versión", VERSION) 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 "" ffbin = sys.argv[1] if len(sys.argv) > 1 else ""
if not ffbin: if not ffbin:
for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")): 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")) ffbin = os.path.dirname(shutil.which("ffmpeg"))
print("ffmpeg desde:", ffbin or "(no encontrado: el zip irá sin ffmpeg)") print("ffmpeg desde:", ffbin or "(no encontrado: el zip irá sin ffmpeg)")
for d in ("build", "dist"):
shutil.rmtree(d, ignore_errors=True) def firmado(ruta):
subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed", """True si Windows da por buena la firma del archivo."""
"--name", "OrganizadorLabstream", "--icon", os.path.join("static", "organilab.ico"), class Archivo(ctypes.Structure):
"--hidden-import", "resolve_bridge", "--hidden-import", "reglas", _fields_ = [("cbStruct", W.DWORD), ("pcwszFilePath", W.LPCWSTR), ("hFile", W.HANDLE), ("pgKnownSubject", ctypes.c_void_p)]
"--hidden-import", "asistente", "--hidden-import", "actualizador", "--hidden-import", "firma",
"--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet", class Datos(ctypes.Structure):
"--exclude-module", "tkinter", "organizador.py"]) _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") 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")) 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) os.makedirs(os.path.join(dest, "datos"), exist_ok=True)
for f in ("config.json", "diccionario.json", "plantillas.json"): for f in ("config.json", "diccionario.json", "plantillas.json"):
src = os.path.join("datos", f) src = os.path.join("datos", f)
if os.path.exists(src): if os.path.exists(src):
shutil.copy(src, os.path.join(dest, "datos", f)) 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") cfg_p = os.path.join(dest, "datos", "config.json")
if os.path.exists(cfg_p): if os.path.exists(cfg_p):
cfg = json.load(open(cfg_p, encoding="utf-8")) 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 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) json.dump(cfg, open(cfg_p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
shutil.copy("README.md", dest) 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: with open(os.path.join(dest, "Apartar stock Envato viejo (arrastra aqui).bat"), "w", encoding="cp1252") as fh:
fh.write(BAT.format(cmd="stock")) fh.write(BAT.format(cmd="stock"))
with open(os.path.join(dest, "LEEME.txt"), "w", encoding="utf-8") as fh: 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" fh.write(f"OrganiLAB · Organizador Labstream {VERSION}\n\n"
"ORDENAR UNA CARPETA: arrastra la carpeta del cliente encima de «Organizar carpeta (arrastra aqui).bat»\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" "(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" "Por consola: OrganizadorLabstream.exe asistente \"D:\\Clientes\\Carpeta\" [--simular] [--si]\n"
"Más comandos: OrganizadorLabstream.exe ayuda\n\n" "Más comandos: OrganizadorLabstream.exe ayuda\n\n"
"1. Doble clic en OrganizadorLabstream.exe abre OrganiLAB en su propia ventana (sin navegador).\n" "1. No hace falta instalar nada: Python y ffmpeg van dentro.\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" "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" "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" "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" with open(os.path.join(motor, "AVISOS.txt"), "w", encoding="utf-8") as fh:
"Si Windows SmartScreen avisa, «Más información» → «Ejecutar de todas formas».\n") 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 ── # ── paquete APP (actualización): todo menos ffmpeg y datos ──
app_zip = f"OrganizadorLabstream-app-{VERSION}.zip" 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 ── # ── paquete COMPLETO (primera instalación): con ffmpeg ──
if ffbin: if ffbin:
os.makedirs(os.path.join(dest, "ffmpeg"), exist_ok=True) os.makedirs(os.path.join(dest, "ffmpeg"), exist_ok=True)
for exe in ("ffmpeg.exe", "ffprobe.exe"): for exe_ff in ("ffmpeg.exe", "ffprobe.exe"):
shutil.copy(os.path.join(ffbin, exe), os.path.join(dest, "ffmpeg", 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") lic = os.path.join(os.path.dirname(ffbin), "LICENSE")
if os.path.exists(lic): if os.path.exists(lic):
shutil.copy(lic, os.path.join(dest, "ffmpeg", "LICENSE.txt")) 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") sys.exit("la clave privada no corresponde a CLAVE_PUBLICA de actualizador.py; el manifiesto no serviría")
print("manifiesto firmado") print("manifiesto firmado")
ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json") 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 = {} previo = {}
try: try:
previo = json.load(open(ruta_os, encoding="utf-8")) previo = json.load(open(ruta_os, encoding="utf-8"))

238
instalar.py Archivo normal
Ver fichero

@@ -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

Ver fichero

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "2.6.2", "version": "3.0.0",
"builtAt": "2026-09-25T13:05:26.000Z", "builtAt": "2026-10-04T14:50:25.000Z",
"archivo": "OrganizadorLabstream-portatil-2.6.2.zip", "archivo": "OrganizadorLabstream-portatil-3.0.0.zip",
"size": 198766962, "size": 195378394,
"sha256": "a9fc3a1ad72a0473cda1c163400ff6ef542105c04075da823cb2c75344754006", "sha256": "98c43f1f06865f233e98d4b4b8f2cae82e1386def7b38673ce5a4cd01bc850dd",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-2.6.2.zip", "archivo": "OrganizadorLabstream-app-3.0.0.zip",
"size": 14947203, "size": 11565815,
"sha256": "97573e047f879249fb7770db9ba4a480b0341cea889e56c0a368858e2d3ca38b", "sha256": "09d9024a41370bdd07dbbd3fe1110822c0f51ce4987f62cf9dd185ad9aa3e82e",
"url": "/api/herramientas/OrganizadorLabstream-app-2.6.2.zip" "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": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "886666421a2acccc37e09fcf52c8275ce1f5a7f524bc359e1d0b93a993fe10b59394b8996964a3733c2f710d871e3426eb584e13071800d92ce24fd22eb4a8f007ffb36055eb0bf72ed6cd40791c360eef9b922bde682f97e881647b31c371dd0c1112da4d4bc7e6bcb456220726e7c2736294c538a6c586ac5b09241ece81909b01843090f3044f93d8116df26aeccfca12c4aad3f2cb131397504a6a1558bc21f5113b064438b9fbdad52e1a4a7d8844af523b5986da66d644d377c7b3bc8f6cf6d55cc925db9652d366944ef544a0ef083dbd92fcd8eefcc0f988c04e097bb28adf6de063dd1a51f7e4fc543b3be6a505f2d254394b8cef0d4672397058d2" "valor": "6456976cf21259a5ae91b5ba5cc8074b552cfec0a389f8b9c89c7af962841cdb523f8f39b7143281d7d965079ee0471fc8b7f14c79636d2cc443315131a3fc53ec6a22ff87b1c49d806e4a795c2365b83f8236a523a1349730b90300eff68cc84ad32f58e663798e77301024f870e094c1fa112b994d55f719c1cdf8c22b0375f6eb24276bfa9837d755ef07c71e9b701df073fd4c94cf3ddccc6e5a2203e48458398beb4f38d2d68ebab2810a0ac56add4b9294321cb4eb3083f1e2d717bd2b2a6b638cadb8140e48f4d46e3e0a13686b462477bb8e9a3923f7a178c5e326f65a8ae90ff68949ac6a5bd2cf5758a53dd79779c79b4f67414f0251aae3f9566e"
} }
} }

Ver fichero

@@ -2,10 +2,12 @@
""" """
Organizador Labstream — inventario, mapa, orden y optimización del material. 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 Solo librería estándar de Python (3.10+). Es un programa de Windows: abre su
interfaz en el navegador: 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 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. movimiento queda en datos/bitacora.jsonl para poder deshacerlo.
@@ -37,11 +39,14 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "2.6.2" VERSION = "3.0.0"
CONGELADO = getattr(sys, "frozen", False) BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller # Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
BASE = os.path.dirname(os.path.abspath(sys.executable if CONGELADO else __file__)) # 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") DATOS = os.path.join(BASE, "datos")
os.makedirs(DATOS, exist_ok=True) os.makedirs(DATOS, exist_ok=True)
DB_PATH = os.path.join(DATOS, "inventario.sqlite") 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") lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json")
with open(lote, "w", encoding="utf-8") as fh: with open(lote, "w", encoding="utf-8") as fh:
json.dump(jobs, fh, ensure_ascii=False) json.dump(jobs, fh, ensure_ascii=False)
if CONGELADO and not o.get("python_resolve"): # el puente corre con el mismo motor que el programa (en el paquete, su propio .exe haciendo de intérprete)
cmd = [sys.executable, "--puente", lote] # el mismo .exe hace de puente cmd = [o.get("python_resolve") or sys.executable, os.path.join(BASE, "resolve_bridge.py"), lote]
else:
py = o.get("python_resolve") or sys.executable
cmd = [py, os.path.join(BASE, "resolve_bridge.py"), lote]
try: try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex: except OSError as ex:
@@ -2889,13 +2891,20 @@ def _clave(ruta):
_ELIGIENDO = threading.Lock() _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(): 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): if not _ELIGIENDO.acquire(blocking=False):
raise ValueError("ya hay un selector de carpetas abierto") raise ValueError("ya hay un selector de carpetas abierto")
try: 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; " ps = ("Add-Type -AssemblyName System.Windows.Forms; "
"$d = New-Object System.Windows.Forms.FolderBrowserDialog; " "$d = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; " "$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; "
@@ -3777,6 +3786,7 @@ class Manejador(BaseHTTPRequestHandler):
return True return True
def do_GET(self): def do_GET(self):
ULTIMA_PETICION[0] = time.time()
if self.path.startswith("/api/") and not self._es_local(): if self.path.startswith("/api/") and not self._es_local():
return self._json({"error": "petición no permitida"}, 403) return self._json({"error": "petición no permitida"}, 403)
u = urlparse(self.path) u = urlparse(self.path)
@@ -3917,6 +3927,7 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500) return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
def do_POST(self): def do_POST(self):
ULTIMA_PETICION[0] = time.time()
u = urlparse(self.path) u = urlparse(self.path)
ruta = u.path ruta = u.path
if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"): 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 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 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 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) 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": []} 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): def _al_soltar(rutas):
try: """Lo que se acaba de soltar en la ventana, con su ruta completa: la ventana la sabe, la interfaz no."""
rutas = [f.get("pywebviewFullPath") for f in (e.get("dataTransfer") or {}).get("files", []) if f.get("pywebviewFullPath")]
except Exception: # noqa
rutas = []
if rutas: if rutas:
SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas]) SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas])
def soltado(nombre, espera=2.5): 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 """Ruta completa de lo que se acaba de soltar en la ventana. Espera un poco porque el aviso de la ventana
porque el drop del navegador y el aviso del puente no llegan en el mismo orden.""" y la pregunta de la interfaz no llegan siempre en el mismo orden."""
fin = time.time() + espera fin = time.time() + espera
while time.time() < fin: while time.time() < fin:
if time.time() - SOLTADOS["ts"] < 8: if time.time() - SOLTADOS["ts"] < 8:
@@ -4368,46 +4380,181 @@ def soltado(nombre, espera=2.5):
return None return None
def _ventana(url): def en_marcha():
"""Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador.""" """Qué está haciendo el programa ahora mismo, dicho en llano, o "" si no hace nada. Mientras haya algo,
try: cerrar la ventana no lo corta."""
import webview que = actualizador.tarea_en_marcha()
except Exception: # noqa if not que and APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
webbrowser.open(url) que = "se están moviendo archivos"
return False return que
try:
v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff")
def _al_cargar():
# WebView2 entrega la ruta completa de lo que se suelta solo por este puente (el navegador no la da): def avance():
# se guarda y la interfaz la pide en /api/soltado en cuanto ve el drop. """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: try:
v.dom.document.events.drop += _al_soltar con = db()
except Exception as ex: # noqa espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0]
print("sin puente de arrastre:", ex) t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None
v.events.loaded += _al_cargar con.close()
webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana")) 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 return True
except Exception as ex: # noqa k = ctypes.WinDLL("kernel32", use_last_error=True)
print("Sin WebView2; abro el navegador:", ex) k.CreateMutexW.argtypes, k.CreateMutexW.restype = [ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p], ctypes.c_void_p
webbrowser.open(url) h = k.CreateMutexW(None, False, f"OrganiLAB-{_marca():08x}")
if h and ctypes.get_last_error() == 183: # ya existía
return False 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(): def main():
global PUERTO
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")] 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: if args_tmp and "--puente" not in sys.argv:
_asegurar_consola() _asegurar_consola()
try: try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception: # noqa except Exception: # noqa
pass 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 import resolve_bridge
sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1])) 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")] args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")]
if args: 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() actualizador.comprobar_en_segundo_plano()
codigo = cli(args) codigo = cli(args)
d = actualizador.ESTADO.get("disponible") d = actualizador.ESTADO.get("disponible")
@@ -4419,6 +4566,16 @@ def main():
except EOFError: except EOFError:
pass pass
sys.exit(codigo) 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() h = herramientas()
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"]) 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)") print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)")
@@ -4428,15 +4585,20 @@ def main():
try: try:
srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador) srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador)
except OSError: except OSError:
print(f"Ya hay un Organizador abierto en el puerto {PUERTO}; abro esa ventana.") if os.environ.get("ORGANIZADOR_PUERTO"):
webbrowser.open(f"http://127.0.0.1:{PUERTO}/") print(f"El puerto {PUERTO} está ocupado por otro programa.")
return 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}/" url = f"http://127.0.0.1:{PUERTO}/"
guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": PUERTO})
if sys.stdout: 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: if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True:
actualizador.comprobar_en_segundo_plano() 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: try:
srv.serve_forever() srv.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -4444,6 +4606,7 @@ def main():
return return
hilo = threading.Thread(target=srv.serve_forever, daemon=True) hilo = threading.Thread(target=srv.serve_forever, daemon=True)
hilo.start() hilo.start()
threading.Thread(target=_limpiar_restos, daemon=True).start()
if "--navegador" in sys.argv: if "--navegador" in sys.argv:
webbrowser.open(url) webbrowser.open(url)
try: try:
@@ -4451,11 +4614,17 @@ def main():
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
return return
# ventana propia: al cerrarla se cierra el programa (salvo que haya trabajo en marcha) if not _ventana(url):
_ventana(url) webbrowser.open(url)
while COLA["activa"] or RESPALDO.get("estado") == "corriendo" or asistente.ESTADO.get("activo"): _atender_navegador()
while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir
time.sleep(1) time.sleep(1)
srv.shutdown() srv.shutdown()
finally:
try:
os.remove(SESION_PATH)
except OSError:
pass
if __name__ == "__main__": if __name__ == "__main__":

140
probar.py
Ver fichero

@@ -90,6 +90,131 @@ class Config(unittest.TestCase):
shutil.rmtree(tmp, ignore_errors=True) 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): class Firma(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -143,7 +268,7 @@ class Servidor(unittest.TestCase):
cls.tmp = tempfile.mkdtemp(prefix="organilab-e2e-") cls.tmp = tempfile.mkdtemp(prefix="organilab-e2e-")
cls.app = os.path.join(cls.tmp, "app") cls.app = os.path.join(cls.tmp, "app")
os.makedirs(cls.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.copy(os.path.join(AQUI, f), cls.app)
shutil.copytree(os.path.join(AQUI, "static"), os.path.join(cls.app, "static")) shutil.copytree(os.path.join(AQUI, "static"), os.path.join(cls.app, "static"))
# carpeta de ensayo: cámara Sony, basura y un proxy # 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()) out.append(os.path.relpath(os.path.join(d, f), self.raiz).lower())
return sorted(out) 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): def test_recorrido(self):
q = "?raiz=" + urllib.parse.quote(self.raiz) q = "?raiz=" + urllib.parse.quote(self.raiz)
# 0. una instalación nueva abre en blanco: ninguna carpeta puesta de fábrica # 0. una instalación nueva abre en blanco: ninguna carpeta puesta de fábrica

Ver fichero

@@ -19,7 +19,7 @@ AQUI = os.path.dirname(os.path.abspath(__file__))
TMP = tempfile.mkdtemp(prefix='organilab-smoke-') TMP = tempfile.mkdtemp(prefix='organilab-smoke-')
APP = os.path.join(TMP, 'app') APP = os.path.join(TMP, 'app')
os.makedirs(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.copy(os.path.join(AQUI, f), APP)
shutil.copytree(os.path.join(AQUI, 'static'), os.path.join(APP, 'static')) shutil.copytree(os.path.join(AQUI, 'static'), os.path.join(APP, 'static'))
RAIZ = os.path.join(TMP, 'Cliente Smoke') RAIZ = os.path.join(TMP, 'Cliente Smoke')

373
prueba_ventana.py Archivo normal
Ver fichero

@@ -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("<IiiHHIIiiII", 40, ancho, -alto, 1, 32, 0, 0, 0, 0, 0, 0), 64)
buf = ctypes.create_string_buffer(ancho * alto * 4)
G.GetDIBits(mem, bmp, 0, alto, buf, cab, 0)
G.DeleteObject(bmp), G.DeleteDC(mem), U.ReleaseDC(hwnd, dc)
filas, paso, bgra = bytearray(), ancho * 4, buf.raw
for y in range(alto):
fila, rgb = bgra[y * paso:(y + 1) * paso], bytearray(ancho * 3)
rgb[0::3], rgb[1::3], rgb[2::3] = fila[2::4], fila[1::4], fila[0::4]
filas += b"\x00" + rgb
def trozo(t, d):
return 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())

Ver fichero

@@ -26,6 +26,9 @@
button:disabled{opacity:.45;cursor:default;border-color:var(--line)!important;background:var(--panel2)!important;color:var(--mut)!important} button:disabled{opacity:.45;cursor:default;border-color:var(--line)!important;background:var(--panel2)!important;color:var(--mut)!important}
button .spin{margin-right:6px} button .spin{margin-right:6px}
.por-que{font-size:12px;color:var(--mut);margin-left:2px} .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 ── */ /* ── 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} 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} .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'); ACT_SIGUIENDO = true; const caja = $('#actualizacionCaja'); caja.style.display = ''; irA('carpeta');
for (;;) { for (;;) {
let a; try { a = await api('/api/actualizacion'); } catch (e) { a = null; } let a; try { a = await api('/api/actualizacion'); } catch (e) { a = null; }
if (!a) { caja.innerHTML = '<h3>Reiniciando…</h3><p class="mut">El programa se está sustituyendo por la versión nueva. La página se recargará sola.</p>'; 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 = '<h3>Reiniciando…</h3><p class="mut">El programa se está sustituyendo por la versión nueva y vuelve a abrirse solo.</p>'; 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 = `<h3>No se pudo actualizar</h3><p class="bad">${esc(a.error)}</p>`; ACT_SIGUIENDO = false; return; } if (a.estado === 'error') { caja.innerHTML = `<h3>No se pudo actualizar</h3><p class="bad">${esc(a.error)}</p>`; ACT_SIGUIENDO = false; return; }
if (a.estado === 'reiniciando' || a.aplicando) { caja.innerHTML = '<h3>Instalando…</h3><p class="mut">El programa se cierra y vuelve a abrirse con la versión nueva. La página se recargará sola.</p>'; await new Promise(r => setTimeout(r, 2000)); continue; } if (a.estado === 'reiniciando' || a.aplicando) { caja.innerHTML = '<h3>Instalando…</h3><p class="mut">El programa se cierra y vuelve a abrirse solo con la versión nueva.</p>'; await new Promise(r => setTimeout(r, 2000)); continue; }
caja.innerHTML = `<h3>${a.estado === 'verificando' ? 'Verificando' : 'Descargando'} la versión ${esc(a.disponible ? a.disponible.version : '')}</h3><span class="barra" style="width:100%"><i style="width:${a.pct}%"></i></span><p class="mut">${(a.bytes / 1e6).toFixed(0)} / ${(a.total / 1e6).toFixed(0)} MB${a.error ? ' · ' + esc(a.error) : ''}</p>`; caja.innerHTML = `<h3>${a.estado === 'verificando' ? 'Verificando' : 'Descargando'} la versión ${esc(a.disponible ? a.disponible.version : '')}</h3><span class="barra" style="width:100%"><i style="width:${a.pct}%"></i></span><p class="mut">${(a.bytes / 1e6).toFixed(0)} / ${(a.total / 1e6).toFixed(0)} MB${a.error ? ' · ' + esc(a.error) : ''}</p>`;
await new Promise(r => setTimeout(r, 700)); await new Promise(r => setTimeout(r, 700));
} }

972
ventana.py Archivo normal
Ver fichero

@@ -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"]