Comparar commits

...

5 Commits

Autor SHA1 Mensaje Fecha
Munka
a725900a4a v1.22.0: la IA del equipo (máscara de sujeto en la tarjeta NVIDIA)
La máscara de «Sujeto» y «Fondo» del editor de revelado de Labstream OS la busca
la app en la tarjeta del equipo, no el navegador (src-tauri/src/ia/):

- Tres comandos para la página, permitidos solo para el origen de la app (manifiesto
  en build.rs + capabilities): ia_estado (¿hay NVIDIA?, ¿está bajado?, cuánto
  falta), ia_preparar (baja lo que falte con eventos ia-progreso y abre el modelo)
  e ia_sujeto (RGBA en binario → [ancho][alto][un byte por punto]).
- Motor: ONNX Runtime 1.24.4 con DirectML (crate ort 2.0.0-rc.14, load-dynamic,
  api-24): nada se enlaza al compilar; las DLL se bajan de NuGet la primera vez y
  DirectML.dll se carga por su ruta antes que onnxruntime.dll. Sin fundir el grafo
  (fundido, el HR no cabía en 16 GB) y sin seguir en la CPU si la tarjeta falla.
- Modelo: BiRefNet general en media precisión, del servidor de la app con la
  sesión de la pestaña (cookies de WebView2 leídas en el comando asíncrono).
- Todo fijado por huella SHA-256; los .nupkg se sacan por ruta exacta (DirectML trae
  también las DLL de Xbox y de depurar con el mismo nombre).
- ¿Hay NVIDIA?: los adaptadores de DXGI, sin cargar nada de la IA.
- El modelo se suelta de la tarjeta a los 2 min sin uso; si la tarjeta falla, se
  suelta y se abre de nuevo la próxima vez. Todo va a salud.log.

Medido en una RTX 5080: 0,3 s por foto (1,4 s la primera; el navegador tarda
2,5–3 s) y el mismo borde que el modelo original en la CPU. El HR (2048 px,
matting) se probó y se descartó: 14–15 GB de la tarjeta y de 6 a 45 s por foto.

