Comparar commits

...

1 Commits

Autor SHA1 Mensaje Fecha
Jonathan David Florez Salomón
2d9f14f026 Shell de pestañas: multiwebview, zoom persistente y documentos dentro de la app
La app deja de ser un envoltorio de una sola vista y pasa a ser un shell con
pestañas (multiwebview de Tauri 2, ventana "main" = barra local "chrome" +
webviews "tab-*" del servidor):

- Los enlaces internos target=_blank (documentos, entregables, OnlyOffice,
  exportaciones) abrían nada: ahora abren pestaña dentro de la app. También
  Cmd/Ctrl+clic, clic central y window.open del propio origen.
- Zoom de interfaz: Cmd/Ctrl +/−/0 y Ctrl+rueda (50%–250%), con indicador en
  la barra y persistencia entre sesiones.
- Sesión restaurada: pestañas abiertas, activa, zoom y tamaño/posición de la
  ventana vuelven al reabrir (estado.json en app_config_dir).
- Atajos: Cmd/Ctrl+T/W/R, Ctrl+Tab rota, Cmd/Ctrl+1..9; en macOS van por el
  menú nativo nuevo en español (Archivo/Edición/Vista/Ventana); en Windows
  por JS (sin barra de menú).
- macOS: clic en el Dock reabre la ventana oculta (RunEvent::Reopen).
- Una sola instancia en release (single-instance): reabrir enfoca la existente.
- Updater: avisa con notificación nativa antes de reiniciar por actualización.
- Los enlaces a OTRO origen siguen yendo al navegador del sistema y el login
  por Authentik sigue intacto (el init script se desactiva fuera del origen).

Shell ↔ Rust por eventos ls-* (core:default cubre el origen remoto; sin ACL
de comandos propios). Versión 1.1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:50:04 -05:00
Se han modificado 8 ficheros con 1018 adiciones y 101 borrados

Ver fichero

