diff --git a/README.md b/README.md index 9ffeb52..9192080 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,41 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac: 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/`). +- **Salud de la app** (v1.20.0, `src-tauri/src/salud.rs`): cuánto gasta de verdad, qué falla + y cómo se repone sola. Nada sale del equipo; todo queda en la carpeta de logs de la app + (⋮ ▸ *Abrir diagnóstico*, o desde la bandeja). + - **Recursos reales**: el exe es pequeño; el gasto está en la docena de procesos de WebView2 + que cuelgan de él (uno por pestaña, GPU, utilidad), que el Administrador de tareas reparte. + Cada minuto se mide el árbol entero —memoria privada, CPU como la columna del Administrador + de tareas, número de procesos— y se ve en la bandeja y en ⋮ («Recursos: 2,1 GB · CPU 3 % · + 13 procesos»). Cada muestra va a `recursos.csv` (fecha, procesos, MB, CPU, pestañas, + dormidas): la curva de un día de trabajo real. + - **Diario de errores** (`salud.log`, rota a 2 MB): pánicos de Rust, procesos de WebView2 + caídos o colgados, errores de JavaScript de las páginas (mensaje y archivo:línea, máx. 20 + por carga y sin repetidos en un minuto), el hilo principal sin responder (ventana + congelada: cuándo y cuánto), fallos del actualizador y **cierres de golpe** (si al arrancar + sigue el testigo `en-marcha`, la sesión anterior no terminó bien). + - **Se repone sola**: pestaña cuyo proceso muere o se cuelga → se recarga (freno: 3 veces en + 10 min; después avisa y la deja quieta). Si muere el proceso principal de WebView2 (caen + todas) → la app se reinicia sola. Windows la reabre si se cuelga o se cierra de golpe + (`RegisterApplicationRestart`). Con el árbol por encima de 3 GB se duermen **ya** las + pestañas ocultas (con las mismas reglas del reposo: nunca una con subida, descarga o audio). + +- **Ahorro de memoria** (v1.20.0, `src-tauri/src/memoria.rs`). Medido en el equipo de edición: + 2,2 GB con 5 pestañas tras un día abierta, **1,3 GB de ellos en el proceso gráfico** de WebView2, + que crece con las horas y no devuelve nada (reiniciarlo a mano bajó la app a 1,1 GB sin perder + nada). Sin quitar funciones: + - **Al abrir solo se carga la pestaña activa**; las demás esperan en la barra con su título y + se cargan al hacer clic (como Chrome/Edge). Antes arrancaban todas a la vez. + - **Pestañas descartadas**: una que lleva 30 min dormida se cierra de verdad y vuelve a su URL + al activarla (1-2 s de carga). Nunca un editor de documentos ni una con subida, descarga o audio. + - **Nivel de memoria bajo** para lo que no se ve (pestañas ocultas; todo con la ventana en la + bandeja o minimizada): WebView2 suelta cachés que puede regenerar. + - **El proceso gráfico se recicla** al pasar de 600 MB, solo si nadie mira (ventana oculta o + minimizada, o 10 min sin tocar el equipo y sin sonido saliendo), como mucho cada 30 min. + - Lo que ya se guardaba en disco sigue igual: la caché HTTP (los archivos de la app vienen con + `immutable`, 1 año), el service worker de la web y el cajón de miniaturas (`cache.rs`). + - **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.json b/package.json index 17526ff..836ee34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "labstream-desktop", - "version": "1.19.0", + "version": "1.20.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 db67a1d..1d6e6dc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2366,7 +2366,7 @@ dependencies = [ [[package]] name = "labstream-desktop" -version = "1.19.0" +version = "1.20.0" dependencies = [ "active-win-pos-rs", "device_query", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fd50709..c81f0d8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labstream-desktop" -version = "1.19.0" +version = "1.20.0" description = "Cliente de escritorio de Labstream OS" authors = ["Labstream SAS"] edition = "2021" @@ -75,4 +75,12 @@ windows = { version = "0.61", features = [ "Win32_System_Variant", "Win32_Media_Audio", "Win32_Media_Audio_Endpoints", + # Salud (1.20): medir el árbol de procesos (exe + WebView2) y el reinicio automático de Windows. + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_ProcessStatus", + "Win32_System_Threading", + "Win32_System_Recovery", + # Memoria (1.20): ¿cuánto lleva el equipo sin tocar? (para reciclar el proceso gráfico sin que se note) + "Win32_UI_Input_KeyboardAndMouse", + "Win32_System_SystemInformation", ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f0d714..1579596 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,8 @@ mod cache; mod tracker; +mod salud; +mod memoria; use std::{ collections::HashMap, @@ -117,6 +119,9 @@ struct TabRec { // se suspendió. oculta_desde: Option, dormida: bool, + // Ahorro de memoria (memoria.rs): su webview se cerró tras DESCARTE_TRAS dormida. Sigue en la + // barra con título, URL y color, y se crea otra vez al volver a ella. + descartada: bool, } // Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de @@ -189,6 +194,10 @@ struct Persisted { // que un estado.json viejo (sin colores) siga cargando tal cual. #[serde(default)] colors: Vec, + // Título de cada pestaña (mismo orden): al abrir, las que no son la activa se quedan sin + // cargar (ver `registrar_diferida`) y la barra necesita algo que enseñar mientras tanto. + #[serde(default)] + titles: Vec, #[serde(default)] active: usize, #[serde(default)] @@ -220,6 +229,7 @@ fn save_persisted(app: &AppHandle) { win: s.win, tabs: s.tabs.iter().map(|t| t.url.clone()).collect(), colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(), + titles: s.tabs.iter().map(|t| t.title.clone()).collect(), active: s.active, autostart: s.autostart, dark: s.dark, @@ -279,6 +289,23 @@ const INIT_JS_TPL: &str = r#" } function newTab(url) { emit('ls-new-tab', { url: url }); } + // ── Errores de la página → diario de salud del shell (salud.log) ── + // Solo el mensaje, el archivo:línea y la ruta (sin datos del formulario ni de la sesión). Como + // mucho 20 por carga de página; el shell además descarta repetidos durante un minuto. + var errores = 0; + function reportarError(tipo, msg, src) { + if (++errores > 20) return; + emit('ls-error', { label: LABEL, tipo: tipo, msg: String(msg || '').slice(0, 300), src: String(src || '').slice(0, 200), url: location.pathname }); + } + addEventListener('error', function (e) { + // Con captura llegan también las imágenes o scripts que no cargan: esos no traen mensaje. + if (e && e.message) reportarError('error', e.message, (e.filename || '') + ':' + (e.lineno || 0)); + }, true); + addEventListener('unhandledrejection', function (e) { + var r = e && e.reason; + reportarError('promesa', (r && (r.stack || r.message)) || r, ''); + }); + // ── 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 @@ -501,6 +528,27 @@ fn ronda_de_reposo(app: &AppHandle) { let _ = wv.eval("window.__lsReposo && window.__lsReposo()"); } } + // Ahorro de memoria: las que llevan DESCARTE_TRAS ocultas y ya están dormidas (pasaron el + // filtro de arriba: sin subida, descarga ni audio, y no son un editor) se descartan. + let a_descartar: Vec = { + let shell = app.state::(); + let s = shell.lock().unwrap(); + s.tabs + .iter() + .enumerate() + .filter(|(i, t)| { + *i != s.active + && t.dormida + && !t.descartada + && !t.url.contains("/docs/") + && t.oculta_desde.map(|d| d.elapsed() >= memoria::DESCARTE_TRAS).unwrap_or(false) + }) + .map(|(_, t)| t.label.clone()) + .collect() + }; + for label in a_descartar { + descartar_pestana(app, &label); + } } fn dormir_pestana(app: &AppHandle, label: &str) { @@ -649,6 +697,8 @@ fn activate_tab(app: &AppHandle, label: &str) { } } } + // Descartada por ahorro de memoria: se crea otra vez en su última URL antes de mostrarla. + revivir_pestana(app, label); let Some(win) = main_window(app) else { return }; for wv in win.webviews() { if wv.label() == CHROME { @@ -656,10 +706,13 @@ fn activate_tab(app: &AppHandle, label: &str) { } if wv.label() == label { despertar_webview(&wv); + memoria::nivel_bajo(&wv, false); let _ = wv.show(); let _ = wv.set_focus(); } else { let _ = wv.hide(); + // Oculta: que WebView2 suelte cachés y memoria que puede regenerar (memoria.rs). + memoria::nivel_bajo(&wv, true); } } broadcast(app); @@ -774,18 +827,40 @@ 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, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false }); + 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, descartada: false }); label }; - let Some(win) = main_window(app) else { return }; + match montar_webview(app, &label, parsed) { + Ok(_) => { + if activate { + activate_tab(app, &label); + } else { + broadcast(app); + save_persisted(app); + } + } + Err(e) => { + salud::anotar("ERROR", "pestaña", &format!("no se pudo crear el webview: {e}")); + // Revertir el registro si el webview no se pudo crear. + let shell = app.state::(); + let mut s = shell.lock().unwrap(); + s.tabs.retain(|t| t.label != label); + } + } +} + +// Crea el webview de una pestaña: una nueva (create_tab) o una descartada que vuelve +// (revivir_pestana, al activarla). Lo vigila salud.rs: si su proceso muere, se recarga sola. +fn montar_webview(app: &AppHandle, label: &str, parsed: Url) -> tauri::Result> { + let win = main_window(app).ok_or(tauri::Error::WindowNotFound)?; let scale = win.scale_factor().unwrap_or(1.0); let size = win.inner_size().map(|s| s.to_logical::(scale)).unwrap_or(LogicalSize::new(1400.0, 900.0)); - 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_navigation = app.clone(); - let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed)) + let builder = WebviewBuilder::new(label, WebviewUrl::External(parsed)) .initialization_script(&init) .on_navigation(move |url| { if url.scheme() == "https" && url.host_str().map(is_google_host).unwrap_or(false) { @@ -822,30 +897,111 @@ fn create_tab(app: &AppHandle, url: Option, activate: boo set_loading(&app_zoom, wv.label(), cargando); }); - let created = win.add_child( + let wv = win.add_child( builder, LogicalPosition::new(0.0, TAB_BAR_H), LogicalSize::new(size.width, (size.height - TAB_BAR_H).max(0.0)), - ); + )?; + // Si su proceso de WebView2 muere o se cuelga, se anota y la pestaña se recarga sola. + salud::vigilar_pestana(app, &wv); + Ok(wv) +} - match created { - Ok(_) => { - if activate { - activate_tab(app, &label); - } else { - broadcast(app); - save_persisted(app); - } +// Ahorro de memoria: una pestaña dormida desde hace DESCARTE_TRAS se cierra de verdad (su proceso +// sale de la memoria) y queda en la barra con su título, URL y color. `activate_tab` la revive. +fn descartar_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 }; + if i == activa || !t.dormida || t.descartada { + return; } - Err(_) => { - // Revertir el registro si el webview no se pudo crear. + t.descartada = true; + t.dormida = false; + } + if let Some(wv) = get_webview(app, label) { + let _ = wv.close(); + } + salud::anotar("INFO", "memoria", &format!("pestaña {label} descartada (lleva {} min dormida)", memoria::DESCARTE_TRAS.as_secs() / 60)); +} + +// Una pestaña de la sesión anterior que no es la activa: entra en la barra SIN webview (como si se +// hubiera descartado) y se crea la primera vez que se activa. +fn registrar_diferida(app: &AppHandle, url: &str, title: &str) { + let shell = app.state::(); + let mut s = shell.lock().unwrap(); + let label = format!("tab-{}", s.next_id); + s.next_id += 1; + let title = if title.is_empty() { "Labstream OS".to_string() } else { title.to_string() }; + s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true }); +} + +// Vuelve a crear el webview de una pestaña descartada, en su última URL. Devuelve false si no se +// pudo (la pestaña se deja como estaba y se anota). +fn revivir_pestana(app: &AppHandle, label: &str) -> bool { + let url = { + let shell = app.state::(); + let s = shell.lock().unwrap(); + match s.tabs.iter().find(|t| t.label == label) { + Some(t) if t.descartada => t.url.clone(), + _ => return true, // no estaba descartada: nada que hacer + } + }; + let Ok(parsed) = Url::parse(&url) else { return false }; + match montar_webview(app, label, parsed) { + Ok(_) => { let shell = app.state::(); let mut s = shell.lock().unwrap(); - s.tabs.retain(|t| t.label != label); + if let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) { + t.descartada = false; + t.loading = true; + } + true + } + Err(e) => { + salud::anotar("ERROR", "memoria", &format!("no se pudo revivir la pestaña {label}: {e}")); + false } } } +#[cfg_attr(not(windows), allow(dead_code))] +// Una pestaña que se cayó varias veces seguidas ya no se recarga sola (salud.rs): se avisa para +// que la persona la recargue (F5) o la cierre, en vez de dejarla en blanco sin explicación. +pub(crate) fn aviso_pestana_rota(app: &AppHandle, label: &str) { + let titulo = { + let shell = app.state::(); + let s = shell.lock().unwrap(); + s.tabs.iter().find(|t| t.label == label).map(|t| t.title.clone()).unwrap_or_else(|| "Una pestaña".into()) + }; + let _ = app + .notification() + .builder() + .title("Labstream OS") + .body(format!("«{titulo}» falló varias veces seguidas. Recárgala con F5 o ciérrala.")) + .show(); +} + +// Memoria alta (salud.rs): las pestañas ocultas se duermen YA, sin esperar sus 5 minutos. Se les +// adelanta el reloj de «oculta desde» y se pasa la ronda normal, que sigue preguntando a cada una +// si está libre (una subida, una descarga o algo sonando no se duermen nunca). +fn dormir_ocultas_ya(app: &AppHandle) { + { + let shell = app.state::(); + let mut s = shell.lock().unwrap(); + let activa = s.active; + let antes = Instant::now().checked_sub(REPOSO_TRAS).unwrap_or_else(Instant::now); + for (i, t) in s.tabs.iter_mut().enumerate() { + if i != activa && t.oculta_desde.is_some() { + t.oculta_desde = Some(antes); + } + } + } + ronda_de_reposo(app); +} + // Cierra una pestaña; si era la activa, activa la vecina. Nunca deja cero pestañas. fn close_tab(app: &AppHandle, label: &str) { let next_active: Option = { @@ -1059,6 +1215,9 @@ fn show_app_menu(app: &AppHandle) { &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)?, + &MenuItem::with_id(app, "salud-resumen", salud::resumen(), false, None::<&str>)?, + &MenuItem::with_id(app, "diagnostico", "Abrir diagnóstico", true, None::<&str>)?, + &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "version", &version, false, None::<&str>)?, &MenuItem::with_id(app, "quit-app", "Salir de Labstream OS", true, None::<&str>)?, ], @@ -1253,19 +1412,34 @@ async fn check_update_manual(handle: tauri::AppHandle) { match updater.check().await { Ok(Some(update)) => { notify(format!("Instalando la versión {}…", update.version)); - if update.download_and_install(|_, _| {}, || {}).await.is_ok() { - handle.restart(); - } else { - notify("No se pudo instalar la actualización. Intenta de nuevo más tarde.".into()); + let version = update.version.clone(); + match update.download_and_install(|_, _| {}, move || salud::antes_de_actualizar(&version)).await { + Ok(()) => handle.restart(), + Err(e) => { + salud::anotar("ERROR", "actualizacion", &format!("no se pudo instalar: {e}")); + notify("No se pudo instalar la actualización. Intenta de nuevo más tarde.".into()); + } } } Ok(None) => notify(format!("Estás en la última versión ({}).", handle.package_info().version)), - Err(_) => notify("No se pudo comprobar la actualización (revisa la red).".into()), + Err(e) => { + salud::anotar("AVISO", "actualizacion", &format!("no se pudo comprobar: {e}")); + notify("No se pudo comprobar la actualización (revisa la red).".into()); + } + } +} + +// ⋮ ▸ Abrir diagnóstico (y la bandeja): la carpeta con salud.log y recursos.csv, para mirarla o +// mandarla cuando algo falle. +fn abrir_diagnostico(app: &AppHandle) { + if let Some(dir) = salud::carpeta() { + let _ = app.opener().open_path(dir.to_string_lossy().to_string(), None::<&str>); } } // Muestra y enfoca la ventana principal (tray, dock, segunda instancia). fn show_main(app: &AppHandle) { + memoria::a_la_vista(app); if let Some(w) = main_window(app) { let _ = w.show(); let _ = w.unminimize(); @@ -1281,8 +1455,21 @@ async fn check_update(handle: tauri::AppHandle) { Ok(u) => u, Err(_) => return, }; - if let Ok(Some(update)) = updater.check().await { - if update.download_and_install(|_, _| {}, || {}).await.is_ok() { + let update = match updater.check().await { + Ok(Some(u)) => u, + Ok(None) => return, + Err(e) => { + salud::anotar("AVISO", "actualizacion", &format!("no se pudo comprobar: {e}")); + return; + } + }; + { + let version = update.version.clone(); + let r = update.download_and_install(|_, _| {}, move || salud::antes_de_actualizar(&version)).await; + if let Err(e) = &r { + salud::anotar("ERROR", "actualizacion", &format!("no se pudo instalar: {e}")); + } + if r.is_ok() { let _ = handle .notification() .builder() @@ -1405,6 +1592,8 @@ pub fn run() { .manage(Descargas::default()) .setup(|app| { let handle = app.handle().clone(); + // Lo primero: el diario de salud (pánicos, cierres de golpe, reinicio automático). + salud::preparar(&handle); let saved = load_persisted(&handle); // Arranque automático al iniciar sesión: por defecto activado, pero se respeta @@ -1571,6 +1760,7 @@ pub fn run() { } id if id.starts_with("tab-color-") => tab_menu_action(app, id), "autostart-toggle" => toggle_autostart(app), + "diagnostico" => abrir_diagnostico(app), "clear-cache" => { let antes = cache::tamano(app); cache::vaciar(app); @@ -1610,7 +1800,11 @@ pub fn run() { // Pestañas de la sesión anterior (o una nueva si no hay), cada una con su color. // El color se empareja con su URL ANTES de filtrar: el filtro por origen puede // descartar entradas y entonces los índices de las dos listas dejarían de casar. - let restore: Vec<(String, String)> = { + // Ahorro de memoria al abrir: solo se CARGA la pestaña que estaba activa; las demás se + // registran descartadas (título, URL y color en la barra) y se crean al hacer clic en + // ellas, como hacen Chrome y Edge. Antes se cargaban todas a la vez: con 5 pestañas, 5 + // copias enteras de la web app arrancando juntas. + let restore: Vec<(String, String, String)> = { let server = Url::parse(SERVER_URL).expect("URL del servidor válida"); saved .tabs @@ -1618,16 +1812,23 @@ pub fn run() { .enumerate() .filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false)) .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(), saved.titles.get(i).cloned().unwrap_or_default()) + }) .collect() }; if restore.is_empty() { create_tab(&handle, None, true); } else { - for (url, color) in &restore { - create_tab(&handle, Some(url.clone()), false); + let idx = saved.active.min(restore.len() - 1); + for (i, (url, color, title)) in restore.iter().enumerate() { + if i == idx { + create_tab(&handle, Some(url.clone()), false); + } else { + registrar_diferida(&handle, url, title); + } if !color.is_empty() { - // create_tab acaba de empujar su pestaña al final de la lista. + // La pestaña se acaba de empujar al final de la lista. let shell = handle.state::(); let mut s = shell.lock().unwrap(); if let Some(t) = s.tabs.last_mut() { @@ -1637,7 +1838,6 @@ pub fn run() { } } } - let idx = saved.active.min(restore.len() - 1); let label = { let shell = handle.state::(); let s = shell.lock().unwrap(); @@ -1803,9 +2003,16 @@ pub fn run() { let abrir = MenuItem::with_id(app, "abrir", "Abrir Labstream", true, None::<&str>)?; let salir = MenuItem::with_id(app, "salir", "Salir", true, None::<&str>)?; let (trk_estado, trk_toggle) = tracker::armar_menu(app.handle())?; + // Lo que gasta la app entera (exe + todos sus procesos de WebView2), cada minuto. + let recursos = MenuItem::with_id(app, "salud-recursos", salud::resumen(), false, None::<&str>)?; + let diagnostico = MenuItem::with_id(app, "diagnostico", "Abrir diagnóstico", true, None::<&str>)?; let sep1 = PredefinedMenuItem::separator(app)?; let sep2 = PredefinedMenuItem::separator(app)?; - let menu = Menu::with_items(app, &[&abrir, &sep1, &trk_estado, &trk_toggle, &sep2, &salir])?; + let sep3 = PredefinedMenuItem::separator(app)?; + let menu = Menu::with_items( + app, + &[&abrir, &sep1, &trk_estado, &trk_toggle, &sep2, &recursos, &diagnostico, &sep3, &salir], + )?; TrayIconBuilder::with_id("main-tray") .icon(app.default_window_icon().unwrap().clone()) @@ -1814,6 +2021,7 @@ pub fn run() { .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "abrir" => show_main(app), + "diagnostico" => abrir_diagnostico(app), "salir" => { save_persisted(app); app.exit(0); @@ -1837,6 +2045,20 @@ pub fn run() { // El sensor del rastreador: listener de vinculación + hilo de medición. tracker::iniciar(app.handle()); + // Salud: medición de recursos cada minuto y vigilante del hilo principal. + salud::iniciar(app.handle(), recursos, dormir_ocultas_ya); + // Errores de JavaScript de las pestañas (los manda el script de cada página). + handle.listen_any("ls-error", |e| { + let p = e.payload(); + salud::error_js( + &field(p, "label").unwrap_or_default(), + &field(p, "tipo").unwrap_or_default(), + &field(p, "msg").unwrap_or_default(), + &field(p, "src").unwrap_or_default(), + &field(p, "url").unwrap_or_default(), + ); + }); + Ok(()) }) .on_window_event(|window, event| { @@ -1847,6 +2069,8 @@ pub fn run() { let _ = window.hide(); api.prevent_close(); } + // Vuelve a primer plano (p. ej. tras minimizar): memoria normal para lo que se ve. + WindowEvent::Focused(true) => memoria::a_la_vista(window.app_handle()), // La barra y las pestañas siguen el tamaño de la ventana. WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { let app = window.app_handle(); @@ -1909,6 +2133,10 @@ pub fn run() { if let tauri::RunEvent::ExitRequested { .. } = event { save_persisted(app); } + // Salida limpia: sin esto, el próximo arranque anotaría un «cierre de golpe». + if let tauri::RunEvent::Exit = event { + salud::salida_limpia(); + } let _ = app; }); } diff --git a/src-tauri/src/memoria.rs b/src-tauri/src/memoria.rs new file mode 100644 index 0000000..99e9e1d --- /dev/null +++ b/src-tauri/src/memoria.rs @@ -0,0 +1,212 @@ +// ── Memoria (v1.20.0): que la app gaste lo que necesita y devuelva lo que no ── +// +// MEDIDO en el equipo de edición (2026-10-14, app abierta desde el día anterior, 5 pestañas): +// 2.221 MB de memoria privada en 13 procesos. De eso, 1.329 MB eran del PROCESO GRÁFICO de +// WebView2 (gpu-process), que crece con las horas (fotos, miniaturas, el reproductor de revisión, +// dos monitores 4K) y no devuelve nada; cada pestaña viva pesaba 110-180 MB más. Se reinició a +// mano solo el proceso gráfico: WebView2 lo levantó de nuevo, nada se perdió, y la app bajó a +// 1.140 MB. De ahí salen las tres medidas de este módulo: +// +// 1. RECICLAR EL PROCESO GRÁFICO: si pasa de GPU_RECICLAR_MB y nadie está mirando (ventana +// oculta o minimizada, o sin tocar ratón ni teclado RECICLAR_INACTIVO), se cierra y WebView2 +// crea otro al momento (es un caso previsto: ProcessFailed GPU_PROCESS_EXITED, que salud.rs +// anota). Como mucho cada RECICLAR_CADA: Chromium pasa a dibujar sin GPU si el proceso gráfico +// cae varias veces en poco tiempo, y eso sería peor que la memoria. +// 2. NIVEL DE MEMORIA BAJO (ICoreWebView2_19::MemoryUsageTargetLevel): a las pestañas ocultas +// —y a todas con la ventana oculta o minimizada— se les pide a WebView2 que suelte cachés y +// memoria que puede regenerar. La pestaña a la vista vuelve a «normal» al instante. +// 3. DESCARTAR PESTAÑAS (lib.rs, `ronda_de_reposo`): una pestaña que lleva DESCARTE_TRAS dormida +// se cierra de verdad —su proceso sale de la memoria— y se queda en la barra con su título, +// URL y color; al volver a ella se crea otra vez en la misma URL (1-2 s de carga). Es lo que +// hace el «Ahorro de memoria» de Chrome. Solo llegan aquí pestañas que ya pasaron el filtro +// del reposo (sin subidas, descargas ni audio, y nunca un editor de documentos). + +// En macOS solo existen los niveles y el descarte; la medición y el reciclaje son de Windows. +#![cfg_attr(not(windows), allow(dead_code, unused_imports))] + +use std::{ + sync::{mpsc, Mutex}, + time::{Duration, Instant}, +}; + +use tauri::{AppHandle, Manager, Runtime, Webview}; + +use crate::salud; + +pub const GPU_RECICLAR_MB: u64 = 600; +const RECICLAR_CADA: Duration = Duration::from_secs(30 * 60); +const RECICLAR_INACTIVO: Duration = Duration::from_secs(10 * 60); +pub const DESCARTE_TRAS: Duration = Duration::from_secs(30 * 60); + +static ULTIMO_RECICLAJE: Mutex> = Mutex::new(None); + +/// Memoria de los procesos de WebView2 por tipo, para el diario: (gpu_mb, renderers_mb). +#[derive(Clone, Copy, Default)] +pub struct Reparto { + pub gpu_mb: u64, + pub pestanas_mb: u64, +} + +// ── Nivel de memoria de una pestaña ── + +#[cfg(windows)] +pub fn nivel_bajo(wv: &Webview, bajo: bool) { + let _ = wv.with_webview(move |pw| unsafe { + use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2_19, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL, + }; + use windows::core::Interface; + let Ok(core) = pw.controller().CoreWebView2() else { return }; + // ICoreWebView2_19 existe desde el runtime 1.0.2210 (2023): si no está, no pasa nada. + let Ok(c19) = core.cast::() else { return }; + let _ = c19.SetMemoryUsageTargetLevel(if bajo { COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW } else { COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL }); + }); +} +#[cfg(not(windows))] +pub fn nivel_bajo(_wv: &Webview, _bajo: bool) {} + +/// Con la ventana oculta o minimizada, todas las vistas a nivel bajo; si no, solo las ocultas. +/// La llama la ronda de salud cada minuto (desde su hilo: las consultas a la ventana esperan al +/// hilo principal y aquí eso es seguro). +pub fn ajustar_niveles(app: &AppHandle) { + let Some(win) = app.get_window("main") else { return }; + let nadie_mira = !win.is_visible().unwrap_or(true) || win.is_minimized().unwrap_or(false); + let activa = { + let shell = app.state::(); + let s = shell.lock().unwrap(); + s.tabs.get(s.active).map(|t| t.label.clone()) + }; + for wv in win.webviews() { + let visible = wv.label() == crate::CHROME || Some(wv.label()) == activa.as_deref(); + nivel_bajo(&wv, nadie_mira || !visible); + } +} + +/// La ventana vuelve a verse (bandeja, minimizar, foco): la barra y la pestaña activa recuperan el +/// nivel normal YA, sin esperar a la ronda del minuto. No consulta la ventana: vale en cualquier hilo. +pub fn a_la_vista(app: &AppHandle) { + let activa = { + let shell = app.state::(); + let s = shell.lock().unwrap(); + s.tabs.get(s.active).map(|t| t.label.clone()) + }; + for label in std::iter::once(crate::CHROME.to_string()).chain(activa) { + if let Some(wv) = app.get_webview(&label) { + nivel_bajo(&wv, false); + } + } +} + +// ── Procesos de WebView2 y reciclaje del proceso gráfico ── + +/// (pid, tipo) de todos los procesos del entorno WebView2 de la app. Tipo: el valor de +/// COREWEBVIEW2_PROCESS_KIND (0 navegador, 1 pestaña, 2 utilidad, 3 sandbox, 4 GPU…). +/// No llamar desde el hilo principal: espera su respuesta. +#[cfg(windows)] +fn procesos(app: &AppHandle) -> Vec<(u32, i32)> { + let Some(wv) = app.get_webview(crate::CHROME) else { return Vec::new() }; + let (tx, rx) = mpsc::channel(); + let pedido = wv.with_webview(move |pw| unsafe { + use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment8, ICoreWebView2_2, COREWEBVIEW2_PROCESS_KIND}; + use windows::core::Interface; + let mut v = Vec::new(); + let lista = pw + .controller() + .CoreWebView2() + .and_then(|c| c.cast::()) + .and_then(|c| c.Environment()) + .and_then(|e| e.cast::()) + .and_then(|e| e.GetProcessInfos()); + if let Ok(lista) = lista { + let mut n = 0u32; + let _ = lista.Count(&mut n); + for i in 0..n { + let Ok(info) = lista.GetValueAtIndex(i) else { continue }; + let (mut pid, mut kind) = (0i32, COREWEBVIEW2_PROCESS_KIND::default()); + if info.ProcessId(&mut pid).is_ok() && info.Kind(&mut kind).is_ok() { + v.push((pid as u32, kind.0)); + } + } + } + let _ = tx.send(v); + }); + if pedido.is_err() { + return Vec::new(); + } + rx.recv_timeout(Duration::from_secs(5)).unwrap_or_default() +} + +/// Segundos sin tocar ratón ni teclado en todo el equipo. +#[cfg(windows)] +fn inactivo() -> Duration { + use windows::Win32::{System::SystemInformation::GetTickCount, UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO}}; + unsafe { + let mut li = LASTINPUTINFO { cbSize: std::mem::size_of::() as u32, dwTime: 0 }; + if !GetLastInputInfo(&mut li).as_bool() { + return Duration::ZERO; + } + Duration::from_millis(GetTickCount().wrapping_sub(li.dwTime) as u64) + } +} + +/// Ronda de memoria (cada minuto, hilo de salud): reparte la memoria por tipo y, si toca, +/// recicla el proceso gráfico. +#[cfg(windows)] +pub fn ronda(app: &AppHandle) -> Reparto { + const GPU: i32 = 4; // COREWEBVIEW2_PROCESS_KIND_GPU + const PESTANA: i32 = 1; // COREWEBVIEW2_PROCESS_KIND_RENDERER + let procs = procesos(app); + let mut r = Reparto::default(); + let mut gpu = None; + for &(pid, kind) in &procs { + let mb = salud::memoria_privada_mb(pid); + match kind { + GPU => { + r.gpu_mb += mb; + gpu = Some((pid, mb)); + } + PESTANA => r.pestanas_mb += mb, + _ => {} + } + } + if let Some((pid, mb)) = gpu { + if mb >= GPU_RECICLAR_MB { + let nadie_mira = app + .get_window("main") + .map(|w| !w.is_visible().unwrap_or(true) || w.is_minimized().unwrap_or(false)) + .unwrap_or(false); + let toca = ULTIMO_RECICLAJE.lock().unwrap().map(|t| t.elapsed() >= RECICLAR_CADA).unwrap_or(true); + // Con la ventana a la vista solo si nadie toca el equipo Y no sale sonido: alguien + // viendo un video de revisión sin tocar nada está «inactivo» pero sí está mirando, y + // cortarle el proceso gráfico le daría un parpadeo en pleno video. + let quieto = inactivo() >= RECICLAR_INACTIVO && !crate::tracker::hay_audio_sonando(); + if toca && (nadie_mira || quieto) { + *ULTIMO_RECICLAJE.lock().unwrap() = Some(Instant::now()); + if terminar(pid) { + salud::anotar("INFO", "memoria", &format!("proceso gráfico reciclado: tenía {mb} MB (WebView2 crea otro limpio)")); + } else { + salud::anotar("AVISO", "memoria", &format!("no se pudo reciclar el proceso gráfico ({mb} MB)")); + } + } + } + } + r +} +#[cfg(not(windows))] +pub fn ronda(_app: &AppHandle) -> Reparto { + Reparto::default() +} + +#[cfg(windows)] +fn terminar(pid: u32) -> bool { + use windows::Win32::{ + Foundation::CloseHandle, + System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}, + }; + unsafe { + let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) else { return false }; + let ok = TerminateProcess(h, 0).is_ok(); + let _ = CloseHandle(h); + ok + } +} diff --git a/src-tauri/src/salud.rs b/src-tauri/src/salud.rs new file mode 100644 index 0000000..c963bb7 --- /dev/null +++ b/src-tauri/src/salud.rs @@ -0,0 +1,514 @@ +// ── Salud de la app (v1.20.0): cuánto gasta de verdad, qué falla y cómo se repone sola ── +// +// POR QUÉ: la app es un exe pequeño (~60 MB) que cuelga de él una docena de procesos de WebView2 +// (uno por pestaña, más el GPU y los de utilidad). En el Administrador de tareas el gasto real +// queda repartido entre «Microsoft Edge WebView2» y no se ve cuánto pesa Labstream OS entero; y +// cuando algo fallaba (una pestaña en blanco, un cuelgue) no quedaba rastro. Este módulo: +// +// 1. MIDE cada minuto el ÁRBOL de procesos de la app (el exe + todos sus WebView2): memoria +// privada, CPU y número de procesos. Lo enseña en la bandeja («Recursos: 2,1 GB · CPU 3 %») +// y lo anota en `recursos.csv`, para tener la curva de un día de trabajo real. +// Columnas del CSV: fecha, procesos, memoria_mb, cpu, pestañas, dormidas, descartadas, +// gpu_mb (proceso gráfico) y pestanas_mb (suma de los procesos de pestaña). +// 2. ANOTA en `salud.log` lo que falla: pánicos de Rust, procesos de WebView2 caídos o colgados, +// errores de JavaScript de las páginas, el hilo principal sin responder y los cierres de golpe +// (si al arrancar sigue el testigo `en-marcha`, la sesión anterior no se cerró bien). +// 3. SE REPONE: una pestaña cuyo proceso murió o se colgó se recarga sola (con freno: más de 3 en +// 10 min y se deja quieta para no entrar en bucle); si muere el proceso principal de WebView2 +// —se caen TODAS las pestañas— la app se reinicia; Windows la vuelve a abrir si se cuelga o +// se cierra de golpe (RegisterApplicationRestart); y con la memoria por encima del umbral se +// duermen ya las pestañas ocultas, sin esperar sus 5 minutos. +// +// Todo queda en la carpeta de logs de la app (%LOCALAPPDATA%\co.labstream.os\logs) y se abre +// desde ⋮ ▸ Diagnóstico. No se envía nada a ningún sitio. + +use std::{ + collections::HashMap, + fs, + io::Write, + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicU32, Ordering}, + Mutex, OnceLock, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use tauri::{menu::MenuItem, AppHandle, Manager, Runtime}; + +const LOG_MAX: u64 = 2 * 1024 * 1024; // salud.log rota a salud.1.log al pasar de 2 MB +const CSV_MAX: u64 = 4 * 1024 * 1024; +const MUESTRA_CADA: Duration = Duration::from_secs(60); +/// Memoria privada del árbol a partir de la cual se duermen ya las pestañas ocultas. +pub const MEMORIA_ALTA_MB: u64 = 3 * 1024; +const RECARGAS_MAX: usize = 3; // por pestaña… +const RECARGAS_VENTANA: Duration = Duration::from_secs(10 * 60); // …en esta ventana + +static DIR: OnceLock = OnceLock::new(); +static ESCRITURA: Mutex<()> = Mutex::new(()); +static ERRORES: AtomicU32 = AtomicU32::new(0); +static RECUPERACIONES: AtomicU32 = AtomicU32::new(0); +static REINICIANDO: AtomicBool = AtomicBool::new(false); +static ULTIMA: Mutex> = Mutex::new(None); +static RECARGAS: OnceLock>>> = OnceLock::new(); +static VISTOS_JS: OnceLock>> = OnceLock::new(); + +#[derive(Clone, Copy, Default)] +pub struct Muestra { + pub procesos: u32, + pub memoria_mb: u64, + pub cpu: f64, +} + +pub struct SaludUi(pub MenuItem); + +// ── Diario ── + +/// Fecha y hora UTC en ISO 8601, sin dependencias (días civiles de Howard Hinnant). +fn ahora_iso() -> String { + let s = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64; + let (dias, resto) = (s.div_euclid(86_400), s.rem_euclid(86_400)); + let z = dias + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", resto / 3600, resto % 3600 / 60, resto % 60) +} + +fn anexar(nombre: &str, max: u64, linea: &str) { + let Some(dir) = DIR.get() else { return }; + let _g = ESCRITURA.lock(); + let ruta = dir.join(nombre); + if fs::metadata(&ruta).map(|m| m.len() > max).unwrap_or(false) { + let viejo = dir.join(nombre.replacen('.', ".1.", 1)); + let _ = fs::remove_file(&viejo); + let _ = fs::rename(&ruta, &viejo); + } + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&ruta) { + let _ = writeln!(f, "{linea}"); + } +} + +/// Anota una línea en salud.log. `nivel`: INFO, AVISO o ERROR. +pub fn anotar(nivel: &str, origen: &str, msg: &str) { + if nivel == "ERROR" { + ERRORES.fetch_add(1, Ordering::Relaxed); + } + let msg = msg.replace(['\r', '\n'], " "); + anexar("salud.log", LOG_MAX, &format!("{} {nivel:<5} {origen}: {msg}", ahora_iso())); +} + +/// Error de JavaScript de una pestaña. El mismo mensaje no se repite más de una vez por minuto. +pub fn error_js(label: &str, tipo: &str, msg: &str, src: &str, ruta: &str) { + let clave = format!("{tipo}|{msg}|{src}"); + { + let mut v = VISTOS_JS.get_or_init(Default::default).lock().unwrap(); + if v.get(&clave).map(|t| t.elapsed() < Duration::from_secs(60)).unwrap_or(false) { + return; + } + if v.len() > 500 { + v.clear(); + } + v.insert(clave, Instant::now()); + } + anotar("ERROR", &format!("js {label}"), &format!("{tipo} en {ruta}: {msg} [{src}]")); +} + +pub fn carpeta() -> Option { + DIR.get().cloned() +} + +/// Texto para la bandeja y el menú ⋮. +pub fn resumen() -> String { + let Some(m) = *ULTIMA.lock().unwrap() else { return "Recursos: midiendo…".into() }; + let mem = if m.memoria_mb >= 1024 { format!("{:.1} GB", m.memoria_mb as f64 / 1024.0).replace('.', ",") } else { format!("{} MB", m.memoria_mb) }; + let mut s = format!("Recursos: {mem} · CPU {:.0} % · {} procesos", m.cpu, m.procesos); + let (e, r) = (ERRORES.load(Ordering::Relaxed), RECUPERACIONES.load(Ordering::Relaxed)); + if e > 0 || r > 0 { + s.push_str(&format!(" · {e} errores, {r} recuperadas")); + } + s +} + +// ── Arranque y salida ── + +/// Prepara la carpeta, el registro de pánicos, el testigo de cierre limpio y el reinicio automático. +/// Va lo PRIMERO en setup: así un fallo del propio arranque ya queda anotado. +pub fn preparar(app: &AppHandle) { + let dir = app + .path() + .app_log_dir() + .or_else(|_| app.path().app_local_data_dir().map(|d| d.join("logs"))) + .unwrap_or_else(|_| std::env::temp_dir().join("labstream-os-logs")); + let _ = fs::create_dir_all(&dir); + let _ = DIR.set(dir.clone()); + + let anterior = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let donde = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default(); + let que = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_default(); + anotar("ERROR", "panico", &format!("{que} ({donde}) hilo={}", std::thread::current().name().unwrap_or("?"))); + anterior(info); + })); + + let testigo = dir.join("en-marcha"); + if testigo.exists() { + anotar("ERROR", "app", "la sesión anterior terminó de golpe (cierre forzado, cuelgue, apagón o fallo)"); + } + let _ = fs::write(&testigo, std::process::id().to_string()); + anotar("INFO", "app", &format!("inicio v{} pid {}", app.package_info().version, std::process::id())); + + #[cfg(windows)] + unsafe { + // Si la app se cuelga o se cae, Windows la vuelve a abrir (solo si llevaba ≥60 s en marcha, + // así que un fallo en el arranque no entra en bucle). Al reiniciar el equipo no: de eso ya + // se encarga el arranque automático. + use windows::Win32::System::Recovery::{RegisterApplicationRestart, RESTART_NO_REBOOT}; + if RegisterApplicationRestart(windows::core::PCWSTR::null(), RESTART_NO_REBOOT).is_err() { + anotar("AVISO", "app", "no se pudo registrar el reinicio automático de Windows"); + } + } +} + +/// Salida limpia: borra el testigo (si no se borra, el próximo arranque anota un cierre de golpe). +pub fn salida_limpia() { + if REINICIANDO.load(Ordering::Relaxed) { + anotar("INFO", "app", "reinicio para recuperarse"); + } else { + anotar("INFO", "app", "salida"); + } + if let Some(d) = DIR.get() { + let _ = fs::remove_file(d.join("en-marcha")); + } +} + +/// Justo antes de que el instalador de una actualización cierre la app (en Windows lo hace él, sin +/// pasar por la salida normal): se anota y se quita el testigo, para no contarlo como cierre de golpe. +pub fn antes_de_actualizar(version: &str) { + anotar("INFO", "actualizacion", &format!("se instala la versión {version}")); + if let Some(d) = DIR.get() { + let _ = fs::remove_file(d.join("en-marcha")); + } +} + +/// Hilo de medición y vigilante del hilo principal. `memoria_alta` se llama (como mucho cada 10 +/// min) cuando el árbol pasa de MEMORIA_ALTA_MB: lib.rs duerme ahí las pestañas ocultas. +pub fn iniciar(app: &AppHandle, item: MenuItem, memoria_alta: impl Fn(&AppHandle) + Send + 'static) { + app.manage(SaludUi(item)); + let h = app.clone(); + std::thread::Builder::new() + .name("salud-recursos".into()) + .spawn(move || { + // El medidor de audio (memoria.rs → tracker::hay_audio_sonando) usa COM en este hilo. + #[cfg(windows)] + unsafe { + use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + let mut medidor = Medidor::default(); + let mut ultimo_aviso: Option = None; + let mut pico = 0u64; + loop { + std::thread::sleep(MUESTRA_CADA); + // Primero la memoria (memoria.rs): niveles de las vistas y, si toca, reciclar el + // proceso gráfico. Luego se mide, para que la muestra ya refleje lo liberado. + crate::memoria::ajustar_niveles(&h); + let reparto = crate::memoria::ronda(&h); + let Some(m) = medidor.medir() else { continue }; + *ULTIMA.lock().unwrap() = Some(m); + let (pestanas, dormidas, descartadas) = conteo_pestanas(&h); + anexar( + "recursos.csv", + CSV_MAX, + &format!( + "{},{},{},{:.1},{},{},{},{},{}", + ahora_iso(), m.procesos, m.memoria_mb, m.cpu, pestanas, dormidas, descartadas, reparto.gpu_mb, reparto.pestanas_mb + ), + ); + if m.memoria_mb > pico + 256 && m.memoria_mb > 1024 { + pico = m.memoria_mb; + anotar("INFO", "recursos", &format!("nuevo pico de memoria: {} MB en {} procesos ({pestanas} pestañas)", m.memoria_mb, m.procesos)); + } + if m.memoria_mb >= MEMORIA_ALTA_MB && ultimo_aviso.map(|t| t.elapsed() > Duration::from_secs(600)).unwrap_or(true) { + ultimo_aviso = Some(Instant::now()); + anotar("AVISO", "recursos", &format!("memoria alta ({} MB): se duermen ya las pestañas ocultas", m.memoria_mb)); + memoria_alta(&h); + } + // set_text despacha al hilo principal y espera: aquí, en un hilo propio, es seguro. + let _ = h.state::>().0.set_text(resumen()); + } + }) + .ok(); + + // ¿Responde el hilo principal? Un cuelgue de la interfaz (la ventana «No responde») no deja + // ningún otro rastro. Se le manda una tarea vacía cada 20 s; si tarda más de 15 s, se anota + // cuándo empezó y cuánto duró. + let h = app.clone(); + std::thread::Builder::new() + .name("salud-vigilante".into()) + .spawn(move || loop { + std::thread::sleep(Duration::from_secs(20)); + let (tx, rx) = std::sync::mpsc::channel(); + let t0 = Instant::now(); + if h.run_on_main_thread(move || { + let _ = tx.send(()); + }) + .is_err() + { + return; // la app está cerrándose + } + if rx.recv_timeout(Duration::from_secs(15)).is_err() { + anotar("ERROR", "app", "el hilo principal no responde (la ventana se congeló)"); + let _ = rx.recv(); + anotar("AVISO", "app", &format!("el hilo principal volvió a responder tras {} s", t0.elapsed().as_secs())); + } + }) + .ok(); +} + +fn conteo_pestanas(app: &AppHandle) -> (usize, usize, usize) { + let shell = app.state::(); + let s = shell.lock().unwrap(); + (s.tabs.len(), s.tabs.iter().filter(|t| t.dormida).count(), s.tabs.iter().filter(|t| t.descartada).count()) +} + +/// Memoria privada de un proceso, en MB (0 si no se puede abrir). +#[cfg(windows)] +pub fn memoria_privada_mb(pid: u32) -> u64 { + use windows::Win32::{ + Foundation::CloseHandle, + System::{ + ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX}, + Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}, + }, + }; + unsafe { + let Ok(hp) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { return 0 }; + let mut mc = PROCESS_MEMORY_COUNTERS_EX { cb: std::mem::size_of::() as u32, ..Default::default() }; + let ok = GetProcessMemoryInfo(hp, &mut mc as *mut _ as *mut PROCESS_MEMORY_COUNTERS, mc.cb).is_ok(); + let _ = CloseHandle(hp); + if ok { + mc.PrivateUsage as u64 / (1024 * 1024) + } else { + 0 + } + } +} + +// ── Recuperación de pestañas (WebView2) ── + +#[cfg_attr(not(windows), allow(dead_code))] // en macOS no hay ProcessFailed: solo lo usa Windows +/// ¿Se puede recargar otra vez esta pestaña? Freno contra bucles: RECARGAS_MAX en RECARGAS_VENTANA. +fn permitir_recarga(label: &str) -> bool { + let mut m = RECARGAS.get_or_init(Default::default).lock().unwrap(); + let v = m.entry(label.to_string()).or_default(); + v.retain(|t| t.elapsed() < RECARGAS_VENTANA); + if v.len() >= RECARGAS_MAX { + return false; + } + v.push(Instant::now()); + true +} + +/// Se engancha al aviso `ProcessFailed` de WebView2 de una pestaña recién creada. +#[cfg(windows)] +pub fn vigilar_pestana(app: &AppHandle, wv: &tauri::Webview) { + let label = wv.label().to_string(); + let h = app.clone(); + let _ = wv.with_webview(move |pw| unsafe { + use webview2_com::{Microsoft::Web::WebView2::Win32::*, ProcessFailedEventHandler}; + let Ok(core) = pw.controller().CoreWebView2() else { return }; + let mut token = 0i64; + let handler = ProcessFailedEventHandler::create(Box::new(move |sender, args| { + let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default(); + if let Some(a) = args { + let _ = a.ProcessFailedKind(&mut kind); + } + let recargar = |por: &str| { + if !permitir_recarga(&label) { + anotar("ERROR", &format!("pestaña {label}"), &format!("{por} otra vez: ya se recargó {RECARGAS_MAX} veces en 10 min, se deja quieta")); + crate::aviso_pestana_rota(&h, &label); + return; + } + if let Some(c) = &sender { + if c.Reload().is_ok() { + RECUPERACIONES.fetch_add(1, Ordering::Relaxed); + anotar("AVISO", &format!("pestaña {label}"), &format!("{por}: recargada sola")); + } + } + }; + match kind { + COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_EXITED => { + anotar("ERROR", &format!("pestaña {label}"), "su proceso se cerró (pestaña en blanco)"); + recargar("proceso cerrado"); + } + COREWEBVIEW2_PROCESS_FAILED_KIND_RENDER_PROCESS_UNRESPONSIVE => { + anotar("ERROR", &format!("pestaña {label}"), "su proceso dejó de responder"); + recargar("proceso colgado"); + } + COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED => { + // Lo avisan TODAS las pestañas a la vez: solo la primera reinicia. + if !REINICIANDO.swap(true, Ordering::SeqCst) { + anotar("ERROR", "webview2", "se cerró el proceso principal de WebView2: se reinicia la app"); + RECUPERACIONES.fetch_add(1, Ordering::Relaxed); + crate::save_persisted(&h); + let hh = h.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(500)); + salida_limpia(); + hh.restart(); + }); + } + } + COREWEBVIEW2_PROCESS_FAILED_KIND_FRAME_RENDER_PROCESS_EXITED => { + anotar("AVISO", &format!("pestaña {label}"), "se cerró el proceso de un marco interno (p. ej. un editor incrustado)"); + } + COREWEBVIEW2_PROCESS_FAILED_KIND_GPU_PROCESS_EXITED => { + anotar("AVISO", "webview2", "se reinició el proceso de gráficos (WebView2 lo levanta solo)"); + } + otro => anotar("AVISO", "webview2", &format!("se cerró un proceso auxiliar (tipo {})", otro.0)), + } + Ok(()) + })); + if core.add_ProcessFailed(&handler, &mut token).is_err() { + anotar("AVISO", "webview2", "no se pudo vigilar una pestaña"); + } + }); +} +#[cfg(not(windows))] +pub fn vigilar_pestana(_app: &AppHandle, _wv: &tauri::Webview) {} + +// ── Medición del árbol de procesos ── + +#[derive(Default)] +struct Medidor { + cpu_previa: HashMap, // pid → tiempo de CPU acumulado (100 ns) + cuando: Option, +} + +#[cfg(windows)] +impl Medidor { + fn medir(&mut self) -> Option { + use windows::Win32::{ + Foundation::{CloseHandle, FILETIME}, + System::{ + Diagnostics::ToolHelp::{CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS}, + ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX}, + Threading::{GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}, + }, + }; + unsafe { + // 1. Todos los procesos con su padre; 2. el árbol que cuelga de este exe. + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?; + let mut padres: Vec<(u32, u32)> = Vec::with_capacity(512); + let mut e = PROCESSENTRY32W { dwSize: std::mem::size_of::() as u32, ..Default::default() }; + if Process32FirstW(snap, &mut e).is_ok() { + loop { + padres.push((e.th32ProcessID, e.th32ParentProcessID)); + if Process32NextW(snap, &mut e).is_err() { + break; + } + } + } + let _ = CloseHandle(snap); + let mut arbol = vec![std::process::id()]; + let mut i = 0; + while i < arbol.len() { + let p = arbol[i]; + for &(hijo, padre) in &padres { + if padre == p && hijo != p && !arbol.contains(&hijo) { + arbol.push(hijo); + } + } + i += 1; + } + + let mut memoria = 0u64; + let mut cpu_ahora: HashMap = HashMap::new(); + for &pid in &arbol { + let Ok(hp) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else { continue }; + let mut mc = PROCESS_MEMORY_COUNTERS_EX { cb: std::mem::size_of::() as u32, ..Default::default() }; + if GetProcessMemoryInfo(hp, &mut mc as *mut _ as *mut PROCESS_MEMORY_COUNTERS, mc.cb).is_ok() { + memoria += mc.PrivateUsage as u64; + } + let (mut c, mut x, mut k, mut u) = (FILETIME::default(), FILETIME::default(), FILETIME::default(), FILETIME::default()); + if GetProcessTimes(hp, &mut c, &mut x, &mut k, &mut u).is_ok() { + let t = |f: FILETIME| ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64; + cpu_ahora.insert(pid, t(k) + t(u)); + } + let _ = CloseHandle(hp); + } + + // CPU: lo gastado desde la muestra anterior, sobre el tiempo de reloj × núcleos (como + // la columna CPU del Administrador de tareas). Un proceso que no estaba en la muestra + // anterior empieza a contar en la siguiente (si no, sumaría toda su vida de golpe). + let ahora = Instant::now(); + let cpu = match self.cuando { + Some(antes) => { + let pared = ahora.duration_since(antes).as_secs_f64() * 1e7; // en 100 ns + let nucleos = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) as f64; + let gastado: u64 = cpu_ahora.iter().filter_map(|(p, t)| self.cpu_previa.get(p).map(|antes| t.saturating_sub(*antes))).sum(); + (gastado as f64 / (pared * nucleos) * 100.0).clamp(0.0, 100.0) + } + None => 0.0, + }; + self.cpu_previa = cpu_ahora; + self.cuando = Some(ahora); + Some(Muestra { procesos: arbol.len() as u32, memoria_mb: memoria / (1024 * 1024), cpu }) + } + } +} + +#[cfg(not(windows))] +impl Medidor { + fn medir(&mut self) -> Option { + None // macOS: pendiente (proc_pidinfo); el equipo de edición es Windows + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fecha_iso_con_formato_fijo() { + let f = ahora_iso(); + assert_eq!(f.len(), 20, "{f}"); + assert!(f.starts_with("20") && f.ends_with('Z') && &f[10..11] == "T", "{f}"); + } + + #[test] + fn el_freno_corta_la_cuarta_recarga() { + let l = "tab-prueba-freno"; + assert!(permitir_recarga(l)); + assert!(permitir_recarga(l)); + assert!(permitir_recarga(l)); + assert!(!permitir_recarga(l), "la cuarta en 10 minutos no se permite"); + assert!(permitir_recarga("otra-pestana"), "el freno es por pestaña"); + } + + #[cfg(windows)] + #[test] + fn mide_al_menos_este_proceso() { + // un binario de prueba casi no reserva memoria: se le da 32 MB tocados para que se note + let lastre = vec![1u8; 32 * 1024 * 1024]; + let mut m = Medidor::default(); + let a = m.medir().expect("primera muestra"); + assert!(a.procesos >= 1 && a.memoria_mb > 0, "procesos={} memoria={}", a.procesos, a.memoria_mb); + assert_eq!(a.cpu, 0.0, "sin muestra previa no hay CPU"); + std::thread::sleep(Duration::from_millis(200)); + let b = m.medir().expect("segunda muestra"); + assert!((0.0..=100.0).contains(&b.cpu)); + assert!(lastre.iter().all(|&x| x == 1)); + } +} diff --git a/src-tauri/src/tracker.rs b/src-tauri/src/tracker.rs index 2ae8557..b5e494e 100644 --- a/src-tauri/src/tracker.rs +++ b/src-tauri/src/tracker.rs @@ -279,7 +279,7 @@ fn es_navegador(app: &str) -> bool { // si hay sonido saliendo AHORA. Un video mudo no se detecta (no suena); es el límite honesto de // medirlo desde fuera del navegador. #[cfg(windows)] -fn hay_audio_sonando() -> bool { +pub(crate) fn hay_audio_sonando() -> bool { use windows::Win32::Media::Audio::Endpoints::IAudioMeterInformation; use windows::Win32::Media::Audio::{eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator}; use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL}; @@ -300,7 +300,7 @@ fn hay_audio_sonando() -> bool { } #[cfg(not(windows))] -fn hay_audio_sonando() -> bool { +pub(crate) fn hay_audio_sonando() -> bool { // macOS: no hay API pública sencilla de nivel de audio; la excepción del video no aplica aquí // (el equipo de edición es Windows). Follow-up posible con CoreAudio. false = como siempre. false diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b4ce06a..6d21109 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.19.0", + "version": "1.20.0", "identifier": "co.labstream.os", "build": { "frontendDist": "../dist"