Pruebas nuevas: zip por ruta exacta, entrada igual a la del navegador, salida y
respuesta; y una de punta a punta en la tarjeta (ignorada en CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:45:04 -05:00
Munka
6539f6939c v1.21.0: la pestaña del color de su página y pestañas con nombre
Para el modo edición de los sets de fotos de Labstream OS, que se abre en su
propia pestaña:

- Color de la página: una página pide el color de su pestaña con
  <html data-ls-pestana="#rrggbb">. El script de la pestaña lo manda en
  ls-title (y lo vigila con el mismo observador del tema); Rust solo acepta
  #rrggbb (color_de_pagina) y la barra pinta la pestaña de ese color, con letra
  clara u oscura según su luminancia. Activa se funde con la barra de arriba de
  la página; detrás conserva el color. Un color del menú encima va de franja.
  Una página con color propio no manda el tema: su fondo oscuro no es el de la
  app y no debe oscurecer la barra.
- Pestañas con nombre: window.open(url, "ls-…") o target="ls-…" abre la
  pestaña la primera vez; las siguientes activan ESA y le pasan la URL por
  window.__lsAbrir (el modo edición cambia de foto sin recargar); sin él,
  navega. Una descartada se revive ya en la URL nueva. Solo nombres ls-
  alfanuméricos (nombre_de_ventana); los demás abren pestaña como siempre.
- Color de página y nombre se guardan con la sesión (paginas, nombres en
  estado.json; un estado viejo carga igual).

Prueba nueva: la_pagina_solo_pide_colores_y_nombres_de_la_app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:33:20 -05:00
Munka
d3216c10ef Salud: el reinicio no se anota dos veces; en macOS la bandeja no se queda en «midiendo»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:33:16 -05:00
Munka
d7bf8c6740 La ventana se muestra aunque tao crea que ya está visible
Visto en el equipo de edición con la 1.19.0: la ventana estaba oculta en
Windows pero tao la tenía anotada como visible, así que show() no llamaba a
ShowWindow (apply_diff sin cambios) y ni la bandeja, ni el icono, ni la tecla
del MasterHUB la sacaban; set_focus sí corría y tomaba el foco sin verse.
show_main compara con el estado real (is_visible) y, si no cuadra, rehace
ocultar + mostrar y lo anota en salud.log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:28:30 -05:00
Munka
9c7bdf552c v1.20.0: salud de la app y ahorro de memoria
Salud (src-tauri/src/salud.rs):
- Mide cada minuto el árbol de procesos de la app (exe + WebView2): memoria
  privada, CPU y procesos. Se ve en la bandeja y en ⋮; va a recursos.csv.
- Diario de errores (salud.log): pánicos, procesos de WebView2 caídos o
  colgados, errores de JavaScript, hilo principal sin responder, fallos del
  actualizador y cierres de golpe (testigo en-marcha).
- Se repone sola: recarga la pestaña cuyo proceso muere o se cuelga (freno de
  3 en 10 min), reinicia la app si cae el proceso principal de WebView2 y
  registra el reinicio automático de Windows (RegisterApplicationRestart).
- ⋮ y bandeja: «Abrir diagnóstico» abre la carpeta de logs.

Memoria (src-tauri/src/memoria.rs), medido: 2,2 GB con 5 pestañas, 1,3 GB
en el proceso gráfico; reiniciarlo bajó la app a 1,1 GB sin perder nada.
- Recicla el proceso gráfico al pasar de 600 MB si nadie mira (ventana oculta
  o minimizada, o 10 min sin entrada y sin sonido), como mucho cada 30 min.
- Al abrir solo carga la pestaña activa; las demás se crean al activarlas.
- Descarta las pestañas que llevan 30 min dormidas (vuelven a su URL).
- Nivel de memoria bajo de WebView2 para lo que no se ve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:16:27 -05:00
Se han modificado 21 ficheros con 2148 adiciones y 62 borrados

Ver fichero

@@ -20,6 +20,27 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
- **Colores de pestaña** (v1.6.0): clic derecho sobre una pestaña ▸ **Color**, con
la misma paleta de nueve tonos que usa Chrome para sus grupos. Sirve para agrupar
de un vistazo (todo lo de un cliente en verde) y **se guarda con la sesión**.
- **Pestaña del color de su página** (v1.21.0): una página puede pedir el color de
su pestaña con `<html data-ls-pestana="#rrggbb">` (solo ese formato). El modo
edición de un set la pinta del gris del editor: activa se funde con su barra de
arriba y detrás se reconoce entre las demás. Esa página no cambia el tema de la
barra (su fondo oscuro no es el de la app).
- **La IA del equipo** (v1.22.0, Windows con tarjeta NVIDIA): la máscara de «Sujeto» y
«Fondo» del editor de revelado la busca la app, no el navegador — ONNX Runtime con
DirectML sobre la tarjeta y BiRefNet en media precisión (`src-tauri/src/ia/`): 0,3 s
por foto en vez de 2,5–3 s. La primera vez baja ≈700 MB a los datos de la app
(`%LOCALAPPDATA%\<identificador>\ia`): ONNX Runtime 1.24.4 DirectML y DirectML 1.15.4
de NuGet y el modelo del servidor de la app (`/api/modelos/sujeto-equipo`, con la
sesión de la pestaña), todo fijado por huella SHA-256. La página llama a
`ia_estado` / `ia_preparar` / `ia_sujeto`, permitidos solo para el origen de la app
(`build.rs` + `capabilities/default.json`). El modelo se suelta de la tarjeta a los
2 min sin uso. Prueba de punta a punta en la tarjeta del equipo:
`LS_IA_DIR=<carpeta> cargo test --lib de_punta_a_punta -- --ignored --nocapture`.
- **Pestañas con nombre** (v1.21.0): `window.open(url, "ls-…")` o un enlace con
`target="ls-…"` abre la pestaña la primera vez y las siguientes **vuelven a
ella** en vez de abrir otra. La página recibe la URL nueva por
`window.__lsAbrir(url)` (el modo edición cambia de foto sin recargar); si no lo
tiene, navega. Nombre y color se guardan con la sesión.
- **Zoom de interfaz**: ⌘/Ctrl `+` / `−` / `0` y Ctrl+rueda, con indicador en la
barra y **persistencia** entre sesiones (50%–250%).
- **Documentos dentro de la app**: los visores/editores del propio servidor
@@ -45,6 +66,41 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
donde estaba, al volver a ella. No se duerme la activa, ni una con una subida, una descarga o
audio en curso, ni un editor de documentos (`/docs/`).
- **Salud de la app** (v1.20.0, `src-tauri/src/salud.rs`): cuánto gasta de verdad, qué falla
y cómo se repone sola. Nada sale del equipo; todo queda en la carpeta de logs de la app
(⋮ ▸ *Abrir diagnóstico*, o desde la bandeja).
- **Recursos reales**: el exe es pequeño; el gasto está en la docena de procesos de WebView2
que cuelgan de él (uno por pestaña, GPU, utilidad), que el Administrador de tareas reparte.
Cada minuto se mide el árbol entero —memoria privada, CPU como la columna del Administrador
de tareas, número de procesos— y se ve en la bandeja y en ⋮ («Recursos: 2,1 GB · CPU 3 % ·
13 procesos»). Cada muestra va a `recursos.csv` (fecha, procesos, MB, CPU, pestañas,
dormidas): la curva de un día de trabajo real.
- **Diario de errores** (`salud.log`, rota a 2 MB): pánicos de Rust, procesos de WebView2
caídos o colgados, errores de JavaScript de las páginas (mensaje y archivo:línea, máx. 20
por carga y sin repetidos en un minuto), el hilo principal sin responder (ventana
congelada: cuándo y cuánto), fallos del actualizador y **cierres de golpe** (si al arrancar
sigue el testigo `en-marcha`, la sesión anterior no terminó bien).
- **Se repone sola**: pestaña cuyo proceso muere o se cuelga → se recarga (freno: 3 veces en
10 min; después avisa y la deja quieta). Si muere el proceso principal de WebView2 (caen
todas) → la app se reinicia sola. Windows la reabre si se cuelga o se cierra de golpe
(`RegisterApplicationRestart`). Con el árbol por encima de 3 GB se duermen **ya** las
pestañas ocultas (con las mismas reglas del reposo: nunca una con subida, descarga o audio).
- **Ahorro de memoria** (v1.20.0, `src-tauri/src/memoria.rs`). Medido en el equipo de edición:
2,2 GB con 5 pestañas tras un día abierta, **1,3 GB de ellos en el proceso gráfico** de WebView2,
que crece con las horas y no devuelve nada (reiniciarlo a mano bajó la app a 1,1 GB sin perder
nada). Sin quitar funciones:
- **Al abrir solo se carga la pestaña activa**; las demás esperan en la barra con su título y
se cargan al hacer clic (como Chrome/Edge). Antes arrancaban todas a la vez.
- **Pestañas descartadas**: una que lleva 30 min dormida se cierra de verdad y vuelve a su URL
al activarla (1-2 s de carga). Nunca un editor de documentos ni una con subida, descarga o audio.
- **Nivel de memoria bajo** para lo que no se ve (pestañas ocultas; todo con la ventana en la
bandeja o minimizada): WebView2 suelta cachés que puede regenerar.
- **El proceso gráfico se recicla** al pasar de 600 MB, solo si nadie mira (ventana oculta o
minimizada, o 10 min sin tocar el equipo y sin sonido saliendo), como mucho cada 30 min.
- Lo que ya se guardaba en disco sigue igual: la caché HTTP (los archivos de la app vienen con
`immutable`, 1 año), el service worker de la web y el cajón de miniaturas (`cache.rs`).
- **Rastreador de trabajo efectivo** (v1.10.0, `src-tauri/src/tracker.rs`): mide
cuánto tiempo se trabaja de verdad y en qué aplicación, y lo publica en el
panel **/rastreo** del servidor. Cada 5 s mira la ventana al frente y si

27
dist/index.html vendido
Ver fichero

@@ -7,7 +7,7 @@
<!--
Barra de pestañas del shell de escritorio (webview "chrome", local).
Habla con Rust por eventos (__TAURI__.event):
recibe ls-tabs {tabs:[{label,title,url,loading,active,color}], zoom, version,
recibe ls-tabs {tabs:[{label,title,url,loading,active,color,pagina}], zoom, version,
mac, win, maximized, dark}
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle,
ls-activate-n, ls-nav, ls-menu, ls-check-update, ls-tab-menu, ls-reorder
@@ -208,6 +208,17 @@
}
:root.oscuro .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
:root.oscuro .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
/* ── La pestaña del COLOR DE SU PÁGINA (1.21) ──
La página lo pide (`<html data-ls-pestana="#262626">`): el modo edición de un set pinta la
suya del gris del editor. Activa, es de ese color y se funde con la barra de arriba de la
página, como la de siempre se funde con el blanco de la app. Detrás, el mismo color: entre
las demás se reconoce de un vistazo. Con color del menú encima, ese va de franja arriba.
(#tabs delante: gana a las reglas de tema y de color del menú sin !important.) */
#tabs .tab.pagina { --muted: color-mix(in srgb, var(--tp-fg) 60%, transparent); background: var(--tp); color: var(--tp-fg); }
#tabs .tab.pagina:not(.active) { box-shadow: none; }
#tabs .tab.pagina:not(.active):hover { background: color-mix(in srgb, var(--tp) 86%, var(--tp-fg)); color: var(--tp-fg); }
#tabs .tab.pagina .cerrar:hover { background: color-mix(in srgb, var(--tp-fg) 18%, transparent); color: var(--tp-fg); }
#tabs .tab.pagina[data-color] { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
macOS los eventos no llegan), sino eventos de puntero. Se apaga el arrastre nativo de
WebKit para que no salga la imagen fantasma al mover una pestaña. */
@@ -500,6 +511,14 @@
$tabs.classList.toggle("compactas", ancho < 112);
}
// ¿Un #rrggbb claro? (luminancia relativa): decide si la letra encima va oscura o clara.
function esClaro(hex) {
const n = parseInt(hex.slice(1), 16);
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
return l > 0.35;
}
function render() {
$tabs.textContent = "";
for (const t of estado.tabs) {
@@ -508,6 +527,12 @@
el.className = "tab" + (t.active ? " active" : "");
el.dataset.label = t.label;
if (t.color) el.dataset.color = t.color; // teñido desde el menú de la pestaña
// El color que pide la página (ya validado #rrggbb en el shell), con letra que se lea encima.
if (t.pagina) {
el.classList.add("pagina");
el.style.setProperty("--tp", t.pagina);
el.style.setProperty("--tp-fg", esClaro(t.pagina) ? "#202124" : "#ececec");
}
el.title = texto;
const fav = document.createElement("span");

Ver fichero

@@ -1,6 +1,6 @@
{
"name": "labstream-desktop",
"version": "1.19.0",
"version": "1.22.0",
"private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": {

119
src-tauri/Cargo.lock generado
Ver fichero

@@ -1319,6 +1319,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -2366,15 +2367,17 @@ dependencies = [
[[package]]
name = "labstream-desktop"
version = "1.19.0"
version = "1.22.0"
dependencies = [
"active-win-pos-rs",
"device_query",
"gethostname 0.5.0",
"ort",
"percent-encoding",
"rfd",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
@@ -2386,6 +2389,7 @@ dependencies = [
"ureq",
"webview2-com",
"windows 0.61.3",
"zip",
]
[[package]]
@@ -2459,6 +2463,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]]
name = "libredox"
version = "0.1.17"
@@ -2545,6 +2559,16 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.2"
@@ -2630,6 +2654,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2693,12 +2732,30 @@ dependencies = [
"zbus",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-modular"
version = "0.6.5"
@@ -3003,6 +3060,25 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "ort"
version = "2.0.0-rc.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ca9287c05aace7e95250104a00d54b7da0fc9ac5f25efa424b80827d2e25eb"
dependencies = [
"libloading 0.9.0",
"ndarray",
"ort-sys",
"smallvec",
"tracing",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4af4763e553f916650b0bfe4b8d217d61c28607f6117e48b8ab5584d1138f22f"
[[package]]
name = "os_pipe"
version = "1.2.3"
@@ -3287,6 +3363,21 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3475,6 +3566,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "readkey"
version = "0.2.2"
@@ -6484,16 +6581,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.14.0",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.19.0"
version = "1.22.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"
@@ -49,6 +49,10 @@ percent-encoding = "2"
active-win-pos-rs = "0.11"
ureq = { version = "2", features = ["json"] }
gethostname = "0.5"
# ── La IA del equipo (src/ia, 1.22) ──
# sha2: la huella de lo que se baja (modelo y motor); zip: sacar las DLL del paquete del motor.
sha2 = "0.10"
zip = { version = "4", default-features = false, features = ["deflate"] }
# device_query solo en Windows/Linux: la huella de entrada (posición del mouse + cuántas teclas
# hay presionadas). En macOS NO se usa —leer el teclado global dispara el permiso de
@@ -64,6 +68,10 @@ device_query = "4"
# Solo Windows: en macOS no hay API pública sencilla de nivel de audio por app (el equipo de
# edición es Windows). El `windows` crate ya está en la caché por Tauri.
[target.'cfg(windows)'.dependencies]
# IA del equipo (1.22, src/ia/motor.rs): ONNX Runtime con DirectML sobre la tarjeta. `load-dynamic`:
# nada se enlaza al compilar; la DLL (la de DirectML 1.24, de NuGet) se baja la primera vez y se
# carga desde la carpeta de la IA. `api-24`: la versión de la API que trae esa DLL.
ort = { version = "=2.0.0-rc.14", default-features = false, features = ["std", "load-dynamic", "directml", "api-24"] }
# Pestañas en reposo (1.19): ICoreWebView2_3::TrySuspend. Es la misma versión que ya trae Tauri.
webview2-com = "0.38"
windows = { version = "0.61", features = [
@@ -75,4 +83,16 @@ windows = { version = "0.61", features = [
"Win32_System_Variant",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
# Salud (1.20): medir el árbol de procesos (exe + WebView2) y el reinicio automático de Windows.
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_ProcessStatus",
"Win32_System_Threading",
"Win32_System_Recovery",
# Memoria (1.20): ¿cuánto lleva el equipo sin tocar? (para reciclar el proceso gráfico sin que se note)
"Win32_UI_Input_KeyboardAndMouse",
"Win32_System_SystemInformation",
# IA del equipo (1.22): ¿hay tarjeta NVIDIA? (los adaptadores de DXGI)
"Win32_Graphics_Dxgi",
# y cargar DirectML.dll por su ruta antes que ONNX Runtime (src/ia/motor.rs)
"Win32_System_LibraryLoader",
] }

Ver fichero

@@ -1,3 +1,9 @@
fn main() {
tauri_build::build()
// Los comandos de la app (la IA del equipo, src/ia): con su manifiesto, cada uno necesita su
// permiso en capabilities/default.json, y es lo que deja que la página remota los llame.
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(&["ia_estado", "ia_preparar", "ia_sujeto"])),
)
.expect("tauri-build");
}

Ver fichero

@@ -18,6 +18,9 @@
"core:default",
"opener:default",
"notification:default",
"core:window:allow-start-dragging"
"core:window:allow-start-dragging",
"allow-ia-estado",
"allow-ia-preparar",
"allow-ia-sujeto"
]
}

Ver fichero

@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ia-estado"
description = "Enables the ia_estado command without any pre-configured scope."
commands.allow = ["ia_estado"]
[[permission]]
identifier = "deny-ia-estado"
description = "Denies the ia_estado command without any pre-configured scope."
commands.deny = ["ia_estado"]

Ver fichero

@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ia-preparar"
description = "Enables the ia_preparar command without any pre-configured scope."
commands.allow = ["ia_preparar"]
[[permission]]
identifier = "deny-ia-preparar"
description = "Denies the ia_preparar command without any pre-configured scope."
commands.deny = ["ia_preparar"]

Ver fichero

@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-ia-sujeto"
description = "Enables the ia_sujeto command without any pre-configured scope."
commands.allow = ["ia_sujeto"]
[[permission]]
identifier = "deny-ia-sujeto"
description = "Denies the ia_sujeto command without any pre-configured scope."
commands.deny = ["ia_sujeto"]

170
src-tauri/src/ia/bajar.rs Archivo normal
Ver fichero

@@ -0,0 +1,170 @@
// ── Bajar las piezas de la IA: comprobadas con su huella y, si vienen en un zip, sacadas de él ──
//
// Todo va a la carpeta de la IA en los datos de la app del equipo (`ia::carpeta`). Cada pieza se
// baja a un `.parte`, se le saca la huella SHA-256 MIENTRAS llega y solo si cuadra con la anotada
// en el código pasa a su sitio; al acabar se deja una marca `<nombre>.ok` con la huella, que es lo
// que se mira después para saber si está (un archivo a medias nunca tiene marca).
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::io::{self, BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Una pieza que se baja: un archivo tal cual o un zip del que se sacan unos archivos.
pub struct Pieza {
/// Nombre corto (la marca `.ok` y los avisos).
pub nombre: &'static str,
pub url: &'static str,
/// Huella SHA-256 del archivo bajado, en hexadecimal.
pub sha256: &'static str,
pub bytes: u64,
pub forma: Forma,
/// Se pide con la sesión de la pestaña (lo sirve el servidor de la app solo al equipo).
pub con_sesion: bool,
}
pub enum Forma {
/// El archivo, con este nombre, en la carpeta de la IA.
Archivo(&'static str),
/// Un zip (un .nupkg lo es): de él, estos archivos —por su ruta EXACTA dentro: un paquete trae
/// varias DLL con el mismo nombre (x86, arm64, Xbox…)— a la carpeta de la IA, con su nombre.
Zip(&'static [&'static str]),
}
fn marca(dir: &Path, p: &Pieza) -> PathBuf {
dir.join(format!("{}.ok", p.nombre))
}
/// ¿Está ya en el equipo (con su marca y la huella de ahora)?
pub fn esta(dir: &Path, p: &Pieza) -> bool {
fs::read_to_string(marca(dir, p)).map(|s| s.trim() == p.sha256).unwrap_or(false)
}
/// Baja la pieza (si no está) avisando de lo que lleva con `avance(bytes_nuevos)`. `sesion`: las
/// cookies de la pestaña para el servidor de la app (solo viajan a él: `con_sesion` y su origen).
pub fn bajar(dir: &Path, p: &Pieza, sesion: Option<&str>, avance: &mut dyn FnMut(u64)) -> Result<(), String> {
if esta(dir, p) {
return Ok(());
}
fs::create_dir_all(dir).map_err(|e| format!("No se pudo crear la carpeta de la IA: {e}"))?;
let parte = dir.join(format!("{}.parte", p.nombre));
let mut pedido = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(20))
.timeout_read(Duration::from_secs(60))
.build()
.get(p.url);
if p.con_sesion && p.url.starts_with(&format!("{}/", crate::SERVER_URL)) {
if let Some(c) = sesion.filter(|c| !c.is_empty()) {
pedido = pedido.set("Cookie", c);
}
}
let resp = pedido.call().map_err(|e| match e {
ureq::Error::Status(401, _) => format!("No se pudo bajar {}: la sesión de la app caducó (vuelve a entrar).", p.nombre),
e => format!("No se pudo bajar {}: {e}", p.nombre),
})?;
let mut leer = resp.into_reader();
let mut escribir = BufWriter::new(File::create(&parte).map_err(|e| format!("No se pudo escribir {}: {e}", p.nombre))?);
let mut huella = Sha256::new();
let mut buf = vec![0u8; 1 << 20];
let mut total: u64 = 0;
loop {
let n = leer.read(&mut buf).map_err(|e| format!("La descarga de {} se cortó: {e}", p.nombre))?;
if n == 0 {
break;
}
huella.update(&buf[..n]);
escribir.write_all(&buf[..n]).map_err(|e| format!("No se pudo escribir {}: {e}", p.nombre))?;
total += n as u64;
avance(n as u64);
}
escribir.flush().map_err(|e| format!("No se pudo escribir {}: {e}", p.nombre))?;
drop(escribir);
let hex = huella.finalize().iter().map(|b| format!("{b:02x}")).collect::<String>();
if hex != p.sha256 {
let _ = fs::remove_file(&parte);
return Err(format!("{} llegó distinto de lo esperado ({total} bytes). Vuelve a intentarlo.", p.nombre));
}
match p.forma {
Forma::Archivo(destino) => {
let d = dir.join(destino);
let _ = fs::remove_file(&d);
fs::rename(&parte, &d).map_err(|e| format!("No se pudo guardar {}: {e}", p.nombre))?;
}
Forma::Zip(quiero) => {
let r = extraer(&parte, dir, quiero);
let _ = fs::remove_file(&parte);
r?;
}
}
fs::write(marca(dir, p), p.sha256).map_err(|e| format!("No se pudo guardar {}: {e}", p.nombre))?;
Ok(())
}
/// Saca del zip los archivos de `quiero` (rutas exactas dentro del zip) a `dir`, con su nombre.
fn extraer(zip: &Path, dir: &Path, quiero: &[&str]) -> Result<(), String> {
let f = File::open(zip).map_err(|e| format!("No se pudo abrir lo bajado: {e}"))?;
let mut z = zip::ZipArchive::new(BufReader::new(f)).map_err(|e| format!("Lo bajado no es un zip válido: {e}"))?;
let mut faltan: Vec<&str> = quiero.to_vec();
for i in 0..z.len() {
let mut e = z.by_index(i).map_err(|e| format!("El zip está dañado: {e}"))?;
if e.is_dir() {
continue;
}
let ruta = e.name().replace('\\', "/");
let nombre = ruta.rsplit('/').next().unwrap_or("").to_string();
if nombre.is_empty() || nombre.contains("..") {
continue;
}
if let Some(k) = faltan.iter().position(|q| q.eq_ignore_ascii_case(&ruta)) {
faltan.remove(k);
let destino = dir.join(&nombre);
let tmp = dir.join(format!("{nombre}.parte"));
let mut out = BufWriter::new(File::create(&tmp).map_err(|e| format!("No se pudo escribir {nombre}: {e}"))?);
io::copy(&mut e, &mut out).map_err(|e| format!("No se pudo sacar {nombre}: {e}"))?;
out.flush().map_err(|e| format!("No se pudo escribir {nombre}: {e}"))?;
drop(out);
let _ = fs::remove_file(&destino);
fs::rename(&tmp, &destino).map_err(|e| format!("No se pudo guardar {nombre}: {e}"))?;
}
}
if faltan.is_empty() {
Ok(())
} else {
Err(format!("En lo bajado no vinieron: {}", faltan.join(", ")))
}
}
#[cfg(test)]
mod pruebas {
use super::*;
#[test]
fn saca_del_zip_solo_lo_pedido_y_dice_lo_que_falta() {
let dir = std::env::temp_dir().join(format!("ls-ia-prueba-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let zp = dir.join("x.zip");
{
let mut w = zip::ZipWriter::new(File::create(&zp).unwrap());
let o: zip::write::SimpleFileOptions = Default::default();
w.start_file("bin/x64-xbox/a.dll", o).unwrap();
w.write_all(b"XBOX").unwrap();
w.start_file("bin/x64-win/a.dll", o).unwrap();
w.write_all(b"AAA").unwrap();
w.start_file("bin/x64-win/b.dll", o).unwrap();
w.write_all(b"BBB").unwrap();
w.finish().unwrap();
}
// la de la ruta pedida, no la primera con ese nombre
extraer(&zp, &dir, &["bin/x64-win/a.dll"]).unwrap();
assert_eq!(fs::read(dir.join("a.dll")).unwrap(), b"AAA");
assert!(!dir.join("b.dll").exists());
assert!(extraer(&zp, &dir, &["a.dll"]).unwrap_err().contains("a.dll"));
let p = Pieza { nombre: "x", url: "", sha256: "abc", bytes: 1, forma: Forma::Archivo("x"), con_sesion: false };
assert!(!esta(&dir, &p));
fs::write(marca(&dir, &p), "abc").unwrap();
assert!(esta(&dir, &p));
let _ = fs::remove_dir_all(&dir);
}
}

88
src-tauri/src/ia/imagen.rs Archivo normal
Ver fichero

@@ -0,0 +1,88 @@
// ── La foto como la quiere el modelo, y su respuesta como la quiere la página ──
// Lo mismo que hace el navegador (lib/revelado/modelo-sujeto: entradaDelModelo), aquí en Rust:
// la foto RGBA (como se ve en el editor) a `lado` × `lado` entre sus cuatro píxeles más cercanos,
// en tres planos RGB normalizados como ImageNet. Y la salida, a un byte por punto.
const MEDIA: [f32; 3] = [0.485, 0.456, 0.406];
const DESVIACION: [f32; 3] = [0.229, 0.224, 0.225];
/// RGBA (`ancho` × `alto`) → [1, 3, lado, lado] en un vector plano.
pub fn entrada(rgba: &[u8], ancho: usize, alto: usize, lado: usize) -> Vec<f32> {
let plano = lado * lado;
let mut out = vec![0f32; 3 * plano];
for y in 0..lado {
let fy = (((y as f32 + 0.5) / lado as f32) * alto as f32 - 0.5).clamp(0.0, (alto - 1) as f32);
let y0 = fy.floor() as usize;
let y1 = (y0 + 1).min(alto - 1);
let wy = fy - y0 as f32;
for x in 0..lado {
let fx = (((x as f32 + 0.5) / lado as f32) * ancho as f32 - 0.5).clamp(0.0, (ancho - 1) as f32);
let x0 = fx.floor() as usize;
let x1 = (x0 + 1).min(ancho - 1);
let wx = fx - x0 as f32;
for c in 0..3 {
let p = |xx: usize, yy: usize| rgba[(yy * ancho + xx) * 4 + c] as f32;
let arriba = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * wx;
let abajo = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * wx;
let v = arriba + (abajo - arriba) * wy;
out[c * plano + y * lado + x] = (v / 255.0 - MEDIA[c]) / DESVIACION[c];
}
}
}
out
}
/// ¿La salida viene sin sigmoide? (BiRefNet la aplica fuera de la red; si algo cae fuera de 0..1,
/// son logits y hay que aplicarla aquí.)
pub fn son_logits(salida: &[f32]) -> bool {
salida.iter().any(|&v| !(-0.001..=1.001).contains(&v))
}
/// La salida del modelo a un byte por punto. `logits`: la salida va sin sigmoide (BiRefNet la
/// aplica fuera de la red).
pub fn a_bytes(salida: &[f32], logits: bool) -> Vec<u8> {
salida
.iter()
.map(|&v| {
let p = if logits { 1.0 / (1.0 + (-v).exp()) } else { v };
(p.clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect()
}
/// La respuesta de `ia_sujeto` para la página: [ancho u32][alto u32][bytes] (little endian).
pub fn respuesta(ancho: u32, alto: u32, bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + bytes.len());
out.extend_from_slice(&ancho.to_le_bytes());
out.extend_from_slice(&alto.to_le_bytes());
out.extend_from_slice(bytes);
out
}
#[cfg(test)]
mod pruebas {
use super::*;
#[test]
fn la_entrada_es_la_del_navegador() {
// 2 × 1: rojo puro y blanco; a 2 × 2 cada columna toma su píxel
let rgba = [255, 0, 0, 255, 255, 255, 255, 255];
let e = entrada(&rgba, 2, 1, 2);
let norm = |v: f32, c: usize| (v / 255.0 - MEDIA[c]) / DESVIACION[c];
assert!((e[0] - norm(255.0, 0)).abs() < 1e-5); // R de (0,0)
assert!((e[4] - norm(0.0, 1)).abs() < 1e-5); // G de (0,0)
assert!((e[5] - norm(255.0, 1)).abs() < 1e-5); // G de (1,0)
assert!((e[8 + 3] - norm(255.0, 2)).abs() < 1e-5); // B de (1,1)
assert_eq!(e.len(), 12);
}
#[test]
fn la_salida_a_bytes_y_la_respuesta() {
assert_eq!(a_bytes(&[-20.0, 0.0, 20.0], true), vec![0, 128, 255]);
assert_eq!(a_bytes(&[0.0, 0.5, 1.2], false), vec![0, 128, 255]);
assert!(son_logits(&[0.2, -3.0]) && !son_logits(&[0.0, 0.5, 1.0]));
let r = respuesta(3, 2, &[1, 2, 3, 4, 5, 6]);
assert_eq!(&r[..8], &[3, 0, 0, 0, 2, 0, 0, 0]);
assert_eq!(r.len(), 14);
}
}

262
src-tauri/src/ia/mod.rs Archivo normal
Ver fichero

@@ -0,0 +1,262 @@
// ── La IA EN EL EQUIPO: la máscara de sujeto de Labstream OS con la tarjeta NVIDIA (1.22) ──────
//
// Munka: «los pcs que usan esta función tienen Nvidia de 16 GB… el modelo se ejecuta en local y se
// conecta y sube el resultado a la app». En el editor de revelado (modo edición de un set), «Sujeto»
// y «Fondo» no los busca el navegador sino esta app: ONNX Runtime con DirectML sobre la tarjeta
// NVIDIA y BiRefNet en media precisión (piezas.rs): 0,3 s por foto en vez de 2,5–3 s, y sin depender
// del WebGPU de la pestaña.
//
// La página (lib/revelado/ia-local en labstream-os) llama a tres comandos, permitidos SOLO para el
// origen de la app (capabilities/default.json + el manifiesto de build.rs):
// ia_estado → ¿se puede aquí?, ¿está ya bajado?, cuánto falta, qué tarjeta
// ia_preparar → baja lo que falte (eventos «ia-progreso» { fase, parte } a esa pestaña) y abre
// el modelo en la tarjeta
// ia_sujeto ← RGBA en el cuerpo (binario) + x-ancho / x-alto
// → [ancho u32][alto u32][un byte por punto] (lib/revelado/ia-local: leerRespuesta)
// El resultado vuelve a la página, que lo pone en la máscara y lo guarda con el revelado de la foto.
//
// Nada se baja ni se carga al arrancar la app: solo cuando alguien pide la máscara. Lo bajado vive en
// los datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). El modelo abierto
// ocupa la tarjeta (~7 GB mientras busca: DirectML guarda lo que pidió): se suelta a los SOLTAR_TRAS
// sin usarlo, que los editores trabajan con Resolve abierto.
mod bajar;
mod imagen;
mod motor;
mod piezas;
mod tarjeta;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, EventTarget, Manager, Runtime, Webview};
/// Sin usarlo este rato, el modelo se suelta de la tarjeta (la siguiente foto tarda ~2 s más).
const SOLTAR_TRAS: Duration = Duration::from_secs(2 * 60);
struct Abierto {
motor: motor::Motor,
usado: Instant,
}
static MODELO: Mutex<Option<Abierto>> = Mutex::new(None);
/// Una preparación a la vez (dos pestañas que piden a la vez no bajan dos veces).
static PREPARANDO: Mutex<()> = Mutex::new(());
static VIGILANTE: std::sync::Once = std::sync::Once::new();
fn carpeta<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
app.path().app_local_data_dir().map(|d| d.join("ia")).map_err(|e| format!("No hay carpeta de datos de la app: {e}"))
}
fn faltan(dir: &std::path::Path) -> u64 {
piezas::todas().iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum()
}
#[derive(serde::Serialize)]
pub struct Estado {
disponible: bool,
motivo: Option<String>,
listo: bool,
faltan: u64,
modelo: &'static str,
tarjeta: Option<String>,
}
#[tauri::command]
pub fn ia_estado<R: Runtime>(app: AppHandle<R>) -> Estado {
let t = tarjeta::nvidia();
let motivo = if !cfg!(all(windows, target_arch = "x86_64")) {
Some("La IA del equipo es para Windows con tarjeta NVIDIA.".to_string())
} else if t.is_none() {
Some("Este equipo no tiene tarjeta NVIDIA.".to_string())
} else {
None
};
let dir = carpeta(&app).ok();
let falta = dir.as_deref().map(faltan).unwrap_or(u64::MAX);
Estado {
disponible: motivo.is_none() && dir.is_some(),
motivo,
listo: falta == 0,
faltan: if falta == u64::MAX { 0 } else { falta },
modelo: piezas::NOMBRE_MODELO,
tarjeta: t.map(|t| t.nombre),
}
}
fn avisar<R: Runtime>(app: &AppHandle<R>, label: &str, fase: &str, parte: f64) {
let _ = app.emit_to(EventTarget::webview(label), "ia-progreso", serde_json::json!({ "fase": fase, "parte": parte }));
}
/// Baja lo que falte y deja el modelo abierto (en un hilo aparte: puede tardar minutos).
#[tauri::command]
pub async fn ia_preparar<R: Runtime>(app: AppHandle<R>, webview: Webview<R>) -> Result<(), String> {
let label = webview.label().to_string();
// La sesión de la pestaña, para bajar el modelo del servidor de la app (solo viaja a él). Aquí y
// no en el hilo de abajo: leer cookies de WebView2 desde un comando síncrono se queda colgado.
let sesion = tauri::Url::parse(crate::SERVER_URL)
.ok()
.and_then(|u| webview.cookies_for_url(u).ok())
.map(|cs| cs.iter().map(|c| format!("{}={}", c.name(), c.value())).collect::<Vec<_>>().join("; "));
tauri::async_runtime::spawn_blocking(move || preparar(&app, &label, sesion.as_deref()))
.await
.map_err(|e| format!("La IA no pudo prepararse: {e}"))?
}
fn preparar<R: Runtime>(app: &AppHandle<R>, label: &str, sesion: Option<&str>) -> Result<(), String> {
if tarjeta::nvidia().is_none() {
return Err("Este equipo no tiene tarjeta NVIDIA.".into());
}
let _uno = PREPARANDO.lock().unwrap_or_else(|e| e.into_inner());
let dir = carpeta(app)?;
let total = faltan(&dir);
if total > 0 {
let hecho = AtomicU64::new(0);
let mut ultimo = 0u64;
avisar(app, label, "bajando", 0.0);
for p in piezas::todas() {
bajar::bajar(&dir, p, sesion, &mut |n| {
let h = hecho.fetch_add(n, Ordering::Relaxed) + n;
// un aviso cada ~1 %
if h - ultimo > total / 100 {
ultimo = h;
avisar(app, label, "bajando", (h as f64 / total as f64).min(1.0));
}
})
.map_err(|e| {
crate::salud::anotar("ERROR", "ia", &e);
e
})?;
}
crate::salud::anotar("INFO", "ia", &format!("IA bajada al equipo ({} MB)", total / 1_000_000));
}
let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner());
if m.is_none() {
avisar(app, label, "preparando", 0.0);
let t0 = Instant::now();
let motor = motor::Motor::abrir(&dir).map_err(|e| {
crate::salud::anotar("ERROR", "ia", &format!("no se pudo abrir el modelo: {e}"));
e
})?;
crate::salud::anotar("INFO", "ia", &format!("modelo abierto en {:.1} s ({})", t0.elapsed().as_secs_f32(), motor.donde()));
*m = Some(Abierto { motor, usado: Instant::now() });
vigilar();
}
Ok(())
}
/// Suelta el modelo de la tarjeta cuando lleva SOLTAR_TRAS sin usarse.
fn vigilar() {
VIGILANTE.call_once(|| {
std::thread::spawn(|| loop {
std::thread::sleep(Duration::from_secs(15));
let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner());
if m.as_ref().map(|a| a.usado.elapsed() >= SOLTAR_TRAS).unwrap_or(false) {
*m = None;
crate::salud::anotar("INFO", "ia", "modelo soltado de la tarjeta (sin uso)");
}
});
});
}
/// Busca el sujeto en la foto que manda la página (RGBA en el cuerpo, medidas en las cabeceras).
#[tauri::command]
pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result<tauri::ipc::Response, String> {
let medida = |k: &str| request.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
let (ancho, alto) = (medida("x-ancho"), medida("x-alto"));
let tauri::ipc::InvokeBody::Raw(rgba) = request.body() else {
return Err("La foto no llegó (se esperaban sus píxeles).".into());
};
if ancho < 2 || alto < 2 || ancho > 16_384 || alto > 16_384 || rgba.len() != ancho * alto * 4 {
return Err("La foto llegó con unas medidas que no cuadran.".into());
}
let rgba = rgba.clone();
tauri::async_runtime::spawn_blocking(move || buscar(&rgba, ancho, alto))
.await
.map_err(|e| format!("La IA falló: {e}"))?
.map(tauri::ipc::Response::new)
}
fn buscar(rgba: &[u8], ancho: usize, alto: usize) -> Result<Vec<u8>, String> {
let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner());
let Some(a) = m.as_mut() else {
return Err("La IA no está preparada.".into());
};
let lado = piezas::LADO;
let t0 = Instant::now();
let salida = a.motor.correr(imagen::entrada(rgba, ancho, alto, lado), lado);
a.usado = Instant::now();
match salida {
Ok(s) => {
crate::salud::anotar("INFO", "ia", &format!("sujeto en {:.2} s", t0.elapsed().as_secs_f32()));
Ok(imagen::respuesta(lado as u32, lado as u32, &imagen::a_bytes(&s, imagen::son_logits(&s))))
}
Err(e) => {
// una tarjeta que se perdió: se suelta para abrirla de nuevo la próxima vez
*m = None;
crate::salud::anotar("ERROR", "ia", &format!("no se pudo buscar el sujeto: {e}"));
Err(e)
}
}
}
#[cfg(all(test, windows))]
mod pruebas {
use super::*;
/// De punta a punta en la tarjeta de ESTE equipo (no corre en CI: `cargo test -- --ignored`).
/// LS_IA_DIR: carpeta de la IA (se baja ahí lo que falte). LS_IA_ENTRADA: opcional, un .f32 con
/// la entrada [1,3,lado,lado] ya hecha (para comparar con la CPU); la salida va a LS_IA_SALIDA.
/// LS_IA_MODELO / LS_IA_LADO: probar otro modelo de la carpeta; LS_IA_ESPERA: segundos con el
/// modelo abierto entre vueltas (para mirar la memoria de la tarjeta).
#[test]
#[ignore]
fn de_punta_a_punta_en_la_tarjeta() {
let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR"));
for p in piezas::todas() {
let t = Instant::now();
bajar::bajar(&dir, p, None, &mut |_| {}).unwrap();
eprintln!("{}: listo en {:.1} s", p.nombre, t.elapsed().as_secs_f32());
}
let t = Instant::now();
let mut m = match std::env::var("LS_IA_MODELO") {
Ok(f) => motor::Motor::abrir_archivo(&dir, &f).unwrap(),
Err(_) => motor::Motor::abrir(&dir).unwrap(),
};
eprintln!("modelo abierto en {:.1} s", t.elapsed().as_secs_f32());
let lado = std::env::var("LS_IA_LADO").ok().and_then(|v| v.parse().ok()).unwrap_or(piezas::LADO);
let entrada = match std::env::var("LS_IA_ENTRADA") {
Ok(f) => std::fs::read(f).unwrap().chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(),
Err(_) => {
let (w, h) = (1600usize, 1067usize);
let mut rgba = vec![255u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let i = (y * w + x) * 4;
let d = ((x as f32 - 800.0).powi(2) + (y as f32 - 560.0).powi(2)).sqrt() / 300.0;
let g = (90.0 + 60.0 * y as f32 / h as f32) as u8;
let c = if d < 1.0 { [220, 120, 60] } else { [g, g, g + 10] };
rgba[i..i + 3].copy_from_slice(&c);
}
}
imagen::entrada(&rgba, w, h, lado)
}
};
for vuelta in 0..2 {
let t = Instant::now();
let s = m.correr(entrada.clone(), lado).unwrap();
let (min, max) = s.iter().fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v)));
eprintln!("vuelta {vuelta}: {:.2} s, salida {min:.2}..{max:.2}, logits {}", t.elapsed().as_secs_f32(), imagen::son_logits(&s));
if let Ok(seg) = std::env::var("LS_IA_ESPERA") {
eprintln!("esperando {seg} s con el modelo abierto");
std::thread::sleep(Duration::from_secs(seg.parse().unwrap_or(0)));
}
if vuelta == 1 {
if let Ok(f) = std::env::var("LS_IA_SALIDA") {
std::fs::write(f, s.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>()).unwrap();
}
}
}
}
}

