Comparar commits

...

26 Commits

Autor SHA1 Mensaje Fecha
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
Munka
44898da17c Versión 1.6.0 y README al día
Sube la versión en los cuatro sitios que tienen que coincidir (package.json,
Cargo.toml, Cargo.lock y tauri.conf.json). Así el instalador de prueba se presenta
como v1.6.0 en la barra en vez de repetir v1.5.1, que al probarlo encima de la app
instalada no dejaba saber cuál se está corriendo.

El README describía el comportamiento viejo: decía que en Windows se conserva el
título del sistema con la barra debajo, y que la actualización automática era un
paso futuro cuando lleva funcionando desde la v1.4.0. Ahora cuenta lo que hay: la
barra como fila del título en ambos sistemas, los colores de pestaña, que un commit
sin etiqueta no le llega a nadie, y cómo probar un cambio con el instalador de
prueba del CI sin publicárselo al equipo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:19:02 -05:00
Munka
2c872ace7d Pestañas de colores, como los grupos de Chrome
Con la barra ya en la fila del título, teñir es lo que hace que un montón de
pestañas abiertas se lea de un vistazo: todo lo de un cliente en verde, lo que está
en revisión en rojo. Se elige con clic derecho sobre la pestaña ▸ Color, con marca
en el que tiene puesto, y "Sin color" para quitarlo.

Nueve colores, los mismos que usa Chrome para sus grupos. El color se guarda con la
sesión (lista `colors` en estado.json, paralela a `tabs`), así que sobrevive a
cerrar la app. Un estado.json viejo sin colores carga igual: la lista entra por
serde default y todas las pestañas salen sin teñir.

Cómo se pinta: la inactiva lleva el color de fondo translúcido y se le quita el
separador vertical (con fondo propio ya se ve dónde acaba). La ACTIVA no se tiñe
entera —tiene que seguir fundida con el contenido, que es lo que le da el aspecto de
Chrome— sino que lleva el color en una franja de 3 px arriba. En el CSS el color va
en componentes RGB sueltos (`--tc: r, g, b`) para sacar de una sola definición tanto
el tono sólido como el fondo translúcido, en vez de repetir nueve colores por tema.

Al restaurar la sesión, el color se empareja con su URL ANTES de filtrar por origen:
el filtro puede descartar entradas y entonces los índices de las dos listas dejarían
de casar, y las pestañas saldrían con el color de otra.

El id que llega del menú se valida contra la paleta antes de guardarlo; uno
desconocido deja la pestaña sin color en vez de escribir cualquier cosa en el disco.

Verificado: sintaxis del script con node --check, y en el navegador que la inactiva
se tiñe, que la activa conserva su fondo de tarjeta con la franja del color encima,
y que el separador desaparece en las teñidas. La compilación la verifica el CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:39:10 -05:00
Munka
fb4a441fa0 El CI de rama deja también un instalador de prueba
Verificar que compila no basta para un cambio que se ve: la barra de pestañas hay
que MIRARLA. El job nuevo construye el .exe de Windows y lo cuelga de la ejecución
como artifact, sin tocar Releases ni el updater. Así se puede probar el cambio en la
app de verdad antes de publicárselo al equipo, que en la práctica no tiene vuelta
atrás porque las apps instaladas se actualizan solas.

Firma con la clave del repo porque tauri.conf.json pide artefactos de updater; el
.exe se queda en el artifact igualmente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:51:56 -05:00
Munka
9489d6cc58 Las pestañas ocupan la fila del título también en Windows
En Windows la ventana conservaba la barra de título del sistema y la barra de
pestañas iba DEBAJO: una fila entera gastada en repetir el nombre de la app. En
macOS ya no pasaba (TitleBarStyle::Overlay desde la v1.4.2). Ahora las dos se
comportan igual, y como Chrome: la barra de pestañas ES la fila del título.

- Ventana sin decoración en Windows (decorations(false)). resizable(true) conserva
  WS_THICKFRAME, así que los bordes siguen redimensionando y Aero Snap (arrastrar a
  un borde, Win+flechas) sigue vivo.
