From cac69dd7be3127ee9ef0c38204d9702bc9077df1 Mon Sep 17 00:00:00 2001 From: Munka Date: Thu, 13 Aug 2026 15:48:54 -0500 Subject: [PATCH 1/3] El escritorio aprende a medir el trabajo efectivo, y lo dice en la bandeja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/Cargo.toml | 9 + src-tauri/src/lib.rs | 13 +- src-tauri/src/tracker.rs | 362 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/tracker.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8b72bd1..22322b7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,3 +28,12 @@ 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" +device_query = "4" +ureq = { version = "2", features = ["json"] } +gethostname = "0.5" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2c28c5..8ffbb43 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,8 @@ // `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 tracker; + use std::sync::Mutex; use serde::{Deserialize, Serialize}; @@ -1394,10 +1396,13 @@ pub fn run() { } }); - // 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()) @@ -1410,6 +1415,7 @@ pub fn run() { save_persisted(app); app.exit(0); } + id if tracker::menu_event(app, id) => {} _ => {} }) .on_tray_icon_event(|tray, event| { @@ -1425,6 +1431,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| { diff --git a/src-tauri/src/tracker.rs b/src-tauri/src/tracker.rs new file mode 100644 index 0000000..5e82bc0 --- /dev/null +++ b/src-tauri/src/tracker.rs @@ -0,0 +1,362 @@ +// ── 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, 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 +const PAUSA_MANUAL: u64 = u64::MAX; + +#[derive(Serialize, Deserialize, Default, Clone)] +struct Config { + token: Option, + // Instante unix (seg) hasta el que está pausado; PAUSA_MANUAL = hasta reanudar a mano. + #[serde(default)] + pausado_hasta: Option, +} + +#[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) +} + +// Estado compartido sensor ↔ menú. Los items de menú viven aparte (son genéricos en R). +struct Nucleo { + cfg: Config, + cola: Vec>, // lotes pendientes de subir +} + +struct NucleoState(Mutex); +struct MenuUi { + estado: MenuItem, + toggle: MenuItem, +} +struct MenuState(Mutex>); + +fn ahora_seg() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} +fn ahora_ms() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) +} + +fn ruta_cfg(app: &AppHandle) -> Option { + app.path().app_config_dir().ok().map(|d| d.join("rastreador.json")) +} +fn ruta_cola(app: &AppHandle) -> Option { + app.path().app_config_dir().ok().map(|d| d.join("rastreador-cola.json")) +} + +fn cargar_cfg(app: &AppHandle) -> 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(app: &AppHandle, 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(app: &AppHandle) -> Vec> { + 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() +} +fn guardar_cola(app: &AppHandle, cola: &[Vec]) { + 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(app: &AppHandle) -> tauri::Result<(MenuItem, MenuItem)> { + 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(app: &AppHandle, id: &str) -> bool { + match id { + "trk-toggle" => { + let nucleo = app.state::(); + 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(app: &AppHandle, 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(app: &AppHandle, estado: &str, cfg: &Config) { + let Some(menu) = app.try_state::>() 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::>().join(" "); + limpio.chars().take(max).collect() +} + +// ── 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(app: &AppHandle) { + let (token, lotes) = { + let nucleo = app.state::(); + let n = nucleo.0.lock().unwrap(); + let Some(t) = n.cfg.token.clone() else { return }; + if n.cola.is_empty() { + return; + } + (t, n.cola.clone()) + }; + 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, + })); + 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::(); + 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; + } + n.cola.drain(0..enviados.min(n.cola.len())); + 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); +} + +// ── Arranque: listener de vinculación + hilo del sensor ── +pub fn iniciar(app: &AppHandle) { + 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::

