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