3.0.0: OrganiLAB pasa a ser un programa de Windows

- 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>
Este commit está contenido en:
Labstream
2026-10-04 09:54:31 -05:00
padre 2daffc175a
commit 252b1fd00e
Se han modificado 14 ficheros con 2275 adiciones y 165 borrados

140
probar.py
Ver fichero

@@ -90,6 +90,131 @@ class Config(unittest.TestCase):
shutil.rmtree(tmp, ignore_errors=True)
@unittest.skipUnless(os.name == "nt", "la ventana y los accesos directos son de Windows")
class Windows(unittest.TestCase):
def test_tablas_de_la_ventana(self):
"""Todos los identificadores de interfaz de ventana.py están bien escritos, y preguntar si hay
WebView2 no revienta en ningún equipo (la respuesta depende del equipo)."""
import ventana
ids = [v for k, v in vars(ventana).items() if k.startswith(("IID_", "CLSID_", "FMT_")) and isinstance(v, str)]
self.assertGreater(len(ids), 20)
for g in ids:
self.assertEqual(len(bytes(ventana._guid(g))), 16)
ok, detalle = ventana.disponible(AQUI)
self.assertIsInstance(ok, bool)
self.assertTrue(detalle)
self.assertFalse(ventana.traer_al_frente(123456789)) # nadie abierto con esa marca
def test_acceso_directo(self):
"""El acceso directo que se escribe dice lo que se le pidió, identidad de OrganiLAB incluida: sin
ella, la barra de tareas trataría el acceso anclado y la ventana abierta como dos programas."""
import ventana
import instalar
tmp = tempfile.mkdtemp(prefix="organilab-lnk-")
try:
exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "notepad.exe")
ruta = instalar.acceso_directo(os.path.join(tmp, "OrganiLAB.lnk"), exe, '"una carpeta\\organizador.py"', tmp,
os.path.join(AQUI, "static", "organilab.ico"))
a = instalar.leer_acceso(ruta)
self.assertEqual(os.path.normcase(a["exe"]), os.path.normcase(exe))
self.assertEqual(a["args"], '"una carpeta\\organizador.py"')
self.assertEqual(os.path.normcase(a["carpeta"]), os.path.normcase(tmp))
self.assertTrue(a["icono"].lower().endswith("organilab.ico"))
self.assertEqual(a["identidad"], ventana.IDENTIDAD)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_instalar_y_quitar(self):
"""Dar de alta el programa en Windows y quitarlo, de punta a punta pero sin tocar el menú Inicio ni el
Escritorio de verdad: los accesos van a una carpeta temporal y la línea de Aplicaciones a una clave de ensayo."""
import winreg
import instalar
tmp = tempfile.mkdtemp(prefix="organilab-alta-")
base = os.path.join(tmp, "programa")
os.makedirs(os.path.join(base, "static"))
inicio, escritorio = os.path.join(tmp, "inicio", "OrganiLAB.lnk"), os.path.join(tmp, "escritorio", "OrganiLAB.lnk")
os.makedirs(os.path.dirname(inicio))
os.makedirs(os.path.dirname(escritorio))
viejos = instalar._accesos, instalar.CLAVE
instalar._accesos, instalar.CLAVE = (lambda: (inicio, escritorio)), r"Software\OrganiLAB-ensayo\Alta"
try:
self.assertIsNone(instalar.instalado())
r = instalar.instalar(base, "3.0.0")
self.assertEqual(sorted(r["accesos"]), sorted([inicio, escritorio]))
self.assertTrue(instalar.instalado_aqui(base))
self.assertFalse(instalar.instalado_aqui(tmp))
self.assertEqual(os.path.normcase(instalar.leer_acceso(inicio)["carpeta"]), os.path.normcase(base))
# tras una actualización: sube la versión que enseña Windows, y no repone el acceso que alguien quitó
self.assertFalse(instalar.al_dia(base, "3.0.0"))
os.remove(escritorio)
self.assertTrue(instalar.al_dia(base, "3.0.1"))
self.assertEqual(instalar.instalado()["version"], "3.0.1")
self.assertFalse(os.path.exists(escritorio))
# quitarlo desde otra copia del programa no toca lo de esta; desde esta, sí
self.assertEqual(instalar.desinstalar(tmp)["accesos"], [])
self.assertTrue(instalar.instalado_aqui(base))
self.assertEqual(instalar.desinstalar(base)["accesos"], [inicio])
self.assertIsNone(instalar.instalado())
finally:
for clave in (instalar.CLAVE, r"Software\OrganiLAB-ensayo"):
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, clave)
except OSError:
pass
instalar._accesos, instalar.CLAVE = viejos
shutil.rmtree(tmp, ignore_errors=True)
def test_sin_ventana_y_con_el_puerto_ocupado(self):
"""Dos imprevistos a la vez. El puerto de siempre lo tiene otro programa: se toma uno libre en vez de
abrir la página de ese otro. Y este Windows no puede abrir la ventana (aquí, porque falta el cargador
de WebView2): la interfaz va al navegador y el programa se va solo cuando esa página deja de preguntar."""
tmp = tempfile.mkdtemp(prefix="organilab-imprevistos-")
tapon = socket.socket()
tapon.bind(("127.0.0.1", 0))
tapon.listen(1)
ocupado = tapon.getsockname()[1]
try:
for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"):
shutil.copy(os.path.join(AQUI, f), tmp)
shutil.copytree(os.path.join(AQUI, "static"), os.path.join(tmp, "static"))
guion = ("import sys; sys.argv = ['organizador.py']; import organizador as O; "
f"O.PUERTO = {ocupado}; O.SILENCIO_NAVEGADOR = 2; "
"O.webbrowser.open = lambda u: print('ABRE', u, flush=True); O.main(); print('FIN', O.PUERTO, flush=True)")
env = {k: v for k, v in os.environ.items() if k != "ORGANIZADOR_PUERTO"}
env["PYTHONIOENCODING"] = "utf-8"
p = subprocess.run([sys.executable, "-c", guion], cwd=tmp, env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=90)
self.assertEqual(p.returncode, 0, p.stderr[-600:])
abre = [l for l in p.stdout.splitlines() if l.startswith("ABRE ")]
self.assertEqual(len(abre), 1, p.stdout[-600:])
self.assertNotIn(f":{ocupado}/", abre[0])
self.assertIn("Sin ventana propia", p.stdout)
self.assertTrue(p.stdout.strip().splitlines()[-1].startswith("FIN "))
self.assertFalse(os.path.exists(os.path.join(tmp, "datos", "sesion.json")))
finally:
tapon.close()
shutil.rmtree(tmp, ignore_errors=True)
def test_orden_de_arranque(self):
"""Desde el código el programa se abre con el Python sin consola; en el paquete, con su propio .exe."""
import instalar
tmp = tempfile.mkdtemp(prefix="organilab-orden-")
try:
exe, args = instalar.orden(tmp)
self.assertTrue(exe.lower().endswith(("pythonw.exe", "python.exe")))
self.assertEqual(args, '"%s"' % os.path.join(tmp, "organizador.py"))
open(os.path.join(tmp, "OrganizadorLabstream.exe"), "wb").close()
self.assertEqual(instalar.orden(tmp), (os.path.join(tmp, "OrganizadorLabstream.exe"), ""))
finally:
shutil.rmtree(tmp, ignore_errors=True)
def test_arranque_no_estorba(self):
"""arranque.py es el sitecustomize del paquete. Fuera de un paquete (este Python no vive junto a un
organizador.py) y usado como intérprete normal, no hace nada: ni lanza el programa ni corta el proceso."""
p = subprocess.run([sys.executable, "-c", "import arranque; print('sigue')"], cwd=AQUI, capture_output=True, text=True, timeout=60)
self.assertEqual(p.stdout.strip(), "sigue")
class Firma(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -143,7 +268,7 @@ class Servidor(unittest.TestCase):
cls.tmp = tempfile.mkdtemp(prefix="organilab-e2e-")
cls.app = os.path.join(cls.tmp, "app")
os.makedirs(cls.app)
for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py"):
for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"):
shutil.copy(os.path.join(AQUI, f), cls.app)
shutil.copytree(os.path.join(AQUI, "static"), os.path.join(cls.app, "static"))
# carpeta de ensayo: cámara Sony, basura y un proxy
@@ -201,6 +326,19 @@ class Servidor(unittest.TestCase):
out.append(os.path.relpath(os.path.join(d, f), self.raiz).lower())
return sorted(out)
def test_una_sola_copia(self):
"""Abrir el programa otra vez sobre los mismos datos no levanta una segunda copia (se pisarían el
inventario, y la segunda daría por huérfanas las conversiones de la primera): se va, y dice por qué."""
with open(os.path.join(self.app, "datos", "sesion.json"), encoding="utf-8") as fh:
self.assertEqual(json.load(fh)["puerto"], self.puerto)
env = dict(os.environ, ORGANIZADOR_PUERTO=str(_puerto_libre()), PYTHONIOENCODING="utf-8")
p = subprocess.run([sys.executable, "organizador.py", "--sin-navegador"], cwd=self.app, env=env, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=60)
self.assertEqual(p.returncode, 0)
self.assertIn("Ya hay un OrganiLAB abierto", p.stdout)
self.assertIn(str(self.puerto), p.stdout)
self.assertEqual(self.get("/api/estado")["version"], self.get("/api/actualizacion")["version"]) # la primera sigue atendiendo
def test_recorrido(self):
q = "?raiz=" + urllib.parse.quote(self.raiz)
# 0. una instalación nueva abre en blanco: ninguna carpeta puesta de fábrica