Comparar commits

...

9 Commits

Autor SHA1 Mensaje Fecha
Munka
3798970f74 v1.7.1: el detector de tema deja de creer y empieza a mirar
La v1.7.0 preguntaba por la clase `dark` de <html> y punto. En este
mismo PC quedó la prueba de que eso miente en WebView2: estado.json
con "dark": false y la app pintada de negro — barra clara sobre app
oscura, el bug al revés.

Ahora el reporte es a prueba de balas:

- temaOscuro(): clase `dark` en <html> o <body>; si no está, la
  LUMINANCIA del fondo realmente pintado del body (funciona sea cual
  sea el mecanismo de tema); si tampoco, el color-scheme computado.
  Con el documento a medio crear devuelve null y el campo NO viaja
  (Rust conserva el último valor bueno) — nada revienta en frío.
- Vigilancia segura: MutationObserver sobre <html> Y <body>
  (class/style/data-theme) registrado cuando existen, y dos
  re-reportes tras la carga por si el tema aterriza con la
  hidratación.

Rust y la barra quedan como en v1.7.0 (un campo `dark` ausente ya
significaba «conserva lo último»). El estado.json mentiroso se
corrige solo con el primer reporte bueno.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:45:01 -05:00
Munka
29f0b47371 Version 1.7.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:36:46 -05:00
Munka
b18c155b89 La barra obedece a la app, no a Windows: tema real + acento naranja
El bug de «siempre oscura»: la web decide su modo con su propio botón
(next-themes, defaultTheme claro, sin seguir al sistema), pero la barra
elegía tema con prefers-color-scheme — el del SISTEMA. Con Windows en
oscuro y la app en claro, franja negra sobre app blanca, siempre. La
v1.6.1 pintó bien ambos temas, pero la barra seguía eligiendo el
equivocado.

El arreglo, de punta a punta:

- El script inyectado reporta en ls-title si <html> lleva la clase
  `dark`, con un MutationObserver para que la luna se refleje al
  instante. La guarda de marco y origen queda intacta (OnlyOffice).
- Rust lo guarda (global: todas las pestañas comparten modo), lo
  reenvía en ls-tabs (`dark`) y lo PERSISTE en estado.json: la app
  abre ya del color correcto, sin fogonazo.
- La barra se pone la clase `oscuro`/`claro` al recibirlo; las media
  queries quedan solo como arranque en frío (primer arranque sin
  estado.json). Los tintes de colores de pestaña cuelgan del mismo
  interruptor.
- El acento pasa de índigo #6366f1 (comentado «color de Labstream»,
  que no lo era) al naranja de marca #F47A20: aro de carga, zoom
  activo y hover de la versión.

