Comparar commits

..

6 Commits

Autor SHA1 Mensaje Fecha
Munka
a10805c51b v1.14.0: las pestanas se presentan como navegador de escritorio
Google degrada sus apps cuando detecta un WebView embebido: Sheets abria en blanco y la
sesion no terminaba de asentarse aunque la cuenta ya estuviera iniciada. Ahora cada
pestana declara un User-Agent de Chrome de escritorio (por plataforma), que es lo que
hacen los clientes de escritorio que embeben Google.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:12:37 -05:00
Munka
d287b4e647 v1.13.0: los documentos del contador se abren dentro de la app
Bump de version. La novedad esta en el commit anterior: hosts de Google (docs, drive,
sheets, accounts, myaccount) se abren como pestanas propias del shell en vez de mandarse
al navegador del sistema, para que la sesion de Google viva dentro de la app y las hojas
del contador se vean en la pestana Contable sin pedir cookies cada vez.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-14 11:46:02 -05:00
Munka
d847aca9a7 Los documentos del contador (Google) se abren como pestanas de esta app
La app de escritorio es un navegador aparte sin la sesion de Google del usuario. Antes
un enlace de docs.google.com se abria en el navegador del sistema y Google pedia
Permitir cookies dentro de un marco que nadie podia contestar. Ahora hosts de Google
(docs / drive / sheets / accounts / myaccount) se abren como PESTANAS internas del shell,
y el WebView2 guarda la sesion de Google como cualquier otra cookie: se inicia sesion
UNA vez y las hojas del contador se ven y editan en la pestana Contable.

- create_tab acepta URLs con scheme https y host en GOOGLE_HOSTS ademas del origen propio.
- La restauracion de sesion respeta el mismo criterio (una pestana de Google guardada
  reabre como pestana de Google).
- El script inyectado detecta docs.google.com/... y hace newTab en vez de openExternal;
  en paginas de Google solo reporta el titulo (sin tocar clics, atajos ni window.open)
  para no interferir con el editor de Google.

Falta probar el instalador de prueba antes de etiquetar la version.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-14 11:45:40 -05:00
Jonathan David Florez Salomón
d5d24c6cb8 v1.12.0: rastreo sin permiso de Accesibilidad en macOS
Sube la version para publicar el arreglo del sensor (macOS ya no pide el permiso
de Accesibilidad: usa CGEventSourceSecondsSinceLastEventType en vez de leer el
teclado global con device_query) junto con la caché de miniaturas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 08:25:11 -05:00
Jonathan David Florez Salomón
a4c91079d9 fix(tracker): en macOS medir inactividad sin permiso de Accesibilidad
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>
2026-09-05 07:49:30 -05:00
Jonathan David Florez Salomón
a202a37e75 El cajon de miniaturas: la app deja de re-bajar las fotos cada vez que se abre
El problema: las webviews guardan las imagenes en un cache del SISTEMA, pequeno y
volatil (medidos 22 MB en un Mac del equipo). Un set de 500 fotos pasa de 150 MB
entre cuadricula y visor: no cabe, el sistema lo vacia, y al reabrir la app hay
que volver a bajarlo todo del NAS. Eso es el "se relee cada vez que cierro".

El cajon es un cache PROPIO de la app, en su carpeta interna (app_cache_dir,
invisible en el Finder), con tope de 3 GB y poda por antiguedad — el mismo
criterio que ya usa el NAS con sus miniaturas. Sobrevive a cerrar la app y se
vacia con un boton del menu (que ademas dice cuanto ocupa).

Como funciona: dentro de la app, la miniatura de la cuadricula (thumb=1) se pide
por el esquema lsthumb:// en vez de directo al servidor (lo reescribe el script
de inicio, solo en la app). El handler en Rust la sirve del disco si ya esta, o
la trae del NAS UNA vez, la guarda y la sirve. La clave del cajon NO incluye el
token firmado (que rota cada 24h): se deriva de la identidad estable de la foto,
asi que acierta un mes despues.

Decisiones:
- Solo se reescribe thumb=1 (la de 480px, la que se repite 500 veces). El visor
  grande (thumb=xl) y la anotacion sobre lienzo NO se tocan: cargar la imagen del
  lienzo por otro esquema lo "ensuciaria" y romperia guardar el dibujo.
- Cerrojo anti-proxy: el handler solo trae del origen del servidor y de /api/,
  nunca una URL arbitraria (evita convertir la app en un proxy abierto / SSRF).
- La reescritura solo corre DENTRO de la app (guarda de origen del init script).
  En el navegador normal, y para el cliente, nada cambia.
- El traer va en el pool de hilos de bloqueo, no un hilo por peticion: 500 fotos
  no disparan 500 hilos.

Verificado en Mac: compila y enlaza limpio (tauri 2.11.3), 10 pruebas del motor
del cajon en verde (clave estable sin token, cerrojo anti-proxy, poda LRU,
extraccion de la URL), y la logica de reescritura probada contra casos reales en
node (thumb=xl NO se toca, idempotente, Drive/data: no se tocan).

