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>
Este commit está contenido en:
Jonathan Flórez
2026-08-20 09:02:07 -05:00
padre d01d1708c7
commit 684f099c43
Se han modificado 6 ficheros con 797 adiciones y 52 borrados

Ver fichero

@@ -39,7 +39,7 @@ 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 resultado. Es un popup nativo del sistema, igual en Windows y macOS, y vive
solo en el shell (la web app no se toca). solo en el shell (la web app no se toca).
- **Rastreador de trabajo efectivo** (v1.9.0, `src-tauri/src/tracker.rs`): mide - **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 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 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** hubo entrada de ratón/teclado; **con 3 minutos sin entrada deja de contar**
@@ -55,6 +55,15 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
su último latido en vez de perderlo). Y un tramo se cierra solo a las 4 h: 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 más que eso sin tocar el equipo es ausencia, no descanso frente a la
pantalla. En pausa no se anota nada. 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 - **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 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. pantallazos, ni el contenido de nada, ni los procesos de fondo.

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.9.0", "version": "1.10.0",
"private": true, "private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": { "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] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.9.0" version = "1.10.0"
description = "Cliente de escritorio de Labstream OS" description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"] authors = ["Labstream SAS"]
edition = "2021" edition = "2021"
@@ -37,3 +37,21 @@ active-win-pos-rs = "0.11"
device_query = "4" device_query = "4"
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
gethostname = "0.5" gethostname = "0.5"
# ── Excepción del video (Windows) ──
# Para saber si «hay un video reproduciéndose» sin espiar el navegador: se mira el MEDIDOR de
# la salida de audio por defecto (WASAPI, IAudioMeterInformation). Si al frente hay un navegador
# y está saliendo sonido, es un video sonando → cuenta como navegador, no como inactividad.
# Solo Windows: en macOS no hay API pública sencilla de nivel de audio por app (el equipo de
# edición es Windows). El `windows` crate ya está en la caché por Tauri.
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Com",
# IMMDevice::Activate exige AMBAS (su firma lleva un PROPVARIANT): sin ellas el método
# queda cfg'd out y «no existe». Verificado con cargo check --target windows-msvc.
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
] }

Ver fichero

@@ -244,6 +244,52 @@ fn normaliza(s: &str, max: usize) -> String {
limpio.chars().take(max).collect() 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 ── // ── Envío ──
// Sube la cola entera; deja en la cola lo que no pudo. Un 401 = token revocado → se descarta // 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. // el token (la bandeja pasa a «sin vincular») y la cola se vacía: ya nadie la quiere.
@@ -372,6 +418,15 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
// El sensor: un hilo con tic de 5 s. Nada de esto toca la interfaz salvo el menú del tray. // El sensor: un hilo con tic de 5 s. Nada de esto toca la interfaz salvo el menú del tray.
let h = app.clone(); let h = app.clone();
thread::spawn(move || { 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);
}
let device_state = device_query::DeviceState::new(); let device_state = device_query::DeviceState::new();
let mut ultimo_input = ahora_seg(); let mut ultimo_input = ahora_seg();
let mut huella_anterior: (i32, i32, usize) = (0, 0, 0); let mut huella_anterior: (i32, i32, usize) = (0, 0, 0);
@@ -462,18 +517,40 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
} }
let ocioso = ahora.saturating_sub(ultimo_input) >= OCIO_PROFUNDO_SEG; 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 ── // ── El tramo de inactividad ──
// Abre cuando se cruza el umbral (retrocedido al minuto exacto en que se dejó de // 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. En // 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 // pausa o sin vincular no se anota nada: pausar es «no me midas», y eso incluye
// la inactividad. // la inactividad.
if token_ok && !pausado { if token_ok && !pausado {
if ocioso && !ocioso_antes { if ocioso_efectivo && !ocioso_antes {
let ini = (ultimo_input + OCIO_PROFUNDO_SEG) * 1000; let ini = (ultimo_input + OCIO_PROFUNDO_SEG) * 1000;
ocio_abierto = Some(ini); ocio_abierto = Some(ini);
guardar_encurso(&h, ahora, ini); guardar_encurso(&h, ahora, ini);
encurso_guardado = ahora; encurso_guardado = ahora;
} else if !ocioso && ocioso_antes { } else if !ocioso_efectivo && ocioso_antes {
if let Some(ini) = ocio_abierto.take() { if let Some(ini) = ocio_abierto.take() {
let fin = ahora * 1000; let fin = ahora * 1000;
if fin > ini { if fin > ini {
@@ -502,7 +579,7 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
ultimo_input = ahora; ultimo_input = ahora;
} }
if token_ok && !pausado { if token_ok && !pausado {
ocioso_antes = ocioso; ocioso_antes = ocioso_efectivo;
} }
// El tope del tramo: a las 4 h se cierra y NO se reabre (ocioso_antes queda en // El tope del tramo: a las 4 h se cierra y NO se reabre (ocioso_antes queda en
@@ -525,7 +602,10 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
"sin vincular" "sin vincular"
} else if pausado { } else if pausado {
"en pausa" "en pausa"
} else if ocioso { } 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 // 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. // efectivas); la bandeja lo dice para que la medición siga siendo transparente.
"inactivo (se anota aparte)" "inactivo (se anota aparte)"
@@ -542,10 +622,13 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
ultimo_estado = estado.to_string(); ultimo_estado = estado.to_string();
} }
if token_ok && !pausado && !ocioso { // Se acumula la ventana al frente cuando NO hay inactividad efectiva: o sea, con
if let Ok(v) = active_win_pos_rs::get_active_window() { // actividad real, O con un video en primer plano (viendo_video → ocioso_efectivo es
let app_name = normaliza(&v.app_name, 80); // false). En el caso del video, `hubo_input` es false, así que el bloque suma
let titulo = normaliza(&v.title, 140); // 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() { if !app_name.is_empty() {
let b = acumulado.entry((app_name.clone(), titulo.clone())).or_insert_with(|| Bloque { let b = acumulado.entry((app_name.clone(), titulo.clone())).or_insert_with(|| Bloque {
s: ahora_ms(), s: ahora_ms(),

Ver fichero

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