v1.21.0: la pestaña del color de su página y pestañas con nombre

Para el modo edición de los sets de fotos de Labstream OS, que se abre en su
propia pestaña:

- Color de la página: una página pide el color de su pestaña con
  <html data-ls-pestana="#rrggbb">. El script de la pestaña lo manda en
  ls-title (y lo vigila con el mismo observador del tema); Rust solo acepta
  #rrggbb (color_de_pagina) y la barra pinta la pestaña de ese color, con letra
  clara u oscura según su luminancia. Activa se funde con la barra de arriba de
  la página; detrás conserva el color. Un color del menú encima va de franja.
  Una página con color propio no manda el tema: su fondo oscuro no es el de la
  app y no debe oscurecer la barra.
- Pestañas con nombre: window.open(url, "ls-…") o target="ls-…" abre la
  pestaña la primera vez; las siguientes activan ESA y le pasan la URL por
  window.__lsAbrir (el modo edición cambia de foto sin recargar); sin él,
  navega. Una descartada se revive ya en la URL nueva. Solo nombres ls-
  alfanuméricos (nombre_de_ventana); los demás abren pestaña como siempre.
- Color de página y nombre se guardan con la sesión (paginas, nombres en
  estado.json; un estado viejo carga igual).

Prueba nueva: la_pagina_solo_pide_colores_y_nombres_de_la_app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Munka
2026-10-14 17:33:20 -05:00
padre d3216c10ef
commit 6539f6939c
Se han modificado 7 ficheros con 177 adiciones y 32 borrados

Ver fichero

@@ -81,6 +81,19 @@ fn es_pestana_interna(target: &Url, server: &Url) -> bool {
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)]
mod navigation_tests {
use super::*;
@@ -95,6 +108,20 @@ mod navigation_tests {
assert!(is_google_host("docs.google.com"));
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 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
// barra con título, URL y color, y se crea otra vez al volver a ella.
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
@@ -198,6 +231,12 @@ struct Persisted {
// cargar (ver `registrar_diferida`) y la barra necesita algo que enseñar mientras tanto.
#[serde(default)]
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)]
active: usize,
#[serde(default)]
@@ -230,6 +269,8 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).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,
autostart: s.autostart,
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 }); }
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) ──
// 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; }
}
// ── 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).
// 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() {
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href, pestana: colorPestana() };
if (!carga.pestana) {
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
}
emit('ls-title', carga);
}
function watchTitle() {
@@ -404,7 +459,7 @@ const INIT_JS_TPL: &str = r#"
// por si el tema aterriza tarde con la hidratación.
function vigilarTema() {
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.body) new MutationObserver(report).observe(document.body, conf);
}
@@ -421,7 +476,8 @@ const INIT_JS_TPL: &str = r#"
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); }
var nombre = mod ? null : nombreVentana(a.target);
if (isApp(u) && (mod || a.target === '_blank' || nombre)) { e.preventDefault(); newTab(u.href, nombre); }
}, true);
// 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) {
var u = url ? abs(url) : 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;
};
@@ -641,7 +697,7 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
.tabs
.iter()
.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();
json!({
"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).
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());
// 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 };
let parsed = Url::parse(&target).ok()?;
let server = Url::parse(SERVER_URL).ok()?;
if !es_pestana_interna(&parsed, &server) {
return;
return None;
}
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 label = format!("tab-{}", s.next_id);
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
};
@@ -839,6 +900,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
broadcast(app);
save_persisted(app);
}
Some(label)
}
Err(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 mut s = shell.lock().unwrap();
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
// (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>> {
@@ -935,7 +1033,7 @@ fn registrar_diferida<R: Runtime>(app: &AppHandle<R>, url: &str, title: &str) {
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
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
@@ -1815,7 +1913,7 @@ pub fn run() {
// 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
// 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");
saved
.tabs
@@ -1824,7 +1922,8 @@ pub fn run() {
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12)
.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()
};
@@ -1832,21 +1931,21 @@ pub fn run() {
create_tab(&handle, None, true);
} else {
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 {
create_tab(&handle, Some(url.clone()), false);
} else {
registrar_diferida(&handle, url, title);
}
if !color.is_empty() {
// La pestaña se acaba de empujar al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut() {
if t.url == *url {
t.color = Some(color.clone());
}
// La pestaña se acaba de empujar al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut().filter(|t| t.url == *url) {
if !color.is_empty() {
t.color = Some(color.clone());
}
t.pagina = color_de_pagina(pagina);
t.nombre = nombre_de_ventana(nombre);
}
}
let label = {
@@ -1881,7 +1980,13 @@ pub fn run() {
let h = handle.clone();
handle.listen_any("ls-new-tab", move |e| {
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();
handle.listen_any("ls-activate", move |e| {
@@ -1982,11 +2087,12 @@ pub fn run() {
});
let h = handle.clone();
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(), "title"),
field(e.payload(), "url"),
field_bool(e.payload(), "dark"),
field(e.payload(), "pestana"),
);
if let Some(label) = label {
let shell = h.state::<ShellState>();
@@ -1998,6 +2104,10 @@ pub fn run() {
if let Some(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
// último reporte manda, venga de la pestaña que venga.