PENDIENTE de probar con las manos, y por eso esto NO se etiqueta todavia:
- El extremo a extremo (la miniatura se cachea y sobrevive al reinicio) necesita
  entrar con sesion a un set de fotos — no pude por no tener la contrasena.
- No lo lance en vivo: habia una instancia release corriendo que comparte
  estado.json; una segunda instancia abriria otra ventana y pisaria la sesion.
- Windows: el esquema custom desde un origen remoto puede comportarse distinto en
  WebView2 (AllowedOrigins). Hay que probarlo en un Windows real ANTES de etiquetar
  — como ya se hizo con el fix de OnlyOffice (v1.5.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 21:17:07 -05:00
Se han modificado 7 ficheros con 620 adiciones y 20 borrados

Ver fichero

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

Ver fichero

@@ -2366,7 +2366,7 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.11.0" version = "1.14.0"
dependencies = [ dependencies = [
"active-win-pos-rs", "active-win-pos-rs",
"device_query", "device_query",

Ver fichero

@@ -1,6 +1,6 @@
[package] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.11.0" version = "1.14.0"
description = "Cliente de escritorio de Labstream OS" description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"] authors = ["Labstream SAS"]
edition = "2021" edition = "2021"
@@ -34,10 +34,16 @@ serde_json = "1"
# ureq: subir los lotes a /api/tracker (bloqueante, corre en su propio hilo). # 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. # gethostname: el nombre del equipo, que bautiza al device en el servidor.
active-win-pos-rs = "0.11" active-win-pos-rs = "0.11"
device_query = "4"
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
gethostname = "0.5" gethostname = "0.5"
# device_query solo en Windows/Linux: la huella de entrada (posición del mouse + cuántas teclas
# hay presionadas). En macOS NO se usa —leer el teclado global dispara el permiso de
# Accesibilidad y el diálogo «… quiere controlar este ordenador»— y se sustituye por
# CGEventSourceSecondsSinceLastEventType (idle_seconds_macos en tracker.rs), que no pide permiso.
[target.'cfg(not(target_os = "macos"))'.dependencies]
device_query = "4"
# ── Excepción del video (Windows) ── # ── Excepción del video (Windows) ──
# Para saber si «hay un video reproduciéndose» sin espiar el navegador: se mira el MEDIDOR de # 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 # la salida de audio por defecto (WASAPI, IAudioMeterInformation). Si al frente hay un navegador

339
src-tauri/src/cache.rs Archivo normal
Ver fichero

@@ -0,0 +1,339 @@
// ── El «cajón» de miniaturas de la app ──
//
// El problema que resuelve: las webviews guardan las imágenes en un caché del SISTEMA que es
// pequeño y volátil — el sistema lo vacía cuando necesita espacio. En un Mac del equipo se
// midieron 22 MB; un solo set de 500 fotos, entre cuadrícula y visor, pasa de 150 MB. No cabe,
// se borra solo, y al reabrir la app hay que volver a bajarlo todo del NAS. Eso es lo que se
// siente como «se relee cada vez que cierro».
//
// El cajón es un caché PROPIO de la app, en su carpeta interna (`app_cache_dir`, invisible en el
// Finder), con un tope que decidimos nosotros y una poda por antigüedad — igual que la que el NAS
// ya hace con sus miniaturas. Sobrevive a cerrar la app y se vacía con un botón.
//
// Cómo se usa: la web app, DENTRO de la app de escritorio, pide las miniaturas por el esquema
// `lsthumb://` en vez de directamente al servidor (lo reescribe el script de inicio). Este módulo
// es quien contesta ese esquema: si la miniatura está en el cajón la sirve del disco sin tocar la
// red; si no, la trae del NAS una vez, la guarda y la sirve.
//
// La clave del cajón NO incluye el token firmado de la URL (que rota cada 24 h): se deriva de la
// identidad estable de la miniatura (la ruta + su variante), así que la misma foto acierta en el
// cajón un mes después. El token solo se usa para ir a buscarla al NAS cuando falta.
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, SystemTime};
use tauri::{AppHandle, Manager, Runtime};
// El único servidor del que este cajón acepta traer bytes. Sin este cerrojo, una URL manipulada
// convertiría la app en un proxy abierto que trae cualquier cosa de internet (un SSRF). Se compara
// por ORIGEN exacto (esquema + host + puerto), no por «empieza por».
const SERVER: &str = "https://os.labstreamsas.com";
// Tope del cajón: 3 GB, una sesión grande entera de miniaturas. Al pasarse, se borra lo más viejo
// (por último uso) hasta bajar del tope. Es el mismo criterio y tamaño que el caché del NAS.
const CAP_BYTES: u64 = 3 * 1024 * 1024 * 1024;
// Recorrer el directorio para podar es caro: no se hace en cada escritura, sino cada N.
const PODA_CADA: u32 = 200;
static DESDE_PODA: AtomicU32 = AtomicU32::new(0);
// Subcarpeta del caché de la app donde vive el cajón.
const DIR: &str = "miniaturas";
// ── Piezas PURAS (probadas abajo) ─────────────────────────────────────────────
// ¿Se puede traer esta URL? Solo el origen del servidor, y solo rutas de su API. Cualquier otra
// cosa se rechaza: el cajón no es un proxy de propósito general.
pub fn permitido(url: &str) -> bool {
let (Ok(u), Ok(srv)) = (tauri::Url::parse(url), tauri::Url::parse(SERVER)) else {
return false;
};
u.origin() == srv.origin() && u.path().starts_with("/api/")
}
// La clave estable de una miniatura: su ruta + los parámetros que la IDENTIFICAN, ordenados, y
// SIN el token `t` (que rota). Se resume con FNV-1a a un nombre corto y seguro para el sistema de
// archivos. Que no lleve el token es lo que hace que el cajón siga acertando cuando el token de la
// página cambió: la foto es la misma.
pub fn clave_de(url: &str) -> Option<String> {
let u = tauri::Url::parse(url).ok()?;
let mut params: Vec<(String, String)> = u
.query_pairs()
.filter(|(k, _)| k != "t") // el token NO forma parte de la identidad
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
params.sort();
let mut base = u.path().to_string();
for (k, v) in &params {
base.push('|');
base.push_str(k);
base.push('=');
base.push_str(v);
}
Some(fnv1a_hex(&base))
}
// Saca el parámetro `u` (la URL real del servidor, percent-encoded) de la URI del esquema. Funciona
// sea cual sea la forma que la plataforma le dé al esquema — `lsthumb://c/?u=…` en macOS,
// `http://lsthumb.localhost/…?u=…` en Windows —: en ambas `u` viaja como parámetro de consulta y
// query_pairs lo decodifica.
pub fn url_pedida(uri: &str) -> Option<String> {
tauri::Url::parse(uri)
.ok()?
.query_pairs()
.find(|(k, _)| k == "u")
.map(|(_, v)| v.into_owned())
}
fn fnv1a_hex(s: &str) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
format!("{h:016x}")
}
// Un archivo del cajón, para decidir la poda.
#[derive(Clone)]
struct Entrada {
ruta: PathBuf,
bytes: u64,
usado: SystemTime,
}
// La POLÍTICA de poda, aislada del disco para poder probarla: dado el contenido del cajón y el
// tope, devuelve qué archivos borrar — los MÁS VIEJOS por último uso, hasta que el total baje del
// tope. Si ya cabe, no borra nada.
fn plan_de_poda(mut items: Vec<Entrada>, cap: u64) -> Vec<PathBuf> {
let total: u64 = items.iter().map(|e| e.bytes).sum();
if total <= cap {
return Vec::new();
}
// Más viejo primero (el primero en salir).
items.sort_by_key(|e| e.usado);
let mut sobra = total - cap;
let mut a_borrar = Vec::new();
for e in items {
if sobra == 0 {
break;
}
a_borrar.push(e.ruta);
sobra = sobra.saturating_sub(e.bytes);
}
a_borrar
}
// ── Disco ──────────────────────────────────────────────────────────────────────
fn dir<R: Runtime>(app: &AppHandle<R>) -> Option<PathBuf> {
let base = app.path().app_cache_dir().ok()?;
Some(base.join(DIR))
}
// Lee una miniatura del cajón. `None` = no está (o no se pudo leer): el que llama irá al NAS.
pub fn leer<R: Runtime>(app: &AppHandle<R>, clave: &str) -> Option<Vec<u8>> {
let ruta = dir(app)?.join(clave);
fs::read(&ruta).ok()
}
// Guarda una miniatura en el cajón (escritura atómica: se escribe a un temporal y se renombra, así
// una lectura nunca ve un archivo a medias). Cada cierto número de escrituras, poda.
pub fn escribir<R: Runtime>(app: &AppHandle<R>, clave: &str, bytes: &[u8]) {
let Some(d) = dir(app) else { return };
if fs::create_dir_all(&d).is_err() {
return;
}
let destino = d.join(clave);
let tmp = d.join(format!("{clave}.tmp"));
if fs::write(&tmp, bytes).is_ok() {
let _ = fs::rename(&tmp, &destino);
}
if DESDE_PODA.fetch_add(1, Ordering::Relaxed) + 1 >= PODA_CADA {
DESDE_PODA.store(0, Ordering::Relaxed);
podar(&d);
}
}
fn podar(d: &Path) {
let Ok(rd) = fs::read_dir(d) else { return };
let mut items = Vec::new();
for e in rd.flatten() {
let Ok(meta) = e.metadata() else { continue };
if !meta.is_file() {
continue;
}
let nombre = e.file_name();
if nombre.to_string_lossy().ends_with(".tmp") {
continue; // un temporal a medio escribir no se poda ni se cuenta
}
// Último uso: la fecha de acceso si el sistema la lleva, si no la de modificación.
let usado = meta.accessed().or_else(|_| meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
items.push(Entrada { ruta: e.path(), bytes: meta.len(), usado });
}
for ruta in plan_de_poda(items, CAP_BYTES) {
let _ = fs::remove_file(ruta);
}
}
// Cuánto ocupa el cajón, en bytes (para la etiqueta «Vaciar caché (NN MB)» del menú).
pub fn tamano<R: Runtime>(app: &AppHandle<R>) -> u64 {
let Some(d) = dir(app) else { return 0 };
let Ok(rd) = fs::read_dir(&d) else { return 0 };
rd.flatten()
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
}
// Vacía el cajón entero. No pierde nada importante: se vuelve a llenar solo con el uso.
pub fn vaciar<R: Runtime>(app: &AppHandle<R>) {
if let Some(d) = dir(app) {
let _ = fs::remove_dir_all(&d);
let _ = fs::create_dir_all(&d);
}
}
// ── Traer del NAS (solo cuando falta en el cajón) ───────────────────────────────
// Tope de lo que se acepta bajar por miniatura. Una miniatura webp pesa cientos de KB; 64 MB es un
// techo absurdo a propósito, solo para que una respuesta descomunal no llene la memoria.
const MAX_DESCARGA: u64 = 64 * 1024 * 1024;
// Trae los bytes de una miniatura del servidor. Devuelve `(bytes, es_webp)`: solo se GUARDAN en el
// cajón las que son webp (lo único que este esquema pide); una respuesta de otro tipo se sirve tal
// cual pero no se cachea. `Err(codigo)` = no se pudo traer (para que la imagen quede rota como
// quedaría sin la app, no en blanco).
pub fn traer(url: &str) -> Result<(Vec<u8>, bool), u16> {
let resp = match ureq::get(url).timeout(Duration::from_secs(30)).call() {
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => return Err(code),
Err(_) => return Err(502), // sin red o error de transporte
};
let es_webp = resp
.header("Content-Type")
.map(|c| c.starts_with("image/webp"))
.unwrap_or(false);
let mut bytes = Vec::new();
resp.into_reader()
.take(MAX_DESCARGA)
.read_to_end(&mut bytes)
.map_err(|_| 502u16)?;
Ok((bytes, es_webp))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn url_pedida_saca_y_decodifica_la_url_real() {
let real = "https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1";
let uri = format!("lsthumb://c/?u={}", urlencoding_min(real));
assert_eq!(url_pedida(&uri).as_deref(), Some(real));
// Y también la forma que podría tomar en Windows.
let uri_win = format!("http://lsthumb.localhost/?u={}", urlencoding_min(real));
assert_eq!(url_pedida(&uri_win).as_deref(), Some(real));
}
#[test]
fn url_pedida_sin_u_es_none() {
assert_eq!(url_pedida("lsthumb://c/?otra=cosa"), None);
}
// Percent-encoding mínimo para las pruebas (: / ? & =), suficiente para estos casos.
fn urlencoding_min(s: &str) -> String {
s.chars()
.map(|c| match c {
':' => "%3A".into(),
'/' => "%2F".into(),
'?' => "%3F".into(),
'&' => "%26".into(),
'=' => "%3D".into(),
c => c.to_string(),
})
.collect()
}
#[test]
fn permitido_solo_el_servidor_y_su_api() {
assert!(permitido("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1"));
// Otro host, aunque diga labstream: NO.
assert!(!permitido("https://malo.labstreamsas.com.attacker.net/api/x"));
// El servidor pero fuera de /api/: NO (nada de rutas arbitrarias).
assert!(!permitido("https://os.labstreamsas.com/etc/passwd"));
// http en vez de https: distinto origen, NO.
assert!(!permitido("http://os.labstreamsas.com/api/x"));
// Basura: NO.
assert!(!permitido("no-soy-una-url"));
}
#[test]
fn la_clave_ignora_el_token_que_rota() {
// La MISMA miniatura con dos tokens distintos (mañana, pasado) → la misma clave.
let hoy = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1000.aaa&thumb=1");
let man = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=2000.bbb&thumb=1");
assert!(hoy.is_some());
assert_eq!(hoy, man, "el token no debe formar parte de la clave");
}
#[test]
fn la_clave_distingue_foto_y_variante() {
let mini = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1").unwrap();
let xl = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=xl").unwrap();
let otra = clave_de("https://os.labstreamsas.com/api/files-asset/def?t=1.x&thumb=1").unwrap();
assert_ne!(mini, xl, "la miniatura y el visor son distintos");
assert_ne!(mini, otra, "dos fotos distintas son claves distintas");
}
#[test]
fn la_clave_no_depende_del_orden_de_los_parametros() {
let a = clave_de("https://os.labstreamsas.com/api/files-asset/abc?thumb=1&t=1.x").unwrap();
let b = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1").unwrap();
assert_eq!(a, b);
}
#[test]
fn la_clave_es_segura_para_el_sistema_de_archivos() {
let k = clave_de("https://os.labstreamsas.com/api/files-asset/abc?thumb=1").unwrap();
assert!(k.chars().all(|c| c.is_ascii_hexdigit()), "clave = {k}");
}
fn e(ruta: &str, bytes: u64, hace_segundos: u64) -> Entrada {
Entrada {
ruta: PathBuf::from(ruta),
bytes,
usado: SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000 - hace_segundos),
}
}
#[test]
fn poda_no_borra_nada_si_cabe() {
let items = vec![e("a", 100, 10), e("b", 100, 5)];
assert!(plan_de_poda(items, 1000).is_empty());
}
#[test]
fn poda_borra_lo_mas_viejo_primero_hasta_bajar_del_tope() {
// Total 900, tope 500 → sobran 400. Debe borrar los más viejos hasta cubrir 400.
let items = vec![
e("nueva", 300, 1), // la más reciente
e("media", 300, 100),
e("vieja", 300, 1000), // la más antigua
];
let borra = plan_de_poda(items, 500);
// Vieja (300) no basta (faltan 100) → también media. Nueva se salva.
assert_eq!(borra, vec![PathBuf::from("vieja"), PathBuf::from("media")]);
}
#[test]
fn poda_justo_en_el_tope_no_borra() {
let items = vec![e("a", 250, 10), e("b", 250, 5)];
assert!(plan_de_poda(items, 500).is_empty(), "500==500 cabe");
}
}

Ver fichero

@@ -32,6 +32,7 @@
// `core:default` también para el origen remoto (capabilities/default.json). Así no hace falta // `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. // declarar comandos propios en el ACL para páginas remotas.
mod cache;
mod tracker; mod tracker;
use std::sync::Mutex; use std::sync::Mutex;
@@ -52,6 +53,44 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
const SERVER_URL: &str = "https://os.labstreamsas.com"; const SERVER_URL: &str = "https://os.labstreamsas.com";
// Los documentos del contador viven en Google (pestaña Contable). Se abren como PESTANAS
// de esta app, no en el navegador externo: asi el WebView guarda la sesion de Google del
// usuario (cookies persistentes) y no hay que iniciar sesion cada vez. La lista es cerrada
// a proposito: solo hosts propios de Google (docs/drive/sheets y accounts para el login).
// Todo lo demas sigue yendo al navegador del sistema.
const GOOGLE_HOSTS: &[&str] = &[
"docs.google.com",
"drive.google.com",
"sheets.google.com",
"accounts.google.com",
"myaccount.google.com",
];
// Cadena de navegador de escritorio. Sin esto, WebView2 se anuncia como control embebido y
// Google Sheets/Docs se quedan en blanco. Se declara por plataforma porque Google tambien
// mira la coherencia entre el sistema declarado y el resto de la cadena.
#[cfg(target_os = "windows")]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
#[cfg(target_os = "macos")]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
fn is_google_host(host: &str) -> bool {
let h = host.to_ascii_lowercase();
GOOGLE_HOSTS.iter().any(|d| h == *d || h.ends_with(&format!(".{}", d)))
}
fn es_pestana_interna(target: &Url, server: &Url) -> bool {
if target.origin() == server.origin() {
return true;
}
if target.scheme() != "https" {
return false;
}
target.host_str().map(is_google_host).unwrap_or(false)
}
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome) const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
const CHROME: &str = "chrome"; const CHROME: &str = "chrome";
// Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese // Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese
@@ -204,7 +243,12 @@ const INIT_JS_TPL: &str = r#"
// descargar fallaban solo en la app de Windows, y no en Mac ni en el navegador. // descargar fallaban solo en la app de Windows, y no en Mac ni en el navegador.
// En macOS estas dos líneas son un no-op: allí el script nunca ha corrido en subframes. // En macOS estas dos líneas son un no-op: allí el script nunca ha corrido en subframes.
try { if (window.top !== window.self) return; } catch (e) { return; } try { if (window.top !== window.self) return; } catch (e) { return; }
if (location.origin !== ORIGIN) return; // p. ej. el login del SSO: no intervenir // Hosts de Google que la app abre como pestana propia (pestana Contable). En esas
// paginas solo reportamos titulo a la barra; NO tocamos clics ni window.open para no
// romper el editor de Google.
var GOOGLE_RE = /^(?:.*\.)?(?:docs|drive|sheets|accounts|myaccount)\.google\.com$/i;
var esGoogle = GOOGLE_RE.test(location.hostname);
if (location.origin !== ORIGIN && !esGoogle) return;
if (window.__lsShell) return; if (window.__lsShell) return;
window.__lsShell = true; window.__lsShell = true;
@@ -215,7 +259,9 @@ const INIT_JS_TPL: &str = r#"
function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } } function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } }
function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); } function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); }
function isApp(u) { return isHttp(u) && u.origin === ORIGIN; } function isApp(u) { return isHttp(u) && u.origin === ORIGIN; }
function isExt(u) { return isHttp(u) && u.origin !== ORIGIN; } function isGoogleUrl(u) { return isHttp(u) && GOOGLE_RE.test(u.hostname); }
function isInterno(u) { return isApp(u) || isGoogleUrl(u); }
function isExt(u) { return isHttp(u) && !isInterno(u); }
function emit(name, payload) { try { T.event.emit(name, payload); } catch (e) {} } function emit(name, payload) { try { T.event.emit(name, payload); } catch (e) {} }
function openExternal(url) { function openExternal(url) {
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); } try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
@@ -223,6 +269,74 @@ const INIT_JS_TPL: &str = r#"
} }
function newTab(url) { emit('ls-new-tab', { url: url }); } function newTab(url) { emit('ls-new-tab', { url: url }); }
// ── Miniaturas por el CAJÓN de la app (lsthumb://) ──
// Dentro de la app de escritorio, las miniaturas de la cuadrícula de fotos se piden por el
// esquema lsthumb:// en vez de directo al servidor. Así las sirve el cajón local (Rust): del
// disco si ya están —sobreviven a cerrar la app, que es lo que se pedía— y del NAS solo la
// primera vez. Fuera de la app (navegador normal) este script no corre y todo sigue igual.
//
// Se reescribe SOLO la miniatura de 480 px (thumb=1): es la que se repite 500 veces por set y
// ahoga la cuadrícula. El visor grande (thumb=xl) y la anotación sobre lienzo NO se tocan —
// cargar la imagen del lienzo por otro esquema lo «ensuciaría» y rompería «guardar el dibujo».
try {
function porCajon(src) {
if (!src || src.lastIndexOf('lsthumb:', 0) === 0) return src; // ya reescrita
var u = abs(src);
if (!u || u.origin !== ORIGIN) return src; // solo lo del propio servidor
if (!/\/api\/files-asset\//.test(u.pathname) || u.searchParams.get('thumb') !== '1') return src;
return 'lsthumb://c/?u=' + encodeURIComponent(u.href);
}
var proto = HTMLImageElement.prototype;
// 1) La propiedad .src (lo que usa React al asignar la imagen).
var d = Object.getOwnPropertyDescriptor(proto, 'src');
if (d && d.get && d.set) {
Object.defineProperty(proto, 'src', {
configurable: true, enumerable: d.enumerable,
get: function () { return d.get.call(this); },
set: function (v) { d.set.call(this, porCajon(String(v))); },
});
}
// 2) setAttribute('src', …), que no pasa por el setter de la propiedad. Se parchea SOLO en
// <img> (prototipo de HTMLImageElement), no en todos los elementos.
var setA = proto.setAttribute;
proto.setAttribute = function (name, value) {
if (arguments.length >= 2 && String(name).toLowerCase() === 'src') {
return setA.call(this, name, porCajon(String(value)));
}
return setA.apply(this, arguments);
};
// 3) Las que ya vienen en el HTML del servidor (SSR) o llegan luego. porCajon es idempotente
// (una ya reescrita se devuelve igual), así que barrer no entra en bucle con el observer.
function barrer(raiz) {
if (!raiz || !raiz.querySelectorAll) return;
var imgs = raiz.querySelectorAll('img[src]');
for (var i = 0; i < imgs.length; i++) {
var cur = imgs[i].getAttribute('src');
var nue = porCajon(cur);
if (nue !== cur) imgs[i].setAttribute('src', nue);
}
}
if (window.MutationObserver) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var m = muts[i];
if (m.type === 'attributes' && m.target && m.target.tagName === 'IMG') {
var cur = m.target.getAttribute('src'), nue = porCajon(cur);
if (nue !== cur) m.target.setAttribute('src', nue);
}
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (!n || n.nodeType !== 1) continue;
if (n.tagName === 'IMG') { var c = n.getAttribute('src'), x = porCajon(c); if (x !== c) n.setAttribute('src', x); }
else barrer(n);
}
}
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { barrer(document); });
else barrer(document);
} catch (e) { /* si algo falla, las imágenes cargan directo del servidor como siempre */ }
// ── ¿La página se VE oscura? ── // ── ¿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 // 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 // está, el color de fondo REALMENTE pintado (luminancia del body) — vale sea cual sea
@@ -274,6 +388,7 @@ const INIT_JS_TPL: &str = r#"
history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; }; history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; };
}); });
addEventListener('popstate', function () { setTimeout(report, 0); }); addEventListener('popstate', function () { setTimeout(report, 0); });
if (esGoogle) return; // en Google solo miramos el titulo
// Pulsar la luna cambia clases/estilos sin navegar: se vigilan <html> y <body> (cuando // 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 // 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. // por si el tema aterriza tarde con la hidratación.
@@ -296,6 +411,9 @@ const INIT_JS_TPL: &str = r#"
if (!u) return; if (!u) return;
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; } if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
var mod = e.metaKey || e.ctrlKey; var mod = e.metaKey || e.ctrlKey;
// Enlace a Google (docs, drive, sheets, accounts): SIEMPRE como pestana propia
// aunque no lleve target=_blank, para que la sesion viva dentro de la app.
if (isGoogleUrl(u)) { e.preventDefault(); newTab(u.href); return; }
if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); } if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); }
}, true); }, true);
@@ -306,14 +424,14 @@ const INIT_JS_TPL: &str = r#"
if (!a) return; if (!a) return;
var u = abs(a.getAttribute('href')); var u = abs(a.getAttribute('href'));
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; } if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
if (isApp(u)) { e.preventDefault(); newTab(u.href); } if (isInterno(u)) { e.preventDefault(); newTab(u.href); }
}, true); }, true);
var _open = window.open; var _open = window.open;
window.open = function (url, name, feats) { window.open = function (url, name, feats) {
var u = url ? abs(url) : null; var u = url ? abs(url) : null;
if (isExt(u)) { openExternal(u.href); return null; } if (isExt(u)) { openExternal(u.href); return null; }
if (isApp(u)) { newTab(u.href); return null; } if (isInterno(u)) { newTab(u.href); return null; }
return _open ? _open.call(window, url, name, feats) : null; return _open ? _open.call(window, url, name, feats) : null;
}; };
@@ -475,7 +593,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
// Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador. // Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador.
let Ok(parsed) = Url::parse(&target) else { return }; let Ok(parsed) = Url::parse(&target) else { return };
let Ok(server) = Url::parse(SERVER_URL) else { return }; let Ok(server) = Url::parse(SERVER_URL) else { return };
if parsed.origin() != server.origin() { if !es_pestana_interna(&parsed, &server) {
return; return;
} }
@@ -495,6 +613,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL); let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL);
let app_zoom = app.clone(); let app_zoom = app.clone();
let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed)) let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed))
.user_agent(UA_ESCRITORIO)
.initialization_script(&init) .initialization_script(&init)
// Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar. // Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar.
// El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE // El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE
@@ -750,6 +869,10 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
let Some(win) = main_window(app) else { return }; let Some(win) = main_window(app) else { return };
let autostart_on = app.autolaunch().is_enabled().unwrap_or(false); let autostart_on = app.autolaunch().is_enabled().unwrap_or(false);
let version = format!("Versión {}", app.package_info().version); let version = format!("Versión {}", app.package_info().version);
// El cajón dice cuánto ocupa en su propia etiqueta: así vaciarlo es una decisión informada, y
// de paso es la comprobación más simple de que el caché está funcionando (el número crece al
// navegar fotos y vuelve a 0 al vaciar).
let vaciar_txt = format!("Vaciar caché de imágenes ({})", tamano_legible(cache::tamano(app)));
let build = || -> tauri::Result<Menu<R>> { let build = || -> tauri::Result<Menu<R>> {
Ok(Menu::with_items( Ok(Menu::with_items(
app, app,
@@ -767,6 +890,7 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
&MenuItem::with_id(app, "zoom-0", "Tamaño real", true, Some("CmdOrCtrl+0"))?, &MenuItem::with_id(app, "zoom-0", "Tamaño real", true, Some("CmdOrCtrl+0"))?,
&PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "open-browser", "Abrir esta página en el navegador", true, None::<&str>)?, &MenuItem::with_id(app, "open-browser", "Abrir esta página en el navegador", true, None::<&str>)?,
&MenuItem::with_id(app, "clear-cache", &vaciar_txt, true, None::<&str>)?,
&CheckMenuItem::with_id(app, "autostart-toggle", "Iniciar con el sistema", true, autostart_on, None::<&str>)?, &CheckMenuItem::with_id(app, "autostart-toggle", "Iniciar con el sistema", true, autostart_on, None::<&str>)?,
&MenuItem::with_id(app, "check-update", "Buscar actualización…", true, None::<&str>)?, &MenuItem::with_id(app, "check-update", "Buscar actualización…", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::separator(app)?,
@@ -780,6 +904,24 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
} }
} }
// Bytes → «12 MB» / «340 KB» / «1.2 GB», para la etiqueta del menú.
fn tamano_legible(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{} MB", bytes / MB)
} else if bytes >= KB {
format!("{} KB", bytes / KB)
} else if bytes == 0 {
"vacío".to_string()
} else {
format!("{bytes} B")
}
}
// Menú de CLIC DERECHO sobre una pestaña. Nativo (la barra mide 40 px: un menú HTML dentro // Menú de CLIC DERECHO sobre una pestaña. Nativo (la barra mide 40 px: un menú HTML dentro
// del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió. // del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió.
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) { fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
@@ -1004,6 +1146,73 @@ fn field_bool(payload: &str, key: &str) -> Option<bool> {
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_bool() serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_bool()
} }
// ── Esquema lsthumb:// — el cajón de miniaturas ────────────────────────────────
// La web app, dentro de la app, pide las miniaturas de la cuadrícula como
// `lsthumb://c/?u=<URL real del servidor, con su token>`. Aquí se contesta: si está en el cajón,
// del disco; si no, se trae del NAS UNA vez, se guarda y se sirve. Todo el trabajo de red y disco
// va en un hilo aparte (ureq es bloqueante) para no congelar el webview.
//
// El módulo `cache` pone los cerrojos: `permitido` (solo el servidor y su API, nunca un proxy
// abierto) y `clave_de` (identidad estable sin el token que rota).
fn respuesta_bytes(bytes: Vec<u8>) -> tauri::http::Response<Vec<u8>> {
tauri::http::Response::builder()
.status(200)
.header("Content-Type", "image/webp")
// Privada y de larga vida: quien manda de verdad es el cajón en disco; esto solo evita
// que el propio webview volviera a pedirla dentro de la misma sesión.
.header("Cache-Control", "private, max-age=86400")
.header("Access-Control-Allow-Origin", "*")
.body(bytes)
.unwrap()
}
fn respuesta_estado(code: u16) -> tauri::http::Response<Vec<u8>> {
tauri::http::Response::builder()
.status(code)
.header("Access-Control-Allow-Origin", "*")
.body(Vec::new())
.unwrap()
}
fn responder_lsthumb<R: Runtime>(
ctx: tauri::UriSchemeContext<'_, R>,
req: tauri::http::Request<Vec<u8>>,
responder: tauri::UriSchemeResponder,
) {
let app = ctx.app_handle().clone();
let objetivo = match cache::url_pedida(&req.uri().to_string()) {
Some(u) if cache::permitido(&u) => u,
_ => {
responder.respond(respuesta_estado(400));
return;
}
};
let Some(clave) = cache::clave_de(&objetivo) else {
responder.respond(respuesta_estado(400));
return;
};
// ¿En el cajón? Del disco, sin tocar la red.
if let Some(bytes) = cache::leer(&app, &clave) {
responder.respond(respuesta_bytes(bytes));
return;
}
// Falta: traerla del NAS y guardarla para la próxima. Va en el POOL de hilos de bloqueo (no un
// `std::thread::spawn` por petición): al abrir un set de 500 fotos podrían dispararse muchas a
// la vez, y el pool las acota y reutiliza sus hilos en vez de crear cientos.
tauri::async_runtime::spawn_blocking(move || match cache::traer(&objetivo) {
Ok((bytes, es_webp)) => {
if es_webp {
cache::escribir(&app, &clave, &bytes);
}
responder.respond(respuesta_bytes(bytes));
}
Err(code) => responder.respond(respuesta_estado(code)),
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let mut builder = tauri::Builder::default(); let mut builder = tauri::Builder::default();
@@ -1021,7 +1230,10 @@ pub fn run() {
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)); .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
// El cajón de miniaturas (ver el módulo `cache` y el handler de arriba). Registrado en el
// builder para que TODAS las webviews de pestaña puedan usarlo.
.register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb);
let app = builder let app = builder
.manage(ShellState::default()) .manage(ShellState::default())
@@ -1193,6 +1405,18 @@ pub fn run() {
} }
id if id.starts_with("tab-color-") => tab_menu_action(app, id), id if id.starts_with("tab-color-") => tab_menu_action(app, id),
"autostart-toggle" => toggle_autostart(app), "autostart-toggle" => toggle_autostart(app),
"clear-cache" => {
let antes = cache::tamano(app);
cache::vaciar(app);
// Un aviso nativo confirma que se hizo (y cuánto se liberó): sin él, «vaciar»
// no da ninguna señal y parece que no pasó nada.
let _ = app
.notification()
.builder()
.title("Labstream OS")
.body(format!("Caché de imágenes vaciado ({} liberados).", tamano_legible(antes)))
.show();
}
"check-update" => { "check-update" => {
#[cfg(desktop)] #[cfg(desktop)]
tauri::async_runtime::spawn(check_update_manual(app.clone())); tauri::async_runtime::spawn(check_update_manual(app.clone()));
@@ -1226,7 +1450,7 @@ pub fn run() {
.tabs .tabs
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, u)| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false)) .filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12) .take(12)
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default())) .map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
.collect() .collect()