130
src-tauri/src/ia/motor.rs Archivo normal
Ver fichero

@@ -0,0 +1,130 @@
// ── El motor: ONNX Runtime con DirectML sobre la tarjeta (Windows) ──
//
// DirectML y no CUDA: corre en cualquier tarjeta con DirectX 12 —las NVIDIA del equipo incluidas—
// sin pedir versión de controlador, y lo que hay que bajar son ~200 MB de Microsoft (NuGet) en vez
// de ~1,3 GB de bibliotecas de NVIDIA. La exportación oficial de BiRefNet lleva la convolución
// deformable deshecha en GatherND (lo que DirectML sabe hacer; DeformConv no).
//
// Las DLL no van en el instalador: las baja `ia_preparar` (piezas.rs) a la carpeta de la IA y aquí
// se cargan desde ahí. DirectML.dll se carga ANTES por su ruta: onnxruntime.dll la pide por nombre
// y, ya cargada, Windows le da esa y no la vieja de System32. ONNX Runtime se inicia UNA vez por
// proceso (`ENTORNO`); la sesión (el modelo en la tarjeta) se abre y se suelta según se use.
#[cfg(windows)]
mod imp {
use super::super::piezas;
use ort::{
ep,
environment::Environment,
session::{builder::GraphOptimizationLevel, Session},
value::Tensor,
};
use std::path::Path;
use std::sync::OnceLock;
static ENTORNO: OnceLock<Result<Environment, String>> = OnceLock::new();
fn entorno(dir: &Path) -> Result<&'static Environment, String> {
ENTORNO
.get_or_init(|| {
cargar_por_ruta(&dir.join(piezas::DLL_DIRECTML))?;
let env = ort::init_from(dir.join(piezas::DLL_ORT))
.map_err(|e| format!("No se pudo cargar ONNX Runtime: {e}"))?
.with_name("labstream-ia")
.build()
.map_err(|e| format!("No se pudo iniciar ONNX Runtime: {e}"))?;
Ok(env)
})
.as_ref()
.map_err(|e| e.clone())
}
/// Carga una DLL por su ruta completa y la deja cargada para siempre.
fn cargar_por_ruta(ruta: &Path) -> Result<(), String> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::System::LibraryLoader::LoadLibraryW;
let w: Vec<u16> = ruta.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
unsafe { LoadLibraryW(PCWSTR(w.as_ptr())) }.map(|_| ()).map_err(|e| format!("No se pudo cargar {}: {e}", ruta.display()))
}
pub struct Motor {
sesion: Session,
entrada: String,
}
impl Motor {
pub fn abrir(dir: &Path) -> Result<Motor, String> {
Self::abrir_archivo(dir, piezas::ARCHIVO_MODELO)
}
pub fn abrir_archivo(dir: &Path, archivo: &str) -> Result<Motor, String> {
let env = entorno(dir)?;
let e = |e: ort::Error<ort::session::builder::SessionBuilder>| format!("No se pudo abrir el modelo: {e}");
let sesion = Session::builder(env)
.map_err(|e| format!("No se pudo abrir el modelo: {e}"))?
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(e)?
// lo que pide DirectML: sin patrones de memoria y en serie
.with_memory_pattern(false)
.map_err(e)?
.with_parallel_execution(false)
.map_err(e)?
// El grafo FUNDIDO de DirectML pide la memoria de toda la red de una vez: con el modelo
// HR no cabía ni en 16 GB (E_OUTOFMEMORY en DmlFusedNode, RTX 5080, 14-oct-2026) y en
// media precisión iba 6 veces más lento. Nodo a nodo, cada tensor se suelta cuando ya
// no hace falta (el general: 0,3 s).
.with_config_entry("ep.dml.disable_graph_fusion", "1")
.map_err(e)?
.with_execution_providers([ep::DirectML::default()
.with_device_filter(ep::directml::DeviceFilter::Gpu)
.with_performance_preference(ep::directml::PerformancePreference::HighPerformance)
.build()
// sin la tarjeta NO se sigue en la CPU: serían 30 s por foto
.error_on_failure()])
.map_err(e)?
.commit_from_file(dir.join(archivo))
.map_err(|e| format!("No se pudo abrir el modelo: {e}"))?;
let entrada = sesion.inputs().first().map(|i| i.name().to_string()).ok_or("El modelo no tiene entrada.")?;
Ok(Motor { sesion, entrada })
}
pub fn donde(&self) -> &'static str {
"DirectML"
}
/// La entrada [1, 3, lado, lado] → la salida [1, 1, lado, lado] (la última, si hay varias).
pub fn correr(&mut self, entrada: Vec<f32>, lado: usize) -> Result<Vec<f32>, String> {
let t = Tensor::from_array(([1usize, 3, lado, lado], entrada)).map_err(|e| format!("La foto no entró al modelo: {e}"))?;
let nombre = self.entrada.clone();
let salidas = self.sesion.run(ort::inputs![nombre => t]).map_err(|e| format!("La tarjeta no pudo buscar el sujeto: {e}"))?;
let ultima = salidas.len().checked_sub(1).ok_or("El modelo no contestó.")?;
let (forma, datos) = salidas[ultima].try_extract_tensor::<f32>().map_err(|e| format!("El modelo contestó algo raro: {e}"))?;
if datos.len() != lado * lado {
return Err(format!("El modelo contestó {forma:?} y se esperaba {lado} × {lado}."));
}
Ok(datos.to_vec())
}
}
}
#[cfg(not(windows))]
mod imp {
use std::path::Path;
pub struct Motor;
impl Motor {
pub fn abrir(_dir: &Path) -> Result<Motor, String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into())
}
pub fn donde(&self) -> &'static str {
"-"
}
pub fn correr(&mut self, _entrada: Vec<f32>, _lado: usize) -> Result<Vec<f32>, String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into())
}
}
}
pub use imp::Motor;

