Comparar commits

..

5 Commits

Autor SHA1 Mensaje Fecha
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
Se han modificado 8 ficheros con 1063 adiciones y 64 borrados

Ver fichero

@@ -39,12 +39,31 @@ 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.8.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 cuánto tiempo se trabaja de verdad y en qué aplicación, y lo publica en el
**Reportes → Equipo** 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**
(eso cubre también pantalla bloqueada y suspensión). Sube lotes cada 5 min a (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. `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 - **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.

Archivo binario no mostrado.

Antes

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

Después

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

24
dist/index.html vendido
Ver fichero

@@ -264,6 +264,17 @@
height: 26px; height: 26px;
font-size: 15px; 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 { #zoom {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@@ -325,8 +336,9 @@
<div id="bar" data-tauri-drag-region> <div id="bar" data-tauri-drag-region>
<button class="icono" id="atras" title="Atrás (⌘/Ctrl+[)">←</button> <button class="icono" id="atras" title="Atrás (⌘/Ctrl+[)">←</button>
<button class="icono" id="adelante" title="Adelante (⌘/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> <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> <button class="icono" id="recargar" title="Recargar (⌘/Ctrl+R)">⟳</button>
<div id="zoom"> <div id="zoom">
<button id="z-menos" title="Alejar (⌘/Ctrl+−)">−</button> <button id="z-menos" title="Alejar (⌘/Ctrl+−)">−</button>
@@ -523,6 +535,15 @@
$tabs.appendChild(el); $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); requestAnimationFrame(ajustarCompactas);
const activa = $tabs.querySelector(".tab.active"); const activa = $tabs.querySelector(".tab.active");
@@ -560,7 +581,6 @@
document.getElementById("atras").addEventListener("click", () => emitir("ls-nav", { dir: -1 })); document.getElementById("atras").addEventListener("click", () => emitir("ls-nav", { dir: -1 }));
document.getElementById("adelante").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", {})); 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. // 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", {})); document.getElementById("menu").addEventListener("click", () => emitir("ls-menu", {}));

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.8.0", "version": "1.11.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.8.0" version = "1.11.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

@@ -25,13 +25,22 @@ use std::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tauri::{menu::MenuItem, AppHandle, Listener, Manager, Runtime}; use tauri::{menu::MenuItem, AppHandle, Emitter, Listener, Manager, Runtime};
const API: &str = "https://os.labstreamsas.com/api/tracker"; const API: &str = "https://os.labstreamsas.com/api/tracker";
const TICK_SEG: u64 = 5; const TICK_SEG: u64 = 5;
const OCIO_PROFUNDO_SEG: u64 = 180; // 3 min sin entrada → deja de contar const OCIO_PROFUNDO_SEG: u64 = 180; // 3 min sin entrada → deja de contar
const LOTE_SEG: u64 = 300; // subir cada 5 min 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 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; const PAUSA_MANUAL: u64 = u64::MAX;
#[derive(Serialize, Deserialize, Default, Clone)] #[derive(Serialize, Deserialize, Default, Clone)]
@@ -51,10 +60,36 @@ struct Bloque {
t: String, // título de ventana (truncado; puede ir vacío) 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). // Estado compartido sensor ↔ menú. Los items de menú viven aparte (son genéricos en R).
struct Nucleo { struct Nucleo {
cfg: Config, cfg: Config,
cola: Vec<Vec<Bloque>>, // lotes pendientes de subir cola: Vec<Lote>, // lotes pendientes de subir
} }
struct NucleoState(Mutex<Nucleo>); struct NucleoState(Mutex<Nucleo>);
@@ -77,6 +112,25 @@ fn ruta_cfg<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
fn ruta_cola<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> { 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")) 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 { fn cargar_cfg<R: Runtime>(app: &AppHandle<R>) -> Config {
ruta_cfg(app) ruta_cfg(app)
@@ -93,13 +147,20 @@ fn guardar_cfg<R: Runtime>(app: &AppHandle<R>, cfg: &Config) {
let _ = std::fs::write(p, txt); let _ = std::fs::write(p, txt);
} }
} }
fn cargar_cola<R: Runtime>(app: &AppHandle<R>) -> Vec<Vec<Bloque>> { fn cargar_cola<R: Runtime>(app: &AppHandle<R>) -> Vec<Lote> {
ruta_cola(app) let crudo: Vec<LoteEnDisco> = ruta_cola(app)
.and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default() .unwrap_or_default();
crudo
.into_iter()
.map(|l| match l {
LoteEnDisco::Nuevo(x) => x,
LoteEnDisco::Viejo(b) => Lote { b, i: Vec::new() },
})
.collect()
} }
fn guardar_cola<R: Runtime>(app: &AppHandle<R>, cola: &[Vec<Bloque>]) { fn guardar_cola<R: Runtime>(app: &AppHandle<R>, cola: &[Lote]) {
let Some(p) = ruta_cola(app) else { return }; let Some(p) = ruta_cola(app) else { return };
if cola.is_empty() { if cola.is_empty() {
let _ = std::fs::remove_file(p); let _ = std::fs::remove_file(p);
@@ -183,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.
@@ -194,7 +301,9 @@ fn enviar<R: Runtime>(app: &AppHandle<R>) {
if n.cola.is_empty() { if n.cola.is_empty() {
return; return;
} }
(t, n.cola.clone()) // Con tope por llamada: la cola grande (días sin red) se drena a lo largo de varios
// ciclos en vez de bloquear el hilo del sensor con cientos de POST seguidos.
(t, n.cola.iter().take(MAX_LOTES_POR_ENVIO).cloned().collect::<Vec<Lote>>())
}; };
let hostname = gethostname::gethostname().to_string_lossy().to_string(); let hostname = gethostname::gethostname().to_string_lossy().to_string();
let version = app.package_info().version.to_string(); let version = app.package_info().version.to_string();
@@ -206,7 +315,8 @@ fn enviar<R: Runtime>(app: &AppHandle<R>) {
.timeout(Duration::from_secs(20)) .timeout(Duration::from_secs(20))
.send_json(json!({ .send_json(json!({
"device": { "hostname": hostname, "platform": std::env::consts::OS, "version": version }, "device": { "hostname": hostname, "platform": std::env::consts::OS, "version": version },
"blocks": lote, "blocks": lote.b,
"idles": lote.i,
})); }));
match r { match r {
Ok(_) => enviados += 1, Ok(_) => enviados += 1,
@@ -243,6 +353,16 @@ fn enviar<R: Runtime>(app: &AppHandle<R>) {
guardar_cola(app, &cola); 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 ── // ── Arranque: listener de vinculación + hilo del sensor ──
pub fn iniciar<R: Runtime>(app: &AppHandle<R>) { pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
let cfg = cargar_cfg(app); let cfg = cargar_cfg(app);
@@ -270,19 +390,85 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
n.cfg.clone() n.cfg.clone()
}; };
guardar_cfg(&h, &cfg); 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 // 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. // 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. // 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);
let mut acumulado: HashMap<(String, String), Bloque> = HashMap::new(); 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_envio = ahora_seg();
let mut ultimo_estado = String::new(); 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 { loop {
thread::sleep(Duration::from_secs(TICK_SEG)); thread::sleep(Duration::from_secs(TICK_SEG));
@@ -294,6 +480,30 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
(n.cfg.token.is_some(), esta_pausado(&n.cfg)) (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 se compara una huella (posición del // ¿Hubo entrada desde el último tic? Solo se compara una huella (posición del
// mouse + cuántas teclas hay presionadas): nunca se registra QUÉ se tecleó. // mouse + cuántas teclas hay presionadas): nunca se registra QUÉ se tecleó.
use device_query::DeviceQuery; use device_query::DeviceQuery;
@@ -307,12 +517,98 @@ 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 ──
// 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 { let estado = if !token_ok {
"sin vincular" "sin vincular"
} else if pausado { } else if pausado {
"en pausa" "en pausa"
} else if ocioso { } else if viendo_video {
"inactivo (no cuenta)" // 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 { } else {
"registrando" "registrando"
}; };
@@ -326,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(),
@@ -346,11 +645,15 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
} }
} }
// Cada 5 min: el acumulado pasa a la cola y se intenta subir todo. // Cada 5 min: el acumulado (trabajo + ocio cerrado) pasa a la cola y se intenta
// subir todo. Un tramo de ocio AÚN abierto no viaja: se sube cuando cierre.
if ahora.saturating_sub(ultimo_envio) >= LOTE_SEG { if ahora.saturating_sub(ultimo_envio) >= LOTE_SEG {
ultimo_envio = ahora; ultimo_envio = ahora;
if !acumulado.is_empty() { if !acumulado.is_empty() || !ocios_pendientes.is_empty() {
let lote: Vec<Bloque> = acumulado.drain().map(|(_, b)| b).collect(); let lote = Lote {
b: acumulado.drain().map(|(_, b)| b).collect(),
i: std::mem::take(&mut ocios_pendientes),
};
let nucleo = h.state::<NucleoState>(); let nucleo = h.state::<NucleoState>();
let cola = { let cola = {
let mut n = nucleo.0.lock().unwrap(); let mut n = nucleo.0.lock().unwrap();
@@ -360,6 +663,10 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
guardar_cola(&h, &cola); guardar_cola(&h, &cola);
} }
enviar(&h); 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", "$schema": "https://schema.tauri.app/config/2",
"productName": "Labstream OS", "productName": "Labstream OS",
"version": "1.8.0", "version": "1.11.0",
"identifier": "co.labstream.os", "identifier": "co.labstream.os",
"build": { "build": {
"frontendDist": "../dist" "frontendDist": "../dist"