Files
labstream-desktop/src-tauri/src/lib.rs
Jonathan David Florez Salomón d8a35f5bae Atrás/adelante en la barra + reabrir pestaña + globo de no-leídos en el icono
- ATRÁS/ADELANTE por fin: botones ← → en la barra de pestañas, ⌘[ / ⌘] en
  ambas plataformas, Alt+←/→ en Windows (fuera de campos de texto) y menú
  «Historial» en macOS. Cierra el callejón sin salida: una página sin menú
  propio (p. ej. el portal de revisión) dejaba al usuario atrapado sin forma
  de volver.
- «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T, tope de 20): en la barra ⌘⇧T
  antes caía en «nueva pestaña» por no mirar el Shift.
- Globo de NO-LEÍDOS en el icono: la web app emite `ls-badge {count}` (mismo
  conteo del «(N)» del título) y el shell pinta el número en el Dock (macOS),
  un punto rojo dibujado a mano sobre el icono de la barra de tareas (Windows,
  sin assets) y el tooltip de la bandeja («— N sin leer»).

Verificado: cargo check limpio (macOS) y firmas de las APIs solo-Windows
(set_overlay_icon, set_badge_count, Image::new_owned) cotejadas contra el
código fuente de tauri 2.11.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:11:37 -05:00

1104 líneas
44 KiB
Rust
Original Blame Histórico

Este archivo contiene caracteres Unicode ambiguos

Este archivo contiene caracteres Unicode que pueden confundirse con otros caracteres. Si crees que esto es intencional, puedes ignorar esta advertencia. Usa el botón de Escape para revelarlos.

// 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<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)
}
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<Shell>;
#[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>,
#[serde(default)]
active: usize,
#[serde(default)]
autostart: 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(),
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 <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.
// 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;
});
}