Files
labstream-organizador/ventana.py
Labstream 252b1fd00e 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>
2026-10-04 09:54:31 -05:00

973 líneas
45 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 = 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"]