Comparar commits

...

8 Commits

Autor SHA1 Mensaje Fecha
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
Jonathan David Florez Salomón
d302591c25 v1.5.1: el shell dejaba de meterse dentro del editor de OnlyOffice en Windows
OnlyOffice fallaba al editar y al descargar SOLO en la app de escritorio de
Windows. En el navegador y en la app de macOS iba bien. La causa estaba en el
propio shell, no en el servidor de documentos.

En Windows, wry inyecta los scripts de inicio en TODOS los marcos e ignora
for_main_frame_only (wry-0.55.1/src/webview2/mod.rs:492-495, documentado en su
lib.rs:990); macOS si respeta la bandera (wkwebview/mod.rs:643-645). Y __TAURI__
viaja con ellos, asi que la guarda «if (!T || !T.event) return» no frenaba nada.
Resultado: INIT_JS_TPL corria DENTRO del iframe del Document Server con ORIGIN
apuntando todavia a la app; como alli location.href es el de docs.*, cada enlace
del editor se resolvia contra ese origen, daba isExt() = true y se desviaba al
navegador del sistema —que no tiene la sesion: «no se puede acceder»—, y
window.open devolvia null.

Cambios:

1. Guarda de marco y origen al principio del script inyectado: solo actua en el
   documento PRINCIPAL y solo en el origen de la app. En macOS es un no-op (alli
   nunca corrio en subframes). De paso arregla que en Windows el shell tambien
   secuestrara los enlaces de las paginas de SSO.

2. El manejador de atajos ignora e.altKey. AltGr enciende ctrlKey en Windows, asi
   que escribir [ o ] con teclado espanol disparaba history.back()/forward() en
   vez de escribir el caracter — en el editor y en cualquier campo de la app.

3. Aviso nativo al terminar una descarga. No es un adorno: wry trae un manejador
   de descargas por defecto (wry-0.55.1/src/lib.rs:830), de modo que en WebView2
   siempre se registra DownloadStarting y siempre responde SetHandled(true)
   (webview2/mod.rs:858-861), lo que apaga la interfaz de descargas de Windows.
   El archivo bajaba a Descargas pero nada lo decia y parecia roto. Quitar el
   manejador NO devuelve la interfaz nativa: el de wry sigue puesto.

4. Feature «devtools» de tauri: el inspector queda disponible tambien en release.
   Sin el, un fallo que solo aparece en la app instalada no se puede mirar por
   dentro.

Descartado a proposito: registrar on_new_window. En macOS el delegado ejecuta
webview.window().unwrap() y current_window.screen().unwrap() ANTES del match
(wry-0.55.1/src/wkwebview/class/wry_web_view_ui_delegate.rs:150-154), tambien en
la rama Deny; y este shell oculta la ventana al cerrar, con lo que NSWindow.screen
puede ser nil y la app entera abortaria. Cambiar un fallo de Windows por un crash
en macOS no compensa.

