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>
1914 líneas
84 KiB
Rust
1914 líneas
84 KiB
Rust
// 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.
|
||
// - DESCARGAS con «Guardar como»: el sistema pregunta dónde guardar cada archivo (con el
|
||
// nombre que sugiere el servidor) en vez de dejarlo caer en silencio en Descargas.
|
||
// - 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.
|
||
|
||
mod cache;
|
||
mod tracker;
|
||
|
||
use std::{
|
||
collections::HashMap,
|
||
path::{Path, PathBuf},
|
||
sync::Mutex,
|
||
time::{Duration, Instant},
|
||
};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::json;
|
||
use tauri::{
|
||
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||
webview::{DownloadEvent, 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_clipboard_manager::ClipboardExt;
|
||
use tauri_plugin_opener::OpenerExt;
|
||
|
||
const SERVER_URL: &str = "https://os.labstreamsas.com";
|
||
|
||
// Google requiere el navegador del sistema para autenticar y editar con compatibilidad completa.
|
||
const GOOGLE_HOSTS: &[&str] = &[
|
||
"docs.google.com",
|
||
"drive.google.com",
|
||
"sheets.google.com",
|
||
"accounts.google.com",
|
||
"myaccount.google.com",
|
||
];
|
||
|
||
fn is_google_host(host: &str) -> bool {
|
||
let h = host.to_ascii_lowercase();
|
||
GOOGLE_HOSTS.iter().any(|d| h == *d || h.ends_with(&format!(".{}", d)))
|
||
}
|
||
|
||
fn es_pestana_interna(target: &Url, server: &Url) -> bool {
|
||
target.origin() == server.origin()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod navigation_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn only_labstream_is_an_embedded_tab() {
|
||
let server = Url::parse(SERVER_URL).unwrap();
|
||
assert!(es_pestana_interna(&server.join("/revisiones/1").unwrap(), &server));
|
||
for url in ["https://docs.google.com/document/d/1/edit", "https://accounts.google.com", "https://os.labstreamsas.com.evil.example", "http://os.labstreamsas.com"] {
|
||
assert!(!es_pestana_interna(&Url::parse(url).unwrap(), &server));
|
||
}
|
||
assert!(is_google_host("docs.google.com"));
|
||
assert!(!is_google_host("docs.google.com.evil.example"));
|
||
}
|
||
}
|
||
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
|
||
const CHROME: &str = "chrome";
|
||
// Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese
|
||
// espacio a la izquierda (lo aplica el CSS de la barra con la bandera `mac` del payload).
|
||
|
||
// 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,
|
||
loading: bool, // la barra pinta un giro en el icono mientras carga
|
||
// Color elegido a mano desde el menú de la pestaña (None = sin teñir). Aquí solo vive el
|
||
// NOMBRE; el tono exacto y cómo se pinta son cosa del CSS de la barra.
|
||
color: Option<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
|
||
// un vistazo («todo lo de este cliente en verde»). El id viaja en el payload de `ls-tabs`.
|
||
const TAB_COLORS: &[(&str, &str)] = &[
|
||
("gris", "Gris"),
|
||
("azul", "Azul"),
|
||
("rojo", "Rojo"),
|
||
("amarillo", "Amarillo"),
|
||
("verde", "Verde"),
|
||
("rosa", "Rosa"),
|
||
("morado", "Morado"),
|
||
("cian", "Cian"),
|
||
("naranja", "Naranja"),
|
||
];
|
||
|
||
struct Shell {
|
||
tabs: Vec<TabRec>,
|
||
active: usize,
|
||
zoom_idx: usize,
|
||
next_id: u64,
|
||
win: Option<WinRect>,
|
||
autostart: Option<bool>, // elección del usuario en el menú ⋮ (None = por defecto, activado)
|
||
closed: Vec<String>, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T)
|
||
menu_tab: Option<String>, // pestaña sobre la que se abrió el menú de clic derecho
|
||
// Último estado maximizado conocido. Solo sirve para no repintar la barra en CADA
|
||
// evento de redimensión: únicamente cuando el icono ▢/❐ tiene que cambiar.
|
||
maximized: bool,
|
||
// Tema REAL de la app (la clase `dark` de <html>, reportada por el script inyectado).
|
||
// Es global y no por pestaña: todas las pestañas del origen comparten el mismo modo
|
||
// (localStorage). None = ninguna pestaña ha reportado aún → la barra cae al del sistema.
|
||
dark: Option<bool>,
|
||
}
|
||
|
||
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(), menu_tab: None, maximized: false, dark: None }
|
||
}
|
||
}
|
||
|
||
type ShellState = Mutex<Shell>;
|
||
|
||
// Rutas elegidas en «Guardar como» de las descargas EN CURSO, por URL de origen. Hace falta para
|
||
// nombrar el archivo al terminar: en macOS `DownloadEvent::Finished` llega SIEMPRE con
|
||
// `path: None` (wry-0.55.1/src/wkwebview/download.rs:98-99 pasa `None` a mano), así que la única
|
||
// forma de decir «Descargado: informe.xlsx» es recordar aquí lo que el usuario eligió. La URL es
|
||
// la misma en Requested y en Finished en ambos motores (DownloadOperation.Uri en WebView2;
|
||
// originalRequest.URL en WKDownload).
|
||
#[derive(Default)]
|
||
struct Descargas(Mutex<HashMap<String, PathBuf>>);
|
||
|
||
#[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<usize>,
|
||
#[serde(default)]
|
||
win: Option<WinRect>,
|
||
#[serde(default)]
|
||
tabs: Vec<String>,
|
||
// Color de cada pestaña, en el mismo orden que `tabs` ("" = sin teñir). Lista aparte para
|
||
// que un estado.json viejo (sin colores) siga cargando tal cual.
|
||
#[serde(default)]
|
||
colors: Vec<String>,
|
||
#[serde(default)]
|
||
active: usize,
|
||
#[serde(default)]
|
||
autostart: Option<bool>,
|
||
// Último tema conocido de la app: la barra abre ya del color correcto en vez de
|
||
// arrancar con el del sistema y dar un fogonazo al cargar la primera pestaña.
|
||
#[serde(default)]
|
||
dark: Option<bool>,
|
||
}
|
||
|
||
fn state_path<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
|
||
app.path().app_config_dir().ok().map(|d| d.join("estado.json"))
|
||
}
|
||
|
||
fn load_persisted<R: Runtime>(app: &AppHandle<R>) -> 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<R: Runtime>(app: &AppHandle<R>) {
|
||
let Some(path) = state_path(app) else { return };
|
||
let data = {
|
||
let shell = app.state::<ShellState>();
|
||
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(),
|
||
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
|
||
active: s.active,
|
||
autostart: s.autostart,
|
||
dark: s.dark,
|
||
}
|
||
};
|
||
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 <title> + pushState del SPA).
|
||
// - Atajos de teclado (en macOS los consume antes el menú nativo; esto cubre Windows y extras)
|
||
// y Ctrl+rueda para zoom.
|
||
// SOLO debe gobernar el documento principal de NUESTRO origen; ver la guarda de abajo.
|
||
const INIT_JS_TPL: &str = r#"
|
||
(function () {
|
||
var ORIGIN = "__ORIGIN__";
|
||
|
||
// ── GUARDA DE MARCO Y ORIGEN ──
|
||
// Antes aquí solo estaba `if (!T || !T.event) return;`, confiando en que __TAURI__ no
|
||
// existiría fuera del origen permitido. Eso es cierto en macOS y FALSO en Windows:
|
||
// · wry inyecta los scripts de inicio en TODOS los marcos e IGNORA for_main_frame_only
|
||
// (wry-0.55.1/src/webview2/mod.rs:492-495, documentado en su lib.rs:990), mientras que
|
||
// WKWebView sí la respeta (wry-0.55.1/src/wkwebview/mod.rs:643-645);
|
||
// · y __TAURI__ viaja con ellos (withGlobalTauri, tauri/src/manager/webview.rs:159-165).
|
||
// Consecuencia real: dentro del iframe del editor de OnlyOffice (docs.labstreamsas.com)
|
||
// este script SÍ corría en Windows, con ORIGIN apuntando todavía a la app. Como allí
|
||
// `location.href` es el del Document Server, abs() resolvía CADA enlace del editor contra
|
||
// docs.* → isExt() cierto → preventDefault() + mandarlo al navegador del sistema (que no
|
||
// tiene la sesión: «no se puede acceder»), y window.open devolvía null. Por eso editar y
|
||
// descargar fallaban solo en la app de Windows, y no en Mac ni en el navegador.
|
||
// En macOS estas dos líneas son un no-op: allí el script nunca ha corrido en subframes.
|
||
try { if (window.top !== window.self) return; } catch (e) { return; }
|
||
if (location.origin !== ORIGIN) return;
|
||
|
||
if (window.__lsShell) return;
|
||
window.__lsShell = true;
|
||
var T = window.__TAURI__;
|
||
if (!T || !T.event) return;
|
||
var LABEL = "__LABEL__";
|
||
|
||
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 isInterno(u) { return isApp(u); }
|
||
function isExt(u) { return isHttp(u) && !isInterno(u); }
|
||
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 }); }
|
||
|
||
// ── Reposo ──
|
||
// El shell suspende las pestañas que llevan un rato ocultas (dejan de gastar CPU y sueltan
|
||
// memoria). Antes PREGUNTA: una pestaña con una subida o una descarga en curso, o con algo
|
||
// sonando, no se duerme. Se cuentan las peticiones vivas (fetch y XHR; el stream del chat es
|
||
// EventSource y no cuenta) y, ante cualquier duda, la cuenta se queda arriba: no dormir es
|
||
// siempre el lado seguro.
|
||
var enCurso = 0;
|
||
try {
|
||
var _send = XMLHttpRequest.prototype.send;
|
||
XMLHttpRequest.prototype.send = function () {
|
||
var hecho = false;
|
||
enCurso++;
|
||
this.addEventListener('loadend', function () { if (!hecho) { hecho = true; enCurso--; } });
|
||
try { return _send.apply(this, arguments); }
|
||
catch (e) { if (!hecho) { hecho = true; enCurso--; } throw e; }
|
||
};
|
||
var _fetch = window.fetch;
|
||
if (_fetch) {
|
||
window.fetch = function () {
|
||
enCurso++;
|
||
var baja = function () { enCurso--; };
|
||
var p;
|
||
try { p = _fetch.apply(this, arguments); } catch (e) { baja(); throw e; }
|
||
p.then(baja, baja);
|
||
return p;
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
window.__lsReposo = function () {
|
||
var suena = false;
|
||
try {
|
||
var medios = document.querySelectorAll('video,audio');
|
||
for (var i = 0; i < medios.length; i++) { if (!medios[i].paused && !medios[i].ended) suena = true; }
|
||
} catch (e) { suena = true; }
|
||
emit('ls-reposo', { label: LABEL, libre: enCurso <= 0 && !suena });
|
||
};
|
||
|
||
// Las imagenes conservan su URL HTTPS y la sesion del webview. El descargador
|
||
// lsthumb no comparte cookies: falla con miniaturas sin token y omite la
|
||
// revalidacion de permisos al leer del disco. La cache HTTP respeta los ETag
|
||
// y Cache-Control del servidor sin modificar src, srcset ni la hidratacion.
|
||
|
||
// ── ¿La página se VE oscura? ──
|
||
// Señales, de la más directa a la más física: la clase `dark` (next-themes) y, si no
|
||
// está, el color de fondo REALMENTE pintado (luminancia del body) — vale sea cual sea
|
||
// el mecanismo de tema. Devuelve null con el documento a medio crear (los scripts de
|
||
// inicio corren antes de que exista <html>): entonces el campo NO viaja y Rust conserva
|
||
// el último valor bueno. La v1.7.0 leía la clase a secas y sin red: en WebView2 eso
|
||
// podía reventar en frío y dejar `dark` mintiendo en estado.json (barra clara sobre
|
||
// app oscura — el bug al revés).
|
||
function temaOscuro() {
|
||
try {
|
||
var doc = document.documentElement, cuerpo = document.body;
|
||
if (!doc) return null;
|
||
if (doc.classList.contains('dark') || (cuerpo && cuerpo.classList.contains('dark'))) return true;
|
||
if (!cuerpo) return null;
|
||
var fondo = getComputedStyle(cuerpo).backgroundColor || '';
|
||
var m = fondo.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s\/]+([\d.]+))?/);
|
||
if (m && (m[4] === undefined || parseFloat(m[4]) > 0.5)) {
|
||
return 0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3] < 128;
|
||
}
|
||
var esquema = getComputedStyle(doc).colorScheme || '';
|
||
if (esquema.indexOf('dark') >= 0 && esquema.indexOf('light') < 0) return true;
|
||
return false;
|
||
} catch (e) { return null; }
|
||
}
|
||
|
||
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
|
||
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
|
||
// sistema: la barra tiene que copiarle a ELLA.
|
||
function report() {
|
||
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
|
||
var oscuro = temaOscuro();
|
||
if (oscuro !== null) carga.dark = oscuro;
|
||
emit('ls-title', carga);
|
||
}
|
||
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); });
|
||
// Pulsar la luna cambia clases/estilos sin navegar: se vigilan <html> y <body> (cuando
|
||
// existan — a document-start aún no hay), y tras la carga se re-reporta un par de veces
|
||
// por si el tema aterriza tarde con la hidratación.
|
||
function vigilarTema() {
|
||
if (!window.MutationObserver) return;
|
||
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] };
|
||
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
|
||
if (document.body) new MutationObserver(report).observe(document.body, conf);
|
||
}
|
||
if (document.body) vigilarTema();
|
||
else document.addEventListener('DOMContentLoaded', vigilarTema);
|
||
addEventListener('load', function () { setTimeout(report, 800); setTimeout(report, 2500); });
|
||
|
||
// 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 (isInterno(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 (isInterno(u)) { newTab(u.href); return null; }
|
||
return _open ? _open.call(window, url, name, feats) : null;
|
||
};
|
||
|
||
// Atajos de teclado.
|
||
addEventListener('keydown', function (e) {
|
||
// AltGr enciende ctrlKey en Windows. Sin este filtro, escribir [ o ] con teclado
|
||
// español —que se hacen con AltGr— entraba por aquí y disparaba history.back() /
|
||
// history.forward() en vez de escribir el carácter: en el editor, en el chat, en una
|
||
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
|
||
if (e.altKey) return;
|
||
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;
|
||
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 });
|
||
})();
|
||
"#;
|
||
|
||
// ── 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 ──
|
||
|
||
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>) {
|
||
// Se consulta ANTES de tomar el lock del shell: así no se anidan dos cerrojos.
|
||
let maximized = main_window(app).and_then(|w| w.is_maximized().ok()).unwrap_or(false);
|
||
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, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
|
||
.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(),
|
||
// En macOS la barra ocupa la fila del título (ventana con título en overlay) y
|
||
// debe dejar libre el hueco del semáforo, que lo pinta el sistema.
|
||
"mac": cfg!(target_os = "macos"),
|
||
// En Windows la ventana va SIN decoración: la barra es la fila del título, así
|
||
// que dibuja ella misma los botones minimizar/maximizar/cerrar (no hay nativos).
|
||
"win": cfg!(target_os = "windows"),
|
||
"maximized": maximized,
|
||
// Tema real de la app (null hasta el primer reporte: la barra usa el del sistema).
|
||
"dark": s.dark,
|
||
})
|
||
};
|
||
let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload);
|
||
}
|
||
|
||
// Marca una pestaña como «cargando» y refresca la barra (giro en el icono).
|
||
fn set_loading<R: Runtime>(app: &AppHandle<R>, label: &str, loading: bool) {
|
||
{
|
||
let shell = app.state::<ShellState>();
|
||
let mut s = shell.lock().unwrap();
|
||
let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) else { return };
|
||
if t.loading == loading {
|
||
return; // sin cambios: no repintar la barra
|
||
}
|
||
t.loading = loading;
|
||
}
|
||
broadcast(app);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
// Reposo: la activa deja de contar; las demás empiezan a contar desde que se ocultan.
|
||
let ahora = Instant::now();
|
||
for (i, t) in s.tabs.iter_mut().enumerate() {
|
||
if i == s.active {
|
||
t.oculta_desde = None;
|
||
t.dormida = false;
|
||
} else if t.oculta_desde.is_none() {
|
||
t.oculta_desde = Some(ahora);
|
||
}
|
||
}
|
||
}
|
||
let Some(win) = main_window(app) else { return };
|
||
for wv in win.webviews() {
|
||
if wv.label() == CHROME {
|
||
continue;
|
||
}
|
||
if wv.label() == label {
|
||
despertar_webview(&wv);
|
||
let _ = wv.show();
|
||
let _ = wv.set_focus();
|
||
} else {
|
||
let _ = wv.hide();
|
||
}
|
||
}
|
||
broadcast(app);
|
||
save_persisted(app);
|
||
}
|
||
|
||
// ── Descargas: «Guardar como» ──
|
||
//
|
||
// tauri::webview::DownloadEvent avisa DOS veces por descarga:
|
||
// - Requested { url, destination: &mut PathBuf }: antes de empezar. `destination` trae la ruta
|
||
// que el motor usaría por defecto (carpeta Descargas + nombre sugerido) y se puede
|
||
// SOBRESCRIBIR (ruta absoluta); devolver false cancela la descarga.
|
||
// - Finished { url, path: Option<PathBuf>, success }: al terminar, bien o mal.
|
||
//
|
||
// Cómo llega `destination` según el motor:
|
||
// - Windows (WebView2): ICoreWebView2DownloadStartingEventArgs::ResultFilePath, que ya lleva el
|
||
// nombre del Content-Disposition (o del último segmento de la URL) y « (1)», « (2)»… si el
|
||
// archivo ya existe en Descargas. Si devolvemos true, wry hace SetResultFilePath(destination)
|
||
// + SetHandled(true) (wry-0.55.1/src/webview2/mod.rs:849-864); si false, SetCancel(true).
|
||
// - macOS (WKWebView): wry compone ~/Downloads/<suggestedFilename de WebKit> con el mismo
|
||
// esquema de « (n)» y, si devolvemos true, entrega esa ruta al completion handler de
|
||
// WKDownload como destino (wry-0.55.1/src/wkwebview/download.rs:49-91). El nombre viene
|
||
// siempre relleno. LÍMITE: WKWebView solo trata como descarga (a) los enlaces con atributo
|
||
// `download` (shouldPerformDownload) y (b) las respuestas cuyo MIME no sabe mostrar
|
||
// (canShowMIMEType == false; wkwebview/navigation.rs:59-73 y 93-100). WebKit NO mira
|
||
// Content-Disposition al decidir (WebPage::canShowResponse consulta solo el MIME), así que un
|
||
// PDF o una imagen servidos como attachment se MUESTRAN en la pestaña en vez de bajar; un
|
||
// .xlsx/.docx/.zip sí dispara la descarga porque WebKit no los renderiza.
|
||
|
||
// Abre el «Guardar como» nativo y deja en `destination` la ruta elegida. Devuelve false (y wry
|
||
// cancela la descarga) si el usuario cierra el diálogo; en ese caso no se avisa de nada.
|
||
//
|
||
// El diálogo es SÍNCRONO a propósito: el manejador de wry tiene que responder antes de volver y
|
||
// en el mismo hilo (el principal, donde WebView2 y WKWebView despachan estos eventos; wry no
|
||
// expone el deferral de WebView2). Por eso va rfd::FileDialog directo y no tauri-plugin-dialog:
|
||
// su `blocking_save_file()` monta un rfd::AsyncFileDialog y espera en un canal (macro
|
||
// blocking_fn en plugins/dialog/src/lib.rs); en macOS ese diálogo asíncrono se presenta como
|
||
// hoja sobre la ventana principal (beginSheetModalForWindow) y solo se muestra y resuelve si el
|
||
// run loop principal gira (rfd/src/backend/macos/modal_future.rs:79-102)… que es justo el hilo
|
||
// que estaría parado en recv() → cuelgue. rfd::FileDialog::save_file corre un bucle modal
|
||
// anidado en el propio hilo (NSSavePanel.runModal / IFileDialog::Show), que es lo que hace
|
||
// falta aquí. La ventana va como padre para que en Windows el diálogo sea modal a la app y no
|
||
// se quede detrás. No se fuerza carpeta inicial: el sistema recuerda la última usada.
|
||
fn pedir_destino_descarga<R: Runtime>(webview: &Webview<R>, url: &Url, destination: &mut PathBuf) -> bool {
|
||
let ventana = webview.window();
|
||
let dialogo = rfd::FileDialog::new()
|
||
.set_title("Guardar como")
|
||
.set_file_name(nombre_sugerido(url, destination))
|
||
.set_parent(&ventana);
|
||
match dialogo.save_file() {
|
||
Some(ruta) => {
|
||
webview.state::<Descargas>().0.lock().unwrap().insert(url.to_string(), ruta.clone());
|
||
*destination = ruta;
|
||
true
|
||
}
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
// Nombre que propone el diálogo: el que trae `destination` (WebView2 lo saca del
|
||
// Content-Disposition que manda el servidor; en macOS wry lo toma del suggestedFilename de
|
||
// WebKit) y, si viniera vacío, el último segmento de la URL decodificado. Nunca vacío.
|
||
fn nombre_sugerido(url: &Url, destination: &Path) -> String {
|
||
let del_motor = destination
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().trim().to_string())
|
||
.filter(|n| !n.is_empty());
|
||
if let Some(n) = del_motor {
|
||
return n;
|
||
}
|
||
url.path_segments()
|
||
.and_then(|segs| segs.rev().find(|s| !s.is_empty()))
|
||
.map(|s| percent_encoding::percent_decode_str(s).decode_utf8_lossy().trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
.unwrap_or_else(|| "descarga".to_string())
|
||
}
|
||
|
||
// Notificación nativa al terminar la descarga. En Windows `path` es la ruta final real (puede
|
||
// diferir de la elegida si el motor tuvo que renombrar); en macOS llega None y se usa la ruta
|
||
// que el usuario eligió en el diálogo (ver `Descargas`).
|
||
fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path: Option<PathBuf>, success: bool) {
|
||
let elegida = webview.state::<Descargas>().0.lock().unwrap().remove(url.as_str());
|
||
let cuerpo = if !success {
|
||
"No se pudo completar la descarga.".to_string()
|
||
} else {
|
||
match path.or(elegida).as_deref().and_then(Path::file_name) {
|
||
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
|
||
None => "Descarga terminada.".to_string(),
|
||
}
|
||
};
|
||
let _ = webview
|
||
.app_handle()
|
||
.notification()
|
||
.builder()
|
||
.title("Labstream OS")
|
||
.body(cuerpo)
|
||
.show();
|
||
}
|
||
|
||
// 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 !es_pestana_interna(&parsed, &server) {
|
||
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(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false });
|
||
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 app_navigation = app.clone();
|
||
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) {
|
||
let _ = app_navigation.opener().open_url(url.as_str(), None::<&str>);
|
||
return false;
|
||
}
|
||
true
|
||
})
|
||
// Descargas (entregables, exportaciones, enlaces `?download=1`): antes de empezar se
|
||
// PREGUNTA dónde guardar con el «Guardar como» nativo (ver pedir_destino_descarga) y al
|
||
// terminar se avisa con una notificación (ver avisar_descarga_terminada).
|
||
// El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE
|
||
// apagada. wry trae un manejador de descargas por defecto (wry-0.55.1/src/lib.rs:830),
|
||
// asi que DownloadStarting se registra siempre y siempre responde SetHandled(true)
|
||
// (wry-0.55.1/src/webview2/mod.rs:858-861), lo que le dice a WebView2 que del aviso
|
||
// se encarga la app: ni barra de progreso, ni globo de «descarga terminada». El
|
||
// archivo bajaba bien a la carpeta de Descargas, pero NADA lo decia y parecia roto.
|
||
// Quitar este manejador NO devuelve la interfaz nativa: el de wry sigue puesto.
|
||
.on_download(|webview, event| match event {
|
||
DownloadEvent::Requested { url, destination } => pedir_destino_descarga(&webview, &url, destination),
|
||
DownloadEvent::Finished { url, path, success, .. } => {
|
||
avisar_descarga_terminada(&webview, &url, path, success);
|
||
true
|
||
}
|
||
// El enum es #[non_exhaustive]: si wry añade eventos, dejarlos pasar.
|
||
_ => 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| {
|
||
let cargando = matches!(payload.event(), PageLoadEvent::Started);
|
||
if !cargando {
|
||
let _ = wv.set_zoom(current_zoom(&app_zoom));
|
||
}
|
||
set_loading(&app_zoom, wv.label(), cargando);
|
||
});
|
||
|
||
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);
|
||
// El cajón dice cuánto ocupa en su propia etiqueta: así vaciarlo es una decisión informada, y
|
||
// de paso es la comprobación más simple de que el caché está funcionando (el número crece al
|
||
// navegar fotos y vuelve a 0 al vaciar).
|
||
let vaciar_txt = format!("Vaciar caché de imágenes ({})", tamano_legible(cache::tamano(app)));
|
||
let build = || -> tauri::Result<Menu<R>> {
|
||
Ok(Menu::with_items(
|
||
app,
|
||
&[
|
||
&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>)?,
|
||
&MenuItem::with_id(app, "clear-cache", &vaciar_txt, true, None::<&str>)?,
|
||
&CheckMenuItem::with_id(app, "autostart-toggle", "Iniciar con el sistema", true, autostart_on, None::<&str>)?,
|
||
&MenuItem::with_id(app, "check-update", "Buscar actualización…", true, None::<&str>)?,
|
||
&PredefinedMenuItem::separator(app)?,
|
||
&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);
|
||
}
|
||
}
|
||
|
||
// Bytes → «12 MB» / «340 KB» / «1.2 GB», para la etiqueta del menú.
|
||
fn tamano_legible(bytes: u64) -> String {
|
||
const KB: u64 = 1024;
|
||
const MB: u64 = KB * 1024;
|
||
const GB: u64 = MB * 1024;
|
||
if bytes >= GB {
|
||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||
} else if bytes >= MB {
|
||
format!("{} MB", bytes / MB)
|
||
} else if bytes >= KB {
|
||
format!("{} KB", bytes / KB)
|
||
} else if bytes == 0 {
|
||
"vacío".to_string()
|
||
} else {
|
||
format!("{bytes} B")
|
||
}
|
||
}
|
||
|
||
// Menú de CLIC DERECHO sobre una pestaña. Nativo (la barra mide 40 px: un menú HTML dentro
|
||
// del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió.
|
||
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||
let Some(win) = main_window(app) else { return };
|
||
let (idx, total, actual) = {
|
||
let shell = app.state::<ShellState>();
|
||
let mut s = shell.lock().unwrap();
|
||
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
|
||
s.menu_tab = Some(label.to_string());
|
||
let actual = s.tabs[i].color.clone();
|
||
(i, s.tabs.len(), actual)
|
||
};
|
||
// Los ids se arman fuera del builder para poder pasarlos como &str (igual que el resto
|
||
// del menú) en vez de mezclar String y &str en el mismo constructor.
|
||
let ids: Vec<String> = TAB_COLORS.iter().map(|(id, _)| format!("tab-color-{id}")).collect();
|
||
let build = || -> tauri::Result<Menu<R>> {
|
||
// Submenú de color, con marca en el que tiene puesto ahora.
|
||
let mut colores: Vec<CheckMenuItem<R>> = Vec::with_capacity(TAB_COLORS.len() + 1);
|
||
colores.push(CheckMenuItem::with_id(app, "tab-color-ninguno", "Sin color", true, actual.is_none(), None::<&str>)?);
|
||
for ((cid, nombre), id) in TAB_COLORS.iter().zip(ids.iter()) {
|
||
let marcado = actual.as_deref() == Some(*cid);
|
||
colores.push(CheckMenuItem::with_id(app, id.as_str(), *nombre, true, marcado, None::<&str>)?);
|
||
}
|
||
let refs: Vec<&dyn tauri::menu::IsMenuItem<R>> = colores.iter().map(|c| c as &dyn tauri::menu::IsMenuItem<R>).collect();
|
||
let submenu = Submenu::with_items(app, "Color", true, &refs)?;
|
||
Ok(Menu::with_items(
|
||
app,
|
||
&[
|
||
&MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?,
|
||
&MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?,
|
||
&PredefinedMenuItem::separator(app)?,
|
||
&submenu,
|
||
&PredefinedMenuItem::separator(app)?,
|
||
&MenuItem::with_id(app, "tab-close", "Cerrar pestaña", true, None::<&str>)?,
|
||
&MenuItem::with_id(app, "tab-close-others", "Cerrar las demás", total > 1, None::<&str>)?,
|
||
&MenuItem::with_id(app, "tab-close-right", "Cerrar las de la derecha", idx + 1 < total, None::<&str>)?,
|
||
],
|
||
)?)
|
||
};
|
||
if let Ok(menu) = build() {
|
||
let _ = win.popup_menu(&menu);
|
||
}
|
||
}
|
||
|
||
// Acciones del menú de pestaña. `menu_tab` guarda sobre cuál se abrió (puede no ser la activa).
|
||
fn tab_menu_action<R: Runtime>(app: &AppHandle<R>, action: &str) {
|
||
let target = {
|
||
let shell = app.state::<ShellState>();
|
||
let s = shell.lock().unwrap();
|
||
s.menu_tab.clone()
|
||
};
|
||
let Some(label) = target else { return };
|
||
// Las que hay que cerrar se calculan ANTES de cerrar nada (los índices se mueven).
|
||
let (url, otras, derecha) = {
|
||
let shell = app.state::<ShellState>();
|
||
let s = shell.lock().unwrap();
|
||
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
|
||
(
|
||
s.tabs[i].url.clone(),
|
||
s.tabs.iter().filter(|t| t.label != label).map(|t| t.label.clone()).collect::<Vec<_>>(),
|
||
s.tabs.iter().skip(i + 1).map(|t| t.label.clone()).collect::<Vec<_>>(),
|
||
)
|
||
};
|
||
match action {
|
||
"tab-duplicate" => create_tab(app, Some(url), true),
|
||
"tab-copy-url" => {
|
||
let _ = app.clipboard().write_text(url);
|
||
}
|
||
"tab-close" => close_tab(app, &label),
|
||
"tab-close-others" => {
|
||
for l in otras {
|
||
close_tab(app, &l);
|
||
}
|
||
}
|
||
"tab-close-right" => {
|
||
for l in derecha {
|
||
close_tab(app, &l);
|
||
}
|
||
}
|
||
// Teñir la pestaña (o quitarle el color). Se valida contra la paleta: un id que no
|
||
// esté en TAB_COLORS deja la pestaña sin color en vez de guardar cualquier cosa.
|
||
a if a.starts_with("tab-color-") => {
|
||
let elegido = &a["tab-color-".len()..];
|
||
let nuevo = TAB_COLORS
|
||
.iter()
|
||
.find(|(id, _)| *id == elegido)
|
||
.map(|(id, _)| (*id).to_string());
|
||
{
|
||
let shell = app.state::<ShellState>();
|
||
let mut s = shell.lock().unwrap();
|
||
if let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) {
|
||
t.color = nuevo;
|
||
}
|
||
}
|
||
broadcast(app);
|
||
save_persisted(app);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Reordena las pestañas al soltar una en otra posición (arrastrar en la barra). Llega el
|
||
// orden completo de etiquetas; se conserva cuál estaba activa y se guarda la sesión.
|
||
fn reorder_tabs<R: Runtime>(app: &AppHandle<R>, order: Vec<String>) {
|
||
{
|
||
let shell = app.state::<ShellState>();
|
||
let mut s = shell.lock().unwrap();
|
||
// Solo se acepta una permutación EXACTA de las pestañas actuales (nada de perder o
|
||
// duplicar si la barra manda algo raro).
|
||
if order.len() != s.tabs.len() || !order.iter().all(|l| s.tabs.iter().any(|t| &t.label == l)) {
|
||
return;
|
||
}
|
||
let activa = s.tabs.get(s.active).map(|t| t.label.clone());
|
||
let mut nuevas: Vec<TabRec> = Vec::with_capacity(order.len());
|
||
for l in &order {
|
||
if let Some(i) = s.tabs.iter().position(|t| &t.label == l) {
|
||
nuevas.push(s.tabs.remove(i));
|
||
}
|
||
}
|
||
s.tabs = nuevas;
|
||
if let Some(a) = activa {
|
||
s.active = s.tabs.iter().position(|t| t.label == a).unwrap_or(0);
|
||
}
|
||
}
|
||
broadcast(app);
|
||
save_persisted(app);
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
fn field_bool(payload: &str, key: &str) -> Option<bool> {
|
||
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_bool()
|
||
}
|
||
|
||
// ── Esquema lsthumb:// — el cajón de miniaturas ────────────────────────────────
|
||
// La web app, dentro de la app, pide las miniaturas de la cuadrícula como
|
||
// `lsthumb://c/?u=<URL real del servidor, con su token>`. Aquí se contesta: si está en el cajón,
|
||
// del disco; si no, se trae del NAS UNA vez, se guarda y se sirve. Todo el trabajo de red y disco
|
||
// va en un hilo aparte (ureq es bloqueante) para no congelar el webview.
|
||
//
|
||
// El módulo `cache` pone los cerrojos: `permitido` (solo el servidor y su API, nunca un proxy
|
||
// abierto) y `clave_de` (identidad estable sin el token que rota).
|
||
|
||
fn respuesta_bytes(bytes: Vec<u8>) -> tauri::http::Response<Vec<u8>> {
|
||
tauri::http::Response::builder()
|
||
.status(200)
|
||
.header("Content-Type", "image/webp")
|
||
// Privada y de larga vida: quien manda de verdad es el cajón en disco; esto solo evita
|
||
// que el propio webview volviera a pedirla dentro de la misma sesión.
|
||
.header("Cache-Control", "private, max-age=86400")
|
||
.header("Access-Control-Allow-Origin", "*")
|
||
.body(bytes)
|
||
.unwrap()
|
||
}
|
||
|
||
fn respuesta_estado(code: u16) -> tauri::http::Response<Vec<u8>> {
|
||
tauri::http::Response::builder()
|
||
.status(code)
|
||
.header("Access-Control-Allow-Origin", "*")
|
||
.body(Vec::new())
|
||
.unwrap()
|
||
}
|
||
|
||
fn responder_lsthumb<R: Runtime>(
|
||
ctx: tauri::UriSchemeContext<'_, R>,
|
||
req: tauri::http::Request<Vec<u8>>,
|
||
responder: tauri::UriSchemeResponder,
|
||
) {
|
||
let app = ctx.app_handle().clone();
|
||
let objetivo = match cache::url_pedida(&req.uri().to_string()) {
|
||
Some(u) if cache::permitido(&u) => u,
|
||
_ => {
|
||
responder.respond(respuesta_estado(400));
|
||
return;
|
||
}
|
||
};
|
||
let Some(clave) = cache::clave_de(&objetivo) else {
|
||
responder.respond(respuesta_estado(400));
|
||
return;
|
||
};
|
||
|
||
// ¿En el cajón? Del disco, sin tocar la red.
|
||
if let Some(bytes) = cache::leer(&app, &clave) {
|
||
responder.respond(respuesta_bytes(bytes));
|
||
return;
|
||
}
|
||
|
||
// Falta: traerla del NAS y guardarla para la próxima. Va en el POOL de hilos de bloqueo (no un
|
||
// `std::thread::spawn` por petición): al abrir un set de 500 fotos podrían dispararse muchas a
|
||
// la vez, y el pool las acota y reutiliza sus hilos en vez de crear cientos.
|
||
tauri::async_runtime::spawn_blocking(move || match cache::traer(&objetivo) {
|
||
Ok((bytes, es_webp)) => {
|
||
if es_webp {
|
||
cache::escribir(&app, &clave, &bytes);
|
||
}
|
||
responder.respond(respuesta_bytes(bytes));
|
||
}
|
||
Err(code) => responder.respond(respuesta_estado(code)),
|
||
});
|
||
}
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
let mut builder = tauri::Builder::default();
|
||
|
||
// 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_clipboard_manager::init())
|
||
.plugin(tauri_plugin_notification::init())
|
||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
|
||
// El cajón de miniaturas (ver el módulo `cache` y el handler de arriba). Registrado en el
|
||
// builder para que TODAS las webviews de pestaña puedan usarlo.
|
||
.register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb);
|
||
|
||
let app = builder
|
||
.manage(ShellState::default())
|
||
.manage(Descargas::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;
|
||
s.dark = saved.dark;
|
||
}
|
||
|
||
// Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay.
|
||
// En AMBOS sistemas la barra de pestañas ocupa la fila del título, como Chrome:
|
||
// no se desperdicia una franja entera en repetir el nombre de la app.
|
||
// - macOS: título en OVERLAY y sin texto; el semáforo lo sigue pintando el sistema
|
||
// encima y la barra le deja su hueco (clase `mac` del CSS).
|
||
// - Windows: ventana SIN decoración; los botones minimizar/maximizar/cerrar los
|
||
// dibuja la propia barra (clase `win`) y actúan por el evento `ls-window`.
|
||
// `resizable(true)` conserva WS_THICKFRAME, así que los bordes siguen
|
||
// redimensionando y Aero Snap (arrastrar a un borde, Win+flechas) sigue vivo.
|
||
let mut wb = WindowBuilder::new(app, "main")
|
||
.title("Labstream OS")
|
||
.resizable(true)
|
||
.min_inner_size(1024.0, 700.0);
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
wb = wb.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true);
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
wb = wb.decorations(false);
|
||
}
|
||
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>);
|
||
}
|
||
}
|
||
id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => {
|
||
tab_menu_action(app, id)
|
||
}
|
||
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
|
||
"autostart-toggle" => toggle_autostart(app),
|
||
"clear-cache" => {
|
||
let antes = cache::tamano(app);
|
||
cache::vaciar(app);
|
||
// Un aviso nativo confirma que se hizo (y cuánto se liberó): sin él, «vaciar»
|
||
// no da ninguna señal y parece que no pasó nada.
|
||
let _ = app
|
||
.notification()
|
||
.builder()
|
||
.title("Labstream OS")
|
||
.body(format!("Caché de imágenes vaciado ({} liberados).", tamano_legible(antes)))
|
||
.show();
|
||
}
|
||
"check-update" => {
|
||
#[cfg(desktop)]
|
||
tauri::async_runtime::spawn(check_update_manual(app.clone()));
|
||
}
|
||
"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()))
|
||
// Sin esto Tauri instala su manejador de arrastre del SISTEMA en el
|
||
// webview y se traga los eventos HTML5 → no se podrían reordenar las
|
||
// pestañas arrastrando. La barra no recibe archivos, así que sobra.
|
||
.disable_drag_drop_handler(),
|
||
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), 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)> = {
|
||
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
|
||
saved
|
||
.tabs
|
||
.iter()
|
||
.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()))
|
||
.collect()
|
||
};
|
||
if restore.is_empty() {
|
||
create_tab(&handle, None, true);
|
||
} else {
|
||
for (url, color) in &restore {
|
||
create_tab(&handle, Some(url.clone()), false);
|
||
if !color.is_empty() {
|
||
// create_tab acaba de empujar su pestaña al final de la lista.
|
||
let shell = handle.state::<ShellState>();
|
||
let mut s = shell.lock().unwrap();
|
||
if let Some(t) = s.tabs.last_mut() {
|
||
if t.url == *url {
|
||
t.color = Some(color.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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-reposo", move |e| {
|
||
if field_bool(e.payload(), "libre") == Some(true) {
|
||
if let Some(l) = field(e.payload(), "label") {
|
||
dormir_pestana(&h, &l);
|
||
}
|
||
}
|
||
});
|
||
let h = handle.clone();
|
||
std::thread::spawn(move || loop {
|
||
std::thread::sleep(REPOSO_RONDA);
|
||
ronda_de_reposo(&h);
|
||
});
|
||
let h = handle.clone();
|
||
handle.listen_any("ls-new-tab", move |e| {
|
||
let url = field(e.payload(), "url");
|
||
create_tab(&h, url, true);
|
||
});
|
||
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();
|
||
let h_tabmenu = handle.clone();
|
||
handle.listen_any("ls-tab-menu", move |e| {
|
||
if let Some(label) = field(e.payload(), "label") {
|
||
show_tab_menu(&h_tabmenu, &label);
|
||
}
|
||
});
|
||
let h_reorder = handle.clone();
|
||
handle.listen_any("ls-reorder", move |e| {
|
||
// `order` llega como lista de etiquetas separadas por coma (el payload de los
|
||
// eventos del shell es plano: strings y números).
|
||
if let Some(orden) = field(e.payload(), "order") {
|
||
let v: Vec<String> = orden.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||
reorder_tabs(&h_reorder, v);
|
||
}
|
||
});
|
||
handle.listen_any("ls-menu", move |_| {
|
||
show_app_menu(&h);
|
||
});
|
||
// Botones de ventana de la barra. Solo se pintan en Windows, donde la ventana va
|
||
// sin decoración y no hay aspa del sistema. «close» pasa por CloseRequested, así
|
||
// que hace lo mismo de siempre: ocultar a la bandeja, no salir de la app.
|
||
let h = handle.clone();
|
||
handle.listen_any("ls-window", move |e| {
|
||
let Some(win) = main_window(&h) else { return };
|
||
match field(e.payload(), "act").as_deref() {
|
||
Some("min") => {
|
||
let _ = win.minimize();
|
||
}
|
||
Some("max") => {
|
||
// El repintado del icono ▢/❐ lo dispara el Resized que viene después.
|
||
if win.is_maximized().unwrap_or(false) {
|
||
let _ = win.unmaximize();
|
||
} else {
|
||
let _ = win.maximize();
|
||
}
|
||
}
|
||
Some("close") => {
|
||
let _ = win.close();
|
||
}
|
||
_ => {}
|
||
}
|
||
});
|
||
// 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, dark) = (
|
||
field(e.payload(), "label"),
|
||
field(e.payload(), "title"),
|
||
field(e.payload(), "url"),
|
||
field_bool(e.payload(), "dark"),
|
||
);
|
||
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;
|
||
}
|
||
}
|
||
// El tema es global (todas las pestañas del origen comparten modo): el
|
||
// último reporte manda, venga de la pestaña que venga.
|
||
if let Some(dark) = dark {
|
||
s.dark = Some(dark);
|
||
}
|
||
drop(s);
|
||
broadcast(&h);
|
||
save_persisted(&h);
|
||
}
|
||
});
|
||
|
||
// Icono en la bandeja: Abrir / estado del rastreador + pausa / 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 (trk_estado, trk_toggle) = tracker::armar_menu(app.handle())?;
|
||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||
let menu = Menu::with_items(app, &[&abrir, &sep1, &trk_estado, &trk_toggle, &sep2, &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);
|
||
}
|
||
id if tracker::menu_event(app, id) => {}
|
||
_ => {}
|
||
})
|
||
.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)?;
|
||
|
||
// El sensor del rastreador: listener de vinculación + hilo de medición.
|
||
tracker::iniciar(app.handle());
|
||
|
||
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);
|
||
// Maximizar/restaurar llega como un Resized más: si el estado cambió, la
|
||
// barra tiene que repintar su icono ▢/❐. Se compara antes de emitir para
|
||
// no repintarla en cada píxel mientras se arrastra un borde.
|
||
let maxi = window.is_maximized().unwrap_or(false);
|
||
let cambio = {
|
||
let shell = app.state::<ShellState>();
|
||
let mut s = shell.lock().unwrap();
|
||
let antes = s.maximized;
|
||
s.maximized = maxi;
|
||
antes != maxi
|
||
};
|
||
if cambio {
|
||
broadcast(app);
|
||
}
|
||
// La geometría MAXIMIZADA no se guarda: al reabrir volvería como ventana
|
||
// normal del tamaño de la pantalla y, sin decoración, taparía la barra de
|
||
// tareas. Se conserva la última geometría restaurada.
|
||
if !maxi {
|
||
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 window.is_maximized().unwrap_or(false) {
|
||
return; // ver arriba: maximizada no se guarda
|
||
}
|
||
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;
|
||
});
|
||
}
|