- La barra dibuja sus propios botones minimizar/maximizar/cerrar con las medidas y
  colores del sistema (46 px de ancho, alto completo de la barra, esquinas rectas,
  rojo #c42b1c al posarse en el aspa) y los glifos de Segoe Fluent Icons. Actúan
  por el evento nuevo ls-window; cerrar sigue pasando por CloseRequested, o sea que
  oculta a la bandeja igual que antes.
- El icono del medio alterna maximizar/restaurar según el estado. El repintado se
  dispara solo cuando ese estado CAMBIA, no en cada píxel del redimensionado.
- La geometría maximizada ya no se guarda entre sesiones: sin decoración, al
  reabrir volvería como ventana normal del tamaño de la pantalla y taparía la barra
  de tareas. Se conserva la última geometría restaurada.
- Doble clic en el hueco libre de la barra ahora maximiza/restaura (lo gobierna
  data-tauri-drag-region), como la barra de título del sistema y como Chrome. Antes
  abría una pestaña nueva; para eso quedan el botón + y Cmd/Ctrl+T.

NO incluye los Snap Layouts de Windows 11 (el menú que asoma al posar el cursor
sobre maximizar). Requieren que la ventana conteste WM_NCHITTEST con HTMAXBUTTON, y
aquí ese botón lo tapa el webview hijo de la barra, que se queda con los mensajes
del ratón: la ventana principal nunca los ve. Es la misma razón por la que Electron
necesita botones NATIVOS (titleBarOverlay) para poder ofrecerlo. El resto de Aero
Snap no se ve afectado.

Se añade .github/workflows/check.yml: cargo check en Windows y macOS al empujar una
rama, sin publicar nada. Hasta ahora solo existía el workflow de publicar (etiquetas
v*), así que no había forma de comprobar que algo compilaba sin sacar un release.

Verificado: sintaxis del script de la barra con node --check; medidas, clases y
estados de los botones comprobados en el navegador (46 px, pegados al borde, ocultos
en macOS, icono y rótulo correctos al maximizar, y el arrastre solo en el hueco
libre, nunca en los botones). La compilación la verifica el workflow nuevo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:35:10 -05:00
Jonathan David Florez Salomón
d302591c25 v1.5.1: el shell dejaba de meterse dentro del editor de OnlyOffice en Windows
OnlyOffice fallaba al editar y al descargar SOLO en la app de escritorio de
Windows. En el navegador y en la app de macOS iba bien. La causa estaba en el
propio shell, no en el servidor de documentos.

En Windows, wry inyecta los scripts de inicio en TODOS los marcos e ignora
for_main_frame_only (wry-0.55.1/src/webview2/mod.rs:492-495, documentado en su
lib.rs:990); macOS si respeta la bandera (wkwebview/mod.rs:643-645). Y __TAURI__
viaja con ellos, asi que la guarda «if (!T || !T.event) return» no frenaba nada.
Resultado: INIT_JS_TPL corria DENTRO del iframe del Document Server con ORIGIN
apuntando todavia a la app; como alli location.href es el de docs.*, cada enlace
del editor se resolvia contra ese origen, daba isExt() = true y se desviaba al
navegador del sistema —que no tiene la sesion: «no se puede acceder»—, y
window.open devolvia null.

Cambios:

1. Guarda de marco y origen al principio del script inyectado: solo actua en el
   documento PRINCIPAL y solo en el origen de la app. En macOS es un no-op (alli
   nunca corrio en subframes). De paso arregla que en Windows el shell tambien
   secuestrara los enlaces de las paginas de SSO.

2. El manejador de atajos ignora e.altKey. AltGr enciende ctrlKey en Windows, asi
   que escribir [ o ] con teclado espanol disparaba history.back()/forward() en
   vez de escribir el caracter — en el editor y en cualquier campo de la app.

3. Aviso nativo al terminar una descarga. No es un adorno: wry trae un manejador
   de descargas por defecto (wry-0.55.1/src/lib.rs:830), de modo que en WebView2
   siempre se registra DownloadStarting y siempre responde SetHandled(true)
   (webview2/mod.rs:858-861), lo que apaga la interfaz de descargas de Windows.
   El archivo bajaba a Descargas pero nada lo decia y parecia roto. Quitar el
   manejador NO devuelve la interfaz nativa: el de wry sigue puesto.

4. Feature «devtools» de tauri: el inspector queda disponible tambien en release.
   Sin el, un fallo que solo aparece en la app instalada no se puede mirar por
   dentro.

Descartado a proposito: registrar on_new_window. En macOS el delegado ejecuta
webview.window().unwrap() y current_window.screen().unwrap() ANTES del match
(wry-0.55.1/src/wkwebview/class/wry_web_view_ui_delegate.rs:150-154), tambien en
la rama Deny; y este shell oculta la ventana al cerrar, con lo que NSWindow.screen
puede ser nil y la app entera abortaria. Cambiar un fallo de Windows por un crash
en macOS no compensa.

Verificado: cargo check limpio; sintaxis del script inyectado validada con node;
y simulacion de la guarda — marco principal + origen propio interviene, iframe de
OnlyOffice no interviene, origen ajeno no interviene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:12:22 -05:00
Se han modificado 11 ficheros con 2597 adiciones y 113 borrados

102
.github/workflows/check.yml vendido Archivo normal
Ver fichero

@@ -0,0 +1,102 @@
name: Verificar compilación (sin publicar)
# Comprueba que el shell COMPILA en Windows y macOS sin construir instaladores ni tocar
# Releases. Sirve para revisar un cambio en una rama antes de etiquetar la versión: el
# workflow de publicar solo se dispara con etiquetas `v*`, así que hasta ahora no había
# forma de saber si algo compilaba sin publicarlo.
on:
push:
branches-ignore:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read # solo lee el repo: no crea releases ni sube nada
jobs:
check:
strategy:
fail-fast: false
matrix:
platform: [windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Clonar repo
uses: actions/checkout@v4
- name: Instalar Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Instalar Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache de Rust
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Instalar dependencias
run: npm install
# 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
- name: cargo check
working-directory: src-tauri
run: cargo check --all-targets
# Avisa de errores reales de Clippy sin tumbar la verificación por avisos de estilo.
- name: cargo clippy (informativo)
working-directory: src-tauri
continue-on-error: true
run: cargo clippy --all-targets
# Instalador de PRUEBA para Windows. Queda colgado de la ejecución como artifact y no
# toca Releases ni el updater: sirve para VER el cambio funcionando antes de publicárselo
# a todo el equipo, que es irreversible en la práctica (las apps se actualizan solas).
instalador-prueba:
runs-on: windows-latest
steps:
- name: Clonar repo
uses: actions/checkout@v4
- name: Instalar Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Instalar Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache de Rust
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Instalar dependencias
run: npm install
- name: Generar iconos (desde app-icon.png)
run: npm run icons
# La firma hace falta porque tauri.conf.json pide artefactos de updater; el .exe se
# queda igualmente aquí, sin publicarse en ningún sitio.
- name: Construir el instalador (NSIS)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --bundles nsis
- name: Guardar el instalador como artifact
uses: actions/upload-artifact@v4
with:
name: instalador-windows-prueba
path: src-tauri/target/release/bundle/nsis/*.exe
retention-days: 14

Ver fichero

@@ -12,6 +12,14 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
la app** — antes morían en silencio. También ⌘/Ctrl+clic y clic central.
Atajos: ⌘/Ctrl+T nueva, ⌘/Ctrl+W cerrar, Ctrl+Tab rotar, ⌘/Ctrl+1…9 ir a la n.
La sesión (pestañas abiertas, activa) **se restaura** al reabrir.
- **La barra de pestañas ES la fila del título** (v1.6.0), en Windows y en macOS:
no se gasta una franja entera en repetir el nombre de la app. En Windows la
ventana va sin decoración y la propia barra dibuja los botones minimizar /
maximizar / cerrar; en macOS el semáforo lo sigue pintando el sistema y la barra
le deja su hueco. El doble clic en el hueco libre maximiza y restaura.
- **Colores de pestaña** (v1.6.0): clic derecho sobre una pestaña ▸ **Color**, con
la misma paleta de nueve tonos que usa Chrome para sus grupos. Sirve para agrupar
de un vistazo (todo lo de un cliente en verde) y **se guarda con la sesión**.
- **Zoom de interfaz**: ⌘/Ctrl `+` / `−` / `0` y Ctrl+rueda, con indicador en la
barra y **persistencia** entre sesiones (50%–250%).
- **Documentos dentro de la app**: los visores/editores del propio servidor
@@ -31,6 +39,46 @@ 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-*`.
@@ -133,22 +181,29 @@ el de Mac a la vez, así ambas plataformas van siempre sincronizadas.
---
## Actualización automática (opcional, recomendable a futuro)
## Actualización automática (ACTIVA)
Hoy, para actualizar, el equipo descarga el nuevo instalador del Release y lo
reinstala encima. Para que las apps ya instaladas se actualicen solas, Tauri trae
un *updater* (funciona igual en Windows y Mac). Para activarlo (paso futuro):
Las apps ya instaladas **se actualizan solas**: el updater consulta el `latest.json`
que publica cada Release, y los instaladores se firman en el CI con la clave privada
guardada como secret del repo (`TAURI_SIGNING_PRIVATE_KEY`). También se puede forzar
la comprobación con clic en la versión de la barra, o desde el menú ⋮ ▸ *Buscar
actualización…*.
1. Genera el par de claves de firma:
```bash
npm run tauri signer generate -- -w ~/.tauri/labstream.key
```
2. Añade el `plugin-updater` y la clave pública en `tauri.conf.json`, y guarda la
clave privada como **secret** en GitHub (`TAURI_SIGNING_PRIVATE_KEY`).
3. Apunta el updater al `latest.json` que publica el Release.
> ⚠️ De ahí se sigue algo que conviene tener presente: **un commit en `main` no le
> llega a nadie**. Mientras no exista la etiqueta `vX.Y.Z`, el CI no construye nada y
> el updater no ve versión nueva. Y al revés: publicar una etiqueta **actualiza la app
> de todo el equipo**, así que no es un paso que se dé a la ligera.
Se deja sin activar a propósito para que la **primera** versión compile sin
configuración extra.
## Probar un cambio antes de publicarlo
`.github/workflows/check.yml` corre en cualquier rama que no sea `main` y **no publica
nada**:
- `cargo check` en Windows y macOS — comprueba que compila en las dos.
- Un job aparte deja el **instalador `.exe` de prueba** como *artifact* de la ejecución
(14 días), para instalarlo encima y ver el cambio funcionando de verdad.
Es la forma de mirar un cambio visual —la barra, los colores— sin sacárselo al equipo.
---

Archivo binario no mostrado.

Antes

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

Después

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

168
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;
@@ -135,6 +166,34 @@
right: -8px;
background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px);
}
/* ── Color de pestaña ────────────────────────────────────────────────────────────
Misma idea que los grupos de Chrome: teñir para agrupar de un vistazo («todo lo de
este cliente, en verde»). El color se elige con clic derecho sobre la pestaña ▸ Color,
y se guarda con la sesión.
Va en componentes RGB sueltos (`--tc: r, g, b`) para sacar de UNA sola definición
tanto el tono sólido como el fondo translúcido, sin repetir nueve colores por tema.
La pestaña ACTIVA no se tiñe entera: tiene que seguir fundida con el contenido, así
que lleva el color en una franja superior — igual que Chrome. */
.tab[data-color="gris"] { --tc: 95, 99, 104; }
.tab[data-color="azul"] { --tc: 26, 115, 232; }
.tab[data-color="rojo"] { --tc: 217, 48, 37; }
.tab[data-color="amarillo"] { --tc: 249, 171, 0; }
.tab[data-color="verde"] { --tc: 30, 142, 62; }
.tab[data-color="rosa"] { --tc: 208, 24, 132; }
.tab[data-color="morado"] { --tc: 147, 52, 230; }
.tab[data-color="cian"] { --tc: 0, 123, 131; }
.tab[data-color="naranja"] { --tc: 250, 144, 62; }
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.32); }
/* Con fondo propio el separador vertical sobra: ya se ve dónde acaba cada pestaña. */
.tab[data-color]:not(.active)::before { display: none; }
.tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
@media (prefers-color-scheme: dark) {
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
: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. */
@@ -205,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;
@@ -233,6 +303,31 @@
border-radius: 13px;
}
#version:hover { color: var(--accent); }
/* ── Botones de ventana ──────────────────────────────────────────────────────────
Solo en Windows: allí la ventana va SIN decoración, así que esta barra es la fila
del título (como Chrome) y el aspa del sistema no existe. Medidas y colores del
propio Windows 11: 46 px de ancho, alto completo de la barra, esquinas rectas
hasta el borde, y rojo #c42b1c en el aspa al pasar por encima.
Los glifos salen de la fuente de iconos del sistema (Fluent en Win 11, MDL2 en 10). */
#wincontrols { display: none; }
body.win #wincontrols {
display: flex;
flex: 0 0 auto;
align-self: stretch;
margin-left: 6px;
}
/* Los botones tienen que morder el borde derecho: la barra cede su margen. */
body.win #bar { padding-right: 0; }
#wincontrols button {
width: 46px;
border-radius: 0;
color: var(--fg);
font-family: "Segoe Fluent Icons", "Segoe MDL2 Assets", system-ui;
font-size: 10px;
line-height: 1;
}
#wincontrols button:hover { background: var(--btn-hover); }
#w-cerrar:hover { background: #c42b1c; color: #fff; }
</style>
</head>
<body>
@@ -241,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>
@@ -251,6 +347,12 @@
</div>
<button id="version" title="Versión de la app · clic: buscar actualización"></button>
<button class="icono" id="menu" title="Opciones de la app">⋮</button>
<!-- Solo visibles en Windows (clase `win` del body): allí no hay barra de título. -->
<div id="wincontrols">
<button id="w-min" title="Minimizar">&#xE921;</button>
<button id="w-max" title="Maximizar">&#xE922;</button>
<button id="w-cerrar" title="Cerrar">&#xE8BB;</button>
</div>
</div>
<script>
const T = window.__TAURI__;
@@ -264,6 +366,7 @@
const $tabs = document.getElementById("tabs");
const $pct = document.getElementById("z-pct");
const $ver = document.getElementById("version");
const $wmax = document.getElementById("w-max");
// Nombre + icono de cada pestaña a partir de su RUTA (mismas etiquetas que la barra
// superior de la web). Sin esto todas las pestañas se llamaban «(25) Labstream OS» y
@@ -385,6 +488,7 @@
const el = document.createElement("div");
el.className = "tab" + (t.active ? " active" : "");
el.dataset.label = t.label;
if (t.color) el.dataset.color = t.color; // teñido desde el menú de la pestaña
el.title = texto;
const fav = document.createElement("span");
@@ -431,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");
@@ -439,8 +552,22 @@
$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
// pinta esta barra: el del medio alterna entre maximizar (▢) y restaurar (❐).
document.body.classList.toggle("win", !!estado.win);
if (estado.win) {
const max = !!estado.maximized;
$wmax.textContent = max ? "\uE923" : "\uE922"; // ChromeRestore / ChromeMaximize
$wmax.title = max ? "Restaurar" : "Maximizar";
}
}
if (E) {
@@ -454,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", {}));
@@ -466,10 +592,14 @@
document.getElementById("z-menos").addEventListener("click", () => emitir("ls-zoom", { dir: -1 }));
document.getElementById("z-mas").addEventListener("click", () => emitir("ls-zoom", { dir: 1 }));
$pct.addEventListener("click", () => emitir("ls-zoom", { dir: 0 }));
// Doble clic en el hueco libre de la barra = pestaña nueva.
$tabs.addEventListener("dblclick", (e) => {
if (e.target === $tabs) emitir("ls-new-tab", {});
});
// Botones de ventana (Windows). Cerrar oculta a la bandeja, como el aspa de siempre.
document.getElementById("w-min").addEventListener("click", () => emitir("ls-window", { act: "min" }));
$wmax.addEventListener("click", () => emitir("ls-window", { act: "max" }));
document.getElementById("w-cerrar").addEventListener("click", () => emitir("ls-window", { act: "close" }));
// El doble clic en el hueco libre lo gobierna `data-tauri-drag-region`: maximiza y
// restaura, como la barra de título del sistema y como la de pestañas de Chrome.
// (Antes abría una pestaña nueva; ahora que la barra ES el título, chocaba con él.
// Para una pestaña nueva quedan el botón + y ⌘/Ctrl+T.)
// Atajos también cuando el foco está en la barra.
addEventListener("keydown", (e) => {

Ver fichero

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

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.5.0"
version = "1.14.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"
@@ -15,7 +15,11 @@ tauri-build = { version = "2", features = [] }
[dependencies]
# `unstable` habilita multiwebview: varios webviews (pestañas) dentro de UNA ventana.
tauri = { version = "2", features = ["tray-icon", "unstable"] }
# `devtools` deja el inspector disponible TAMBIÉN en release (por defecto solo va en debug:
# wry-0.55.1/src/lib.rs:834-837). Sin él, un fallo que solo aparece en la app instalada —como
# el de OnlyOffice en Windows— no se puede mirar por dentro y hay que deducirlo leyendo
# fuentes. Con él, clic derecho → Inspeccionar sobre el marco que falle y se acabó.
tauri = { version = "2", features = ["tray-icon", "unstable", "devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-notification = "2"
@@ -24,3 +28,36 @@ tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# ── 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",
] }

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

@@ -32,6 +32,9 @@
// `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.
mod cache;
mod tracker;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
@@ -39,7 +42,7 @@ use serde_json::json;
use tauri::{
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::{PageLoadEvent, Webview, WebviewBuilder},
webview::{DownloadEvent, PageLoadEvent, Webview, WebviewBuilder},
window::{Window, WindowBuilder},
AppHandle, Emitter, EventTarget, Listener, LogicalPosition, LogicalSize, Manager, Runtime,
Url, WebviewUrl, WindowEvent,
@@ -50,6 +53,44 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
const SERVER_URL: &str = "https://os.labstreamsas.com";
// Los documentos del contador viven en Google (pestaña Contable). Se abren como PESTANAS
// de esta app, no en el navegador externo: asi el WebView guarda la sesion de Google del
// usuario (cookies persistentes) y no hay que iniciar sesion cada vez. La lista es cerrada
// a proposito: solo hosts propios de Google (docs/drive/sheets y accounts para el login).
// Todo lo demas sigue yendo al navegador del sistema.
const GOOGLE_HOSTS: &[&str] = &[
"docs.google.com",
"drive.google.com",
"sheets.google.com",
"accounts.google.com",
"myaccount.google.com",
];
// Cadena de navegador de escritorio. Sin esto, WebView2 se anuncia como control embebido y
// Google Sheets/Docs se quedan en blanco. Se declara por plataforma porque Google tambien
// mira la coherencia entre el sistema declarado y el resto de la cadena.
#[cfg(target_os = "windows")]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
#[cfg(target_os = "macos")]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
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 {
if target.origin() == server.origin() {
return true;
}
if target.scheme() != "https" {
return false;
}
target.host_str().map(is_google_host).unwrap_or(false)
}
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
@@ -66,8 +107,25 @@ struct TabRec {
title: String,
url: String,
loading: bool, // la barra pinta un giro en el icono mientras carga
// Color elegido a mano desde el menú de la pestaña (None = sin teñir). Aquí solo vive el
// NOMBRE; el tono exacto y cómo se pinta son cosa del CSS de la barra.
color: Option<String>,
}
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
// un vistazo («todo lo de este cliente en verde»). El id viaja en el payload de `ls-tabs`.
const TAB_COLORS: &[(&str, &str)] = &[
("gris", "Gris"),
("azul", "Azul"),
("rojo", "Rojo"),
("amarillo", "Amarillo"),
("verde", "Verde"),
("rosa", "Rosa"),
("morado", "Morado"),
("cian", "Cian"),
("naranja", "Naranja"),
];
struct Shell {
tabs: Vec<TabRec>,
active: usize,
@@ -77,11 +135,18 @@ struct Shell {
autostart: Option<bool>, // elección del usuario en el menú ⋮ (None = por defecto, activado)
closed: Vec<String>, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T)
menu_tab: Option<String>, // pestaña sobre la que se abrió el menú de clic derecho
// Ú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 }
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 }
}
}
@@ -104,10 +169,18 @@ struct Persisted {
win: Option<WinRect>,
#[serde(default)]
tabs: Vec<String>,
// Color de cada pestaña, en el mismo orden que `tabs` ("" = sin teñir). Lista aparte para
// que un estado.json viejo (sin colores) siga cargando tal cual.
#[serde(default)]
colors: Vec<String>,
#[serde(default)]
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> {
@@ -130,8 +203,10 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
zoom_idx: Some(s.zoom_idx),
win: s.win,
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
active: s.active,
autostart: s.autostart,
dark: s.dark,
}
};
if let Some(dir) = path.parent() {
@@ -148,21 +223,45 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
// - Reporta título/URL a la barra (observer del <title> + pushState del SPA).
// - Atajos de teclado (en macOS los consume antes el menú nativo; esto cubre Windows y extras)
// y Ctrl+rueda para zoom.
// En páginas de OTRO origen (p. ej. el login de Authentik) __TAURI__ no está inyectado y el
// script sale al principio sin tocar nada — la navegación del SSO sigue intacta.
// SOLO debe gobernar el documento principal de NUESTRO origen; ver la guarda de abajo.
const INIT_JS_TPL: &str = r#"
(function () {
var ORIGIN = "__ORIGIN__";
// ── GUARDA DE MARCO Y ORIGEN ──
// Antes aquí solo estaba `if (!T || !T.event) return;`, confiando en que __TAURI__ no
// existiría fuera del origen permitido. Eso es cierto en macOS y FALSO en Windows:
// · wry inyecta los scripts de inicio en TODOS los marcos e IGNORA for_main_frame_only
// (wry-0.55.1/src/webview2/mod.rs:492-495, documentado en su lib.rs:990), mientras que
// WKWebView sí la respeta (wry-0.55.1/src/wkwebview/mod.rs:643-645);
// · y __TAURI__ viaja con ellos (withGlobalTauri, tauri/src/manager/webview.rs:159-165).
// Consecuencia real: dentro del iframe del editor de OnlyOffice (docs.labstreamsas.com)
// este script SÍ corría en Windows, con ORIGIN apuntando todavía a la app. Como allí
// `location.href` es el del Document Server, abs() resolvía CADA enlace del editor contra
// docs.* → isExt() cierto → preventDefault() + mandarlo al navegador del sistema (que no
// tiene la sesión: «no se puede acceder»), y window.open devolvía null. Por eso editar y
// 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; }
// Hosts de Google que la app abre como pestana propia (pestana Contable). En esas
// paginas solo reportamos titulo a la barra; NO tocamos clics ni window.open para no
// romper el editor de Google.
var GOOGLE_RE = /^(?:.*\.)?(?:docs|drive|sheets|accounts|myaccount)\.google\.com$/i;
var esGoogle = GOOGLE_RE.test(location.hostname);
if (location.origin !== ORIGIN && !esGoogle) return;
if (window.__lsShell) return;
window.__lsShell = true;
var T = window.__TAURI__;
if (!T || !T.event) return; // página fuera del origen permitido (p. ej. SSO): no intervenir
if (!T || !T.event) return;
var LABEL = "__LABEL__";
var ORIGIN = "__ORIGIN__";
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 isGoogleUrl(u) { return isHttp(u) && GOOGLE_RE.test(u.hostname); }
function isInterno(u) { return isApp(u) || isGoogleUrl(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 }); }
@@ -170,9 +269,107 @@ 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).
// ── Miniaturas por el CAJÓN de la app (lsthumb://) ──
// Dentro de la app de escritorio, las miniaturas de la cuadrícula de fotos se piden por el
// esquema lsthumb:// en vez de directo al servidor. Así las sirve el cajón local (Rust): del
// disco si ya están —sobreviven a cerrar la app, que es lo que se pedía— y del NAS solo la
// primera vez. Fuera de la app (navegador normal) este script no corre y todo sigue igual.
//
// Se reescribe SOLO la miniatura de 480 px (thumb=1): es la que se repite 500 veces por set y
// ahoga la cuadrícula. El visor grande (thumb=xl) y la anotación sobre lienzo NO se tocan —
// cargar la imagen del lienzo por otro esquema lo «ensuciaría» y rompería «guardar el dibujo».
try {
function porCajon(src) {
if (!src || src.lastIndexOf('lsthumb:', 0) === 0) return src; // ya reescrita
var u = abs(src);
if (!u || u.origin !== ORIGIN) return src; // solo lo del propio servidor
if (!/\/api\/files-asset\//.test(u.pathname) || u.searchParams.get('thumb') !== '1') return src;
return 'lsthumb://c/?u=' + encodeURIComponent(u.href);
}
var proto = HTMLImageElement.prototype;
// 1) La propiedad .src (lo que usa React al asignar la imagen).
var d = Object.getOwnPropertyDescriptor(proto, 'src');
if (d && d.get && d.set) {
Object.defineProperty(proto, 'src', {
configurable: true, enumerable: d.enumerable,
get: function () { return d.get.call(this); },
set: function (v) { d.set.call(this, porCajon(String(v))); },
});
}
// 2) setAttribute('src', …), que no pasa por el setter de la propiedad. Se parchea SOLO en
// <img> (prototipo de HTMLImageElement), no en todos los elementos.
var setA = proto.setAttribute;
proto.setAttribute = function (name, value) {
if (arguments.length >= 2 && String(name).toLowerCase() === 'src') {
return setA.call(this, name, porCajon(String(value)));
}
return setA.apply(this, arguments);
};
// 3) Las que ya vienen en el HTML del servidor (SSR) o llegan luego. porCajon es idempotente
// (una ya reescrita se devuelve igual), así que barrer no entra en bucle con el observer.
function barrer(raiz) {
if (!raiz || !raiz.querySelectorAll) return;
var imgs = raiz.querySelectorAll('img[src]');
for (var i = 0; i < imgs.length; i++) {
var cur = imgs[i].getAttribute('src');
var nue = porCajon(cur);
if (nue !== cur) imgs[i].setAttribute('src', nue);
}
}
if (window.MutationObserver) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var m = muts[i];
if (m.type === 'attributes' && m.target && m.target.tagName === 'IMG') {
var cur = m.target.getAttribute('src'), nue = porCajon(cur);
if (nue !== cur) m.target.setAttribute('src', nue);
}
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (!n || n.nodeType !== 1) continue;
if (n.tagName === 'IMG') { var c = n.getAttribute('src'), x = porCajon(c); if (x !== c) n.setAttribute('src', x); }
else barrer(n);
}
}
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { barrer(document); });
else barrer(document);
} catch (e) { /* si algo falla, las imágenes cargan directo del servidor como siempre */ }
// ── ¿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');
@@ -191,6 +388,19 @@ 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); });
if (esGoogle) return; // en Google solo miramos el titulo
// 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) {
@@ -201,6 +411,9 @@ const INIT_JS_TPL: &str = r#"
if (!u) return;
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
var mod = e.metaKey || e.ctrlKey;
// Enlace a Google (docs, drive, sheets, accounts): SIEMPRE como pestana propia
// aunque no lleve target=_blank, para que la sesion viva dentro de la app.
if (isGoogleUrl(u)) { e.preventDefault(); newTab(u.href); return; }
if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); }
}, true);
@@ -211,19 +424,24 @@ 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;
};
// Atajos de teclado.
addEventListener('keydown', function (e) {
// AltGr enciende ctrlKey en Windows. Sin este filtro, escribir [ o ] con teclado
// español —que se hacen con AltGr— entraba por aquí y disparaba history.back() /
// history.forward() en vez de escribir el carácter: en el editor, en el chat, en una
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
if (e.altKey) return;
var mod = e.metaKey || e.ctrlKey;
if (!mod) return;
var k = e.key;
@@ -299,6 +517,8 @@ fn current_zoom<R: Runtime>(app: &AppHandle<R>) -> f64 {
// Manda el estado de pestañas + zoom + versión a la barra (webview "chrome").
fn broadcast<R: Runtime>(app: &AppHandle<R>) {
// Se consulta ANTES de tomar el lock del shell: así no se anidan dos cerrojos.
let maximized = main_window(app).and_then(|w| w.is_maximized().ok()).unwrap_or(false);
let payload = {
let shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
@@ -306,15 +526,21 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
.tabs
.iter()
.enumerate()
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active }))
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
.collect();
json!({
"tabs": tabs,
"zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32,
"version": app.package_info().version.to_string(),
// En macOS la barra ocupa la fila del título (ventana con título en overlay) y
// debe dejar libre el hueco del semáforo; en Windows hay título nativo aparte.
// debe dejar libre el hueco del semáforo, que lo pinta el sistema.
"mac": cfg!(target_os = "macos"),
// En Windows la ventana va SIN decoración: la barra es la fila del título, así
// 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);
@@ -367,7 +593,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
// 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;
}
@@ -376,7 +602,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let mut s = shell.lock().unwrap();
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true });
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None });
label
};
@@ -387,9 +613,41 @@ 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 builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed))
.user_agent(UA_ESCRITORIO)
.initialization_script(&init)
// Deja pasar las descargas (entregables, exportaciones) con el diálogo nativo de guardado.
.on_download(|_webview, _event| true)
// Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar.
// 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)
// (wry-0.55.1/src/webview2/mod.rs:858-861), lo que le dice a WebView2 que del aviso
// 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();
}
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| {
let cargando = matches!(payload.event(), PageLoadEvent::Started);
@@ -611,6 +869,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,
@@ -628,6 +890,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)?,
@@ -641,24 +904,57 @@ 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) {
let Some(win) = main_window(app) else { return };
let (idx, total) = {
let (idx, total, actual) = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
s.menu_tab = Some(label.to_string());
(i, s.tabs.len())
let actual = s.tabs[i].color.clone();
(i, s.tabs.len(), actual)
};
// Los ids se arman fuera del builder para poder pasarlos como &str (igual que el resto
// del menú) en vez de mezclar String y &str en el mismo constructor.
let ids: Vec<String> = TAB_COLORS.iter().map(|(id, _)| format!("tab-color-{id}")).collect();
let build = || -> tauri::Result<Menu<R>> {
// Submenú de color, con marca en el que tiene puesto ahora.
let mut colores: Vec<CheckMenuItem<R>> = Vec::with_capacity(TAB_COLORS.len() + 1);
colores.push(CheckMenuItem::with_id(app, "tab-color-ninguno", "Sin color", true, actual.is_none(), None::<&str>)?);
for ((cid, nombre), id) in TAB_COLORS.iter().zip(ids.iter()) {
let marcado = actual.as_deref() == Some(*cid);
colores.push(CheckMenuItem::with_id(app, id.as_str(), *nombre, true, marcado, None::<&str>)?);
}
let refs: Vec<&dyn tauri::menu::IsMenuItem<R>> = colores.iter().map(|c| c as &dyn tauri::menu::IsMenuItem<R>).collect();
let submenu = Submenu::with_items(app, "Color", true, &refs)?;
Ok(Menu::with_items(
app,
&[
&MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?,
&MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
&submenu,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "tab-close", "Cerrar pestaña", true, None::<&str>)?,
&MenuItem::with_id(app, "tab-close-others", "Cerrar las demás", total > 1, None::<&str>)?,
&MenuItem::with_id(app, "tab-close-right", "Cerrar las de la derecha", idx + 1 < total, None::<&str>)?,
@@ -705,6 +1001,24 @@ fn tab_menu_action<R: Runtime>(app: &AppHandle<R>, action: &str) {
close_tab(app, &l);
}
}
// Teñir la pestaña (o quitarle el color). Se valida contra la paleta: un id que no
// esté en TAB_COLORS deja la pestaña sin color en vez de guardar cualquier cosa.
a if a.starts_with("tab-color-") => {
let elegido = &a["tab-color-".len()..];
let nuevo = TAB_COLORS
.iter()
.find(|(id, _)| *id == elegido)
.map(|(id, _)| (*id).to_string());
{
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) {
t.color = nuevo;
}
}
broadcast(app);
save_persisted(app);
}
_ => {}
}
}
@@ -828,6 +1142,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();
@@ -845,7 +1230,10 @@ 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())
@@ -877,13 +1265,18 @@ 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.
// macOS: título en OVERLAY y sin texto → el contenido (y por tanto la barra de
// pestañas) ocupa también la fila del título, al estilo de Notion/VS Code. Los
// botones del semáforo siguen siendo los nativos; la barra les deja su hueco.
// En Windows se conserva el título del sistema con la barra debajo.
// En AMBOS sistemas la barra de pestañas ocupa la fila del título, como Chrome:
// no se desperdicia una franja entera en repetir el nombre de la app.
// - macOS: título en OVERLAY y sin texto; el semáforo lo sigue pintando el sistema
// encima y la barra le deja su hueco (clase `mac` del CSS).
// - Windows: ventana SIN decoración; los botones minimizar/maximizar/cerrar los
// dibuja la propia barra (clase `win`) y actúan por el evento `ls-window`.
// `resizable(true)` conserva WS_THICKFRAME, así que los bordes siguen
// redimensionando y Aero Snap (arrastrar a un borde, Win+flechas) sigue vivo.
let mut wb = WindowBuilder::new(app, "main")
.title("Labstream OS")
.resizable(true)
@@ -892,6 +1285,10 @@ pub fn run() {
{
wb = wb.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true);
}
#[cfg(target_os = "windows")]
{
wb = wb.decorations(false);
}
match saved.win {
Some(r) if r.w >= 600.0 && r.h >= 400.0 => {
wb = wb.inner_size(r.w, r.h).position(r.x, r.y);
@@ -1006,7 +1403,20 @@ pub fn run() {
id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => {
tab_menu_action(app, id)
}
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()));
@@ -1031,22 +1441,35 @@ pub fn run() {
LogicalSize::new(size.width, TAB_BAR_H),
)?;
// Pestañas de la sesión anterior (o una nueva si no hay).
let restore: Vec<String> = {
// Pestañas de la sesión anterior (o una nueva si no hay), cada una con su color.
// El color se empareja con su URL ANTES de filtrar: el filtro por origen puede
// descartar entradas y entonces los índices de las dos listas dejarían de casar.
let restore: Vec<(String, String)> = {
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
saved
.tabs
.iter()
.filter(|u| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false))
.enumerate()
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12)
.cloned()
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
.collect()
};
if restore.is_empty() {
create_tab(&handle, None, true);
} else {
for url in &restore {
for (url, color) in &restore {
create_tab(&handle, Some(url.clone()), false);
if !color.is_empty() {
// create_tab acaba de empujar su pestaña al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut() {
if t.url == *url {
t.color = Some(color.clone());
}
}
}
}
let idx = saved.active.min(restore.len() - 1);
let label = {
@@ -1137,6 +1560,30 @@ pub fn run() {
handle.listen_any("ls-menu", move |_| {
show_app_menu(&h);
});
// Botones de ventana de la barra. Solo se pintan en Windows, donde la ventana va
// sin decoración y no hay aspa del sistema. «close» pasa por CloseRequested, así
// que hace lo mismo de siempre: ocultar a la bandeja, no salir de la app.
let h = handle.clone();
handle.listen_any("ls-window", move |e| {
let Some(win) = main_window(&h) else { return };
match field(e.payload(), "act").as_deref() {
Some("min") => {
let _ = win.minimize();
}
Some("max") => {
// El repintado del icono ▢/❐ lo dispara el Resized que viene después.
if win.is_maximized().unwrap_or(false) {
let _ = win.unmaximize();
} else {
let _ = win.maximize();
}
}
Some("close") => {
let _ = win.close();
}
_ => {}
}
});
// Clic en la etiqueta de versión de la barra → buscar actualización (con aviso).
let h = handle.clone();
handle.listen_any("ls-check-update", move |_| {
@@ -1145,10 +1592,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>();
@@ -1161,16 +1609,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())
@@ -1183,6 +1639,7 @@ pub fn run() {
save_persisted(app);
app.exit(0);
}
id if tracker::menu_event(app, id) => {}
_ => {}
})
.on_tray_icon_event(|tray, event| {
@@ -1198,6 +1655,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| {
@@ -1212,17 +1672,39 @@ pub fn run() {
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
let app = window.app_handle();
layout_all(app);
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale);
let s = size.to_logical::<f64>(scale);
// Maximizar/restaurar llega como un Resized más: si el estado cambió, la
// barra tiene que repintar su icono ▢/❐. Se compara antes de emitir para
// no repintarla en cada píxel mientras se arrastra un borde.
let maxi = window.is_maximized().unwrap_or(false);
let cambio = {
let shell = app.state::<ShellState>();
shell.lock().unwrap().win =
Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height });
let mut s = shell.lock().unwrap();
let antes = s.maximized;
s.maximized = maxi;
antes != maxi
};
if cambio {
broadcast(app);
}
// La geometría MAXIMIZADA no se guarda: al reabrir volvería como ventana
// normal del tamaño de la pantalla y, sin decoración, taparía la barra de
// tareas. Se conserva la última geometría restaurada.
if !maxi {
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale);
let s = size.to_logical::<f64>(scale);
let shell = app.state::<ShellState>();
shell.lock().unwrap().win =
Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height });
}
}
}
WindowEvent::Moved(_) => {
let app = window.app_handle();
if window.is_maximized().unwrap_or(false) {
return; // ver arriba: maximizada no se guarda
}
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale);

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.5.0",
"version": "1.14.0",
"identifier": "co.labstream.os",
"build": {
"frontendDist": "../dist"