Comparar commits

...

10 Commits

Autor SHA1 Mensaje Fecha
Munka
b4c9418b14 El sensor acusa recibo: entra en la 1.8.0 antes de publicarla 2026-08-14 16:04:48 -05:00
Munka
f162f5ae09 El sensor acusa recibo del token y dice como esta
Antes la pagina emitia el token a ciegas: en una app SIN sensor (anterior a la
1.8.0) nadie lo recogia y la pantalla cantaba "equipo vinculado" igual,
dejando un equipo registrado que jamas reporta.

- ls-tracker-token  -> responde ls-tracker-listo (acuse). Sin acuse, la web
  deshace el equipo que acaba de crear y avisa que hay que actualizar.
- ls-tracker-consulta -> responde ls-tracker-estado {vinculado, pausado,
  version}, para no ofrecer vincular lo que ya esta vinculado y para poder
  avisar de un clic a quien le falta.

Los dos emit salen en un hilo aparte, por el mismo motivo que los repintados
del menu: un listener puede correr en el hilo principal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:19:03 -05:00
Munka
37403791f6 v1.8.0: la version que trae el rastreador de trabajo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:03:02 -05:00
Munka
29b88ed97f Fase 1 del rastreador de trabajo efectivo (verificada en CI, Windows y macOS)
Sensor de ventana activa + actividad, cola offline y estado visible en la
bandeja. NO se etiqueta todavia: una etiqueta actualiza sola a TODO el
equipo, y eso lo decide Jonathan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:36:30 -05:00
Munka
0df9bd1374 El README cuenta el rastreador: que mide, que NO mide y como se vincula
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:23:20 -05:00
Munka
2595faf372 El drain calcula su largo antes: el MutexGuard no admite el prestamo doble
`n.cola.drain(0..enviados.min(n.cola.len()))` no compila: el argumento pide
un prestamo inmutable de `n.cola` cuando ya esta prestado mutable, y a
traves del MutexGuard —un deref explicito, no un autoref— el two-phase
borrow no lo cubre (E0502, el UNICO error que encontro el CI en todo el
sensor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:21:53 -05:00
Munka
cac69dd7be El escritorio aprende a medir el trabajo efectivo, y lo dice en la bandeja
Sensor nuevo (src/tracker.rs): cada 5 s mira que app esta al frente y si
hubo entrada; con 3 minutos sin entrada deja de contar (ocio profundo, que
cubre tambien pantalla bloqueada y suspension). Acumula bloques {app,
titulo, segundos, segundos activos} y los sube cada 5 min a /api/tracker con
el token del equipo; sin red la cola persiste en disco y reintenta, y un 401
(token revocado desde Ajustes) descarta el token y vacia la cola.

Lo que NO hace, a proposito: no registra que teclas se pulsan —solo compara
una huella de posicion del raton y cuantas teclas hay presionadas—, no toma
pantallazos y no lista procesos de fondo. El estado vive VISIBLE en el menu
de la bandeja, con pausa de un toque.

Ojo al tocar el menu: set_text de Tauri despacha al hilo principal y bloquea
esperandolo, asi que llamarlo desde setup() —que corre en ese mismo hilo
antes de que el bucle de eventos atienda— colgaria la app al abrir. Por eso
los items nacen ya con su texto y todo repintado posterior sale de un hilo
secundario.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:48:54 -05:00
Munka
3798970f74 v1.7.1: el detector de tema deja de creer y empieza a mirar
La v1.7.0 preguntaba por la clase `dark` de <html> y punto. En este
mismo PC quedó la prueba de que eso miente en WebView2: estado.json
con "dark": false y la app pintada de negro — barra clara sobre app
oscura, el bug al revés.

Ahora el reporte es a prueba de balas:

- temaOscuro(): clase `dark` en <html> o <body>; si no está, la
  LUMINANCIA del fondo realmente pintado del body (funciona sea cual
  sea el mecanismo de tema); si tampoco, el color-scheme computado.
  Con el documento a medio crear devuelve null y el campo NO viaja
  (Rust conserva el último valor bueno) — nada revienta en frío.
- Vigilancia segura: MutationObserver sobre <html> Y <body>
  (class/style/data-theme) registrado cuando existen, y dos
  re-reportes tras la carga por si el tema aterriza con la
  hidratación.

Rust y la barra quedan como en v1.7.0 (un campo `dark` ausente ya
significaba «conserva lo último»). El estado.json mentiroso se
corrige solo con el primer reporte bueno.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:45:01 -05:00
Munka
29f0b47371 Version 1.7.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:36:46 -05:00
Munka
b18c155b89 La barra obedece a la app, no a Windows: tema real + acento naranja
El bug de «siempre oscura»: la web decide su modo con su propio botón
(next-themes, defaultTheme claro, sin seguir al sistema), pero la barra
elegía tema con prefers-color-scheme — el del SISTEMA. Con Windows en
oscuro y la app en claro, franja negra sobre app blanca, siempre. La
v1.6.1 pintó bien ambos temas, pero la barra seguía eligiendo el
equivocado.

El arreglo, de punta a punta:

- El script inyectado reporta en ls-title si <html> lleva la clase
  `dark`, con un MutationObserver para que la luna se refleje al
  instante. La guarda de marco y origen queda intacta (OnlyOffice).
- Rust lo guarda (global: todas las pestañas comparten modo), lo
  reenvía en ls-tabs (`dark`) y lo PERSISTE en estado.json: la app
  abre ya del color correcto, sin fogonazo.
- La barra se pone la clase `oscuro`/`claro` al recibirlo; las media
  queries quedan solo como arranque en frío (primer arranque sin
  estado.json). Los tintes de colores de pestaña cuelgan del mismo
  interruptor.
- El acento pasa de índigo #6366f1 (comentado «color de Labstream»,
  que no lo era) al naranja de marca #F47A20: aro de carga, zoom
  activo y hover de la versión.

