Comparar commits
1 Commits
salud-y-re
...
v1.21.0
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
|
6539f6939c |
10
README.md
10
README.md
@@ -20,6 +20,16 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
|
|||||||
- **Colores de pestaña** (v1.6.0): clic derecho sobre una pestaña ▸ **Color**, con
|
- **Colores de pestaña** (v1.6.0): clic derecho sobre una pestaña ▸ **Color**, con
|
||||||
la misma paleta de nueve tonos que usa Chrome para sus grupos. Sirve para agrupar
|
la misma paleta de nueve tonos que usa Chrome para sus grupos. Sirve para agrupar
|
||||||
de un vistazo (todo lo de un cliente en verde) y **se guarda con la sesión**.
|
de un vistazo (todo lo de un cliente en verde) y **se guarda con la sesión**.
|
||||||
|
- **Pestaña del color de su página** (v1.21.0): una página puede pedir el color de
|
||||||
|
su pestaña con `<html data-ls-pestana="#rrggbb">` (solo ese formato). El modo
|
||||||
|
edición de un set la pinta del gris del editor: activa se funde con su barra de
|
||||||
|
arriba y detrás se reconoce entre las demás. Esa página no cambia el tema de la
|
||||||
|
barra (su fondo oscuro no es el de la app).
|
||||||
|
- **Pestañas con nombre** (v1.21.0): `window.open(url, "ls-…")` o un enlace con
|
||||||
|
`target="ls-…"` abre la pestaña la primera vez y las siguientes **vuelven a
|
||||||
|
ella** en vez de abrir otra. La página recibe la URL nueva por
|
||||||
|
`window.__lsAbrir(url)` (el modo edición cambia de foto sin recargar); si no lo
|
||||||
|
tiene, navega. Nombre y color se guardan con la sesión.
|
||||||
- **Zoom de interfaz**: ⌘/Ctrl `+` / `−` / `0` y Ctrl+rueda, con indicador en la
|
- **Zoom de interfaz**: ⌘/Ctrl `+` / `−` / `0` y Ctrl+rueda, con indicador en la
|
||||||
barra y **persistencia** entre sesiones (50%–250%).
|
barra y **persistencia** entre sesiones (50%–250%).
|
||||||
- **Documentos dentro de la app**: los visores/editores del propio servidor
|
- **Documentos dentro de la app**: los visores/editores del propio servidor
|
||||||
|
|||||||
27
dist/index.html
vendido
27
dist/index.html
vendido
@@ -7,7 +7,7 @@
|
|||||||
<!--
|
<!--
|
||||||
Barra de pestañas del shell de escritorio (webview "chrome", local).
|
Barra de pestañas del shell de escritorio (webview "chrome", local).
|
||||||
Habla con Rust por eventos (__TAURI__.event):
|
Habla con Rust por eventos (__TAURI__.event):
|
||||||
recibe ls-tabs {tabs:[{label,title,url,loading,active,color}], zoom, version,
|
recibe ls-tabs {tabs:[{label,title,url,loading,active,color,pagina}], zoom, version,
|
||||||
mac, win, maximized, dark}
|
mac, win, maximized, dark}
|
||||||
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle,
|
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle,
|
||||||
ls-activate-n, ls-nav, ls-menu, ls-check-update, ls-tab-menu, ls-reorder
|
ls-activate-n, ls-nav, ls-menu, ls-check-update, ls-tab-menu, ls-reorder
|
||||||
@@ -208,6 +208,17 @@
|
|||||||
}
|
}
|
||||||
:root.oscuro .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
|
:root.oscuro .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
|
||||||
:root.oscuro .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
|
:root.oscuro .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
|
||||||
|
/* ── La pestaña del COLOR DE SU PÁGINA (1.21) ──
|
||||||
|
La página lo pide (`<html data-ls-pestana="#262626">`): el modo edición de un set pinta la
|
||||||
|
suya del gris del editor. Activa, es de ese color y se funde con la barra de arriba de la
|
||||||
|
página, como la de siempre se funde con el blanco de la app. Detrás, el mismo color: entre
|
||||||
|
las demás se reconoce de un vistazo. Con color del menú encima, ese va de franja arriba.
|
||||||
|
(#tabs delante: gana a las reglas de tema y de color del menú sin !important.) */
|
||||||
|
#tabs .tab.pagina { --muted: color-mix(in srgb, var(--tp-fg) 60%, transparent); background: var(--tp); color: var(--tp-fg); }
|
||||||
|
#tabs .tab.pagina:not(.active) { box-shadow: none; }
|
||||||
|
#tabs .tab.pagina:not(.active):hover { background: color-mix(in srgb, var(--tp) 86%, var(--tp-fg)); color: var(--tp-fg); }
|
||||||
|
#tabs .tab.pagina .cerrar:hover { background: color-mix(in srgb, var(--tp-fg) 18%, transparent); color: var(--tp-fg); }
|
||||||
|
#tabs .tab.pagina[data-color] { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
|
||||||
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
|
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
|
||||||
macOS los eventos no llegan), sino eventos de puntero. Se apaga el arrastre nativo de
|
macOS los eventos no llegan), sino eventos de puntero. Se apaga el arrastre nativo de
|
||||||
WebKit para que no salga la imagen fantasma al mover una pestaña. */
|
WebKit para que no salga la imagen fantasma al mover una pestaña. */
|
||||||
@@ -500,6 +511,14 @@
|
|||||||
$tabs.classList.toggle("compactas", ancho < 112);
|
$tabs.classList.toggle("compactas", ancho < 112);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ¿Un #rrggbb claro? (luminancia relativa): decide si la letra encima va oscura o clara.
|
||||||
|
function esClaro(hex) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||||
|
const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
|
||||||
|
return l > 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
$tabs.textContent = "";
|
$tabs.textContent = "";
|
||||||
for (const t of estado.tabs) {
|
for (const t of estado.tabs) {
|
||||||
@@ -508,6 +527,12 @@
|
|||||||
el.className = "tab" + (t.active ? " active" : "");
|
el.className = "tab" + (t.active ? " active" : "");
|
||||||
el.dataset.label = t.label;
|
el.dataset.label = t.label;
|
||||||
if (t.color) el.dataset.color = t.color; // teñido desde el menú de la pestaña
|
if (t.color) el.dataset.color = t.color; // teñido desde el menú de la pestaña
|
||||||
|
// El color que pide la página (ya validado #rrggbb en el shell), con letra que se lea encima.
|
||||||
|
if (t.pagina) {
|
||||||
|
el.classList.add("pagina");
|
||||||
|
el.style.setProperty("--tp", t.pagina);
|
||||||
|
el.style.setProperty("--tp-fg", esClaro(t.pagina) ? "#202124" : "#ececec");
|
||||||
|
}
|
||||||
el.title = texto;
|
el.title = texto;
|
||||||
|
|
||||||
const fav = document.createElement("span");
|
const fav = document.createElement("span");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "labstream-desktop",
|
"name": "labstream-desktop",
|
||||||
"version": "1.20.0",
|
"version": "1.21.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": {
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generado
2
src-tauri/Cargo.lock
generado
@@ -2366,7 +2366,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "labstream-desktop"
|
name = "labstream-desktop"
|
||||||
version = "1.20.0"
|
version = "1.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"active-win-pos-rs",
|
"active-win-pos-rs",
|
||||||
"device_query",
|
"device_query",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "labstream-desktop"
|
name = "labstream-desktop"
|
||||||
version = "1.20.0"
|
version = "1.21.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"
|
||||||
|
|||||||
@@ -81,6 +81,19 @@ fn es_pestana_interna(target: &Url, server: &Url) -> bool {
|
|||||||
target.origin() == server.origin()
|
target.origin() == server.origin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// El color que pide una página para su pestaña: solo `#rrggbb` (va a un estilo de la barra; nada
|
||||||
|
// que no sea un color llega allí).
|
||||||
|
fn color_de_pagina(c: &str) -> Option<String> {
|
||||||
|
let hex = c.strip_prefix('#')?;
|
||||||
|
(hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un nombre de ventana que identifica pestaña: los de la app (`ls-…`, p. ej. `ls-edicion-<set>`).
|
||||||
|
// Los reservados del navegador (_blank, _self…) y cualquier otro abren pestaña nueva como siempre.
|
||||||
|
fn nombre_de_ventana(n: &str) -> Option<String> {
|
||||||
|
(n.starts_with("ls-") && n.len() <= 80 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')).then(|| n.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod navigation_tests {
|
mod navigation_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -95,6 +108,20 @@ mod navigation_tests {
|
|||||||
assert!(is_google_host("docs.google.com"));
|
assert!(is_google_host("docs.google.com"));
|
||||||
assert!(!is_google_host("docs.google.com.evil.example"));
|
assert!(!is_google_host("docs.google.com.evil.example"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn la_pagina_solo_pide_colores_y_nombres_de_la_app() {
|
||||||
|
assert_eq!(color_de_pagina("#262626").as_deref(), Some("#262626"));
|
||||||
|
assert_eq!(color_de_pagina("#A0b1C2").as_deref(), Some("#a0b1c2"));
|
||||||
|
for malo in ["", "262626", "#26262", "#2626266", "red", "#26262g", "#262626;background:url(x)"] {
|
||||||
|
assert_eq!(color_de_pagina(malo), None, "{malo}");
|
||||||
|
}
|
||||||
|
assert_eq!(nombre_de_ventana("ls-edicion-cmg123").as_deref(), Some("ls-edicion-cmg123"));
|
||||||
|
for malo in ["", "_blank", "_self", "edicion", "ls-a b", "ls-\"<x>"] {
|
||||||
|
assert_eq!(nombre_de_ventana(malo), None, "{malo}");
|
||||||
|
}
|
||||||
|
assert_eq!(nombre_de_ventana(&format!("ls-{}", "a".repeat(80))), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
|
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
|
||||||
const CHROME: &str = "chrome";
|
const CHROME: &str = "chrome";
|
||||||
@@ -122,6 +149,12 @@ struct TabRec {
|
|||||||
// Ahorro de memoria (memoria.rs): su webview se cerró tras DESCARTE_TRAS dormida. Sigue en la
|
// Ahorro de memoria (memoria.rs): su webview se cerró tras DESCARTE_TRAS dormida. Sigue en la
|
||||||
// barra con título, URL y color, y se crea otra vez al volver a ella.
|
// barra con título, URL y color, y se crea otra vez al volver a ella.
|
||||||
descartada: bool,
|
descartada: bool,
|
||||||
|
// El color que PIDE la página (`<html data-ls-pestana="#262626">`, ver `color_de_pagina`): el
|
||||||
|
// modo edición de un set pinta su pestaña del gris del editor. Lo manda la página, no el menú.
|
||||||
|
pagina: Option<String>,
|
||||||
|
// El nombre de ventana con que se abrió (`window.open(url, nombre)`): abrir otra vez con el
|
||||||
|
// mismo nombre lleva a esta pestaña en vez de abrir otra (ver `reusar_pestana`).
|
||||||
|
nombre: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
|
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
|
||||||
@@ -198,6 +231,12 @@ struct Persisted {
|
|||||||
// cargar (ver `registrar_diferida`) y la barra necesita algo que enseñar mientras tanto.
|
// cargar (ver `registrar_diferida`) y la barra necesita algo que enseñar mientras tanto.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
titles: Vec<String>,
|
titles: Vec<String>,
|
||||||
|
// El color que pidió la página y el nombre de ventana de cada una (mismo orden; "" = ninguno):
|
||||||
|
// la pestaña del modo edición vuelve del gris del editor y se sigue reutilizando.
|
||||||
|
#[serde(default)]
|
||||||
|
paginas: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
nombres: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
active: usize,
|
active: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -230,6 +269,8 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
|
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
|
||||||
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
|
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
|
||||||
titles: s.tabs.iter().map(|t| t.title.clone()).collect(),
|
titles: s.tabs.iter().map(|t| t.title.clone()).collect(),
|
||||||
|
paginas: s.tabs.iter().map(|t| t.pagina.clone().unwrap_or_default()).collect(),
|
||||||
|
nombres: s.tabs.iter().map(|t| t.nombre.clone().unwrap_or_default()).collect(),
|
||||||
active: s.active,
|
active: s.active,
|
||||||
autostart: s.autostart,
|
autostart: s.autostart,
|
||||||
dark: s.dark,
|
dark: s.dark,
|
||||||
@@ -287,7 +328,9 @@ const INIT_JS_TPL: &str = r#"
|
|||||||
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
|
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
|
||||||
catch (e) { try { T.opener.openUrl(url); } catch (_) {} }
|
catch (e) { try { T.opener.openUrl(url); } catch (_) {} }
|
||||||
}
|
}
|
||||||
function newTab(url) { emit('ls-new-tab', { url: url }); }
|
function newTab(url, nombre) { emit('ls-new-tab', nombre ? { url: url, name: nombre } : { url: url }); }
|
||||||
|
// Una ventana con nombre de la app (`ls-…`): reabrirla lleva a la pestaña que ya la tiene.
|
||||||
|
function nombreVentana(n) { return typeof n === 'string' && /^ls-[A-Za-z0-9_-]{1,77}$/.test(n) ? n : null; }
|
||||||
|
|
||||||
// ── Errores de la página → diario de salud del shell (salud.log) ──
|
// ── Errores de la página → diario de salud del shell (salud.log) ──
|
||||||
// Solo el mensaje, el archivo:línea y la ruta (sin datos del formulario ni de la sesión). Como
|
// Solo el mensaje, el archivo:línea y la ruta (sin datos del formulario ni de la sesión). Como
|
||||||
@@ -373,13 +416,25 @@ const INIT_JS_TPL: &str = r#"
|
|||||||
} catch (e) { return null; }
|
} catch (e) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── El color que la página pide para SU pestaña ── `<html data-ls-pestana='#262626'>` (el modo
|
||||||
|
// edición de un set, del gris del editor). "" = ninguno.
|
||||||
|
function colorPestana() {
|
||||||
|
try {
|
||||||
|
var c = document.documentElement && document.documentElement.getAttribute('data-ls-pestana');
|
||||||
|
return c && /^#[0-9a-fA-F]{6}$/.test(c) ? c : '';
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
|
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
|
||||||
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
|
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
|
||||||
// sistema: la barra tiene que copiarle a ELLA.
|
// sistema: la barra tiene que copiarle a ELLA. Una página con color propio no lo dice: su
|
||||||
|
// fondo oscuro es el de su pestaña, no el tema de la app.
|
||||||
function report() {
|
function report() {
|
||||||
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
|
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href, pestana: colorPestana() };
|
||||||
var oscuro = temaOscuro();
|
if (!carga.pestana) {
|
||||||
if (oscuro !== null) carga.dark = oscuro;
|
var oscuro = temaOscuro();
|
||||||
|
if (oscuro !== null) carga.dark = oscuro;
|
||||||
|
}
|
||||||
emit('ls-title', carga);
|
emit('ls-title', carga);
|
||||||
}
|
}
|
||||||
function watchTitle() {
|
function watchTitle() {
|
||||||
@@ -404,7 +459,7 @@ const INIT_JS_TPL: &str = r#"
|
|||||||
// por si el tema aterriza tarde con la hidratación.
|
// por si el tema aterriza tarde con la hidratación.
|
||||||
function vigilarTema() {
|
function vigilarTema() {
|
||||||
if (!window.MutationObserver) return;
|
if (!window.MutationObserver) return;
|
||||||
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] };
|
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-ls-pestana'] };
|
||||||
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
|
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
|
||||||
if (document.body) new MutationObserver(report).observe(document.body, conf);
|
if (document.body) new MutationObserver(report).observe(document.body, conf);
|
||||||
}
|
}
|
||||||
@@ -421,7 +476,8 @@ const INIT_JS_TPL: &str = r#"
|
|||||||
if (!u) return;
|
if (!u) return;
|
||||||
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
|
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
|
||||||
var mod = e.metaKey || e.ctrlKey;
|
var mod = e.metaKey || e.ctrlKey;
|
||||||
if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); }
|
var nombre = mod ? null : nombreVentana(a.target);
|
||||||
|
if (isApp(u) && (mod || a.target === '_blank' || nombre)) { e.preventDefault(); newTab(u.href, nombre); }
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
// Clic con la rueda (botón central) en un enlace interno → pestaña nueva, como en el navegador.
|
// Clic con la rueda (botón central) en un enlace interno → pestaña nueva, como en el navegador.
|
||||||
@@ -438,7 +494,7 @@ const INIT_JS_TPL: &str = r#"
|
|||||||
window.open = function (url, name, feats) {
|
window.open = function (url, name, feats) {
|
||||||
var u = url ? abs(url) : null;
|
var u = url ? abs(url) : null;
|
||||||
if (isExt(u)) { openExternal(u.href); return null; }
|
if (isExt(u)) { openExternal(u.href); return null; }
|
||||||
if (isInterno(u)) { newTab(u.href); return null; }
|
if (isInterno(u)) { newTab(u.href, nombreVentana(name)); return null; }
|
||||||
return _open ? _open.call(window, url, name, feats) : null;
|
return _open ? _open.call(window, url, name, feats) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -641,7 +697,7 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
|
|||||||
.tabs
|
.tabs
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
|
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color, "pagina": t.pagina }))
|
||||||
.collect();
|
.collect();
|
||||||
json!({
|
json!({
|
||||||
"tabs": tabs,
|
"tabs": tabs,
|
||||||
@@ -814,12 +870,17 @@ fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path:
|
|||||||
|
|
||||||
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
|
// 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) {
|
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
|
||||||
|
crear_pestana(app, url, activate, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lo mismo, con su nombre de ventana (ver `reusar_pestana`). Devuelve la etiqueta de la nueva.
|
||||||
|
fn crear_pestana<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool, nombre: Option<String>) -> Option<String> {
|
||||||
let target = url.unwrap_or_else(|| SERVER_URL.to_string());
|
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.
|
// 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 parsed = Url::parse(&target).ok()?;
|
||||||
let Ok(server) = Url::parse(SERVER_URL) else { return };
|
let server = Url::parse(SERVER_URL).ok()?;
|
||||||
if !es_pestana_interna(&parsed, &server) {
|
if !es_pestana_interna(&parsed, &server) {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let label = {
|
let label = {
|
||||||
@@ -827,7 +888,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
|||||||
let mut s = shell.lock().unwrap();
|
let mut s = shell.lock().unwrap();
|
||||||
let label = format!("tab-{}", s.next_id);
|
let label = format!("tab-{}", s.next_id);
|
||||||
s.next_id += 1;
|
s.next_id += 1;
|
||||||
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false, descartada: false });
|
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false, descartada: false, pagina: None, nombre });
|
||||||
label
|
label
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -839,6 +900,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
|||||||
broadcast(app);
|
broadcast(app);
|
||||||
save_persisted(app);
|
save_persisted(app);
|
||||||
}
|
}
|
||||||
|
Some(label)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
salud::anotar("ERROR", "pestaña", &format!("no se pudo crear el webview: {e}"));
|
salud::anotar("ERROR", "pestaña", &format!("no se pudo crear el webview: {e}"));
|
||||||
@@ -846,10 +908,46 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
|||||||
let shell = app.state::<ShellState>();
|
let shell = app.state::<ShellState>();
|
||||||
let mut s = shell.lock().unwrap();
|
let mut s = shell.lock().unwrap();
|
||||||
s.tabs.retain(|t| t.label != label);
|
s.tabs.retain(|t| t.label != label);
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pestañas con NOMBRE (1.21) ──
|
||||||
|
// `window.open(url, "ls-edicion-<set>")` (el modo edición de un set) abre la pestaña la primera
|
||||||
|
// vez; las siguientes llevan a ESA pestaña, como hace el navegador con una ventana con nombre, en
|
||||||
|
// vez de amontonar una por foto. A la página se le pasa la URL nueva por `__lsAbrir` (el modo
|
||||||
|
// edición cambia de foto sin recargar, guardando lo de la que tenía); si no lo conoce, navega.
|
||||||
|
// Devuelve false si no hay pestaña con ese nombre (entonces se crea).
|
||||||
|
fn reusar_pestana<R: Runtime>(app: &AppHandle<R>, nombre: &str, url: Option<&str>) -> bool {
|
||||||
|
let Ok(server) = Url::parse(SERVER_URL) else { return false };
|
||||||
|
let destino = match url.map(Url::parse) {
|
||||||
|
None => None,
|
||||||
|
Some(Ok(u)) if es_pestana_interna(&u, &server) => Some(u),
|
||||||
|
Some(_) => return false,
|
||||||
|
};
|
||||||
|
let (label, descartada) = {
|
||||||
|
let shell = app.state::<ShellState>();
|
||||||
|
let mut s = shell.lock().unwrap();
|
||||||
|
let Some(t) = s.tabs.iter_mut().find(|t| t.nombre.as_deref() == Some(nombre)) else { return false };
|
||||||
|
// Descartada (sin webview): se crea ya en la URL nueva al activarla.
|
||||||
|
if t.descartada {
|
||||||
|
if let Some(d) = &destino {
|
||||||
|
t.url = d.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(t.label.clone(), t.descartada)
|
||||||
|
};
|
||||||
|
activate_tab(app, &label);
|
||||||
|
if let (Some(d), false) = (destino, descartada) {
|
||||||
|
if let Some(wv) = get_webview(app, &label) {
|
||||||
|
let u = serde_json::to_string(d.as_str()).unwrap_or_else(|_| "\"\"".into());
|
||||||
|
let _ = wv.eval(format!("(function(u){{try{{if(window.__lsAbrir&&window.__lsAbrir(u))return}}catch(e){{}}location.assign(u)}})({u})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
// Crea el webview de una pestaña: una nueva (create_tab) o una descartada que vuelve
|
// Crea el webview de una pestaña: una nueva (create_tab) o una descartada que vuelve
|
||||||
// (revivir_pestana, al activarla). Lo vigila salud.rs: si su proceso muere, se recarga sola.
|
// (revivir_pestana, al activarla). Lo vigila salud.rs: si su proceso muere, se recarga sola.
|
||||||
fn montar_webview<R: Runtime>(app: &AppHandle<R>, label: &str, parsed: Url) -> tauri::Result<Webview<R>> {
|
fn montar_webview<R: Runtime>(app: &AppHandle<R>, label: &str, parsed: Url) -> tauri::Result<Webview<R>> {
|
||||||
@@ -935,7 +1033,7 @@ fn registrar_diferida<R: Runtime>(app: &AppHandle<R>, url: &str, title: &str) {
|
|||||||
let label = format!("tab-{}", s.next_id);
|
let label = format!("tab-{}", s.next_id);
|
||||||
s.next_id += 1;
|
s.next_id += 1;
|
||||||
let title = if title.is_empty() { "Labstream OS".to_string() } else { title.to_string() };
|
let title = if title.is_empty() { "Labstream OS".to_string() } else { title.to_string() };
|
||||||
s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true });
|
s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true, pagina: None, nombre: None });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vuelve a crear el webview de una pestaña descartada, en su última URL. Devuelve false si no se
|
// Vuelve a crear el webview de una pestaña descartada, en su última URL. Devuelve false si no se
|
||||||
@@ -1815,7 +1913,7 @@ pub fn run() {
|
|||||||
// registran descartadas (título, URL y color en la barra) y se crean al hacer clic en
|
// registran descartadas (título, URL y color en la barra) y se crean al hacer clic en
|
||||||
// ellas, como hacen Chrome y Edge. Antes se cargaban todas a la vez: con 5 pestañas, 5
|
// ellas, como hacen Chrome y Edge. Antes se cargaban todas a la vez: con 5 pestañas, 5
|
||||||
// copias enteras de la web app arrancando juntas.
|
// copias enteras de la web app arrancando juntas.
|
||||||
let restore: Vec<(String, String, String)> = {
|
let restore: Vec<(String, String, String, String, String)> = {
|
||||||
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
|
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
|
||||||
saved
|
saved
|
||||||
.tabs
|
.tabs
|
||||||
@@ -1824,7 +1922,8 @@ pub fn run() {
|
|||||||
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
|
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
|
||||||
.take(12)
|
.take(12)
|
||||||
.map(|(i, u)| {
|
.map(|(i, u)| {
|
||||||
(u.clone(), saved.colors.get(i).cloned().unwrap_or_default(), saved.titles.get(i).cloned().unwrap_or_default())
|
let de = |v: &Vec<String>| v.get(i).cloned().unwrap_or_default();
|
||||||
|
(u.clone(), de(&saved.colors), de(&saved.titles), de(&saved.paginas), de(&saved.nombres))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
@@ -1832,21 +1931,21 @@ pub fn run() {
|
|||||||
create_tab(&handle, None, true);
|
create_tab(&handle, None, true);
|
||||||
} else {
|
} else {
|
||||||
let idx = saved.active.min(restore.len() - 1);
|
let idx = saved.active.min(restore.len() - 1);
|
||||||
for (i, (url, color, title)) in restore.iter().enumerate() {
|
for (i, (url, color, title, pagina, nombre)) in restore.iter().enumerate() {
|
||||||
if i == idx {
|
if i == idx {
|
||||||
create_tab(&handle, Some(url.clone()), false);
|
create_tab(&handle, Some(url.clone()), false);
|
||||||
} else {
|
} else {
|
||||||
registrar_diferida(&handle, url, title);
|
registrar_diferida(&handle, url, title);
|
||||||
}
|
}
|
||||||
if !color.is_empty() {
|
// La pestaña se acaba de empujar al final de la lista.
|
||||||
// La pestaña se acaba de empujar al final de la lista.
|
let shell = handle.state::<ShellState>();
|
||||||
let shell = handle.state::<ShellState>();
|
let mut s = shell.lock().unwrap();
|
||||||
let mut s = shell.lock().unwrap();
|
if let Some(t) = s.tabs.last_mut().filter(|t| t.url == *url) {
|
||||||
if let Some(t) = s.tabs.last_mut() {
|
if !color.is_empty() {
|
||||||
if t.url == *url {
|
t.color = Some(color.clone());
|
||||||
t.color = Some(color.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
t.pagina = color_de_pagina(pagina);
|
||||||
|
t.nombre = nombre_de_ventana(nombre);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let label = {
|
let label = {
|
||||||
@@ -1881,7 +1980,13 @@ pub fn run() {
|
|||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
handle.listen_any("ls-new-tab", move |e| {
|
handle.listen_any("ls-new-tab", move |e| {
|
||||||
let url = field(e.payload(), "url");
|
let url = field(e.payload(), "url");
|
||||||
create_tab(&h, url, true);
|
let nombre = field(e.payload(), "name").and_then(|n| nombre_de_ventana(&n));
|
||||||
|
if let Some(n) = &nombre {
|
||||||
|
if reusar_pestana(&h, n, url.as_deref()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crear_pestana(&h, url, true, nombre);
|
||||||
});
|
});
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
handle.listen_any("ls-activate", move |e| {
|
handle.listen_any("ls-activate", move |e| {
|
||||||
@@ -1982,11 +2087,12 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
handle.listen_any("ls-title", move |e| {
|
handle.listen_any("ls-title", move |e| {
|
||||||
let (label, title, url, dark) = (
|
let (label, title, url, dark, pestana) = (
|
||||||
field(e.payload(), "label"),
|
field(e.payload(), "label"),
|
||||||
field(e.payload(), "title"),
|
field(e.payload(), "title"),
|
||||||
field(e.payload(), "url"),
|
field(e.payload(), "url"),
|
||||||
field_bool(e.payload(), "dark"),
|
field_bool(e.payload(), "dark"),
|
||||||
|
field(e.payload(), "pestana"),
|
||||||
);
|
);
|
||||||
if let Some(label) = label {
|
if let Some(label) = label {
|
||||||
let shell = h.state::<ShellState>();
|
let shell = h.state::<ShellState>();
|
||||||
@@ -1998,6 +2104,10 @@ pub fn run() {
|
|||||||
if let Some(url) = url {
|
if let Some(url) = url {
|
||||||
t.url = url;
|
t.url = url;
|
||||||
}
|
}
|
||||||
|
// (una página de antes de la 1.21 no lo manda: se deja como estaba)
|
||||||
|
if let Some(p) = pestana {
|
||||||
|
t.pagina = color_de_pagina(&p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// El tema es global (todas las pestañas del origen comparten modo): el
|
// El tema es global (todas las pestañas del origen comparten modo): el
|
||||||
// último reporte manda, venga de la pestaña que venga.
|
// último reporte manda, venga de la pestaña que venga.
|
||||||
|
|||||||
@@ -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.20.0",
|
"version": "1.21.0",
|
||||||
"identifier": "co.labstream.os",
|
"identifier": "co.labstream.os",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user