diff --git a/README.md b/README.md index f875906..9ffeb52 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ 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). +- **Pestañas en reposo** (v1.19.0, solo Windows): cada pestaña es la web app entera, con su + conexión del chat y sus sondeos. Una que lleva 5 minutos oculta se **suspende** (WebView2 + `TrySuspend`: deja de gastar CPU y el sistema puede recuperar su memoria) y despierta sola, + donde estaba, al volver a ella. No se duerme la activa, ni una con una subida, una descarga o + audio en curso, ni un editor de documentos (`/docs/`). + - **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 diff --git a/package-lock.json b/package-lock.json index 01fd9f8..5a7f003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "labstream-desktop", - "version": "1.14.1", + "version": "1.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "labstream-desktop", - "version": "1.14.1", + "version": "1.19.0", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/package.json b/package.json index 95a8cb1..17526ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "labstream-desktop", - "version": "1.18.0", + "version": "1.19.0", "private": true, "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 220bae4..db67a1d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2366,11 +2366,13 @@ dependencies = [ [[package]] name = "labstream-desktop" -version = "1.18.0" +version = "1.19.0" dependencies = [ "active-win-pos-rs", "device_query", "gethostname 0.5.0", + "percent-encoding", + "rfd", "serde", "serde_json", "tauri", @@ -2382,6 +2384,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "ureq", + "webview2-com", "windows 0.61.3", ] @@ -3603,6 +3606,29 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4795e6a..fd50709 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labstream-desktop" -version = "1.18.0" +version = "1.19.0" description = "Cliente de escritorio de Labstream OS" authors = ["Labstream SAS"] edition = "2021" @@ -64,6 +64,8 @@ device_query = "4" # 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] +# Pestañas en reposo (1.19): ICoreWebView2_3::TrySuspend. Es la misma versión que ya trae Tauri. +webview2-com = "0.38" windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_Com", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89118a1..feca589 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ use std::{ collections::HashMap, path::{Path, PathBuf}, sync::Mutex, + time::{Duration, Instant}, }; use serde::{Deserialize, Serialize}; @@ -112,6 +113,10 @@ struct TabRec { // Color elegido a mano desde el menú de la pestaña (None = sin teñir). Aquí solo vive el // NOMBRE; el tono exacto y cómo se pinta son cosa del CSS de la barra. color: Option, + // Reposo (ver «Pestañas en reposo»): desde cuándo está oculta (None = es la activa) y si ya + // se suspendió. + oculta_desde: Option, + dormida: bool, } // Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de @@ -274,6 +279,43 @@ const INIT_JS_TPL: &str = r#" } function newTab(url) { emit('ls-new-tab', { url: url }); } + // ── Reposo ── + // El shell suspende las pestañas que llevan un rato ocultas (dejan de gastar CPU y sueltan + // memoria). Antes PREGUNTA: una pestaña con una subida o una descarga en curso, o con algo + // sonando, no se duerme. Se cuentan las peticiones vivas (fetch y XHR; el stream del chat es + // EventSource y no cuenta) y, ante cualquier duda, la cuenta se queda arriba: no dormir es + // siempre el lado seguro. + var enCurso = 0; + try { + var _send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function () { + var hecho = false; + enCurso++; + this.addEventListener('loadend', function () { if (!hecho) { hecho = true; enCurso--; } }); + try { return _send.apply(this, arguments); } + catch (e) { if (!hecho) { hecho = true; enCurso--; } throw e; } + }; + var _fetch = window.fetch; + if (_fetch) { + window.fetch = function () { + enCurso++; + var baja = function () { enCurso--; }; + var p; + try { p = _fetch.apply(this, arguments); } catch (e) { baja(); throw e; } + p.then(baja, baja); + return p; + }; + } + } catch (e) {} + window.__lsReposo = function () { + var suena = false; + try { + var medios = document.querySelectorAll('video,audio'); + for (var i = 0; i < medios.length; i++) { if (!medios[i].paused && !medios[i].ended) suena = true; } + } catch (e) { suena = true; } + emit('ls-reposo', { label: LABEL, libre: enCurso <= 0 && !suena }); + }; + // Las imagenes conservan su URL HTTPS y la sesion del webview. El descargador // lsthumb no comparte cookies: falla con miniaturas sin token y omite la // revalidacion de permisos al leer del disco. La cache HTTP respeta los ETag @@ -423,6 +465,89 @@ const INIT_JS_TPL: &str = r#" })(); "#; +// ── Pestañas en reposo ── +// +// Cada pestaña es la web app ENTERA: su árbol de React, su conexión del chat, sus sondeos. Con +// seis abiertas corrían seis, aunque solo se mire una. Una pestaña que lleva REPOSO_TRAS oculta +// se SUSPENDE (WebView2 `TrySuspend`: se congelan sus temporizadores y el sistema puede recuperar +// su memoria) y despierta sola, donde estaba, al volver a ella. La activa nunca se duerme, así +// que los avisos y el globo de no leídos siguen llegando por ella. +// +// No se duerme: la que está cargando, la que dice estar ocupada (subida, descarga o audio en +// curso: ver `__lsReposo` en el script de la pestaña) ni un editor de documentos (`/docs/`: +// OnlyOffice mantiene su propia conexión de edición y no conviene cortarla). +// Solo Windows: WKWebView (macOS) no ofrece nada equivalente y ya frena lo oculto por su cuenta. +const REPOSO_TRAS: Duration = Duration::from_secs(5 * 60); +const REPOSO_RONDA: Duration = Duration::from_secs(60); + +fn puede_reposar(t: &TabRec, activa: bool) -> bool { + !activa + && !t.dormida + && !t.loading + && !t.url.contains("/docs/") + && t.oculta_desde.map(|d| d.elapsed() >= REPOSO_TRAS).unwrap_or(false) +} + +// Cada minuto: a las candidatas se les PREGUNTA si están libres. La respuesta llega por el +// evento `ls-reposo` y es ahí donde se suspende. +fn ronda_de_reposo(app: &AppHandle) { + let candidatas: Vec = { + let shell = app.state::(); + let s = shell.lock().unwrap(); + s.tabs.iter().enumerate().filter(|(i, t)| puede_reposar(t, *i == s.active)).map(|(_, t)| t.label.clone()).collect() + }; + for label in candidatas { + if let Some(wv) = get_webview(app, &label) { + let _ = wv.eval("window.__lsReposo && window.__lsReposo()"); + } + } +} + +fn dormir_pestana(app: &AppHandle, label: &str) { + { + let shell = app.state::(); + let mut s = shell.lock().unwrap(); + let activa = s.active; + let Some((i, t)) = s.tabs.iter_mut().enumerate().find(|(_, t)| t.label == label) else { return }; + // Pudo volverse la activa (o empezar a cargar) mientras contestaba. + if !puede_reposar(t, i == activa) { + return; + } + t.dormida = true; + } + if let Some(wv) = get_webview(app, label) { + dormir_webview(&wv); + } +} + +#[cfg(windows)] +fn dormir_webview(wv: &Webview) { + let _ = wv.with_webview(|pw| unsafe { + use webview2_com::{Microsoft::Web::WebView2::Win32::ICoreWebView2_3, TrySuspendCompletedHandler}; + use windows::core::Interface; + let Ok(core) = pw.controller().CoreWebView2() else { return }; + let Ok(core3) = core.cast::() else { return }; + // Si WebView2 no puede (la pestaña está visible, o usa algo que lo impide), no pasa nada. + let _ = core3.TrySuspend(&TrySuspendCompletedHandler::create(Box::new(|_, _| Ok(())))); + }); +} +#[cfg(not(windows))] +fn dormir_webview(_wv: &Webview) {} + +// WebView2 ya despierta sola una vista al hacerla visible; se pide igual, por si acaso. +#[cfg(windows)] +fn despertar_webview(wv: &Webview) { + let _ = wv.with_webview(|pw| unsafe { + use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_3; + use windows::core::Interface; + let Ok(core) = pw.controller().CoreWebView2() else { return }; + let Ok(core3) = core.cast::() else { return }; + let _ = core3.Resume(); + }); +} +#[cfg(not(windows))] +fn despertar_webview(_wv: &Webview) {} + // ── Utilidades del shell ── fn main_window(app: &AppHandle) -> Option> { @@ -512,6 +637,16 @@ fn activate_tab(app: &AppHandle, label: &str) { } else { return; } + // Reposo: la activa deja de contar; las demás empiezan a contar desde que se ocultan. + let ahora = Instant::now(); + for (i, t) in s.tabs.iter_mut().enumerate() { + if i == s.active { + t.oculta_desde = None; + t.dormida = false; + } else if t.oculta_desde.is_none() { + t.oculta_desde = Some(ahora); + } + } } let Some(win) = main_window(app) else { return }; for wv in win.webviews() { @@ -519,6 +654,7 @@ fn activate_tab(app: &AppHandle, label: &str) { continue; } if wv.label() == label { + despertar_webview(&wv); let _ = wv.show(); let _ = wv.set_focus(); } else { @@ -637,7 +773,7 @@ fn create_tab(app: &AppHandle, url: Option, activate: boo let mut s = shell.lock().unwrap(); let label = format!("tab-{}", s.next_id); s.next_id += 1; - s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None }); + s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false }); label }; @@ -1518,6 +1654,19 @@ pub fn run() { broadcast(&h); }); let h = handle.clone(); + handle.listen_any("ls-reposo", move |e| { + if field_bool(e.payload(), "libre") == Some(true) { + if let Some(l) = field(e.payload(), "label") { + dormir_pestana(&h, &l); + } + } + }); + let h = handle.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(REPOSO_RONDA); + ronda_de_reposo(&h); + }); + let h = handle.clone(); handle.listen_any("ls-new-tab", move |e| { let url = field(e.payload(), "url"); create_tab(&h, url, true); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a3bb363..b4ce06a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Labstream OS", - "version": "1.18.0", + "version": "1.19.0", "identifier": "co.labstream.os", "build": { "frontendDist": "../dist"