Verificado en navegador con el CSS y el JS reales, simulando el
payload de Rust: app clara + sistema oscuro → barra clara; app
oscura → negra; sin reporte → cae al sistema. El Rust lo compila el
CI de rama (este equipo no tiene cargo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:22:46 -05:00
Se han modificado 8 ficheros con 544 adiciones y 15 borrados

Ver fichero

@@ -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-*`.

39
dist/index.html vendido
Ver fichero

@@ -7,7 +7,8 @@
<!--
Barra de pestañas del shell de escritorio (webview "chrome", local).
Habla con Rust por eventos (__TAURI__.event):
recibe ls-tabs {tabs:[{label,title,url,loading,active}], zoom, version, mac}
recibe ls-tabs {tabs:[{label,title,url,loading,active,color}], zoom, version,
mac, win, maximized, dark}
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle,
ls-activate-n, ls-nav, ls-menu, ls-check-update, ls-tab-menu, ls-reorder
En macOS la ventana lleva el título en OVERLAY: esta barra ES la fila del título, así que
@@ -29,15 +30,24 @@
--muted: #5f6368;
--tab-active-bg: #ffffff; /* mismo fondo que la app → pestaña fundida con el contenido */
--tab-active-fg: #202124;
--accent: #6366f1; /* indigo-500, color de Labstream */
--accent: #F47A20; /* NARANJA Labstream (el índigo de antes no era de la marca) */
--border: #dcdee1; /* línea bajo la barra (la tapa la pestaña activa) */
/* Sobre una franja casi blanca, un hover blanco no se vería: aquí oscurece. */
--hover: rgba(0, 0, 0, 0.06);
--btn-hover: rgba(0, 0, 0, 0.08);
--sep: rgba(0, 0, 0, 0.22); /* separador fino entre pestañas inactivas */
}
/* ── Quién decide el tema ──
La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su
<html>, defaultTheme claro, sin seguir al sistema), así que obedecer aquí al
prefers-color-scheme del sistema pintaba la barra negra sobre una app clara — el
bug de siempre. El script inyectado reporta el tema real, Rust lo reenvía en
`ls-tabs` (`dark`) y este documento se pone la clase `oscuro` o `claro`.
El bloque de medios queda SOLO como arranque en frío (primer arranque, sin
estado.json y sin reporte aún): en cuanto hay clase, la clase manda.
OJO: los valores oscuros van DOS veces (media query y .oscuro) — cambiarlos a la par. */
@media (prefers-color-scheme: dark) {
:root {
:root:not(.claro) {
--bg: #0d0d0f; /* franja: negra, más oscura que el contenido */
--fg: #bdc1c6;
--muted: #80868b;
@@ -49,6 +59,17 @@
--sep: rgba(255, 255, 255, 0.25);
}
}
:root.oscuro {
--bg: #0d0d0f;
--fg: #bdc1c6;
--muted: #80868b;
--tab-active-bg: #18181b;
--tab-active-fg: #e8eaed;
--border: #26262a;
--hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
@@ -168,9 +189,11 @@
.tab[data-color]:not(.active)::before { display: none; }
.tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
@media (prefers-color-scheme: dark) {
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
:root:not(.claro) .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
}
:root.oscuro .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
:root.oscuro .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
macOS los eventos no llegan), sino eventos de puntero. Se apaga el arrastre nativo de
WebKit para que no salga la imagen fantasma al mover una pestaña. */
@@ -508,6 +531,12 @@
$pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100);
$ver.textContent = estado.version ? "v" + estado.version : "";
// Tema: manda el de la APP (payload `dark`). Mientras Rust no sepa nada (null),
// no se pone clase y siguen las media queries del sistema como arranque en frío.
if (typeof estado.dark === "boolean") {
document.documentElement.classList.toggle("oscuro", estado.dark);
document.documentElement.classList.toggle("claro", !estado.dark);
}
// En macOS la barra ES la fila del título: hay que dejar libre el semáforo.
document.body.classList.toggle("mac", !!estado.mac);
// En Windows también ES la fila del título, pero ahí los botones de ventana los

Ver fichero

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

Ver fichero

@@ -2062,7 +2062,7 @@ dependencies = [
[[package]]
name = "labstream-desktop"
version = "1.6.1"
version = "1.7.1"
dependencies = [
"serde",
"serde_json",

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.6.1"
version = "1.8.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"
@@ -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"

Ver fichero

@@ -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};
@@ -97,11 +99,15 @@ struct Shell {
// Último estado maximizado conocido. Solo sirve para no repintar la barra en CADA
// evento de redimensión: únicamente cuando el icono ▢/❐ tiene que cambiar.
maximized: bool,
// Tema REAL de la app (la clase `dark` de <html>, reportada por el script inyectado).
// Es global y no por pestaña: todas las pestañas del origen comparten el mismo modo
// (localStorage). None = ninguna pestaña ha reportado aún → la barra cae al del sistema.
dark: Option<bool>,
}
impl Default for Shell {
fn default() -> Self {
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None, maximized: false }
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None, maximized: false, dark: None }
}
}
@@ -132,6 +138,10 @@ struct Persisted {
active: usize,
#[serde(default)]
autostart: Option<bool>,
// Último tema conocido de la app: la barra abre ya del color correcto en vez de
// arrancar con el del sistema y dar un fogonazo al cargar la primera pestaña.
#[serde(default)]
dark: Option<bool>,
}
fn state_path<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
@@ -157,6 +167,7 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
active: s.active,
autostart: s.autostart,
dark: s.dark,
}
};
if let Some(dir) = path.parent() {
@@ -212,9 +223,39 @@ const INIT_JS_TPL: &str = r#"
}
function newTab(url) { emit('ls-new-tab', { url: url }); }
// Título y URL → barra de pestañas (y para restaurar la sesión al reabrir).
// ── ¿La página se VE oscura? ──
// Señales, de la más directa a la más física: la clase `dark` (next-themes) y, si no
// está, el color de fondo REALMENTE pintado (luminancia del body) — vale sea cual sea
// el mecanismo de tema. Devuelve null con el documento a medio crear (los scripts de
// inicio corren antes de que exista <html>): entonces el campo NO viaja y Rust conserva
// el último valor bueno. La v1.7.0 leía la clase a secas y sin red: en WebView2 eso
// podía reventar en frío y dejar `dark` mintiendo en estado.json (barra clara sobre
// app oscura — el bug al revés).
function temaOscuro() {
try {
var doc = document.documentElement, cuerpo = document.body;
if (!doc) return null;
if (doc.classList.contains('dark') || (cuerpo && cuerpo.classList.contains('dark'))) return true;
if (!cuerpo) return null;
var fondo = getComputedStyle(cuerpo).backgroundColor || '';
var m = fondo.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s\/]+([\d.]+))?/);
if (m && (m[4] === undefined || parseFloat(m[4]) > 0.5)) {
return 0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3] < 128;
}
var esquema = getComputedStyle(doc).colorScheme || '';
if (esquema.indexOf('dark') >= 0 && esquema.indexOf('light') < 0) return true;
return false;
} catch (e) { return null; }
}
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
// sistema: la barra tiene que copiarle a ELLA.
function report() {
emit('ls-title', { label: LABEL, title: document.title || 'Labstream OS', url: location.href });
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
emit('ls-title', carga);
}
function watchTitle() {
var el = document.querySelector('title');
@@ -233,6 +274,18 @@ const INIT_JS_TPL: &str = r#"
history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; };
});
addEventListener('popstate', function () { setTimeout(report, 0); });
// Pulsar la luna cambia clases/estilos sin navegar: se vigilan <html> y <body> (cuando
// existan — a document-start aún no hay), y tras la carga se re-reporta un par de veces
// por si el tema aterriza tarde con la hidratación.
function vigilarTema() {
if (!window.MutationObserver) return;
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] };
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
if (document.body) new MutationObserver(report).observe(document.body, conf);
}
if (document.body) vigilarTema();
else document.addEventListener('DOMContentLoaded', vigilarTema);
addEventListener('load', function () { setTimeout(report, 800); setTimeout(report, 2500); });
// Clics en enlaces.
document.addEventListener('click', function (e) {
@@ -368,6 +421,8 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
// que dibuja ella misma los botones minimizar/maximizar/cerrar (no hay nativos).
"win": cfg!(target_os = "windows"),
"maximized": maximized,
// Tema real de la app (null hasta el primer reporte: la barra usa el del sistema).
"dark": s.dark,
})
};
let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload);
@@ -945,6 +1000,10 @@ fn field_i64(payload: &str, key: &str) -> Option<i64> {
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_i64()
}
fn field_bool(payload: &str, key: &str) -> Option<bool> {
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_bool()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default();
@@ -994,6 +1053,7 @@ pub fn run() {
s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1);
s.win = saved.win;
s.autostart = saved.autostart;
s.dark = saved.dark;
}
// Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay.
@@ -1308,10 +1368,11 @@ pub fn run() {
});
let h = handle.clone();
handle.listen_any("ls-title", move |e| {
let (label, title, url) = (
let (label, title, url, dark) = (
field(e.payload(), "label"),
field(e.payload(), "title"),
field(e.payload(), "url"),
field_bool(e.payload(), "dark"),
);
if let Some(label) = label {
let shell = h.state::<ShellState>();
@@ -1324,16 +1385,24 @@ pub fn run() {
t.url = url;
}
}
// El tema es global (todas las pestañas del origen comparten modo): el
// último reporte manda, venga de la pestaña que venga.
if let Some(dark) = dark {
s.dark = Some(dark);
}
drop(s);
broadcast(&h);
save_persisted(&h);
}
});
// Icono en la bandeja del sistema con menú Abrir / Salir.
// Icono en la bandeja: Abrir / estado del rastreador + pausa / Salir.
let abrir = MenuItem::with_id(app, "abrir", "Abrir Labstream", true, None::<&str>)?;
let salir = MenuItem::with_id(app, "salir", "Salir", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&abrir, &salir])?;
let (trk_estado, trk_toggle) = tracker::armar_menu(app.handle())?;
let sep1 = PredefinedMenuItem::separator(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let menu = Menu::with_items(app, &[&abrir, &sep1, &trk_estado, &trk_toggle, &sep2, &salir])?;
TrayIconBuilder::with_id("main-tray")
.icon(app.default_window_icon().unwrap().clone())
@@ -1346,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| {
@@ -1361,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| {

397
src-tauri/src/tracker.rs Archivo normal
Ver fichero

@@ -0,0 +1,397 @@
// ── 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
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)
}
// Estado compartido sensor ↔ menú. Los items de menú viven aparte (son genéricos en R).
struct Nucleo {
cfg: Config,
cola: Vec<Vec<Bloque>>, // 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)
}
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"))
}
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<Vec<Bloque>> {
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<R: Runtime>(app: &AppHandle<R>, cola: &[Vec<Bloque>]) {
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()
}
// ── 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;
}
(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::<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 || {
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::<NucleoState>();
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::<NucleoState>();
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<Bloque> = acumulado.drain().map(|(_, b)| b).collect();
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);
}
}
});
}

Ver fichero

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