Comparar commits

..

24 Commits

Autor SHA1 Mensaje Fecha
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
Munka
5797c478bc Reposo: el índice activo se lee antes de recorrer las pestañas 2026-10-03 11:53:53 -05:00
Munka
4d066157cf v1.19.0: pestañas en reposo (Windows)
Cada pestaña es la web app entera, con su conexión del chat y sus sondeos; con seis
abiertas corrían seis. Una pestaña que lleva 5 minutos oculta se suspende con
WebView2 TrySuspend y despierta sola al volver a ella. No se duerme la activa, ni
una con subidas, descargas o audio en curso, ni un editor de documentos.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 11:46:29 -05:00
Munka
5a6f6c918f v1.18.0: F5 recarga, como en cualquier navegador
WebView2 tiene apagados sus propios atajos de navegador, así que F5 no hacía
nada dentro de la app: el único que recargaba era Ctrl+R. Ahora F5 (y
Ctrl+F5, Shift+F5) recarga la pestaña. Va antes del filtro de modificadores
de los atajos, porque F5 no lleva ninguno y ese filtro lo descartaba.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:54:57 -05:00
Jonathan David Florez Salomón
7b0c031a41 v1.17.0: la esquina rota, la profundidad de las pestañas y la activa en negrilla
Tres cambios en la barra, todos de la misma idea: que se vea de un vistazo cuál
manda.

· LA ESQUINA. Se retiran los dos pseudo-elementos de 8px que imitaban la curva
  cóncava de Chrome. El truco funciona allí porque NO hay línea bajo la barra;
  aquí sí la hay, y esas piezas blancas la partían en dos a los costados de la
  pestaña activa. La activa ya se funde con el contenido por su propio fondo.

· LA PROFUNDIDAD. Las inactivas se hunden con un velo tenue y sombra interior, y
  al pasar el ratón el gesto se invierte: la sombra sale y el fondo tira hacia el
  blanco de la activa. Sombra y no relleno sólido — un bloque gris pesa tanto
  como la activa y compite con ella. La activa gana sombra HACIA ARRIBA: abajo
  está fundida con el contenido.

· LA NEGRILLA. El título de la activa a 600. Va en el título y no en la pestaña
  entera para que el aspa de cerrar no engorde con él.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:37:36 -05:00
Jonathan David Florez Salomón
641c9660cc La pestaña activa, en negrilla
El relieve dice dónde está el plano de delante; el peso de la letra es lo que se
lee sin mirar. Va en el TÍTULO y no en la pestaña entera, para que el aspa de
cerrar no engorde con él, y a 600 y no a 700: a 12,5 px el bold entero emborrona,
y lo que hace falta es que destaque, no que grite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:22:28 -05:00
Jonathan David Florez Salomón
73486aa3fe La esquina de la pestaña activa deja de romperse, y las que esperan se hunden
DOS COSAS EN LA BARRA DE PESTAÑAS.

1) LA ESQUINA. La activa llevaba dos pseudo-elementos de 8px a los lados con un
   degradado radial: la curva cóncava de Chrome. El truco funciona en Chrome
   porque allí NO hay línea bajo la barra; aquí sí la hay, y esas dos piezas
   blancas la partían en dos justo a los costados de la pestaña — la fluctuación
   que se veía en la esquina. Se retiran: la activa ya se funde con el contenido
   por su propio fondo, que tapa la línea exactamente por su ancho, y sin las
   piezas no asoma nada.

2) LA PROFUNDIDAD. La inactiva no era nada: texto suelto sobre la franja, al
   mismo plano que la activa, separado por un filete de 1px. Ahora se HUNDE —velo
   muy tenue y sombra interior— y al pasar el ratón el gesto se invierte: la
   sombra sale hacia fuera y el fondo tira hacia el blanco de la activa, así que
   la pestaña sube hacia ti. Es sombra y no relleno sólido a propósito: un bloque
   gris pesa tanto como la activa y acaba compitiendo con ella.

   La activa gana su propia sombra, HACIA ARRIBA: abajo está fundida con el
   contenido y una sombra ahí dibujaría una línea donde no debe haberla.

   Con fondo propio el separador vertical sobra y se retira. `--sep` se queda:
   lo sigue usando el aro del favicon mientras carga.

Sin cambio de versión: queda listo para la próxima que se saque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:20:30 -05:00
Jonathan David Florez Salomón
a37a0bcc0b v1.16.0: la app de macOS por fin se firma de verdad
El build NUNCA firmó la app de macOS. Al no pasarle ninguna identidad, el
bundler de Tauri se saltaba `codesign` entero y el .dmg salía con la firma
ad hoc que deja el enlazador:

  Identifier=labstream_desktop-0a5819ba37119701
  flags=0x20002(adhoc,linker-signed)
  Info.plist=not bound
  Sealed Resources=none

Tres cosas mal, y ninguna avisaba en ningún log:

 · El Entitlements.plist del repo era DECORACIÓN. Los entitlements se
   escriben al firmar; sin firma, el micrófono, la cámara y el JIT nunca
   pidieron nada porque nunca estuvieron pedidos.
 · Ni el Info.plist ni los recursos iban dentro de la firma: se podían
   cambiar sin romperla.
 · El identificador era el del enlazador y no `co.labstream.os`, que es por
   el que macOS recuerda los permisos que le concedes a una app.

AHORA. `APPLE_SIGNING_IDENTITY` se calcula en un paso propio y no en el
`env:` del build, porque un secret que no existe llega como cadena VACÍA y
Tauri lee `APPLE_CERTIFICATE=""` como «hay certificado» y revienta al
importarlo. Solo se exporta lo que tiene valor:

 · con Developer ID → firma real, y notarizado si están las tres claves;
 · sin él → identidad "-", firma AD HOC. No engaña a Gatekeeper, pero sella
   los recursos, ata el Info.plist y APLICA LOS ENTITLEMENTS. En cuanto
   Jonathan añada los secrets, la siguiente etiqueta sale firmada de verdad
   sin tocar un solo archivo.

ENSAYADO sobre una copia de la app instalada, con el mismo comando que va a
correr el CI. Antes y después:

  flags=0x20002(adhoc,linker-signed)  →  flags=0x10002(adhoc,runtime)
  Identifier=labstream_desktop-0a58…  →  Identifier=co.labstream.os
  Info.plist=not bound                →  Info.plist entries=17
  Sealed Resources=none               →  Sealed Resources version=2 rules=13
  (sin entitlements)                  →  allow-jit, audio-input, camera

Y EL ENSAYO ENCONTRÓ UNA TRAMPA que `plutil -lint` no ve: el
Entitlements.plist llevaba `--entitlements` dentro de un comentario XML, y
dos guiones seguidos ahí son XML inválido. plutil lo aceptaba; AMFI, que es
quien lo lee de verdad dentro de codesign, contestaba «Failed to parse
entitlements» y la firma fallaba. Queda avisado en el propio archivo.

De paso se cae `com.apple.security.network.client`: es exclusiva del App
Sandbox y esta app no lo usa, así que no hacía nada.

LA RED QUE FALTABA: un paso nuevo comprueba la firma del .app y TUMBA la
publicación si los entitlements no llegaron. Que esto estuviera roto no lo
dijo nada durante meses; se descubrió a mano persiguiendo otra cosa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:41:13 -05:00
Munka
b4c4875cf6 v1.15.0: las descargas preguntan dónde guardar
Al bajar un archivo desde una pestaña, la app abre el «Guardar como» nativo (rfd directo, modal en el hilo del webview) con el nombre sugerido; cancelar cancela la descarga. Antes caía en silencio en Descargas. El aviso al terminar usa la ruta elegida (en macOS WKDownload no la devuelve).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 17:46:21 -05:00
Labstream2026
261acc0201 Merge pull request #1 from Labstream2026/codex/portadas-mac-autenticadas
Desktop 1.14.1: portadas autenticadas y apertura fiable de Google
2026-09-15 07:39:54 -05:00
Jonathan David Florez Salomón
1bdc7d0bba fix(ci): use a portable Node test path on Windows 2026-09-15 07:27:39 -05:00
Jonathan David Florez Salomón
013d1e7759 fix(desktop): authenticated previews and reliable Google document opening 2026-09-15 07:25:34 -05:00
Munka
a10805c51b v1.14.0: las pestanas se presentan como navegador de escritorio
Google degrada sus apps cuando detecta un WebView embebido: Sheets abria en blanco y la
sesion no terminaba de asentarse aunque la cuenta ya estuviera iniciada. Ahora cada
pestana declara un User-Agent de Chrome de escritorio (por plataforma), que es lo que
hacen los clientes de escritorio que embeben Google.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:12:37 -05:00
Munka
d287b4e647 v1.13.0: los documentos del contador se abren dentro de la app
Bump de version. La novedad esta en el commit anterior: hosts de Google (docs, drive,
sheets, accounts, myaccount) se abren como pestanas propias del shell en vez de mandarse
al navegador del sistema, para que la sesion de Google viva dentro de la app y las hojas
del contador se vean en la pestana Contable sin pedir cookies cada vez.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-14 11:46:02 -05:00
Munka
d847aca9a7 Los documentos del contador (Google) se abren como pestanas de esta app
La app de escritorio es un navegador aparte sin la sesion de Google del usuario. Antes
un enlace de docs.google.com se abria en el navegador del sistema y Google pedia
Permitir cookies dentro de un marco que nadie podia contestar. Ahora hosts de Google
(docs / drive / sheets / accounts / myaccount) se abren como PESTANAS internas del shell,
y el WebView2 guarda la sesion de Google como cualquier otra cookie: se inicia sesion
UNA vez y las hojas del contador se ven y editan en la pestana Contable.

- create_tab acepta URLs con scheme https y host en GOOGLE_HOSTS ademas del origen propio.
- La restauracion de sesion respeta el mismo criterio (una pestana de Google guardada
  reabre como pestana de Google).
- El script inyectado detecta docs.google.com/... y hace newTab en vez de openExternal;
  en paginas de Google solo reporta el titulo (sin tocar clics, atajos ni window.open)
  para no interferir con el editor de Google.

Falta probar el instalador de prueba antes de etiquetar la version.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-14 11:45:40 -05:00
Jonathan David Florez Salomón
d5d24c6cb8 v1.12.0: rastreo sin permiso de Accesibilidad en macOS
Sube la version para publicar el arreglo del sensor (macOS ya no pide el permiso
de Accesibilidad: usa CGEventSourceSecondsSinceLastEventType en vez de leer el
teclado global con device_query) junto con la caché de miniaturas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 08:25:11 -05:00
Jonathan David Florez Salomón
a4c91079d9 fix(tracker): en macOS medir inactividad sin permiso de Accesibilidad
El sensor leia el teclado/mouse global con device_query para saber si hubo
entrada. En macOS eso exige el permiso de Accesibilidad y, como la app va sin
firma estable, macOS nunca lo retiene: el dialogo «... quiere controlar este
ordenador» reaparecia en cada arranque aunque ya se hubiera concedido.

Solo hace falta saber SI hubo entrada, no cual. macOS lo da con
CGEventSourceSecondsSinceLastEventType (segundos desde la ultima entrada HID),
de solo lectura y sin pedir ningun permiso. Windows/Linux siguen con
device_query (ahi no piden permiso), ahora como dependencia solo de esas
plataformas.

Verificado: cargo check OK (4.5s); prueba aislada de la API devuelve los
segundos de inactividad en vivo sin ningun dialogo de permiso.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 07:49:30 -05:00
Jonathan David Florez Salomón
a202a37e75 El cajon de miniaturas: la app deja de re-bajar las fotos cada vez que se abre
El problema: las webviews guardan las imagenes en un cache del SISTEMA, pequeno y
volatil (medidos 22 MB en un Mac del equipo). Un set de 500 fotos pasa de 150 MB
entre cuadricula y visor: no cabe, el sistema lo vacia, y al reabrir la app hay
que volver a bajarlo todo del NAS. Eso es el "se relee cada vez que cierro".

