Munka: «que al cerrar cierre todos los procesos… que al continuar la cola siga por donde iba y vuelva a generar los videos que hagan falta… que identifique bien el material… y convertir antes de mover». Cerrar con trabajo avisa y lo para todo (cola, lectura, respaldo, traer), cierra lo que quede con lo que lanzó y borra lo que quedó a medias. Lo cortado vuelve a esperar en su sitio (no «cancelado») y al continuar se hace entero; también vuelven los fallos por un corte y los convertidos que ya no están. Actualizar con la cola en marcha la detiene y sigue sola al volver. El Convertidor enseña lo grande de la carpeta y qué pasa con cada parte (exportados sin incluir, con un botón). Convertir va antes de ordenar en los pasos y en Inicio; lo que se está convirtiendo no se mueve, y una carpeta con algo abierto ya no se copia entera. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
996 líneas
46 KiB
Python
996 líneas
46 KiB
Python
# -*- coding: utf-8 -*-
|
|
r"""
|
|
La ventana de OrganiLAB: un programa de Windows con su marco, su icono y su sitio en la barra de tareas.
|
|
|
|
El marco es Win32 de verdad (RegisterClassEx, CreateWindowEx y un bucle de mensajes); lo de dentro lo
|
|
pinta WebView2, el componente que trae Windows para eso. Todo va con ctypes sobre DLL del sistema: sin
|
|
pywebview, sin pythonnet y sin .NET, que eran tres dependencias con binarios sin firmar. Smart App
|
|
Control puede parar un binario sin firmar el día menos pensado, y entonces el programa acababa abierto
|
|
en el navegador.
|
|
|
|
Lo único que no es de Windows ni de Python es WebView2Loader.dll (160 KB, firmada por Microsoft): es
|
|
la entrada documentada a WebView2 y viaja con el programa.
|
|
|
|
Los IID y las posiciones en las tablas virtuales no están escritos de memoria: salen del ensamblado
|
|
Microsoft.Web.WebView2.Core.dll, que declara cada interfaz COM con sus métodos en orden.
|
|
|
|
Qué da de más respecto a una página en un marco:
|
|
- identidad propia en la barra de tareas (se puede anclar y vuelve a abrir el programa, no «Python»)
|
|
- una sola copia abierta: lanzarlo otra vez trae esta ventana al frente
|
|
- recuerda dónde y de qué tamaño se dejó
|
|
- las carpetas que se sueltan llegan con su ruta completa
|
|
- el selector de carpetas de Windows, modal sobre la ventana
|
|
- avance en el botón de la barra de tareas, y un parpadeo al terminar si se está en otra cosa
|
|
- nada de navegador: sin menú de «Atrás / Recargar / Inspeccionar», sin F5, sin zoom, sin permisos
|
|
"""
|
|
import os
|
|
import sys
|
|
import json
|
|
import queue
|
|
import ctypes
|
|
import threading
|
|
from ctypes import wintypes as W
|
|
|
|
LRESULT = ctypes.c_ssize_t
|
|
WNDPROC = ctypes.WINFUNCTYPE(LRESULT, W.HWND, W.UINT, W.WPARAM, W.LPARAM)
|
|
PVOID = ctypes.c_void_p
|
|
PPVOID = ctypes.POINTER(ctypes.c_void_p)
|
|
S_OK, E_NOINTERFACE, E_FAIL = 0, -2147467262, -2147467259
|
|
|
|
WM_DESTROY, WM_MOVE, WM_SIZE, WM_SETFOCUS, WM_CLOSE = 0x0002, 0x0003, 0x0005, 0x0007, 0x0010
|
|
WM_GETMINMAXINFO, WM_SETICON, WM_TIMER, WM_DPICHANGED, WM_APP = 0x0024, 0x0080, 0x0113, 0x02E0, 0x8000
|
|
MSG_LLAMAR = WM_APP + 1 # hay llamadas de otros hilos esperando en la cola
|
|
SW_HIDE, SW_SHOWNORMAL, SW_SHOWMINIMIZED, SW_SHOWMAXIMIZED, SW_SHOW, SW_RESTORE = 0, 1, 2, 3, 5, 9
|
|
WS_OVERLAPPEDWINDOW, SIZE_MINIMIZED = 0x00CF0000, 1
|
|
MB_OK, MB_OKCANCEL, MB_YESNO, MB_ICONWARNING, MB_ICONINFORMATION, MB_ICONQUESTION = 0x0, 0x1, 0x4, 0x30, 0x40, 0x20
|
|
IDOK, IDYES = 1, 6
|
|
T_GUARDIA, T_REINTENTO = 1, 2 # temporizadores de la ventana
|
|
|
|
CLASE = "OrganiLAB.Ventana"
|
|
PROP_MARCA = "OrganiLAB.datos" # qué instalación es (un número que sale de su carpeta de datos)
|
|
IDENTIDAD = "Labstream.OrganiLAB" # AppUserModelID: la misma en la ventana y en los accesos directos
|
|
|
|
IID_IUNKNOWN = "{00000000-0000-0000-C000-000000000046}"
|
|
IID_ENTORNO_LISTO = "{4E8A3389-C9D8-4BD2-B6B5-124FEE6CC14D}"
|
|
IID_CONTROL_LISTO = "{6C4819F3-C9B7-4260-8127-C9F5BDE7F68C}"
|
|
IID_NAVEGACION_EMPIEZA = "{9ADBE429-F36D-432B-9DDC-F8881FBD76E3}"
|
|
IID_NAVEGACION_TERMINA = "{D33A35BF-1C49-4F98-93AB-006E0533FE1C}"
|
|
IID_VENTANA_NUEVA = "{D4C185FE-C81C-4989-97AF-2D3FA7AB5651}"
|
|
IID_MENSAJE = "{57213F19-00E6-49FA-8E07-898EA01ECBD2}"
|
|
IID_TITULO = "{F5F2B923-953E-4042-9F95-F3A118E1AFD4}"
|
|
IID_PERMISO = "{15E1C6A3-C72A-4DF3-91D7-D097FBEC6BFD}"
|
|
IID_PROCESO_CAIDO = "{79E0AEA4-990B-42D9-AA1D-0FCC2E5BC7F1}"
|
|
IID_GUION_LISTO = "{49511172-CC67-4BCA-9923-137112F4C4CC}"
|
|
IID_CAPTURA_LISTA = "{697E05E9-3D8F-45FA-96F4-8FFE1EDEDAF5}"
|
|
IID_CONTROL2 = "{C979903E-D4CA-4228-92EB-47EE3FA96EAB}"
|
|
IID_AJUSTES3 = "{FDB5AB74-AF33-4854-84F0-0A631DEB5EBA}"
|
|
IID_AJUSTES4 = "{CB56846C-4168-4D53-B04F-03B6D6796FF2}"
|
|
IID_AJUSTES5 = "{183E7052-1D03-43A0-AB99-98E043B66B39}"
|
|
IID_AJUSTES6 = "{11CB3ACD-9BC8-43B8-83BF-F40753714F87}"
|
|
IID_AJUSTES8 = "{9E6B0E8F-86AD-4E81-8147-A9B5EDB68650}"
|
|
IID_MENSAJE_ARGS2 = "{06FC7AB7-C90C-4297-9389-33CA01CF6D5E}"
|
|
IID_ARCHIVO = "{F2C19559-6BC1-4583-A757-90021BE9AFEC}"
|
|
CLSID_BARRA, IID_BARRA3 = "{56FDF344-FD6D-11D0-958A-006097C9A090}", "{EA1AFB91-9E28-4B86-90E9-9E9F8A5EEFAF}"
|
|
CLSID_ABRIR, IID_ABRIR = "{DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7}", "{D57C7288-D4AD-4768-BE02-9D969532D960}"
|
|
IID_PROPIEDADES = "{886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99}"
|
|
FMT_APPUSERMODEL = "{9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}"
|
|
|
|
# El motor no sale a la red. Lo primero le quita las tareas de fondo (componentes, estadísticas, pings);
|
|
# lo segundo es el cerrojo: fuera de este equipo no resuelve ningún nombre, y lo que aun así quisiera salir
|
|
# va a un proxy que no existe. La interfaz se sirve desde 127.0.0.1, que nunca pasa por el proxy.
|
|
ARGUMENTOS = ('--disable-background-networking --disable-component-update --disable-domain-reliability --no-pings '
|
|
'--proxy-server=127.0.0.1:9 --host-resolver-rules="MAP * ~NOTFOUND, EXCLUDE 127.0.0.1"')
|
|
|
|
# Se inyecta antes que la página. Las carpetas soltadas viajan al anfitrión, que es quien puede leer su
|
|
# ruta (el motor no se la da a la página). Y el menú del navegador solo sale donde sirve: al escribir o
|
|
# con texto seleccionado.
|
|
GUION = r"""
|
|
(function () {
|
|
if (window.__organilab) return;
|
|
window.__organilab = true;
|
|
document.addEventListener('drop', function (e) {
|
|
try {
|
|
var fs = e.dataTransfer && e.dataTransfer.files;
|
|
if (fs && fs.length) window.chrome.webview.postMessageWithAdditionalObjects('soltado', fs);
|
|
} catch (_) {}
|
|
}, true);
|
|
document.addEventListener('contextmenu', function (e) {
|
|
var t = e.target;
|
|
var campo = t && t.closest && t.closest('input, textarea, [contenteditable=""], [contenteditable="true"]');
|
|
if (!campo && !String(window.getSelection())) e.preventDefault();
|
|
});
|
|
document.addEventListener('DOMContentLoaded', function () { document.documentElement.classList.add('nativo'); });
|
|
})();
|
|
"""
|
|
|
|
|
|
class GUID(ctypes.Structure):
|
|
_fields_ = [("a", ctypes.c_uint32), ("b", ctypes.c_uint16), ("c", ctypes.c_uint16), ("d", ctypes.c_ubyte * 8)]
|
|
|
|
|
|
class WNDCLASSEXW(ctypes.Structure):
|
|
_fields_ = [("cbSize", W.UINT), ("style", W.UINT), ("lpfnWndProc", WNDPROC), ("cbClsExtra", ctypes.c_int),
|
|
("cbWndExtra", ctypes.c_int), ("hInstance", W.HINSTANCE), ("hIcon", W.HANDLE), ("hCursor", W.HANDLE),
|
|
("hbrBackground", W.HANDLE), ("lpszMenuName", W.LPCWSTR), ("lpszClassName", W.LPCWSTR), ("hIconSm", W.HANDLE)]
|
|
|
|
|
|
class WINDOWPLACEMENT(ctypes.Structure):
|
|
_fields_ = [("length", W.UINT), ("flags", W.UINT), ("showCmd", W.UINT), ("ptMin", W.POINT), ("ptMax", W.POINT), ("normal", W.RECT)]
|
|
|
|
|
|
class MINMAXINFO(ctypes.Structure):
|
|
_fields_ = [("reservado", W.POINT), ("maximo", W.POINT), ("posicion", W.POINT), ("minimo", W.POINT), ("tope", W.POINT)]
|
|
|
|
|
|
class MONITORINFO(ctypes.Structure):
|
|
_fields_ = [("cbSize", W.DWORD), ("monitor", W.RECT), ("trabajo", W.RECT), ("dwFlags", W.DWORD)]
|
|
|
|
|
|
class FLASHWINFO(ctypes.Structure):
|
|
_fields_ = [("cbSize", W.UINT), ("hwnd", W.HWND), ("dwFlags", W.DWORD), ("uCount", W.UINT), ("dwTimeout", W.DWORD)]
|
|
|
|
|
|
class PROPERTYKEY(ctypes.Structure):
|
|
_fields_ = [("fmtid", GUID), ("pid", W.DWORD)]
|
|
|
|
|
|
class PROPVARIANT(ctypes.Structure):
|
|
_fields_ = [("vt", ctypes.c_ushort), ("r1", ctypes.c_ushort), ("r2", ctypes.c_ushort), ("r3", ctypes.c_ushort),
|
|
("valor", ctypes.c_void_p), ("resto", ctypes.c_void_p)]
|
|
|
|
|
|
class _Dll:
|
|
"""Las DLL del sistema, con sus firmas puestas: en 64 bits un manejador no cabe en el int que ctypes
|
|
supone si no se le dice otra cosa."""
|
|
def __init__(self):
|
|
u = self.u = ctypes.WinDLL("user32", use_last_error=True)
|
|
self.k = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
self.g = ctypes.WinDLL("gdi32")
|
|
self.o = ctypes.WinDLL("ole32")
|
|
self.s = ctypes.WinDLL("shell32")
|
|
self.d = ctypes.WinDLL("dwmapi")
|
|
self.w = ctypes.WinDLL("shlwapi")
|
|
H, I, B = W.HWND, ctypes.c_int, W.BOOL
|
|
for nombre, args, res in (
|
|
("DefWindowProcW", [H, W.UINT, W.WPARAM, W.LPARAM], LRESULT),
|
|
("RegisterClassExW", [ctypes.POINTER(WNDCLASSEXW)], W.ATOM),
|
|
("CreateWindowExW", [W.DWORD, W.LPCWSTR, W.LPCWSTR, W.DWORD, I, I, I, I, H, W.HMENU, W.HINSTANCE, PVOID], H),
|
|
("DestroyWindow", [H], B), ("ShowWindow", [H, I], B), ("ShowWindowAsync", [H, I], B), ("UpdateWindow", [H], B),
|
|
("GetMessageW", [ctypes.POINTER(W.MSG), H, W.UINT, W.UINT], I),
|
|
("TranslateMessage", [ctypes.POINTER(W.MSG)], B), ("DispatchMessageW", [ctypes.POINTER(W.MSG)], LRESULT),
|
|
("PostMessageW", [H, W.UINT, W.WPARAM, W.LPARAM], B), ("SendMessageW", [H, W.UINT, W.WPARAM, W.LPARAM], LRESULT),
|
|
("PostQuitMessage", [I], None), ("GetClientRect", [H, ctypes.POINTER(W.RECT)], B),
|
|
("LoadImageW", [W.HINSTANCE, W.LPCWSTR, W.UINT, I, I, W.UINT], W.HANDLE), ("LoadCursorW", [W.HINSTANCE, PVOID], W.HANDLE),
|
|
("SetWindowTextW", [H, W.LPCWSTR], B), ("GetWindowPlacement", [H, ctypes.POINTER(WINDOWPLACEMENT)], B),
|
|
("SetWindowPlacement", [H, ctypes.POINTER(WINDOWPLACEMENT)], B),
|
|
("MonitorFromRect", [ctypes.POINTER(W.RECT), W.DWORD], W.HANDLE), ("GetMonitorInfoW", [W.HANDLE, ctypes.POINTER(MONITORINFO)], B),
|
|
("SetWindowPos", [H, H, I, I, I, I, W.UINT], B), ("SetTimer", [H, ctypes.c_size_t, W.UINT, PVOID], ctypes.c_size_t),
|
|
("KillTimer", [H, ctypes.c_size_t], B), ("MessageBoxW", [H, W.LPCWSTR, W.LPCWSTR, W.UINT], I),
|
|
("FindWindowExW", [H, H, W.LPCWSTR, W.LPCWSTR], H), ("SetForegroundWindow", [H], B), ("GetForegroundWindow", [], H),
|
|
("IsIconic", [H], B), ("IsWindowVisible", [H], B), ("IsWindow", [H], B), ("FlashWindowEx", [ctypes.POINTER(FLASHWINFO)], B),
|
|
("RegisterWindowMessageW", [W.LPCWSTR], W.UINT), ("SetPropW", [H, W.LPCWSTR, W.HANDLE], B), ("GetPropW", [H, W.LPCWSTR], W.HANDLE),
|
|
("GetWindowThreadProcessId", [H, ctypes.POINTER(W.DWORD)], W.DWORD), ("AllowSetForegroundWindow", [W.DWORD], B),
|
|
):
|
|
f = getattr(u, nombre)
|
|
f.argtypes, f.restype = args, res
|
|
try: # no existen antes de Windows 10 1607
|
|
u.GetDpiForWindow.argtypes, u.GetDpiForWindow.restype = [H], W.UINT
|
|
u.GetDpiForSystem.argtypes, u.GetDpiForSystem.restype = [], W.UINT
|
|
u.SetProcessDpiAwarenessContext.argtypes, u.SetProcessDpiAwarenessContext.restype = [PVOID], B
|
|
except AttributeError:
|
|
pass
|
|
self.k.GetModuleHandleW.argtypes, self.k.GetModuleHandleW.restype = [W.LPCWSTR], W.HMODULE
|
|
self.g.CreateSolidBrush.argtypes, self.g.CreateSolidBrush.restype = [W.DWORD], W.HANDLE
|
|
o = self.o
|
|
o.CoInitializeEx.argtypes, o.CoInitializeEx.restype = [PVOID, W.DWORD], ctypes.c_long
|
|
o.CoTaskMemFree.argtypes, o.CoTaskMemFree.restype = [PVOID], None
|
|
o.CLSIDFromString.argtypes, o.CLSIDFromString.restype = [W.LPCWSTR, ctypes.POINTER(GUID)], ctypes.c_long
|
|
o.CoCreateInstance.argtypes = [ctypes.POINTER(GUID), PVOID, W.DWORD, ctypes.POINTER(GUID), PPVOID]
|
|
o.CoCreateInstance.restype = ctypes.c_long
|
|
s = self.s
|
|
s.SetCurrentProcessExplicitAppUserModelID.argtypes, s.SetCurrentProcessExplicitAppUserModelID.restype = [W.LPCWSTR], ctypes.c_long
|
|
s.SHGetPropertyStoreForWindow.argtypes, s.SHGetPropertyStoreForWindow.restype = [H, ctypes.POINTER(GUID), PPVOID], ctypes.c_long
|
|
s.ShellExecuteW.argtypes, s.ShellExecuteW.restype = [H, W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, I], W.HINSTANCE
|
|
self.d.DwmSetWindowAttribute.argtypes, self.d.DwmSetWindowAttribute.restype = [H, W.DWORD, PVOID, W.DWORD], ctypes.c_long
|
|
self.w.SHCreateStreamOnFileEx.argtypes = [W.LPCWSTR, W.DWORD, W.DWORD, B, PVOID, PPVOID]
|
|
self.w.SHCreateStreamOnFileEx.restype = ctypes.c_long
|
|
|
|
|
|
_DLL = None
|
|
|
|
|
|
def _dll():
|
|
global _DLL
|
|
if _DLL is None:
|
|
_DLL = _Dll()
|
|
return _DLL
|
|
|
|
|
|
def _log(*partes):
|
|
"""Lo que le pasa a la ventana, a la consola si la hay (con pythonw no la hay y no pasa nada)."""
|
|
try:
|
|
if sys.stdout:
|
|
print("[ventana]", *partes, flush=True)
|
|
except Exception: # noqa
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# COM a mano
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _guid(texto):
|
|
g = GUID()
|
|
if _dll().o.CLSIDFromString(texto, ctypes.byref(g)) != 0:
|
|
raise ValueError(f"GUID mal escrito: {texto}")
|
|
return g
|
|
|
|
|
|
_PROTOS = {}
|
|
|
|
|
|
def _m(p, indice, *args, tipos=(), res=ctypes.c_long):
|
|
"""Llama al método que ocupa la posición `indice` en la tabla virtual del objeto COM `p`."""
|
|
clave = (res,) + tuple(tipos)
|
|
proto = _PROTOS.get(clave)
|
|
if proto is None:
|
|
proto = _PROTOS[clave] = ctypes.WINFUNCTYPE(res, PVOID, *tipos)
|
|
tabla = ctypes.cast(ctypes.cast(p, PPVOID)[0], PPVOID)
|
|
return proto(tabla[indice])(p, *args)
|
|
|
|
|
|
def _mas(p):
|
|
_m(p, 1, res=ctypes.c_ulong) # AddRef
|
|
|
|
|
|
def _soltar(p):
|
|
if p:
|
|
_m(p, 2, res=ctypes.c_ulong) # Release
|
|
|
|
|
|
def _qi(p, iid):
|
|
"""QueryInterface: el mismo objeto visto como otra interfaz, o None si no la tiene."""
|
|
out = ctypes.c_void_p()
|
|
hr = _m(p, 0, ctypes.byref(_guid(iid)), ctypes.byref(out), tipos=(ctypes.POINTER(GUID), PPVOID))
|
|
return out.value if hr >= 0 and out.value else None
|
|
|
|
|
|
def _texto(p, indice, *args, tipos=()):
|
|
"""Una propiedad de texto: el objeto reserva la cadena y aquí se copia y se libera."""
|
|
s = ctypes.c_void_p()
|
|
hr = _m(p, indice, *args, ctypes.byref(s), tipos=tuple(tipos) + (PPVOID,))
|
|
if hr < 0 or not s.value:
|
|
return ""
|
|
try:
|
|
return ctypes.wstring_at(s.value)
|
|
finally:
|
|
_dll().o.CoTaskMemFree(s)
|
|
|
|
|
|
def _entero(p, indice, tipo=ctypes.c_int):
|
|
v = tipo()
|
|
return v.value if _m(p, indice, ctypes.byref(v), tipos=(ctypes.POINTER(tipo),)) >= 0 else 0
|
|
|
|
|
|
_VIVOS = [] # lo que Windows tiene apuntado no puede caer en manos del recolector
|
|
|
|
|
|
def _objeto(iid, firma, funcion, fijo=True):
|
|
"""Un objeto COM mínimo hecho en Python: IUnknown y un solo método, Invoke, que es lo que piden todos
|
|
los avisos de WebView2. `firma` son los tipos de los argumentos de Invoke detrás del propio objeto.
|
|
Devuelve (puntero, lo que hay que conservar para que siga vivo)."""
|
|
aceptados = {bytes(_guid(iid)), bytes(_guid(IID_IUNKNOWN))}
|
|
|
|
def consulta(este, riid, salida):
|
|
if riid and bytes(riid.contents) in aceptados:
|
|
salida[0] = este
|
|
return S_OK
|
|
if salida:
|
|
salida[0] = None
|
|
return E_NOINTERFACE
|
|
|
|
def cuenta(este):
|
|
return 1 # no se destruye solo: lo sujeta Python
|
|
|
|
def invocar(este, *args):
|
|
try:
|
|
r = funcion(*args)
|
|
return S_OK if r is None else r
|
|
except Exception as ex: # noqa una excepción no puede cruzar a Windows
|
|
_log("fallo en un aviso:", repr(ex))
|
|
return E_FAIL
|
|
|
|
funcs = [ctypes.WINFUNCTYPE(ctypes.c_long, PVOID, ctypes.POINTER(GUID), PPVOID)(consulta),
|
|
ctypes.WINFUNCTYPE(ctypes.c_ulong, PVOID)(cuenta),
|
|
ctypes.WINFUNCTYPE(ctypes.c_ulong, PVOID)(cuenta),
|
|
ctypes.WINFUNCTYPE(ctypes.c_long, PVOID, *firma)(invocar)]
|
|
tabla = (ctypes.c_void_p * len(funcs))(*[ctypes.cast(f, ctypes.c_void_p).value for f in funcs])
|
|
obj = (ctypes.c_void_p * 1)(ctypes.addressof(tabla))
|
|
sosten = (obj, tabla, funcs)
|
|
if fijo:
|
|
_VIVOS.append(sosten)
|
|
return ctypes.addressof(obj), sosten
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Lo que se puede preguntar sin abrir nada
|
|
# --------------------------------------------------------------------------
|
|
|
|
def cargador(base):
|
|
"""Dónde está WebView2Loader.dll: junto al programa."""
|
|
for carpeta in (base, os.path.dirname(os.path.abspath(__file__))):
|
|
p = os.path.join(carpeta, "WebView2Loader.dll")
|
|
if os.path.isfile(p):
|
|
return p
|
|
return ""
|
|
|
|
|
|
def disponible(base):
|
|
"""(True, versión de WebView2) si se puede abrir la ventana; (False, por qué no) si no."""
|
|
if os.name != "nt" or ctypes.sizeof(ctypes.c_void_p) != 8:
|
|
return False, "la ventana propia solo existe en Windows de 64 bits"
|
|
dll = cargador(base)
|
|
if not dll:
|
|
return False, "falta WebView2Loader.dll junto al programa"
|
|
try:
|
|
f = ctypes.WinDLL(dll).GetAvailableCoreWebView2BrowserVersionString
|
|
f.argtypes, f.restype = [W.LPCWSTR, PPVOID], ctypes.c_long
|
|
p = ctypes.c_void_p()
|
|
if f(None, ctypes.byref(p)) < 0 or not p.value:
|
|
return False, "este Windows no tiene WebView2 (viene con Windows 11 y con Edge)"
|
|
try:
|
|
return True, ctypes.wstring_at(p.value)
|
|
finally:
|
|
_dll().o.CoTaskMemFree(p)
|
|
except OSError as ex:
|
|
return False, f"no se pudo cargar WebView2Loader.dll: {ex}"
|
|
|
|
|
|
def _ventanas(marca):
|
|
"""Las ventanas abiertas en este equipo de la instalación con esa marca (visibles u ocultas)."""
|
|
u, h, out = _dll().u, None, []
|
|
while True:
|
|
h = u.FindWindowExW(None, h, CLASE, None)
|
|
if not h:
|
|
return out
|
|
if (u.GetPropW(h, PROP_MARCA) or 0) == marca:
|
|
out.append(h)
|
|
|
|
|
|
def traer_al_frente(marca):
|
|
"""Si esa instalación de OrganiLAB ya tiene su ventana abierta, la enseña y devuelve True."""
|
|
if os.name != "nt":
|
|
return False
|
|
u = _dll().u
|
|
for h in _ventanas(marca):
|
|
pid = W.DWORD()
|
|
u.GetWindowThreadProcessId(h, ctypes.byref(pid))
|
|
u.AllowSetForegroundWindow(pid.value)
|
|
u.PostMessageW(h, u.RegisterWindowMessageW("OrganiLAB.Mostrar"), 0, 0) # si estaba oculta, vuelve
|
|
u.ShowWindowAsync(h, SW_RESTORE if u.IsIconic(h) else SW_SHOW)
|
|
u.SetForegroundWindow(h)
|
|
return True
|
|
return False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# La ventana
|
|
# --------------------------------------------------------------------------
|
|
|
|
class Ventana:
|
|
"""Una ventana de Windows con la interfaz dentro. `mostrar()` no vuelve hasta que se cierra.
|
|
|
|
Avisos que puede poner quien la crea (todos opcionales):
|
|
al_cerrar() -> texto de lo que hay en marcha, o "" si se puede cerrar sin más
|
|
al_soltar(rutas) -> rutas completas de lo que se soltó encima
|
|
al_abrir() -> la interfaz ya está a la vista (una sola vez)
|
|
"""
|
|
|
|
def __init__(self, url, titulo="OrganiLAB", icono="", perfil="", memoria="", base="", marca=0,
|
|
tamano=(1280, 820), minimo=(960, 640), fondo=(255, 255, 255), relanzar=("", ""), depurar=False):
|
|
self.url, self.titulo, self.icono, self.perfil, self.memoria = url, titulo, icono, perfil, memoria
|
|
self.base, self.marca, self.tamano, self.minimo, self.fondo = base, marca, tamano, minimo, fondo
|
|
self.relanzar, self.depurar = relanzar, depurar
|
|
self.al_cerrar = self.al_soltar = self.al_abrir = self.al_detener = None
|
|
self.hwnd = None
|
|
self.oculta = False # cerrada a la vista, pero viva hasta que termine el trabajo
|
|
self.lista = False # la interfaz está cargada y a la vista
|
|
self.abierta = False # llegó a cargar alguna vez
|
|
self.fallo = "" # por qué no se pudo abrir, si no se pudo
|
|
self._hilo = None
|
|
self._cola = queue.Queue()
|
|
self._entorno = self._control = self._wv = self._barra = None
|
|
self._reintentos = self._caidas = 0
|
|
self._guiones = {}
|
|
self._origen = url.split("/", 3)[0] + "//" + url.split("/", 3)[2] if "://" in url else url
|
|
self._proc = WNDPROC(self._mensaje)
|
|
self._msg_mostrar = 0
|
|
ev = (PVOID, PVOID)
|
|
self._h_entorno = _objeto(IID_ENTORNO_LISTO, (ctypes.c_long, PVOID), self._entorno_listo)[0]
|
|
self._h_control = _objeto(IID_CONTROL_LISTO, (ctypes.c_long, PVOID), self._control_listo)[0]
|
|
self._h_empieza = _objeto(IID_NAVEGACION_EMPIEZA, ev, self._navegacion_empieza)[0]
|
|
self._h_termina = _objeto(IID_NAVEGACION_TERMINA, ev, self._navegacion_termina)[0]
|
|
self._h_nueva = _objeto(IID_VENTANA_NUEVA, ev, self._ventana_nueva)[0]
|
|
self._h_mensaje = _objeto(IID_MENSAJE, ev, self._mensaje_web)[0]
|
|
self._h_titulo = _objeto(IID_TITULO, ev, self._titulo_cambia)[0]
|
|
self._h_permiso = _objeto(IID_PERMISO, ev, self._permiso)[0]
|
|
self._h_caido = _objeto(IID_PROCESO_CAIDO, ev, self._proceso_caido)[0]
|
|
|
|
# ---- abrir y cerrar ----
|
|
|
|
def mostrar(self):
|
|
"""Abre la ventana y atiende sus mensajes hasta que se cierra. Devuelve False si la interfaz no
|
|
llegó a cargar (y entonces quien llama decide qué hacer: `fallo` dice por qué)."""
|
|
D = _dll()
|
|
u = D.u
|
|
self._hilo = threading.get_ident()
|
|
try:
|
|
u.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # nítida en cualquier monitor
|
|
except Exception: # noqa
|
|
pass
|
|
D.o.CoInitializeEx(None, 2) # un apartamento: lo exige WebView2
|
|
D.s.SetCurrentProcessExplicitAppUserModelID(IDENTIDAD)
|
|
self._msg_mostrar = u.RegisterWindowMessageW("OrganiLAB.Mostrar")
|
|
inst = D.k.GetModuleHandleW(None)
|
|
escala = self._escala()
|
|
grande = u.LoadImageW(None, self.icono, 1, int(32 * escala), int(32 * escala), 0x10) if self.icono else None
|
|
chico = u.LoadImageW(None, self.icono, 1, int(16 * escala), int(16 * escala), 0x10) if self.icono else None
|
|
clase = WNDCLASSEXW()
|
|
clase.cbSize = ctypes.sizeof(WNDCLASSEXW)
|
|
clase.lpfnWndProc = self._proc
|
|
clase.hInstance = inst
|
|
clase.hIcon, clase.hIconSm = grande, chico
|
|
clase.hCursor = u.LoadCursorW(None, ctypes.c_void_p(32512))
|
|
r, g, b = self.fondo
|
|
clase.hbrBackground = D.g.CreateSolidBrush(r | g << 8 | b << 16)
|
|
clase.lpszClassName = CLASE
|
|
u.RegisterClassExW(ctypes.byref(clase)) # si ya estaba registrada, vale la que hay
|
|
self.hwnd = u.CreateWindowExW(0, CLASE, self.titulo, WS_OVERLAPPEDWINDOW, 0, 0, 100, 100, None, None, inst, None)
|
|
if not self.hwnd:
|
|
self.fallo = f"Windows no creó la ventana (error {ctypes.get_last_error()})"
|
|
return False
|
|
u.SetPropW(self.hwnd, PROP_MARCA, self.marca)
|
|
if grande:
|
|
u.SendMessageW(self.hwnd, WM_SETICON, 1, grande)
|
|
u.SendMessageW(self.hwnd, WM_SETICON, 0, chico)
|
|
color = ctypes.c_uint32(r | g << 8 | b << 16) # la barra de título, del color de la interfaz (Windows 11)
|
|
D.d.DwmSetWindowAttribute(self.hwnd, 35, ctypes.byref(color), 4)
|
|
self._identidad()
|
|
self._colocar()
|
|
try:
|
|
self._crear_motor()
|
|
except Exception as ex: # noqa
|
|
self.fallo = str(ex)
|
|
u.DestroyWindow(self.hwnd)
|
|
u.SetTimer(self.hwnd, T_GUARDIA, 30000, None) # si en 30 s no hay interfaz, algo va mal
|
|
msg = W.MSG()
|
|
while u.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
|
|
u.TranslateMessage(ctypes.byref(msg))
|
|
u.DispatchMessageW(ctypes.byref(msg))
|
|
self._vaciar_cola()
|
|
for p in (self._barra, self._wv, self._control, self._entorno):
|
|
try:
|
|
_soltar(p)
|
|
except Exception: # noqa
|
|
pass
|
|
self._barra = self._wv = self._control = self._entorno = None
|
|
self.hwnd = None
|
|
return self.abierta
|
|
|
|
def cerrar(self):
|
|
"""Cierra de verdad, sin preguntar (desde cualquier hilo)."""
|
|
if self.hwnd:
|
|
self.en_ui(self._destruir, esperar=False)
|
|
|
|
def _destruir(self):
|
|
self._recordar_sitio()
|
|
if self._control:
|
|
try:
|
|
_m(self._control, 24) # Close: el motor suelta la ventana
|
|
except Exception: # noqa
|
|
pass
|
|
_dll().u.DestroyWindow(self.hwnd)
|
|
|
|
def _pedir_cierre(self):
|
|
que = ""
|
|
try:
|
|
que = self.al_cerrar() if self.al_cerrar else ""
|
|
except Exception as ex: # noqa
|
|
_log("al_cerrar falló:", repr(ex))
|
|
if not que:
|
|
return self._destruir()
|
|
u = _dll().u
|
|
if not self.al_detener:
|
|
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras la ventana, el trabajo sigue hasta "
|
|
"terminar y después el programa se cierra solo. Para verlo, vuelve a abrir OrganiLAB.",
|
|
self.titulo, MB_OKCANCEL | MB_ICONINFORMATION)
|
|
if r != IDOK:
|
|
return
|
|
self._recordar_sitio()
|
|
self.oculta = True
|
|
self.progreso(None)
|
|
u.ShowWindow(self.hwnd, SW_HIDE)
|
|
return
|
|
# Cerrar para todo (Munka, 14-oct-2026): la conversión que va se corta y vuelve a la cola, y la próxima vez
|
|
# «Continuar» sigue por donde iba. Antes el trabajo seguía oculto y, al actualizar, quedaban procesos sueltos.
|
|
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras, se detiene todo. Lo que iba a medias "
|
|
"vuelve a la cola, y la próxima vez «Continuar» sigue por donde iba (lo cortado se hace entero otra vez).",
|
|
self.titulo, MB_OKCANCEL | MB_ICONQUESTION)
|
|
if r != IDOK:
|
|
return
|
|
self._recordar_sitio()
|
|
self.oculta = True
|
|
self.progreso(None)
|
|
u.ShowWindow(self.hwnd, SW_HIDE)
|
|
|
|
def parar():
|
|
try:
|
|
self.al_detener()
|
|
except Exception as ex: # noqa
|
|
_log("al_detener falló:", repr(ex))
|
|
finally:
|
|
if self.oculta: # si mientras se paraba la volvieron a abrir, se queda abierta (ya sin nada en marcha)
|
|
self.cerrar()
|
|
threading.Thread(target=parar, daemon=True).start()
|
|
|
|
def _volver(self):
|
|
"""La abrieron otra vez mientras trabajaba oculta: vuelve a la vista."""
|
|
u = _dll().u
|
|
self.oculta = False
|
|
u.ShowWindow(self.hwnd, SW_RESTORE if u.IsIconic(self.hwnd) else SW_SHOW)
|
|
u.SetForegroundWindow(self.hwnd)
|
|
|
|
def _fallar(self, motivo):
|
|
_log("no se pudo abrir la interfaz:", motivo)
|
|
self.fallo = motivo
|
|
if self.hwnd:
|
|
_dll().u.DestroyWindow(self.hwnd)
|
|
return S_OK
|
|
|
|
# ---- el marco ----
|
|
|
|
def _escala(self):
|
|
u = _dll().u
|
|
try:
|
|
return (u.GetDpiForWindow(self.hwnd) if self.hwnd else u.GetDpiForSystem()) / 96.0
|
|
except Exception: # noqa Windows anteriores a la 1607
|
|
return 1.0
|
|
|
|
def _identidad(self):
|
|
"""Con esto la barra de tareas sabe que la ventana es OrganiLAB (no «Python») y, si se ancla, qué
|
|
tiene que volver a lanzar."""
|
|
exe, args = self.relanzar
|
|
if not exe:
|
|
return
|
|
D = _dll()
|
|
almacen = ctypes.c_void_p()
|
|
if D.s.SHGetPropertyStoreForWindow(self.hwnd, ctypes.byref(_guid(IID_PROPIEDADES)), ctypes.byref(almacen)) < 0:
|
|
return
|
|
try:
|
|
orden = f'"{exe}" {args}'.strip()
|
|
for pid, valor in ((5, IDENTIDAD), (2, orden), (4, self.titulo), (3, self.icono)):
|
|
if not valor:
|
|
continue
|
|
clave = PROPERTYKEY(_guid(FMT_APPUSERMODEL), pid)
|
|
bufer = ctypes.create_unicode_buffer(valor)
|
|
pv = PROPVARIANT(31, 0, 0, 0, ctypes.cast(bufer, ctypes.c_void_p).value, None) # VT_LPWSTR
|
|
_m(almacen, 6, ctypes.byref(clave), ctypes.byref(pv), tipos=(ctypes.POINTER(PROPERTYKEY), ctypes.POINTER(PROPVARIANT)))
|
|
_m(almacen, 7) # Commit
|
|
finally:
|
|
_soltar(almacen.value)
|
|
|
|
def _colocar(self):
|
|
"""Donde se dejó la última vez; la primera, centrada. Si aquel monitor ya no está, también centrada."""
|
|
u = _dll().u
|
|
try:
|
|
with open(self.memoria, "r", encoding="utf-8") as fh:
|
|
m = json.load(fh)
|
|
sitio = W.RECT(int(m["x"]), int(m["y"]), int(m["x"]) + int(m["ancho"]), int(m["y"]) + int(m["alto"]))
|
|
if sitio.right - sitio.left < 200 or sitio.bottom - sitio.top < 150 or not u.MonitorFromRect(ctypes.byref(sitio), 0):
|
|
raise ValueError("fuera de pantalla")
|
|
modo = SW_SHOWMAXIMIZED if m.get("maximizada") else SW_SHOWNORMAL
|
|
except Exception: # noqa sin memoria, ilegible o de otro monitor
|
|
escala = self._escala()
|
|
ancho, alto = int(self.tamano[0] * escala), int(self.tamano[1] * escala)
|
|
info = MONITORINFO()
|
|
info.cbSize = ctypes.sizeof(MONITORINFO)
|
|
u.GetMonitorInfoW(u.MonitorFromRect(ctypes.byref(W.RECT(0, 0, 1, 1)), 1), ctypes.byref(info))
|
|
t = info.trabajo
|
|
ancho, alto = min(ancho, t.right - t.left), min(alto, t.bottom - t.top)
|
|
x, y = t.left + (t.right - t.left - ancho) // 2, t.top + (t.bottom - t.top - alto) // 2
|
|
sitio, modo = W.RECT(x, y, x + ancho, y + alto), SW_SHOWNORMAL
|
|
wp = WINDOWPLACEMENT()
|
|
wp.length = ctypes.sizeof(WINDOWPLACEMENT)
|
|
wp.showCmd, wp.normal = modo, sitio
|
|
u.SetWindowPlacement(self.hwnd, ctypes.byref(wp))
|
|
u.UpdateWindow(self.hwnd)
|
|
|
|
def _recordar_sitio(self):
|
|
if not self.memoria or not self.hwnd:
|
|
return
|
|
wp = WINDOWPLACEMENT()
|
|
wp.length = ctypes.sizeof(WINDOWPLACEMENT)
|
|
if not _dll().u.GetWindowPlacement(self.hwnd, ctypes.byref(wp)):
|
|
return
|
|
n = wp.normal
|
|
try:
|
|
with open(self.memoria, "w", encoding="utf-8") as fh:
|
|
json.dump({"x": n.left, "y": n.top, "ancho": n.right - n.left, "alto": n.bottom - n.top,
|
|
"maximizada": wp.showCmd == SW_SHOWMAXIMIZED}, fh)
|
|
except OSError:
|
|
pass
|
|
|
|
def _acomodar(self):
|
|
"""La interfaz ocupa todo el interior de la ventana."""
|
|
if not self._control:
|
|
return
|
|
r = W.RECT()
|
|
_dll().u.GetClientRect(self.hwnd, ctypes.byref(r))
|
|
_m(self._control, 6, ctypes.byref(r), tipos=(ctypes.POINTER(W.RECT),)) # put_Bounds
|
|
|
|
def _mensaje(self, hwnd, msg, wp, lp):
|
|
u = _dll().u
|
|
try:
|
|
if msg == WM_SIZE:
|
|
if self._control:
|
|
_m(self._control, 4, 0 if wp == SIZE_MINIMIZED else 1, tipos=(ctypes.c_int,)) # minimizada no pinta
|
|
self._acomodar()
|
|
return 0
|
|
if msg == WM_MOVE:
|
|
if self._control:
|
|
_m(self._control, 23) # los menús de la interfaz siguen a la ventana
|
|
return 0
|
|
if msg == WM_SETFOCUS:
|
|
if self._control:
|
|
_m(self._control, 12, 0, tipos=(ctypes.c_int,)) # el teclado, a la interfaz
|
|
return 0
|
|
if msg == WM_GETMINMAXINFO:
|
|
info = ctypes.cast(lp, ctypes.POINTER(MINMAXINFO)).contents
|
|
escala = self._escala()
|
|
info.minimo.x, info.minimo.y = int(self.minimo[0] * escala), int(self.minimo[1] * escala)
|
|
return 0
|
|
if msg == WM_DPICHANGED:
|
|
r = ctypes.cast(lp, ctypes.POINTER(W.RECT)).contents
|
|
u.SetWindowPos(hwnd, None, r.left, r.top, r.right - r.left, r.bottom - r.top, 0x0014)
|
|
return 0
|
|
if msg == MSG_LLAMAR:
|
|
self._vaciar_cola(correr=True)
|
|
return 0
|
|
if msg == self._msg_mostrar and msg:
|
|
self._volver()
|
|
return 0
|
|
if msg == WM_TIMER:
|
|
if wp == T_GUARDIA:
|
|
u.KillTimer(hwnd, T_GUARDIA)
|
|
if not self.lista:
|
|
self._fallar("la interfaz no cargó en 30 segundos")
|
|
elif wp == T_REINTENTO:
|
|
u.KillTimer(hwnd, T_REINTENTO)
|
|
if self._wv:
|
|
_m(self._wv, 5, self.url, tipos=(W.LPCWSTR,))
|
|
return 0
|
|
if msg == WM_CLOSE:
|
|
self._pedir_cierre()
|
|
return 0
|
|
if msg == WM_DESTROY:
|
|
u.PostQuitMessage(0)
|
|
return 0
|
|
except Exception as ex: # noqa
|
|
_log("fallo atendiendo el mensaje", hex(msg), repr(ex))
|
|
return u.DefWindowProcW(hwnd, msg, wp, lp)
|
|
|
|
# ---- el motor (WebView2) ----
|
|
|
|
def _crear_motor(self):
|
|
dll = cargador(self.base)
|
|
if not dll:
|
|
raise RuntimeError("falta WebView2Loader.dll junto al programa")
|
|
crear = ctypes.WinDLL(dll).CreateCoreWebView2EnvironmentWithOptions
|
|
crear.argtypes, crear.restype = [W.LPCWSTR, W.LPCWSTR, PVOID, PVOID], ctypes.c_long
|
|
os.makedirs(self.perfil, exist_ok=True)
|
|
os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = ARGUMENTOS
|
|
hr = crear(None, self.perfil, None, self._h_entorno)
|
|
if hr < 0:
|
|
raise RuntimeError(f"WebView2 no arrancó (0x{hr & 0xFFFFFFFF:08X})")
|
|
|
|
def _entorno_listo(self, hr, entorno):
|
|
os.environ.pop("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", None) # era solo para este arranque
|
|
if hr < 0 or not entorno:
|
|
return self._fallar(f"WebView2 no se pudo iniciar (0x{hr & 0xFFFFFFFF:08X})")
|
|
_mas(entorno)
|
|
self._entorno = entorno
|
|
hr = _m(entorno, 3, self.hwnd, self._h_control, tipos=(W.HWND, PVOID))
|
|
if hr < 0:
|
|
return self._fallar(f"WebView2 no creó la vista (0x{hr & 0xFFFFFFFF:08X})")
|
|
|
|
def _control_listo(self, hr, control):
|
|
if hr < 0 or not control:
|
|
return self._fallar(f"WebView2 no creó la vista (0x{hr & 0xFFFFFFFF:08X})")
|
|
_mas(control)
|
|
self._control = control
|
|
wv = ctypes.c_void_p()
|
|
_m(control, 25, ctypes.byref(wv), tipos=(PPVOID,))
|
|
self._wv = wv.value
|
|
c2 = _qi(control, IID_CONTROL2)
|
|
if c2: # mientras carga, el fondo de la interfaz y no un blanco de navegador
|
|
r, g, b = self.fondo
|
|
_m(c2, 27, ctypes.c_uint32(255 | r << 8 | g << 16 | b << 24), tipos=(ctypes.c_uint32,))
|
|
_soltar(c2)
|
|
self._ajustar()
|
|
self._acomodar()
|
|
ficha = ctypes.c_int64()
|
|
for indice, aviso in ((7, self._h_empieza), (15, self._h_termina), (23, self._h_permiso), (25, self._h_caido),
|
|
(34, self._h_mensaje), (44, self._h_nueva), (46, self._h_titulo)):
|
|
_m(self._wv, indice, aviso, ctypes.byref(ficha), tipos=(PVOID, ctypes.POINTER(ctypes.c_int64)))
|
|
_m(self._wv, 27, GUION, None, tipos=(W.LPCWSTR, PVOID))
|
|
_m(self._wv, 5, self.url, tipos=(W.LPCWSTR,)) # Navigate
|
|
|
|
def _ajustar(self):
|
|
"""Fuera lo que es de un navegador y no de un programa."""
|
|
aj = ctypes.c_void_p()
|
|
_m(self._wv, 3, ctypes.byref(aj), tipos=(PPVOID,))
|
|
aj = aj.value
|
|
|
|
def pon(obj, indice, valor):
|
|
_m(obj, indice, 1 if valor else 0, tipos=(ctypes.c_int,))
|
|
pon(aj, 10, False) # la barra de estado con la dirección de cada enlace
|
|
pon(aj, 12, self.depurar) # las herramientas de desarrollo
|
|
pon(aj, 18, False) # el zoom con Ctrl + rueda
|
|
pon(aj, 20, False) # la página de error de Edge
|
|
for iid, pares in ((IID_AJUSTES3, ((24, self.depurar),)), # F5, Ctrl+P, Ctrl+F, F12…
|
|
(IID_AJUSTES4, ((26, False), (28, False))), # guardar contraseñas y autorrellenar
|
|
(IID_AJUSTES5, ((30, False),)), # el zoom de pellizco
|
|
(IID_AJUSTES6, ((32, False),)), # atrás y adelante deslizando
|
|
(IID_AJUSTES8, ((36, False),))): # consultar a SmartScreen cada dirección
|
|
q = _qi(aj, iid)
|
|
if q:
|
|
for indice, valor in pares:
|
|
pon(q, indice, valor)
|
|
_soltar(q)
|
|
_soltar(aj)
|
|
|
|
def _es_nuestra(self, uri):
|
|
return uri == self._origen or uri.startswith((self._origen + "/", "about:", "data:"))
|
|
|
|
def _navegacion_empieza(self, _wv, args):
|
|
uri = _texto(args, 3)
|
|
if self._es_nuestra(uri):
|
|
return
|
|
_m(args, 8, 1, tipos=(ctypes.c_int,)) # un enlace hacia fuera no se abre aquí…
|
|
self._abrir_fuera(uri)
|
|
|
|
def _ventana_nueva(self, _wv, args):
|
|
_m(args, 6, 1, tipos=(ctypes.c_int,)) # …ni en una ventana de navegador sin marco
|
|
self._abrir_fuera(_texto(args, 3))
|
|
|
|
def _abrir_fuera(self, uri):
|
|
if uri.lower().startswith(("http://", "https://", "mailto:")):
|
|
_dll().s.ShellExecuteW(self.hwnd, "open", uri, None, None, SW_SHOWNORMAL)
|
|
|
|
def _navegacion_termina(self, _wv, args):
|
|
u = _dll().u
|
|
if _entero(args, 3):
|
|
if not self.lista:
|
|
self.lista = True
|
|
self._reintentos = 0
|
|
u.KillTimer(self.hwnd, T_GUARDIA)
|
|
_m(self._control, 12, 0, tipos=(ctypes.c_int,))
|
|
if not self.abierta:
|
|
self.abierta = True
|
|
if self.al_abrir:
|
|
threading.Thread(target=self.al_abrir, daemon=True).start()
|
|
return
|
|
if self.lista:
|
|
return # una recarga que falló: la página ya sabe reintentar
|
|
self._reintentos += 1
|
|
if self._reintentos > 20:
|
|
return self._fallar("la interfaz no respondió")
|
|
u.SetTimer(self.hwnd, T_REINTENTO, 400, None)
|
|
|
|
def _titulo_cambia(self, _wv, _args):
|
|
t = _texto(self._wv, 48)
|
|
if t:
|
|
_dll().u.SetWindowTextW(self.hwnd, t)
|
|
|
|
def _permiso(self, _wv, args):
|
|
_m(args, 7, 2, tipos=(ctypes.c_int,)) # cámara, micrófono, ubicación…: no, sin preguntar
|
|
|
|
def _proceso_caido(self, _wv, args):
|
|
clase = _entero(args, 3)
|
|
_log("un proceso del motor se cayó, clase", clase)
|
|
if clase == 1: # el que pinta: se recarga y sigue
|
|
_m(self._wv, 31)
|
|
elif clase == 0: # el motor entero: se monta otro
|
|
self._caidas += 1
|
|
self._luego(self._remontar)
|
|
|
|
def _remontar(self):
|
|
for p in (self._wv, self._control, self._entorno):
|
|
try:
|
|
_soltar(p)
|
|
except Exception: # noqa
|
|
pass
|
|
self._wv = self._control = self._entorno = None
|
|
self.lista = False
|
|
if self._caidas > 3:
|
|
_dll().u.MessageBoxW(self.hwnd, "La ventana de OrganiLAB se cerró por un problema de Windows. Lo que estuviera "
|
|
"en marcha sigue; vuelve a abrir el programa para verlo.", self.titulo, MB_OK | MB_ICONWARNING)
|
|
return _dll().u.DestroyWindow(self.hwnd)
|
|
try:
|
|
self._crear_motor()
|
|
_dll().u.SetTimer(self.hwnd, T_GUARDIA, 30000, None)
|
|
except Exception as ex: # noqa
|
|
self._fallar(str(ex))
|
|
|
|
def _mensaje_web(self, _wv, args):
|
|
if _texto(args, 5) != "soltado":
|
|
return
|
|
a2 = _qi(args, IID_MENSAJE_ARGS2)
|
|
if not a2:
|
|
return
|
|
rutas, cosas = [], ctypes.c_void_p()
|
|
try:
|
|
if _m(a2, 6, ctypes.byref(cosas), tipos=(PPVOID,)) < 0 or not cosas.value:
|
|
return
|
|
for i in range(_entero(cosas.value, 3, ctypes.c_uint32)):
|
|
cosa = ctypes.c_void_p()
|
|
if _m(cosas.value, 4, i, ctypes.byref(cosa), tipos=(ctypes.c_uint32, PPVOID)) < 0 or not cosa.value:
|
|
continue
|
|
archivo = _qi(cosa.value, IID_ARCHIVO)
|
|
if archivo:
|
|
ruta = _texto(archivo, 3)
|
|
if ruta:
|
|
rutas.append(ruta)
|
|
_soltar(archivo)
|
|
_soltar(cosa.value)
|
|
finally:
|
|
_soltar(cosas.value)
|
|
_soltar(a2)
|
|
if rutas and self.al_soltar:
|
|
self.al_soltar(rutas)
|
|
|
|
# ---- lo que se le puede pedir desde cualquier hilo ----
|
|
|
|
def en_ui(self, funcion, esperar=True, plazo=None):
|
|
"""Corre `funcion` en el hilo de la ventana (Windows y COM solo dejan tocarla desde ahí)."""
|
|
if threading.get_ident() == self._hilo:
|
|
return funcion()
|
|
caja = {"listo": threading.Event(), "r": None, "ex": None}
|
|
self._cola.put((funcion, caja))
|
|
if not self.hwnd or not _dll().u.PostMessageW(self.hwnd, MSG_LLAMAR, 0, 0):
|
|
return None
|
|
if not esperar:
|
|
return None
|
|
caja["listo"].wait(plazo)
|
|
if caja["ex"]:
|
|
raise caja["ex"]
|
|
return caja["r"]
|
|
|
|
def _luego(self, funcion):
|
|
"""Como en_ui, pero siempre después: nunca dentro del aviso que se está atendiendo."""
|
|
self._cola.put((funcion, {"listo": threading.Event(), "r": None, "ex": None}))
|
|
_dll().u.PostMessageW(self.hwnd, MSG_LLAMAR, 0, 0)
|
|
|
|
def _vaciar_cola(self, correr=False):
|
|
while True:
|
|
try:
|
|
funcion, caja = self._cola.get_nowait()
|
|
except queue.Empty:
|
|
return
|
|
try:
|
|
if correr:
|
|
caja["r"] = funcion()
|
|
except Exception as ex: # noqa
|
|
caja["ex"] = ex
|
|
finally:
|
|
caja["listo"].set()
|
|
|
|
def mensaje(self, texto, botones=MB_OK | MB_ICONINFORMATION):
|
|
"""Un cuadro de mensaje de Windows sobre la ventana. Devuelve el botón pulsado."""
|
|
return self.en_ui(lambda: _dll().u.MessageBoxW(self.hwnd, texto, self.titulo, botones))
|
|
|
|
def elegir_carpeta(self, titulo):
|
|
"""El selector de carpetas de Windows, modal sobre la ventana. Devuelve la ruta o None."""
|
|
return self.en_ui(lambda: self._elegir_carpeta(titulo))
|
|
|
|
def _elegir_carpeta(self, titulo):
|
|
D = _dll()
|
|
d = ctypes.c_void_p()
|
|
if D.o.CoCreateInstance(ctypes.byref(_guid(CLSID_ABRIR)), None, 1, ctypes.byref(_guid(IID_ABRIR)), ctypes.byref(d)) < 0:
|
|
return None
|
|
d = d.value
|
|
try:
|
|
op = _entero(d, 10, W.DWORD) # GetOptions
|
|
_m(d, 9, op | 0x20 | 0x40 | 0x800 | 0x8, tipos=(W.DWORD,)) # carpetas, del disco, que existan, sin cambiar de directorio
|
|
_m(d, 17, titulo, tipos=(W.LPCWSTR,))
|
|
_m(d, 18, "Elegir carpeta", tipos=(W.LPCWSTR,))
|
|
if _m(d, 3, self.hwnd, tipos=(W.HWND,)) < 0: # Show; cancelar también es «menor que cero»
|
|
return None
|
|
item = ctypes.c_void_p()
|
|
if _m(d, 20, ctypes.byref(item), tipos=(PPVOID,)) < 0 or not item.value:
|
|
return None
|
|
try:
|
|
return _texto(item.value, 5, 0x80058000, tipos=(ctypes.c_uint32,)) or None # la ruta en disco
|
|
finally:
|
|
_soltar(item.value)
|
|
finally:
|
|
_soltar(d)
|
|
|
|
def progreso(self, fraccion):
|
|
"""El avance en el botón de la barra de tareas: 0..1, -1 si no se sabe cuánto falta, None para quitarlo."""
|
|
self.en_ui(lambda: self._progreso(fraccion), esperar=False)
|
|
|
|
def _progreso(self, fraccion):
|
|
D = _dll()
|
|
if not self._barra:
|
|
if fraccion is None:
|
|
return
|
|
b = ctypes.c_void_p()
|
|
if D.o.CoCreateInstance(ctypes.byref(_guid(CLSID_BARRA)), None, 1, ctypes.byref(_guid(IID_BARRA3)), ctypes.byref(b)) < 0:
|
|
return
|
|
self._barra = b.value
|
|
_m(self._barra, 3) # HrInit
|
|
if fraccion is None:
|
|
_m(self._barra, 10, self.hwnd, 0, tipos=(W.HWND, ctypes.c_int))
|
|
elif fraccion < 0:
|
|
_m(self._barra, 10, self.hwnd, 1, tipos=(W.HWND, ctypes.c_int))
|
|
else:
|
|
_m(self._barra, 10, self.hwnd, 2, tipos=(W.HWND, ctypes.c_int))
|
|
_m(self._barra, 9, self.hwnd, int(max(0.0, min(1.0, fraccion)) * 1000), 1000,
|
|
tipos=(W.HWND, ctypes.c_ulonglong, ctypes.c_ulonglong))
|
|
|
|
def avisar(self):
|
|
"""Parpadea en la barra de tareas si se está mirando otra cosa (terminó algo largo)."""
|
|
def parpadeo():
|
|
u = _dll().u
|
|
if u.GetForegroundWindow() == self.hwnd:
|
|
return
|
|
f = FLASHWINFO(ctypes.sizeof(FLASHWINFO), self.hwnd, 0x2 | 0xC, 0, 0) # el botón, hasta que se mire
|
|
u.FlashWindowEx(ctypes.byref(f))
|
|
self.en_ui(parpadeo, esperar=False)
|
|
|
|
def evaluar(self, codigo, plazo=10):
|
|
"""Corre JavaScript en la interfaz y devuelve su resultado (lo que quepa en JSON). Desde otro hilo:
|
|
la respuesta llega por el bucle de mensajes, y el de la ventana no puede esperarse a sí mismo."""
|
|
caja = {"listo": threading.Event(), "r": None}
|
|
|
|
def hecho(hr, resultado):
|
|
caja["r"] = resultado if hr >= 0 else None
|
|
self._guiones.pop(id(caja), None)
|
|
caja["listo"].set()
|
|
|
|
def lanzar():
|
|
p, sosten = _objeto(IID_GUION_LISTO, (ctypes.c_long, W.LPCWSTR), hecho, fijo=False)
|
|
self._guiones[id(caja)] = sosten
|
|
if not self._wv or _m(self._wv, 29, codigo, p, tipos=(W.LPCWSTR, PVOID)) < 0:
|
|
hecho(-1, None)
|
|
self.en_ui(lanzar, esperar=False)
|
|
caja["listo"].wait(plazo)
|
|
try:
|
|
return json.loads(caja["r"]) if caja["r"] else None
|
|
except ValueError:
|
|
return None
|
|
|
|
def capturar(self, ruta, plazo=15):
|
|
"""Guarda en un PNG lo que la interfaz tiene pintado ahora. Devuelve True si se guardó."""
|
|
caja = {"listo": threading.Event(), "ok": False}
|
|
|
|
def lanzar():
|
|
flujo = ctypes.c_void_p()
|
|
if _dll().w.SHCreateStreamOnFileEx(ruta, 0x1011, 0x80, True, None, ctypes.byref(flujo)) < 0 or not flujo.value:
|
|
return caja["listo"].set()
|
|
|
|
def hecho(hr):
|
|
_m(flujo.value, 8, 0, tipos=(W.DWORD,)) # Commit
|
|
_soltar(flujo.value)
|
|
caja["ok"] = hr >= 0
|
|
self._guiones.pop(id(caja), None)
|
|
caja["listo"].set()
|
|
p, sosten = _objeto(IID_CAPTURA_LISTA, (ctypes.c_long,), hecho, fijo=False)
|
|
self._guiones[id(caja)] = sosten
|
|
if not self._wv or _m(self._wv, 30, 0, flujo.value, p, tipos=(ctypes.c_int, PVOID, PVOID)) < 0:
|
|
hecho(-1)
|
|
self.en_ui(lanzar, esperar=False)
|
|
caja["listo"].wait(plazo)
|
|
return caja["ok"]
|