# -*- 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"]