Comparar commits

...

26 Commits

Autor SHA1 Mensaje Fecha
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
Munka
b4c9418b14 El sensor acusa recibo: entra en la 1.8.0 antes de publicarla 2026-08-14 16:04:48 -05:00
Munka
f162f5ae09 El sensor acusa recibo del token y dice como esta
Antes la pagina emitia el token a ciegas: en una app SIN sensor (anterior a la
1.8.0) nadie lo recogia y la pantalla cantaba "equipo vinculado" igual,
dejando un equipo registrado que jamas reporta.

- ls-tracker-token  -> responde ls-tracker-listo (acuse). Sin acuse, la web
  deshace el equipo que acaba de crear y avisa que hay que actualizar.
- ls-tracker-consulta -> responde ls-tracker-estado {vinculado, pausado,
  version}, para no ofrecer vincular lo que ya esta vinculado y para poder
  avisar de un clic a quien le falta.

Los dos emit salen en un hilo aparte, por el mismo motivo que los repintados
del menu: un listener puede correr en el hilo principal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:19:03 -05:00
Munka
37403791f6 v1.8.0: la version que trae el rastreador de trabajo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:03:02 -05:00
Munka
29b88ed97f Fase 1 del rastreador de trabajo efectivo (verificada en CI, Windows y macOS)
Sensor de ventana activa + actividad, cola offline y estado visible en la
bandeja. NO se etiqueta todavia: una etiqueta actualiza sola a TODO el
equipo, y eso lo decide Jonathan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:36:30 -05:00
Munka
0df9bd1374 El README cuenta el rastreador: que mide, que NO mide y como se vincula
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:23:20 -05:00
Munka
2595faf372 El drain calcula su largo antes: el MutexGuard no admite el prestamo doble
`n.cola.drain(0..enviados.min(n.cola.len()))` no compila: el argumento pide
un prestamo inmutable de `n.cola` cuando ya esta prestado mutable, y a
traves del MutexGuard —un deref explicito, no un autoref— el two-phase
borrow no lo cubre (E0502, el UNICO error que encontro el CI en todo el
sensor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:21:53 -05:00
Munka
cac69dd7be El escritorio aprende a medir el trabajo efectivo, y lo dice en la bandeja
Sensor nuevo (src/tracker.rs): cada 5 s mira que app esta al frente y si
hubo entrada; con 3 minutos sin entrada deja de contar (ocio profundo, que
cubre tambien pantalla bloqueada y suspension). Acumula bloques {app,
titulo, segundos, segundos activos} y los sube cada 5 min a /api/tracker con
el token del equipo; sin red la cola persiste en disco y reintenta, y un 401
(token revocado desde Ajustes) descarta el token y vacia la cola.

Lo que NO hace, a proposito: no registra que teclas se pulsan —solo compara
una huella de posicion del raton y cuantas teclas hay presionadas—, no toma
pantallazos y no lista procesos de fondo. El estado vive VISIBLE en el menu
de la bandeja, con pausa de un toque.

Ojo al tocar el menu: set_text de Tauri despacha al hilo principal y bloquea
esperandolo, asi que llamarlo desde setup() —que corre en ese mismo hilo
antes de que el bucle de eventos atienda— colgaria la app al abrir. Por eso
los items nacen ya con su texto y todo repintado posterior sale de un hilo
secundario.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:48:54 -05:00
Munka
3798970f74 v1.7.1: el detector de tema deja de creer y empieza a mirar
La v1.7.0 preguntaba por la clase `dark` de <html> y punto. En este
mismo PC quedó la prueba de que eso miente en WebView2: estado.json
con "dark": false y la app pintada de negro — barra clara sobre app
oscura, el bug al revés.

Ahora el reporte es a prueba de balas:

- temaOscuro(): clase `dark` en <html> o <body>; si no está, la
  LUMINANCIA del fondo realmente pintado del body (funciona sea cual
  sea el mecanismo de tema); si tampoco, el color-scheme computado.
  Con el documento a medio crear devuelve null y el campo NO viaja
  (Rust conserva el último valor bueno) — nada revienta en frío.
- Vigilancia segura: MutationObserver sobre <html> Y <body>
  (class/style/data-theme) registrado cuando existen, y dos
  re-reportes tras la carga por si el tema aterriza con la
  hidratación.

Rust y la barra quedan como en v1.7.0 (un campo `dark` ausente ya
significaba «conserva lo último»). El estado.json mentiroso se
corrige solo con el primer reporte bueno.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:45:01 -05:00
Munka
29f0b47371 Version 1.7.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:36:46 -05:00
Munka
b18c155b89 La barra obedece a la app, no a Windows: tema real + acento naranja
El bug de «siempre oscura»: la web decide su modo con su propio botón
(next-themes, defaultTheme claro, sin seguir al sistema), pero la barra
elegía tema con prefers-color-scheme — el del SISTEMA. Con Windows en
oscuro y la app en claro, franja negra sobre app blanca, siempre. La
v1.6.1 pintó bien ambos temas, pero la barra seguía eligiendo el
equivocado.

El arreglo, de punta a punta:

- El script inyectado reporta en ls-title si <html> lleva la clase
  `dark`, con un MutationObserver para que la luna se refleje al
  instante. La guarda de marco y origen queda intacta (OnlyOffice).
- Rust lo guarda (global: todas las pestañas comparten modo), lo
  reenvía en ls-tabs (`dark`) y lo PERSISTE en estado.json: la app
  abre ya del color correcto, sin fogonazo.
- La barra se pone la clase `oscuro`/`claro` al recibirlo; las media
  queries quedan solo como arranque en frío (primer arranque sin
  estado.json). Los tintes de colores de pestaña cuelgan del mismo
  interruptor.
- El acento pasa de índigo #6366f1 (comentado «color de Labstream»,
  que no lo era) al naranja de marca #F47A20: aro de carga, zoom
  activo y hover de la versión.

