Comparar commits
1 Commits
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
|
2d9f14f026 |
30
README.md
30
README.md
@@ -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
247
dist/index.html
vendido
@@ -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>
|
||||||
|
|||||||
@@ -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
19
src-tauri/Cargo.lock
generado
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user