- instalar.py: instalar un paquete lo copia a la carpeta de programas del usuario (%LOCALAPPDATA%\Programs\OrganiLAB) con sus datos y su ffmpeg, lo da de alta desde ahí y lo vuelve a abrir desde su sitio. Del origen no se borra nada: queda instalado-en.txt, que además hace que abrir esa copia abra la instalada. Un destino ocupado no se pisa, y si ya hay un OrganiLAB instalado lo dice en vez de poner otro encima. - static/index.html: vista «Manual», con cada función del programa, qué hace y cómo lo hace (41 entradas, un apartado por pestaña). - organizador.py: ServidorLocal pide el puerto en exclusiva. Con «reutilizar dirección», en Windows dos copias escuchaban a la vez en el mismo puerto y se contestaban la una por la otra sin que nada fallara a la vista. - Pruebas: probar.py 19, prueba_funcional.py 26 de 26, prueba_ventana.py 18 desde el código y 17 con el paquete, incluida la instalación entera en un equipo de ensayo (ORGANILAB_ENSAYO) que no toca el Windows de verdad. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
373 líneas
18 KiB
Python
373 líneas
18 KiB
Python
# -*- 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
|
||
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=<carpeta>, 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 = r"Software\OrganiLAB-ensayo\Alta" if ENSAYO else r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB"
|
||
MARCA_MUDANZA = "instalado-en.txt" # en la carpeta de origen: adónde se instaló lo que había aquí
|
||
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 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 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."""
|
||
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":
|
||
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
|