// Punto de entrada de la app de escritorio de Labstream OS. // // Ya no es un envoltorio de UNA webview: es un SHELL DE PESTAÑAS (multiwebview de Tauri 2). // La ventana "main" contiene: // - un webview "chrome" (dist/index.html, local): la barra de pestañas + zoom, con acceso IPC. // - N webviews "tab-*" (remotos): cada pestaña carga el servidor (os.labstreamsas.com). // // Con esto los enlaces internos con target=_blank (documentos, entregables, exportaciones, // OnlyOffice…) — que antes MORÍAN en silencio — abren una pestaña dentro de la app, igual que // en el navegador. Cmd/Ctrl+clic también abre pestaña. Los enlaces a OTRO origen siguen yendo // al navegador del sistema. // // Comportamiento de cliente nativo que se conserva del envoltorio anterior: // - plugin `opener`: enlaces externos al navegador del sistema. // - plugin `notification`: la web (window.__TAURI__) muestra avisos nativos. // - plugin `autostart`: la app arranca al iniciar sesión. // - plugin `updater`: busca versión nueva en los Releases de GitHub y se actualiza sola. // - bandeja (tray) + "cerrar = ocultar": sigue corriendo y notificando en segundo plano. // Y lo nuevo: // - ATRÁS/ADELANTE: botones ← → en la barra, ⌘[ / ⌘] (Alt+←/→ en Windows) y menú // Historial (macOS). Antes una página sin menú propio era un callejón sin salida. // - «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T), como en el navegador. // - Globo de NO-LEÍDOS en el icono (evento `ls-badge` desde la web app): número en el // Dock (macOS), punto rojo en la barra de tareas (Windows) y tooltip de la bandeja. // - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones. // - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana. // - single-instance (solo release): abrir la app de nuevo enfoca la ventana existente. // - macOS: clic en el Dock reabre la ventana oculta (RunEvent::Reopen) y menú nativo con // atajos de pestañas/zoom (los del teclado los consume el menú; en Windows los maneja JS). // // La comunicación shell ↔ Rust va por EVENTOS (`__TAURI__.event`), permitidos por // `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. use std::sync::Mutex; use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::{ menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, webview::{PageLoadEvent, Webview, WebviewBuilder}, window::{Window, WindowBuilder}, AppHandle, Emitter, EventTarget, Listener, LogicalPosition, LogicalSize, Manager, Runtime, Url, WebviewUrl, WindowEvent, }; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_notification::NotificationExt; use tauri_plugin_opener::OpenerExt; const SERVER_URL: &str = "https://os.labstreamsas.com"; const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome) const CHROME: &str = "chrome"; // Escalones de zoom (como un navegador). 1.0 = 100%. const ZOOM_STEPS: &[f64] = &[0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5]; const ZOOM_100: usize = 5; // índice de 1.0 en ZOOM_STEPS // ── Estado del shell ── struct TabRec { label: String, title: String, url: String, } struct Shell { tabs: Vec, active: usize, zoom_idx: usize, next_id: u64, win: Option, autostart: Option, // elección del usuario en el menú ⋮ (None = por defecto, activado) closed: Vec, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T) } impl Default for Shell { fn default() -> Self { Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new() } } } type ShellState = Mutex; #[derive(Clone, Copy, Serialize, Deserialize)] struct WinRect { x: f64, y: f64, w: f64, h: f64, } // Lo que se guarda en disco entre sesiones (zoom, ventana y pestañas abiertas). #[derive(Serialize, Deserialize, Default)] struct Persisted { #[serde(default)] zoom_idx: Option, #[serde(default)] win: Option, #[serde(default)] tabs: Vec, #[serde(default)] active: usize, #[serde(default)] autostart: Option, } fn state_path(app: &AppHandle) -> Option { app.path().app_config_dir().ok().map(|d| d.join("estado.json")) } fn load_persisted(app: &AppHandle) -> Persisted { state_path(app) .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } fn save_persisted(app: &AppHandle) { let Some(path) = state_path(app) else { return }; let data = { let shell = app.state::(); let s = shell.lock().unwrap(); Persisted { zoom_idx: Some(s.zoom_idx), win: s.win, tabs: s.tabs.iter().map(|t| t.url.clone()).collect(), active: s.active, autostart: s.autostart, } }; if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } if let Ok(txt) = serde_json::to_string_pretty(&data) { let _ = std::fs::write(path, txt); } } // ── Script inyectado en CADA página de las pestañas (origen remoto) ── // - Enlaces a OTRO origen → navegador del sistema (opener), como antes. // - target=_blank internos, Cmd/Ctrl+clic y window.open internos → pestaña nueva en la app. // - Reporta título/URL a la barra (observer del + pushState del SPA). // - Atajos de teclado (en macOS los consume antes el menú nativo; esto cubre Windows y extras) // y Ctrl+rueda para zoom. // En páginas de OTRO origen (p. ej. el login de Authentik) __TAURI__ no está inyectado y el // script sale al principio sin tocar nada — la navegación del SSO sigue intacta. const INIT_JS_TPL: &str = r#" (function () { if (window.__lsShell) return; window.__lsShell = true; var T = window.__TAURI__; if (!T || !T.event) return; // página fuera del origen permitido (p. ej. SSO): no intervenir var LABEL = "__LABEL__"; var ORIGIN = "__ORIGIN__"; function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } } function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); } function isApp(u) { return isHttp(u) && u.origin === ORIGIN; } function isExt(u) { return isHttp(u) && u.origin !== ORIGIN; } function emit(name, payload) { try { T.event.emit(name, payload); } catch (e) {} } function openExternal(url) { try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); } catch (e) { try { T.opener.openUrl(url); } catch (_) {} } } function newTab(url) { emit('ls-new-tab', { url: url }); } // Título y URL → barra de pestañas (y para restaurar la sesión al reabrir). function report() { emit('ls-title', { label: LABEL, title: document.title || 'Labstream OS', url: location.href }); } function watchTitle() { var el = document.querySelector('title'); if (el && window.MutationObserver) { new MutationObserver(report).observe(el, { childList: true, characterData: true, subtree: true }); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { watchTitle(); report(); }); } else { watchTitle(); } addEventListener('load', report); report(); ['pushState', 'replaceState'].forEach(function (k) { var o = history[k]; if (!o) return; history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; }; }); addEventListener('popstate', function () { setTimeout(report, 0); }); // Clics en enlaces. document.addEventListener('click', function (e) { if (e.defaultPrevented || e.button !== 0) return; var a = e.target && e.target.closest ? e.target.closest('a[href]') : null; if (!a) return; var u = abs(a.getAttribute('href')); if (!u) return; if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; } var mod = e.metaKey || e.ctrlKey; if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); } }, true); // Clic con la rueda (botón central) en un enlace interno → pestaña nueva, como en el navegador. document.addEventListener('auxclick', function (e) { if (e.button !== 1) return; var a = e.target && e.target.closest ? e.target.closest('a[href]') : null; if (!a) return; var u = abs(a.getAttribute('href')); if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; } if (isApp(u)) { e.preventDefault(); newTab(u.href); } }, true); var _open = window.open; window.open = function (url, name, feats) { var u = url ? abs(url) : null; if (isExt(u)) { openExternal(u.href); return null; } if (isApp(u)) { newTab(u.href); return null; } return _open ? _open.call(window, url, name, feats) : null; }; // Atajos de teclado. addEventListener('keydown', function (e) { var mod = e.metaKey || e.ctrlKey; if (!mod) return; var k = e.key; if (k === 't' || k === 'T') { e.preventDefault(); if (e.shiftKey) emit('ls-reopen', {}); else newTab(null); } else if (k === 'w' || k === 'W') { e.preventDefault(); emit('ls-close-tab', { label: LABEL }); } else if ((k === 'r' || k === 'R') && !e.shiftKey) { e.preventDefault(); location.reload(); } else if (k === '[') { e.preventDefault(); history.back(); } else if (k === ']') { e.preventDefault(); history.forward(); } else if (k === '=' || k === '+') { e.preventDefault(); emit('ls-zoom', { dir: 1 }); } else if (k === '-') { e.preventDefault(); emit('ls-zoom', { dir: -1 }); } else if (k === '0') { e.preventDefault(); emit('ls-zoom', { dir: 0 }); } else if (k === 'Tab') { e.preventDefault(); emit('ls-cycle', { dir: e.shiftKey ? -1 : 1 }); } else if (k >= '1' && k <= '9') { e.preventDefault(); emit('ls-activate-n', { n: +k }); } }, true); // Alt+←/→ = atrás/adelante (convención de Windows/Linux; en Mac es ⌘[ / ⌘] y Alt+flecha // se respeta para saltar palabras al escribir). Fuera de campos de texto solamente. var MAC = /Mac/i.test(navigator.platform || ''); function editable(el) { return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')); } addEventListener('keydown', function (e) { if (MAC || !e.altKey || e.metaKey || e.ctrlKey || e.shiftKey || editable(e.target)) return; if (e.key === 'ArrowLeft') { e.preventDefault(); history.back(); } else if (e.key === 'ArrowRight') { e.preventDefault(); history.forward(); } }, true); // Ctrl+rueda = zoom (convención de navegador; en Mac también con Ctrl). addEventListener('wheel', function (e) { if (!e.ctrlKey) return; e.preventDefault(); emit('ls-zoom', { dir: e.deltaY < 0 ? 1 : -1 }); }, { passive: false, capture: true }); })(); "#; // ── Utilidades del shell ── fn main_window<R: Runtime>(app: &AppHandle<R>) -> Option<Window<R>> { app.get_window("main") } fn get_webview<R: Runtime>(app: &AppHandle<R>, label: &str) -> Option<Webview<R>> { main_window(app).and_then(|w| w.webviews().into_iter().find(|v| v.label() == label)) } // Geometría: barra arriba (alto fijo), contenido debajo ocupando el resto. fn layout_all<R: Runtime>(app: &AppHandle<R>) { let Some(win) = main_window(app) else { return }; let scale = win.scale_factor().unwrap_or(1.0); let Ok(size) = win.inner_size() else { return }; let size = size.to_logical::<f64>(scale); let content_h = (size.height - TAB_BAR_H).max(0.0); for wv in win.webviews() { if wv.label() == CHROME { let _ = wv.set_position(LogicalPosition::new(0.0, 0.0)); let _ = wv.set_size(LogicalSize::new(size.width, TAB_BAR_H)); } else { let _ = wv.set_position(LogicalPosition::new(0.0, TAB_BAR_H)); let _ = wv.set_size(LogicalSize::new(size.width, content_h)); } } } fn current_zoom<R: Runtime>(app: &AppHandle<R>) -> f64 { let shell = app.state::<ShellState>(); let s = shell.lock().unwrap(); ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len() - 1)] } // Manda el estado de pestañas + zoom + versión a la barra (webview "chrome"). fn broadcast<R: Runtime>(app: &AppHandle<R>) { let payload = { let shell = app.state::<ShellState>(); let s = shell.lock().unwrap(); let tabs: Vec<_> = s .tabs .iter() .enumerate() .map(|(i, t)| json!({ "label": t.label, "title": t.title, "active": i == s.active })) .collect(); json!({ "tabs": tabs, "zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32, "version": app.package_info().version.to_string(), }) }; let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload); } // Muestra la pestaña `label` y esconde las demás. Devuelve el foco al contenido. fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) { { let shell = app.state::<ShellState>(); let mut s = shell.lock().unwrap(); if let Some(i) = s.tabs.iter().position(|t| t.label == label) { s.active = i; } else { return; } } let Some(win) = main_window(app) else { return }; for wv in win.webviews() { if wv.label() == CHROME { continue; } if wv.label() == label { let _ = wv.show(); let _ = wv.set_focus(); } else { let _ = wv.hide(); } } broadcast(app); save_persisted(app); } // Crea una pestaña nueva cargando `url` (o el servidor si viene vacío). fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) { let target = url.unwrap_or_else(|| SERVER_URL.to_string()); // Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador. let Ok(parsed) = Url::parse(&target) else { return }; let Ok(server) = Url::parse(SERVER_URL) else { return }; if parsed.origin() != server.origin() { return; } let label = { let shell = app.state::<ShellState>(); 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() }); label }; let Some(win) = main_window(app) else { return }; let scale = win.scale_factor().unwrap_or(1.0); let size = win.inner_size().map(|s| s.to_logical::<f64>(scale)).unwrap_or(LogicalSize::new(1400.0, 900.0)); let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL); let app_zoom = app.clone(); let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed)) .initialization_script(&init) // Deja pasar las descargas (entregables, exportaciones) con el diálogo nativo de guardado. .on_download(|_webview, _event| true) // El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación). .on_page_load(move |wv, payload| { if let PageLoadEvent::Finished = payload.event() { let _ = wv.set_zoom(current_zoom(&app_zoom)); } }); let created = win.add_child( builder, LogicalPosition::new(0.0, TAB_BAR_H), LogicalSize::new(size.width, (size.height - TAB_BAR_H).max(0.0)), ); match created { Ok(_) => { if activate { activate_tab(app, &label); } else { broadcast(app); save_persisted(app); } } Err(_) => { // Revertir el registro si el webview no se pudo crear. let shell = app.state::<ShellState>(); let mut s = shell.lock().unwrap(); s.tabs.retain(|t| t.label != label); } } } // Cierra una pestaña; si era la activa, activa la vecina. Nunca deja cero pestañas. fn close_tab<R: Runtime>(app: &AppHandle<R>, label: &str) { let next_active: Option<String> = { let shell = app.state::<ShellState>(); let mut s = shell.lock().unwrap(); let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return }; let was_active = i == s.active; // Recuerda la URL para «Reabrir pestaña cerrada» (tope de 20, las más recientes). let url = s.tabs[i].url.clone(); s.closed.push(url); if s.closed.len() > 20 { s.closed.remove(0); } s.tabs.remove(i); if s.tabs.is_empty() { s.active = 0; None } else { if s.active >= s.tabs.len() { s.active = s.tabs.len() - 1; } else if !was_active && i < s.active { s.active -= 1; } was_active.then(|| s.tabs[s.active].label.clone()) } }; if let Some(wv) = get_webview(app, label) { let _ = wv.close(); } let is_empty = { let shell = app.state::<ShellState>(); let n = shell.lock().unwrap().tabs.len(); n == 0 }; if is_empty { create_tab(app, None, true); } else if let Some(next) = next_active { activate_tab(app, &next); } else { broadcast(app); save_persisted(app); } } // Aplica un paso de zoom (+1 / −1 / 0 = restablecer) a TODAS las pestañas y lo persiste. fn zoom_step<R: Runtime>(app: &AppHandle<R>, dir: i32) { { let shell = app.state::<ShellState>(); let mut s = shell.lock().unwrap(); s.zoom_idx = match dir { 0 => ZOOM_100, d if d > 0 => (s.zoom_idx + 1).min(ZOOM_STEPS.len() - 1), _ => s.zoom_idx.saturating_sub(1), }; } let z = current_zoom(app); if let Some(win) = main_window(app) { for wv in win.webviews() { if wv.label() != CHROME { let _ = wv.set_zoom(z); } } } broadcast(app); save_persisted(app); } fn active_label<R: Runtime>(app: &AppHandle<R>) -> Option<String> { let shell = app.state::<ShellState>(); let s = shell.lock().unwrap(); s.tabs.get(s.active).map(|t| t.label.clone()) } fn reload_tab<R: Runtime>(app: &AppHandle<R>, label: Option<String>) { let label = label.or_else(|| active_label(app)); if let Some(l) = label { if let Some(wv) = get_webview(app, &l) { let _ = wv.eval("location.reload()"); } } } // Rota a la pestaña siguiente/anterior (Ctrl+Tab / Ctrl+Shift+Tab). fn cycle_tab<R: Runtime>(app: &AppHandle<R>, dir: i32) { let label = { let shell = app.state::<ShellState>(); let s = shell.lock().unwrap(); if s.tabs.is_empty() { return; } let n = s.tabs.len() as i32; let i = ((s.active as i32 + dir) % n + n) % n; s.tabs[i as usize].label.clone() }; activate_tab(app, &label); } // Activa la pestaña n (1-9; 9 = última, como en los navegadores). fn activate_nth<R: Runtime>(app: &AppHandle<R>, n: usize) { let label = { let shell = app.state::<ShellState>(); let s = shell.lock().unwrap(); if s.tabs.is_empty() { return; } let i = if n >= 9 || n > s.tabs.len() { s.tabs.len() - 1 } else { n - 1 }; s.tabs[i].label.clone() }; activate_tab(app, &label); } fn active_url<R: Runtime>(app: &AppHandle<R>) -> Option<String> { let shell = app.state::<ShellState>(); let s = shell.lock().unwrap(); s.tabs.get(s.active).map(|t| t.url.clone()) } // Atrás/adelante en el HISTORIAL de la pestaña activa. Antes no existía por ningún lado y // una página sin menú propio (p. ej. el portal de revisión) era un callejón sin salida. fn nav_tab<R: Runtime>(app: &AppHandle<R>, dir: i32) { if let Some(l) = active_label(app) { if let Some(wv) = get_webview(app, &l) { let _ = wv.eval(if dir < 0 { "history.back()" } else { "history.forward()" }); } } } // «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T): la más reciente primero, como en el navegador. fn reopen_tab<R: Runtime>(app: &AppHandle<R>) { let url = { let shell = app.state::<ShellState>(); let mut s = shell.lock().unwrap(); s.closed.pop() }; if let Some(u) = url { create_tab(app, Some(u), true); } } // Punto rojo para el icono de la barra de tareas de Windows (dibujado a mano: sin assets). #[cfg(target_os = "windows")] fn red_dot() -> tauri::image::Image<'static> { const S: usize = 16; let mut px = vec![0u8; S * S * 4]; let c = (S as f64 - 1.0) / 2.0; for y in 0..S { for x in 0..S { let (dx, dy) = (x as f64 - c, y as f64 - c); if (dx * dx + dy * dy).sqrt() <= c { let i = (y * S + x) * 4; px[i] = 225; px[i + 1] = 29; px[i + 2] = 72; px[i + 3] = 255; } } } tauri::image::Image::new_owned(px, S as u32, S as u32) } // Globo de NO-LEÍDOS en el icono, alimentado por la web app (evento `ls-badge {count}`): // macOS = número en el icono del Dock; Windows = punto rojo sobre el icono de la barra de // tareas; y en ambos, el tooltip de la bandeja dice cuántos hay. fn set_badge<R: Runtime>(app: &AppHandle<R>, count: i64) { if let Some(win) = main_window(app) { #[cfg(target_os = "macos")] let _ = win.set_badge_count(if count > 0 { Some(count) } else { None }); #[cfg(target_os = "windows")] let _ = win.set_overlay_icon(if count > 0 { Some(red_dot()) } else { None }); #[cfg(not(any(target_os = "macos", target_os = "windows")))] let _ = &win; } if let Some(tray) = app.tray_by_id("main-tray") { let tip = if count > 0 { format!("Labstream OS — {count} sin leer") } else { "Labstream OS".to_string() }; let _ = tray.set_tooltip(Some(tip)); } } // Menú de OPCIONES de la app (botón ⋮ / clic derecho en la barra): un popup NATIVO del sistema, // igual en Windows y macOS. Se construye fresco en cada apertura para que el check de // "Iniciar con el sistema" y la versión estén siempre al día. Solo opciones del SHELL — la web // app no se toca. 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); let build = || -> tauri::Result<Menu<R>> { Ok(Menu::with_items( app, &[ &MenuItem::with_id(app, "nav-back", "Atrás", true, Some("CmdOrCtrl+["))?, &MenuItem::with_id(app, "nav-forward", "Adelante", true, Some("CmdOrCtrl+]"))?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "new-tab", "Nueva pestaña", true, Some("CmdOrCtrl+T"))?, &MenuItem::with_id(app, "close-tab", "Cerrar pestaña", true, Some("CmdOrCtrl+W"))?, &MenuItem::with_id(app, "reopen-tab", "Reabrir pestaña cerrada", true, Some("CmdOrCtrl+Shift+T"))?, &MenuItem::with_id(app, "reload", "Recargar", true, Some("CmdOrCtrl+R"))?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "zoom-in", "Acercar", true, Some("CmdOrCtrl+="))?, &MenuItem::with_id(app, "zoom-out", "Alejar", true, Some("CmdOrCtrl+-"))?, &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>)?, &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, "version", &version, false, None::<&str>)?, &MenuItem::with_id(app, "quit-app", "Salir de Labstream OS", true, None::<&str>)?, ], )?) }; if let Ok(menu) = build() { let _ = win.popup_menu(&menu); } } // Activa/desactiva el arranque con el sistema y RECUERDA la elección (si no, el arranque // la re-activaba en cada apertura). fn toggle_autostart<R: Runtime>(app: &AppHandle<R>) { let mgr = app.autolaunch(); let was_on = mgr.is_enabled().unwrap_or(false); let _ = if was_on { mgr.disable() } else { mgr.enable() }; { let shell = app.state::<ShellState>(); shell.lock().unwrap().autostart = Some(!was_on); } save_persisted(app); let _ = app .notification() .builder() .title("Labstream OS") .body(if was_on { "Ya no se iniciará con el sistema." } else { "Se iniciará automáticamente con el sistema." }) .show(); } // "Buscar actualización…" del menú: como la silenciosa, pero SIEMPRE responde con un aviso // (instalando / ya estás al día / sin red). #[cfg(desktop)] async fn check_update_manual(handle: tauri::AppHandle) { use tauri_plugin_updater::UpdaterExt; let notify = |body: String| { let _ = handle.notification().builder().title("Labstream OS").body(body).show(); }; let Ok(updater) = handle.updater() else { notify("El actualizador no está disponible en esta instalación.".into()); return; }; 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()); } } 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()), } } // Muestra y enfoca la ventana principal (tray, dock, segunda instancia). fn show_main<R: Runtime>(app: &AppHandle<R>) { if let Some(w) = main_window(app) { let _ = w.show(); let _ = w.unminimize(); let _ = w.set_focus(); } } // Busca e instala una actualización en segundo plano. Si instala, avisa y reinicia. #[cfg(desktop)] async fn check_update(handle: tauri::AppHandle) { use tauri_plugin_updater::UpdaterExt; let updater = match handle.updater() { Ok(u) => u, Err(_) => return, }; if let Ok(Some(update)) = updater.check().await { if update.download_and_install(|_, _| {}, || {}).await.is_ok() { let _ = handle .notification() .builder() .title("Labstream OS") .body("Se instaló una actualización. La app se reiniciará.") .show(); handle.restart(); } } } // Extrae un campo string del payload JSON de un evento. fn field(payload: &str, key: &str) -> Option<String> { serde_json::from_str::<serde_json::Value>(payload) .ok()? .get(key)? .as_str() .map(|s| s.to_string()) } fn field_i64(payload: &str, key: &str) -> Option<i64> { serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_i64() } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default(); // Una sola instancia (solo en release: en dev conviviría con la app instalada). #[cfg(not(debug_assertions))] { builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { show_main(app); })); } builder = builder .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)); let app = builder .manage(ShellState::default()) .setup(|app| { let handle = app.handle().clone(); let saved = load_persisted(&handle); // Arranque automático al iniciar sesión: por defecto activado, pero se respeta // lo que el usuario elija en el menú ⋮ ("Iniciar con el sistema"). match saved.autostart { Some(false) => { let _ = app.autolaunch().disable(); } _ => { let _ = app.autolaunch().enable(); } } // Al abrir, busca una actualización en segundo plano (no bloquea el arranque). #[cfg(desktop)] { let handle = app.handle().clone(); tauri::async_runtime::spawn(check_update(handle)); } { let shell = handle.state::<ShellState>(); let mut s = shell.lock().unwrap(); s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1); s.win = saved.win; s.autostart = saved.autostart; } // Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay. let mut wb = WindowBuilder::new(app, "main") .title("Labstream OS") .resizable(true) .min_inner_size(1024.0, 700.0); match saved.win { Some(r) if r.w >= 600.0 && r.h >= 400.0 => { wb = wb.inner_size(r.w, r.h).position(r.x, r.y); } _ => { wb = wb.inner_size(1400.0, 900.0).center(); } } let win = wb.build()?; // Menú nativo SOLO en macOS: da Cmd+C/V (Edición), y los atajos de pestañas/zoom // como aceleradores de menú. En Windows no ponemos menú (los atajos van por JS). #[cfg(target_os = "macos")] { let h = app.handle(); let app_menu = Submenu::with_items( h, "Labstream OS", true, &[ &MenuItem::with_id(h, "check-update", "Buscar actualización…", true, None::<&str>)?, &PredefinedMenuItem::separator(h)?, &PredefinedMenuItem::hide(h, Some("Ocultar Labstream OS"))?, &PredefinedMenuItem::hide_others(h, Some("Ocultar otros"))?, &PredefinedMenuItem::show_all(h, Some("Mostrar todo"))?, &PredefinedMenuItem::separator(h)?, &PredefinedMenuItem::quit(h, Some("Salir de Labstream OS"))?, ], )?; let archivo = Submenu::with_items( h, "Archivo", true, &[ &MenuItem::with_id(h, "new-tab", "Nueva pestaña", true, Some("CmdOrCtrl+T"))?, &MenuItem::with_id(h, "close-tab", "Cerrar pestaña", true, Some("CmdOrCtrl+W"))?, &MenuItem::with_id(h, "reopen-tab", "Reabrir pestaña cerrada", true, Some("CmdOrCtrl+Shift+T"))?, &PredefinedMenuItem::separator(h)?, &MenuItem::with_id(h, "open-browser", "Abrir esta página en el navegador", true, None::<&str>)?, ], )?; let edicion = Submenu::with_items( h, "Edición", true, &[ &PredefinedMenuItem::undo(h, Some("Deshacer"))?, &PredefinedMenuItem::redo(h, Some("Rehacer"))?, &PredefinedMenuItem::separator(h)?, &PredefinedMenuItem::cut(h, Some("Cortar"))?, &PredefinedMenuItem::copy(h, Some("Copiar"))?, &PredefinedMenuItem::paste(h, Some("Pegar"))?, &PredefinedMenuItem::select_all(h, Some("Seleccionar todo"))?, ], )?; let vista = Submenu::with_items( h, "Vista", true, &[ &MenuItem::with_id(h, "reload", "Recargar", true, Some("CmdOrCtrl+R"))?, &PredefinedMenuItem::separator(h)?, &MenuItem::with_id(h, "zoom-in", "Acercar", true, Some("CmdOrCtrl+="))?, &MenuItem::with_id(h, "zoom-out", "Alejar", true, Some("CmdOrCtrl+-"))?, &MenuItem::with_id(h, "zoom-0", "Tamaño real", true, Some("CmdOrCtrl+0"))?, &PredefinedMenuItem::separator(h)?, &PredefinedMenuItem::fullscreen(h, Some("Pantalla completa"))?, ], )?; let historial = Submenu::with_items( h, "Historial", true, &[ &MenuItem::with_id(h, "nav-back", "Atrás", true, Some("CmdOrCtrl+["))?, &MenuItem::with_id(h, "nav-forward", "Adelante", true, Some("CmdOrCtrl+]"))?, ], )?; let ventana = Submenu::with_items( h, "Ventana", true, &[ &PredefinedMenuItem::minimize(h, Some("Minimizar"))?, &PredefinedMenuItem::maximize(h, Some("Zoom"))?, ], )?; let menu = Menu::with_items(h, &[&app_menu, &archivo, &edicion, &vista, &historial, &ventana])?; app.set_menu(menu)?; } // Manejador de TODOS los menús (el de macOS y el popup ⋮ de ambas plataformas). app.on_menu_event(|app, event| match event.id().as_ref() { "nav-back" => nav_tab(app, -1), "nav-forward" => nav_tab(app, 1), "new-tab" => create_tab(app, None, true), "close-tab" => { if let Some(l) = active_label(app) { close_tab(app, &l); } } "reopen-tab" => reopen_tab(app), "reload" => reload_tab(app, None), "zoom-in" => zoom_step(app, 1), "zoom-out" => zoom_step(app, -1), "zoom-0" => zoom_step(app, 0), "open-browser" => { if let Some(url) = active_url(app) { let _ = app.opener().open_url(url, None::<&str>); } } "autostart-toggle" => toggle_autostart(app), "check-update" => { #[cfg(desktop)] tauri::async_runtime::spawn(check_update_manual(app.clone())); } "quit-app" => { save_persisted(app); app.exit(0); } _ => {} }); // Barra de pestañas (webview local, arriba). let scale = win.scale_factor().unwrap_or(1.0); let size = win.inner_size()?.to_logical::<f64>(scale); win.add_child( WebviewBuilder::new(CHROME, WebviewUrl::App("index.html".into())), LogicalPosition::new(0.0, 0.0), LogicalSize::new(size.width, TAB_BAR_H), )?; // Pestañas de la sesión anterior (o una nueva si no hay). let restore: Vec<String> = { let server = Url::parse(SERVER_URL).expect("URL del servidor válida"); saved .tabs .iter() .filter(|u| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false)) .take(12) .cloned() .collect() }; if restore.is_empty() { create_tab(&handle, None, true); } else { for url in &restore { create_tab(&handle, Some(url.clone()), false); } let idx = saved.active.min(restore.len() - 1); let label = { let shell = handle.state::<ShellState>(); let s = shell.lock().unwrap(); s.tabs.get(idx).map(|t| t.label.clone()) }; if let Some(l) = label { activate_tab(&handle, &l); } } // ── Eventos del shell (desde la barra y desde las pestañas) ── let h = handle.clone(); handle.listen_any("ls-ready", move |_| { layout_all(&h); broadcast(&h); }); let h = handle.clone(); handle.listen_any("ls-new-tab", move |e| { let url = field(e.payload(), "url"); create_tab(&h, url, true); }); let h = handle.clone(); handle.listen_any("ls-activate", move |e| { if let Some(l) = field(e.payload(), "label") { activate_tab(&h, &l); } }); let h = handle.clone(); handle.listen_any("ls-close-tab", move |e| { let label = field(e.payload(), "label").or_else(|| active_label(&h)); if let Some(l) = label { close_tab(&h, &l); } }); let h = handle.clone(); handle.listen_any("ls-reload", move |e| { reload_tab(&h, field(e.payload(), "label")); }); let h = handle.clone(); handle.listen_any("ls-zoom", move |e| { let dir = field_i64(e.payload(), "dir").unwrap_or(0) as i32; zoom_step(&h, dir); }); let h = handle.clone(); handle.listen_any("ls-cycle", move |e| { let dir = field_i64(e.payload(), "dir").unwrap_or(1) as i32; cycle_tab(&h, dir); }); let h = handle.clone(); handle.listen_any("ls-activate-n", move |e| { if let Some(n) = field_i64(e.payload(), "n") { activate_nth(&h, n.max(1) as usize); } }); let h = handle.clone(); handle.listen_any("ls-nav", move |e| { let dir = field_i64(e.payload(), "dir").unwrap_or(-1) as i32; nav_tab(&h, dir); }); let h = handle.clone(); handle.listen_any("ls-reopen", move |_| { reopen_tab(&h); }); // Globo de no-leídos: lo emite la web app cuando cambia el conteo del chat. let h = handle.clone(); handle.listen_any("ls-badge", move |e| { let n = field_i64(e.payload(), "count").unwrap_or(0); set_badge(&h, n); }); let h = handle.clone(); handle.listen_any("ls-menu", move |_| { show_app_menu(&h); }); // Clic en la etiqueta de versión de la barra → buscar actualización (con aviso). let h = handle.clone(); handle.listen_any("ls-check-update", move |_| { #[cfg(desktop)] tauri::async_runtime::spawn(check_update_manual(h.clone())); }); let h = handle.clone(); handle.listen_any("ls-title", move |e| { let (label, title, url) = ( field(e.payload(), "label"), field(e.payload(), "title"), field(e.payload(), "url"), ); if let Some(label) = label { let shell = h.state::<ShellState>(); let mut s = shell.lock().unwrap(); if let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) { if let Some(title) = title { t.title = title; } if let Some(url) = url { t.url = url; } } drop(s); broadcast(&h); save_persisted(&h); } }); // Icono en la bandeja del sistema con menú Abrir / Salir. let abrir = MenuItem::with_id(app, "abrir", "Abrir Labstream", true, None::<&str>)?; let salir = MenuItem::with_id(app, "salir", "Salir", true, None::<&str>)?; let menu = Menu::with_items(app, &[&abrir, &salir])?; TrayIconBuilder::with_id("main-tray") .icon(app.default_window_icon().unwrap().clone()) .tooltip("Labstream OS") .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "abrir" => show_main(app), "salir" => { save_persisted(app); app.exit(0); } _ => {} }) .on_tray_icon_event(|tray, event| { // Clic izquierdo en el icono → mostrar la ventana. if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { show_main(tray.app_handle()); } }) .build(app)?; Ok(()) }) .on_window_event(|window, event| { match event { // Cerrar la ventana = ocultarla a la bandeja (la app sigue notificando). WindowEvent::CloseRequested { api, .. } => { save_persisted(window.app_handle()); let _ = window.hide(); api.prevent_close(); } // La barra y las pestañas siguen el tamaño de la ventana. WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { let app = window.app_handle(); layout_all(app); if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) { let scale = window.scale_factor().unwrap_or(1.0); let p = pos.to_logical::<f64>(scale); let s = size.to_logical::<f64>(scale); let shell = app.state::<ShellState>(); shell.lock().unwrap().win = Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height }); } } WindowEvent::Moved(_) => { let app = window.app_handle(); if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) { let scale = window.scale_factor().unwrap_or(1.0); let p = pos.to_logical::<f64>(scale); let s = size.to_logical::<f64>(scale); let shell = app.state::<ShellState>(); shell.lock().unwrap().win = Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height }); } } _ => {} } }) .build(tauri::generate_context!()) .expect("error al arrancar Labstream OS"); app.run(|app, event| { // macOS: clic en el icono del Dock con la ventana oculta → reabrirla. #[cfg(target_os = "macos")] if let tauri::RunEvent::Reopen { .. } = event { show_main(app); } // Al salir del todo, guarda el estado (pestañas, zoom, ventana). if let tauri::RunEvent::ExitRequested { .. } = event { save_persisted(app); } let _ = app; }); }