Verificado: cargo check limpio; sintaxis del script inyectado validada con node;
y simulacion de la guarda — marco principal + origen propio interviene, iframe de
OnlyOffice no interviene, origen ajeno no interviene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:12:22 -05:00
Se han modificado 8 ficheros con 516 adiciones y 66 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.
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 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
barra y **persistencia** entre sesiones (50%–250%).
- **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
reinstala encima. Para que las apps ya instaladas se actualicen solas, Tauri trae
un *updater* (funciona igual en Windows y Mac). Para activarlo (paso futuro):
Las apps ya instaladas **se actualizan solas**: el updater consulta el `latest.json`
que publica cada Release, y los instaladores se firman en el CI con la clave privada
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:
```bash
npm run tauri signer generate -- -w ~/.tauri/labstream.key
```
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.
> ⚠️ De ahí se sigue algo que conviene tener presente: **un commit en `main` no le
> llega a nadie**. Mientras no exista la etiqueta `vX.Y.Z`, el CI no construye nada y
> 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.
Se deja sin activar a propósito para que la **primera** versión compile sin
configuración extra.
## Probar un cambio antes de publicarlo
`.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).
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,
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
@@ -15,33 +16,60 @@
(`data-tauri-drag-region`). En Windows hay título nativo aparte y no se reserva nada.
-->
<style>
/* Paleta estilo Chrome: la barra es una FRANJA más oscura y la pestaña activa es una
tarjeta del color del contenido que se funde con él (sin línea debajo). */
/* La barra se pinta del color de la APP, no del gris de Chrome: blanca en tema claro y
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 {
--bg: #dee1e6; /* franja de pestañas (Chrome claro) */
--bg: #f4f5f7; /* franja: casi blanca, del tono de la app en claro */
--fg: #45474a;
--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;
--accent: #6366f1; /* indigo-500, color de Labstream */
--border: #c9ccd1; /* 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 */
--accent: #F47A20; /* NARANJA Labstream (el índigo de antes no era de la marca) */
--border: #dcdee1; /* línea bajo la barra (la tapa la pestaña activa) */
/* 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);
--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) {
:root {
--bg: #202124; /* franja (Chrome oscuro) */
:root:not(.claro) {
--bg: #0d0d0f; /* franja: negra, más oscura que el contenido */
--fg: #bdc1c6;
--muted: #80868b;
--tab-active-bg: #18181b; /* mismo fondo que la app → pestaña fundida con el contenido */
--tab-active-fg: #e8eaed;
--border: #101013;
--border: #26262a;
--hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--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; }
html, body {
margin: 0;
@@ -81,10 +109,13 @@
display: flex;
align-items: center;
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;
min-width: 42px;
max-width: 240px;
max-width: 280px;
height: 30px;
margin-bottom: 5px;
padding: 0 6px 0 10px;
@@ -135,6 +166,34 @@
right: -8px;
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
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. */
@@ -233,6 +292,31 @@
border-radius: 13px;
}
#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>
</head>
<body>
@@ -251,6 +335,12 @@
</div>
<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>
<!-- 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>
<script>
const T = window.__TAURI__;
@@ -264,6 +354,7 @@
const $tabs = document.getElementById("tabs");
const $pct = document.getElementById("z-pct");
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
// 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");
el.className = "tab" + (t.active ? " active" : "");
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;
const fav = document.createElement("span");
@@ -439,8 +531,22 @@
$pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100);
$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.
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) {
@@ -466,10 +572,14 @@
document.getElementById("z-menos").addEventListener("click", () => emitir("ls-zoom", { dir: -1 }));
document.getElementById("z-mas").addEventListener("click", () => emitir("ls-zoom", { dir: 1 }));
$pct.addEventListener("click", () => emitir("ls-zoom", { dir: 0 }));
// Doble clic en el hueco libre de la barra = pestaña nueva.
$tabs.addEventListener("dblclick", (e) => {
if (e.target === $tabs) emitir("ls-new-tab", {});
});
// Botones de ventana (Windows). Cerrar oculta a la bandeja, como el aspa de siempre.
document.getElementById("w-min").addEventListener("click", () => emitir("ls-window", { act: "min" }));
$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.
addEventListener("keydown", (e) => {

Ver fichero

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

Ver fichero

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

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.5.0"
version = "1.6.1"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
edition = "2021"
@@ -15,7 +15,11 @@ tauri-build = { version = "2", features = [] }
[dependencies]
# `unstable` habilita multiwebview: varios webviews (pestañas) dentro de UNA ventana.
tauri = { version = "2", features = ["tray-icon", "unstable"] }
# `devtools` deja el inspector disponible TAMBIÉN en release (por defecto solo va en debug:
# wry-0.55.1/src/lib.rs:834-837). Sin él, un fallo que solo aparece en la app instalada —como
# el de OnlyOffice en Windows— no se puede mirar por dentro y hay que deducirlo leyendo
# fuentes. Con él, clic derecho → Inspeccionar sobre el marco que falle y se acabó.
tauri = { version = "2", features = ["tray-icon", "unstable", "devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-notification = "2"

Ver fichero

@@ -39,7 +39,7 @@ use serde_json::json;
use tauri::{
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::{PageLoadEvent, Webview, WebviewBuilder},
webview::{DownloadEvent, PageLoadEvent, Webview, WebviewBuilder},
window::{Window, WindowBuilder},
AppHandle, Emitter, EventTarget, Listener, LogicalPosition, LogicalSize, Manager, Runtime,
Url, WebviewUrl, WindowEvent,
@@ -66,8 +66,25 @@ struct TabRec {
title: String,
url: String,
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 {
tabs: Vec<TabRec>,
active: usize,
@@ -77,11 +94,18 @@ struct Shell {
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)
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 {
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>,
#[serde(default)]
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)]
active: usize,
#[serde(default)]
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> {
@@ -130,8 +162,10 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
zoom_idx: Some(s.zoom_idx),
win: s.win,
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,
autostart: s.autostart,
dark: s.dark,
}
};
if let Some(dir) = path.parent() {
@@ -148,16 +182,33 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
// - Reporta título/URL a la barra (observer del <title> + pushState del SPA).
// - Atajos de teclado (en macOS los consume antes el menú nativo; esto cubre Windows y extras)
// y Ctrl+rueda para zoom.
// En páginas de OTRO origen (p. ej. el login de Authentik) __TAURI__ no está inyectado y el
// script sale al principio sin tocar nada — la navegación del SSO sigue intacta.
// SOLO debe gobernar el documento principal de NUESTRO origen; ver la guarda de abajo.
const INIT_JS_TPL: &str = r#"
(function () {
var ORIGIN = "__ORIGIN__";
// ── GUARDA DE MARCO Y ORIGEN ──
// Antes aquí solo estaba `if (!T || !T.event) return;`, confiando en que __TAURI__ no
// existiría fuera del origen permitido. Eso es cierto en macOS y FALSO en Windows:
// · wry inyecta los scripts de inicio en TODOS los marcos e IGNORA for_main_frame_only
// (wry-0.55.1/src/webview2/mod.rs:492-495, documentado en su lib.rs:990), mientras que
// WKWebView sí la respeta (wry-0.55.1/src/wkwebview/mod.rs:643-645);
// · y __TAURI__ viaja con ellos (withGlobalTauri, tauri/src/manager/webview.rs:159-165).
// Consecuencia real: dentro del iframe del editor de OnlyOffice (docs.labstreamsas.com)
// este script SÍ corría en Windows, con ORIGIN apuntando todavía a la app. Como allí
// `location.href` es el del Document Server, abs() resolvía CADA enlace del editor contra
// docs.* → isExt() cierto → preventDefault() + mandarlo al navegador del sistema (que no
// tiene la sesión: «no se puede acceder»), y window.open devolvía null. Por eso editar y
// descargar fallaban solo en la app de Windows, y no en Mac ni en el navegador.
// En macOS estas dos líneas son un no-op: allí el script nunca ha corrido en subframes.
try { if (window.top !== window.self) return; } catch (e) { return; }
if (location.origin !== ORIGIN) return; // p. ej. el login del SSO: no intervenir
if (window.__lsShell) return;
window.__lsShell = true;
var T = window.__TAURI__;
if (!T || !T.event) return; // página fuera del origen permitido (p. ej. SSO): no intervenir
if (!T || !T.event) return;
var LABEL = "__LABEL__";
var ORIGIN = "__ORIGIN__";
function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } }
function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); }
@@ -170,9 +221,16 @@ const INIT_JS_TPL: &str = r#"
}
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).
// 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 (next-themes,
// clase `dark` en <html>) y NO sigue al sistema: la barra tiene que copiarle a ella.
function report() {
emit('ls-title', { label: LABEL, title: document.title || 'Labstream OS', url: location.href });
emit('ls-title', {
label: LABEL,
title: document.title || 'Labstream OS',
url: location.href,
dark: document.documentElement.classList.contains('dark')
});
}
function watchTitle() {
var el = document.querySelector('title');
@@ -191,6 +249,11 @@ const INIT_JS_TPL: &str = r#"
history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; };
});
addEventListener('popstate', function () { setTimeout(report, 0); });
// Pulsar la luna cambia la clase de <html> sin navegar: se observa para que la barra
// se entere al instante.
if (window.MutationObserver) {
new MutationObserver(report).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}
// Clics en enlaces.
document.addEventListener('click', function (e) {
@@ -224,6 +287,11 @@ const INIT_JS_TPL: &str = r#"
// Atajos de teclado.
addEventListener('keydown', function (e) {
// AltGr enciende ctrlKey en Windows. Sin este filtro, escribir [ o ] con teclado
// español —que se hacen con AltGr— entraba por aquí y disparaba history.back() /
// history.forward() en vez de escribir el carácter: en el editor, en el chat, en una
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
if (e.altKey) return;
var mod = e.metaKey || e.ctrlKey;
if (!mod) return;
var k = e.key;
@@ -299,6 +367,8 @@ fn current_zoom<R: Runtime>(app: &AppHandle<R>) -> f64 {
// Manda el estado de pestañas + zoom + versión a la barra (webview "chrome").
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 shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
@@ -306,15 +376,21 @@ 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 }))
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
.collect();
json!({
"tabs": tabs,
"zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32,
"version": app.package_info().version.to_string(),
// 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"),
// 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);
@@ -376,7 +452,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 });
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None });
label
};
@@ -388,8 +464,39 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let app_zoom = app.clone();
let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed))
.initialization_script(&init)
// Deja pasar las descargas (entregables, exportaciones) con el diálogo nativo de guardado.
.on_download(|_webview, _event| true)
// Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar.
// El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE
// apagada. wry trae un manejador de descargas por defecto (wry-0.55.1/src/lib.rs:830),
// asi que DownloadStarting se registra siempre y siempre responde SetHandled(true)
// (wry-0.55.1/src/webview2/mod.rs:858-861), lo que le dice a WebView2 que del aviso
// se encarga la app: ni barra de progreso, ni globo de «descarga terminada». El
// archivo bajaba bien a la carpeta de Descargas, pero NADA lo decia y parecia roto.
// Quitar este manejador NO devuelve la interfaz nativa: el de wry sigue puesto.
.on_download(|webview, event| {
if let DownloadEvent::Finished { path, success, .. } = &event {
let cuerpo = if !*success {
"No se pudo completar la descarga.".to_string()
} else if let Some(p) = path {
// En macOS `path` llega siempre vacio (limite de WKDownload:
// wry-0.55.1/src/wkwebview/download.rs:98), asi que en la practica
// este brazo con nombre de archivo es el de Windows.
match p.file_name() {
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
None => "Descarga terminada.".to_string(),
}
} else {
"Descarga terminada. Esta en tu carpeta de Descargas.".to_string()
};
let _ = webview
.app_handle()
.notification()
.builder()
.title("Labstream OS")
.body(cuerpo)
.show();
}
true
})
// El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación).
.on_page_load(move |wv, payload| {
let cargando = matches!(payload.event(), PageLoadEvent::Started);
@@ -645,20 +752,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ó.
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
let Some(win) = main_window(app) else { return };
let (idx, total) = {
let (idx, total, actual) = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
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>> {
// 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(
app,
&[
&MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?,
&MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?,
&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-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>)?,
@@ -705,6 +827,24 @@ fn tab_menu_action<R: Runtime>(app: &AppHandle<R>, action: &str) {
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);
}
_ => {}
}
}
@@ -828,6 +968,10 @@ fn field_i64(payload: &str, key: &str) -> Option<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)]
pub fn run() {
let mut builder = tauri::Builder::default();
@@ -877,13 +1021,18 @@ pub fn run() {
s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1);
s.win = saved.win;
s.autostart = saved.autostart;
s.dark = saved.dark;
}
// 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
// pestañas) ocupa también la fila del título, al estilo de Notion/VS Code. Los
// botones del semáforo siguen siendo los nativos; la barra les deja su hueco.
// En Windows se conserva el título del sistema con la barra debajo.
// En AMBOS sistemas la barra de pestañas ocupa la fila del título, como Chrome:
// no se desperdicia una franja entera en repetir el nombre de la app.
// - macOS: título en OVERLAY y sin texto; el semáforo lo sigue pintando el sistema
// 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")
.title("Labstream OS")
.resizable(true)
@@ -892,6 +1041,10 @@ pub fn run() {
{
wb = wb.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true);
}
#[cfg(target_os = "windows")]
{
wb = wb.decorations(false);
}
match saved.win {
Some(r) if r.w >= 600.0 && r.h >= 400.0 => {
wb = wb.inner_size(r.w, r.h).position(r.x, r.y);
@@ -1006,6 +1159,7 @@ pub fn run() {
id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => {
tab_menu_action(app, id)
}
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
"autostart-toggle" => toggle_autostart(app),
"check-update" => {
#[cfg(desktop)]
@@ -1031,22 +1185,35 @@ pub fn run() {
LogicalSize::new(size.width, TAB_BAR_H),
)?;
// Pestañas de la sesión anterior (o una nueva si no hay).
let restore: Vec<String> = {
// Pestañas de la sesión anterior (o una nueva si no hay), cada una con su color.
// 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");
saved
.tabs
.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)
.cloned()
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
.collect()
};
if restore.is_empty() {
create_tab(&handle, None, true);
} else {
for url in &restore {
for (url, color) in &restore {
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 label = {
@@ -1137,6 +1304,30 @@ pub fn run() {
handle.listen_any("ls-menu", move |_| {
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).
let h = handle.clone();
handle.listen_any("ls-check-update", move |_| {
@@ -1145,10 +1336,11 @@ pub fn run() {
});
let h = handle.clone();
handle.listen_any("ls-title", move |e| {
let (label, title, url) = (
let (label, title, url, dark) = (
field(e.payload(), "label"),
field(e.payload(), "title"),
field(e.payload(), "url"),
field_bool(e.payload(), "dark"),
);
if let Some(label) = label {
let shell = h.state::<ShellState>();
@@ -1161,6 +1353,11 @@ pub fn run() {
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);
broadcast(&h);
save_persisted(&h);
@@ -1212,17 +1409,39 @@ pub fn run() {
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => {
let app = window.app_handle();
layout_all(app);
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale);
let s = size.to_logical::<f64>(scale);
// 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>();
shell.lock().unwrap().win =
Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height });
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()) {
let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale);
let s = size.to_logical::<f64>(scale);
let shell = app.state::<ShellState>();
shell.lock().unwrap().win =
Some(WinRect { x: p.x, y: p.y, w: s.width, h: s.height });
}
}
}
WindowEvent::Moved(_) => {
let app = window.app_handle();
if 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()) {
let scale = window.scale_factor().unwrap_or(1.0);
let p = pos.to_logical::<f64>(scale);

Ver fichero

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