Ver fichero

@@ -102,6 +102,22 @@ struct MenuState<R: Runtime>(Mutex<MenuUi<R>>);
fn ahora_seg() -> u64 { fn ahora_seg() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) 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 { fn ahora_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
} }
@@ -427,8 +443,10 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED); let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
} }
#[cfg(not(target_os = "macos"))]
let device_state = device_query::DeviceState::new(); let device_state = device_query::DeviceState::new();
let mut ultimo_input = ahora_seg(); let mut ultimo_input = ahora_seg();
#[cfg(not(target_os = "macos"))]
let mut huella_anterior: (i32, i32, usize) = (0, 0, 0); let mut huella_anterior: (i32, i32, usize) = (0, 0, 0);
let mut acumulado: HashMap<(String, String), Bloque> = HashMap::new(); let mut acumulado: HashMap<(String, String), Bloque> = HashMap::new();
let mut ocios_pendientes: Vec<Ocio> = Vec::new(); let mut ocios_pendientes: Vec<Ocio> = Vec::new();
@@ -504,14 +522,27 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
} }
tic_previo = ahora; tic_previo = ahora;
// ¿Hubo entrada desde el último tic? Solo se compara una huella (posición del // ¿Hubo entrada desde el último tic? Solo interesa el HECHO de que hubo entrada,
// mouse + cuántas teclas hay presionadas): nunca se registra QUÉ se tecleó. // nunca QUÉ se tecleó ni dónde.
use device_query::DeviceQuery; //
let mouse = device_state.get_mouse(); // macOS: se pregunta al sistema cuántos segundos lleva SIN entrada (HID). Esa API
let teclas = device_state.get_keys().len(); // NO exige permiso de Accesibilidad ni de Monitorización de entrada, así que ya no
let huella = (mouse.coords.0, mouse.coords.1, teclas); // aparece el diálogo «… quiere controlar este ordenador». Inactividad < 1 tic =
let hubo_input = huella != huella_anterior || teclas > 0; // hubo entrada. Aquí `device_state`/`huella_anterior` no existen.
huella_anterior = huella; #[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 { if hubo_input {
ultimo_input = ahora; ultimo_input = ahora;
} }

Ver fichero

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