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