@@ -1,21 +1,33 @@
# Labstream OS — App de escritorio (Windows + macOS) # Labstream OS — App de escritorio (Windows + macOS)
Envoltorio de escritorio para **Labstream OS**. Es un cliente delgado hecho con Cliente de escritorio para **Labstream OS**, hecho con [Tauri 2](https://tauri.app).
[Tauri 2](https://tauri.app): abre una ventana propia (sin barra de navegador) Desde la v1.1.0 no es un envoltorio de una sola vista: es un **shell con pestañas**
que carga el servidor de Labstream OS que corre en el NAS (multiwebview) sobre el servidor del NAS (`https://os.labstreamsas.com`).
(`https://os.labstreamsas.com`).
No contiene la app: el backend, la base de datos, OnlyOffice, etc. siguen en el No contiene la app: el backend, la base de datos, OnlyOffice, etc. siguen en el
NAS. Esto solo le da al equipo un ícono y una ventana propia en Windows y Mac. NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
Además del ícono y la ventana, el envoltorio añade comportamiento de cliente nativo: - **Pestañas** como en un navegador: los enlaces internos con `target="_blank"`
(documentos, entregables, exportaciones, OnlyOffice…) abren **pestaña dentro de
- **Enlaces externos** (Drive, documentos ajenos…) abren en el **navegador del sistema**; la app** — antes morían en silencio. También ⌘/Ctrl+clic y clic central.
la navegación del propio servidor y el **login por Authentik** se quedan dentro. Atajos: ⌘/Ctrl+T nueva, ⌘/Ctrl+W cerrar, Ctrl+Tab rotar, ⌘/Ctrl+1…9 ir a la n.
La sesión (pestañas abiertas, activa) **se restaura** al reabrir.
- **Zoom de interfaz**: ⌘/Ctrl `+` / `−` / `0` y Ctrl+rueda, con indicador en la
barra y **persistencia** entre sesiones (50%–250%).
- **Documentos dentro de la app**: los visores/editores del propio servidor
(OnlyOffice, PDFs, reproductor de revisión) abren en pestañas; solo lo de OTRO
origen (Drive…) sale al **navegador del sistema**. El **login por Authentik**
sigue funcionando dentro.
- **Notas de voz / cámara**: pide permiso de micrófono y cámara (entitlements de macOS). - **Notas de voz / cámara**: pide permiso de micrófono y cámara (entitlements de macOS).
- **Descargas** (entregables, exportaciones) con el diálogo nativo de guardado. - **Descargas** (entregables, exportaciones) con el diálogo nativo de guardado.
- **Bandeja del sistema**: cerrar la ventana la oculta; la app sigue notificando. - **Bandeja del sistema**: cerrar la ventana la oculta; la app sigue notificando.
En macOS, el clic en el **Dock** también reabre la ventana, y hay **menú nativo**
en español (Edición/Vista/Archivo) con los atajos.
- **Arranque automático** al iniciar sesión y **sesión persistente** (no re-loguea). - **Arranque automático** al iniciar sesión y **sesión persistente** (no re-loguea).
- **Una sola instancia** (release): abrirla de nuevo enfoca la ventana existente.
La barra de pestañas vive en `dist/index.html` (webview local «chrome»); las
pestañas son webviews hijos `tab-*`. Shell ↔ Rust se hablan por eventos `ls-*`.
El servidor objetivo se configura en `src-tauri/src/lib.rs` → constante `SERVER_URL`. El servidor objetivo se configura en `src-tauri/src/lib.rs` → constante `SERVER_URL`.

247
dist/index.html vendido
Ver fichero

@@ -4,41 +4,242 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Labstream OS</title> <title>Labstream OS</title>
<!--
Barra de pestañas del shell de escritorio (webview "chrome", local).
Habla con Rust por eventos (__TAURI__.event):
recibe ls-tabs {tabs:[{label,title,active}], zoom}
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle, ls-activate-n
-->
<style> <style>
:root {
--bg: #f4f4f5; /* zinc-100 */
--fg: #3f3f46; /* zinc-700 */
--muted: #a1a1aa; /* zinc-400 */
--tab-bg: transparent;
--tab-active-bg: #ffffff;
--tab-active-fg: #18181b;
--accent: #6366f1; /* indigo-500, color de Labstream */
--border: #e4e4e7; /* zinc-200 */
--hover: rgba(0, 0, 0, 0.05);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #18181b;
--fg: #d4d4d8;
--muted: #71717a;
--tab-active-bg: #27272a;
--tab-active-fg: #fafafa;
--border: #27272a;
--hover: rgba(255, 255, 255, 0.07);
}
}
* { box-sizing: border-box; }
html, body { html, body {
margin: 0; margin: 0;
height: 100%; height: 100%;
background: #6366f1; overflow: hidden;
color: #fff; background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 12.5px;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
#bar {
display: flex;
align-items: stretch;
gap: 4px;
height: 40px;
padding: 5px 8px 0;
border-bottom: 1px solid var(--border);
}
#tabs {
display: flex;
align-items: stretch;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
#tabs::-webkit-scrollbar { display: none; }
.tab {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 6px;
text-align: center; flex: 0 1 200px;
min-width: 96px;
padding: 0 6px 0 12px;
border-radius: 8px 8px 0 0;
background: var(--tab-bg);
position: relative;
overflow: hidden;
} }
.box { max-width: 420px; padding: 24px; } .tab:hover { background: var(--hover); }
h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; } .tab.active {
p { opacity: 0.85; font-size: 14px; line-height: 1.5; margin: 0; } background: var(--tab-active-bg);
.spinner { color: var(--tab-active-fg);
width: 28px; height: 28px; margin: 0 auto 20px; box-shadow: 0 -1px 0 var(--border), 1px 0 0 var(--border), -1px 0 0 var(--border);
border: 3px solid rgba(255,255,255,0.35);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } .tab.active::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--accent);
border-radius: 2px 2px 0 0;
}
.tab .titulo {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab .cerrar {
flex: 0 0 auto;
width: 18px;
height: 18px;
display: grid;
place-items: center;
border-radius: 5px;
color: var(--muted);
font-size: 13px;
line-height: 1;
visibility: hidden;
}
.tab:hover .cerrar, .tab.active .cerrar { visibility: visible; }
.tab .cerrar:hover { background: var(--hover); color: var(--tab-active-fg); }
button {
appearance: none;
border: 0;
background: transparent;
color: var(--fg);
font: inherit;
border-radius: 7px;
cursor: default;
display: grid;
place-items: center;
}
button:hover { background: var(--hover); }
.icono {
flex: 0 0 auto;
width: 30px;
margin: 2px 0 7px;
font-size: 15px;
}
#zoom {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 1px;
margin: 2px 0 7px;
}
#zoom button { height: 100%; }
#z-menos, #z-mas { width: 26px; font-size: 14px; }
#z-pct {
min-width: 44px;
padding: 0 4px;
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 11.5px;
}
#z-pct.activo { color: var(--accent); font-weight: 600; }
</style> </style>
</head> </head>
<body> <body>
<!-- <div id="bar">
Pantalla de respaldo. En condiciones normales la ventana carga directamente <div id="tabs" title=""></div>
el servidor del NAS (app.windows[].url en tauri.conf.json), así que este <button class="icono" id="nueva" title="Nueva pestaña (⌘/Ctrl+T)">+</button>
archivo casi nunca se ve. Solo aparece si se cambia la config para arrancar <button class="icono" id="recargar" title="Recargar (⌘/Ctrl+R)">⟳</button>
en local. Es obligatorio porque Tauri exige un frontendDist existente. <div id="zoom">
--> <button id="z-menos" title="Alejar (⌘/Ctrl+−)">−</button>
<div class="box"> <button id="z-pct" title="Restablecer zoom (⌘/Ctrl+0)">100%</button>
<div class="spinner"></div> <button id="z-mas" title="Acercar (⌘/Ctrl++)">+</button>
<h1>Labstream OS</h1>
<p>Conectando con el espacio de trabajo…</p>
</div> </div>
</div>
<script>
const T = window.__TAURI__;
const E = T && T.event;
let estado = { tabs: [], zoom: 100 };
function emitir(nombre, datos) {
try { E.emit(nombre, datos || {}); } catch (e) {}
}
const $tabs = document.getElementById("tabs");
const $pct = document.getElementById("z-pct");
function render() {
$tabs.textContent = "";
for (const t of estado.tabs) {
const el = document.createElement("div");
el.className = "tab" + (t.active ? " active" : "");
el.title = t.title;
const titulo = document.createElement("span");
titulo.className = "titulo";
titulo.textContent = t.title || "Labstream OS";
el.appendChild(titulo);
const x = document.createElement("button");
x.className = "cerrar";
x.textContent = "×";
x.title = "Cerrar pestaña (⌘/Ctrl+W)";
x.addEventListener("click", (e) => {
e.stopPropagation();
emitir("ls-close-tab", { label: t.label });
});
el.appendChild(x);
el.addEventListener("click", () => emitir("ls-activate", { label: t.label }));
// Clic central sobre la pestaña = cerrarla (como en el navegador).
el.addEventListener("auxclick", (e) => {
if (e.button === 1) emitir("ls-close-tab", { label: t.label });
});
$tabs.appendChild(el);
}
const activa = $tabs.querySelector(".tab.active");
if (activa) activa.scrollIntoView({ block: "nearest", inline: "nearest" });
$pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100);
}
if (E) {
E.listen("ls-tabs", (ev) => {
estado = ev.payload || estado;
render();
});
}
document.getElementById("nueva").addEventListener("click", () => emitir("ls-new-tab", {}));
document.getElementById("recargar").addEventListener("click", () => emitir("ls-reload", {}));
document.getElementById("z-menos").addEventListener("click", () => emitir("ls-zoom", { dir: -1 }));
document.getElementById("z-mas").addEventListener("click", () => emitir("ls-zoom", { dir: 1 }));
$pct.addEventListener("click", () => emitir("ls-zoom", { dir: 0 }));
// Doble clic en el hueco libre de la barra = pestaña nueva.
$tabs.addEventListener("dblclick", (e) => {
if (e.target === $tabs) emitir("ls-new-tab", {});
});
// Atajos también cuando el foco está en la barra.
addEventListener("keydown", (e) => {
const mod = e.metaKey || e.ctrlKey;
if (!mod) return;
const k = e.key;
if (k === "t" || k === "T") { e.preventDefault(); emitir("ls-new-tab", {}); }
else if (k === "w" || k === "W") { e.preventDefault(); emitir("ls-close-tab", {}); }
else if ((k === "r" || k === "R") && !e.shiftKey) { e.preventDefault(); emitir("ls-reload", {}); }
else if (k === "=" || k === "+") { e.preventDefault(); emitir("ls-zoom", { dir: 1 }); }
else if (k === "-") { e.preventDefault(); emitir("ls-zoom", { dir: -1 }); }
else if (k === "0") { e.preventDefault(); emitir("ls-zoom", { dir: 0 }); }
else if (k === "Tab") { e.preventDefault(); emitir("ls-cycle", { dir: e.shiftKey ? -1 : 1 }); }
else if (k >= "1" && k <= "9") { e.preventDefault(); emitir("ls-activate-n", { n: +k }); }
});
emitir("ls-ready", {});
</script>
</body> </body>
</html> </html>

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": { "scripts": {