El cajon es un cache PROPIO de la app, en su carpeta interna (app_cache_dir,
invisible en el Finder), con tope de 3 GB y poda por antiguedad — el mismo
criterio que ya usa el NAS con sus miniaturas. Sobrevive a cerrar la app y se
vacia con un boton del menu (que ademas dice cuanto ocupa).

Como funciona: dentro de la app, la miniatura de la cuadricula (thumb=1) se pide
por el esquema lsthumb:// en vez de directo al servidor (lo reescribe el script
de inicio, solo en la app). El handler en Rust la sirve del disco si ya esta, o
la trae del NAS UNA vez, la guarda y la sirve. La clave del cajon NO incluye el
token firmado (que rota cada 24h): se deriva de la identidad estable de la foto,
asi que acierta un mes despues.

Decisiones:
- Solo se reescribe thumb=1 (la de 480px, la que se repite 500 veces). El visor
  grande (thumb=xl) y la anotacion sobre lienzo NO se tocan: cargar la imagen del
  lienzo por otro esquema lo "ensuciaria" y romperia guardar el dibujo.
- Cerrojo anti-proxy: el handler solo trae del origen del servidor y de /api/,
  nunca una URL arbitraria (evita convertir la app en un proxy abierto / SSRF).
- La reescritura solo corre DENTRO de la app (guarda de origen del init script).
  En el navegador normal, y para el cliente, nada cambia.
- El traer va en el pool de hilos de bloqueo, no un hilo por peticion: 500 fotos
  no disparan 500 hilos.

Verificado en Mac: compila y enlaza limpio (tauri 2.11.3), 10 pruebas del motor
del cajon en verde (clave estable sin token, cerrojo anti-proxy, poda LRU,
extraccion de la URL), y la logica de reescritura probada contra casos reales en
node (thumb=xl NO se toca, idempotente, Drive/data: no se tocan).

PENDIENTE de probar con las manos, y por eso esto NO se etiqueta todavia:
- El extremo a extremo (la miniatura se cachea y sobrevive al reinicio) necesita
  entrar con sesion a un set de fotos — no pude por no tener la contrasena.
- No lo lance en vivo: habia una instancia release corriendo que comparte
  estado.json; una segunda instancia abriria otra ventana y pisaria la sesion.
- Windows: el esquema custom desde un origen remoto puede comportarse distinto en
  WebView2 (AllowedOrigins). Hay que probarlo en un Windows real ANTES de etiquetar
  — como ya se hizo con el fix de OnlyOffice (v1.5.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 21:17:07 -05:00
Jonathan Flórez
f892dbbff7 v1.11.0: ícono «labs» + el «+» pegado a la última pestaña
- ÍCONO de la app (Mac + Windows): nuevo app-icon.png (1024²) = «labs» blanco
  centrado sobre negro. Son las MISMAS letras del wordmark «labstream»,
  recortadas (l·a·b·s), no una fuente parecida — así el ícono es fiel al logo.
  NO reemplaza el logo/wordmark de la app; es solo el ícono que se ve en el
  dock/barra de tareas/instalador. La CI regenera todos los tamaños con
  `npm run icons` desde este PNG.
- NUEVA PESTAÑA más a mano: el botón «+» deja de estar fijo a la derecha de la
  barra y pasa a vivir DENTRO de la tira de pestañas, pegado a la última
  (estilo Chrome). Al ser el último elemento flex de #tabs, queda justo tras la
  última pestaña sin importar cuántas haya; el hueco libre después sigue
  moviendo la ventana. Mismo evento de siempre (ls-new-tab); el atajo
  ⌘/Ctrl+T no cambia. Solo se tocó dist/index.html (barra del shell); cero Rust.

Verificado: el «+» renderiza como último hijo de #tabs, pegado a la última
pestaña (medido: 3 pestañas llenando la tira + el «+» justo después). El ícono
se ve nítido a 256 y legible a 48 px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:32:32 -05:00
Jonathan Flórez
d983b8d8b5 v1.10.0: el video en primer plano no cuenta como inactividad
Regla pedida por el usuario: si alguien mira un video en el navegador sin tocar
el mouse, no es que este quieto — esta viendo algo. Ahora el sensor lo cuenta
como tiempo de navegador en vez de inactividad.

- Excepcion del VIDEO (Windows): en el tick, si `ocioso` (3 min sin entrada)
  PERO al frente hay un navegador (chrome/brave/edge/firefox/opera/vivaldi, se
  excluye el host WebView2) Y el sistema esta sacando sonido, es un video
  reproduciendose -> `viendo_video`. Se introduce `ocioso_efectivo = ocioso &&
  !viendo_video`, y TODO (tramo de inactividad, bandeja, acumulacion de la
  ventana) pasa a usar `ocioso_efectivo`. El bloque de navegador se acumula con
  a=0 (segundos sin entrada real): presente, sin teclear. La bandeja lo dice
  («video en primer plano»).
- Deteccion de sonido: medidor de pico de la salida de audio por defecto
  (WASAPI, IAudioMeterInformation via el crate `windows`, solo cfg(windows)).
  No abre el navegador, no lee la pestaña, no graba nada — solo pregunta al SO
  si hay sonido saliendo AHORA (pico > 0.01). Limite honesto documentado: un
  video MUDO no se detecta. macOS: stub que devuelve false (no hay API publica
  sencilla de nivel de audio; el equipo de edicion es Windows) -> comportamiento
  identico al de siempre en Mac.
- La ventana al frente ahora se lee cada tic mientras se mide (antes solo con
  actividad), porque hace falta para decidir la excepcion; se reutiliza esa
  misma lectura para acumular, sin volver a preguntar al SO.
- COM se inicializa una vez al arrancar el hilo del sensor (cfg(windows)).
- El contrato con el servidor NO cambia: se sigue mandando {blocks, idles}, solo
  que ahora hay menos idles y algun bloque de navegador cuando hay video. Nada
  que tocar en labstream-os.

Verificado con `cargo check`: el reensamblado compila limpio en el host (macOS),
y el codigo WASAPI type-checkea contra el target x86_64-pc-windows-msvc (aislado
en un crate minimo, porque `ring` de la TLS de ureq no cross-compila su C a
Windows desde Mac — eso lo hace la CI). Falta la validacion final en una maquina
Windows real (no puedo reproducir audio de Windows aqui): probar que un video en
YouTube con el mouse quieto sale como navegador y no como inactividad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:22 -05:00
Munka
d01d1708c7 v1.9.0: el sensor tambien anota la inactividad
Cada tramo sin ninguna entrada (mas alla del umbral de 3 min, que sigue contando como trabajo
igual que siempre) queda registrado como {desde, segundos} y viaja en el lote como `idles`,
aparte de los bloques. Sin app, sin titulo, sin contenido: la inactividad no tiene nada que
mirar. La bandeja lo dice: «inactivo (se anota aparte)».

Decisiones que importan, salidas de una revision adversaria previa (11 hallazgos, todos
atendidos):

- Tope de 4 h por tramo: una torre que nunca se suspende, bloqueada toda la noche, anotaba
  «14 h inactivo» en el dia anterior. Mas de 4 h sin tocar el equipo es ausencia, no descanso;
  pasado el tope no se reabre nada hasta que vuelva la entrada. El servidor ademas rechaza
  cualquier tramo de mas de 6 h como red de seguridad.
- La suspension corta el tramo (la noche del portatil es una laguna, no un dato) y al
  despertar el reloj queda EN el umbral: sin eso, los despertares automaticos de Windows a las
  3 a. m. contaban hasta 3 min de trabajo fantasma y fabricaban jornadas de madrugada.
- El tramo en curso se persiste a disco (rastreador-encurso.json, un latido por minuto): un
  apagado, el cierre de la app o la auto-actualizacion —que reinicia a todo el equipo a la
  vez— lo cierran en su ultimo latido en vez de perderlo entero.
- La subida se acota a 12 lotes por ciclo y el tiempo de subida no cuenta como suspension:
  al volver la red tras dias sin ella, los cientos de POST seguidos bloqueaban el hilo del
  sensor y el siguiente tic diagnosticaba un «dormido» falso que cortaba el tramo abierto.
- La cola en disco lee el formato viejo (Vec<Vec<Bloque>>) ademas del nuevo: lo pendiente de
  subir en la 1.8 no se pierde al actualizar.
- En pausa no se anota nada, y reanudar no retro-anota la pausa como ocio.

El servidor (labstream-os 453a640) ya acepta `idles` y enseña la columna «Inactivo» en las
jornadas de /rastreo; los sensores 1.8 siguen funcionando sin cambios.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:46:46 -05:00
Se han modificado 18 ficheros con 3575 adiciones y 217 borrados

Ver fichero

@@ -51,6 +51,65 @@ jobs:
- name: Generar iconos (desde app-icon.png)
run: npm run icons
- name: Probar el cliente
run: npm test
# ── FIRMA DE macOS ──────────────────────────────────────────────────────────────────
# Hasta el 2026-09-19 este build NO firmaba la app de macOS: no se le pasaba ninguna
# identidad, así que el bundler de Tauri se saltaba `codesign` entero y el .dmg salía con
# la firma ad hoc que deja el enlazador. Se veía así en la app instalada:
# flags=0x20002(adhoc,linker-signed) · Info.plist=not bound · Sealed Resources=none
# Consecuencias: los entitlements (micrófono, cámara, JIT) NO se aplicaban —el
# Entitlements.plist del repo era decoración—, el Info.plist y los recursos no iban
# sellados, y la app no se podía notarizar de ninguna manera.
#
# `APPLE_SIGNING_IDENTITY` decide qué pasa, y por eso se calcula aquí y no en el `env:`
# del paso de abajo: un secreto que no existe llega como cadena VACÍA, y Tauri
# interpreta `APPLE_CERTIFICATE=""` como «hay certificado» y revienta al importarlo.
# Solo se exporta lo que de verdad tiene valor.
#
# · CON los secretos de Developer ID → firma real + notarizado + grapado.
# · SIN ellos → identidad "-", que es la firma AD HOC: no engaña a Gatekeeper (habrá
# que seguir abriendo con clic derecho), pero SÍ sella los recursos, ata el
# Info.plist y aplica los entitlements. Estrictamente mejor que no firmar.
#
# El hardened runtime no se configura: en Tauri v2 `bundle.macOS.hardenedRuntime` ya
# viene en true, y es lo que hace que los entitlements signifiquen algo.
- name: Preparar la firma de macOS
if: matrix.platform == 'macos-latest'
shell: bash
env:
CERT: ${{ secrets.APPLE_CERTIFICATE }}
CERT_PW: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
NOTARIZA_ID: ${{ secrets.APPLE_ID }}
NOTARIZA_PW: ${{ secrets.APPLE_PASSWORD }}
NOTARIZA_TEAM: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
# Los valores van por heredoc con delimitador: un base64 largo o con saltos rompería
# la forma `CLAVE=valor`. Nada se imprime: los secretos no pasan por el log.
exportar() { printf '%s<<__FIN__\n%s\n__FIN__\n' "$1" "$2" >> "$GITHUB_ENV"; }
if [ -n "${CERT:-}" ] && [ -n "${CERT_PW:-}" ]; then
exportar APPLE_CERTIFICATE "$CERT"
exportar APPLE_CERTIFICATE_PASSWORD "$CERT_PW"
exportar APPLE_SIGNING_IDENTITY "${IDENTITY:-Developer ID Application}"
echo "Firma: Developer ID (certificado del repo)."
else
exportar APPLE_SIGNING_IDENTITY "-"
echo "Firma: AD HOC. Sella recursos y aplica entitlements, pero Gatekeeper seguirá avisando."
echo "Para firma de verdad, añade los secrets APPLE_CERTIFICATE y APPLE_CERTIFICATE_PASSWORD."
fi
# Notarizar solo tiene sentido con Developer ID; las tres claves van juntas o ninguna.
if [ -n "${CERT:-}" ] && [ -n "${NOTARIZA_ID:-}" ] && [ -n "${NOTARIZA_PW:-}" ] && [ -n "${NOTARIZA_TEAM:-}" ]; then
exportar APPLE_ID "$NOTARIZA_ID"
exportar APPLE_PASSWORD "$NOTARIZA_PW"
exportar APPLE_TEAM_ID "$NOTARIZA_TEAM"
echo "Notarizado: activado."
else
echo "Notarizado: omitido (faltan APPLE_ID, APPLE_PASSWORD o APPLE_TEAM_ID, o no hay certificado)."
fi
- name: Construir, firmar y publicar
uses: tauri-apps/tauri-action@v0
env:
@@ -58,11 +117,53 @@ jobs:
# Firma del updater: la clave privada y su contraseña van como secrets del repo.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Las APPLE_* las puso el paso de arriba en $GITHUB_ENV, solo si tenían valor.
with:
# En un tag usa su nombre (v1.0.0); en ejecución manual, deriva vX.Y.Z de la versión.
tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__VERSION__' }}
releaseName: "Labstream OS Desktop __VERSION__"
releaseBody: "Instaladores de escritorio de Labstream OS.\n\n- Windows: el archivo `.exe` (NSIS).\n- macOS: el archivo `.dmg` universal (Intel + Apple Silicon).\n\nActualización automática activada: las apps ya instaladas se actualizan solas al publicar una versión nueva."
releaseDraft: false
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
# ── LA RED QUE FALTABA ──────────────────────────────────────────────────────────────
# Que el build no firmara la app de macOS no lo dijo nada durante meses: el .dmg salía,
# se instalaba y funcionaba. Se descubrió a mano, con `codesign -dv`, persiguiendo otra
# cosa. Este paso hace esa comprobación en cada publicación y TUMBA el build si los
# entitlements no llegaron: es barato y es el único aviso que habría existido.
- name: Comprobar la firma de macOS
if: matrix.platform == 'macos-latest'
shell: bash
run: |
set -euo pipefail
APP=$(find src-tauri/target/universal-apple-darwin/release/bundle/macos -maxdepth 1 -name "*.app" | head -1)
if [ -z "$APP" ]; then echo "::error::No apareció ningún .app donde se esperaba"; exit 1; fi
echo "== $APP =="
codesign -dv --verbose=4 "$APP" 2>&1 || true
echo "== entitlements =="
codesign -d --entitlements - --xml "$APP" 2>/dev/null | plutil -convert xml1 -o - - || true
echo "== integridad del bundle =="
codesign --verify --strict --verbose=2 "$APP"
# Gatekeeper: con firma ad hoc esto SIEMPRE dice «rejected», y es esperado. Se
# informa sin tumbar nada; lo que sí es obligatorio es lo de abajo.
echo "== Gatekeeper (informativo) =="
spctl -a -t exec -vv "$APP" 2>&1 || true
echo "== obligatorio: los entitlements tienen que estar =="
if ! codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q "com.apple.security.cs.allow-jit"; then
echo "::error::La app salió SIN entitlements. Revisa el paso «Preparar la firma de macOS» y bundle.macOS.entitlements en tauri.conf.json."
exit 1
fi
echo "Entitlements aplicados."
activar:
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publicar solo cuando ambos instaladores estan completos
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -p "require('./package.json').version")
gh release edit "v$VERSION" --draft=false --latest