(e.payload()) else { return }; + if !p.token.starts_with("ltk_") { + return; + } + let nucleo = h.state::(); + 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); + // 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. + }); + + // 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 || { + let device_state = device_query::DeviceState::new(); + let mut ultimo_input = ahora_seg(); + let mut huella_anterior: (i32, i32, usize) = (0, 0, 0); + let mut acumulado: HashMap<(String, String), Bloque> = HashMap::new(); + let mut ultimo_envio = ahora_seg(); + let mut ultimo_estado = String::new(); + + loop { + thread::sleep(Duration::from_secs(TICK_SEG)); + let ahora = ahora_seg(); + + let (token_ok, pausado) = { + let nucleo = h.state::(); + let n = nucleo.0.lock().unwrap(); + (n.cfg.token.is_some(), esta_pausado(&n.cfg)) + }; + + // ¿Hubo entrada desde el último tic? Solo se compara una huella (posición del + // mouse + cuántas teclas hay presionadas): nunca se registra QUÉ se tecleó. + use device_query::DeviceQuery; + let mouse = device_state.get_mouse(); + let teclas = device_state.get_keys().len(); + let huella = (mouse.coords.0, mouse.coords.1, teclas); + let hubo_input = huella != huella_anterior || teclas > 0; + huella_anterior = huella; + if hubo_input { + ultimo_input = ahora; + } + let ocioso = ahora.saturating_sub(ultimo_input) >= OCIO_PROFUNDO_SEG; + + let estado = if !token_ok { + "sin vincular" + } else if pausado { + "en pausa" + } else if ocioso { + "inactivo (no cuenta)" + } else { + "registrando" + }; + if estado != ultimo_estado { + let cfg = { + let nucleo = h.state::(); + let n = nucleo.0.lock().unwrap(); + n.cfg.clone() + }; + pintar_menu(&h, estado, &cfg); + ultimo_estado = estado.to_string(); + } + + if token_ok && !pausado && !ocioso { + if let Ok(v) = active_win_pos_rs::get_active_window() { + let app_name = normaliza(&v.app_name, 80); + let titulo = normaliza(&v.title, 140); + 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 pasa a la cola y se intenta subir todo. + if ahora.saturating_sub(ultimo_envio) >= LOTE_SEG { + ultimo_envio = ahora; + if !acumulado.is_empty() { + let lote: Vec = acumulado.drain().map(|(_, b)| b).collect(); + let nucleo = h.state::(); + let cola = { + let mut n = nucleo.0.lock().unwrap(); + n.cola.push(lote); + n.cola.clone() + }; + guardar_cola(&h, &cola); + } + enviar(&h); + } + } + }); +} From 2595faf37248f9636ce30de87ef15bf57bb39000 Mon Sep 17 00:00:00 2001 From: Munka Date: Thu, 13 Aug 2026 21:21:53 -0500 Subject: [PATCH 2/3] El drain calcula su largo antes: el MutexGuard no admite el prestamo doble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- src-tauri/src/tracker.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/tracker.rs b/src-tauri/src/tracker.rs index 5e82bc0..aa7b772 100644 --- a/src-tauri/src/tracker.rs +++ b/src-tauri/src/tracker.rs @@ -229,7 +229,11 @@ fn enviar(app: &AppHandle) { pintar_menu(app, "sin vincular", &cfg); return; } - n.cola.drain(0..enviados.min(n.cola.len())); + // 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 From 0df9bd1374ab0d82f2357a9c132f29dd212d607d Mon Sep 17 00:00:00 2001 From: Munka Date: Thu, 13 Aug 2026 21:23:20 -0500 Subject: [PATCH 3/3] El README cuenta el rastreador: que mide, que NO mide y como se vincula Co-Authored-By: Claude Fable 5 --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 48c9c5c..2edf9c5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,27 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac: resultado. Es un popup nativo del sistema, igual en Windows y macOS, y vive solo en el shell (la web app no se toca). +- **Rastreador de trabajo efectivo** (v1.8.0, `src-tauri/src/tracker.rs`): mide + cuánto tiempo se trabaja de verdad y en qué aplicación, y lo publica en + **Reportes → Equipo** del servidor. Cada 5 s mira la ventana al frente y si + 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. + - **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-*`.