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

Ver fichero

@@ -2,10 +2,12 @@
"""
Organizador Labstream — inventario, mapa, orden y optimización del material.
Solo librería estándar de Python (3.10+). Arranca un servidor local y abre la
interfaz en el navegador:
Solo librería estándar de Python (3.10+). Es un programa de Windows: abre su
propia ventana (ventana.py) y la interfaz habla con este módulo por un servidor
que solo escucha en este equipo.
py -3.12 organizador.py (o doble clic en Iniciar.bat)
OrganizadorLabstream.exe (el paquete: lo arma empaquetar.py)
py -3.12 organizador.py (desde el código)
Nunca borra nada: lo que sobra va a _Para borrar dentro de la misma raíz y cada
movimiento queda en datos/bitacora.jsonl para poder deshacerlo.
@@ -37,11 +39,14 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "2.6.2"
VERSION = "3.0.0"
CONGELADO = getattr(sys, "frozen", False)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
BASE = os.path.dirname(os.path.abspath(sys.executable if CONGELADO else __file__))
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
# el intérprete de Python tal como lo firma la Python Software Foundation, con otro nombre (arranque.py
# cuenta cómo). Hasta la 2.x era un .exe de PyInstaller, sin firma, y Smart App Control lo bloqueaba.
CONGELADO = bool(getattr(sys, "frozen", False)) or os.path.normcase(os.path.dirname(os.path.abspath(sys.executable))) == os.path.normcase(BASE)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras
DATOS = os.path.join(BASE, "datos")
os.makedirs(DATOS, exist_ok=True)
DB_PATH = os.path.join(DATOS, "inventario.sqlite")
@@ -2239,11 +2244,8 @@ def correr_resolve(trabajos):
lote = os.path.join(DATOS, f"resolve_lote_{int(time.time())}.json")
with open(lote, "w", encoding="utf-8") as fh:
json.dump(jobs, fh, ensure_ascii=False)
if CONGELADO and not o.get("python_resolve"):
cmd = [sys.executable, "--puente", lote] # el mismo .exe hace de puente
else:
py = o.get("python_resolve") or sys.executable
cmd = [py, os.path.join(BASE, "resolve_bridge.py"), lote]
# el puente corre con el mismo motor que el programa (en el paquete, su propio .exe haciendo de intérprete)
cmd = [o.get("python_resolve") or sys.executable, os.path.join(BASE, "resolve_bridge.py"), lote]
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
@@ -2889,13 +2891,20 @@ def _clave(ruta):
_ELIGIENDO = threading.Lock()
VENTANA = None # la ventana del programa (ventana.Ventana) mientras está abierta
ULTIMA_PETICION = [0.0] # cuándo preguntó algo la interfaz por última vez
def elegir_carpeta():
"""Abre el selector de carpetas de Windows (en un PowerShell aparte, para no bloquear el servidor) y devuelve la ruta o None."""
"""Abre el selector de carpetas de Windows y devuelve la ruta o None. Con la ventana del programa abierta
es el suyo, modal sobre ella; si la interfaz va en el navegador, uno aparte en PowerShell."""
if not _ELIGIENDO.acquire(blocking=False):
raise ValueError("ya hay un selector de carpetas abierto")
try:
v = VENTANA
if v is not None:
ruta = v.elegir_carpeta("Elige la carpeta del cliente")
return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None
ps = ("Add-Type -AssemblyName System.Windows.Forms; "
"$d = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; "
@@ -3777,6 +3786,7 @@ class Manejador(BaseHTTPRequestHandler):
return True
def do_GET(self):
ULTIMA_PETICION[0] = time.time()
if self.path.startswith("/api/") and not self._es_local():
return self._json({"error": "petición no permitida"}, 403)
u = urlparse(self.path)
@@ -3917,6 +3927,7 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"error": repr(ex), "traza": traceback.format_exc()}, 500)
def do_POST(self):
ULTIMA_PETICION[0] = time.time()
u = urlparse(self.path)
ruta = u.path
if not self._es_local() or not (self.headers.get("Content-Type") or "").lower().startswith("application/json"):
@@ -4120,7 +4131,8 @@ class Manejador(BaseHTTPRequestHandler):
AYUDA = """OrganiLAB (Organizador Labstream) — uso por comando
OrganizadorLabstream.exe abre la interfaz en el navegador
OrganizadorLabstream.exe abre OrganiLAB en su ventana
OrganizadorLabstream.exe instalar lo pone en el menú Inicio y en el Escritorio (desinstalar lo quita)
OrganizadorLabstream.exe "D:\\Clientes\\Carpeta" (o arrastra la carpeta encima del .exe) ASISTENTE: la escanea con barra
de progreso, da el diagnóstico por tipo de archivo y pregunta paso a paso qué hacer
OrganizadorLabstream.exe asistente "ruta" [--si] [--simular] lo mismo por comando (--si acepta lo recomendado sin preguntar)
@@ -4344,20 +4356,20 @@ def _asegurar_consola():
SOLTADOS = {"ts": 0, "rutas": []}
SESION_PATH = os.path.join(DATOS, "sesion.json") # qué copia tiene abiertos estos datos, y en qué puerto atiende
_CANDADO = [] # el de «una sola copia abierta»; vive lo que el proceso
SILENCIO_NAVEGADOR = 180 # segundos sin que la página pregunte para dar el navegador por cerrado
def _al_soltar(e):
try:
rutas = [f.get("pywebviewFullPath") for f in (e.get("dataTransfer") or {}).get("files", []) if f.get("pywebviewFullPath")]
except Exception: # noqa
rutas = []
def _al_soltar(rutas):
"""Lo que se acaba de soltar en la ventana, con su ruta completa: la ventana la sabe, la interfaz no."""
if rutas:
SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas])
def soltado(nombre, espera=2.5):
"""Ruta completa de lo que se acaba de soltar en la ventana (llega por el puente WebView2). Espera un poco
porque el drop del navegador y el aviso del puente no llegan en el mismo orden."""
"""Ruta completa de lo que se acaba de soltar en la ventana. Espera un poco porque el aviso de la ventana
y la pregunta de la interfaz no llegan siempre en el mismo orden."""
fin = time.time() + espera
while time.time() < fin:
if time.time() - SOLTADOS["ts"] < 8:
@@ -4368,46 +4380,181 @@ def soltado(nombre, espera=2.5):
return None
def _ventana(url):
"""Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador."""
try:
import webview
except Exception: # noqa
webbrowser.open(url)
return False
try:
v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff")
def en_marcha():
"""Qué está haciendo el programa ahora mismo, dicho en llano, o "" si no hace nada. Mientras haya algo,
cerrar la ventana no lo corta."""
que = actualizador.tarea_en_marcha()
if not que and APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
que = "se están moviendo archivos"
return que
def _al_cargar():
# WebView2 entrega la ruta completa de lo que se suelta solo por este puente (el navegador no la da):
# se guarda y la interfaz la pide en /api/soltado en cuanto ve el drop.
try:
v.dom.document.events.drop += _al_soltar
except Exception as ex: # noqa
print("sin puente de arrastre:", ex)
v.events.loaded += _al_cargar
webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana"))
def avance():
"""Cuánto lleva lo que está en marcha: de 0 a 1, -1 si no se puede saber y None si no hay nada. Es lo que
se ve en el botón de la barra de tareas."""
if COLA["activa"]:
try:
con = db()
espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0]
t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None
con.close()
except sqlite3.Error:
return -1
total = COLA["hechos"] + (1 if t else 0) + espera
return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1
if RESPALDO.get("estado") == "corriendo":
return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
return APLICANDO["hechas"] / APLICANDO["total"]
if asistente.ESTADO.get("activo"):
return asistente.ESTADO["pct"] / 100 if asistente.ESTADO.get("total") else -1
for a in list(ANALISIS.values()):
if (a or {}).get("estado") == "corriendo":
return a["hechos"] / a["total"] if a.get("total") else -1
if any((p or {}).get("estado") == "corriendo" for p in list(PROGRESO.values())):
return -1
if actualizador.ESTADO.get("estado") == "descargando":
return (actualizador.ESTADO.get("pct") or 0) / 100
return None
def _marca():
"""Un número que identifica esta instalación por su carpeta de datos."""
return (zlib.crc32(os.path.normcase(os.path.abspath(DATOS)).encode("utf-8")) & 0x7FFFFFFF) or 1
def _soy_la_unica():
"""False si ya hay otra copia del programa abierta sobre estos mismos datos: dos a la vez se pisarían el
inventario, y la segunda daría por huérfanas las conversiones que la primera tiene en marcha."""
if os.name != "nt":
return True
except Exception as ex: # noqa
print("Sin WebView2; abro el navegador:", ex)
webbrowser.open(url)
k = ctypes.WinDLL("kernel32", use_last_error=True)
k.CreateMutexW.argtypes, k.CreateMutexW.restype = [ctypes.c_void_p, ctypes.c_int, ctypes.c_wchar_p], ctypes.c_void_p
h = k.CreateMutexW(None, False, f"OrganiLAB-{_marca():08x}")
if h and ctypes.get_last_error() == 183: # ya existía
return False
_CANDADO.append(h)
return True
def _limpiar_restos():
"""Lo que dejó el .exe de PyInstaller de las versiones hasta la 2.x: su carpeta _internal, que ya no usa
nadie (el motor de ahora va en la carpeta runtime)."""
viejo = os.path.join(BASE, "_internal")
if CONGELADO and not getattr(sys, "frozen", False) and os.path.isfile(os.path.join(viejo, "base_library.zip")):
shutil.rmtree(viejo, ignore_errors=True)
def _vigilar(v, fin):
"""Mientras la ventana existe: lleva el avance al botón de la barra de tareas, avisa cuando termina algo
largo y, si la ventana se cerró con trabajo pendiente, cierra el programa en cuanto acaba."""
antes, desde = None, 0.0
while not fin.wait(1.0):
try:
f = avance()
except Exception: # noqa
f = None
if f is None:
if antes is not None:
v.progreso(None)
if time.time() - desde > 20 and not v.oculta:
v.avisar()
if v.oculta and not en_marcha():
v.cerrar()
else:
if antes is None:
desde = time.time()
if not v.oculta:
v.progreso(f)
antes = f
def _recien_abierta(v):
"""Con la interfaz ya a la vista: si el programa está dado de alta en Windows, pone al día su versión y
sus accesos directos; y la primera vez que se abre un paquete en un equipo, ofrece darlo de alta."""
try:
import ventana
import instalar
if instalar.al_dia(BASE, VERSION):
return
oferta = CONFIG.get("instalacion") or {}
if not CONGELADO or os.environ.get("ORGANIZADOR_PUERTO") or oferta.get("ofrecida") or instalar.instalado_aqui(BASE):
return
CONFIG["instalacion"] = {**oferta, "ofrecida": True} # se pregunta una sola vez
guardar_json(CONFIG_PATH, CONFIG)
time.sleep(1.5)
if v.mensaje("¿Quieres tener OrganiLAB en el menú Inicio y en el Escritorio, como un programa más de Windows?\n\n"
"No copia nada: el programa se queda en esta carpeta. Se quita desde Configuración › Aplicaciones.",
ventana.MB_YESNO | ventana.MB_ICONQUESTION) == ventana.IDYES:
instalar.instalar(BASE, VERSION)
v.mensaje("Listo. Búscalo como «OrganiLAB» en el menú Inicio, o ábrelo desde el Escritorio.")
except Exception as ex: # noqa
print("no se pudo dar de alta en Windows:", ex)
def _ventana(url):
"""Abre la ventana del programa y no vuelve hasta que se cierra. Devuelve False si este Windows no puede
abrirla (le falta WebView2): quien llama abre entonces la interfaz en el navegador."""
global VENTANA
try:
import ventana
import instalar
ok, detalle = ventana.disponible(BASE)
if not ok:
print("Sin ventana propia:", detalle)
return False
v = ventana.Ventana(url, titulo="OrganiLAB", icono=os.path.join(BASE, "static", "organilab.ico"),
perfil=os.path.join(DATOS, "ventana"), memoria=os.path.join(DATOS, "ventana.json"),
base=BASE, marca=_marca(), relanzar=instalar.orden(BASE),
depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR")))
v.al_cerrar, v.al_soltar = en_marcha, _al_soltar
v.al_abrir = lambda: _recien_abierta(v)
fin = threading.Event()
threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start()
VENTANA = v
try:
abierta = v.mostrar()
finally:
VENTANA = None
fin.set()
if not abierta:
print("La ventana no llegó a abrirse:", v.fallo)
return abierta
except Exception as ex: # noqa
print("Sin ventana propia:", ex)
return False
def _atender_navegador():
"""Sin ventana no hay nada que cerrar: el programa sigue mientras la página del navegador pregunte (lo hace
cada pocos segundos) y se va cuando lleva tres minutos callada y no queda trabajo en marcha."""
ULTIMA_PETICION[0] = time.time()
try:
while time.time() - ULTIMA_PETICION[0] < SILENCIO_NAVEGADOR or en_marcha():
time.sleep(2)
except KeyboardInterrupt:
pass
def main():
global PUERTO
args_tmp = [a for a in sys.argv[1:] if not a.startswith("--")]
if args_tmp and args_tmp[0].lower() in ("instalar", "desinstalar") and "--puente" not in sys.argv:
import instalar # hablan con cuadros de mensaje: no hace falta consola
sys.exit(instalar.principal(args_tmp[0].lower(), BASE, VERSION, silencioso="--si" in sys.argv))
if args_tmp and "--puente" not in sys.argv:
_asegurar_consola()
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception: # noqa
pass
if "--puente" in sys.argv: # modo puente con Resolve (lo usa el propio .exe)
if "--puente" in sys.argv: # puente con Resolve, como lo llamaban los .exe hasta la 2.x
import resolve_bridge
sys.exit(resolve_bridge.main(sys.argv[sys.argv.index("--puente") + 1]))
args = [a for a in sys.argv[1:] if a not in ("--sin-navegador", "--navegador")]
if args:
if CONGELADO and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?"):
if (CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True
and args[0].lower() not in ("actualizar", "actualizacion", "ayuda", "-h", "--help", "/?")):
actualizador.comprobar_en_segundo_plano()
codigo = cli(args)
d = actualizador.ESTADO.get("disponible")
@@ -4419,6 +4566,16 @@ def main():
except EOFError:
pass
sys.exit(codigo)
sin_ventana = "--sin-navegador" in sys.argv or "--navegador" in sys.argv
if not _soy_la_unica():
import ventana
if not sin_ventana and ventana.traer_al_frente(_marca()):
return # ya estaba abierto: se enseña esa ventana y nada más
otra = cargar_json(SESION_PATH, {}).get("puerto")
print("Ya hay un OrganiLAB abierto con estos datos" + (f", en el puerto {otra}." if otra else "."))
if otra and "--sin-navegador" not in sys.argv:
webbrowser.open(f"http://127.0.0.1:{otra}/") # esa copia no tiene ventana: va en el navegador
return
h = herramientas()
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)")
@@ -4428,34 +4585,46 @@ def main():
try:
srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador)
except OSError:
print(f"Ya hay un Organizador abierto en el puerto {PUERTO}; abro esa ventana.")
webbrowser.open(f"http://127.0.0.1:{PUERTO}/")
return
if os.environ.get("ORGANIZADOR_PUERTO"):
print(f"El puerto {PUERTO} está ocupado por otro programa.")
sys.exit(1)
# el puerto de siempre lo tiene otro programa: se toma uno libre (a la ventana le da igual cuál sea)
srv = ThreadingHTTPServer(("127.0.0.1", 0), Manejador)
PUERTO = srv.server_address[1]
url = f"http://127.0.0.1:{PUERTO}/"
guardar_json(SESION_PATH, {"pid": os.getpid(), "puerto": PUERTO})
if sys.stdout:
print(f"OrganiLAB {VERSION} en {url} (Ctrl+C para cerrar)")
print(f"OrganiLAB {VERSION} en {url}")
if CONGELADO and CONFIG.get("actualizaciones", {}).get("auto") is True:
actualizador.comprobar_en_segundo_plano()
if "--sin-navegador" in sys.argv: # solo el servidor (desarrollo / vista previa)
try:
if "--sin-navegador" in sys.argv: # solo el servidor (pruebas y desarrollo)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return
hilo = threading.Thread(target=srv.serve_forever, daemon=True)
hilo.start()
threading.Thread(target=_limpiar_restos, daemon=True).start()
if "--navegador" in sys.argv:
webbrowser.open(url)
try:
hilo.join()
except KeyboardInterrupt:
pass
return
if not _ventana(url):
webbrowser.open(url)
_atender_navegador()
while en_marcha(): # la ventana se fue con trabajo pendiente: se acaba antes de salir
time.sleep(1)
srv.shutdown()
finally:
try:
srv.serve_forever()
except KeyboardInterrupt:
os.remove(SESION_PATH)
except OSError:
pass
return
hilo = threading.Thread(target=srv.serve_forever, daemon=True)
hilo.start()
if "--navegador" in sys.argv:
webbrowser.open(url)
try:
hilo.join()
except KeyboardInterrupt:
pass
return
# ventana propia: al cerrarla se cierra el programa (salvo que haya trabajo en marcha)
_ventana(url)
while COLA["activa"] or RESPALDO.get("estado") == "corriendo" or asistente.ESTADO.get("activo"):
time.sleep(1)
srv.shutdown()
if __name__ == "__main__":