- 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>
239 líneas
11 KiB
Python
239 líneas
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
r"""
|
||
OrganiLAB como un programa más de Windows: su acceso directo en el menú Inicio y en el Escritorio, y su
|
||
línea en Configuración › Aplicaciones para quitarlo.
|
||
|
||
No copia nada: el programa se queda en la carpeta donde está y esto solo lo da de alta, todo dentro del
|
||
usuario y sin pedir permisos de administrador. Quitarlo borra los accesos directos y la línea de
|
||
Aplicaciones; la carpeta del programa y los datos no se tocan.
|
||
|
||
Los accesos directos llevan la misma identidad que la ventana (ventana.IDENTIDAD). Así el acceso anclado
|
||
a la barra de tareas y la ventana abierta son, para Windows, la misma cosa: un solo botón, con su icono.
|
||
|
||
OrganizadorLabstream.exe instalar (--si para no enseñar ningún mensaje)
|
||
OrganizadorLabstream.exe desinstalar
|
||
"""
|
||
import os
|
||
import sys
|
||
import ctypes
|
||
import winreg
|
||
from ctypes import wintypes as W
|
||
|
||
import ventana as V
|
||
|
||
NOMBRE = "OrganiLAB"
|
||
DESCRIPCION = "OrganiLAB · ordena y aligera el material de video"
|
||
CLAVE = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB"
|
||
EN_INICIO = "{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}" # la carpeta Programas del menú Inicio
|
||
EN_ESCRITORIO = "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}" # el Escritorio (puede estar en OneDrive)
|
||
CLSID_ACCESO, IID_ACCESO = "{00021401-0000-0000-C000-000000000046}", "{000214F9-0000-0000-C000-000000000046}"
|
||
IID_EN_DISCO = "{0000010B-0000-0000-C000-000000000046}" # IPersistFile
|
||
|
||
|
||
def orden(base):
|
||
"""(ejecutable, argumentos) con que se abre el programa que vive en `base`. En el paquete es su propio
|
||
.exe; corriendo desde el código, el Python sin consola de este equipo."""
|
||
exe = os.path.join(base, "OrganizadorLabstream.exe")
|
||
if os.path.isfile(exe):
|
||
return exe, ""
|
||
pyw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
|
||
return (pyw if os.path.isfile(pyw) else sys.executable), f'"{os.path.join(base, "organizador.py")}"'
|
||
|
||
|
||
def carpeta_de(cual):
|
||
"""Dónde tiene este usuario el menú Inicio o el Escritorio."""
|
||
D = V._dll()
|
||
f = D.s.SHGetKnownFolderPath
|
||
f.argtypes, f.restype = [ctypes.POINTER(V.GUID), W.DWORD, W.HANDLE, V.PPVOID], ctypes.c_long
|
||
p = ctypes.c_void_p()
|
||
if f(ctypes.byref(V._guid(cual)), 0, None, ctypes.byref(p)) < 0 or not p.value:
|
||
raise OSError("Windows no dijo dónde está esa carpeta")
|
||
try:
|
||
return ctypes.wstring_at(p.value)
|
||
finally:
|
||
D.o.CoTaskMemFree(p)
|
||
|
||
|
||
def _enlace():
|
||
D = V._dll()
|
||
D.o.CoInitializeEx(None, 2)
|
||
p = ctypes.c_void_p()
|
||
hr = D.o.CoCreateInstance(ctypes.byref(V._guid(CLSID_ACCESO)), None, 1, ctypes.byref(V._guid(IID_ACCESO)), ctypes.byref(p))
|
||
if hr < 0 or not p.value:
|
||
raise OSError(f"Windows no dejó preparar el acceso directo (0x{hr & 0xFFFFFFFF:08X})")
|
||
return p.value
|
||
|
||
|
||
def acceso_directo(ruta, exe, args, carpeta, icono, descripcion=DESCRIPCION):
|
||
"""Escribe un acceso directo (.lnk) con la identidad de OrganiLAB."""
|
||
enlace = _enlace()
|
||
try:
|
||
V._m(enlace, 20, exe, tipos=(W.LPCWSTR,)) # SetPath
|
||
V._m(enlace, 11, args, tipos=(W.LPCWSTR,)) # SetArguments
|
||
V._m(enlace, 9, carpeta, tipos=(W.LPCWSTR,)) # SetWorkingDirectory
|
||
V._m(enlace, 17, icono, 0, tipos=(W.LPCWSTR, ctypes.c_int)) # SetIconLocation
|
||
V._m(enlace, 7, descripcion, tipos=(W.LPCWSTR,)) # SetDescription
|
||
props = V._qi(enlace, V.IID_PROPIEDADES)
|
||
if props:
|
||
clave = V.PROPERTYKEY(V._guid(V.FMT_APPUSERMODEL), 5)
|
||
bufer = ctypes.create_unicode_buffer(V.IDENTIDAD)
|
||
pv = V.PROPVARIANT(31, 0, 0, 0, ctypes.cast(bufer, ctypes.c_void_p).value, None)
|
||
V._m(props, 6, ctypes.byref(clave), ctypes.byref(pv), tipos=(ctypes.POINTER(V.PROPERTYKEY), ctypes.POINTER(V.PROPVARIANT)))
|
||
V._m(props, 7)
|
||
V._soltar(props)
|
||
disco = V._qi(enlace, IID_EN_DISCO)
|
||
if not disco:
|
||
raise OSError("Windows no dejó guardar el acceso directo")
|
||
try:
|
||
hr = V._m(disco, 6, ruta, 1, tipos=(W.LPCWSTR, W.BOOL)) # Save
|
||
if hr < 0:
|
||
raise OSError(f"no se pudo escribir {ruta} (0x{hr & 0xFFFFFFFF:08X})")
|
||
finally:
|
||
V._soltar(disco)
|
||
finally:
|
||
V._soltar(enlace)
|
||
return ruta
|
||
|
||
|
||
def leer_acceso(ruta):
|
||
"""Lo que dice un acceso directo: a qué apunta, con qué argumentos, desde dónde y con qué identidad."""
|
||
enlace = _enlace()
|
||
try:
|
||
disco = V._qi(enlace, IID_EN_DISCO)
|
||
try:
|
||
if V._m(disco, 5, ruta, 0, tipos=(W.LPCWSTR, W.DWORD)) < 0: # Load
|
||
return None
|
||
finally:
|
||
V._soltar(disco)
|
||
b = ctypes.create_unicode_buffer(1024)
|
||
out = {}
|
||
V._m(enlace, 3, b, 1024, None, 0, tipos=(W.LPWSTR, ctypes.c_int, ctypes.c_void_p, W.DWORD)) # GetPath
|
||
out["exe"] = b.value
|
||
V._m(enlace, 10, b, 1024, tipos=(W.LPWSTR, ctypes.c_int)) # GetArguments
|
||
out["args"] = b.value
|
||
V._m(enlace, 8, b, 1024, tipos=(W.LPWSTR, ctypes.c_int)) # GetWorkingDirectory
|
||
out["carpeta"] = b.value
|
||
indice = ctypes.c_int()
|
||
V._m(enlace, 16, b, 1024, ctypes.byref(indice), tipos=(W.LPWSTR, ctypes.c_int, ctypes.POINTER(ctypes.c_int))) # GetIconLocation
|
||
out["icono"] = b.value
|
||
out["identidad"] = ""
|
||
props = V._qi(enlace, V.IID_PROPIEDADES)
|
||
if props:
|
||
clave = V.PROPERTYKEY(V._guid(V.FMT_APPUSERMODEL), 5)
|
||
pv = V.PROPVARIANT()
|
||
if V._m(props, 5, ctypes.byref(clave), ctypes.byref(pv), tipos=(ctypes.POINTER(V.PROPERTYKEY), ctypes.POINTER(V.PROPVARIANT))) >= 0 and pv.vt == 31 and pv.valor:
|
||
out["identidad"] = ctypes.wstring_at(pv.valor)
|
||
V._dll().o.CoTaskMemFree(pv.valor)
|
||
V._soltar(props)
|
||
return out
|
||
finally:
|
||
V._soltar(enlace)
|
||
|
||
|
||
def _accesos():
|
||
"""Las dos rutas donde van los accesos directos: (menú Inicio, Escritorio)."""
|
||
return os.path.join(carpeta_de(EN_INICIO), NOMBRE + ".lnk"), os.path.join(carpeta_de(EN_ESCRITORIO), NOMBRE + ".lnk")
|
||
|
||
|
||
def _peso_kb(base):
|
||
total = 0
|
||
for raiz, dirs, files in os.walk(base):
|
||
dirs[:] = [d for d in dirs if not (raiz == base and d in ("datos", "actualizacion", "__pycache__"))]
|
||
for f in files:
|
||
try:
|
||
total += os.path.getsize(os.path.join(raiz, f))
|
||
except OSError:
|
||
pass
|
||
return total // 1024
|
||
|
||
|
||
def instalado():
|
||
"""Lo que Windows tiene apuntado de OrganiLAB ({"base", "version"}), o None si no está dado de alta."""
|
||
try:
|
||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, CLAVE) as k:
|
||
return {"base": winreg.QueryValueEx(k, "InstallLocation")[0], "version": winreg.QueryValueEx(k, "DisplayVersion")[0]}
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def instalado_aqui(base):
|
||
i = instalado()
|
||
return bool(i) and os.path.normcase(os.path.normpath(i["base"])) == os.path.normcase(os.path.normpath(base))
|
||
|
||
|
||
def instalar(base, version, solo_los_que_hay=False):
|
||
"""Da de alta el programa de `base`. Con `solo_los_que_hay` no repone un acceso directo que alguien
|
||
quitó a propósito: solo pone al día los que siguen ahí (es lo que se hace tras cada actualización)."""
|
||
exe, args = orden(base)
|
||
icono = os.path.join(base, "static", "organilab.ico")
|
||
hechos = []
|
||
for ruta in _accesos():
|
||
if solo_los_que_hay and not os.path.exists(ruta):
|
||
continue
|
||
hechos.append(acceso_directo(ruta, exe, args, base, icono))
|
||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, CLAVE) as k:
|
||
for nombre, valor in (("DisplayName", NOMBRE), ("DisplayVersion", version), ("Publisher", "Labstream"),
|
||
("DisplayIcon", icono), ("InstallLocation", base),
|
||
("UninstallString", f'"{exe}" {args} desinstalar'.replace(" ", " "))):
|
||
winreg.SetValueEx(k, nombre, 0, winreg.REG_SZ, valor)
|
||
for nombre, valor in (("NoModify", 1), ("NoRepair", 1), ("EstimatedSize", _peso_kb(base))):
|
||
winreg.SetValueEx(k, nombre, 0, winreg.REG_DWORD, valor)
|
||
return {"base": base, "accesos": hechos, "orden": f'"{exe}" {args}'.strip()}
|
||
|
||
|
||
def al_dia(base, version):
|
||
"""Tras una actualización: si este programa está dado de alta, Windows tiene que enseñar la versión
|
||
nueva y los accesos directos tienen que apuntar a lo que ahora lo abre."""
|
||
i = instalado()
|
||
if not i or not instalado_aqui(base):
|
||
return False
|
||
exe, args = orden(base)
|
||
viejos = [a for a in map(leer_acceso, (r for r in _accesos() if os.path.exists(r))) if a]
|
||
if i["version"] == version and all(os.path.normcase(a["exe"]) == os.path.normcase(exe) and a["args"] == args for a in viejos):
|
||
return False
|
||
instalar(base, version, solo_los_que_hay=True)
|
||
return True
|
||
|
||
|
||
def desinstalar(base=None):
|
||
"""Quita los accesos directos y la línea de Aplicaciones. No borra ni el programa ni los datos."""
|
||
quitados = []
|
||
for ruta in _accesos():
|
||
if os.path.exists(ruta):
|
||
a = leer_acceso(ruta) or {}
|
||
# solo se quita el acceso que apunta a esta copia: otro OrganiLAB en otra carpeta conserva el suyo
|
||
if base and a.get("carpeta") and os.path.normcase(os.path.normpath(a["carpeta"])) != os.path.normcase(os.path.normpath(base)):
|
||
continue
|
||
os.remove(ruta)
|
||
quitados.append(ruta)
|
||
if not base or instalado_aqui(base):
|
||
try:
|
||
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, CLAVE)
|
||
except OSError:
|
||
pass
|
||
return {"accesos": quitados}
|
||
|
||
|
||
def principal(que, base, version, silencioso=False):
|
||
"""Los comandos «instalar» y «desinstalar». Hablan con cuadros de mensaje: no hace falta consola."""
|
||
u = V._dll().u
|
||
|
||
def decir(texto, botones=V.MB_OK | V.MB_ICONINFORMATION):
|
||
return V.IDOK if silencioso else u.MessageBoxW(None, texto, NOMBRE, botones)
|
||
try:
|
||
if que == "instalar":
|
||
instalar(base, version)
|
||
decir("OrganiLAB ya está en el menú Inicio y en el Escritorio.\n\n"
|
||
"Para quitarlo: Configuración › Aplicaciones › Aplicaciones instaladas.")
|
||
return 0
|
||
if not silencioso and decir("¿Quitar OrganiLAB del menú Inicio, del Escritorio y de la lista de aplicaciones?\n\n"
|
||
"La carpeta del programa y tus datos (inventario, historial y ajustes) no se borran.",
|
||
V.MB_YESNO | V.MB_ICONQUESTION) != V.IDYES:
|
||
return 1
|
||
desinstalar(base)
|
||
decir(f"Quitado. La carpeta del programa sigue en:\n\n{base}\n\nSi ya no la quieres, bórrala a mano.")
|
||
return 0
|
||
except Exception as ex: # noqa
|
||
decir(f"No se pudo: {ex}", V.MB_OK | V.MB_ICONWARNING)
|
||
return 1
|