Ver fichero

@@ -44,6 +44,9 @@ jobs:
- name: Instalar dependencias
run: npm install
- name: Probar el script del cliente
run: npm test
# build.rs de Tauri valida que existan los iconos que declara tauri.conf.json.
- name: Generar iconos (desde app-icon.png)
run: npm run icons

176
README.md
Ver fichero

@@ -20,6 +20,16 @@ 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).
- **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
@@ -39,12 +49,72 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
resultado. Es un popup nativo del sistema, igual en Windows y macOS, y vive
solo en el shell (la web app no se toca).
- **Rastreador de trabajo efectivo** (v1.8.0, `src-tauri/src/tracker.rs`): mide
cuánto tiempo se trabaja de verdad y en qué aplicación, y lo publica en
**Reportes → Equipo** del servidor. Cada 5 s mira la ventana al frente y si
- **Pestañas en reposo** (v1.19.0, solo Windows): cada pestaña es la web app entera, con su
conexión del chat y sus sondeos. Una que lleva 5 minutos oculta se **suspende** (WebView2
`TrySuspend`: deja de gastar CPU y el sistema puede recuperar su memoria) y despierta sola,
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
hubo entrada de ratón/teclado; **con 3 minutos sin entrada deja de contar**
(eso cubre también pantalla bloqueada y suspensión). Sube lotes cada 5 min a
`POST /api/tracker`; sin red, la cola espera en disco y reintenta.
- **Desde la 1.9 también ANOTA la inactividad**: cuando se cruza el umbral de
3 min, el tramo quieto queda registrado aparte (solo desde/hasta — sin app,
sin título, sin contenido) y el panel lo enseña junto a cada jornada. La
bandeja lo dice («inactivo (se anota aparte)»). Suspender o apagar el
equipo NO cuenta como inactividad: el tramo se corta ahí — la noche con el
portátil cerrado es una laguna, no un dato (el tramo en curso se guarda a
disco cada minuto, así que un apagado o la auto-actualización lo cierran en
su último latido en vez de perderlo). Y un tramo se cierra solo a las 4 h:
más que eso sin tocar el equipo es ausencia, no descanso frente a la
pantalla. En pausa no se anota nada.
- **Desde la 1.10, la excepción del VIDEO** (solo Windows): si no hay entrada
PERO al frente hay un navegador (Chrome/Brave/Edge/Firefox…) y está saliendo
sonido —un video reproduciéndose—, ese rato NO se anota como inactividad: se
cuenta como tiempo de navegador (con 0 segundos «activos», porque no se
teclea). Se detecta con el medidor de audio del sistema (WASAPI,
`IAudioMeterInformation`): no abre el navegador, no lee la pestaña, no graba
nada — solo pregunta si hay sonido saliendo. Límite honesto: un video **mudo**
no se detecta (no suena). En macOS no aplica (no hay API pública sencilla de
nivel de audio); el equipo de edición es Windows.
- **Qué NO registra, a propósito:** ni qué teclas se pulsan (solo compara una
huella de posición del ratón y cuántas teclas hay presionadas), ni
pantallazos, ni el contenido de nada, ni los procesos de fondo.
@@ -65,6 +135,28 @@ pestañas son webviews hijos `tab-*`. Shell ↔ Rust se hablan por eventos `ls-*
El servidor objetivo se configura en `src-tauri/src/lib.rs` → constante `SERVER_URL`.
### Portadas autenticadas (1.14.1)
Las imagenes conservan su URL HTTPS y se cargan con la sesion del webview, igual
que en el navegador. Ya no se reescriben a `lsthumb://`: el descargador Rust no
comparte las cookies y fallaba con miniaturas sin token o con token vencido.
Tampoco se modifica el DOM de las imagenes durante la hidratacion.
Se usa la cache HTTP con los ETag y Cache-Control del servidor. Una respuesta
304 reutiliza los bytes descargados; no implica fabricar otro fotograma. El
cajon antiguo no se borra, pero no se usa para las nuevas peticiones de imagenes.
No cambia el almacenamiento de originales, la cache HLS de LabTem ni los datos offline.
Google Docs, Sheets, Drive y el inicio de sesion de Google se abren en el navegador
del sistema. Google no admite OAuth en webviews controlados por una app; ademas,
WKWebView no debe anunciarse como Chrome. Se conserva el agente de usuario nativo
y no se borran cookies ni sesiones. Las hojas conectadas siguen disponibles como
tabla dentro del area contable. Al reiniciar se restauran solo pestanas de Labstream.
Pruebas: `npm test`; con Playwright disponible en Node,
`node tests/images.browser.cjs` verifica sesion, 304, carga dinamica y lienzo.
La prueba de navegador usa un servidor local y una cookie ficticia, sin datos reales.
Salidas:
- **Windows** → instalador `.exe` (NSIS).
@@ -188,20 +280,72 @@ Es la forma de mirar un cambio visual —la barra, los colores— sin sacárselo
---
## Avisos de seguridad del sistema (instalación interna sin firmar)
## Firma de macOS
Como los instaladores **no están firmados** con certificado de pago, la primera
vez cada sistema muestra una advertencia. Es normal para apps internas:
### Lo que pasaba hasta la 1.15.0
- **Windows (SmartScreen):** pantalla azul *"Windows protegió su PC"* →
**Más información → Ejecutar de todos modos**.
- **macOS (Gatekeeper):** *"no se puede abrir porque proviene de un desarrollador
no identificado"* → clic derecho sobre la app → **Abrir** → **Abrir**. (Si
insiste, ejecutar una vez: `xattr -cr "/Applications/Labstream OS.app"`.)
El build **no firmaba** la app de macOS. No se le pasaba ninguna identidad, así que
el bundler de Tauri se saltaba `codesign` entero y el `.dmg` salía con la firma ad hoc
que deja el enlazador. En la app instalada se veía así:
Si en el futuro se distribuye a clientes externos, conviene firmar:
*code signing* en Windows (~150–400 USD/año) y un *Apple Developer ID* + notarizado
en Mac (99 USD/año) para quitar estos avisos.
```
$ codesign -dv "/Applications/Labstream OS.app"
Identifier=labstream_desktop-0a5819ba37119701
CodeDirectory ... flags=0x20002(adhoc,linker-signed)
Info.plist=not bound
Sealed Resources=none
TeamIdentifier=not set
```
Tres cosas mal, y ninguna avisaba:
- **`Entitlements.plist` no se aplicaba.** Los permisos de micrófono, cámara y JIT
eran decoración: se escriben al firmar, y no se firmaba.
- **`Info.plist=not bound` y `Sealed Resources=none`.** Ni el Info.plist ni los
recursos iban dentro de la firma: cualquiera podía cambiarlos sin romperla.
- **Identificador de enlazador** (`labstream_desktop-<hash>`) en vez del de la app
(`co.labstream.os`), que es por lo que macOS recuerda los permisos concedidos.
### Lo que hace ahora (desde la 1.16.0)
`build.yml` decide según los secrets que haya en el repo:
| Secrets | Resultado |
| --- | --- |
| ninguno | Firma **ad hoc** (`-s -`) con hardened runtime y los entitlements. Gatekeeper sigue avisando, pero el bundle va sellado y los permisos se aplican. |
| `APPLE_CERTIFICATE` + `APPLE_CERTIFICATE_PASSWORD` | Firma real con **Developer ID**. Gatekeeper deja de avisar en cualquier Mac. |
| …y además `APPLE_ID` + `APPLE_PASSWORD` + `APPLE_TEAM_ID` | Firma real + **notarizado y grapado**. Ni el primer arranque avisa. |
Y hay un paso que **tumba la publicación** si los entitlements no llegaron al `.app`.
Ese paso es la lección: que esto estuviera roto no lo dijo nada durante meses.
### Pasar a Developer ID (99 USD/año)
1. Alta en el Apple Developer Program.
2. En *Certificates* crear uno de tipo **Developer ID Application** y exportarlo del
Llavero como `.p12` con contraseña.
3. Convertirlo a texto: `base64 -i certificado.p12 | pbcopy`.
4. En GitHub → *Settings ▸ Secrets and variables ▸ Actions*, añadir:
- `APPLE_CERTIFICATE` — lo que quedó en el portapapeles.
- `APPLE_CERTIFICATE_PASSWORD` — la contraseña del `.p12`.
- `APPLE_SIGNING_IDENTITY` — opcional; por defecto `Developer ID Application`.
5. Para notarizar, además:
- `APPLE_ID` — el correo de la cuenta de desarrollador.
- `APPLE_PASSWORD` — una **contraseña específica de app** (appleid.apple.com, no la
del Apple ID).
- `APPLE_TEAM_ID` — el identificador de equipo de 10 caracteres.
No hay que tocar ni un archivo: en cuanto los secrets existan, la siguiente etiqueta
sale firmada.
### Mientras siga ad hoc
- **macOS (Gatekeeper):** *"no se puede abrir porque proviene de un desarrollador no
identificado"* → clic derecho sobre la app → **Abrir** → **Abrir**. (Si insiste:
`xattr -cr "/Applications/Labstream OS.app"`.)
- **Windows (SmartScreen):** *"Windows protegió su PC"* → **Más información →
Ejecutar de todos modos**. Windows es aparte: su firma cuesta 150–400 USD/año y no
la toca nada de lo de arriba.
---
@@ -216,10 +360,10 @@ labstream-desktop/
│ ├── tauri.conf.json nombre, versión, iconos, instaladores, entitlements
│ ├── Cargo.toml dependencias Rust + versión
│ ├── Info.plist textos de permiso (micrófono/cámara) en macOS
│ ├── Entitlements.plist entitlements de macOS (audio-input, cámara, red)
│ ├── Entitlements.plist entitlements de macOS (micrófono, cámara, JIT)
│ ├── build.rs
│ ├── capabilities/default.json
│ ├── icons/ se generan desde app-icon.png
│ └── src/{main.rs,lib.rs} lib.rs crea la ventana + SERVER_URL + tray
└── .github/workflows/build.yml build automático de .exe (Windows) y .dmg (macOS)
└── .github/workflows/build.yml build de .exe (Windows) y .dmg (macOS) + firma de macOS
```

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 52 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 66 KiB

148
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
@@ -35,7 +35,16 @@
/* Sobre una franja casi blanca, un hover blanco no se vería: aquí oscurece. */
--hover: rgba(0, 0, 0, 0.06);
--btn-hover: rgba(0, 0, 0, 0.08);
--sep: rgba(0, 0, 0, 0.22); /* separador fino entre pestañas inactivas */
--sep: rgba(0, 0, 0, 0.22); /* aro del favicon mientras carga */
/* PROFUNDIDAD. La inactiva no lleva un relleno gris —un bloque sólido pesa y compite
con la activa— sino un velo casi imperceptible y una sombra INTERIOR: parece estar un
plano por debajo. Al pasar el ratón cambia de signo, la sombra sale hacia fuera y la
pestaña sube: por eso el hover va hacia el BLANCO de la activa y no hacia el gris. */
--tab-atras: rgba(0, 0, 0, 0.035);
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.055);
--tab-hover-bg: rgba(255, 255, 255, 0.85);
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.10);
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.07);
}
/* ── Quién decide el tema ──
La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su
@@ -57,6 +66,13 @@
--hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25);
/* En oscuro el movimiento es el mismo pero con luz: la inactiva se hunde con un velo
blanco muy tenue y una sombra negra dentro; al pasar el ratón sube hacia la luz. */
--tab-atras: rgba(255, 255, 255, 0.035);
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.35);
--tab-hover-bg: rgba(255, 255, 255, 0.10);
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.45);
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.35);
}
}
:root.oscuro {
@@ -69,6 +85,13 @@
--hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25);
/* En oscuro el movimiento es el mismo pero con luz: la inactiva se hunde con un velo
blanco muy tenue y una sombra negra dentro; al pasar el ratón sube hacia la luz. */
--tab-atras: rgba(255, 255, 255, 0.035);
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.35);
--tab-hover-bg: rgba(255, 255, 255, 0.10);
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.45);
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
html, body {
@@ -119,53 +142,46 @@
height: 30px;
margin-bottom: 5px;
padding: 0 6px 0 10px;
border-radius: 9px; /* inactiva: píldora redondeada (hover) */
border-radius: 9px; /* inactiva: píldora redondeada */
position: relative;
overflow: visible; /* deja asomar las curvas de la base de la activa */
overflow: visible; /* las sombras viven fuera de la caja */
transition: background 140ms ease, box-shadow 140ms ease, color 140ms ease;
}
.tab:not(.active):hover { background: var(--hover); }
/* Separador fino a la IZQUIERDA de cada inactiva; se oculta pegado a la activa,
al hover y en la primera (igual que Chrome). */
.tab:not(.active)::before {
content: "";
position: absolute;
left: -1px;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 16px;
background: var(--sep);
/* ── PROFUNDIDAD: las que no mandan están detrás ──────────────────────────────────
Antes la inactiva no era nada: texto suelto sobre la franja, al mismo plano que la
activa, y lo único que las separaba era un filete de 1px. Ahora la inactiva se HUNDE
—velo tenue y sombra interior— y al pasar el ratón el gesto se invierte: la sombra sale
hacia fuera y el fondo tira hacia el blanco de la activa, así que la pestaña sube hacia
ti. Es sombra y no relleno a propósito: un bloque gris sólido pesa tanto como la activa
y acaba compitiendo con ella.
Con fondo propio el separador vertical entre pestañas sobra —ya se ve dónde acaba cada
una—, así que se retiró. */
.tab:not(.active) {
background: var(--tab-atras);
box-shadow: var(--tab-atras-sombra);
}
.tab:not(.active):hover {
background: var(--tab-hover-bg);
box-shadow: var(--tab-hover-sombra);
color: var(--tab-active-fg);
}
.tab:first-child::before,
.tab:hover::before,
.tab.active + .tab::before,
.tab:hover + .tab::before { display: none; }
.tab.active {
background: var(--tab-active-bg);
color: var(--tab-active-fg);
height: 35px;
margin-bottom: 0; /* su base cae 1px SOBRE el borde de la barra y lo tapa */
border-radius: 10px 10px 0 0;
/* Levantada, apenas: la sombra va HACIA ARRIBA porque abajo la pestaña está fundida con
el contenido y una sombra ahí dibujaría una línea donde justamente no debe haberla. */
box-shadow: var(--tab-activa-sombra);
z-index: 1;
}
/* Curvas cóncavas de Chrome en la base de la pestaña activa: dos piezas de 8px con
un cuarto de círculo transparente que "derrama" el fondo hacia los lados. */
.tab.active::before,
.tab.active::after {
content: "";
position: absolute;
bottom: 0;
width: 8px;
height: 8px;
}
.tab.active::before {
left: -8px;
background: radial-gradient(circle at 0 0, transparent 8px, var(--tab-active-bg) 8.5px);
}
.tab.active::after {
right: -8px;
background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px);
}
/* AQUÍ ESTABAN LAS CURVAS CÓNCAVAS DE CHROME: dos piezas de 8px a los lados de la activa
con un cuarto de círculo transparente que derramaba su fondo hacia fuera. El truco de
Chrome funciona porque allí NO hay línea bajo la barra; aquí sí la hay, y esas dos
piezas blancas la partían en dos justo a los costados de la pestaña — la «fluctuación»
de la esquina. La activa ya se funde con el contenido por su propio fondo, que tapa la
línea exactamente por su ancho; sin las piezas no asoma nada. */
/* ── Color de pestaña ────────────────────────────────────────────────────────────
Misma idea que los grupos de Chrome: teñir para agrupar de un vistazo («todo lo de
este cliente, en verde»). El color se elige con clic derecho sobre la pestaña ▸ Color,
@@ -185,8 +201,6 @@
.tab[data-color="naranja"] { --tc: 250, 144, 62; }
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.32); }
/* Con fondo propio el separador vertical sobra: ya se ve dónde acaba cada pestaña. */
.tab[data-color]:not(.active)::before { display: none; }
.tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
@media (prefers-color-scheme: dark) {
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
@@ -194,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. */
@@ -227,6 +252,11 @@
overflow: hidden;
text-overflow: ellipsis;
}
/* La ACTIVA en negrilla: el relieve dice dónde está el plano de delante, pero el peso de
la letra es lo que se lee sin mirar. Va en el título y no en la pestaña entera para que
el aspa de cerrar no engorde con él. 600 y no 700: a este tamaño el bold entero
emborrona, y lo que hace falta es que destaque, no que grite. */
.tab.active .titulo { font-weight: 600; }
.tab .cerrar {
flex: 0 0 auto;
width: 18px;
@@ -264,6 +294,17 @@
height: 26px;
font-size: 15px;
}
/* «+» de nueva pestaña, DENTRO de la tira y pegado a la última pestaña (estilo Chrome).
Hereda de `button` (transparente, redondo, hover); solo fija tamaño, evita estirarse
y se separa un pelín de la última pestaña. Bottom-alineado con las pestañas. */
.nueva-tab {
flex: 0 0 auto;
width: 28px;
height: 28px;
margin: 0 2px 6px 4px;
font-size: 18px;
line-height: 1;
}
#zoom {
flex: 0 0 auto;
display: flex;
@@ -325,8 +366,9 @@
<div id="bar" data-tauri-drag-region>
<button class="icono" id="atras" title="Atrás (⌘/Ctrl+[)">←</button>
<button class="icono" id="adelante" title="Adelante (⌘/Ctrl+])">→</button>
<!-- El botón «+» de nueva pestaña ya NO va aquí fijo: se dibuja DENTRO de #tabs, pegado a
la última pestaña (estilo Chrome), en render(). Ver `.nueva-tab`. -->
<div id="tabs" data-tauri-drag-region></div>
<button class="icono" id="nueva" title="Nueva pestaña (⌘/Ctrl+T)">+</button>
<button class="icono" id="recargar" title="Recargar (⌘/Ctrl+R)">⟳</button>
<div id="zoom">
<button id="z-menos" title="Alejar (⌘/Ctrl+−)">−</button>
@@ -469,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) {
@@ -477,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");
@@ -523,6 +579,15 @@
$tabs.appendChild(el);
}
// El «+» de nueva pestaña, PEGADO a la última (estilo Chrome): al vivir dentro de #tabs
// como último elemento (flex 0 0 auto), queda justo tras la última pestaña en vez de
// fijo a la derecha de la barra. El hueco libre después de él sigue moviendo la ventana.
const mas = document.createElement("button");
mas.className = "nueva-tab";
mas.textContent = "+";
mas.title = "Nueva pestaña (⌘/Ctrl+T)";
mas.addEventListener("click", () => emitir("ls-new-tab", {}));
$tabs.appendChild(mas);
requestAnimationFrame(ajustarCompactas);
const activa = $tabs.querySelector(".tab.active");
@@ -560,7 +625,6 @@
document.getElementById("atras").addEventListener("click", () => emitir("ls-nav", { dir: -1 }));
document.getElementById("adelante").addEventListener("click", () => emitir("ls-nav", { dir: 1 }));
document.getElementById("nueva").addEventListener("click", () => emitir("ls-new-tab", {}));
document.getElementById("recargar").addEventListener("click", () => emitir("ls-reload", {}));
// Menú de OPCIONES de la app (nativo, lo pinta Rust): botón ⋮ o clic derecho en la barra.
document.getElementById("menu").addEventListener("click", () => emitir("ls-menu", {}));

4
package-lock.json generado
Ver fichero

@@ -1,12 +1,12 @@
{
"name": "labstream-desktop",
"version": "1.4.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "labstream-desktop",
"version": "1.4.0",
"version": "1.19.0",
"devDependencies": {
"@tauri-apps/cli": "^2"
}

Ver fichero

@@ -1,9 +1,10 @@
{
"name": "labstream-desktop",
"version": "1.8.0",
"version": "1.21.0",
"private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": {
"test": "node --test tests/images.test.cjs",
"icons": "tauri icon app-icon.png",
"dev": "npm run icons && tauri dev",
"build": "npm run icons && tauri build",

739
src-tauri/Cargo.lock generado

La diferencia del archivo ha sido suprimido porque es demasiado grande Cargar Diff

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.8.0"
version = "1.21.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"
@@ -28,12 +28,59 @@ tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# ── Diálogo «Guardar como» de las descargas (on_download en create_tab, lib.rs) ──
# rfd DIRECTO y no tauri-plugin-dialog: el manejador de descargas de wry exige la respuesta en
# el mismo hilo y de forma síncrona (WebView2 DownloadStarting / WKDownload
# decideDestination, ambos en el hilo principal). El `blocking_save_file()` del plugin monta
# el diálogo ASÍNCRONO (rfd::AsyncFileDialog) y espera en un canal; en macOS ese diálogo se
# presenta como hoja (beginSheetModalForWindow) y necesita que el run loop principal siga
# girando… que es justo el hilo que está bloqueado esperando el canal → cuelgue. El
# `rfd::FileDialog` síncrono corre un bucle modal anidado en el propio hilo
# (IFileDialog::Show / NSSavePanel.runModal), que es lo que hace falta aquí.
# Mismas features que usa el plugin oficial (common-controls-v6 = estilo moderno en Windows).
rfd = { version = "0.17", default-features = false, features = ["common-controls-v6"] }
# Para decodificar el último segmento de la URL cuando no hay nombre sugerido (macOS).
percent-encoding = "2"
# ── Rastreador de trabajo efectivo (src/tracker.rs) ──
# active-win-pos-rs: la ventana al frente (app + título) en Win/Mac.
# device_query: ¿hubo entrada? (huella de mouse/teclas presionadas; NUNCA qué se tecleó).
# ureq: subir los lotes a /api/tracker (bloqueante, corre en su propio hilo).
# gethostname: el nombre del equipo, que bautiza al device en el servidor.
active-win-pos-rs = "0.11"
device_query = "4"
ureq = { version = "2", features = ["json"] }
gethostname = "0.5"
# 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
# Accesibilidad y el diálogo «… quiere controlar este ordenador»— y se sustituye por
# CGEventSourceSecondsSinceLastEventType (idle_seconds_macos en tracker.rs), que no pide permiso.
[target.'cfg(not(target_os = "macos"))'.dependencies]
device_query = "4"
# ── Excepción del video (Windows) ──
# Para saber si «hay un video reproduciéndose» sin espiar el navegador: se mira el MEDIDOR de
# la salida de audio por defecto (WASAPI, IAudioMeterInformation). Si al frente hay un navegador
# y está saliendo sonido, es un video sonando → cuenta como navegador, no como inactividad.
# 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]
# 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 = [
"Win32_Foundation",
"Win32_System_Com",
# IMMDevice::Activate exige AMBAS (su firma lleva un PROPVARIANT): sin ellas el método
# queda cfg'd out y «no existe». Verificado con cargo check --target windows-msvc.
"Win32_System_Com_StructuredStorage",
"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",
] }

Ver fichero

@@ -1,17 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
ENTITLEMENTS DE macOS.
OJO AL ESCRIBIR AQUÍ: nada de dos guiones seguidos dentro de un comentario. XML los prohíbe,
y el que lee este archivo NO es plutil (que los deja pasar) sino AMFI, dentro de codesign,
que se planta con «Failed to parse entitlements». Por eso abajo las opciones de codesign se
nombran sin sus guiones.
Estas claves solo sirven de algo si la app se FIRMA pasándole este archivo y con el hardened
runtime puesto. Hasta el 2026-09-19 no era el caso: el build no firmaba nada y la app salía
con la firma ad hoc del enlazador (flags=0x20002 adhoc linker-signed, Info.plist sin atar,
recursos sin sellar). O sea, este archivo era decoración. Lo que lo arregla está en
.github/workflows/build.yml.
Son entitlements del HARDENED RUNTIME, no del App Sandbox (esta app no usa sandbox). El
hardened runtime cierra por defecto el micrófono, la cámara y la memoria ejecutable; cada
clave vuelve a abrir una, y sin ella la función no falla con un error claro: simplemente no
hay permiso.
Se cayó com.apple.security.network.client: esa es exclusiva del App Sandbox y, sin
com.apple.security.app-sandbox, no hace absolutamente nada. Si algún día se mete la app en el
sandbox, vuelve, junto con las de acceso a archivos que harán falta.
-->
<plist version="1.0">
<dict>
<!-- Micrófono: notas de voz del chat (getUserMedia en el webview). -->
<!-- Micrófono: notas de voz del chat (getUserMedia en el webview). El texto que ve la
persona al conceder el permiso vive en Info.plist (NSMicrophoneUsageDescription). -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- Cámara: adjuntar foto/video en el chat. -->
<!-- Cámara: adjuntar foto y video en el chat (NSCameraUsageDescription). -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- Cliente de red (conecta con os.labstreamsas.com). -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Requerido por WKWebView (JIT de JavaScript) bajo hardened runtime. -->
<!-- WKWebView compila JavaScript en caliente. Bajo hardened runtime eso es memoria
ejecutable y hay que pedirlo explícitamente. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>

339
src-tauri/src/cache.rs Archivo normal
Ver fichero

@@ -0,0 +1,339 @@
// ── El «cajón» de miniaturas de la app ──
//
// El problema que resuelve: las webviews guardan las imágenes en un caché del SISTEMA que es
// pequeño y volátil — el sistema lo vacía cuando necesita espacio. En un Mac del equipo se
// midieron 22 MB; un solo set de 500 fotos, entre cuadrícula y visor, pasa de 150 MB. No cabe,
// se borra solo, y al reabrir la app hay que volver a bajarlo todo del NAS. Eso es lo que se
// siente como «se relee cada vez que cierro».
//
// El cajón es un caché PROPIO de la app, en su carpeta interna (`app_cache_dir`, invisible en el
// Finder), con un tope que decidimos nosotros y una poda por antigüedad — igual que la que el NAS
// ya hace con sus miniaturas. Sobrevive a cerrar la app y se vacía con un botón.
//
// Cómo se usa: la web app, DENTRO de la app de escritorio, pide las miniaturas por el esquema
// `lsthumb://` en vez de directamente al servidor (lo reescribe el script de inicio). Este módulo
// es quien contesta ese esquema: si la miniatura está en el cajón la sirve del disco sin tocar la
// red; si no, la trae del NAS una vez, la guarda y la sirve.
//
// La clave del cajón NO incluye el token firmado de la URL (que rota cada 24 h): se deriva de la
// identidad estable de la miniatura (la ruta + su variante), así que la misma foto acierta en el
// cajón un mes después. El token solo se usa para ir a buscarla al NAS cuando falta.
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, SystemTime};
use tauri::{AppHandle, Manager, Runtime};
// El único servidor del que este cajón acepta traer bytes. Sin este cerrojo, una URL manipulada
// convertiría la app en un proxy abierto que trae cualquier cosa de internet (un SSRF). Se compara
// por ORIGEN exacto (esquema + host + puerto), no por «empieza por».
const SERVER: &str = "https://os.labstreamsas.com";
// Tope del cajón: 3 GB, una sesión grande entera de miniaturas. Al pasarse, se borra lo más viejo
// (por último uso) hasta bajar del tope. Es el mismo criterio y tamaño que el caché del NAS.
const CAP_BYTES: u64 = 3 * 1024 * 1024 * 1024;
// Recorrer el directorio para podar es caro: no se hace en cada escritura, sino cada N.
const PODA_CADA: u32 = 200;
static DESDE_PODA: AtomicU32 = AtomicU32::new(0);
// Subcarpeta del caché de la app donde vive el cajón.
const DIR: &str = "miniaturas";
// ── Piezas PURAS (probadas abajo) ─────────────────────────────────────────────
// ¿Se puede traer esta URL? Solo el origen del servidor, y solo rutas de su API. Cualquier otra
// cosa se rechaza: el cajón no es un proxy de propósito general.
pub fn permitido(url: &str) -> bool {
let (Ok(u), Ok(srv)) = (tauri::Url::parse(url), tauri::Url::parse(SERVER)) else {
return false;
};
u.origin() == srv.origin() && u.path().starts_with("/api/")
}
// La clave estable de una miniatura: su ruta + los parámetros que la IDENTIFICAN, ordenados, y
// SIN el token `t` (que rota). Se resume con FNV-1a a un nombre corto y seguro para el sistema de
// archivos. Que no lleve el token es lo que hace que el cajón siga acertando cuando el token de la
// página cambió: la foto es la misma.
pub fn clave_de(url: &str) -> Option<String> {
let u = tauri::Url::parse(url).ok()?;
let mut params: Vec<(String, String)> = u
.query_pairs()
.filter(|(k, _)| k != "t") // el token NO forma parte de la identidad
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
params.sort();
let mut base = u.path().to_string();
for (k, v) in &params {
base.push('|');
base.push_str(k);
base.push('=');
base.push_str(v);
}
Some(fnv1a_hex(&base))
}
// Saca el parámetro `u` (la URL real del servidor, percent-encoded) de la URI del esquema. Funciona
// sea cual sea la forma que la plataforma le dé al esquema — `lsthumb://c/?u=…` en macOS,
// `http://lsthumb.localhost/…?u=…` en Windows —: en ambas `u` viaja como parámetro de consulta y
// query_pairs lo decodifica.
pub fn url_pedida(uri: &str) -> Option<String> {
tauri::Url::parse(uri)
.ok()?
.query_pairs()
.find(|(k, _)| k == "u")
.map(|(_, v)| v.into_owned())
}
fn fnv1a_hex(s: &str) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
format!("{h:016x}")
}
// Un archivo del cajón, para decidir la poda.
#[derive(Clone)]
struct Entrada {
ruta: PathBuf,
bytes: u64,
usado: SystemTime,
}
// La POLÍTICA de poda, aislada del disco para poder probarla: dado el contenido del cajón y el
// tope, devuelve qué archivos borrar — los MÁS VIEJOS por último uso, hasta que el total baje del
// tope. Si ya cabe, no borra nada.
fn plan_de_poda(mut items: Vec<Entrada>, cap: u64) -> Vec<PathBuf> {
let total: u64 = items.iter().map(|e| e.bytes).sum();
if total <= cap {
return Vec::new();
}
// Más viejo primero (el primero en salir).
items.sort_by_key(|e| e.usado);
let mut sobra = total - cap;
let mut a_borrar = Vec::new();
for e in items {
if sobra == 0 {
break;
}
a_borrar.push(e.ruta);
sobra = sobra.saturating_sub(e.bytes);
}
a_borrar
}
// ── Disco ──────────────────────────────────────────────────────────────────────
fn dir<R: Runtime>(app: &AppHandle<R>) -> Option<PathBuf> {
let base = app.path().app_cache_dir().ok()?;
Some(base.join(DIR))
}
// Lee una miniatura del cajón. `None` = no está (o no se pudo leer): el que llama irá al NAS.
pub fn leer<R: Runtime>(app: &AppHandle<R>, clave: &str) -> Option<Vec<u8>> {
let ruta = dir(app)?.join(clave);
fs::read(&ruta).ok()
}
// Guarda una miniatura en el cajón (escritura atómica: se escribe a un temporal y se renombra, así
// una lectura nunca ve un archivo a medias). Cada cierto número de escrituras, poda.
pub fn escribir<R: Runtime>(app: &AppHandle<R>, clave: &str, bytes: &[u8]) {
let Some(d) = dir(app) else { return };
if fs::create_dir_all(&d).is_err() {
return;
}
let destino = d.join(clave);
let tmp = d.join(format!("{clave}.tmp"));
if fs::write(&tmp, bytes).is_ok() {
let _ = fs::rename(&tmp, &destino);
}
if DESDE_PODA.fetch_add(1, Ordering::Relaxed) + 1 >= PODA_CADA {
DESDE_PODA.store(0, Ordering::Relaxed);
podar(&d);
}
}
fn podar(d: &Path) {
let Ok(rd) = fs::read_dir(d) else { return };
let mut items = Vec::new();
for e in rd.flatten() {
let Ok(meta) = e.metadata() else { continue };
if !meta.is_file() {
continue;
}
let nombre = e.file_name();
if nombre.to_string_lossy().ends_with(".tmp") {
continue; // un temporal a medio escribir no se poda ni se cuenta
}
// Último uso: la fecha de acceso si el sistema la lleva, si no la de modificación.
let usado = meta.accessed().or_else(|_| meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
items.push(Entrada { ruta: e.path(), bytes: meta.len(), usado });
}
for ruta in plan_de_poda(items, CAP_BYTES) {
let _ = fs::remove_file(ruta);
}
}
// Cuánto ocupa el cajón, en bytes (para la etiqueta «Vaciar caché (NN MB)» del menú).
pub fn tamano<R: Runtime>(app: &AppHandle<R>) -> u64 {
let Some(d) = dir(app) else { return 0 };
let Ok(rd) = fs::read_dir(&d) else { return 0 };
rd.flatten()
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
}
// Vacía el cajón entero. No pierde nada importante: se vuelve a llenar solo con el uso.
pub fn vaciar<R: Runtime>(app: &AppHandle<R>) {
if let Some(d) = dir(app) {
let _ = fs::remove_dir_all(&d);
let _ = fs::create_dir_all(&d);
}
}
// ── Traer del NAS (solo cuando falta en el cajón) ───────────────────────────────
// Tope de lo que se acepta bajar por miniatura. Una miniatura webp pesa cientos de KB; 64 MB es un
// techo absurdo a propósito, solo para que una respuesta descomunal no llene la memoria.
const MAX_DESCARGA: u64 = 64 * 1024 * 1024;
// Trae los bytes de una miniatura del servidor. Devuelve `(bytes, es_webp)`: solo se GUARDAN en el
// cajón las que son webp (lo único que este esquema pide); una respuesta de otro tipo se sirve tal
// cual pero no se cachea. `Err(codigo)` = no se pudo traer (para que la imagen quede rota como
// quedaría sin la app, no en blanco).
pub fn traer(url: &str) -> Result<(Vec<u8>, bool), u16> {
let resp = match ureq::get(url).timeout(Duration::from_secs(30)).call() {
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => return Err(code),
Err(_) => return Err(502), // sin red o error de transporte
};
let es_webp = resp
.header("Content-Type")
.map(|c| c.starts_with("image/webp"))
.unwrap_or(false);
let mut bytes = Vec::new();
resp.into_reader()
.take(MAX_DESCARGA)
.read_to_end(&mut bytes)
.map_err(|_| 502u16)?;
Ok((bytes, es_webp))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn url_pedida_saca_y_decodifica_la_url_real() {
let real = "https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1";
let uri = format!("lsthumb://c/?u={}", urlencoding_min(real));
assert_eq!(url_pedida(&uri).as_deref(), Some(real));
// Y también la forma que podría tomar en Windows.
let uri_win = format!("http://lsthumb.localhost/?u={}", urlencoding_min(real));
assert_eq!(url_pedida(&uri_win).as_deref(), Some(real));
}
#[test]
fn url_pedida_sin_u_es_none() {
assert_eq!(url_pedida("lsthumb://c/?otra=cosa"), None);
}
// Percent-encoding mínimo para las pruebas (: / ? & =), suficiente para estos casos.
fn urlencoding_min(s: &str) -> String {
s.chars()
.map(|c| match c {
':' => "%3A".into(),
'/' => "%2F".into(),
'?' => "%3F".into(),
'&' => "%26".into(),
'=' => "%3D".into(),
c => c.to_string(),
})
.collect()
}
#[test]
fn permitido_solo_el_servidor_y_su_api() {
assert!(permitido("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1"));
// Otro host, aunque diga labstream: NO.
assert!(!permitido("https://malo.labstreamsas.com.attacker.net/api/x"));
// El servidor pero fuera de /api/: NO (nada de rutas arbitrarias).
assert!(!permitido("https://os.labstreamsas.com/etc/passwd"));
// http en vez de https: distinto origen, NO.
assert!(!permitido("http://os.labstreamsas.com/api/x"));
// Basura: NO.
assert!(!permitido("no-soy-una-url"));
}
#[test]
fn la_clave_ignora_el_token_que_rota() {
// La MISMA miniatura con dos tokens distintos (mañana, pasado) → la misma clave.
let hoy = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1000.aaa&thumb=1");
let man = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=2000.bbb&thumb=1");
assert!(hoy.is_some());
assert_eq!(hoy, man, "el token no debe formar parte de la clave");
}
#[test]
fn la_clave_distingue_foto_y_variante() {
let mini = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1").unwrap();
let xl = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=xl").unwrap();
let otra = clave_de("https://os.labstreamsas.com/api/files-asset/def?t=1.x&thumb=1").unwrap();
assert_ne!(mini, xl, "la miniatura y el visor son distintos");
assert_ne!(mini, otra, "dos fotos distintas son claves distintas");
}
#[test]
fn la_clave_no_depende_del_orden_de_los_parametros() {
let a = clave_de("https://os.labstreamsas.com/api/files-asset/abc?thumb=1&t=1.x").unwrap();
let b = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1").unwrap();
assert_eq!(a, b);
}
#[test]
fn la_clave_es_segura_para_el_sistema_de_archivos() {
let k = clave_de("https://os.labstreamsas.com/api/files-asset/abc?thumb=1").unwrap();
assert!(k.chars().all(|c| c.is_ascii_hexdigit()), "clave = {k}");
}
fn e(ruta: &str, bytes: u64, hace_segundos: u64) -> Entrada {
Entrada {
ruta: PathBuf::from(ruta),
bytes,
usado: SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000 - hace_segundos),
}
}
#[test]
fn poda_no_borra_nada_si_cabe() {
let items = vec![e("a", 100, 10), e("b", 100, 5)];
assert!(plan_de_poda(items, 1000).is_empty());
}
#[test]
fn poda_borra_lo_mas_viejo_primero_hasta_bajar_del_tope() {
// Total 900, tope 500 → sobran 400. Debe borrar los más viejos hasta cubrir 400.
let items = vec![
e("nueva", 300, 1), // la más reciente
e("media", 300, 100),
e("vieja", 300, 1000), // la más antigua
];
let borra = plan_de_poda(items, 500);
// Vieja (300) no basta (faltan 100) → también media. Nueva se salva.
assert_eq!(borra, vec![PathBuf::from("vieja"), PathBuf::from("media")]);
}
#[test]
fn poda_justo_en_el_tope_no_borra() {
let items = vec![e("a", 250, 10), e("b", 250, 5)];
assert!(plan_de_poda(items, 500).is_empty(), "500==500 cabe");
}
}

