Comparar commits
2 Commits
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
|
f892dbbff7 | ||
|
|
d983b8d8b5 |
11
README.md
11
README.md
@@ -39,7 +39,7 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
|
||||
resultado. Es un popup nativo del sistema, igual en Windows y macOS, y vive
|
||||
solo en el shell (la web app no se toca).
|
||||
|
||||
- **Rastreador de trabajo efectivo** (v1.9.0, `src-tauri/src/tracker.rs`): mide
|
||||
- **Rastreador de trabajo efectivo** (v1.10.0, `src-tauri/src/tracker.rs`): mide
|
||||
cuánto tiempo se trabaja de verdad y en qué aplicación, y lo publica en el
|
||||
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**
|
||||
@@ -55,6 +55,15 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
|
||||
su último latido en vez de perderlo). Y un tramo se cierra solo a las 4 h:
|
||||
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
|
||||
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.
|
||||
|
||||
BIN
app-icon.png
BIN
app-icon.png
Archivo binario no mostrado.
|
Antes Anchura: | Altura: | Tamaño: 52 KiB Después Anchura: | Altura: | Tamaño: 66 KiB |
24
dist/index.html
vendido
24
dist/index.html
vendido
@@ -264,6 +264,17 @@
|
||||
height: 26px;
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
@@ -325,8 +336,9 @@
|
||||
<div id="bar" data-tauri-drag-region>
|
||||
<button class="icono" id="atras" title="Atrás (⌘/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>
|
||||
<button class="icono" id="nueva" title="Nueva pestaña (⌘/Ctrl+T)">+</button>
|
||||
<button class="icono" id="recargar" title="Recargar (⌘/Ctrl+R)">⟳</button>
|
||||
<div id="zoom">
|
||||
<button id="z-menos" title="Alejar (⌘/Ctrl+−)">−</button>
|
||||
@@ -523,6 +535,15 @@
|
||||
|
||||
$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);
|
||||
|
||||
const activa = $tabs.querySelector(".tab.active");
|
||||
@@ -560,7 +581,6 @@
|
||||
|
||||
document.getElementById("atras").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", {}));
|
||||
// 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", {}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "labstream-desktop",
|
||||
"version": "1.9.0",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
|
||||
"scripts": {
|
||||
|
||||
713
src-tauri/Cargo.lock
generado
713
src-tauri/Cargo.lock
generado
La diferencia del archivo ha sido suprimido porque es demasiado grande
Cargar Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "labstream-desktop"
|
||||
version = "1.9.0"
|
||||
version = "1.11.0"
|
||||
description = "Cliente de escritorio de Labstream OS"
|
||||
authors = ["Labstream SAS"]
|
||||
edition = "2021"
|
||||
@@ -37,3 +37,21 @@ active-win-pos-rs = "0.11"
|
||||
device_query = "4"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
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",
|
||||
] }
|
||||
|
||||
@@ -244,6 +244,52 @@ fn normaliza(s: &str, max: usize) -> String {
|
||||
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.
|
||||
@@ -372,6 +418,15 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
// El sensor: un hilo con tic de 5 s. Nada de esto toca la interfaz salvo el menú del tray.
|
||||
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);
|
||||
}
|
||||
|
||||
let device_state = device_query::DeviceState::new();
|
||||
let mut ultimo_input = ahora_seg();
|
||||
let mut huella_anterior: (i32, i32, usize) = (0, 0, 0);
|
||||
@@ -462,18 +517,40 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
}
|
||||
let ocioso = ahora.saturating_sub(ultimo_input) >= OCIO_PROFUNDO_SEG;
|
||||
|
||||
// ── 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. En
|
||||
// contar trabajo: ultimo_input + umbral) y cierra cuando vuelve la entrada —o cuando
|
||||
// arranca un video en primer plano, que cuenta como actividad de navegador. En
|
||||
// pausa o sin vincular no se anota nada: pausar es «no me midas», y eso incluye
|
||||
// la inactividad.
|
||||
if token_ok && !pausado {
|
||||
if ocioso && !ocioso_antes {
|
||||
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 && ocioso_antes {
|
||||
} else if !ocioso_efectivo && ocioso_antes {
|
||||
if let Some(ini) = ocio_abierto.take() {
|
||||
let fin = ahora * 1000;
|
||||
if fin > ini {
|
||||
@@ -502,7 +579,7 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
ultimo_input = ahora;
|
||||
}
|
||||
if token_ok && !pausado {
|
||||
ocioso_antes = ocioso;
|
||||
ocioso_antes = ocioso_efectivo;
|
||||
}
|
||||
|
||||
// El tope del tramo: a las 4 h se cierra y NO se reabre (ocioso_antes queda en
|
||||
@@ -525,7 +602,10 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
"sin vincular"
|
||||
} else if pausado {
|
||||
"en pausa"
|
||||
} else if ocioso {
|
||||
} else if viendo_video {
|
||||
// Ocioso, pero con un video en primer plano: se cuenta como navegador.
|
||||
"video en primer plano"
|
||||
} else if ocioso_efectivo {
|
||||
// Desde la 1.9 el tramo quieto SÍ queda anotado (aparte de las horas
|
||||
// efectivas); la bandeja lo dice para que la medición siga siendo transparente.
|
||||
"inactivo (se anota aparte)"
|
||||
@@ -542,10 +622,13 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
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);
|
||||
// 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(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Labstream OS",
|
||||
"version": "1.9.0",
|
||||
"version": "1.11.0",
|
||||
"identifier": "co.labstream.os",
|
||||
"build": {
|
||||
"frontendDist": "../dist"
|
||||
|
||||
Referencia en una nueva incidencia
Block a user