57
src-tauri/src/ia/piezas.rs Archivo normal
Ver fichero

@@ -0,0 +1,57 @@
// ── Lo que la IA baja la primera vez (≈700 MB), de dónde, y con qué huella ──
//
// Fijado por versión y huella SHA-256 (comprobadas al bajarlo el 14-oct-2026): si algún día cambia
// lo que hay en esa dirección, no se usa.
//
// - El modelo: BiRefNet general (MIT, ZhengPeng7; el mismo del navegador) en media precisión, del
// servidor de la app (api/modelos/sujeto-equipo, con la sesión de la pestaña). Sale de la
// exportación ONNX oficial del autor (release v1 de su GitHub, float32) pasada a float16 con la
// entrada y la salida en float32 (scripts/modelo-sujeto-equipo.py de labstream-os). Entrada
// `input_image` [1, 3, 1024, 1024], salida `output_image` [1, 1, 1024, 1024] (logits). En una RTX
// 5080: 0,3 s por foto y el mismo borde que el original en la CPU.
// El HR (2048 px, matting) se probó y NO va: su convolución deformable, deshecha en GatherND en la
// exportación oficial, ocupa 14–15 GB con DirectML y tarda de 6 a 45 s (14-oct-2026).
// - ONNX Runtime 1.24.4 con DirectML (NuGet de Microsoft): onnxruntime.dll para x64.
// - DirectML 1.15.4 (NuGet de Microsoft): DirectML.dll para Windows x64 (el paquete trae también
// las de Xbox y las de depurar: se saca por su ruta exacta).
use super::bajar::{Forma, Pieza};
pub const NOMBRE_MODELO: &str = "BiRefNet";
/// El lado de la entrada y de la salida del modelo.
pub const LADO: usize = 1024;
pub const ARCHIVO_MODELO: &str = "birefnet-general-epoch_244-fp16.onnx";
pub const DLL_ORT: &str = "onnxruntime.dll";
pub const DLL_DIRECTML: &str = "DirectML.dll";
static PIEZAS: [Pieza; 3] = [
Pieza {
nombre: "onnxruntime-directml-1.24.4",
url: "https://api.nuget.org/v3-flatcontainer/microsoft.ml.onnxruntime.directml/1.24.4/microsoft.ml.onnxruntime.directml.1.24.4.nupkg",
sha256: "57e9f11b73437bef7a309496135d4c1f96b1a8e9ddba60013fa27bfc1d788681",
bytes: 12_458_649,
forma: Forma::Zip(&["runtimes/win-x64/native/onnxruntime.dll", "runtimes/win-x64/native/onnxruntime_providers_shared.dll"]),
con_sesion: false,
},
Pieza {
nombre: "directml-1.15.4",
url: "https://api.nuget.org/v3-flatcontainer/microsoft.ai.directml/1.15.4/microsoft.ai.directml.1.15.4.nupkg",
sha256: "4e7cb7ddce8cf837a7a75dc029209b520ca0101470fcdf275c1f49736a3615b9",
bytes: 202_292_617,
forma: Forma::Zip(&["bin/x64-win/DirectML.dll"]),
con_sesion: false,
},
Pieza {
nombre: "birefnet-general-epoch_244-fp16",
url: "https://os.labstreamsas.com/api/modelos/sujeto-equipo",
sha256: "323232ec73a04ac4d0ef8c325a75aa8d69ed7062235a7cf9941769fae4c9709f",
bytes: 489_666_838,
forma: Forma::Archivo(ARCHIVO_MODELO),
con_sesion: true,
},
];
pub fn todas() -> &'static [Pieza] {
&PIEZAS
}

36
src-tauri/src/ia/tarjeta.rs Archivo normal
Ver fichero

@@ -0,0 +1,36 @@
// ── ¿Hay tarjeta NVIDIA en este equipo? ── (DXGI: lo que Windows sabe de sus adaptadores, sin
// cargar nada de la IA). Es lo que decide si la máscara de sujeto la busca la app o el navegador.
/// Una tarjeta NVIDIA del equipo: su nombre y su memoria propia (bytes).
pub struct Tarjeta {
pub nombre: String,
pub memoria: u64,
}
const NVIDIA: u32 = 0x10DE;
#[cfg(windows)]
pub fn nvidia() -> Option<Tarjeta> {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE};
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
let mut mejor: Option<Tarjeta> = None;
for i in 0.. {
let Ok(a) = (unsafe { factory.EnumAdapters1(i) }) else { break };
let Ok(d) = (unsafe { a.GetDesc1() }) else { continue };
if d.VendorId != NVIDIA || (d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32) != 0 {
continue;
}
let largo = d.Description.iter().position(|&c| c == 0).unwrap_or(d.Description.len());
let t = Tarjeta { nombre: String::from_utf16_lossy(&d.Description[..largo]).trim().to_string(), memoria: d.DedicatedVideoMemory as u64 };
// con dos, la de más memoria
if mejor.as_ref().map(|m| t.memoria > m.memoria).unwrap_or(true) {
mejor = Some(t);
}
}
mejor
}
#[cfg(not(windows))]
pub fn nvidia() -> Option<Tarjeta> {
None
}

Ver fichero