Verificado en navegador con el CSS y el JS reales, simulando el
payload de Rust: app clara + sistema oscuro → barra clara; app
oscura → negra; sin reporte → cae al sistema. El Rust lo compila el
CI de rama (este equipo no tiene cargo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:22:46 -05:00
Munka
424f401ad6 Versión 1.6.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:58:00 -05:00
Munka
f6eea75404 La barra se pinta del color de la app: blanca en claro, negra en oscuro
Venía con el gris de Chrome (#dee1e6 / #202124) en los dos temas, y encima de una
app negra se veía como una franja de navegador pegada a la ventana en vez de parte
de ella. Ahora la franja es casi blanca en tema claro y negra en oscuro, del tono de
la app.

El truco de Chrome se conserva —la pestaña activa es una tarjeta del color del
CONTENIDO y se funde con él—, solo que en oscuro el contraste va al revés: la franja
queda MÁS negra que el contenido, así que la activa destaca por ser más clara. Quien
marca el corte de verdad es el borde inferior, que la activa tapa con su fondo.

De paso, dos ajustes que arrastraba el cambio:

- El hover de las pestañas inactivas en tema claro era blanco al 55%: sobre una
  franja casi blanca no se habría visto nada. Ahora oscurece.
- El ancho máximo de pestaña sube de 240 a 280 px. Los títulos ahora traen nombre
  real y sección («Hotjamin Orgánicos · Archivos») y a 240 se cortaban de más. Con
  muchas pestañas abiertas no cambia nada: mandan el reparto elástico y el modo
  compacto de solo icono.

Verificado: sintaxis del script con node --check y la barra montada en el navegador
con el CSS real, en los dos temas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:45:38 -05:00
Munka
44898da17c Versión 1.6.0 y README al día
Sube la versión en los cuatro sitios que tienen que coincidir (package.json,
Cargo.toml, Cargo.lock y tauri.conf.json). Así el instalador de prueba se presenta
como v1.6.0 en la barra en vez de repetir v1.5.1, que al probarlo encima de la app
instalada no dejaba saber cuál se está corriendo.

El README describía el comportamiento viejo: decía que en Windows se conserva el
título del sistema con la barra debajo, y que la actualización automática era un
paso futuro cuando lleva funcionando desde la v1.4.0. Ahora cuenta lo que hay: la
barra como fila del título en ambos sistemas, los colores de pestaña, que un commit
sin etiqueta no le llega a nadie, y cómo probar un cambio con el instalador de
prueba del CI sin publicárselo al equipo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:19:02 -05:00
Munka
2c872ace7d Pestañas de colores, como los grupos de Chrome
Con la barra ya en la fila del título, teñir es lo que hace que un montón de
pestañas abiertas se lea de un vistazo: todo lo de un cliente en verde, lo que está
en revisión en rojo. Se elige con clic derecho sobre la pestaña ▸ Color, con marca
en el que tiene puesto, y "Sin color" para quitarlo.

Nueve colores, los mismos que usa Chrome para sus grupos. El color se guarda con la
sesión (lista `colors` en estado.json, paralela a `tabs`), así que sobrevive a
cerrar la app. Un estado.json viejo sin colores carga igual: la lista entra por
serde default y todas las pestañas salen sin teñir.

Cómo se pinta: la inactiva lleva el color de fondo translúcido y se le quita el
separador vertical (con fondo propio ya se ve dónde acaba). La ACTIVA no se tiñe
entera —tiene que seguir fundida con el contenido, que es lo que le da el aspecto de
Chrome— sino que lleva el color en una franja de 3 px arriba. En el CSS el color va
en componentes RGB sueltos (`--tc: r, g, b`) para sacar de una sola definición tanto
el tono sólido como el fondo translúcido, en vez de repetir nueve colores por tema.

Al restaurar la sesión, el color se empareja con su URL ANTES de filtrar por origen:
el filtro puede descartar entradas y entonces los índices de las dos listas dejarían
de casar, y las pestañas saldrían con el color de otra.

El id que llega del menú se valida contra la paleta antes de guardarlo; uno
desconocido deja la pestaña sin color en vez de escribir cualquier cosa en el disco.

Verificado: sintaxis del script con node --check, y en el navegador que la inactiva
se tiñe, que la activa conserva su fondo de tarjeta con la franja del color encima,
y que el separador desaparece en las teñidas. La compilación la verifica el CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:39:10 -05:00
Munka
fb4a441fa0 El CI de rama deja también un instalador de prueba
Verificar que compila no basta para un cambio que se ve: la barra de pestañas hay
que MIRARLA. El job nuevo construye el .exe de Windows y lo cuelga de la ejecución
como artifact, sin tocar Releases ni el updater. Así se puede probar el cambio en la
app de verdad antes de publicárselo al equipo, que en la práctica no tiene vuelta
atrás porque las apps instaladas se actualizan solas.

Firma con la clave del repo porque tauri.conf.json pide artefactos de updater; el
.exe se queda en el artifact igualmente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:51:56 -05:00
Munka
9489d6cc58 Las pestañas ocupan la fila del título también en Windows
En Windows la ventana conservaba la barra de título del sistema y la barra de
pestañas iba DEBAJO: una fila entera gastada en repetir el nombre de la app. En
macOS ya no pasaba (TitleBarStyle::Overlay desde la v1.4.2). Ahora las dos se
comportan igual, y como Chrome: la barra de pestañas ES la fila del título.

- Ventana sin decoración en Windows (decorations(false)). resizable(true) conserva
  WS_THICKFRAME, así que los bordes siguen redimensionando y Aero Snap (arrastrar a
  un borde, Win+flechas) sigue vivo.
- La barra dibuja sus propios botones minimizar/maximizar/cerrar con las medidas y
  colores del sistema (46 px de ancho, alto completo de la barra, esquinas rectas,
  rojo #c42b1c al posarse en el aspa) y los glifos de Segoe Fluent Icons. Actúan
  por el evento nuevo ls-window; cerrar sigue pasando por CloseRequested, o sea que
  oculta a la bandeja igual que antes.
- El icono del medio alterna maximizar/restaurar según el estado. El repintado se
  dispara solo cuando ese estado CAMBIA, no en cada píxel del redimensionado.
- La geometría maximizada ya no se guarda entre sesiones: sin decoración, al
  reabrir volvería como ventana normal del tamaño de la pantalla y taparía la barra
  de tareas. Se conserva la última geometría restaurada.
- Doble clic en el hueco libre de la barra ahora maximiza/restaura (lo gobierna
  data-tauri-drag-region), como la barra de título del sistema y como Chrome. Antes
  abría una pestaña nueva; para eso quedan el botón + y Cmd/Ctrl+T.

NO incluye los Snap Layouts de Windows 11 (el menú que asoma al posar el cursor
sobre maximizar). Requieren que la ventana conteste WM_NCHITTEST con HTMAXBUTTON, y
aquí ese botón lo tapa el webview hijo de la barra, que se queda con los mensajes
del ratón: la ventana principal nunca los ve. Es la misma razón por la que Electron
necesita botones NATIVOS (titleBarOverlay) para poder ofrecerlo. El resto de Aero
Snap no se ve afectado.

Se añade .github/workflows/check.yml: cargo check en Windows y macOS al empujar una
rama, sin publicar nada. Hasta ahora solo existía el workflow de publicar (etiquetas
v*), así que no había forma de comprobar que algo compilaba sin sacar un release.

Verificado: sintaxis del script de la barra con node --check; medidas, clases y
estados de los botones comprobados en el navegador (46 px, pegados al borde, ocultos
en macOS, icono y rótulo correctos al maximizar, y el arrastre solo en el hueco
libre, nunca en los botones). La compilación la verifica el workflow nuevo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:35:10 -05:00
Se han modificado 8 ficheros con 481 adiciones y 58 borrados

102
.github/workflows/check.yml vendido Archivo normal
Ver fichero

@@ -0,0 +1,102 @@
name: Verificar compilación (sin publicar)
# Comprueba que el shell COMPILA en Windows y macOS sin construir instaladores ni tocar
# Releases. Sirve para revisar un cambio en una rama antes de etiquetar la versión: el
# workflow de publicar solo se dispara con etiquetas `v*`, así que hasta ahora no había
# forma de saber si algo compilaba sin publicarlo.
on:
push:
branches-ignore:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read # solo lee el repo: no crea releases ni sube nada
jobs:
check:
strategy:
fail-fast: false
matrix:
platform: [windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Clonar repo
uses: actions/checkout@v4
- name: Instalar Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Instalar Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache de Rust
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Instalar dependencias
run: npm install
# build.rs de Tauri valida que existan los iconos que declara tauri.conf.json.
- name: Generar iconos (desde app-icon.png)
run: npm run icons
- name: cargo check
working-directory: src-tauri
run: cargo check --all-targets
# Avisa de errores reales de Clippy sin tumbar la verificación por avisos de estilo.
- name: cargo clippy (informativo)
working-directory: src-tauri
continue-on-error: true
run: cargo clippy --all-targets
# Instalador de PRUEBA para Windows. Queda colgado de la ejecución como artifact y no
# toca Releases ni el updater: sirve para VER el cambio funcionando antes de publicárselo
# a todo el equipo, que es irreversible en la práctica (las apps se actualizan solas).
instalador-prueba:
runs-on: windows-latest
steps:
- name: Clonar repo
uses: actions/checkout@v4
- name: Instalar Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Instalar Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache de Rust
uses: swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Instalar dependencias
run: npm install
- name: Generar iconos (desde app-icon.png)
run: npm run icons
# La firma hace falta porque tauri.conf.json pide artefactos de updater; el .exe se
# queda igualmente aquí, sin publicarse en ningún sitio.
- name: Construir el instalador (NSIS)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --bundles nsis
- name: Guardar el instalador como artifact
uses: actions/upload-artifact@v4
with:
name: instalador-windows-prueba
path: src-tauri/target/release/bundle/nsis/*.exe
retention-days: 14

Ver fichero

@@ -12,6 +12,14 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
la app** — antes morían en silencio. También ⌘/Ctrl+clic y clic central. la app** — antes morían en silencio. También ⌘/Ctrl+clic y clic central.
Atajos: ⌘/Ctrl+T nueva, ⌘/Ctrl+W cerrar, Ctrl+Tab rotar, ⌘/Ctrl+1…9 ir a la n. 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. La sesión (pestañas abiertas, activa) **se restaura** al reabrir.
- **La barra de pestañas ES la fila del título** (v1.6.0), en Windows y en macOS:
no se gasta una franja entera en repetir el nombre de la app. En Windows la
ventana va sin decoración y la propia barra dibuja los botones minimizar /
maximizar / cerrar; en macOS el semáforo lo sigue pintando el sistema y la barra
le deja su hueco. El doble clic en el hueco libre maximiza y restaura.
- **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
de un vistazo (todo lo de un cliente en verde) y **se guarda 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
@@ -133,22 +141,29 @@ el de Mac a la vez, así ambas plataformas van siempre sincronizadas.
--- ---
## Actualización automática (opcional, recomendable a futuro) ## Actualización automática (ACTIVA)
Hoy, para actualizar, el equipo descarga el nuevo instalador del Release y lo Las apps ya instaladas **se actualizan solas**: el updater consulta el `latest.json`
reinstala encima. Para que las apps ya instaladas se actualicen solas, Tauri trae que publica cada Release, y los instaladores se firman en el CI con la clave privada
un *updater* (funciona igual en Windows y Mac). Para activarlo (paso futuro): guardada como secret del repo (`TAURI_SIGNING_PRIVATE_KEY`). También se puede forzar
la comprobación con clic en la versión de la barra, o desde el menú ⋮ ▸ *Buscar
actualización…*.
1. Genera el par de claves de firma: > ⚠️ De ahí se sigue algo que conviene tener presente: **un commit en `main` no le
```bash > llega a nadie**. Mientras no exista la etiqueta `vX.Y.Z`, el CI no construye nada y
npm run tauri signer generate -- -w ~/.tauri/labstream.key > el updater no ve versión nueva. Y al revés: publicar una etiqueta **actualiza la app
``` > de todo el equipo**, así que no es un paso que se dé a la ligera.
2. Añade el `plugin-updater` y la clave pública en `tauri.conf.json`, y guarda la
clave privada como **secret** en GitHub (`TAURI_SIGNING_PRIVATE_KEY`).
3. Apunta el updater al `latest.json` que publica el Release.
Se deja sin activar a propósito para que la **primera** versión compile sin ## Probar un cambio antes de publicarlo
configuración extra.
`.github/workflows/check.yml` corre en cualquier rama que no sea `main` y **no publica
nada**:
- `cargo check` en Windows y macOS — comprueba que compila en las dos.
- Un job aparte deja el **instalador `.exe` de prueba** como *artifact* de la ejecución
(14 días), para instalarlo encima y ver el cambio funcionando de verdad.
Es la forma de mirar un cambio visual —la barra, los colores— sin sacárselo al equipo.
--- ---

144
dist/index.html vendido
Ver fichero

@@ -7,7 +7,8 @@
<!-- <!--
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}], zoom, version, mac} recibe ls-tabs {tabs:[{label,title,url,loading,active,color}], zoom, version,
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
En macOS la ventana lleva el título en OVERLAY: esta barra ES la fila del título, así que En macOS la ventana lleva el título en OVERLAY: esta barra ES la fila del título, así que
@@ -15,33 +16,60 @@
(`data-tauri-drag-region`). En Windows hay título nativo aparte y no se reserva nada. (`data-tauri-drag-region`). En Windows hay título nativo aparte y no se reserva nada.
--> -->
<style> <style>
/* Paleta estilo Chrome: la barra es una FRANJA más oscura y la pestaña activa es una /* La barra se pinta del color de la APP, no del gris de Chrome: blanca en tema claro y
tarjeta del color del contenido que se funde con él (sin línea debajo). */ negra en oscuro, para que la ventana se lea como una sola pieza y no como una franja
de navegador pegada encima.
El truco de Chrome se conserva igual —la pestaña activa es una tarjeta del color del
CONTENIDO que se funde con él—, solo que ahora el contraste va al revés en oscuro: la
franja es más negra que el contenido, así que la activa destaca por ser más clara. La
diferencia es sutil a propósito; quien la marca de verdad es el borde inferior, que la
pestaña activa tapa con su propio fondo. */
:root { :root {
--bg: #dee1e6; /* franja de pestañas (Chrome claro) */ --bg: #f4f5f7; /* franja: casi blanca, del tono de la app en claro */
--fg: #45474a; --fg: #45474a;
--muted: #5f6368; --muted: #5f6368;
--tab-active-bg: #ffffff; --tab-active-bg: #ffffff; /* mismo fondo que la app → pestaña fundida con el contenido */
--tab-active-fg: #202124; --tab-active-fg: #202124;
--accent: #6366f1; /* indigo-500, color de Labstream */ --accent: #F47A20; /* NARANJA Labstream (el índigo de antes no era de la marca) */
--border: #c9ccd1; /* línea bajo la barra (la tapa la pestaña activa) */ --border: #dcdee1; /* línea bajo la barra (la tapa la pestaña activa) */
--hover: rgba(255, 255, 255, 0.55); /* píldora de hover en pestañas inactivas */ /* Sobre una franja casi blanca, un hover blanco no se vería: aquí oscurece. */
--hover: rgba(0, 0, 0, 0.06);
--btn-hover: rgba(0, 0, 0, 0.08); --btn-hover: rgba(0, 0, 0, 0.08);
--sep: rgba(0, 0, 0, 0.22); /* separador fino entre pestañas inactivas */ --sep: rgba(0, 0, 0, 0.22); /* separador fino entre pestañas inactivas */
} }
/* ── Quién decide el tema ──
La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su
<html>, defaultTheme claro, sin seguir al sistema), así que obedecer aquí al
prefers-color-scheme del sistema pintaba la barra negra sobre una app clara — el
bug de siempre. El script inyectado reporta el tema real, Rust lo reenvía en
`ls-tabs` (`dark`) y este documento se pone la clase `oscuro` o `claro`.
El bloque de medios queda SOLO como arranque en frío (primer arranque, sin
estado.json y sin reporte aún): en cuanto hay clase, la clase manda.
OJO: los valores oscuros van DOS veces (media query y .oscuro) — cambiarlos a la par. */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not(.claro) {
--bg: #202124; /* franja (Chrome oscuro) */ --bg: #0d0d0f; /* franja: negra, más oscura que el contenido */
--fg: #bdc1c6; --fg: #bdc1c6;
--muted: #80868b; --muted: #80868b;
--tab-active-bg: #18181b; /* mismo fondo que la app → pestaña fundida con el contenido */ --tab-active-bg: #18181b; /* mismo fondo que la app → pestaña fundida con el contenido */
--tab-active-fg: #e8eaed; --tab-active-fg: #e8eaed;
--border: #101013; --border: #26262a;
--hover: rgba(255, 255, 255, 0.08); --hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1); --btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25); --sep: rgba(255, 255, 255, 0.25);
} }
} }
:root.oscuro {
--bg: #0d0d0f;
--fg: #bdc1c6;
--muted: #80868b;
--tab-active-bg: #18181b;
--tab-active-fg: #e8eaed;
--border: #26262a;
--hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { html, body {
margin: 0; margin: 0;
@@ -81,10 +109,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
/* Elástico como en Chrome: se reparten el ancho y se encogen al abrir más. */ /* Elástico como en Chrome: se reparten el ancho y se encogen al abrir más. El tope
sube a 280 px porque los títulos ahora traen nombre real y sección
(«Hotjamin Orgánicos · Archivos»), y a 240 se cortaban de más. Con muchas pestañas
abiertas da igual: mandan el reparto y el modo compacto. */
flex: 1 1 220px; flex: 1 1 220px;
min-width: 42px; min-width: 42px;
max-width: 240px; max-width: 280px;
height: 30px; height: 30px;
margin-bottom: 5px; margin-bottom: 5px;
padding: 0 6px 0 10px; padding: 0 6px 0 10px;
@@ -135,6 +166,34 @@
right: -8px; right: -8px;
background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px); background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px);
} }
/* ── Color de pestaña ────────────────────────────────────────────────────────────
Misma idea que los grupos de Chrome: teñir para agrupar de un vistazo («todo lo de
este cliente, en verde»). El color se elige con clic derecho sobre la pestaña ▸ Color,
y se guarda con la sesión.
Va en componentes RGB sueltos (`--tc: r, g, b`) para sacar de UNA sola definición
tanto el tono sólido como el fondo translúcido, sin repetir nueve colores por tema.
La pestaña ACTIVA no se tiñe entera: tiene que seguir fundida con el contenido, así
que lleva el color en una franja superior — igual que Chrome. */
.tab[data-color="gris"] { --tc: 95, 99, 104; }
.tab[data-color="azul"] { --tc: 26, 115, 232; }
.tab[data-color="rojo"] { --tc: 217, 48, 37; }
.tab[data-color="amarillo"] { --tc: 249, 171, 0; }
.tab[data-color="verde"] { --tc: 30, 142, 62; }
.tab[data-color="rosa"] { --tc: 208, 24, 132; }
.tab[data-color="morado"] { --tc: 147, 52, 230; }
.tab[data-color="cian"] { --tc: 0, 123, 131; }
.tab[data-color="naranja"] { --tc: 250, 144, 62; }
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.32); }
/* Con fondo propio el separador vertical sobra: ya se ve dónde acaba cada pestaña. */
.tab[data-color]:not(.active)::before { display: none; }
.tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
@media (prefers-color-scheme: dark) {
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
:root:not(.claro) .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
}
: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); }
/* 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. */
@@ -233,6 +292,31 @@
border-radius: 13px; border-radius: 13px;
} }
#version:hover { color: var(--accent); } #version:hover { color: var(--accent); }
/* ── Botones de ventana ──────────────────────────────────────────────────────────
Solo en Windows: allí la ventana va SIN decoración, así que esta barra es la fila
del título (como Chrome) y el aspa del sistema no existe. Medidas y colores del
propio Windows 11: 46 px de ancho, alto completo de la barra, esquinas rectas
hasta el borde, y rojo #c42b1c en el aspa al pasar por encima.
Los glifos salen de la fuente de iconos del sistema (Fluent en Win 11, MDL2 en 10). */
#wincontrols { display: none; }
body.win #wincontrols {
display: flex;
flex: 0 0 auto;
align-self: stretch;
margin-left: 6px;
}
/* Los botones tienen que morder el borde derecho: la barra cede su margen. */
body.win #bar { padding-right: 0; }
#wincontrols button {
width: 46px;
border-radius: 0;
color: var(--fg);
font-family: "Segoe Fluent Icons", "Segoe MDL2 Assets", system-ui;
font-size: 10px;
line-height: 1;
}
#wincontrols button:hover { background: var(--btn-hover); }
#w-cerrar:hover { background: #c42b1c; color: #fff; }
</style> </style>
</head> </head>
<body> <body>
@@ -251,6 +335,12 @@
</div> </div>
<button id="version" title="Versión de la app · clic: buscar actualización"></button> <button id="version" title="Versión de la app · clic: buscar actualización"></button>
<button class="icono" id="menu" title="Opciones de la app">⋮</button> <button class="icono" id="menu" title="Opciones de la app">⋮</button>
<!-- Solo visibles en Windows (clase `win` del body): allí no hay barra de título. -->
<div id="wincontrols">
<button id="w-min" title="Minimizar">&#xE921;</button>
<button id="w-max" title="Maximizar">&#xE922;</button>
<button id="w-cerrar" title="Cerrar">&#xE8BB;</button>
</div>
</div> </div>
<script> <script>
const T = window.__TAURI__; const T = window.__TAURI__;
@@ -264,6 +354,7 @@
const $tabs = document.getElementById("tabs"); const $tabs = document.getElementById("tabs");
const $pct = document.getElementById("z-pct"); const $pct = document.getElementById("z-pct");
const $ver = document.getElementById("version"); const $ver = document.getElementById("version");
const $wmax = document.getElementById("w-max");
// Nombre + icono de cada pestaña a partir de su RUTA (mismas etiquetas que la barra // Nombre + icono de cada pestaña a partir de su RUTA (mismas etiquetas que la barra
// superior de la web). Sin esto todas las pestañas se llamaban «(25) Labstream OS» y // superior de la web). Sin esto todas las pestañas se llamaban «(25) Labstream OS» y
@@ -385,6 +476,7 @@
const el = document.createElement("div"); const el = document.createElement("div");
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
el.title = texto; el.title = texto;
const fav = document.createElement("span"); const fav = document.createElement("span");
@@ -439,8 +531,22 @@
$pct.textContent = estado.zoom + "%"; $pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100); $pct.classList.toggle("activo", estado.zoom !== 100);
$ver.textContent = estado.version ? "v" + estado.version : ""; $ver.textContent = estado.version ? "v" + estado.version : "";
// Tema: manda el de la APP (payload `dark`). Mientras Rust no sepa nada (null),
// no se pone clase y siguen las media queries del sistema como arranque en frío.
if (typeof estado.dark === "boolean") {
document.documentElement.classList.toggle("oscuro", estado.dark);
document.documentElement.classList.toggle("claro", !estado.dark);
}
// En macOS la barra ES la fila del título: hay que dejar libre el semáforo. // En macOS la barra ES la fila del título: hay que dejar libre el semáforo.
document.body.classList.toggle("mac", !!estado.mac); document.body.classList.toggle("mac", !!estado.mac);
// En Windows también ES la fila del título, pero ahí los botones de ventana los
// pinta esta barra: el del medio alterna entre maximizar (▢) y restaurar (❐).
document.body.classList.toggle("win", !!estado.win);
if (estado.win) {
const max = !!estado.maximized;
$wmax.textContent = max ? "\uE923" : "\uE922"; // ChromeRestore / ChromeMaximize
$wmax.title = max ? "Restaurar" : "Maximizar";
}
} }
if (E) { if (E) {
@@ -466,10 +572,14 @@
document.getElementById("z-menos").addEventListener("click", () => emitir("ls-zoom", { dir: -1 })); document.getElementById("z-menos").addEventListener("click", () => emitir("ls-zoom", { dir: -1 }));
document.getElementById("z-mas").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 })); $pct.addEventListener("click", () => emitir("ls-zoom", { dir: 0 }));
// Doble clic en el hueco libre de la barra = pestaña nueva. // Botones de ventana (Windows). Cerrar oculta a la bandeja, como el aspa de siempre.
$tabs.addEventListener("dblclick", (e) => { document.getElementById("w-min").addEventListener("click", () => emitir("ls-window", { act: "min" }));
if (e.target === $tabs) emitir("ls-new-tab", {}); $wmax.addEventListener("click", () => emitir("ls-window", { act: "max" }));
}); document.getElementById("w-cerrar").addEventListener("click", () => emitir("ls-window", { act: "close" }));
// El doble clic en el hueco libre lo gobierna `data-tauri-drag-region`: maximiza y
// restaura, como la barra de título del sistema y como la de pestañas de Chrome.
// (Antes abría una pestaña nueva; ahora que la barra ES el título, chocaba con él.
// Para una pestaña nueva quedan el botón + y ⌘/Ctrl+T.)
// Atajos también cuando el foco está en la barra. // Atajos también cuando el foco está en la barra.
addEventListener("keydown", (e) => { addEventListener("keydown", (e) => {

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.5.1", "version": "1.7.1",
"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": {

Ver fichero

@@ -2062,7 +2062,7 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.5.1" version = "1.7.1"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",

Ver fichero

@@ -1,6 +1,6 @@
[package] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.5.1" version = "1.7.1"
description = "Cliente de escritorio de Labstream OS" description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"] authors = ["Labstream SAS"]
edition = "2021" edition = "2021"

Ver fichero

@@ -66,8 +66,25 @@ struct TabRec {
title: String, title: String,
url: String, url: String,
loading: bool, // la barra pinta un giro en el icono mientras carga loading: bool, // la barra pinta un giro en el icono mientras carga
// Color elegido a mano desde el menú de la pestaña (None = sin teñir). Aquí solo vive el
// NOMBRE; el tono exacto y cómo se pinta son cosa del CSS de la barra.
color: Option<String>,
} }
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
// un vistazo («todo lo de este cliente en verde»). El id viaja en el payload de `ls-tabs`.
const TAB_COLORS: &[(&str, &str)] = &[
("gris", "Gris"),
("azul", "Azul"),
("rojo", "Rojo"),
("amarillo", "Amarillo"),
("verde", "Verde"),
("rosa", "Rosa"),
("morado", "Morado"),
("cian", "Cian"),
("naranja", "Naranja"),
];
struct Shell { struct Shell {
tabs: Vec<TabRec>, tabs: Vec<TabRec>,
active: usize, active: usize,
@@ -77,11 +94,18 @@ struct Shell {
autostart: Option<bool>, // elección del usuario en el menú ⋮ (None = por defecto, activado) autostart: Option<bool>, // elección del usuario en el menú ⋮ (None = por defecto, activado)
closed: Vec<String>, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T) closed: Vec<String>, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T)
menu_tab: Option<String>, // pestaña sobre la que se abrió el menú de clic derecho menu_tab: Option<String>, // pestaña sobre la que se abrió el menú de clic derecho
// Último estado maximizado conocido. Solo sirve para no repintar la barra en CADA
// evento de redimensión: únicamente cuando el icono ▢/❐ tiene que cambiar.
maximized: bool,
// Tema REAL de la app (la clase `dark` de <html>, reportada por el script inyectado).
// Es global y no por pestaña: todas las pestañas del origen comparten el mismo modo
// (localStorage). None = ninguna pestaña ha reportado aún → la barra cae al del sistema.
dark: Option<bool>,
} }
impl Default for Shell { impl Default for Shell {
fn default() -> Self { fn default() -> Self {
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None } Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None, maximized: false, dark: None }
} }
} }
@@ -104,10 +128,18 @@ struct Persisted {
win: Option<WinRect>, win: Option<WinRect>,
#[serde(default)] #[serde(default)]
tabs: Vec<String>, tabs: Vec<String>,
// Color de cada pestaña, en el mismo orden que `tabs` ("" = sin teñir). Lista aparte para
// que un estado.json viejo (sin colores) siga cargando tal cual.
#[serde(default)]
colors: Vec<String>,
#[serde(default)] #[serde(default)]
active: usize, active: usize,
#[serde(default)] #[serde(default)]
autostart: Option<bool>, autostart: Option<bool>,
// Último tema conocido de la app: la barra abre ya del color correcto en vez de
// arrancar con el del sistema y dar un fogonazo al cargar la primera pestaña.
#[serde(default)]
dark: Option<bool>,
} }
fn state_path<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> { fn state_path<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
@@ -130,8 +162,10 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
zoom_idx: Some(s.zoom_idx), zoom_idx: Some(s.zoom_idx),
win: s.win, win: s.win,
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(),
active: s.active, active: s.active,
autostart: s.autostart, autostart: s.autostart,
dark: s.dark,
} }
}; };
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
@@ -187,9 +221,39 @@ const INIT_JS_TPL: &str = r#"
} }
function newTab(url) { emit('ls-new-tab', { url: url }); } 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). // ── ¿La página se VE oscura? ──
// Señales, de la más directa a la más física: la clase `dark` (next-themes) y, si no
// está, el color de fondo REALMENTE pintado (luminancia del body) — vale sea cual sea
// el mecanismo de tema. Devuelve null con el documento a medio crear (los scripts de
// inicio corren antes de que exista <html>): entonces el campo NO viaja y Rust conserva
// el último valor bueno. La v1.7.0 leía la clase a secas y sin red: en WebView2 eso
// podía reventar en frío y dejar `dark` mintiendo en estado.json (barra clara sobre
// app oscura — el bug al revés).
function temaOscuro() {
try {
var doc = document.documentElement, cuerpo = document.body;
if (!doc) return null;
if (doc.classList.contains('dark') || (cuerpo && cuerpo.classList.contains('dark'))) return true;
if (!cuerpo) return null;
var fondo = getComputedStyle(cuerpo).backgroundColor || '';
var m = fondo.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s\/]+([\d.]+))?/);
if (m && (m[4] === undefined || parseFloat(m[4]) > 0.5)) {
return 0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3] < 128;
}
var esquema = getComputedStyle(doc).colorScheme || '';
if (esquema.indexOf('dark') >= 0 && esquema.indexOf('light') < 0) return true;
return false;
} catch (e) { return null; }
}
// 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.
function report() { function report() {
emit('ls-title', { label: LABEL, title: document.title || 'Labstream OS', url: location.href }); var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
emit('ls-title', carga);
} }
function watchTitle() { function watchTitle() {
var el = document.querySelector('title'); var el = document.querySelector('title');
@@ -208,6 +272,18 @@ const INIT_JS_TPL: &str = r#"
history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; }; history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; };
}); });
addEventListener('popstate', function () { setTimeout(report, 0); }); addEventListener('popstate', function () { setTimeout(report, 0); });
// Pulsar la luna cambia clases/estilos sin navegar: se vigilan <html> y <body> (cuando
// existan — a document-start aún no hay), y tras la carga se re-reporta un par de veces
// 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'] };
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
if (document.body) new MutationObserver(report).observe(document.body, conf);
}
if (document.body) vigilarTema();
else document.addEventListener('DOMContentLoaded', vigilarTema);
addEventListener('load', function () { setTimeout(report, 800); setTimeout(report, 2500); });
// Clics en enlaces. // Clics en enlaces.
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
@@ -321,6 +397,8 @@ fn current_zoom<R: Runtime>(app: &AppHandle<R>) -> f64 {
// Manda el estado de pestañas + zoom + versión a la barra (webview "chrome"). // Manda el estado de pestañas + zoom + versión a la barra (webview "chrome").
fn broadcast<R: Runtime>(app: &AppHandle<R>) { fn broadcast<R: Runtime>(app: &AppHandle<R>) {
// Se consulta ANTES de tomar el lock del shell: así no se anidan dos cerrojos.
let maximized = main_window(app).and_then(|w| w.is_maximized().ok()).unwrap_or(false);
let payload = { let payload = {
let shell = app.state::<ShellState>(); let shell = app.state::<ShellState>();
let s = shell.lock().unwrap(); let s = shell.lock().unwrap();
@@ -328,15 +406,21 @@ 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 })) .map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
.collect(); .collect();
json!({ json!({
"tabs": tabs, "tabs": tabs,
"zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32, "zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32,
"version": app.package_info().version.to_string(), "version": app.package_info().version.to_string(),
// En macOS la barra ocupa la fila del título (ventana con título en overlay) y // En macOS la barra ocupa la fila del título (ventana con título en overlay) y
// debe dejar libre el hueco del semáforo; en Windows hay título nativo aparte. // debe dejar libre el hueco del semáforo, que lo pinta el sistema.
"mac": cfg!(target_os = "macos"), "mac": cfg!(target_os = "macos"),
// En Windows la ventana va SIN decoración: la barra es la fila del título, así
// que dibuja ella misma los botones minimizar/maximizar/cerrar (no hay nativos).
"win": cfg!(target_os = "windows"),
"maximized": maximized,
// Tema real de la app (null hasta el primer reporte: la barra usa el del sistema).
"dark": s.dark,
}) })
}; };
let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload); let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload);
@@ -398,7 +482,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 }); s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None });
label label
}; };
@@ -698,20 +782,35 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
// del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió. // del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió.
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) { fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
let Some(win) = main_window(app) else { return }; let Some(win) = main_window(app) else { return };
let (idx, total) = { let (idx, total, actual) = {
let shell = app.state::<ShellState>(); let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap(); let mut s = shell.lock().unwrap();
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return }; let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
s.menu_tab = Some(label.to_string()); s.menu_tab = Some(label.to_string());
(i, s.tabs.len()) let actual = s.tabs[i].color.clone();
(i, s.tabs.len(), actual)
}; };
// Los ids se arman fuera del builder para poder pasarlos como &str (igual que el resto
// del menú) en vez de mezclar String y &str en el mismo constructor.
let ids: Vec<String> = TAB_COLORS.iter().map(|(id, _)| format!("tab-color-{id}")).collect();
let build = || -> tauri::Result<Menu<R>> { let build = || -> tauri::Result<Menu<R>> {
// Submenú de color, con marca en el que tiene puesto ahora.
let mut colores: Vec<CheckMenuItem<R>> = Vec::with_capacity(TAB_COLORS.len() + 1);
colores.push(CheckMenuItem::with_id(app, "tab-color-ninguno", "Sin color", true, actual.is_none(), None::<&str>)?);
for ((cid, nombre), id) in TAB_COLORS.iter().zip(ids.iter()) {
let marcado = actual.as_deref() == Some(*cid);
colores.push(CheckMenuItem::with_id(app, id.as_str(), *nombre, true, marcado, None::<&str>)?);
}
let refs: Vec<&dyn tauri::menu::IsMenuItem<R>> = colores.iter().map(|c| c as &dyn tauri::menu::IsMenuItem<R>).collect();
let submenu = Submenu::with_items(app, "Color", true, &refs)?;
Ok(Menu::with_items( Ok(Menu::with_items(
app, app,
&[ &[
&MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?, &MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?,
&MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?, &MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::separator(app)?,
&submenu,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "tab-close", "Cerrar pestaña", true, None::<&str>)?, &MenuItem::with_id(app, "tab-close", "Cerrar pestaña", true, None::<&str>)?,
&MenuItem::with_id(app, "tab-close-others", "Cerrar las demás", total > 1, None::<&str>)?, &MenuItem::with_id(app, "tab-close-others", "Cerrar las demás", total > 1, None::<&str>)?,
&MenuItem::with_id(app, "tab-close-right", "Cerrar las de la derecha", idx + 1 < total, None::<&str>)?, &MenuItem::with_id(app, "tab-close-right", "Cerrar las de la derecha", idx + 1 < total, None::<&str>)?,
@@ -758,6 +857,24 @@ fn tab_menu_action<R: Runtime>(app: &AppHandle<R>, action: &str) {
close_tab(app, &l); close_tab(app, &l);
} }
} }
// Teñir la pestaña (o quitarle el color). Se valida contra la paleta: un id que no
// esté en TAB_COLORS deja la pestaña sin color en vez de guardar cualquier cosa.
a if a.starts_with("tab-color-") => {
let elegido = &a["tab-color-".len()..];
let nuevo = TAB_COLORS
.iter()
.find(|(id, _)| *id == elegido)
.map(|(id, _)| (*id).to_string());
{
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) {
t.color = nuevo;
}
}
broadcast(app);
save_persisted(app);
}
_ => {} _ => {}
} }
} }
@@ -881,6 +998,10 @@ fn field_i64(payload: &str, key: &str) -> Option<i64> {
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_i64() serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_i64()
} }
fn field_bool(payload: &str, key: &str) -> Option<bool> {
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_bool()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let mut builder = tauri::Builder::default(); let mut builder = tauri::Builder::default();
@@ -930,13 +1051,18 @@ pub fn run() {
s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1); s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1);
s.win = saved.win; s.win = saved.win;
s.autostart = saved.autostart; s.autostart = saved.autostart;
s.dark = saved.dark;
} }
// Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay. // Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay.
// macOS: título en OVERLAY y sin texto → el contenido (y por tanto la barra de // En AMBOS sistemas la barra de pestañas ocupa la fila del título, como Chrome:
// pestañas) ocupa también la fila del título, al estilo de Notion/VS Code. Los // no se desperdicia una franja entera en repetir el nombre de la app.
// botones del semáforo siguen siendo los nativos; la barra les deja su hueco. // - macOS: título en OVERLAY y sin texto; el semáforo lo sigue pintando el sistema
// En Windows se conserva el título del sistema con la barra debajo. // encima y la barra le deja su hueco (clase `mac` del CSS).
// - Windows: ventana SIN decoración; los botones minimizar/maximizar/cerrar los
// dibuja la propia barra (clase `win`) y actúan por el evento `ls-window`.
// `resizable(true)` conserva WS_THICKFRAME, así que los bordes siguen
// redimensionando y Aero Snap (arrastrar a un borde, Win+flechas) sigue vivo.
let mut wb = WindowBuilder::new(app, "main") let mut wb = WindowBuilder::new(app, "main")
.title("Labstream OS") .title("Labstream OS")
.resizable(true) .resizable(true)
@@ -945,6 +1071,10 @@ pub fn run() {
{ {
wb = wb.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true); wb = wb.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true);
} }
#[cfg(target_os = "windows")]
{
wb = wb.decorations(false);
}
match saved.win { match saved.win {
Some(r) if r.w >= 600.0 && r.h >= 400.0 => { Some(r) if r.w >= 600.0 && r.h >= 400.0 => {
wb = wb.inner_size(r.w, r.h).position(r.x, r.y); wb = wb.inner_size(r.w, r.h).position(r.x, r.y);
@@ -1059,6 +1189,7 @@ pub fn run() {
id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => { id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => {
tab_menu_action(app, id) tab_menu_action(app, id)
} }
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
"autostart-toggle" => toggle_autostart(app), "autostart-toggle" => toggle_autostart(app),
"check-update" => { "check-update" => {
#[cfg(desktop)] #[cfg(desktop)]
@@ -1084,22 +1215,35 @@ pub fn run() {
LogicalSize::new(size.width, TAB_BAR_H), LogicalSize::new(size.width, TAB_BAR_H),
)?; )?;
// Pestañas de la sesión anterior (o una nueva si no hay). // Pestañas de la sesión anterior (o una nueva si no hay), cada una con su color.
let restore: Vec<String> = { // El color se empareja con su URL ANTES de filtrar: el filtro por origen puede
// descartar entradas y entonces los índices de las dos listas dejarían de casar.
let restore: Vec<(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
.iter() .iter()
.filter(|u| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false)) .enumerate()
.filter(|(_, u)| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false))
.take(12) .take(12)
.cloned() .map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
.collect() .collect()
}; };
if restore.is_empty() { if restore.is_empty() {
create_tab(&handle, None, true); create_tab(&handle, None, true);
} else { } else {
for url in &restore { for (url, color) in &restore {
create_tab(&handle, Some(url.clone()), false); create_tab(&handle, Some(url.clone()), false);
if !color.is_empty() {
// create_tab acaba de empujar su pestaña 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());
}
}
}
} }
let idx = saved.active.min(restore.len() - 1); let idx = saved.active.min(restore.len() - 1);
let label = { let label = {
@@ -1190,6 +1334,30 @@ pub fn run() {
handle.listen_any("ls-menu", move |_| { handle.listen_any("ls-menu", move |_| {
show_app_menu(&h); show_app_menu(&h);
}); });
// Botones de ventana de la barra. Solo se pintan en Windows, donde la ventana va
// sin decoración y no hay aspa del sistema. «close» pasa por CloseRequested, así
// que hace lo mismo de siempre: ocultar a la bandeja, no salir de la app.
let h = handle.clone();
handle.listen_any("ls-window", move |e| {
let Some(win) = main_window(&h) else { return };
match field(e.payload(), "act").as_deref() {
Some("min") => {
let _ = win.minimize();
}
Some("max") => {
// El repintado del icono ▢/❐ lo dispara el Resized que viene después.
if win.is_maximized().unwrap_or(false) {
let _ = win.unmaximize();
} else {
let _ = win.maximize();
}
}
Some("close") => {
let _ = win.close();
}
_ => {}
}
});
// Clic en la etiqueta de versión de la barra → buscar actualización (con aviso). // Clic en la etiqueta de versión de la barra → buscar actualización (con aviso).
let h = handle.clone(); let h = handle.clone();
handle.listen_any("ls-check-update", move |_| { handle.listen_any("ls-check-update", move |_| {
@@ -1198,10 +1366,11 @@ 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) = ( let (label, title, url, dark) = (
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"),
); );
if let Some(label) = label { if let Some(label) = label {
let shell = h.state::<ShellState>(); let shell = h.state::<ShellState>();
@@ -1214,6 +1383,11 @@ pub fn run() {
t.url = url; t.url = url;
} }
} }
// El tema es global (todas las pestañas del origen comparten modo): el
// último reporte manda, venga de la pestaña que venga.
if let Some(dark) = dark {
s.dark = Some(dark);
}
drop(s); drop(s);
broadcast(&h); broadcast(&h);
save_persisted(&h); save_persisted(&h);
@@ -1265,6 +1439,24 @@ pub fn run() {
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
let app = window.app_handle(); let app = window.app_handle();
layout_all(app); layout_all(app);
// Maximizar/restaurar llega como un Resized más: si el estado cambió, la
// barra tiene que repintar su icono ▢/❐. Se compara antes de emitir para
// no repintarla en cada píxel mientras se arrastra un borde.
let maxi = window.is_maximized().unwrap_or(false);
let cambio = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let antes = s.maximized;
s.maximized = maxi;
antes != maxi
};
if cambio {
broadcast(app);
}
// La geometría MAXIMIZADA no se guarda: al reabrir volvería como ventana
// normal del tamaño de la pantalla y, sin decoración, taparía la barra de
// tareas. Se conserva la última geometría restaurada.
if !maxi {
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) { if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
let scale = window.scale_factor().unwrap_or(1.0); let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale); let p = pos.to_logical::<f64>(scale);
@@ -1274,8 +1466,12 @@ pub fn run() {
Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height }); Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height });
} }
} }
}
WindowEvent::Moved(_) => { WindowEvent::Moved(_) => {
let app = window.app_handle(); let app = window.app_handle();
if window.is_maximized().unwrap_or(false) {
return; // ver arriba: maximizada no se guarda
}
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) { if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
let scale = window.scale_factor().unwrap_or(1.0); let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale); let p = pos.to_logical::<f64>(scale);

Ver fichero

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