Verificado en navegador con el CSS y el JS reales, simulando el
payload de Rust: app clara + sistema oscuro → barra clara; app
oscura → negra; sin reporte → cae al sistema. El Rust lo compila el
CI de rama (este equipo no tiene cargo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:22:46 -05:00
Munka
424f401ad6 Versión 1.6.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:58:00 -05:00
Munka
f6eea75404 La barra se pinta del color de la app: blanca en claro, negra en oscuro
Venía con el gris de Chrome (#dee1e6 / #202124) en los dos temas, y encima de una
app negra se veía como una franja de navegador pegada a la ventana en vez de parte
de ella. Ahora la franja es casi blanca en tema claro y negra en oscuro, del tono de
la app.

El truco de Chrome se conserva —la pestaña activa es una tarjeta del color del
CONTENIDO y se funde con él—, solo que en oscuro el contraste va al revés: la franja
queda MÁS negra que el contenido, así que la activa destaca por ser más clara. Quien
marca el corte de verdad es el borde inferior, que la activa tapa con su fondo.

De paso, dos ajustes que arrastraba el cambio:

- El hover de las pestañas inactivas en tema claro era blanco al 55%: sobre una
  franja casi blanca no se habría visto nada. Ahora oscurece.
- El ancho máximo de pestaña sube de 240 a 280 px. Los títulos ahora traen nombre
  real y sección («Hotjamin Orgánicos · Archivos») y a 240 se cortaban de más. Con
  muchas pestañas abiertas no cambia nada: mandan el reparto elástico y el modo
  compacto de solo icono.

Verificado: sintaxis del script con node --check y la barra montada en el navegador
con el CSS real, en los dos temas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:45:38 -05:00
Se han modificado 16 ficheros con 2651 adiciones y 119 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

140
README.md
Ver fichero

@@ -39,11 +39,73 @@ 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.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.
- **Se ve y se pausa**: el menú de la bandeja dice el estado («registrando»,
«inactivo», «en pausa») y pausa/reanuda de un toque.
- **Se vincula** desde el servidor: Ajustes ▸ Perfil ▸ *Vincular este equipo*,
**estando dentro de esta app**. El servidor genera el token del equipo y se
lo entrega al sensor por el evento `ls-tracker-token`; el secreto nunca se
enseña ni se copia a mano. Revocar el equipo (misma pantalla) lo corta al
instante: el siguiente lote recibe 401, el sensor tira el token y la bandeja
vuelve a «sin vincular».
- En **macOS** el sistema pide permiso de *Accesibilidad* la primera vez (para
leer el título de la ventana al frente); sin él, el sensor mide tiempo pero
no sabe decir en qué app.
La barra de pestañas vive en `dist/index.html` (webview local «chrome»); las
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).
@@ -167,20 +229,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.
---
@@ -195,10 +309,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

93
dist/index.html vendido
Ver fichero

@@ -7,7 +7,8 @@
<!--
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}], zoom, version, mac}
recibe ls-tabs {tabs:[{label,title,url,loading,active,color}], 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
En macOS la ventana lleva el título en OVERLAY: esta barra ES la fila del título, así que
@@ -15,33 +16,60 @@
(`data-tauri-drag-region`). En Windows hay título nativo aparte y no se reserva nada.
-->
<style>
/* Paleta estilo Chrome: la barra es una FRANJA más oscura y la pestaña activa es una
tarjeta del color del contenido que se funde con él (sin línea debajo). */
/* La barra se pinta del color de la APP, no del gris de Chrome: blanca en tema claro y
negra en oscuro, para que la ventana se lea como una sola pieza y no como una franja
de navegador pegada encima.
El truco de Chrome se conserva igual —la pestaña activa es una tarjeta del color del
CONTENIDO que se funde con él—, solo que ahora el contraste va al revés en oscuro: la
franja es más negra que el contenido, así que la activa destaca por ser más clara. La
diferencia es sutil a propósito; quien la marca de verdad es el borde inferior, que la
pestaña activa tapa con su propio fondo. */
:root {
--bg: #dee1e6; /* franja de pestañas (Chrome claro) */
--bg: #f4f5f7; /* franja: casi blanca, del tono de la app en claro */
--fg: #45474a;
--muted: #5f6368;
--tab-active-bg: #ffffff;
--tab-active-bg: #ffffff; /* mismo fondo que la app → pestaña fundida con el contenido */
--tab-active-fg: #202124;
--accent: #6366f1; /* indigo-500, color de Labstream */
--border: #c9ccd1; /* línea bajo la barra (la tapa la pestaña activa) */
--hover: rgba(255, 255, 255, 0.55); /* píldora de hover en pestañas inactivas */
--accent: #F47A20; /* NARANJA Labstream (el índigo de antes no era de la marca) */
--border: #dcdee1; /* línea bajo la barra (la tapa la pestaña activa) */
/* 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 */
}
/* ── Quién decide el tema ──
La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su
<html>, defaultTheme claro, sin seguir al sistema), así que obedecer aquí al
prefers-color-scheme del sistema pintaba la barra negra sobre una app clara — el
bug de siempre. El script inyectado reporta el tema real, Rust lo reenvía en
`ls-tabs` (`dark`) y este documento se pone la clase `oscuro` o `claro`.
El bloque de medios queda SOLO como arranque en frío (primer arranque, sin
estado.json y sin reporte aún): en cuanto hay clase, la clase manda.
OJO: los valores oscuros van DOS veces (media query y .oscuro) — cambiarlos a la par. */
@media (prefers-color-scheme: dark) {
:root {
--bg: #202124; /* franja (Chrome oscuro) */
:root:not(.claro) {
--bg: #0d0d0f; /* franja: negra, más oscura que el contenido */
--fg: #bdc1c6;
--muted: #80868b;
--tab-active-bg: #18181b; /* mismo fondo que la app → pestaña fundida con el contenido */
--tab-active-fg: #e8eaed;
--border: #101013;
--border: #26262a;
--hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25);
}
}
:root.oscuro {
--bg: #0d0d0f;
--fg: #bdc1c6;
--muted: #80868b;
--tab-active-bg: #18181b;
--tab-active-fg: #e8eaed;
--border: #26262a;
--hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
@@ -81,10 +109,13 @@
display: flex;
align-items: center;
gap: 7px;
/* Elástico como en Chrome: se reparten el ancho y se encogen al abrir más. */
/* Elástico como en Chrome: se reparten el ancho y se encogen al abrir más. El tope
sube a 280 px porque los títulos ahora traen nombre real y sección
(«Hotjamin Orgánicos · Archivos»), y a 240 se cortaban de más. Con muchas pestañas
abiertas da igual: mandan el reparto y el modo compacto. */
flex: 1 1 220px;
min-width: 42px;
max-width: 240px;
max-width: 280px;
height: 30px;
margin-bottom: 5px;
padding: 0 6px 0 10px;
@@ -158,9 +189,11 @@
.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) {
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
:root:not(.claro) .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
}
: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); }
/* 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. */
@@ -231,6 +264,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;
@@ -292,8 +336,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>
@@ -490,6 +535,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");
@@ -498,6 +552,12 @@
$pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100);
$ver.textContent = estado.version ? "v" + estado.version : "";
// Tema: manda el de la APP (payload `dark`). Mientras Rust no sepa nada (null),
// no se pone clase y siguen las media queries del sistema como arranque en frío.
if (typeof estado.dark === "boolean") {
document.documentElement.classList.toggle("oscuro", estado.dark);
document.documentElement.classList.toggle("claro", !estado.dark);
}
// En macOS la barra ES la fila del título: hay que dejar libre el semáforo.
document.body.classList.toggle("mac", !!estado.mac);
// En Windows también ES la fila del título, pero ahí los botones de ventana los
@@ -521,7 +581,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.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "labstream-desktop",
"version": "1.4.0",
"version": "1.14.1",
"devDependencies": {
"@tauri-apps/cli": "^2"
}