19
src-tauri/Cargo.lock generado
Ver fichero

@@ -1946,7 +1946,7 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1955,6 +1955,7 @@ dependencies = [
"tauri-plugin-autostart", "tauri-plugin-autostart",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
] ]
@@ -3841,6 +3842,22 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tokio",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.10.1" version = "2.10.1"

Ver fichero

@@ -1,6 +1,6 @@
[package] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.0.0" version = "1.1.0"
description = "Cliente de escritorio de Labstream OS" description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"] authors = ["Labstream SAS"]
edition = "2021" edition = "2021"
@@ -14,10 +14,12 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } # `unstable` habilita multiwebview: varios webviews (pestañas) dentro de UNA ventana.
tauri = { version = "2", features = ["tray-icon", "unstable"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-autostart = "2" tauri-plugin-autostart = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

Ver fichero

@@ -1,8 +1,9 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Permisos por defecto del envoltorio de escritorio.", "description": "Permisos del shell de escritorio: la barra local (chrome) y las pestañas remotas (tab-*).",
"windows": ["main"], "windows": ["main"],
"webviews": ["chrome", "tab-*"],
"remote": { "remote": {
"urls": ["https://os.labstreamsas.com"] "urls": ["https://os.labstreamsas.com"]
}, },

Ver fichero

@@ -1,70 +1,489 @@
// Punto de entrada de la app de escritorio de Labstream OS. // Punto de entrada de la app de escritorio de Labstream OS.
// Es un "envoltorio": la ventana carga directamente el servidor (os.labstreamsas.com), //
// así que aquí no hay UI propia. Sí añadimos comportamiento de cliente nativo: // Ya no es un envoltorio de UNA webview: es un SHELL DE PESTAÑAS (multiwebview de Tauri 2).
// - plugin `opener`: enlaces externos abren el navegador del sistema. // La ventana "main" contiene:
// - plugin `notification`: la web (vía window.__TAURI__) muestra avisos nativos. // - 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 `autostart`: la app arranca al iniciar sesión.
// - plugin `updater`: al abrir, busca una versión nueva en los Releases de GitHub // - plugin `updater`: busca versión nueva en los Releases de GitHub y se actualiza sola.
// (firmada) y, si la hay, la instala sola y reinicia. // - bandeja (tray) + "cerrar = ocultar": sigue corriendo y notificando en segundo plano.
// - bandeja (tray) + "cerrar = ocultar": la app sigue corriendo y notificando // Y lo nuevo:
// aunque cierres la ventana. // - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones.
// - la ventana se crea AQUÍ (no en tauri.conf.json) para poder inyectar un script // - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana.
// que manda los enlaces externos al navegador y para permitir descargas. // - 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::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, 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_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_notification::NotificationExt;
const SERVER_URL: &str = "https://os.labstreamsas.com"; 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";
// Script que corre en CADA carga de página del servidor. Reenvía al NAVEGADOR del sistema los // Escalones de zoom (como un navegador). 1.0 = 100%.
// enlaces a OTRO origen (Drive, documentos externos, etc.): clics en `<a>` externos, `target=_blank` 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];
// externos y `window.open(url)` externos. NO toca la navegación del propio origen, así el login por const ZOOM_100: usize = 5; // índice de 1.0 en ZOOM_STEPS
// Authentik —que redirige a otro dominio y vuelve— sigue funcionando. Usa el comando del plugin
// opener, permitido por la capability `opener:default` para este origen remoto. // ── Estado del shell ──
const INIT_JS: &str = r#"
(function () { struct TabRec {
if (window.__lsExternalLinks) return; label: String,
window.__lsExternalLinks = true; title: String,
var APP_ORIGIN = location.origin; url: String,
function toAbs(href) { try { return new URL(href, location.href); } catch (e) { return null; } }
function isExternal(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:') && u.origin !== APP_ORIGIN; }
function openExternal(url) {
try { window.__TAURI__.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
catch (e) { try { window.__TAURI__.opener.openUrl(url); } catch (_) {} }
} }
struct Shell {
tabs: Vec<TabRec>,
active: usize,
zoom_idx: usize,
next_id: u64,
win: Option<WinRect>,
}
impl Default for Shell {
fn default() -> Self {
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: 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,
}
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,
}
};
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) { document.addEventListener('click', function (e) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; if (e.defaultPrevented || e.button !== 0) return;
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null; var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return; if (!a) return;
var u = toAbs(a.getAttribute('href')); var u = abs(a.getAttribute('href'));
if (isExternal(u)) { e.preventDefault(); openExternal(u.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); }, 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; var _open = window.open;
window.open = function (url, name, feats) { window.open = function (url, name, feats) {
var u = url ? toAbs(url) : null; var u = url ? abs(url) : null;
if (isExternal(u)) { openExternal(u.href); return 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; 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 });
})(); })();
"#; "#;
// Muestra y enfoca la ventana principal (desde el tray o su menú). // ── 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);
}
// Muestra y enfoca la ventana principal (tray, dock, segunda instancia).
fn show_main<R: Runtime>(app: &AppHandle<R>) { fn show_main<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window("main") { if let Some(w) = main_window(app) {
let _ = w.show(); let _ = w.show();
let _ = w.unminimize(); let _ = w.unminimize();
let _ = w.set_focus(); let _ = w.set_focus();
} }
} }
// Busca e instala una actualización en segundo plano (best-effort, no molesta al usuario). // Busca e instala una actualización en segundo plano. Si instala, avisa y reinicia.
// Compara con el `latest.json` publicado en los Releases de GitHub; si hay una versión más
// nueva y firmada con nuestra clave, la descarga, la instala y reinicia la app. Si no hay
// red o no hay actualización, no hace nada.
#[cfg(desktop)] #[cfg(desktop)]
async fn check_update(handle: tauri::AppHandle) { async fn check_update(handle: tauri::AppHandle) {
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
@@ -74,21 +493,50 @@ async fn check_update(handle: tauri::AppHandle) {
}; };
if let Ok(Some(update)) = updater.check().await { if let Ok(Some(update)) = updater.check().await {
if update.download_and_install(|_, _| {}, || {}).await.is_ok() { 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(); 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() 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_opener::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_autostart::init( .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None));
MacosLauncher::LaunchAgent,
None, let app = builder
)) .manage(ShellState::default())
.setup(|app| { .setup(|app| {
// Arranque automático al iniciar sesión (idempotente). // Arranque automático al iniciar sesión (idempotente).
let _ = app.autolaunch().enable(); let _ = app.autolaunch().enable();
@@ -100,22 +548,214 @@ pub fn run() {
tauri::async_runtime::spawn(check_update(handle)); tauri::async_runtime::spawn(check_update(handle));
} }
// Ventana principal: carga el servidor del NAS. Se crea aquí (no en el config) para let handle = app.handle().clone();
// poder inyectar el script de enlaces externos y permitir las descargas. let saved = load_persisted(&handle);
WebviewWindowBuilder::new( {
app, let shell = handle.state::<ShellState>();
"main", let mut s = shell.lock().unwrap();
WebviewUrl::External(SERVER_URL.parse().expect("URL del servidor válida")), s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1);
) s.win = saved.win;
}
// 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") .title("Labstream OS")
.inner_size(1400.0, 900.0)
.min_inner_size(1024.0, 700.0)
.resizable(true) .resizable(true)
.center() .min_inner_size(1024.0, 700.0);
.initialization_script(INIT_JS) match saved.win {
// Deja pasar las descargas (entregables, exportaciones) con el diálogo nativo de guardado. Some(r) if r.w >= 600.0 && r.h >= 400.0 => {
.on_download(|_webview, _event| true) wb = wb.inner_size(r.w, r.h).position(r.x, r.y);
.build()?; }
_ => {
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,
&[
&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"))?,
],
)?;
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)?;
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),
_ => {}
});
}
// 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-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. // Icono en la bandeja del sistema con menú Abrir / Salir.
let abrir = MenuItem::with_id(app, "abrir", "Abrir Labstream", true, None::<&str>)?; let abrir = MenuItem::with_id(app, "abrir", "Abrir Labstream", true, None::<&str>)?;
@@ -129,7 +769,10 @@ pub fn run() {
.show_menu_on_left_click(false) .show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() { .on_menu_event(|app, event| match event.id.as_ref() {
"abrir" => show_main(app), "abrir" => show_main(app),
"salir" => app.exit(0), "salir" => {
save_persisted(app);
app.exit(0);
}
_ => {} _ => {}
}) })
.on_tray_icon_event(|tray, event| { .on_tray_icon_event(|tray, event| {
@@ -147,13 +790,54 @@ pub fn run() {
Ok(()) Ok(())
}) })
// Cerrar la ventana = ocultarla a la bandeja (la app sigue notificando).
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = 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(); let _ = window.hide();
api.prevent_close(); api.prevent_close();
} }
}) // La barra y las pestañas siguen el tamaño de la ventana.
.run(tauri::generate_context!()) WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
.expect("error al arrancar Labstream OS"); 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;
});
} }

Ver fichero

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Labstream OS", "productName": "Labstream OS",
"version": "1.0.0", "version": "1.1.0",
"identifier": "co.labstream.os", "identifier": "co.labstream.os",
"build": { "build": {
"frontendDist": "../dist" "frontendDist": "../dist"