Comparar commits

..

3 Commits

Autor SHA1 Mensaje Fecha
Munka
5797c478bc Reposo: el índice activo se lee antes de recorrer las pestañas 2026-10-03 11:53:53 -05:00
Munka
4d066157cf v1.19.0: pestañas en reposo (Windows)
Cada pestaña es la web app entera, con su conexión del chat y sus sondeos; con seis
abiertas corrían seis. Una pestaña que lleva 5 minutos oculta se suspende con
WebView2 TrySuspend y despierta sola al volver a ella. No se duerme la activa, ni
una con subidas, descargas o audio en curso, ni un editor de documentos.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 11:46:29 -05:00
Munka
5a6f6c918f v1.18.0: F5 recarga, como en cualquier navegador
WebView2 tiene apagados sus propios atajos de navegador, así que F5 no hacía
nada dentro de la app: el único que recargaba era Ctrl+R. Ahora F5 (y
Ctrl+F5, Shift+F5) recarga la pestaña. Va antes del filtro de modificadores
de los atajos, porque F5 no lleva ninguno y ese filtro lo descartaba.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:54:57 -05:00
Se han modificado 7 ficheros con 195 adiciones y 7 borrados

Ver fichero

@@ -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 resultado. Es un popup nativo del sistema, igual en Windows y macOS, y vive
solo en el shell (la web app no se toca). 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 - **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 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 panel **/rastreo** del servidor. Cada 5 s mira la ventana al frente y si

4
package-lock.json generado
Ver fichero

@@ -1,12 +1,12 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.14.1", "version": "1.19.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.14.1", "version": "1.19.0",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
} }

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.17.0", "version": "1.19.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": {

28
src-tauri/Cargo.lock generado
Ver fichero

@@ -2366,11 +2366,13 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.17.0" version = "1.19.0"
dependencies = [ dependencies = [
"active-win-pos-rs", "active-win-pos-rs",
"device_query", "device_query",
"gethostname 0.5.0", "gethostname 0.5.0",
"percent-encoding",
"rfd",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -2382,6 +2384,7 @@ dependencies = [
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
"ureq", "ureq",
"webview2-com",
"windows 0.61.3", "windows 0.61.3",
] ]
@@ -3603,6 +3606,29 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"

Ver fichero

@@ -1,6 +1,6 @@
[package] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.17.0" version = "1.19.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"
@@ -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 # 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. # edición es Windows). El `windows` crate ya está en la caché por Tauri.
[target.'cfg(windows)'.dependencies] [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 = [ windows = { version = "0.61", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Com", "Win32_System_Com",

Ver fichero

@@ -41,6 +41,7 @@ use std::{
collections::HashMap, collections::HashMap,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Mutex, sync::Mutex,
time::{Duration, Instant},
}; };
use serde::{Deserialize, Serialize}; 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 // 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. // NOMBRE; el tono exacto y cómo se pinta son cosa del CSS de la barra.
color: Option<String>, color: Option<String>,
// Reposo (ver «Pestañas en reposo»): desde cuándo está oculta (None = es la activa) y si ya
// se suspendió.
oculta_desde: Option<Instant>,
dormida: bool,
} }
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de // 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 }); } 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 // 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 // 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 // revalidacion de permisos al leer del disco. La cache HTTP respeta los ETag
@@ -381,6 +423,10 @@ const INIT_JS_TPL: &str = r#"
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey). // nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
if (e.altKey) return; if (e.altKey) return;
var mod = e.metaKey || e.ctrlKey; var mod = e.metaKey || e.ctrlKey;
// F5 recarga, como en cualquier navegador de Windows (y Ctrl+F5 / Shift+F5 también). WebView2
// tiene apagados sus propios atajos, así que F5 no hacía NADA: el único que recargaba era
// Ctrl+R, y va antes del filtro de abajo porque F5 no lleva modificador.
if (e.key === 'F5') { e.preventDefault(); location.reload(); return; }
if (!mod) return; if (!mod) return;
var k = e.key; var k = e.key;
if (k === 't' || k === 'T') { if (k === 't' || k === 'T') {
@@ -419,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<R: Runtime>(app: &AppHandle<R>) {
let candidatas: Vec<String> = {
let shell = app.state::<ShellState>();
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<R: Runtime>(app: &AppHandle<R>, label: &str) {
{
let shell = app.state::<ShellState>();
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<R: Runtime>(wv: &Webview<R>) {
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::<ICoreWebView2_3>() 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<R: Runtime>(_wv: &Webview<R>) {}
// WebView2 ya despierta sola una vista al hacerla visible; se pide igual, por si acaso.
#[cfg(windows)]
fn despertar_webview<R: Runtime>(wv: &Webview<R>) {
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::<ICoreWebView2_3>() else { return };
let _ = core3.Resume();
});
}
#[cfg(not(windows))]
fn despertar_webview<R: Runtime>(_wv: &Webview<R>) {}
// ── Utilidades del shell ── // ── Utilidades del shell ──
fn main_window<R: Runtime>(app: &AppHandle<R>) -> Option<Window<R>> { fn main_window<R: Runtime>(app: &AppHandle<R>) -> Option<Window<R>> {
@@ -508,6 +637,17 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
} else { } else {
return; return;
} }
// Reposo: la activa deja de contar; las demás empiezan a contar desde que se ocultan.
let ahora = Instant::now();
let activa = s.active;
for (i, t) in s.tabs.iter_mut().enumerate() {
if i == activa {
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 }; let Some(win) = main_window(app) else { return };
for wv in win.webviews() { for wv in win.webviews() {
@@ -515,6 +655,7 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
continue; continue;
} }
if wv.label() == label { if wv.label() == label {
despertar_webview(&wv);
let _ = wv.show(); let _ = wv.show();
let _ = wv.set_focus(); let _ = wv.set_focus();
} else { } else {
@@ -633,7 +774,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let mut s = shell.lock().unwrap(); let mut s = shell.lock().unwrap();
let label = format!("tab-{}", s.next_id); let label = format!("tab-{}", s.next_id);
s.next_id += 1; 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 label
}; };
@@ -1514,6 +1655,19 @@ pub fn run() {
broadcast(&h); broadcast(&h);
}); });
let h = handle.clone(); 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| { handle.listen_any("ls-new-tab", move |e| {
let url = field(e.payload(), "url"); let url = field(e.payload(), "url");
create_tab(&h, url, true); create_tab(&h, url, true);

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.17.0", "version": "1.19.0",
"identifier": "co.labstream.os", "identifier": "co.labstream.os",
"build": { "build": {
"frontendDist": "../dist" "frontendDist": "../dist"