Files
labstream-desktop/src-tauri/src/lib.rs
Jonathan David Florez Salomón 329ea1195e Menú de opciones ⋮ de la app (popup nativo) + preferencia de autostart
Botón ⋮ en la barra de pestañas (y clic derecho sobre ella) → menú NATIVO del
sistema, idéntico en Windows y macOS, solo del shell (la web app no se toca):

- Nueva/cerrar pestaña, recargar, acercar/alejar/tamaño real.
- "Abrir esta página en el navegador" (la pestaña activa, vía opener).
- "Iniciar con el sistema" con check: ahora la elección SE RECUERDA
  (antes el arranque re-activaba el autostart en cada apertura).
- "Buscar actualización…" manual con aviso del resultado (instalando /
  ya estás al día / sin red), además del chequeo silencioso de siempre.
- Versión visible y "Salir de Labstream OS".

El manejador de menús pasa a ser global (antes solo existía dentro del cfg de
macOS): lo comparten el menú de macOS y el popup ⋮ de ambas plataformas. El
menú de macOS gana "Buscar actualización…" y "Abrir en el navegador".

Versión 1.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:50:46 -05:00

972 líneas
38 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:
// - 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)
}
impl Default for Shell {
fn default() -> Self {
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None }
}
}
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(); 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 === '=' || 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);
// 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 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 })
};
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;
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())
}
// 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, "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, "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"))?,
&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 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, &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() {
"new-tab" => create_tab(app, None, true),
"close-tab" => {
if let Some(l) = active_label(app) {
close_tab(app, &l);
}
}
"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-menu", move |_| {
show_app_menu(&h);
});
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;
});
}