Files
labstream-organizador/ventana.py
Labstream 58197ffd67 Cerrar lo para todo, continuar sigue por donde iba, convertir antes de ordenar, y lo grande a la vista
Munka: «que al cerrar cierre todos los procesos… que al continuar la cola siga por donde iba y vuelva a generar los
videos que hagan falta… que identifique bien el material… y convertir antes de mover». Cerrar con trabajo avisa y
lo para todo (cola, lectura, respaldo, traer), cierra lo que quede con lo que lanzó y borra lo que quedó a medias.
Lo cortado vuelve a esperar en su sitio (no «cancelado») y al continuar se hace entero; también vuelven los fallos
por un corte y los convertidos que ya no están. Actualizar con la cola en marcha la detiene y sigue sola al volver.
El Convertidor enseña lo grande de la carpeta y qué pasa con cada parte (exportados sin incluir, con un botón).
Convertir va antes de ordenar en los pasos y en Inicio; lo que se está convirtiendo no se mueve, y una carpeta con
algo abierto ya no se copia entera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:55:10 -05:00

996 líneas
46 KiB
Python

# -*- 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 = self.al_detener = 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
if not self.al_detener:
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)
return
# Cerrar para todo (Munka, 14-oct-2026): la conversión que va se corta y vuelve a la cola, y la próxima vez
# «Continuar» sigue por donde iba. Antes el trabajo seguía oculto y, al actualizar, quedaban procesos sueltos.
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras, se detiene todo. Lo que iba a medias "
"vuelve a la cola, y la próxima vez «Continuar» sigue por donde iba (lo cortado se hace entero otra vez).",
self.titulo, MB_OKCANCEL | MB_ICONQUESTION)
if r != IDOK:
return
self._recordar_sitio()
self.oculta = True
self.progreso(None)
u.ShowWindow(self.hwnd, SW_HIDE)
def parar():
try:
self.al_detener()
except Exception as ex: # noqa
_log("al_detener falló:", repr(ex))
finally:
if self.oculta: # si mientras se paraba la volvieron a abrir, se queda abierta (ya sin nada en marcha)
self.cerrar()
threading.Thread(target=parar, daemon=True).start()
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"]