@@ -36,6 +36,9 @@
mod cache;
mod tracker;
mod salud;
mod memoria;
mod ia;
use std::{
collections::HashMap,
@@ -79,6 +82,19 @@ fn es_pestana_interna(target: &Url, server: &Url) -> bool {
target.origin() == server.origin()
}
// El color que pide una página para su pestaña: solo `#rrggbb` (va a un estilo de la barra; nada
// que no sea un color llega allí).
fn color_de_pagina(c: &str) -> Option<String> {
let hex = c.strip_prefix('#')?;
(hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_ascii_lowercase())
}
// Un nombre de ventana que identifica pestaña: los de la app (`ls-…`, p. ej. `ls-edicion-<set>`).
// Los reservados del navegador (_blank, _self…) y cualquier otro abren pestaña nueva como siempre.
fn nombre_de_ventana(n: &str) -> Option<String> {
(n.starts_with("ls-") && n.len() <= 80 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')).then(|| n.to_string())
}
#[cfg(test)]
mod navigation_tests {
use super::*;
@@ -93,6 +109,20 @@ mod navigation_tests {
assert!(is_google_host("docs.google.com"));
assert!(!is_google_host("docs.google.com.evil.example"));
}
#[test]
fn la_pagina_solo_pide_colores_y_nombres_de_la_app() {
assert_eq!(color_de_pagina("#262626").as_deref(), Some("#262626"));
assert_eq!(color_de_pagina("#A0b1C2").as_deref(), Some("#a0b1c2"));
for malo in ["", "262626", "#26262", "#2626266", "red", "#26262g", "#262626;background:url(x)"] {
assert_eq!(color_de_pagina(malo), None, "{malo}");
}
assert_eq!(nombre_de_ventana("ls-edicion-cmg123").as_deref(), Some("ls-edicion-cmg123"));
for malo in ["", "_blank", "_self", "edicion", "ls-a b", "ls-\"<x>"] {
assert_eq!(nombre_de_ventana(malo), None, "{malo}");
}
assert_eq!(nombre_de_ventana(&format!("ls-{}", "a".repeat(80))), None);
}
}
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
const CHROME: &str = "chrome";
@@ -117,6 +147,15 @@ struct TabRec {
// se suspendió.
oculta_desde: Option<Instant>,
dormida: bool,
// Ahorro de memoria (memoria.rs): su webview se cerró tras DESCARTE_TRAS dormida. Sigue en la
// barra con título, URL y color, y se crea otra vez al volver a ella.
descartada: bool,
// El color que PIDE la página (`<html data-ls-pestana="#262626">`, ver `color_de_pagina`): el
// modo edición de un set pinta su pestaña del gris del editor. Lo manda la página, no el menú.
pagina: Option<String>,
// El nombre de ventana con que se abrió (`window.open(url, nombre)`): abrir otra vez con el
// mismo nombre lleva a esta pestaña en vez de abrir otra (ver `reusar_pestana`).
nombre: Option<String>,
}
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
@@ -189,6 +228,16 @@ struct Persisted {
// que un estado.json viejo (sin colores) siga cargando tal cual.
#[serde(default)]
colors: Vec<String>,
// Título de cada pestaña (mismo orden): al abrir, las que no son la activa se quedan sin
// cargar (ver `registrar_diferida`) y la barra necesita algo que enseñar mientras tanto.
#[serde(default)]
titles: Vec<String>,
// El color que pidió la página y el nombre de ventana de cada una (mismo orden; "" = ninguno):
// la pestaña del modo edición vuelve del gris del editor y se sigue reutilizando.
#[serde(default)]
paginas: Vec<String>,
#[serde(default)]
nombres: Vec<String>,
#[serde(default)]
active: usize,
#[serde(default)]
@@ -220,6 +269,9 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
win: s.win,
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
titles: s.tabs.iter().map(|t| t.title.clone()).collect(),
paginas: s.tabs.iter().map(|t| t.pagina.clone().unwrap_or_default()).collect(),
nombres: s.tabs.iter().map(|t| t.nombre.clone().unwrap_or_default()).collect(),
active: s.active,
autostart: s.autostart,
dark: s.dark,
@@ -277,7 +329,26 @@ const INIT_JS_TPL: &str = r#"
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
catch (e) { try { T.opener.openUrl(url); } catch (_) {} }
}
function newTab(url) { emit('ls-new-tab', { url: url }); }
function newTab(url, nombre) { emit('ls-new-tab', nombre ? { url: url, name: nombre } : { url: url }); }
// Una ventana con nombre de la app (`ls-…`): reabrirla lleva a la pestaña que ya la tiene.
function nombreVentana(n) { return typeof n === 'string' && /^ls-[A-Za-z0-9_-]{1,77}$/.test(n) ? n : null; }
// ── Errores de la página → diario de salud del shell (salud.log) ──
// Solo el mensaje, el archivo:línea y la ruta (sin datos del formulario ni de la sesión). Como
// mucho 20 por carga de página; el shell además descarta repetidos durante un minuto.
var errores = 0;
function reportarError(tipo, msg, src) {
if (++errores > 20) return;
emit('ls-error', { label: LABEL, tipo: tipo, msg: String(msg || '').slice(0, 300), src: String(src || '').slice(0, 200), url: location.pathname });
}
addEventListener('error', function (e) {
// Con captura llegan también las imágenes o scripts que no cargan: esos no traen mensaje.
if (e && e.message) reportarError('error', e.message, (e.filename || '') + ':' + (e.lineno || 0));
}, true);
addEventListener('unhandledrejection', function (e) {
var r = e && e.reason;
reportarError('promesa', (r && (r.stack || r.message)) || r, '');
});
// ── Reposo ──
// El shell suspende las pestañas que llevan un rato ocultas (dejan de gastar CPU y sueltan
@@ -346,13 +417,25 @@ const INIT_JS_TPL: &str = r#"
} catch (e) { return null; }
}
// ── El color que la página pide para SU pestaña ── `<html data-ls-pestana='#262626'>` (el modo
// edición de un set, del gris del editor). "" = ninguno.
function colorPestana() {
try {
var c = document.documentElement && document.documentElement.getAttribute('data-ls-pestana');
return c && /^#[0-9a-fA-F]{6}$/.test(c) ? c : '';
} catch (e) { return ''; }
}
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
// sistema: la barra tiene que copiarle a ELLA.
// sistema: la barra tiene que copiarle a ELLA. Una página con color propio no lo dice: su
// fondo oscuro es el de su pestaña, no el tema de la app.
function report() {
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href, pestana: colorPestana() };
if (!carga.pestana) {
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
}
emit('ls-title', carga);
}
function watchTitle() {
@@ -377,7 +460,7 @@ const INIT_JS_TPL: &str = r#"
// por si el tema aterriza tarde con la hidratación.
function vigilarTema() {
if (!window.MutationObserver) return;
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] };
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-ls-pestana'] };
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
if (document.body) new MutationObserver(report).observe(document.body, conf);
}
@@ -394,7 +477,8 @@ const INIT_JS_TPL: &str = r#"
if (!u) return;
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
var mod = e.metaKey || e.ctrlKey;
if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); }
var nombre = mod ? null : nombreVentana(a.target);
if (isApp(u) && (mod || a.target === '_blank' || nombre)) { e.preventDefault(); newTab(u.href, nombre); }
}, true);
// Clic con la rueda (botón central) en un enlace interno → pestaña nueva, como en el navegador.
@@ -411,7 +495,7 @@ const INIT_JS_TPL: &str = r#"
window.open = function (url, name, feats) {
var u = url ? abs(url) : null;
if (isExt(u)) { openExternal(u.href); return null; }
if (isInterno(u)) { newTab(u.href); return null; }
if (isInterno(u)) { newTab(u.href, nombreVentana(name)); return null; }
return _open ? _open.call(window, url, name, feats) : null;
};
@@ -501,6 +585,27 @@ fn ronda_de_reposo<R: Runtime>(app: &AppHandle<R>) {
let _ = wv.eval("window.__lsReposo && window.__lsReposo()");
}
}
// Ahorro de memoria: las que llevan DESCARTE_TRAS ocultas y ya están dormidas (pasaron el
// filtro de arriba: sin subida, descarga ni audio, y no son un editor) se descartan.
let a_descartar: Vec<String> = {
let shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
s.tabs
.iter()
.enumerate()
.filter(|(i, t)| {
*i != s.active
&& t.dormida
&& !t.descartada
&& !t.url.contains("/docs/")
&& t.oculta_desde.map(|d| d.elapsed() >= memoria::DESCARTE_TRAS).unwrap_or(false)
})
.map(|(_, t)| t.label.clone())
.collect()
};
for label in a_descartar {
descartar_pestana(app, &label);
}
}
fn dormir_pestana<R: Runtime>(app: &AppHandle<R>, label: &str) {
@@ -593,7 +698,7 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
.tabs
.iter()
.enumerate()
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color, "pagina": t.pagina }))
.collect();
json!({
"tabs": tabs,
@@ -649,6 +754,8 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
}
}
}
// Descartada por ahorro de memoria: se crea otra vez en su última URL antes de mostrarla.
revivir_pestana(app, label);
let Some(win) = main_window(app) else { return };
for wv in win.webviews() {
if wv.label() == CHROME {
@@ -656,10 +763,13 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
}
if wv.label() == label {
despertar_webview(&wv);
memoria::nivel_bajo(&wv, false);
let _ = wv.show();
let _ = wv.set_focus();
} else {
let _ = wv.hide();
// Oculta: que WebView2 suelte cachés y memoria que puede regenerar (memoria.rs).
memoria::nivel_bajo(&wv, true);
}
}
broadcast(app);
@@ -761,12 +871,17 @@ fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path:
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
crear_pestana(app, url, activate, None);
}
// Lo mismo, con su nombre de ventana (ver `reusar_pestana`). Devuelve la etiqueta de la nueva.
fn crear_pestana<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool, nombre: Option<String>) -> Option<String> {
let target = url.unwrap_or_else(|| SERVER_URL.to_string());
// Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador.
let Ok(parsed) = Url::parse(&target) else { return };
let Ok(server) = Url::parse(SERVER_URL) else { return };
let parsed = Url::parse(&target).ok()?;
let server = Url::parse(SERVER_URL).ok()?;
if !es_pestana_interna(&parsed, &server) {
return;
return None;
}
let label = {
@@ -774,18 +889,77 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let mut s = shell.lock().unwrap();
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false });
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false, descartada: false, pagina: None, nombre });
label
};
let Some(win) = main_window(app) else { return };
match montar_webview(app, &label, parsed) {
Ok(_) => {
if activate {
activate_tab(app, &label);
} else {
broadcast(app);
save_persisted(app);
}
Some(label)
}
Err(e) => {
salud::anotar("ERROR", "pestaña", &format!("no se pudo crear el webview: {e}"));
// Revertir el registro si el webview no se pudo crear.
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
s.tabs.retain(|t| t.label != label);
None
}
}
}
// ── Pestañas con NOMBRE (1.21) ──
// `window.open(url, "ls-edicion-<set>")` (el modo edición de un set) abre la pestaña la primera
// vez; las siguientes llevan a ESA pestaña, como hace el navegador con una ventana con nombre, en
// vez de amontonar una por foto. A la página se le pasa la URL nueva por `__lsAbrir` (el modo
// edición cambia de foto sin recargar, guardando lo de la que tenía); si no lo conoce, navega.
// Devuelve false si no hay pestaña con ese nombre (entonces se crea).
fn reusar_pestana<R: Runtime>(app: &AppHandle<R>, nombre: &str, url: Option<&str>) -> bool {
let Ok(server) = Url::parse(SERVER_URL) else { return false };
let destino = match url.map(Url::parse) {
None => None,
Some(Ok(u)) if es_pestana_interna(&u, &server) => Some(u),
Some(_) => return false,
};
let (label, descartada) = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let Some(t) = s.tabs.iter_mut().find(|t| t.nombre.as_deref() == Some(nombre)) else { return false };
// Descartada (sin webview): se crea ya en la URL nueva al activarla.
if t.descartada {
if let Some(d) = &destino {
t.url = d.to_string();
}
}
(t.label.clone(), t.descartada)
};
activate_tab(app, &label);
if let (Some(d), false) = (destino, descartada) {
if let Some(wv) = get_webview(app, &label) {
let u = serde_json::to_string(d.as_str()).unwrap_or_else(|_| "\"\"".into());
let _ = wv.eval(format!("(function(u){{try{{if(window.__lsAbrir&&window.__lsAbrir(u))return}}catch(e){{}}location.assign(u)}})({u})"));
}
}
true
}
// Crea el webview de una pestaña: una nueva (create_tab) o una descartada que vuelve
// (revivir_pestana, al activarla). Lo vigila salud.rs: si su proceso muere, se recarga sola.
fn montar_webview<R: Runtime>(app: &AppHandle<R>, label: &str, parsed: Url) -> tauri::Result<Webview<R>> {
let win = main_window(app).ok_or(tauri::Error::WindowNotFound)?;
let scale = win.scale_factor().unwrap_or(1.0);
let size = win.inner_size().map(|s| s.to_logical::<f64>(scale)).unwrap_or(LogicalSize::new(1400.0, 900.0));
let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL);
let init = INIT_JS_TPL.replace("__LABEL__", label).replace("__ORIGIN__", SERVER_URL);
let app_zoom = app.clone();
let app_navigation = app.clone();
let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed))
let builder = WebviewBuilder::new(label, WebviewUrl::External(parsed))
.initialization_script(&init)
.on_navigation(move |url| {
if url.scheme() == "https" && url.host_str().map(is_google_host).unwrap_or(false) {
@@ -822,30 +996,111 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
set_loading(&app_zoom, wv.label(), cargando);
});
let created = win.add_child(
let wv = win.add_child(
builder,
LogicalPosition::new(0.0, TAB_BAR_H),
LogicalSize::new(size.width, (size.height - TAB_BAR_H).max(0.0)),
);
)?;
// Si su proceso de WebView2 muere o se cuelga, se anota y la pestaña se recarga sola.
salud::vigilar_pestana(app, &wv);
Ok(wv)
}
match created {
Ok(_) => {
if activate {
activate_tab(app, &label);
} else {
broadcast(app);
save_persisted(app);
}
// Ahorro de memoria: una pestaña dormida desde hace DESCARTE_TRAS se cierra de verdad (su proceso
// sale de la memoria) y queda en la barra con su título, URL y color. `activate_tab` la revive.
fn descartar_pestana<R: Runtime>(app: &AppHandle<R>, label: &str) {
{
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let activa = s.active;
let Some((i, t)) = s.tabs.iter_mut().enumerate().find(|(_, t)| t.label == label) else { return };
if i == activa || !t.dormida || t.descartada {
return;
}
Err(_) => {
// Revertir el registro si el webview no se pudo crear.
t.descartada = true;
t.dormida = false;
}
if let Some(wv) = get_webview(app, label) {
let _ = wv.close();
}
salud::anotar("INFO", "memoria", &format!("pestaña {label} descartada (lleva {} min dormida)", memoria::DESCARTE_TRAS.as_secs() / 60));
}
// Una pestaña de la sesión anterior que no es la activa: entra en la barra SIN webview (como si se
// hubiera descartado) y se crea la primera vez que se activa.
fn registrar_diferida<R: Runtime>(app: &AppHandle<R>, url: &str, title: &str) {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
let title = if title.is_empty() { "Labstream OS".to_string() } else { title.to_string() };
s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true, pagina: None, nombre: None });
}
// Vuelve a crear el webview de una pestaña descartada, en su última URL. Devuelve false si no se
// pudo (la pestaña se deja como estaba y se anota).
fn revivir_pestana<R: Runtime>(app: &AppHandle<R>, label: &str) -> bool {
let url = {
let shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
match s.tabs.iter().find(|t| t.label == label) {
Some(t) if t.descartada => t.url.clone(),
_ => return true, // no estaba descartada: nada que hacer
}
};
let Ok(parsed) = Url::parse(&url) else { return false };
match montar_webview(app, label, parsed) {
Ok(_) => {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
s.tabs.retain(|t| t.label != label);
if let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) {
t.descartada = false;
t.loading = true;
}
true
}
Err(e) => {
salud::anotar("ERROR", "memoria", &format!("no se pudo revivir la pestaña {label}: {e}"));
false
}
}
}
#[cfg_attr(not(windows), allow(dead_code))]
// Una pestaña que se cayó varias veces seguidas ya no se recarga sola (salud.rs): se avisa para
// que la persona la recargue (F5) o la cierre, en vez de dejarla en blanco sin explicación.
pub(crate) fn aviso_pestana_rota<R: Runtime>(app: &AppHandle<R>, label: &str) {
let titulo = {
let shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
s.tabs.iter().find(|t| t.label == label).map(|t| t.title.clone()).unwrap_or_else(|| "Una pestaña".into())
};
let _ = app
.notification()
.builder()
.title("Labstream OS")
.body(format!("«{titulo}» falló varias veces seguidas. Recárgala con F5 o ciérrala."))
.show();
}
// Memoria alta (salud.rs): las pestañas ocultas se duermen YA, sin esperar sus 5 minutos. Se les
// adelanta el reloj de «oculta desde» y se pasa la ronda normal, que sigue preguntando a cada una
// si está libre (una subida, una descarga o algo sonando no se duermen nunca).
fn dormir_ocultas_ya<R: Runtime>(app: &AppHandle<R>) {
{
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let activa = s.active;
let antes = Instant::now().checked_sub(REPOSO_TRAS).unwrap_or_else(Instant::now);
for (i, t) in s.tabs.iter_mut().enumerate() {
if i != activa && t.oculta_desde.is_some() {
t.oculta_desde = Some(antes);
}
}
}
ronda_de_reposo(app);
}
// Cierra una pestaña; si era la activa, activa la vecina. Nunca deja cero pestañas.
fn close_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
let next_active: Option<String> = {
@@ -1059,6 +1314,9 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
&CheckMenuItem::with_id(app, "autostart-toggle", "Iniciar con el sistema", true, autostart_on, None::<&str>)?,
&MenuItem::with_id(app, "check-update", "Buscar actualización…", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "salud-resumen", salud::resumen(), false, None::<&str>)?,
&MenuItem::with_id(app, "diagnostico", "Abrir diagnóstico", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "version", &version, false, None::<&str>)?,
&MenuItem::with_id(app, "quit-app", "Salir de Labstream OS", true, None::<&str>)?,
],
@@ -1253,21 +1511,47 @@ async fn check_update_manual(handle: tauri::AppHandle) {
match updater.check().await {
Ok(Some(update)) => {
notify(format!("Instalando la versión {}…", update.version));
if update.download_and_install(|_, _| {}, || {}).await.is_ok() {
handle.restart();
} else {
notify("No se pudo instalar la actualización. Intenta de nuevo más tarde.".into());
let version = update.version.clone();
match update.download_and_install(|_, _| {}, move || salud::antes_de_actualizar(&version)).await {
Ok(()) => handle.restart(),
Err(e) => {
salud::anotar("ERROR", "actualizacion", &format!("no se pudo instalar: {e}"));
notify("No se pudo instalar la actualización. Intenta de nuevo más tarde.".into());
}
}
}
Ok(None) => notify(format!("Estás en la última versión ({}).", handle.package_info().version)),
Err(_) => notify("No se pudo comprobar la actualización (revisa la red).".into()),
Err(e) => {
salud::anotar("AVISO", "actualizacion", &format!("no se pudo comprobar: {e}"));
notify("No se pudo comprobar la actualización (revisa la red).".into());
}
}
}
// ⋮ ▸ Abrir diagnóstico (y la bandeja): la carpeta con salud.log y recursos.csv, para mirarla o
// mandarla cuando algo falle.
fn abrir_diagnostico<R: Runtime>(app: &AppHandle<R>) {
if let Some(dir) = salud::carpeta() {
let _ = app.opener().open_path(dir.to_string_lossy().to_string(), None::<&str>);
}
}
// Muestra y enfoca la ventana principal (tray, dock, segunda instancia).
fn show_main<R: Runtime>(app: &AppHandle<R>) {
memoria::a_la_vista(app);
if let Some(w) = main_window(app) {
let _ = w.show();
// tao guarda su propia nota de «visible» y `show()` solo llama a ShowWindow si esa nota
// cambia (WindowFlags::apply_diff). Visto en el equipo de edición (2026-10-14, 1.19.0): la
// ventana estaba OCULTA en Windows con la nota en «visible», así que ni la bandeja, ni el
// icono, ni la tecla del MasterHUB la sacaban (set_focus sí corría: tomaba el foco sin
// verse). `is_visible()` lee el estado REAL (IsWindowVisible); si no cuadra, se baja y
// se sube la nota para que ShowWindow corra de verdad, y se anota para medir cada cuánto pasa.
if !w.is_visible().unwrap_or(true) {
salud::anotar("AVISO", "ventana", "show() no la mostró (estado de tao desincronizado): se rehace ocultar + mostrar");
let _ = w.hide();
let _ = w.show();
}
let _ = w.unminimize();
let _ = w.set_focus();
}
@@ -1281,8 +1565,21 @@ async fn check_update(handle: tauri::AppHandle) {
Ok(u) => u,
Err(_) => return,
};
if let Ok(Some(update)) = updater.check().await {
if update.download_and_install(|_, _| {}, || {}).await.is_ok() {
let update = match updater.check().await {
Ok(Some(u)) => u,
Ok(None) => return,
Err(e) => {
salud::anotar("AVISO", "actualizacion", &format!("no se pudo comprobar: {e}"));
return;
}
};
{
let version = update.version.clone();
let r = update.download_and_install(|_, _| {}, move || salud::antes_de_actualizar(&version)).await;
if let Err(e) = &r {
salud::anotar("ERROR", "actualizacion", &format!("no se pudo instalar: {e}"));
}
if r.is_ok() {
let _ = handle
.notification()
.builder()
@@ -1398,13 +1695,17 @@ pub fn run() {
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
// El cajón de miniaturas (ver el módulo `cache` y el handler de arriba). Registrado en el
// builder para que TODAS las webviews de pestaña puedan usarlo.
.register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb);
.register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb)
// La IA del equipo (src/ia): la máscara de sujeto con la tarjeta NVIDIA, para la página.
.invoke_handler(tauri::generate_handler![ia::ia_estado, ia::ia_preparar, ia::ia_sujeto]);
let app = builder
.manage(ShellState::default())
.manage(Descargas::default())
.setup(|app| {
let handle = app.handle().clone();
// Lo primero: el diario de salud (pánicos, cierres de golpe, reinicio automático).
salud::preparar(&handle);
let saved = load_persisted(&handle);
// Arranque automático al iniciar sesión: por defecto activado, pero se respeta
@@ -1571,6 +1872,7 @@ pub fn run() {
}
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
"autostart-toggle" => toggle_autostart(app),
"diagnostico" => abrir_diagnostico(app),
"clear-cache" => {
let antes = cache::tamano(app);
cache::vaciar(app);
@@ -1610,7 +1912,11 @@ pub fn run() {
// Pestañas de la sesión anterior (o una nueva si no hay), cada una con su color.
// El color se empareja con su URL ANTES de filtrar: el filtro por origen puede
// descartar entradas y entonces los índices de las dos listas dejarían de casar.
let restore: Vec<(String, String)> = {
// Ahorro de memoria al abrir: solo se CARGA la pestaña que estaba activa; las demás se
// registran descartadas (título, URL y color en la barra) y se crean al hacer clic en
// ellas, como hacen Chrome y Edge. Antes se cargaban todas a la vez: con 5 pestañas, 5
// copias enteras de la web app arrancando juntas.
let restore: Vec<(String, String, String, String, String)> = {
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
saved
.tabs
@@ -1618,26 +1924,33 @@ pub fn run() {
.enumerate()
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12)
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
.map(|(i, u)| {
let de = |v: &Vec<String>| v.get(i).cloned().unwrap_or_default();
(u.clone(), de(&saved.colors), de(&saved.titles), de(&saved.paginas), de(&saved.nombres))
})
.collect()
};
if restore.is_empty() {
create_tab(&handle, None, true);
} else {
for (url, color) in &restore {
create_tab(&handle, Some(url.clone()), false);
if !color.is_empty() {
// create_tab acaba de empujar su pestaña al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut() {
if t.url == *url {
t.color = Some(color.clone());
}
let idx = saved.active.min(restore.len() - 1);
for (i, (url, color, title, pagina, nombre)) in restore.iter().enumerate() {
if i == idx {
create_tab(&handle, Some(url.clone()), false);
} else {
registrar_diferida(&handle, url, title);
}
// La pestaña se acaba de empujar al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut().filter(|t| t.url == *url) {
if !color.is_empty() {
t.color = Some(color.clone());
}
t.pagina = color_de_pagina(pagina);
t.nombre = nombre_de_ventana(nombre);
}
}
let idx = saved.active.min(restore.len() - 1);
let label = {
let shell = handle.state::<ShellState>();
let s = shell.lock().unwrap();
@@ -1670,7 +1983,13 @@ pub fn run() {
let h = handle.clone();
handle.listen_any("ls-new-tab", move |e| {
let url = field(e.payload(), "url");
create_tab(&h, url, true);
let nombre = field(e.payload(), "name").and_then(|n| nombre_de_ventana(&n));
if let Some(n) = &nombre {
if reusar_pestana(&h, n, url.as_deref()) {
return;
}
}
crear_pestana(&h, url, true, nombre);
});
let h = handle.clone();
handle.listen_any("ls-activate", move |e| {
@@ -1771,11 +2090,12 @@ pub fn run() {
});
let h = handle.clone();
handle.listen_any("ls-title", move |e| {
let (label, title, url, dark) = (
let (label, title, url, dark, pestana) = (
field(e.payload(), "label"),
field(e.payload(), "title"),
field(e.payload(), "url"),
field_bool(e.payload(), "dark"),
field(e.payload(), "pestana"),
);
if let Some(label) = label {
let shell = h.state::<ShellState>();
@@ -1787,6 +2107,10 @@ pub fn run() {
if let Some(url) = url {
t.url = url;
}
// (una página de antes de la 1.21 no lo manda: se deja como estaba)
if let Some(p) = pestana {
t.pagina = color_de_pagina(&p);
}
}
// El tema es global (todas las pestañas del origen comparten modo): el
// último reporte manda, venga de la pestaña que venga.
@@ -1803,9 +2127,16 @@ pub fn run() {
let abrir = MenuItem::with_id(app, "abrir", "Abrir Labstream", true, None::<&str>)?;
let salir = MenuItem::with_id(app, "salir", "Salir", true, None::<&str>)?;
let (trk_estado, trk_toggle) = tracker::armar_menu(app.handle())?;
// Lo que gasta la app entera (exe + todos sus procesos de WebView2), cada minuto.
let recursos = MenuItem::with_id(app, "salud-recursos", salud::resumen(), false, None::<&str>)?;
let diagnostico = MenuItem::with_id(app, "diagnostico", "Abrir diagnóstico", true, None::<&str>)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(app, &[&abrir, &sep1, &trk_estado, &trk_toggle, &sep2, &salir])?;
let sep3 = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(
app,
&[&abrir, &sep1, &trk_estado, &trk_toggle, &sep2, &recursos, &diagnostico, &sep3, &salir],
)?;
TrayIconBuilder::with_id("main-tray")
.icon(app.default_window_icon().unwrap().clone())
@@ -1814,6 +2145,7 @@ pub fn run() {
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"abrir" => show_main(app),
"diagnostico" => abrir_diagnostico(app),
"salir" => {
save_persisted(app);
app.exit(0);
@@ -1837,6 +2169,20 @@ pub fn run() {
// El sensor del rastreador: listener de vinculación + hilo de medición.
tracker::iniciar(app.handle());
// Salud: medición de recursos cada minuto y vigilante del hilo principal.
salud::iniciar(app.handle(), recursos, dormir_ocultas_ya);
// Errores de JavaScript de las pestañas (los manda el script de cada página).
handle.listen_any("ls-error", |e| {
let p = e.payload();
salud::error_js(
&field(p, "label").unwrap_or_default(),
&field(p, "tipo").unwrap_or_default(),
&field(p, "msg").unwrap_or_default(),
&field(p, "src").unwrap_or_default(),
&field(p, "url").unwrap_or_default(),
);
});
Ok(())
})
.on_window_event(|window, event| {
@@ -1847,6 +2193,8 @@ pub fn run() {
let _ = window.hide();
api.prevent_close();
}
// Vuelve a primer plano (p. ej. tras minimizar): memoria normal para lo que se ve.
WindowEvent::Focused(true) => memoria::a_la_vista(window.app_handle()),
// La barra y las pestañas siguen el tamaño de la ventana.
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
let app = window.app_handle();
@@ -1909,6 +2257,10 @@ pub fn run() {
if let tauri::RunEvent::ExitRequested { .. } = event {
save_persisted(app);
}
// Salida limpia: sin esto, el próximo arranque anotaría un «cierre de golpe».
if let tauri::RunEvent::Exit = event {
salud::salida_limpia();
}
let _ = app;
});
}

212
src-tauri/src/memoria.rs Archivo normal
Ver fichero

@@ -0,0 +1,212 @@
// ── Memoria (v1.20.0): que la app gaste lo que necesita y devuelva lo que no ──
//
// MEDIDO en el equipo de edición (2026-10-14, app abierta desde el día anterior, 5 pestañas):
// 2.221 MB de memoria privada en 13 procesos. De eso, 1.329 MB eran del PROCESO GRÁFICO de
// WebView2 (gpu-process), que crece con las horas (fotos, miniaturas, el reproductor de revisión,
// dos monitores 4K) y no devuelve nada; cada pestaña viva pesaba 110-180 MB más. Se reinició a
// mano solo el proceso gráfico: WebView2 lo levantó de nuevo, nada se perdió, y la app bajó a
// 1.140 MB. De ahí salen las tres medidas de este módulo:
//
// 1. RECICLAR EL PROCESO GRÁFICO: si pasa de GPU_RECICLAR_MB y nadie está mirando (ventana
// oculta o minimizada, o sin tocar ratón ni teclado RECICLAR_INACTIVO), se cierra y WebView2
// crea otro al momento (es un caso previsto: ProcessFailed GPU_PROCESS_EXITED, que salud.rs
// anota). Como mucho cada RECICLAR_CADA: Chromium pasa a dibujar sin GPU si el proceso gráfico
// cae varias veces en poco tiempo, y eso sería peor que la memoria.
// 2. NIVEL DE MEMORIA BAJO (ICoreWebView2_19::MemoryUsageTargetLevel): a las pestañas ocultas
// —y a todas con la ventana oculta o minimizada— se les pide a WebView2 que suelte cachés y
// memoria que puede regenerar. La pestaña a la vista vuelve a «normal» al instante.
// 3. DESCARTAR PESTAÑAS (lib.rs, `ronda_de_reposo`): una pestaña que lleva DESCARTE_TRAS dormida
// se cierra de verdad —su proceso sale de la memoria— y se queda en la barra con su título,
// URL y color; al volver a ella se crea otra vez en la misma URL (1-2 s de carga). Es lo que
// hace el «Ahorro de memoria» de Chrome. Solo llegan aquí pestañas que ya pasaron el filtro
// del reposo (sin subidas, descargas ni audio, y nunca un editor de documentos).
// En macOS solo existen los niveles y el descarte; la medición y el reciclaje son de Windows.
#![cfg_attr(not(windows), allow(dead_code, unused_imports))]
use std::{
sync::{mpsc, Mutex},
time::{Duration, Instant},
};
use tauri::{AppHandle, Manager, Runtime, Webview};
use crate::salud;
pub const GPU_RECICLAR_MB: u64 = 600;
const RECICLAR_CADA: Duration = Duration::from_secs(30 * 60);
const RECICLAR_INACTIVO: Duration = Duration::from_secs(10 * 60);
pub const DESCARTE_TRAS: Duration = Duration::from_secs(30 * 60);
static ULTIMO_RECICLAJE: Mutex<Option<Instant>> = Mutex::new(None);
/// Memoria de los procesos de WebView2 por tipo, para el diario: (gpu_mb, renderers_mb).
#[derive(Clone, Copy, Default)]
pub struct Reparto {
pub gpu_mb: u64,
pub pestanas_mb: u64,
}
// ── Nivel de memoria de una pestaña ──
#[cfg(windows)]
pub fn nivel_bajo<R: Runtime>(wv: &Webview<R>, bajo: bool) {
let _ = wv.with_webview(move |pw| unsafe {
use webview2_com::Microsoft::Web::WebView2::Win32::{
ICoreWebView2_19, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL,
};
use windows::core::Interface;
let Ok(core) = pw.controller().CoreWebView2() else { return };
// ICoreWebView2_19 existe desde el runtime 1.0.2210 (2023): si no está, no pasa nada.
let Ok(c19) = core.cast::<ICoreWebView2_19>() else { return };
let _ = c19.SetMemoryUsageTargetLevel(if bajo { COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW } else { COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL });
});
}
#[cfg(not(windows))]
pub fn nivel_bajo<R: Runtime>(_wv: &Webview<R>, _bajo: bool) {}
/// Con la ventana oculta o minimizada, todas las vistas a nivel bajo; si no, solo las ocultas.
/// La llama la ronda de salud cada minuto (desde su hilo: las consultas a la ventana esperan al
/// hilo principal y aquí eso es seguro).
pub fn ajustar_niveles<R: Runtime>(app: &AppHandle<R>) {
let Some(win) = app.get_window("main") else { return };
let nadie_mira = !win.is_visible().unwrap_or(true) || win.is_minimized().unwrap_or(false);
let activa = {
let shell = app.state::<crate::ShellState>();
let s = shell.lock().unwrap();
s.tabs.get(s.active).map(|t| t.label.clone())
};
for wv in win.webviews() {
let visible = wv.label() == crate::CHROME || Some(wv.label()) == activa.as_deref();
nivel_bajo(&wv, nadie_mira || !visible);
}
}
/// La ventana vuelve a verse (bandeja, minimizar, foco): la barra y la pestaña activa recuperan el
/// nivel normal YA, sin esperar a la ronda del minuto. No consulta la ventana: vale en cualquier hilo.
pub fn a_la_vista<R: Runtime>(app: &AppHandle<R>) {
let activa = {
let shell = app.state::<crate::ShellState>();
let s = shell.lock().unwrap();
s.tabs.get(s.active).map(|t| t.label.clone())
};
for label in std::iter::once(crate::CHROME.to_string()).chain(activa) {
if let Some(wv) = app.get_webview(&label) {
nivel_bajo(&wv, false);
}
}
}
// ── Procesos de WebView2 y reciclaje del proceso gráfico ──
/// (pid, tipo) de todos los procesos del entorno WebView2 de la app. Tipo: el valor de
/// COREWEBVIEW2_PROCESS_KIND (0 navegador, 1 pestaña, 2 utilidad, 3 sandbox, 4 GPU…).
/// No llamar desde el hilo principal: espera su respuesta.
#[cfg(windows)]
fn procesos<R: Runtime>(app: &AppHandle<R>) -> Vec<(u32, i32)> {
let Some(wv) = app.get_webview(crate::CHROME) else { return Vec::new() };
let (tx, rx) = mpsc::channel();
let pedido = wv.with_webview(move |pw| unsafe {
use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment8, ICoreWebView2_2, COREWEBVIEW2_PROCESS_KIND};
use windows::core::Interface;
let mut v = Vec::new();
let lista = pw
.controller()
.CoreWebView2()
.and_then(|c| c.cast::<ICoreWebView2_2>())
.and_then(|c| c.Environment())
.and_then(|e| e.cast::<ICoreWebView2Environment8>())
.and_then(|e| e.GetProcessInfos());
if let Ok(lista) = lista {
let mut n = 0u32;
let _ = lista.Count(&mut n);
for i in 0..n {
let Ok(info) = lista.GetValueAtIndex(i) else { continue };
let (mut pid, mut kind) = (0i32, COREWEBVIEW2_PROCESS_KIND::default());
if info.ProcessId(&mut pid).is_ok() && info.Kind(&mut kind).is_ok() {
v.push((pid as u32, kind.0));
}
}
}
let _ = tx.send(v);
});
if pedido.is_err() {
return Vec::new();
}
rx.recv_timeout(Duration::from_secs(5)).unwrap_or_default()
}
/// Segundos sin tocar ratón ni teclado en todo el equipo.
#[cfg(windows)]
fn inactivo() -> Duration {
use windows::Win32::{System::SystemInformation::GetTickCount, UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO}};
unsafe {
let mut li = LASTINPUTINFO { cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32, dwTime: 0 };
if !GetLastInputInfo(&mut li).as_bool() {
return Duration::ZERO;
}
Duration::from_millis(GetTickCount().wrapping_sub(li.dwTime) as u64)
}
}
/// Ronda de memoria (cada minuto, hilo de salud): reparte la memoria por tipo y, si toca,
/// recicla el proceso gráfico.
#[cfg(windows)]
pub fn ronda<R: Runtime>(app: &AppHandle<R>) -> Reparto {
const GPU: i32 = 4; // COREWEBVIEW2_PROCESS_KIND_GPU
const PESTANA: i32 = 1; // COREWEBVIEW2_PROCESS_KIND_RENDERER
let procs = procesos(app);
let mut r = Reparto::default();
let mut gpu = None;
for &(pid, kind) in &procs {
let mb = salud::memoria_privada_mb(pid);
match kind {
GPU => {
r.gpu_mb += mb;
gpu = Some((pid, mb));
}
PESTANA => r.pestanas_mb += mb,
_ => {}
}
}
if let Some((pid, mb)) = gpu {
if mb >= GPU_RECICLAR_MB {
let nadie_mira = app
.get_window("main")
.map(|w| !w.is_visible().unwrap_or(true) || w.is_minimized().unwrap_or(false))
.unwrap_or(false);
let toca = ULTIMO_RECICLAJE.lock().unwrap().map(|t| t.elapsed() >= RECICLAR_CADA).unwrap_or(true);
// Con la ventana a la vista solo si nadie toca el equipo Y no sale sonido: alguien
// viendo un video de revisión sin tocar nada está «inactivo» pero sí está mirando, y
// cortarle el proceso gráfico le daría un parpadeo en pleno video.
let quieto = inactivo() >= RECICLAR_INACTIVO && !crate::tracker::hay_audio_sonando();
if toca && (nadie_mira || quieto) {
*ULTIMO_RECICLAJE.lock().unwrap() = Some(Instant::now());
if terminar(pid) {
salud::anotar("INFO", "memoria", &format!("proceso gráfico reciclado: tenía {mb} MB (WebView2 crea otro limpio)"));
} else {
salud::anotar("AVISO", "memoria", &format!("no se pudo reciclar el proceso gráfico ({mb} MB)"));
}
}
}
}
r
}
#[cfg(not(windows))]
pub fn ronda<R: Runtime>(_app: &AppHandle<R>) -> Reparto {
Reparto::default()
}
#[cfg(windows)]
fn terminar(pid: u32) -> bool {
use windows::Win32::{
Foundation::CloseHandle,
System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE},
};
unsafe {
let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) else { return false };
let ok = TerminateProcess(h, 0).is_ok();
let _ = CloseHandle(h);
ok
}
}

519
src-tauri/src/salud.rs Archivo normal
Ver fichero

@@ -0,0 +1,519 @@
// ── Salud de la app (v1.20.0): cuánto gasta de verdad, qué falla y cómo se repone sola ──
//
// POR QUÉ: la app es un exe pequeño (~60 MB) que cuelga de él una docena de procesos de WebView2
// (uno por pestaña, más el GPU y los de utilidad). En el Administrador de tareas el gasto real
// queda repartido entre «Microsoft Edge WebView2» y no se ve cuánto pesa Labstream OS entero; y
// cuando algo fallaba (una pestaña en blanco, un cuelgue) no quedaba rastro. Este módulo:
//
// 1. MIDE cada minuto el ÁRBOL de procesos de la app (el exe + todos sus WebView2): memoria
// privada, CPU y número de procesos. Lo enseña en la bandeja («Recursos: 2,1 GB · CPU 3 %»)
// y lo anota en `recursos.csv`, para tener la curva de un día de trabajo real.
// Columnas del CSV: fecha, procesos, memoria_mb, cpu, pestañas, dormidas, descartadas,
// gpu_mb (proceso gráfico) y pestanas_mb (suma de los procesos de pestaña).
// 2. ANOTA en `salud.log` lo que falla: pánicos de Rust, procesos de WebView2 caídos o colgados,
// errores de JavaScript de las páginas, el hilo principal sin responder y los cierres de golpe
// (si al arrancar sigue el testigo `en-marcha`, la sesión anterior no se cerró bien).
// 3. SE REPONE: una pestaña cuyo proceso murió o se colgó se recarga sola (con freno: más de 3 en
// 10 min y se deja quieta para no entrar en bucle); si muere el proceso principal de WebView2
// —se caen TODAS las pestañas— la app se reinicia; Windows la vuelve a abrir si se cuelga o
// se cierra de golpe (RegisterApplicationRestart); y con la memoria por encima del umbral se
// duermen ya las pestañas ocultas, sin esperar sus 5 minutos.
//
// Todo queda en la carpeta de logs de la app (%LOCALAPPDATA%\co.labstream.os\logs) y se abre
// desde ⋮ ▸ Diagnóstico. No se envía nada a ningún sitio.
use std::{
collections::HashMap,
fs,
io::Write,
path::PathBuf,
sync::{
atomic::{AtomicBool, AtomicU32, Ordering},
Mutex, OnceLock,
},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::{menu::MenuItem, AppHandle, Manager, Runtime};
const LOG_MAX: u64 = 2 * 1024 * 1024; // salud.log rota a salud.1.log al pasar de 2 MB
const CSV_MAX: u64 = 4 * 1024 * 1024;
const MUESTRA_CADA: Duration = Duration::from_secs(60);
/// Memoria privada del árbol a partir de la cual se duermen ya las pestañas ocultas.
pub const MEMORIA_ALTA_MB: u64 = 3 * 1024;
const RECARGAS_MAX: usize = 3; // por pestaña…
const RECARGAS_VENTANA: Duration = Duration::from_secs(10 * 60); // …en esta ventana
static DIR: OnceLock<PathBuf> = OnceLock::new();
static ESCRITURA: Mutex<()> = Mutex::new(());
static ERRORES: AtomicU32 = AtomicU32::new(0);
static RECUPERACIONES: AtomicU32 = AtomicU32::new(0);
static REINICIANDO: AtomicBool = AtomicBool::new(false);
static ULTIMA: Mutex<Option<Muestra>> = Mutex::new(None);
static RECARGAS: OnceLock<Mutex<HashMap<String, Vec<Instant>>>> = OnceLock::new();
static VISTOS_JS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
#[derive(Clone, Copy, Default)]
pub struct Muestra {
pub procesos: u32,
pub memoria_mb: u64,
pub cpu: f64,
}
pub struct SaludUi<R: Runtime>(pub MenuItem<R>);
// ── Diario ──
/// Fecha y hora UTC en ISO 8601, sin dependencias (días civiles de Howard Hinnant).
fn ahora_iso() -> String {
let s = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
let (dias, resto) = (s.div_euclid(86_400), s.rem_euclid(86_400));
let z = dias + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", resto / 3600, resto % 3600 / 60, resto % 60)
}
fn anexar(nombre: &str, max: u64, linea: &str) {
let Some(dir) = DIR.get() else { return };
let _g = ESCRITURA.lock();
let ruta = dir.join(nombre);
if fs::metadata(&ruta).map(|m| m.len() > max).unwrap_or(false) {
let viejo = dir.join(nombre.replacen('.', ".1.", 1));
let _ = fs::remove_file(&viejo);
let _ = fs::rename(&ruta, &viejo);
}
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&ruta) {
let _ = writeln!(f, "{linea}");
}
}
/// Anota una línea en salud.log. `nivel`: INFO, AVISO o ERROR.
pub fn anotar(nivel: &str, origen: &str, msg: &str) {
if nivel == "ERROR" {
ERRORES.fetch_add(1, Ordering::Relaxed);
}
let msg = msg.replace(['\r', '\n'], " ");
anexar("salud.log", LOG_MAX, &format!("{} {nivel:<5} {origen}: {msg}", ahora_iso()));
}
/// Error de JavaScript de una pestaña. El mismo mensaje no se repite más de una vez por minuto.
pub fn error_js(label: &str, tipo: &str, msg: &str, src: &str, ruta: &str) {
let clave = format!("{tipo}|{msg}|{src}");
{
let mut v = VISTOS_JS.get_or_init(Default::default).lock().unwrap();
if v.get(&clave).map(|t| t.elapsed() < Duration::from_secs(60)).unwrap_or(false) {
return;
}
if v.len() > 500 {
v.clear();
}
v.insert(clave, Instant::now());
}
anotar("ERROR", &format!("js {label}"), &format!("{tipo} en {ruta}: {msg} [{src}]"));
}
pub fn carpeta() -> Option<PathBuf> {
DIR.get().cloned()
}
/// Texto para la bandeja y el menú ⋮.
pub fn resumen() -> String {
if cfg!(not(windows)) {
return "Recursos: la medición es solo de Windows".into();
}
let Some(m) = *ULTIMA.lock().unwrap() else { return "Recursos: midiendo…".into() };
let mem = if m.memoria_mb >= 1024 { format!("{:.1} GB", m.memoria_mb as f64 / 1024.0).replace('.', ",") } else { format!("{} MB", m.memoria_mb) };
let mut s = format!("Recursos: {mem} · CPU {:.0} % · {} procesos", m.cpu, m.procesos);
let (e, r) = (ERRORES.load(Ordering::Relaxed), RECUPERACIONES.load(Ordering::Relaxed));
if e > 0 || r > 0 {
s.push_str(&format!(" · {e} errores, {r} recuperadas"));
}
s
}
// ── Arranque y salida ──
/// Prepara la carpeta, el registro de pánicos, el testigo de cierre limpio y el reinicio automático.
/// Va lo PRIMERO en setup: así un fallo del propio arranque ya queda anotado.
pub fn preparar<R: Runtime>(app: &AppHandle<R>) {
let dir = app
.path()
.app_log_dir()
.or_else(|_| app.path().app_local_data_dir().map(|d| d.join("logs")))
.unwrap_or_else(|_| std::env::temp_dir().join("labstream-os-logs"));
let _ = fs::create_dir_all(&dir);
let _ = DIR.set(dir.clone());
let anterior = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let donde = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
let que = info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_default();
anotar("ERROR", "panico", &format!("{que} ({donde}) hilo={}", std::thread::current().name().unwrap_or("?")));
anterior(info);
}));
let testigo = dir.join("en-marcha");
if testigo.exists() {
anotar("ERROR", "app", "la sesión anterior terminó de golpe (cierre forzado, cuelgue, apagón o fallo)");
}
let _ = fs::write(&testigo, std::process::id().to_string());
anotar("INFO", "app", &format!("inicio v{} pid {}", app.package_info().version, std::process::id()));
#[cfg(windows)]
unsafe {
// Si la app se cuelga o se cae, Windows la vuelve a abrir (solo si llevaba ≥60 s en marcha,
// así que un fallo en el arranque no entra en bucle). Al reiniciar el equipo no: de eso ya
// se encarga el arranque automático.
use windows::Win32::System::Recovery::{RegisterApplicationRestart, RESTART_NO_REBOOT};
if RegisterApplicationRestart(windows::core::PCWSTR::null(), RESTART_NO_REBOOT).is_err() {
anotar("AVISO", "app", "no se pudo registrar el reinicio automático de Windows");
}
}
}
/// Salida limpia: borra el testigo (si no se borra, el próximo arranque anota un cierre de golpe).
pub fn salida_limpia() {
if REINICIANDO.load(Ordering::Relaxed) {
anotar("INFO", "app", "reinicio para recuperarse");
} else {
anotar("INFO", "app", "salida");
}
if let Some(d) = DIR.get() {
let _ = fs::remove_file(d.join("en-marcha"));
}
}
/// Justo antes de que el instalador de una actualización cierre la app (en Windows lo hace él, sin
/// pasar por la salida normal): se anota y se quita el testigo, para no contarlo como cierre de golpe.
pub fn antes_de_actualizar(version: &str) {
anotar("INFO", "actualizacion", &format!("se instala la versión {version}"));
if let Some(d) = DIR.get() {
let _ = fs::remove_file(d.join("en-marcha"));
}
}
/// Hilo de medición y vigilante del hilo principal. `memoria_alta` se llama (como mucho cada 10
/// min) cuando el árbol pasa de MEMORIA_ALTA_MB: lib.rs duerme ahí las pestañas ocultas.
pub fn iniciar<R: Runtime>(app: &AppHandle<R>, item: MenuItem<R>, memoria_alta: impl Fn(&AppHandle<R>) + Send + 'static) {
app.manage(SaludUi(item));
let h = app.clone();
std::thread::Builder::new()
.name("salud-recursos".into())
.spawn(move || {
// El medidor de audio (memoria.rs → tracker::hay_audio_sonando) usa COM en este hilo.
#[cfg(windows)]
unsafe {
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
}
let mut medidor = Medidor::default();
let mut ultimo_aviso: Option<Instant> = None;
let mut pico = 0u64;
loop {
std::thread::sleep(MUESTRA_CADA);
// Primero la memoria (memoria.rs): niveles de las vistas y, si toca, reciclar el
// proceso gráfico. Luego se mide, para que la muestra ya refleje lo liberado.
crate::memoria::ajustar_niveles(&h);
let reparto = crate::memoria::ronda(&h);
let Some(m) = medidor.medir() else { continue };
*ULTIMA.lock().unwrap() = Some(m);
let (pestanas, dormidas, descartadas) = conteo_pestanas(&h);
anexar(
"recursos.csv",
CSV_MAX,
&format!(
"{},{},{},{:.1},{},{},{},{},{}",
ahora_iso(), m.procesos, m.memoria_mb, m.cpu, pestanas, dormidas, descartadas, reparto.gpu_mb, reparto.pestanas_mb
),
);
if m.memoria_mb > pico + 256 && m.memoria_mb > 1024 {
pico = m.memoria_mb;
anotar("INFO", "recursos", &format!("nuevo pico de memoria: {} MB en {} procesos ({pestanas} pestañas)", m.memoria_mb, m.procesos));
}
if m.memoria_mb >= MEMORIA_ALTA_MB && ultimo_aviso.map(|t| t.elapsed() > Duration::from_secs(600)).unwrap_or(true) {
ultimo_aviso = Some(Instant::now());
anotar("AVISO", "recursos", &format!("memoria alta ({} MB): se duermen ya las pestañas ocultas", m.memoria_mb));
memoria_alta(&h);
}
// set_text despacha al hilo principal y espera: aquí, en un hilo propio, es seguro.
let _ = h.state::<SaludUi<R>>().0.set_text(resumen());
}
})
.ok();
// ¿Responde el hilo principal? Un cuelgue de la interfaz (la ventana «No responde») no deja
// ningún otro rastro. Se le manda una tarea vacía cada 20 s; si tarda más de 15 s, se anota
// cuándo empezó y cuánto duró.
let h = app.clone();
std::thread::Builder::new()
.name("salud-vigilante".into())
.spawn(move || loop {
std::thread::sleep(Duration::from_secs(20));
let (tx, rx) = std::sync::mpsc::channel();
let t0 = Instant::now();
if h.run_on_main_thread(move || {
let _ = tx.send(());
})
.is_err()
{
return; // la app está cerrándose
}
if rx.recv_timeout(Duration::from_secs(15)).is_err() {
anotar("ERROR", "app", "el hilo principal no responde (la ventana se congeló)");
let _ = rx.recv();
anotar("AVISO", "app", &format!("el hilo principal volvió a responder tras {} s", t0.elapsed().as_secs()));
}
})
.ok();
}
fn conteo_pestanas<R: Runtime>(app: &AppHandle<R>) -> (usize, usize, usize) {
let shell = app.state::<crate::ShellState>();
let s = shell.lock().unwrap();
(s.tabs.len(), s.tabs.iter().filter(|t| t.dormida).count(), s.tabs.iter().filter(|t| t.descartada).count())
}
/// Memoria privada de un proceso, en MB (0 si no se puede abrir).
#[cfg(windows)]
pub fn memoria_privada_mb(pid: u32) -> u64 {
use windows::Win32::{
Foundation::CloseHandle,
System::{
ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX},
Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION},
},
};
unsafe {
let Ok(hp) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { return 0 };
let mut mc = PROCESS_MEMORY_COUNTERS_EX { cb: std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32, ..Default::default() };
let ok = GetProcessMemoryInfo(hp, &mut mc as *mut _ as *mut PROCESS_MEMORY_COUNTERS, mc.cb).is_ok();
let _ = CloseHandle(hp);
if ok {
mc.PrivateUsage as u64 / (1024 * 1024)
} else {
0
}
}
}
// ── Recuperación de pestañas (WebView2) ──
#[cfg_attr(not(windows), allow(dead_code))] // en macOS no hay ProcessFailed: solo lo usa Windows
/// ¿Se puede recargar otra vez esta pestaña? Freno contra bucles: RECARGAS_MAX en RECARGAS_VENTANA.
fn permitir_recarga(label: &str) -> bool {
let mut m = RECARGAS.get_or_init(Default::default).lock().unwrap();
let v = m.entry(label.to_string()).or_default();
v.retain(|t| t.elapsed() < RECARGAS_VENTANA);
if v.len() >= RECARGAS_MAX {
return false;
}
v.push(Instant::now());
true
}
/// Se engancha al aviso `ProcessFailed` de WebView2 de una pestaña recién creada.
#[cfg(windows)]
pub fn vigilar_pestana<R: Runtime>(app: &AppHandle<R>, wv: &tauri::Webview<R>) {
let label = wv.label().to_string();
let h = app.clone();
let _ = wv.with_webview(move |pw| unsafe {
use webview2_com::{Microsoft::Web::WebView2::Win32::*, ProcessFailedEventHandler};
let Ok(core) = pw.controller().CoreWebView2() else { return };
let mut token = 0i64;
let handler = ProcessFailedEventHandler::create(Box::new(move |sender, args| {
let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default();
if let Some(a) = args {
let _ = a.ProcessFailedKind(&mut kind);
}
let recargar = |por: &str| {
if !permitir_recarga(&label) {
anotar("ERROR", &format!("pestaña {label}"), &format!("{por} otra vez: ya se recargó {RECARGAS_MAX} veces en 10 min, se deja quieta"));
crate::aviso_pestana_rota(&h, &label);
return;
}
if let Some(c) = &sender {
if c.Reload().is_ok() {
RECUPERACIONES.fetch_add(1, Ordering::Relaxed);
anotar("AVISO", &format!("pestaña {label}"), &format!("{por}: recargada sola"));
}
}
};
match kind {
COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED => {
anotar("ERROR", &format!("pestaña {label}"), "su proceso se cerró (pestaña en blanco)");
recargar("proceso cerrado");
}
COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE => {
anotar("ERROR", &format!("pestaña {label}"), "su proceso dejó de responder");
recargar("proceso colgado");
}
COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED => {
// Lo avisan TODAS las pestañas a la vez: solo la primera reinicia.
if !REINICIANDO.swap(true, Ordering::SeqCst) {
anotar("ERROR", "webview2", "se cerró el proceso principal de WebView2: se reinicia la app");
RECUPERACIONES.fetch_add(1, Ordering::Relaxed);
crate::save_persisted(&h);
let hh = h.clone();
// Desde un hilo aparte, restart() pide la salida al bucle de eventos: pasa por
// RunEvent::Exit (salida_limpia anota el reinicio y quita el testigo) y el
// plugin de instancia única suelta su mutex ANTES de lanzar la copia nueva.
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(500));
hh.restart();
});
}
}
COREWEBVIEW2_PROCESS_FAILED_KIND_FRAME_RENDER_PROCESS_EXITED => {
anotar("AVISO", &format!("pestaña {label}"), "se cerró el proceso de un marco interno (p. ej. un editor incrustado)");
}
COREWEBVIEW2_PROCESS_FAILED_KIND_GPU_PROCESS_EXITED => {
anotar("AVISO", "webview2", "se reinició el proceso de gráficos (WebView2 lo levanta solo)");
}
otro => anotar("AVISO", "webview2", &format!("se cerró un proceso auxiliar (tipo {})", otro.0)),
}
Ok(())
}));
if core.add_ProcessFailed(&handler, &mut token).is_err() {
anotar("AVISO", "webview2", "no se pudo vigilar una pestaña");
}
});
}
#[cfg(not(windows))]
pub fn vigilar_pestana<R: Runtime>(_app: &AppHandle<R>, _wv: &tauri::Webview<R>) {}
// ── Medición del árbol de procesos ──
#[derive(Default)]
struct Medidor {
cpu_previa: HashMap<u32, u64>, // pid → tiempo de CPU acumulado (100 ns)
cuando: Option<Instant>,
}
#[cfg(windows)]
impl Medidor {
fn medir(&mut self) -> Option<Muestra> {
use windows::Win32::{
Foundation::{CloseHandle, FILETIME},
System::{
Diagnostics::ToolHelp::{CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS},
ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX},
Threading::{GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION},
},
};
unsafe {
// 1. Todos los procesos con su padre; 2. el árbol que cuelga de este exe.
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
let mut padres: Vec<(u32, u32)> = Vec::with_capacity(512);
let mut e = PROCESSENTRY32W { dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
if Process32FirstW(snap, &mut e).is_ok() {
loop {
padres.push((e.th32ProcessID, e.th32ParentProcessID));
if Process32NextW(snap, &mut e).is_err() {
break;
}
}
}
let _ = CloseHandle(snap);
let mut arbol = vec![std::process::id()];
let mut i = 0;
while i < arbol.len() {
let p = arbol[i];
for &(hijo, padre) in &padres {
if padre == p && hijo != p && !arbol.contains(&hijo) {
arbol.push(hijo);
}
}
i += 1;
}
let mut memoria = 0u64;
let mut cpu_ahora: HashMap<u32, u64> = HashMap::new();
for &pid in &arbol {
let Ok(hp) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { continue };
let mut mc = PROCESS_MEMORY_COUNTERS_EX { cb: std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32, ..Default::default() };
if GetProcessMemoryInfo(hp, &mut mc as *mut _ as *mut PROCESS_MEMORY_COUNTERS, mc.cb).is_ok() {
memoria += mc.PrivateUsage as u64;
}
let (mut c, mut x, mut k, mut u) = (FILETIME::default(), FILETIME::default(), FILETIME::default(), FILETIME::default());
if GetProcessTimes(hp, &mut c, &mut x, &mut k, &mut u).is_ok() {
let t = |f: FILETIME| ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64;
cpu_ahora.insert(pid, t(k) + t(u));
}
let _ = CloseHandle(hp);
}
// CPU: lo gastado desde la muestra anterior, sobre el tiempo de reloj × núcleos (como
// la columna CPU del Administrador de tareas). Un proceso que no estaba en la muestra
// anterior empieza a contar en la siguiente (si no, sumaría toda su vida de golpe).
let ahora = Instant::now();
let cpu = match self.cuando {
Some(antes) => {
let pared = ahora.duration_since(antes).as_secs_f64() * 1e7; // en 100 ns
let nucleos = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) as f64;
let gastado: u64 = cpu_ahora.iter().filter_map(|(p, t)| self.cpu_previa.get(p).map(|antes| t.saturating_sub(*antes))).sum();
(gastado as f64 / (pared * nucleos) * 100.0).clamp(0.0, 100.0)
}
None => 0.0,
};
self.cpu_previa = cpu_ahora;
self.cuando = Some(ahora);
Some(Muestra { procesos: arbol.len() as u32, memoria_mb: memoria / (1024 * 1024), cpu })
}
}
}
#[cfg(not(windows))]
impl Medidor {
fn medir(&mut self) -> Option<Muestra> {
None // macOS: pendiente (proc_pidinfo); el equipo de edición es Windows
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fecha_iso_con_formato_fijo() {
let f = ahora_iso();
assert_eq!(f.len(), 20, "{f}");
assert!(f.starts_with("20") && f.ends_with('Z') && &f[10..11] == "T", "{f}");
}
#[test]
fn el_freno_corta_la_cuarta_recarga() {
let l = "tab-prueba-freno";
assert!(permitir_recarga(l));
assert!(permitir_recarga(l));
assert!(permitir_recarga(l));
assert!(!permitir_recarga(l), "la cuarta en 10 minutos no se permite");
assert!(permitir_recarga("otra-pestana"), "el freno es por pestaña");
}
#[cfg(windows)]
#[test]
fn mide_al_menos_este_proceso() {
// un binario de prueba casi no reserva memoria: se le da 32 MB tocados para que se note
let lastre = vec![1u8; 32 * 1024 * 1024];
let mut m = Medidor::default();
let a = m.medir().expect("primera muestra");
assert!(a.procesos >= 1 && a.memoria_mb > 0, "procesos={} memoria={}", a.procesos, a.memoria_mb);
assert_eq!(a.cpu, 0.0, "sin muestra previa no hay CPU");
std::thread::sleep(Duration::from_millis(200));
let b = m.medir().expect("segunda muestra");
assert!((0.0..=100.0).contains(&b.cpu));
assert!(lastre.iter().all(|&x| x == 1));
}
}

Ver fichero

@@ -279,7 +279,7 @@ fn es_navegador(app: &str) -> bool {
// si hay sonido saliendo AHORA. Un video mudo no se detecta (no suena); es el límite honesto de
// medirlo desde fuera del navegador.
#[cfg(windows)]
fn hay_audio_sonando() -> bool {
pub(crate) fn hay_audio_sonando() -> bool {
use windows::Win32::Media::Audio::Endpoints::IAudioMeterInformation;
use windows::Win32::Media::Audio::{eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
@@ -300,7 +300,7 @@ fn hay_audio_sonando() -> bool {
}
#[cfg(not(windows))]
fn hay_audio_sonando() -> bool {
pub(crate) fn hay_audio_sonando() -> bool {
// macOS: no hay API pública sencilla de nivel de audio; la excepción del video no aplica aquí
// (el equipo de edición es Windows). Follow-up posible con CoreAudio. false = como siempre.
false

Ver fichero

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Labstream OS",
"version": "1.19.0",
"version": "1.22.0",
"identifier": "co.labstream.os",
"build": {
"frontendDist": "../dist"