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>
705 líneas
32 KiB
Rust
705 líneas
32 KiB
Rust
// ── 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();
|
|
}
|
|
}
|
|
});
|
|
}
|