Comparar commits

..

4 Commits

Autor SHA1 Mensaje Fecha
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
Jonathan Flórez
f892dbbff7 v1.11.0: ícono «labs» + el «+» pegado a la última pestaña
- ÍCONO de la app (Mac + Windows): nuevo app-icon.png (1024²) = «labs» blanco
  centrado sobre negro. Son las MISMAS letras del wordmark «labstream»,
  recortadas (l·a·b·s), no una fuente parecida — así el ícono es fiel al logo.
  NO reemplaza el logo/wordmark de la app; es solo el ícono que se ve en el
  dock/barra de tareas/instalador. La CI regenera todos los tamaños con
  `npm run icons` desde este PNG.
- NUEVA PESTAÑA más a mano: el botón «+» deja de estar fijo a la derecha de la
  barra y pasa a vivir DENTRO de la tira de pestañas, pegado a la última
  (estilo Chrome). Al ser el último elemento flex de #tabs, queda justo tras la
  última pestaña sin importar cuántas haya; el hueco libre después sigue
  moviendo la ventana. Mismo evento de siempre (ls-new-tab); el atajo
  ⌘/Ctrl+T no cambia. Solo se tocó dist/index.html (barra del shell); cero Rust.

Verificado: el «+» renderiza como último hijo de #tabs, pegado a la última
pestaña (medido: 3 pestañas llenando la tira + el «+» justo después). El ícono
se ve nítido a 256 y legible a 48 px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 20:32:32 -05:00
Se han modificado 9 ficheros con 586 adiciones y 16 borrados

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 52 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 66 KiB

24
dist/index.html vendido
Ver fichero

@@ -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", {}));

Ver fichero

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

Ver fichero

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

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.10.0"
version = "1.12.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"
@@ -34,10 +34,16 @@ serde_json = "1"
# 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"
# 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) ──
# 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

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
// declarar comandos propios en el ACL para páginas remotas.
mod cache;
mod tracker;
use std::sync::Mutex;
@@ -223,6 +224,74 @@ const INIT_JS_TPL: &str = r#"
}
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? ──
// 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
@@ -750,6 +819,10 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
let Some(win) = main_window(app) else { return };
let autostart_on = app.autolaunch().is_enabled().unwrap_or(false);
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>> {
Ok(Menu::with_items(
app,
@@ -767,6 +840,7 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
&MenuItem::with_id(app, "zoom-0", "Tamaño real", true, Some("CmdOrCtrl+0"))?,
&PredefinedMenuItem::separator(app)?,
&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>)?,
&MenuItem::with_id(app, "check-update", "Buscar actualización…", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
@@ -780,6 +854,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
// 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) {
@@ -1004,6 +1096,73 @@ fn field_bool(payload: &str, key: &str) -> Option<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)]
pub fn run() {
let mut builder = tauri::Builder::default();
@@ -1021,7 +1180,10 @@ pub fn run() {
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.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
.manage(ShellState::default())
@@ -1193,6 +1355,18 @@ pub fn run() {
}
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
"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" => {
#[cfg(desktop)]
tauri::async_runtime::spawn(check_update_manual(app.clone()));

Ver fichero

@@ -102,6 +102,22 @@ struct MenuState<R: Runtime>(Mutex<MenuUi<R>>);
fn ahora_seg() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
// Segundos desde la última entrada del usuario (teclado/mouse/trackpad), preguntados al
// subsistema HID de macOS. CGEventSourceSecondsSinceLastEventType es de solo LECTURA y NO
// exige permiso de Accesibilidad ni de Monitorización de entrada (lo usan el salvapantallas y
// `ioreg HIDIdleTime`). Con esto el sensor sabe si hubo actividad SIN el diálogo «… quiere
// controlar este ordenador» que salía al leer el teclado global con device_query.
#[cfg(target_os = "macos")]
fn idle_seconds_macos() -> f64 {
const HID_SYSTEM_STATE: i32 = 1; // kCGEventSourceStateHIDSystemState
const ANY_INPUT_EVENT: u32 = 0xFFFF_FFFF; // kCGAnyInputEventType
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGEventSourceSecondsSinceLastEventType(state_id: i32, event_type: u32) -> f64;
}
unsafe { CGEventSourceSecondsSinceLastEventType(HID_SYSTEM_STATE, ANY_INPUT_EVENT) }
}
fn ahora_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
@@ -427,8 +443,10 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
}
#[cfg(not(target_os = "macos"))]
let device_state = device_query::DeviceState::new();
let mut ultimo_input = ahora_seg();
#[cfg(not(target_os = "macos"))]
let mut huella_anterior: (i32, i32, usize) = (0, 0, 0);
let mut acumulado: HashMap<(String, String), Bloque> = HashMap::new();
let mut ocios_pendientes: Vec<Ocio> = Vec::new();
@@ -504,14 +522,27 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
}
tic_previo = ahora;
// ¿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;
// ¿Hubo entrada desde el último tic? Solo interesa el HECHO de que hubo entrada,
// nunca QUÉ se tecleó ni dónde.
//
// macOS: se pregunta al sistema cuántos segundos lleva SIN entrada (HID). Esa API
// NO exige permiso de Accesibilidad ni de Monitorización de entrada, así que ya no
// aparece el diálogo «… quiere controlar este ordenador». Inactividad < 1 tic =
// hubo entrada. Aquí `device_state`/`huella_anterior` no existen.
#[cfg(target_os = "macos")]
let hubo_input = idle_seconds_macos() < (TICK_SEG as f64) + 1.0;
// Windows/Linux: huella de entrada con device_query (posición del mouse + cuántas
// teclas hay presionadas). Leer esto ahí no pide permiso especial.
#[cfg(not(target_os = "macos"))]
let hubo_input = {
use device_query::DeviceQuery;
let mouse = device_state.get_mouse();
let teclas = device_state.get_keys().len();
let huella = (mouse.coords.0, mouse.coords.1, teclas);
let cambio = huella != huella_anterior || teclas > 0;
huella_anterior = huella;
cambio
};
if hubo_input {
ultimo_input = ahora;
}

Ver fichero

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