From fde0fc1c723177c0b43bbcea77b4dafbe51c1dd9 Mon Sep 17 00:00:00 2001 From: sito Date: Sun, 16 Aug 2026 16:31:11 +0200 Subject: [PATCH] =?UTF-8?q?Boton=20cerrar=20y=20cerrar=20la=20pesta=C3=B1a?= =?UTF-8?q?=20apaga=20JARVIS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cerrar el panel dejaba el proceso vivo (whisper agarrando la grafica, el microfono escuchando). Ahora hay un boton "cerrar" (dos clics) y cerrar la ultima pestaña apaga JARVIS del todo: para la voz, suelta whisper y cierra el proceso, via SIGINT (la misma limpieza que Ctrl+C). --- nucleo/cara/hud/hud.js | 45 ++++++++++++++++++++++++++++++-------- nucleo/cara/hud/index.html | 7 ++++++ nucleo/cara/hud/style.css | 7 ++++++ nucleo/cara/panel.py | 22 +++++++++++++++++++ nucleo/cara/servidor.py | 23 ++++++++++++++++++- nucleo/jarvis.py | 1 + 6 files changed, 95 insertions(+), 10 deletions(-) diff --git a/nucleo/cara/hud/hud.js b/nucleo/cara/hud/hud.js index 40ce5c4..3185de6 100644 --- a/nucleo/cara/hud/hud.js +++ b/nucleo/cara/hud/hud.js @@ -758,6 +758,13 @@ } else if (tipo === "medidas") { ponMedidas(datos); + } else if (tipo === "cerrando") { + // JARVIS se esta apagando: cerrar la ventana del panel. En chromium + // --app window.close() cierra la ventana de la aplicacion. + document.body.classList.add("parado"); + ponEstado("parado"); + setTimeout(() => { try { window.close(); } catch (e) {} }, 300); + } else if (tipo === "telemetria") { if (datos.nucleos) ponNucleos(datos.nucleos); ponConstantes(datos); @@ -851,6 +858,35 @@ } }); + // Cerrar JARVIS del todo. Dos clics para no apagarlo por un roce: el primero + // arma ("pulsa otra vez"), el segundo lo manda. Si no se confirma en 3 s, + // vuelve a como estaba. Al confirmar, Python contesta "cerrando" y la ventana + // se cierra sola. + (function () { + const b = document.getElementById("cerrar"); + if (!b) return; + const rotulo = b.querySelector(".rotulo"); + let armado = false, reloj = null; + b.addEventListener("click", () => { + if (!armado) { + armado = true; + b.classList.add("armado"); + rotulo.textContent = "¿seguro?"; + reloj = setTimeout(() => { + armado = false; + b.classList.remove("armado"); + rotulo.textContent = "cerrar"; + }, 3000); + return; + } + clearTimeout(reloj); + rotulo.textContent = "cerrando"; + try { + window.webkit.messageHandlers.accion.postMessage("cerrar"); + } catch (e) { /* suelto en el navegador: no hay Python que apagar */ } + }); + })(); + // Lanza la tanda de prueba. No cambia nada en el boton: lo que hay que mirar // son las tarjetas, que es justo lo que se quiere comprobar. document.getElementById("prueba").addEventListener("click", (ev) => { @@ -944,14 +980,5 @@ { pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 }, ], }}); - - window.jarvis.recibe({ tipo: "medidas", datos: { - n: 240, - lat_p50: 2.1, lat_p95: 4.8, lat_max: 7.2, - lat_cerebro: 2.7, lat_catalogo: 0.2, - pct_cerebro: 44, pct_catalogo: 53, pct_ruido: 3, - vol24: 87, porhora: [2, 0, 1, 4, 6, 3, 5, 8, 4, 2, 1, 3], - rag: { hit5: 95, hit1: 82 }, - }}); } })(); diff --git a/nucleo/cara/hud/index.html b/nucleo/cara/hud/index.html index 502b874..dc3f5c7 100644 --- a/nucleo/cara/hud/index.html +++ b/nucleo/cara/hud/index.html @@ -159,6 +159,13 @@ + + + diff --git a/nucleo/cara/hud/style.css b/nucleo/cara/hud/style.css index 0b76517..417e512 100644 --- a/nucleo/cara/hud/style.css +++ b/nucleo/cara/hud/style.css @@ -659,6 +659,13 @@ h2.separado { margin-top: 26px; } border-color: color-mix(in srgb, var(--mal) 60%, transparent); } +/* El boton de cerrar, armado tras el primer clic: rojo, para que se vea que el + segundo clic apaga de verdad. */ +.controles button#cerrar.armado { + color: var(--mal, #ff7a6b); + border-color: var(--mal, #ff7a6b); +} + .controles button:hover { color: var(--brillo); border-color: var(--acento); diff --git a/nucleo/cara/panel.py b/nucleo/cara/panel.py index 10b22fd..522f65a 100644 --- a/nucleo/cara/panel.py +++ b/nucleo/cara/panel.py @@ -18,6 +18,7 @@ mirando: sin panel abierto son quince mensajes por segundo a nadie. """ import os import shutil +import signal import subprocess import threading import time @@ -360,6 +361,20 @@ class Panel: except Exception: pass + def cerrar(self): + """Apaga JARVIS entero: la escucha, la voz, whisper-server y el proceso. + + Cerrar la pestaña o pulsar «cerrar» tiene que soltar de verdad el + microfono y la grafica, no dejar el proceso escuchando en segundo plano. + Se manda SIGINT al propio proceso: eso dispara el mismo apagado que + Ctrl+C —el finally del bucle de escucha para whisper, y atexit lo remata— + asi que se reutiliza la limpieza que ya estaba probada, sin duplicarla. + Corre desde un hilo del servidor; la señal la atiende el hilo principal. + """ + self.parado = True # que la escucha no atienda nada mas ya + self.para() # corta los hilos y las tareas del panel + os.kill(os.getpid(), signal.SIGINT) + # --------------------------------------------------------------- pulsar def pulsado(self, canal: str, dato: str): @@ -417,6 +432,13 @@ class Panel: self.manda_controles() self.estado("escuchando" if not self.centinela.activo else "dormido") + elif dato == "cerrar": + # Apagar JARVIS del todo. Se responde primero al HUD para que la + # ventana sepa cerrarse, y luego se mata el proceso. + self.cara.manda("cerrando", True) + self.cerrar() + return + elif dato.startswith("parar"): # El boton alterna PAUSA. No es solo "cortar lo de ahora": el titulo # promete "cierra el microfono y corta la voz; pulsa otra vez para diff --git a/nucleo/cara/servidor.py b/nucleo/cara/servidor.py index c5ab75a..0c626be 100644 --- a/nucleo/cara/servidor.py +++ b/nucleo/cara/servidor.py @@ -42,10 +42,17 @@ FICHEROS = { class Cara: """El panel: se le empujan eventos y devuelve lo que se pulsa.""" - def __init__(self, al_pulsar=None, puerto=PUERTO): + # Segundos que se espera tras cerrarse la ultima pestaña antes de apagar. + # Da margen a una RECARGA (que cierra el socket y lo reabre enseguida) para + # no confundirla con cerrar la ventana de verdad. + GRACIA = 4 + + def __init__(self, al_pulsar=None, al_cerrar=None, puerto=PUERTO): self.al_pulsar = al_pulsar # (canal, dato) -> None + self.al_cerrar = al_cerrar # se cerro la ultima pestaña -> apagar self.puerto = puerto self._clientes = set() + self._hubo_cliente = False # para no apagar antes de abrir el panel self._bucle = None self._hilo = None self._ultimo = {} # el estado, para el que llegue tarde @@ -124,6 +131,7 @@ class Cara: async def _cliente(self, ws): self._clientes.add(ws) + self._hubo_cliente = True try: # Al conectar se le manda el estado que ya habia. Sin esto, abrir el # panel a media sesion enseña un HUD vacio hasta que algo cambie. @@ -146,6 +154,19 @@ class Cara: pass finally: self._clientes.discard(ws) + # Se cerro una pestaña. Si no queda ninguna, se espera un poco (por + # si es una recarga) y, si sigue sin haber panel abierto, se apaga + # JARVIS: cerrar la ventana suelta el microfono y la grafica. + if self._hubo_cliente and not self._clientes and self.al_cerrar: + try: + asyncio.get_running_loop().call_later( + self.GRACIA, self._quiza_cerrar) + except RuntimeError: + pass + + def _quiza_cerrar(self): + if not self._clientes and self.al_cerrar: + threading.Thread(target=self.al_cerrar, daemon=True).start() # --------------------------------------------------------------- empujar diff --git a/nucleo/jarvis.py b/nucleo/jarvis.py index 3c99716..dade051 100755 --- a/nucleo/jarvis.py +++ b/nucleo/jarvis.py @@ -227,6 +227,7 @@ def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small", p = Panel(cara, boca, manos, cerebro, candado=not sin_candado, centinela=centinela) cara.al_pulsar = p.pulsado + cara.al_cerrar = p.cerrar # cerrar la ultima pestaña apaga JARVIS url = cara.arranca() if not url: return 1