3.1.0: se instala solo en su sitio, manual dentro y el puerto para una sola copia

- 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>
Este commit está contenido en:
Labstream
2026-10-04 12:28:03 -05:00
padre 6c21145b1c
commit 50562aeaab
Se han modificado 8 ficheros con 562 adiciones y 43 borrados

Ver fichero

@@ -1,11 +1,16 @@
# -*- 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.
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.
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.
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.
@@ -15,15 +20,26 @@ a la barra de tareas y la ventana abierta son, para Windows, la misma cosa: un s
"""
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"
CLAVE = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB"
# 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}"
@@ -132,9 +148,106 @@ def leer_acceso(ruta):
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):
@@ -181,6 +294,18 @@ def instalar(base, version, solo_los_que_hay=False):
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."""
@@ -222,9 +347,13 @@ def principal(que, base, version, silencioso=False):
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.")
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.",
@@ -233,6 +362,11 @@ def principal(que, base, version, silencioso=False):
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