Ver fichero

@@ -1,9 +1,10 @@
{
"name": "labstream-desktop",
"version": "1.6.0",
"version": "1.16.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",

713
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.6.0"
version = "1.16.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"
@@ -28,3 +28,49 @@ 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"
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]
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",
] }

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");
}
}

Ver fichero

@@ -23,6 +23,8 @@
// - Globo de NO-LEÍDOS en el icono (evento `ls-badge` desde la web app): número en el
// Dock (macOS), punto rojo en la barra de tareas (Windows) y tooltip de la bandeja.
// - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones.
// - DESCARGAS con «Guardar como»: el sistema pregunta dónde guardar cada archivo (con el
// nombre que sugiere el servidor) en vez de dejarlo caer en silencio en Descargas.
// - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana.
// - single-instance (solo release): abrir la app de nuevo enfoca la ventana existente.
// - macOS: clic en el Dock reabre la ventana oculta (RunEvent::Reopen) y menú nativo con
@@ -32,7 +34,14 @@
// `core:default` también para el origen remoto (capabilities/default.json). Así no hace falta
// declarar comandos propios en el ACL para páginas remotas.
use std::sync::Mutex;
mod cache;
mod tracker;
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Mutex,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -50,6 +59,40 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
const SERVER_URL: &str = "https://os.labstreamsas.com";
// Google requiere el navegador del sistema para autenticar y editar con compatibilidad completa.
const GOOGLE_HOSTS: &[&str] = &[
"docs.google.com",
"drive.google.com",
"sheets.google.com",
"accounts.google.com",
"myaccount.google.com",
];
fn is_google_host(host: &str) -> bool {
let h = host.to_ascii_lowercase();
GOOGLE_HOSTS.iter().any(|d| h == *d || h.ends_with(&format!(".{}", d)))
}
fn es_pestana_interna(target: &Url, server: &Url) -> bool {
target.origin() == server.origin()
}
#[cfg(test)]
mod navigation_tests {
use super::*;
#[test]
fn only_labstream_is_an_embedded_tab() {
let server = Url::parse(SERVER_URL).unwrap();
assert!(es_pestana_interna(&server.join("/revisiones/1").unwrap(), &server));
for url in ["https://docs.google.com/document/d/1/edit", "https://accounts.google.com", "https://os.labstreamsas.com.evil.example", "http://os.labstreamsas.com"] {
assert!(!es_pestana_interna(&Url::parse(url).unwrap(), &server));
}
assert!(is_google_host("docs.google.com"));
assert!(!is_google_host("docs.google.com.evil.example"));
}
}
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
const CHROME: &str = "chrome";
// Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese
@@ -97,16 +140,29 @@ struct Shell {
// Último estado maximizado conocido. Solo sirve para no repintar la barra en CADA
// evento de redimensión: únicamente cuando el icono ▢/❐ tiene que cambiar.
maximized: bool,
// Tema REAL de la app (la clase `dark` de <html>, reportada por el script inyectado).
// Es global y no por pestaña: todas las pestañas del origen comparten el mismo modo
// (localStorage). None = ninguna pestaña ha reportado aún → la barra cae al del sistema.
dark: Option<bool>,
}
impl Default for Shell {
fn default() -> Self {
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None, maximized: false }
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None, maximized: false, dark: None }
}
}
type ShellState = Mutex<Shell>;
// Rutas elegidas en «Guardar como» de las descargas EN CURSO, por URL de origen. Hace falta para
// nombrar el archivo al terminar: en macOS `DownloadEvent::Finished` llega SIEMPRE con
// `path: None` (wry-0.55.1/src/wkwebview/download.rs:98-99 pasa `None` a mano), así que la única
// forma de decir «Descargado: informe.xlsx» es recordar aquí lo que el usuario eligió. La URL es
// la misma en Requested y en Finished en ambos motores (DownloadOperation.Uri en WebView2;
// originalRequest.URL en WKDownload).
#[derive(Default)]
struct Descargas(Mutex<HashMap<String, PathBuf>>);
#[derive(Clone, Copy, Serialize, Deserialize)]
struct WinRect {
x: f64,
@@ -132,6 +188,10 @@ struct Persisted {
active: usize,
#[serde(default)]
autostart: Option<bool>,
// Último tema conocido de la app: la barra abre ya del color correcto en vez de
// arrancar con el del sistema y dar un fogonazo al cargar la primera pestaña.
#[serde(default)]
dark: Option<bool>,
}
fn state_path<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
@@ -157,6 +217,7 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
active: s.active,
autostart: s.autostart,
dark: s.dark,
}
};
if let Some(dir) = path.parent() {
@@ -193,7 +254,7 @@ const INIT_JS_TPL: &str = r#"
// descargar fallaban solo en la app de Windows, y no en Mac ni en el navegador.
// En macOS estas dos líneas son un no-op: allí el script nunca ha corrido en subframes.
try { if (window.top !== window.self) return; } catch (e) { return; }
if (location.origin !== ORIGIN) return; // p. ej. el login del SSO: no intervenir
if (location.origin !== ORIGIN) return;
if (window.__lsShell) return;
window.__lsShell = true;
@@ -204,7 +265,8 @@ const INIT_JS_TPL: &str = r#"
function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } }
function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); }
function isApp(u) { return isHttp(u) && u.origin === ORIGIN; }
function isExt(u) { return isHttp(u) && u.origin !== ORIGIN; }
function isInterno(u) { return isApp(u); }
function isExt(u) { return isHttp(u) && !isInterno(u); }
function emit(name, payload) { try { T.event.emit(name, payload); } catch (e) {} }
function openExternal(url) {
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
@@ -212,9 +274,44 @@ const INIT_JS_TPL: &str = r#"
}
function newTab(url) { emit('ls-new-tab', { url: url }); }
// Título y URL → barra de pestañas (y para restaurar la sesión al reabrir).
// Las imagenes conservan su URL HTTPS y la sesion del webview. El descargador
// lsthumb no comparte cookies: falla con miniaturas sin token y omite la
// revalidacion de permisos al leer del disco. La cache HTTP respeta los ETag
// y Cache-Control del servidor sin modificar src, srcset ni la hidratacion.
// ── ¿La página se VE oscura? ──
// Señales, de la más directa a la más física: la clase `dark` (next-themes) y, si no
// está, el color de fondo REALMENTE pintado (luminancia del body) — vale sea cual sea
// el mecanismo de tema. Devuelve null con el documento a medio crear (los scripts de
// inicio corren antes de que exista <html>): entonces el campo NO viaja y Rust conserva
// el último valor bueno. La v1.7.0 leía la clase a secas y sin red: en WebView2 eso
// podía reventar en frío y dejar `dark` mintiendo en estado.json (barra clara sobre
// app oscura — el bug al revés).
function temaOscuro() {
try {
var doc = document.documentElement, cuerpo = document.body;
if (!doc) return null;
if (doc.classList.contains('dark') || (cuerpo && cuerpo.classList.contains('dark'))) return true;
if (!cuerpo) return null;
var fondo = getComputedStyle(cuerpo).backgroundColor || '';
var m = fondo.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s\/]+([\d.]+))?/);
if (m && (m[4] === undefined || parseFloat(m[4]) > 0.5)) {
return 0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3] < 128;
}
var esquema = getComputedStyle(doc).colorScheme || '';
if (esquema.indexOf('dark') >= 0 && esquema.indexOf('light') < 0) return true;
return false;
} catch (e) { return null; }
}
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
// sistema: la barra tiene que copiarle a ELLA.
function report() {
emit('ls-title', { label: LABEL, title: document.title || 'Labstream OS', url: location.href });
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
emit('ls-title', carga);
}
function watchTitle() {
var el = document.querySelector('title');
@@ -233,6 +330,18 @@ const INIT_JS_TPL: &str = r#"
history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; };
});
addEventListener('popstate', function () { setTimeout(report, 0); });
// Pulsar la luna cambia clases/estilos sin navegar: se vigilan <html> y <body> (cuando
// existan — a document-start aún no hay), y tras la carga se re-reporta un par de veces
// por si el tema aterriza tarde con la hidratación.
function vigilarTema() {
if (!window.MutationObserver) return;
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] };
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
if (document.body) new MutationObserver(report).observe(document.body, conf);
}
if (document.body) vigilarTema();
else document.addEventListener('DOMContentLoaded', vigilarTema);
addEventListener('load', function () { setTimeout(report, 800); setTimeout(report, 2500); });
// Clics en enlaces.
document.addEventListener('click', function (e) {
@@ -253,14 +362,14 @@ const INIT_JS_TPL: &str = r#"
if (!a) return;
var u = abs(a.getAttribute('href'));
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
if (isApp(u)) { e.preventDefault(); newTab(u.href); }
if (isInterno(u)) { e.preventDefault(); newTab(u.href); }
}, true);
var _open = window.open;
window.open = function (url, name, feats) {
var u = url ? abs(url) : null;
if (isExt(u)) { openExternal(u.href); return null; }
if (isApp(u)) { newTab(u.href); return null; }
if (isInterno(u)) { newTab(u.href); return null; }
return _open ? _open.call(window, url, name, feats) : null;
};
@@ -368,6 +477,8 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
// que dibuja ella misma los botones minimizar/maximizar/cerrar (no hay nativos).
"win": cfg!(target_os = "windows"),
"maximized": maximized,
// Tema real de la app (null hasta el primer reporte: la barra usa el del sistema).
"dark": s.dark,
})
};
let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload);
@@ -414,13 +525,106 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
save_persisted(app);
}
// ── Descargas: «Guardar como» ──
//
// tauri::webview::DownloadEvent avisa DOS veces por descarga:
// - Requested { url, destination: &mut PathBuf }: antes de empezar. `destination` trae la ruta
// que el motor usaría por defecto (carpeta Descargas + nombre sugerido) y se puede
// SOBRESCRIBIR (ruta absoluta); devolver false cancela la descarga.
// - Finished { url, path: Option<PathBuf>, success }: al terminar, bien o mal.
//
// Cómo llega `destination` según el motor:
// - Windows (WebView2): ICoreWebView2DownloadStartingEventArgs::ResultFilePath, que ya lleva el
// nombre del Content-Disposition (o del último segmento de la URL) y « (1)», « (2)»… si el
// archivo ya existe en Descargas. Si devolvemos true, wry hace SetResultFilePath(destination)
// + SetHandled(true) (wry-0.55.1/src/webview2/mod.rs:849-864); si false, SetCancel(true).
// - macOS (WKWebView): wry compone ~/Downloads/<suggestedFilename de WebKit> con el mismo
// esquema de « (n)» y, si devolvemos true, entrega esa ruta al completion handler de
// WKDownload como destino (wry-0.55.1/src/wkwebview/download.rs:49-91). El nombre viene
// siempre relleno. LÍMITE: WKWebView solo trata como descarga (a) los enlaces con atributo
// `download` (shouldPerformDownload) y (b) las respuestas cuyo MIME no sabe mostrar
// (canShowMIMEType == false; wkwebview/navigation.rs:59-73 y 93-100). WebKit NO mira
// Content-Disposition al decidir (WebPage::canShowResponse consulta solo el MIME), así que un
// PDF o una imagen servidos como attachment se MUESTRAN en la pestaña en vez de bajar; un
// .xlsx/.docx/.zip sí dispara la descarga porque WebKit no los renderiza.
// Abre el «Guardar como» nativo y deja en `destination` la ruta elegida. Devuelve false (y wry
// cancela la descarga) si el usuario cierra el diálogo; en ese caso no se avisa de nada.
//
// El diálogo es SÍNCRONO a propósito: el manejador de wry tiene que responder antes de volver y
// en el mismo hilo (el principal, donde WebView2 y WKWebView despachan estos eventos; wry no
// expone el deferral de WebView2). Por eso va rfd::FileDialog directo y no tauri-plugin-dialog:
// su `blocking_save_file()` monta un rfd::AsyncFileDialog y espera en un canal (macro
// blocking_fn en plugins/dialog/src/lib.rs); en macOS ese diálogo asíncrono se presenta como
// hoja sobre la ventana principal (beginSheetModalForWindow) y solo se muestra y resuelve si el
// run loop principal gira (rfd/src/backend/macos/modal_future.rs:79-102)… que es justo el hilo
// que estaría parado en recv() → cuelgue. rfd::FileDialog::save_file corre un bucle modal
// anidado en el propio hilo (NSSavePanel.runModal / IFileDialog::Show), que es lo que hace
// falta aquí. La ventana va como padre para que en Windows el diálogo sea modal a la app y no
// se quede detrás. No se fuerza carpeta inicial: el sistema recuerda la última usada.
fn pedir_destino_descarga<R: Runtime>(webview: &Webview<R>, url: &Url, destination: &mut PathBuf) -> bool {
let ventana = webview.window();
let dialogo = rfd::FileDialog::new()
.set_title("Guardar como")
.set_file_name(nombre_sugerido(url, destination))
.set_parent(&ventana);
match dialogo.save_file() {
Some(ruta) => {
webview.state::<Descargas>().0.lock().unwrap().insert(url.to_string(), ruta.clone());
*destination = ruta;
true
}
None => false,
}
}
// Nombre que propone el diálogo: el que trae `destination` (WebView2 lo saca del
// Content-Disposition que manda el servidor; en macOS wry lo toma del suggestedFilename de
// WebKit) y, si viniera vacío, el último segmento de la URL decodificado. Nunca vacío.
fn nombre_sugerido(url: &Url, destination: &Path) -> String {
let del_motor = destination
.file_name()
.map(|n| n.to_string_lossy().trim().to_string())
.filter(|n| !n.is_empty());
if let Some(n) = del_motor {
return n;
}
url.path_segments()
.and_then(|segs| segs.rev().find(|s| !s.is_empty()))
.map(|s| percent_encoding::percent_decode_str(s).decode_utf8_lossy().trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "descarga".to_string())
}
// Notificación nativa al terminar la descarga. En Windows `path` es la ruta final real (puede
// diferir de la elegida si el motor tuvo que renombrar); en macOS llega None y se usa la ruta
// que el usuario eligió en el diálogo (ver `Descargas`).
fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path: Option<PathBuf>, success: bool) {
let elegida = webview.state::<Descargas>().0.lock().unwrap().remove(url.as_str());
let cuerpo = if !success {
"No se pudo completar la descarga.".to_string()
} else {
match path.or(elegida).as_deref().and_then(Path::file_name) {
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
None => "Descarga terminada.".to_string(),
}
};
let _ = webview
.app_handle()
.notification()
.builder()
.title("Labstream OS")
.body(cuerpo)
.show();
}
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
let target = url.unwrap_or_else(|| SERVER_URL.to_string());
// Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador.
let Ok(parsed) = Url::parse(&target) else { return };
let Ok(server) = Url::parse(SERVER_URL) else { return };
if parsed.origin() != server.origin() {
if !es_pestana_interna(&parsed, &server) {
return;
}
@@ -439,9 +643,19 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL);
let app_zoom = app.clone();
let app_navigation = app.clone();
let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed))
.initialization_script(&init)
// Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar.
.on_navigation(move |url| {
if url.scheme() == "https" && url.host_str().map(is_google_host).unwrap_or(false) {
let _ = app_navigation.opener().open_url(url.as_str(), None::<&str>);
return false;
}
true
})
// Descargas (entregables, exportaciones, enlaces `?download=1`): antes de empezar se
// PREGUNTA dónde guardar con el «Guardar como» nativo (ver pedir_destino_descarga) y al
// terminar se avisa con una notificación (ver avisar_descarga_terminada).
// El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE
// apagada. wry trae un manejador de descargas por defecto (wry-0.55.1/src/lib.rs:830),
// asi que DownloadStarting se registra siempre y siempre responde SetHandled(true)
@@ -449,30 +663,14 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
// se encarga la app: ni barra de progreso, ni globo de «descarga terminada». El
// archivo bajaba bien a la carpeta de Descargas, pero NADA lo decia y parecia roto.
// Quitar este manejador NO devuelve la interfaz nativa: el de wry sigue puesto.
.on_download(|webview, event| {
if let DownloadEvent::Finished { path, success, .. } = &event {
let cuerpo = if !*success {
"No se pudo completar la descarga.".to_string()
} else if let Some(p) = path {
// En macOS `path` llega siempre vacio (limite de WKDownload:
// wry-0.55.1/src/wkwebview/download.rs:98), asi que en la practica
// este brazo con nombre de archivo es el de Windows.
match p.file_name() {
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
None => "Descarga terminada.".to_string(),
}
} else {
"Descarga terminada. Esta en tu carpeta de Descargas.".to_string()
};
let _ = webview
.app_handle()
.notification()
.builder()
.title("Labstream OS")
.body(cuerpo)
.show();
.on_download(|webview, event| match event {
DownloadEvent::Requested { url, destination } => pedir_destino_descarga(&webview, &url, destination),
DownloadEvent::Finished { url, path, success, .. } => {
avisar_descarga_terminada(&webview, &url, path, success);
true
}
true
// El enum es #[non_exhaustive]: si wry añade eventos, dejarlos pasar.
_ => true,
})
// El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación).
.on_page_load(move |wv, payload| {
@@ -695,6 +893,10 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
let Some(win) = main_window(app) else { return };
let autostart_on = app.autolaunch().is_enabled().unwrap_or(false);
let version = format!("Versión {}", app.package_info().version);
// El cajón dice cuánto ocupa en su propia etiqueta: así vaciarlo es una decisión informada, y
// de paso es la comprobación más simple de que el caché está funcionando (el número crece al
// navegar fotos y vuelve a 0 al vaciar).
let vaciar_txt = format!("Vaciar caché de imágenes ({})", tamano_legible(cache::tamano(app)));
let build = || -> tauri::Result<Menu<R>> {
Ok(Menu::with_items(
app,
@@ -712,6 +914,7 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
&MenuItem::with_id(app, "zoom-0", "Tamaño real", true, Some("CmdOrCtrl+0"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "open-browser", "Abrir esta página en el navegador", true, None::<&str>)?,
&MenuItem::with_id(app, "clear-cache", &vaciar_txt, true, None::<&str>)?,
&CheckMenuItem::with_id(app, "autostart-toggle", "Iniciar con el sistema", true, autostart_on, None::<&str>)?,
&MenuItem::with_id(app, "check-update", "Buscar actualización…", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
@@ -725,6 +928,24 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
}
}
// Bytes → «12 MB» / «340 KB» / «1.2 GB», para la etiqueta del menú.
fn tamano_legible(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{} MB", bytes / MB)
} else if bytes >= KB {
format!("{} KB", bytes / KB)
} else if bytes == 0 {
"vacío".to_string()
} else {
format!("{bytes} B")
}
}
// Menú de CLIC DERECHO sobre una pestaña. Nativo (la barra mide 40 px: un menú HTML dentro
// del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió.
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
@@ -945,6 +1166,77 @@ fn field_i64(payload: &str, key: &str) -> Option<i64> {
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_i64()
}
fn field_bool(payload: &str, key: &str) -> Option<bool> {
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_bool()
}
// ── Esquema lsthumb:// — el cajón de miniaturas ────────────────────────────────
// La web app, dentro de la app, pide las miniaturas de la cuadrícula como
// `lsthumb://c/?u=<URL real del servidor, con su token>`. Aquí se contesta: si está en el cajón,
// del disco; si no, se trae del NAS UNA vez, se guarda y se sirve. Todo el trabajo de red y disco
// va en un hilo aparte (ureq es bloqueante) para no congelar el webview.
//
// El módulo `cache` pone los cerrojos: `permitido` (solo el servidor y su API, nunca un proxy
// abierto) y `clave_de` (identidad estable sin el token que rota).
fn respuesta_bytes(bytes: Vec<u8>) -> tauri::http::Response<Vec<u8>> {
tauri::http::Response::builder()
.status(200)
.header("Content-Type", "image/webp")
// Privada y de larga vida: quien manda de verdad es el cajón en disco; esto solo evita
// que el propio webview volviera a pedirla dentro de la misma sesión.
.header("Cache-Control", "private, max-age=86400")
.header("Access-Control-Allow-Origin", "*")
.body(bytes)
.unwrap()
}
fn respuesta_estado(code: u16) -> tauri::http::Response<Vec<u8>> {
tauri::http::Response::builder()
.status(code)
.header("Access-Control-Allow-Origin", "*")
.body(Vec::new())
.unwrap()
}
fn responder_lsthumb<R: Runtime>(
ctx: tauri::UriSchemeContext<'_, R>,
req: tauri::http::Request<Vec<u8>>,
responder: tauri::UriSchemeResponder,
) {
let app = ctx.app_handle().clone();
let objetivo = match cache::url_pedida(&req.uri().to_string()) {
Some(u) if cache::permitido(&u) => u,
_ => {
responder.respond(respuesta_estado(400));
return;
}
};
let Some(clave) = cache::clave_de(&objetivo) else {
responder.respond(respuesta_estado(400));
return;
};
// ¿En el cajón? Del disco, sin tocar la red.
if let Some(bytes) = cache::leer(&app, &clave) {
responder.respond(respuesta_bytes(bytes));
return;
}
// Falta: traerla del NAS y guardarla para la próxima. Va en el POOL de hilos de bloqueo (no un
// `std::thread::spawn` por petición): al abrir un set de 500 fotos podrían dispararse muchas a
// la vez, y el pool las acota y reutiliza sus hilos en vez de crear cientos.
tauri::async_runtime::spawn_blocking(move || match cache::traer(&objetivo) {
Ok((bytes, es_webp)) => {
if es_webp {
cache::escribir(&app, &clave, &bytes);
}
responder.respond(respuesta_bytes(bytes));
}
Err(code) => responder.respond(respuesta_estado(code)),
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default();
@@ -962,10 +1254,14 @@ pub fn run() {
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None));
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
// El cajón de miniaturas (ver el módulo `cache` y el handler de arriba). Registrado en el
// builder para que TODAS las webviews de pestaña puedan usarlo.
.register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb);
let app = builder
.manage(ShellState::default())
.manage(Descargas::default())
.setup(|app| {
let handle = app.handle().clone();
let saved = load_persisted(&handle);
@@ -994,6 +1290,7 @@ pub fn run() {
s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1);
s.win = saved.win;
s.autostart = saved.autostart;
s.dark = saved.dark;
}
// Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay.
@@ -1133,6 +1430,18 @@ pub fn run() {
}
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
"autostart-toggle" => toggle_autostart(app),
"clear-cache" => {
let antes = cache::tamano(app);
cache::vaciar(app);
// Un aviso nativo confirma que se hizo (y cuánto se liberó): sin él, «vaciar»
// no da ninguna señal y parece que no pasó nada.
let _ = app
.notification()
.builder()
.title("Labstream OS")
.body(format!("Caché de imágenes vaciado ({} liberados).", tamano_legible(antes)))
.show();
}
"check-update" => {
#[cfg(desktop)]
tauri::async_runtime::spawn(check_update_manual(app.clone()));
@@ -1166,7 +1475,7 @@ pub fn run() {
.tabs
.iter()
.enumerate()
.filter(|(_, u)| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false))
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12)
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
.collect()
@@ -1308,10 +1617,11 @@ pub fn run() {
});
let h = handle.clone();
handle.listen_any("ls-title", move |e| {
let (label, title, url) = (
let (label, title, url, dark) = (
field(e.payload(), "label"),
field(e.payload(), "title"),
field(e.payload(), "url"),
field_bool(e.payload(), "dark"),
);
if let Some(label) = label {
let shell = h.state::<ShellState>();
@@ -1324,16 +1634,24 @@ pub fn run() {
t.url = url;
}
}
// El tema es global (todas las pestañas del origen comparten modo): el
// último reporte manda, venga de la pestaña que venga.
if let Some(dark) = dark {
s.dark = Some(dark);
}
drop(s);
broadcast(&h);
save_persisted(&h);
}
});
// Icono en la bandeja del sistema con menú Abrir / Salir.
// Icono en la bandeja: Abrir / estado del rastreador + pausa / Salir.
let abrir = MenuItem::with_id(app, "abrir", "Abrir Labstream", true, None::<&str>)?;
let salir = MenuItem::with_id(app, "salir", "Salir", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&abrir, &salir])?;
let (trk_estado, trk_toggle) = tracker::armar_menu(app.handle())?;
let sep1 = PredefinedMenuItem::separator(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(app, &[&abrir, &sep1, &trk_estado, &trk_toggle, &sep2, &salir])?;
TrayIconBuilder::with_id("main-tray")
.icon(app.default_window_icon().unwrap().clone())
@@ -1346,6 +1664,7 @@ pub fn run() {
save_persisted(app);
app.exit(0);
}
id if tracker::menu_event(app, id) => {}
_ => {}
})
.on_tray_icon_event(|tray, event| {
@@ -1361,6 +1680,9 @@ pub fn run() {
})
.build(app)?;
// El sensor del rastreador: listener de vinculación + hilo de medición.
tracker::iniciar(app.handle());
Ok(())
})
.on_window_event(|window, event| {

704
src-tauri/src/tracker.rs Archivo normal
Ver fichero

@@ -0,0 +1,704 @@
// ── Rastreador de trabajo efectivo (Fase 1) ──
//
// Mide TIEMPO CON EL EQUIPO ACTIVO y en qué app: cada 5 s mira la ventana al frente
// (active-win-pos-rs) y si hubo entrada de mouse/teclado (device_query). Con 3 minutos sin
// entrada deja de contar (ocio profundo); bloquear la pantalla o suspender cae en lo mismo
// porque tampoco hay entrada. Acumula bloques {app, título, segundos, segundos activos} y los
// sube cada 5 min a POST /api/tracker con el token del equipo (ltk_). Sin red, la cola
// persiste en disco y reintenta.
//
// LO QUE NO HACE, a propósito: no registra teclas ni posiciones (solo "hubo entrada"), no
// toma pantallazos, no lista procesos de fondo. El estado es VISIBLE en el menú de la
// bandeja, con pausa de un toque — medición transparente, no spyware.
//
// La vinculación llega por el evento `ls-tracker-token`: la página de Ajustes → Perfil
// (dentro de la app, con la sesión del usuario) genera el token en el servidor y lo emite;
// aquí se guarda en rastreador.json y el sensor arranca. Un 401 del servidor = token
// revocado → se descarta y la bandeja vuelve a «sin vincular».
use std::{
collections::HashMap,
sync::Mutex,
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::{menu::MenuItem, AppHandle, Emitter, Listener, Manager, Runtime};
const API: &str = "https://os.labstreamsas.com/api/tracker";
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)]
struct Config {
token: Option<String>,
// Instante unix (seg) hasta el que está pausado; PAUSA_MANUAL = hasta reanudar a mano.
#[serde(default)]
pausado_hasta: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone)]
struct Bloque {
s: u64, // inicio en ms unix
d: u32, // segundos del bloque
a: u32, // segundos con entrada real
app: String,
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<Lote>, // lotes pendientes de subir
}
struct NucleoState(Mutex<Nucleo>);
struct MenuUi<R: Runtime> {
estado: MenuItem<R>,
toggle: MenuItem<R>,
}
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)
}
fn ruta_cfg<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
app.path().app_config_dir().ok().map(|d| d.join("rastreador.json"))
}
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)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn guardar_cfg<R: Runtime>(app: &AppHandle<R>, cfg: &Config) {
let Some(p) = ruta_cfg(app) else { return };
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(txt) = serde_json::to_string(cfg) {
let _ = std::fs::write(p, txt);
}
}
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();
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: &[Lote]) {
let Some(p) = ruta_cola(app) else { return };
if cola.is_empty() {
let _ = std::fs::remove_file(p);
return;
}
if let Ok(txt) = serde_json::to_string(cola) {
let _ = std::fs::write(p, txt);
}
}
// ── Menú de bandeja ──
// Crea los dos items del rastreador (estado informativo + pausar/reanudar) YA con el texto
// correcto: `set_text` de Tauri despacha al hilo principal y BLOQUEA esperándolo, así que
// llamarlo desde `setup()` —que corre en ese mismo hilo, antes de que el bucle de eventos
// empiece a atender— colgaría la app al abrir. Por eso el texto inicial se decide aquí y
// todos los repintados posteriores salen de hilos secundarios (ver `pintar_en_hilo`).
pub fn armar_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<(MenuItem<R>, MenuItem<R>)> {
let cfg = cargar_cfg(app);
let estado = MenuItem::with_id(app, "trk-estado", texto_estado(&cfg, "registrando"), false, None::<&str>)?;
let toggle = MenuItem::with_id(app, "trk-toggle", texto_toggle(&cfg), cfg.token.is_some(), None::<&str>)?;
app.manage(MenuState(Mutex::new(MenuUi { estado: estado.clone(), toggle: toggle.clone() })));
Ok((estado, toggle))
}
// Maneja los clics del menú del rastreador. Devuelve true si el id era nuestro.
pub fn menu_event<R: Runtime>(app: &AppHandle<R>, id: &str) -> bool {
match id {
"trk-toggle" => {
let nucleo = app.state::<NucleoState>();
let mut n = nucleo.0.lock().unwrap();
let pausado = esta_pausado(&n.cfg);
n.cfg.pausado_hasta = if pausado { None } else { Some(PAUSA_MANUAL) };
let cfg = n.cfg.clone();
drop(n);
guardar_cfg(app, &cfg);
// El clic del menú llega EN el hilo principal: repintar aquí se bloquearía a sí
// mismo. Se delega a un hilo suelto, que además responde al instante.
pintar_en_hilo(app, if esta_pausado(&cfg) { "en pausa" } else { "registrando" }, cfg);
true
}
_ => false,
}
}
fn pintar_en_hilo<R: Runtime>(app: &AppHandle<R>, estado: &str, cfg: Config) {
let h = app.clone();
let estado = estado.to_string();
thread::spawn(move || pintar_menu(&h, &estado, &cfg));
}
fn esta_pausado(cfg: &Config) -> bool {
match cfg.pausado_hasta {
Some(h) => h == PAUSA_MANUAL || h > ahora_seg(),
None => false,
}
}
fn texto_estado(cfg: &Config, estado: &str) -> String {
if cfg.token.is_none() {
"Rastreador: sin vincular (Ajustes → Perfil)".to_string()
} else {
format!("Rastreador: {estado}")
}
}
fn texto_toggle(cfg: &Config) -> &'static str {
if esta_pausado(cfg) { "Reanudar el rastreador" } else { "Pausar el rastreador" }
}
// OJO: solo desde hilos secundarios (ver `armar_menu`).
fn pintar_menu<R: Runtime>(app: &AppHandle<R>, estado: &str, cfg: &Config) {
let Some(menu) = app.try_state::<MenuState<R>>() else { return };
let ui = menu.0.lock().unwrap();
let _ = ui.estado.set_text(texto_estado(cfg, estado));
let _ = ui.toggle.set_text(texto_toggle(cfg));
let _ = ui.toggle.set_enabled(cfg.token.is_some());
}
// ── Normalización ──
fn normaliza(s: &str, max: usize) -> String {
let limpio = s.split_whitespace().collect::<Vec<_>>().join(" ");
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)]
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))]
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.
fn enviar<R: Runtime>(app: &AppHandle<R>) {
let (token, lotes) = {
let nucleo = app.state::<NucleoState>();
let n = nucleo.0.lock().unwrap();
let Some(t) = n.cfg.token.clone() else { return };
if n.cola.is_empty() {
return;
}
// 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();
let mut enviados = 0usize;
let mut revocado = false;
for lote in &lotes {
let r = ureq::post(API)
.set("Authorization", &format!("Bearer {token}"))
.timeout(Duration::from_secs(20))
.send_json(json!({
"device": { "hostname": hostname, "platform": std::env::consts::OS, "version": version },
"blocks": lote.b,
"idles": lote.i,
}));
match r {
Ok(_) => enviados += 1,
Err(ureq::Error::Status(401, _)) => {
revocado = true;
break;
}
Err(_) => break, // sin red o error del servidor: se reintenta en el próximo ciclo
}
}
let nucleo = app.state::<NucleoState>();
let mut n = nucleo.0.lock().unwrap();
if revocado {
n.cfg.token = None;
n.cola.clear();
let cfg = n.cfg.clone();
drop(n);
guardar_cfg(app, &cfg);
guardar_cola(app, &[]);
pintar_menu(app, "sin vincular", &cfg);
return;
}
// El largo se calcula ANTES del drain: dentro del argumento sería un préstamo inmutable
// mientras `n.cola` ya está prestado mutable — a través del MutexGuard (un deref
// explícito) el two-phase borrow no lo cubre, y no compila.
let hasta = enviados.min(n.cola.len());
n.cola.drain(0..hasta);
if n.cola.len() > MAX_COLA_LOTES {
let sobra = n.cola.len() - MAX_COLA_LOTES;
n.cola.drain(0..sobra); // sin red por semanas: se suelta lo más viejo, no lo nuevo
}
let cola = n.cola.clone();
drop(n);
guardar_cola(app, &cola);
}
/// Manda un evento a la página SIN bloquear el hilo del que sale. Mismo motivo que
/// `pintar_en_hilo`: un listener puede correr en el hilo principal, y cualquier cosa que
/// espere a ese hilo desde ahí cuelga la app.
fn avisar<R: Runtime>(app: &AppHandle<R>, evento: &'static str, payload: serde_json::Value) {
let h = app.clone();
thread::spawn(move || {
let _ = h.emit(evento, payload);
});
}
// ── Arranque: listener de vinculación + hilo del sensor ──
pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
let cfg = cargar_cfg(app);
let cola = cargar_cola(app);
app.manage(NucleoState(Mutex::new(Nucleo { cfg, cola })));
// El menú ya nació con el texto correcto (armar_menu); repintarlo aquí bloquearía el
// hilo principal. De aquí en adelante lo repinta el sensor cuando cambia el estado.
// La página de Ajustes (dentro de la app) entrega el token recién generado.
let h = app.clone();
app.listen_any("ls-tracker-token", move |e| {
#[derive(Deserialize)]
struct P {
token: String,
}
let Ok(p) = serde_json::from_str::<P>(e.payload()) else { return };
if !p.token.starts_with("ltk_") {
return;
}
let nucleo = h.state::<NucleoState>();
let cfg = {
let mut n = nucleo.0.lock().unwrap();
n.cfg.token = Some(p.token);
n.cfg.pausado_hasta = None;
n.cfg.clone()
};
guardar_cfg(&h, &cfg);
// ACUSE de recibo. Sin esto la página no puede distinguir «el sensor lo guardó» de
// «esta app es vieja y no había nadie escuchando», y cantaba éxito en falso dejando
// un equipo registrado que jamás reporta. Si no llega este evento, la página revoca.
avisar(&h, "ls-tracker-listo", json!({ "ok": true }));
// Sin repintar aquí: el evento puede llegar en el hilo principal. El sensor ve el
// token en su próximo tic (≤5 s) y actualiza la bandeja solo.
});
// La página pregunta cómo está ESTE equipo (¿ya vinculado? ¿en pausa?) para no ofrecer
// vincular lo que ya está vinculado y para poder avisar cuando falta hacerlo.
let h = app.clone();
app.listen_any("ls-tracker-consulta", move |_| {
let nucleo = h.state::<NucleoState>();
let cfg = nucleo.0.lock().unwrap().cfg.clone();
avisar(
&h,
"ls-tracker-estado",
json!({
"vinculado": cfg.token.is_some(),
"pausado": esta_pausado(&cfg),
"version": env!("CARGO_PKG_VERSION"),
}),
);
});
// 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));
let ahora = ahora_seg();
let (token_ok, pausado) = {
let nucleo = h.state::<NucleoState>();
let n = nucleo.0.lock().unwrap();
(n.cfg.token.is_some(), esta_pausado(&n.cfg))
};
// ¿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 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 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"
};
if estado != ultimo_estado {
let cfg = {
let nucleo = h.state::<NucleoState>();
let n = nucleo.0.lock().unwrap();
n.cfg.clone()
};
pintar_menu(&h, estado, &cfg);
ultimo_estado = estado.to_string();
}
// 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(),
d: 0,
a: 0,
app: app_name,
t: titulo,
});
b.d += TICK_SEG as u32;
if hubo_input {
b.a += TICK_SEG as u32;
}
}
}
}
// 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() || !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();
n.cola.push(lote);
n.cola.clone()
};
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.6.0",
"version": "1.16.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);
}
});