# -*- coding: utf-8 -*- r""" OrganiLAB como un programa más de Windows: en su sitio, con su acceso directo en el menú Inicio y en el Escritorio, y con su línea en Configuración › Aplicaciones para quitarlo. Instalar el paquete lo lleva a su sitio definitivo, la carpeta de programas del usuario (%LOCALAPPDATA%\Programs\OrganiLAB): de Descargas, que es donde se suele descomprimir, se limpia por descuido. Se copia el programa con sus datos y su ffmpeg; del origen no se borra ni se mueve nada, y allí queda una nota que, además, hace que abrir esa copia abra la instalada. Todo dentro del usuario, sin permisos de administrador. Desde el código (sin .exe) solo se da de alta donde está. 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 time import ctypes import shutil import winreg import sqlite3 import subprocess from ctypes import wintypes as W import ventana as V NOMBRE = "OrganiLAB" DESCRIPCION = "OrganiLAB · ordena y aligera el material de video" # Para probar la instalación entera sin tocar el Windows de verdad: con ORGANILAB_ENSAYO=, el # «equipo» es esa carpeta (sus programas, su menú Inicio y su Escritorio) y la línea de Aplicaciones va a # una clave aparte del registro. Lo usa prueba_ventana.py. ENSAYO = os.environ.get("ORGANILAB_ENSAYO", "") CLAVE_DE_VERDAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB" CLAVE = r"Software\OrganiLAB-ensayo\Alta" if ENSAYO else CLAVE_DE_VERDAD MARCA_MUDANZA = "instalado-en.txt" # en la carpeta de origen: adónde se instaló lo que había aquí TAREA_AUTOMATICO = "OrganiLAB - orden automatico" # la tarea de Windows del orden automático (organizador.TAREA_WINDOWS) INVENTARIO = "inventario.sqlite" NO_VIAJA = {"actualizacion", "__pycache__", MARCA_MUDANZA} # del primer nivel: lo que es de paso NO_VIAJA_DATOS = {"sesion.json", "ventana"} # de datos\: la sesión abierta y el perfil del motor, que se rehace solo 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).""" if ENSAYO: carpetas = os.path.join(ENSAYO, "Inicio"), os.path.join(ENSAYO, "Escritorio") for c in carpetas: os.makedirs(c, exist_ok=True) return tuple(os.path.join(c, NOMBRE + ".lnk") for c in carpetas) return os.path.join(carpeta_de(EN_INICIO), NOMBRE + ".lnk"), os.path.join(carpeta_de(EN_ESCRITORIO), NOMBRE + ".lnk") def _misma(a, b): return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b)) def sitio_definitivo(): """Donde Windows pone los programas de cada usuario. De ahí no se limpia nada por descuido.""" programas = os.path.join(ENSAYO, "Programs") if ENSAYO else os.path.join( os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local"), "Programs") return os.path.join(programas, NOMBRE) def hay_que_mudar(base): """True si instalar significa llevar el programa a su sitio: es un paquete (trae su .exe) y no está ya allí.""" return os.path.isfile(os.path.join(base, "OrganizadorLabstream.exe")) and not _misma(base, sitio_definitivo()) def mudado(base): """Si desde esta carpeta ya se instaló el programa en otra, el .exe de esa otra; si no, "".""" try: with open(os.path.join(base, MARCA_MUDANZA), encoding="utf-8") as fh: destino = fh.readline().strip() except OSError: return "" exe = os.path.join(destino, "OrganizadorLabstream.exe") return exe if destino and not _misma(destino, base) and os.path.isfile(exe) else "" def _lo_que_viaja(base): """(ruta relativa, bytes) de cada archivo que se lleva la mudanza. No viaja lo que es de la sesión abierta ni lo que se rehace solo; el inventario va aparte, con la copia segura de SQLite.""" out = [] for raiz, dirs, files in os.walk(base): rel = os.path.relpath(raiz, base) if rel == ".": dirs[:] = [d for d in dirs if d not in NO_VIAJA] files = [f for f in files if f not in NO_VIAJA] elif rel == "datos": dirs[:] = [d for d in dirs if d not in NO_VIAJA_DATOS] files = [f for f in files if f not in NO_VIAJA_DATOS and not f.startswith(INVENTARIO) and not f.endswith(".tmp")] for f in files: p = os.path.join(raiz, f) out.append((os.path.relpath(p, base), os.path.getsize(p))) return out def mudar(base, destino=None, avance=None): """Copia el programa de `base`, con sus datos y su ffmpeg, a su sitio definitivo. Del origen no borra ni mueve nada: deja allí la nota de la mudanza. Si falla a medias, quita lo copiado y el origen sigue como estaba. `avance(0..1)` va diciendo cuánto lleva. Devuelve el destino.""" destino = destino or sitio_definitivo() if os.path.isdir(destino) and os.listdir(destino): raise FileExistsError(f"la carpeta {destino} ya existe y no está vacía") cosas = _lo_que_viaja(base) inventario = os.path.join(base, "datos", INVENTARIO) total = sum(t for _, t in cosas) + (os.path.getsize(inventario) if os.path.exists(inventario) else 0) or 1 os.makedirs(os.path.dirname(destino), exist_ok=True) if shutil.disk_usage(os.path.dirname(destino)).free < total * 1.05 + (50 << 20): raise OSError(f"no hay sitio en el disco: hacen falta {total / 1e6:.0f} MB") hechos = 0 try: for rel, tam in cosas: a = os.path.join(destino, rel) os.makedirs(os.path.dirname(a), exist_ok=True) shutil.copy2(os.path.join(base, rel), a) hechos += tam if avance: avance(hechos / total) if os.path.exists(inventario): # puede estar en uso: SQLite sabe copiarse entero y coherente os.makedirs(os.path.join(destino, "datos"), exist_ok=True) origen, copia = sqlite3.connect(inventario), sqlite3.connect(os.path.join(destino, "datos", INVENTARIO)) try: origen.backup(copia) finally: copia.close() origen.close() except Exception: shutil.rmtree(destino, ignore_errors=True) raise with open(os.path.join(base, MARCA_MUDANZA), "w", encoding="utf-8") as fh: fh.write(destino + "\n\nOrganiLAB se instaló en la carpeta de arriba el " + time.strftime("%d/%m/%Y") + ", con sus datos.\nEsta copia ya no se usa: si la abres, se abre la instalada.\n" "Puedes borrar esta carpeta entera cuando quieras.\n") return destino class YaInstalado(Exception): """Ya hay un OrganiLAB instalado en este equipo, en otra carpeta.""" def __init__(self, otro): super().__init__(f"ya hay un OrganiLAB instalado en {otro['base']} (versión {otro['version']})") self.otro = otro 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 _apuntar(base, version): """La línea del programa en Configuración › Aplicaciones.""" exe, args = orden(base) with winreg.CreateKey(winreg.HKEY_CURRENT_USER, CLAVE) as k: for nombre, valor in (("DisplayName", NOMBRE), ("DisplayVersion", version), ("Publisher", "Labstream"), ("DisplayIcon", os.path.join(base, "static", "organilab.ico")), ("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) def instalar(base, version): """Da de alta el programa de `base`: sus dos accesos directos y su línea en Aplicaciones.""" exe, args = orden(base) icono = os.path.join(base, "static", "organilab.ico") hechos = [acceso_directo(ruta, exe, args, base, icono) for ruta in _accesos()] _apuntar(base, version) return {"base": base, "accesos": hechos, "orden": f'"{exe}" {args}'.strip()} def poner(base, version, avance=None): """Instalar de verdad: si el programa es un paquete que no está en su sitio definitivo, se copia allí; y se da de alta en Windows desde donde quede. El resultado dice dónde quedó y si hubo mudanza.""" if not hay_que_mudar(base): return {**instalar(base, version), "mudado": False} otro = instalado() if otro and not _misma(otro["base"], base) and os.path.isfile(os.path.join(otro["base"], "OrganizadorLabstream.exe")): raise YaInstalado(otro) destino = mudar(base, avance=avance) return {**instalar(destino, version), "mudado": True} 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. Devuelve True si hubo que cambiar algo.""" i = instalado() if not i or not instalado_aqui(base): return False exe, args = orden(base) cambio = i["version"] != version for ruta in _accesos(): if not os.path.exists(ruta): continue # el que alguien quitó a propósito no se repone try: a = leer_acceso(ruta) if a and _misma(a["exe"], exe) and a["args"] == args: continue # ya abre lo que tiene que abrir: no se toca acceso_directo(ruta, exe, args, base, os.path.join(base, "static", "organilab.ico")) cambio = True except OSError: pass # un acceso que ahora no se deja reescribir no impide lo demás if cambio: _apuntar(base, version) return cambio 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 # si estaba encendido el orden automático, su tarea de Windows se va con el programa: si no, # seguiría intentando abrir cada noche algo que ya no está. Solo cuando se desinstala de verdad: # las pruebas trabajan contra una clave de ensayo y no deben tocar la tarea de nadie. if not ENSAYO and CLAVE == CLAVE_DE_VERDAD: try: subprocess.run(["schtasks", "/Delete", "/F", "/TN", TAREA_AUTOMATICO], capture_output=True, timeout=30, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) except (OSError, subprocess.SubprocessError): 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": r = poner(base, version) if r["mudado"]: decir(f"OrganiLAB quedó instalado en:\n\n{r['base']}\n\nÁbrelo desde el menú Inicio o el Escritorio. " "La carpeta desde la que lo instalaste ya no hace falta: puedes borrarla.") else: 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 YaInstalado as ex: decir(f"Ya hay un OrganiLAB instalado en este equipo (versión {ex.otro['version']}), en:\n\n{ex.otro['base']}\n\n" "Ábrelo desde el menú Inicio. Para ponerlo al día, usa «Buscar actualizaciones» dentro del programa.", V.MB_OK | V.MB_ICONINFORMATION) return 1 except Exception as ex: # noqa decir(f"No se pudo: {ex}", V.MB_OK | V.MB_ICONWARNING) return 1