La diferencia del archivo ha sido suprimido porque es demasiado grande Cargar Diff

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

@@ -32,6 +32,15 @@ const TICK_SEG: u64 = 5;
const OCIO_PROFUNDO_SEG: u64 = 180; // 3 min sin entrada → deja de contar
const LOTE_SEG: u64 = 300; // subir cada 5 min
const MAX_COLA_LOTES: usize = 2000; // ~1 semana sin red; después se pierde lo más viejo
// Un tramo de ocio se cierra SOLO a las 4 h: más que eso sin tocar el equipo no es un descanso
// frente a la pantalla, es ausencia — y sin este tope, una torre que nunca se suspende anotaría
// «14 h inactivo» por una noche con la pantalla bloqueada. Pasado el tope no se reabre nada
// hasta que vuelva la entrada.
const OCIO_MAX_TRAMO_SEG: u64 = 4 * 3600;
// Lotes por intento de subida. Sin tope, al volver la red tras días sin ella los cientos de
// POST seguidos bloquean el hilo del sensor más de un minuto y el siguiente tic diagnostica
// una «suspensión» que nunca ocurrió. La cola se drena en varios ciclos de 5 min.
const MAX_LOTES_POR_ENVIO: usize = 12;
const PAUSA_MANUAL: u64 = u64::MAX;
#[derive(Serialize, Deserialize, Default, Clone)]
@@ -51,10 +60,36 @@ struct Bloque {
t: String, // título de ventana (truncado; puede ir vacío)
}
// Un tramo de INACTIVIDAD: desde que se cruzó el umbral de 3 min sin entrada hasta que volvió
// a haberla. Los primeros 3 min siguen contando como trabajo (igual que siempre); esto anota
// lo de ahí en adelante. Sin app ni título a propósito: la inactividad no tiene contenido.
#[derive(Serialize, Deserialize, Clone)]
struct Ocio {
s: u64, // inicio en ms unix
d: u32, // segundos quieto (más allá del umbral)
}
// Un lote: bloques de trabajo + tramos de ocio del mismo intervalo.
#[derive(Serialize, Deserialize, Clone, Default)]
struct Lote {
b: Vec<Bloque>,
#[serde(default)]
i: Vec<Ocio>,
}
// La cola en disco de la 1.8 era `Vec<Vec<Bloque>>` (solo bloques). Este enum lee AMBOS
// formatos: al actualizar, lo que quedó pendiente de subir no se pierde.
#[derive(Deserialize)]
#[serde(untagged)]
enum LoteEnDisco {
Nuevo(Lote),
Viejo(Vec<Bloque>),
}
// Estado compartido sensor ↔ menú. Los items de menú viven aparte (son genéricos en R).
struct Nucleo {
cfg: Config,
cola: Vec<Vec<Bloque>>, // lotes pendientes de subir
cola: Vec<Lote>, // lotes pendientes de subir
}
struct NucleoState(Mutex<Nucleo>);
@@ -67,6 +102,22 @@ struct MenuState<R: Runtime>(Mutex<MenuUi<R>>);
fn ahora_seg() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
// Segundos desde la última entrada del usuario (teclado/mouse/trackpad), preguntados al
// subsistema HID de macOS. CGEventSourceSecondsSinceLastEventType es de solo LECTURA y NO
// exige permiso de Accesibilidad ni de Monitorización de entrada (lo usan el salvapantallas y
// `ioreg HIDIdleTime`). Con esto el sensor sabe si hubo actividad SIN el diálogo «… quiere
// controlar este ordenador» que salía al leer el teclado global con device_query.
#[cfg(target_os = "macos")]
fn idle_seconds_macos() -> f64 {
const HID_SYSTEM_STATE: i32 = 1; // kCGEventSourceStateHIDSystemState
const ANY_INPUT_EVENT: u32 = 0xFFFF_FFFF; // kCGAnyInputEventType
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGEventSourceSecondsSinceLastEventType(state_id: i32, event_type: u32) -> f64;
}
unsafe { CGEventSourceSecondsSinceLastEventType(HID_SYSTEM_STATE, ANY_INPUT_EVENT) }
}
fn ahora_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
@@ -77,6 +128,25 @@ fn ruta_cfg<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
fn ruta_cola<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
app.path().app_config_dir().ok().map(|d| d.join("rastreador-cola.json"))
}
// El tramo de ocio EN CURSO, persistido para sobrevivir a apagados y a la auto-actualización
// (que reinicia la app de todo el equipo a la vez). La suspensión no lo necesita —el mismo
// proceso despierta y lo cierra—, pero si el proceso MUERE con un tramo abierto, sin esto el
// descanso se perdía entero. Contiene {t: último tic (seg), o: inicio del tramo (ms)}.
fn ruta_encurso<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
app.path().app_config_dir().ok().map(|d| d.join("rastreador-encurso.json"))
}
fn guardar_encurso<R: Runtime>(app: &AppHandle<R>, tic: u64, inicio_ms: u64) {
let Some(p) = ruta_encurso(app) else { return };
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(p, format!("{{\"t\":{tic},\"o\":{inicio_ms}}}"));
}
fn borrar_encurso<R: Runtime>(app: &AppHandle<R>) {
if let Some(p) = ruta_encurso(app) {
let _ = std::fs::remove_file(p);
}
}
fn cargar_cfg<R: Runtime>(app: &AppHandle<R>) -> Config {
ruta_cfg(app)
@@ -93,13 +163,20 @@ fn guardar_cfg<R: Runtime>(app: &AppHandle<R>, cfg: &Config) {
let _ = std::fs::write(p, txt);
}
}
fn cargar_cola<R: Runtime>(app: &AppHandle<R>) -> Vec<Vec<Bloque>> {
ruta_cola(app)
fn cargar_cola<R: Runtime>(app: &AppHandle<R>) -> Vec<Lote> {
let crudo: Vec<LoteEnDisco> = ruta_cola(app)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
.unwrap_or_default();
crudo
.into_iter()
.map(|l| match l {
LoteEnDisco::Nuevo(x) => x,
LoteEnDisco::Viejo(b) => Lote { b, i: Vec::new() },
})
.collect()
}
fn guardar_cola<R: Runtime>(app: &AppHandle<R>, cola: &[Vec<Bloque>]) {
fn guardar_cola<R: Runtime>(app: &AppHandle<R>, cola: &[Lote]) {
let Some(p) = ruta_cola(app) else { return };
if cola.is_empty() {
let _ = std::fs::remove_file(p);
@@ -183,6 +260,52 @@ fn normaliza(s: &str, max: usize) -> String {
limpio.chars().take(max).collect()
}
// ── Excepción del video ──
// ¿La ventana al frente es un NAVEGADOR? El servidor ya clasifica el sitio por el título; aquí
// solo hace falta saber que es un navegador para aplicar la excepción del video. Se excluye el
// host WebView2 (el motor de Edge embebido que usa ESTA misma app y otras de escritorio): no es
// navegación de nadie, igual que en el servidor.
fn es_navegador(app: &str) -> bool {
let a = app.to_lowercase();
if a.contains("webview") {
return false;
}
["chrome", "brave", "edge", "firefox", "opera", "vivaldi"].iter().any(|n| a.contains(n))
}
// ¿Está saliendo SONIDO por la salida de audio por defecto? Es el proxy de «hay un video
// reproduciéndose»: se lee el medidor de pico de WASAPI (IAudioMeterInformation) del dispositivo
// de salida. No abre el navegador, no lee la pestaña, no graba nada — solo pregunta al sistema
// 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)]
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};
unsafe {
let Ok(enumerador) = CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) else {
return false;
};
// eMultimedia = el endpoint que usan música y video (no el de comunicaciones).
let Ok(dispositivo) = enumerador.GetDefaultAudioEndpoint(eRender, eMultimedia) else {
return false; // sin dispositivo de salida (p. ej. sin tarjeta/monitor de audio)
};
let Ok(medidor) = dispositivo.Activate::<IAudioMeterInformation>(CLSCTX_ALL, None) else {
return false;
};
// Umbral pequeño para no dispararse con el ruido de fondo del silencio digital.
matches!(medidor.GetPeakValue(), Ok(pico) if pico > 0.01)
}
}
#[cfg(not(windows))]
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
}
// ── Envío ──
// Sube la cola entera; deja en la cola lo que no pudo. Un 401 = token revocado → se descarta
// el token (la bandeja pasa a «sin vincular») y la cola se vacía: ya nadie la quiere.
@@ -194,7 +317,9 @@ fn enviar<R: Runtime>(app: &AppHandle<R>) {
if n.cola.is_empty() {
return;
}
(t, n.cola.clone())
// Con tope por llamada: la cola grande (días sin red) se drena a lo largo de varios
// ciclos en vez de bloquear el hilo del sensor con cientos de POST seguidos.
(t, n.cola.iter().take(MAX_LOTES_POR_ENVIO).cloned().collect::<Vec<Lote>>())
};
let hostname = gethostname::gethostname().to_string_lossy().to_string();
let version = app.package_info().version.to_string();
@@ -206,7 +331,8 @@ fn enviar<R: Runtime>(app: &AppHandle<R>) {
.timeout(Duration::from_secs(20))
.send_json(json!({
"device": { "hostname": hostname, "platform": std::env::consts::OS, "version": version },
"blocks": lote,
"blocks": lote.b,
"idles": lote.i,
}));
match r {
Ok(_) => enviados += 1,
@@ -308,12 +434,59 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
// El sensor: un hilo con tic de 5 s. Nada de esto toca la interfaz salvo el menú del tray.
let h = app.clone();
thread::spawn(move || {
// COM para este hilo: lo necesita la lectura del medidor de audio (WASAPI) de la
// excepción del video. Se hace UNA vez al arrancar el hilo; si ya estaba inicializado,
// devuelve S_FALSE y no pasa nada. En macOS no existe y se omite.
#[cfg(windows)]
unsafe {
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
}
#[cfg(not(target_os = "macos"))]
let device_state = device_query::DeviceState::new();
let mut ultimo_input = ahora_seg();
#[cfg(not(target_os = "macos"))]
let mut huella_anterior: (i32, i32, usize) = (0, 0, 0);
let mut acumulado: HashMap<(String, String), Bloque> = HashMap::new();
let mut ocios_pendientes: Vec<Ocio> = Vec::new();
// Inicio (ms) del tramo de inactividad en curso, si lo hay.
let mut ocio_abierto: Option<u64> = None;
let mut ocioso_antes = false;
let mut tic_previo = ahora_seg();
let mut ultimo_envio = ahora_seg();
let mut ultimo_estado = String::new();
let mut encurso_guardado: u64 = 0;
// ¿La sesión anterior murió con un tramo de ocio abierto (apagado, cierre de la app,
// auto-actualización)? Se cierra en su último latido y se encola: el descanso fue
// real y medido — perderlo entero hacía mentir al README.
{
#[derive(Deserialize)]
struct EnCurso {
t: u64,
o: u64,
}
let previo: Option<EnCurso> = ruta_encurso(&h)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok());
if let Some(e) = previo {
let fin = (e.t + TICK_SEG) * 1000;
if fin > e.o {
let d = ((fin - e.o) / 1000).min(OCIO_MAX_TRAMO_SEG) as u32;
if d >= 60 {
let nucleo = h.state::<NucleoState>();
let cola = {
let mut n = nucleo.0.lock().unwrap();
n.cola.push(Lote { b: Vec::new(), i: vec![Ocio { s: e.o, d }] });
n.cola.clone()
};
guardar_cola(&h, &cola);
}
}
}
borrar_encurso(&h);
}
loop {
thread::sleep(Duration::from_secs(TICK_SEG));
@@ -325,25 +498,148 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
(n.cfg.token.is_some(), esta_pausado(&n.cfg))
};
// ¿Hubo entrada desde el último tic? Solo se compara una huella (posición del
// mouse + cuántas teclas hay presionadas): nunca se registra QUÉ se tecleó.
// ¿Se SUSPENDIÓ el equipo? Sin tics durante mucho más que un tic = la máquina
// durmió (suspensión, hibernación, tapa cerrada). La noche del portátil NO es
// inactividad: el tramo abierto se CIERRA en el último tic visto y el reloj de
// entrada arranca de cero al despertar — si no, el sensor anotaría «12 h quieto»
// por una noche apagado, y al despertar abriría tramos que empiezan ayer.
let dormido = ahora.saturating_sub(tic_previo) > TICK_SEG * 12; // > 1 min sin tics
if dormido {
if let Some(ini) = ocio_abierto.take() {
let fin = (tic_previo + TICK_SEG) * 1000;
if fin > ini {
ocios_pendientes.push(Ocio { s: ini, d: ((fin - ini) / 1000) as u32 });
}
borrar_encurso(&h);
}
// Al despertar, el reloj de entrada queda EXACTAMENTE en el umbral: seguimos
// ociosos hasta que alguien toque algo de verdad. Dejarlo en `ahora` parecía
// más natural, pero hacía que los 3 min siguientes contaran como TRABAJO sin
// una sola entrada — y los despertares automáticos de Windows (mantenimiento,
// wake timers a las 3 a. m.) fabricaban jornadas fantasma de madrugada.
ultimo_input = ahora.saturating_sub(OCIO_PROFUNDO_SEG);
ocioso_antes = false;
}
tic_previo = ahora;
// ¿Hubo entrada desde el último tic? Solo interesa el HECHO de que hubo entrada,
// nunca QUÉ se tecleó ni dónde.
//
// macOS: se pregunta al sistema cuántos segundos lleva SIN entrada (HID). Esa API
// NO exige permiso de Accesibilidad ni de Monitorización de entrada, así que ya no
// aparece el diálogo «… quiere controlar este ordenador». Inactividad < 1 tic =
// hubo entrada. Aquí `device_state`/`huella_anterior` no existen.
#[cfg(target_os = "macos")]
let hubo_input = idle_seconds_macos() < (TICK_SEG as f64) + 1.0;
// Windows/Linux: huella de entrada con device_query (posición del mouse + cuántas
// teclas hay presionadas). Leer esto ahí no pide permiso especial.
#[cfg(not(target_os = "macos"))]
let hubo_input = {
use device_query::DeviceQuery;
let mouse = device_state.get_mouse();
let teclas = device_state.get_keys().len();
let huella = (mouse.coords.0, mouse.coords.1, teclas);
let hubo_input = huella != huella_anterior || teclas > 0;
let cambio = huella != huella_anterior || teclas > 0;
huella_anterior = huella;
cambio
};
if hubo_input {
ultimo_input = ahora;
}
let ocioso = ahora.saturating_sub(ultimo_input) >= OCIO_PROFUNDO_SEG;
// ── Ventana al frente + excepción del VIDEO ──
// La ventana se lee cada tic mientras se mide (antes solo cuando había actividad):
// ahora hace falta AUNQUE se esté ocioso, para saber si al frente hay un video.
let ventana = if token_ok && !pausado {
active_win_pos_rs::get_active_window()
.ok()
.map(|v| (normaliza(&v.app_name, 80), normaliza(&v.title, 140)))
} else {
None
};
// Sin entrada PERO con un navegador al frente y sonido saliendo = un video
// reproduciéndose. El usuario lo pidió: eso NO es inactividad, cuenta como tiempo de
// navegador (abajo se acumula el bloque con a=0, porque no hubo entrada). Solo se
// consulta el audio en ese caso puntual (ocioso + navegador al frente), no en cada tic.
let viendo_video = ocioso
&& ventana.as_ref().map(|(app, _)| es_navegador(app)).unwrap_or(false)
&& hay_audio_sonando();
// La inactividad EFECTIVA: quieto de verdad, salvo que sea un video en primer plano.
// Todo lo de abajo (tramo de ocio, bandeja, acumulación) usa esta, no `ocioso`.
let ocioso_efectivo = ocioso && !viendo_video;
// ── El tramo de inactividad ──
// Abre cuando se cruza el umbral (retrocedido al minuto exacto en que se dejó de
// contar trabajo: ultimo_input + umbral) y cierra cuando vuelve la entrada —o cuando
// arranca un video en primer plano, que cuenta como actividad de navegador. En
// pausa o sin vincular no se anota nada: pausar es «no me midas», y eso incluye
// la inactividad.
if token_ok && !pausado {
if ocioso_efectivo && !ocioso_antes {
let ini = (ultimo_input + OCIO_PROFUNDO_SEG) * 1000;
ocio_abierto = Some(ini);
guardar_encurso(&h, ahora, ini);
encurso_guardado = ahora;
} else if !ocioso_efectivo && ocioso_antes {
if let Some(ini) = ocio_abierto.take() {
let fin = ahora * 1000;
if fin > ini {
ocios_pendientes.push(Ocio { s: ini, d: ((fin - ini) / 1000) as u32 });
}
borrar_encurso(&h);
}
}
} else {
if let Some(ini) = ocio_abierto.take() {
// Se pausó o se desvinculó a mitad de un tramo: lo quieto HASTA ese
// momento fue real y medido — se cierra aquí; de la pausa en adelante ya
// no es asunto del sensor. Sin token (desvinculado) se descarta.
if token_ok {
let fin = ahora * 1000;
if fin > ini {
ocios_pendientes.push(Ocio { s: ini, d: ((fin - ini) / 1000) as u32 });
}
}
borrar_encurso(&h);
}
ocioso_antes = false;
// El reloj de ocio parte de cero mientras dure la pausa: sin esto, reanudar
// estando quieto abriría un tramo retrocedido hasta DENTRO de la pausa —
// anotando como inactividad un rato que el editor pidió expresamente no medir.
ultimo_input = ahora;
}
if token_ok && !pausado {
ocioso_antes = ocioso_efectivo;
}
// El tope del tramo: a las 4 h se cierra y NO se reabre (ocioso_antes queda en
// true, así que la transición de apertura no vuelve a disparar hasta que haya
// entrada real). Más de eso no es descanso frente al equipo, es ausencia.
if let Some(ini) = ocio_abierto {
if (ahora * 1000).saturating_sub(ini) >= OCIO_MAX_TRAMO_SEG * 1000 {
ocio_abierto = None;
ocios_pendientes.push(Ocio { s: ini, d: OCIO_MAX_TRAMO_SEG as u32 });
borrar_encurso(&h);
} else if ahora.saturating_sub(encurso_guardado) >= 60 {
// Latido del tramo abierto (1/min): si el proceso muere, al arrancar se
// cierra en el último latido en vez de perderse entero.
guardar_encurso(&h, ahora, ini);
encurso_guardado = ahora;
}
}
let estado = if !token_ok {
"sin vincular"
} else if pausado {
"en pausa"
} else if ocioso {
"inactivo (no cuenta)"
} else if viendo_video {
// Ocioso, pero con un video en primer plano: se cuenta como navegador.
"video en primer plano"
} else if ocioso_efectivo {
// Desde la 1.9 el tramo quieto SÍ queda anotado (aparte de las horas
// efectivas); la bandeja lo dice para que la medición siga siendo transparente.
"inactivo (se anota aparte)"
} else {
"registrando"
};
@@ -357,10 +653,13 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
ultimo_estado = estado.to_string();
}
if token_ok && !pausado && !ocioso {
if let Ok(v) = active_win_pos_rs::get_active_window() {
let app_name = normaliza(&v.app_name, 80);
let titulo = normaliza(&v.title, 140);
// Se acumula la ventana al frente cuando NO hay inactividad efectiva: o sea, con
// actividad real, O con un video en primer plano (viendo_video → ocioso_efectivo es
// false). En el caso del video, `hubo_input` es false, así que el bloque suma
// segundos de navegador pero 0 activos — honesto: presente, sin teclear. Se usa la
// `ventana` ya leída arriba, sin volver a preguntar al sistema.
if token_ok && !pausado && !ocioso_efectivo {
if let Some((app_name, titulo)) = ventana.clone() {
if !app_name.is_empty() {
let b = acumulado.entry((app_name.clone(), titulo.clone())).or_insert_with(|| Bloque {
s: ahora_ms(),
@@ -377,11 +676,15 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
}
}
// Cada 5 min: el acumulado pasa a la cola y se intenta subir todo.
// Cada 5 min: el acumulado (trabajo + ocio cerrado) pasa a la cola y se intenta
// subir todo. Un tramo de ocio AÚN abierto no viaja: se sube cuando cierre.
if ahora.saturating_sub(ultimo_envio) >= LOTE_SEG {
ultimo_envio = ahora;
if !acumulado.is_empty() {
let lote: Vec<Bloque> = acumulado.drain().map(|(_, b)| b).collect();
if !acumulado.is_empty() || !ocios_pendientes.is_empty() {
let lote = Lote {
b: acumulado.drain().map(|(_, b)| b).collect(),
i: std::mem::take(&mut ocios_pendientes),
};
let nucleo = h.state::<NucleoState>();
let cola = {
let mut n = nucleo.0.lock().unwrap();
@@ -391,6 +694,10 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
guardar_cola(&h, &cola);
}
enviar(&h);
// Lo que tardó la subida NO fue una suspensión: sin este refresco, una tanda
// lenta (>1 min) hacía que el siguiente tic entrara por la rama `dormido` y
// cortara en falso el tramo de ocio abierto.
tic_previo = ahora_seg();
}
}
});

Ver fichero

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

84
tests/images.browser.cjs Archivo normal
Ver fichero

@@ -0,0 +1,84 @@
// Run with Playwright available: node tests/images.browser.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const { chromium, webkit } = require('playwright');
const root = path.join(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'src-tauri/src/lib.rs'), 'utf8');
const template = source.match(/const INIT_JS_TPL: &str = r#"([\s\S]*?)"#;/)[1];
const png = fs.readFileSync(path.join(root, 'app-icon.png'));
const urls = [
'/api/files-asset/session?thumb=1',
'/api/files-asset/signed?thumb=1&t=expired.sig',
'/api/files-asset/poster?poster=1',
'/api/files-asset/large?thumb=xl',
'/api/files-asset/original',
];
const counts = { authenticated: 0, revalidated: 0, unauthorized: 0 };
const server = http.createServer((req, res) => {
if (req.url.startsWith('/api/files-asset/')) {
if (req.headers.cookie !== 'test-session=ok') {
counts.unauthorized++;
res.writeHead(401, { 'Cache-Control': 'no-store' }).end();
return;
}
counts.authenticated++;
const headers = { 'Content-Type': 'image/png', 'Cache-Control': 'private, no-cache', ETag: '"cover-1"' };
if (req.headers['if-none-match'] === '"cover-1"') {
counts.revalidated++;
res.writeHead(304, headers).end();
} else res.writeHead(200, headers).end(png);
return;
}
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
res.end(`<html><title>Portadas: prueba local</title><body>
${urls.map((url, i) => `<img id="image-${i}" width="120" height="120" src="${url}">`).join('')}
<canvas width="16" height="16"></canvas></body></html>`);
});
(async () => {
let browser;
try {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
browser = await (process.env.TEST_BROWSER === 'webkit' ? webkit : chromium).launch({ headless: true });
const context = await browser.newContext();
await context.addCookies([{ name: 'test-session', value: 'ok', url: origin, httpOnly: true }]);
await context.addInitScript(`window.__TAURI__ = { event: { emit() {} } };`);
await context.addInitScript(template.replaceAll('__ORIGIN__', origin).replaceAll('__LABEL__', 'tab-test'));
const page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(origin);
await page.waitForFunction(() => [...document.images].every((image) => image.complete && image.naturalWidth > 0));
assert.deepEqual(await page.locator('img').evaluateAll((images) => images.map((image) => image.getAttribute('src'))), urls);
// Cached images must still be readable by the annotation canvas.
assert.equal(await page.evaluate(() => {
const canvas = document.querySelector('canvas');
canvas.getContext('2d').drawImage(document.images[0], 0, 0, 16, 16);
return canvas.toDataURL().startsWith('data:image/png;');
}), true);
await page.reload();
await page.waitForFunction(() => [...document.images].every((image) => image.complete && image.naturalWidth > 0));
assert.ok(counts.revalidated >= urls.length, 'HTTP cache revalidation expected on reload');
await page.evaluate(() => {
const image = new Image();
image.id = 'dynamic';
image.setAttribute('src', '/api/files-asset/dynamic?thumb=1');
document.body.appendChild(image);
});
await page.waitForFunction(() => document.getElementById('dynamic').naturalWidth > 0);
// A native downloader without the webview session reproduces the 401.
assert.equal((await fetch(origin + urls[0])).status, 401);
await context.clearCookies();
await page.reload();
assert.equal(await page.locator('img').evaluateAll((images) => images.filter((image) => image.naturalWidth > 0).length), 0);
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, covers: urls.length, dynamic: true, canvas: true, ...counts }));
} finally {
if (browser) await browser.close();
await new Promise((resolve) => server.close(resolve));
}
})().catch((error) => { console.error(error); process.exitCode = 1; });

102
tests/images.test.cjs Archivo normal
Ver fichero

@@ -0,0 +1,102 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src-tauri/src/lib.rs'), 'utf8');
const template = source.match(/const INIT_JS_TPL: &str = r#"([\s\S]*?)"#;/)[1];
const origin = 'https://os.labstreamsas.com';
function shell({ frame = false, host = origin, images = [] } = {}) {
class Image {
constructor(src = '') { this.attrs = { src }; }
get src() { return this.attrs.src; }
set src(value) { this.attrs.src = value; }
getAttribute(name) { return this.attrs[name]; }
setAttribute(name, value) { this.attrs[name] = value; }
}
const setter = Object.getOwnPropertyDescriptor(Image.prototype, 'src').set;
const setAttribute = Image.prototype.setAttribute;
const emitted = [];
const opened = [];
const listeners = {};
const nodes = images.map((src) => new Image(src));
const root = { classList: { contains: () => false } };
const win = {
__TAURI__: { event: { emit: (...args) => emitted.push(args) }, core: { invoke: (...args) => opened.push(args) } },
open: () => 'original',
};
win.self = win;
win.top = frame ? {} : win;
const sandbox = {
window: win, URL, HTMLImageElement: Image,
location: new URL(host + '/proyectos/ejemplo'),
document: {
readyState: 'complete', title: 'Entregables', documentElement: root, body: root,
querySelector: () => null, querySelectorAll: () => nodes,
addEventListener: (name, callback) => { listeners[name] = callback; },
},
history: {}, navigator: { platform: 'MacIntel' },
getComputedStyle: () => ({ backgroundColor: 'rgb(255, 255, 255)' }),
addEventListener: (name, callback) => { listeners[name] = callback; },
setTimeout: () => 0,
};
vm.runInNewContext(template.replaceAll('__ORIGIN__', origin).replaceAll('__LABEL__', 'tab-1'), sandbox);
return { Image, nodes, setter, setAttribute, win, emitted, opened, listeners };
}
test('does not replace native image setters or rewrite server-rendered covers', () => {
const urls = ['/api/files-asset/cover?thumb=1', '/api/files-asset/cover?t=123.sig&thumb=1'];
const state = shell({ images: urls });
assert.equal(Object.getOwnPropertyDescriptor(state.Image.prototype, 'src').set, state.setter);
assert.equal(state.Image.prototype.setAttribute, state.setAttribute);
assert.deepEqual(state.nodes.map((image) => image.src), urls);
});
for (const url of [
'/api/files-asset/cover?thumb=1',
'/api/files-asset/cover?t=expired.sig&thumb=1&v=abc',
'/api/files-asset/cover?thumb=xl',
'/api/files-asset/video?poster=1',
'/api/files-asset/original',
'https://other.example/image.webp',
]) {
test(`keeps authenticated browser transport for ${url}`, () => {
const { Image } = shell();
const image = new Image();
image.src = url;
assert.equal(image.src, url);
image.setAttribute('src', url + '&r=1');
assert.equal(image.src, url + '&r=1');
});
}
test('tab opening and title reports still work', () => {
const { win, emitted } = shell();
win.open('/revisiones/123', '_blank');
assert.equal(emitted[0][0], 'ls-title');
assert.equal(emitted[0][1].title, 'Entregables');
assert.equal(emitted[1][0], 'ls-new-tab');
assert.equal(emitted[1][1].url, origin + '/revisiones/123');
});
test('does not interfere with embedded editors or unrelated origins', () => {
for (const options of [{ frame: true }, { host: 'https://docs.example.com' }, { host: 'https://docs.google.com' }]) {
const { win, emitted } = shell(options);
assert.equal(win.__lsShell, undefined);
assert.equal(win.open(), 'original');
assert.equal(emitted.length, 0);
}
});
test('Google editors and login use the system browser, never an embedded tab', () => {
for (const host of ['docs', 'sheets', 'drive', 'accounts']) {
const { win, emitted, opened } = shell();
const url = `https://${host}.google.com/example`;
win.open(url, '_blank');
assert.equal(emitted.filter(([name]) => name === 'ls-new-tab').length, 0);
assert.equal(opened[0][0], 'plugin:opener|open_url');
assert.equal(opened[0][1].url, url);
}
});