Comparar commits

...

9 Commits

Autor SHA1 Mensaje Fecha
Munka
5a6f6c918f v1.18.0: F5 recarga, como en cualquier navegador
WebView2 tiene apagados sus propios atajos de navegador, así que F5 no hacía
nada dentro de la app: el único que recargaba era Ctrl+R. Ahora F5 (y
Ctrl+F5, Shift+F5) recarga la pestaña. Va antes del filtro de modificadores
de los atajos, porque F5 no lleva ninguno y ese filtro lo descartaba.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:54:57 -05:00
Jonathan David Florez Salomón
7b0c031a41 v1.17.0: la esquina rota, la profundidad de las pestañas y la activa en negrilla
Tres cambios en la barra, todos de la misma idea: que se vea de un vistazo cuál
manda.

· LA ESQUINA. Se retiran los dos pseudo-elementos de 8px que imitaban la curva
  cóncava de Chrome. El truco funciona allí porque NO hay línea bajo la barra;
  aquí sí la hay, y esas piezas blancas la partían en dos a los costados de la
  pestaña activa. La activa ya se funde con el contenido por su propio fondo.

· LA PROFUNDIDAD. Las inactivas se hunden con un velo tenue y sombra interior, y
  al pasar el ratón el gesto se invierte: la sombra sale y el fondo tira hacia el
  blanco de la activa. Sombra y no relleno sólido — un bloque gris pesa tanto
  como la activa y compite con ella. La activa gana sombra HACIA ARRIBA: abajo
  está fundida con el contenido.

· LA NEGRILLA. El título de la activa a 600. Va en el título y no en la pestaña
  entera para que el aspa de cerrar no engorde con él.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:37:36 -05:00
Jonathan David Florez Salomón
641c9660cc La pestaña activa, en negrilla
El relieve dice dónde está el plano de delante; el peso de la letra es lo que se
lee sin mirar. Va en el TÍTULO y no en la pestaña entera, para que el aspa de
cerrar no engorde con él, y a 600 y no a 700: a 12,5 px el bold entero emborrona,
y lo que hace falta es que destaque, no que grite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:22:28 -05:00
Jonathan David Florez Salomón
73486aa3fe La esquina de la pestaña activa deja de romperse, y las que esperan se hunden
DOS COSAS EN LA BARRA DE PESTAÑAS.

1) LA ESQUINA. La activa llevaba dos pseudo-elementos de 8px a los lados con un
   degradado radial: la curva cóncava de Chrome. El truco funciona en Chrome
   porque allí NO hay línea bajo la barra; aquí sí la hay, y esas dos piezas
   blancas la partían en dos justo a los costados de la pestaña — la fluctuación
   que se veía en la esquina. Se retiran: la activa ya se funde con el contenido
   por su propio fondo, que tapa la línea exactamente por su ancho, y sin las
   piezas no asoma nada.

2) LA PROFUNDIDAD. La inactiva no era nada: texto suelto sobre la franja, al
   mismo plano que la activa, separado por un filete de 1px. Ahora se HUNDE —velo
   muy tenue y sombra interior— y al pasar el ratón el gesto se invierte: la
   sombra sale hacia fuera y el fondo tira hacia el blanco de la activa, así que
   la pestaña sube hacia ti. Es sombra y no relleno sólido a propósito: un bloque
   gris pesa tanto como la activa y acaba compitiendo con ella.

   La activa gana su propia sombra, HACIA ARRIBA: abajo está fundida con el
   contenido y una sombra ahí dibujaría una línea donde no debe haberla.

   Con fondo propio el separador vertical sobra y se retira. `--sep` se queda:
   lo sigue usando el aro del favicon mientras carga.

Sin cambio de versión: queda listo para la próxima que se saque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:20:30 -05:00
Jonathan David Florez Salomón
a37a0bcc0b v1.16.0: la app de macOS por fin se firma de verdad
El build NUNCA firmó la app de macOS. Al no pasarle ninguna identidad, el
bundler de Tauri se saltaba `codesign` entero y el .dmg salía con la firma
ad hoc que deja el enlazador:

  Identifier=labstream_desktop-0a5819ba37119701
  flags=0x20002(adhoc,linker-signed)
  Info.plist=not bound
  Sealed Resources=none

Tres cosas mal, y ninguna avisaba en ningún log:

 · El Entitlements.plist del repo era DECORACIÓN. Los entitlements se
   escriben al firmar; sin firma, el micrófono, la cámara y el JIT nunca
   pidieron nada porque nunca estuvieron pedidos.
 · Ni el Info.plist ni los recursos iban dentro de la firma: se podían
   cambiar sin romperla.
 · El identificador era el del enlazador y no `co.labstream.os`, que es por
   el que macOS recuerda los permisos que le concedes a una app.

AHORA. `APPLE_SIGNING_IDENTITY` se calcula en un paso propio y no en el
`env:` del build, porque un secret que no existe llega como cadena VACÍA y
Tauri lee `APPLE_CERTIFICATE=""` como «hay certificado» y revienta al
importarlo. Solo se exporta lo que tiene valor:

 · con Developer ID → firma real, y notarizado si están las tres claves;
 · sin él → identidad "-", firma AD HOC. No engaña a Gatekeeper, pero sella
   los recursos, ata el Info.plist y APLICA LOS ENTITLEMENTS. En cuanto
   Jonathan añada los secrets, la siguiente etiqueta sale firmada de verdad
   sin tocar un solo archivo.

ENSAYADO sobre una copia de la app instalada, con el mismo comando que va a
correr el CI. Antes y después:

  flags=0x20002(adhoc,linker-signed)  →  flags=0x10002(adhoc,runtime)
  Identifier=labstream_desktop-0a58…  →  Identifier=co.labstream.os
  Info.plist=not bound                →  Info.plist entries=17
  Sealed Resources=none               →  Sealed Resources version=2 rules=13
  (sin entitlements)                  →  allow-jit, audio-input, camera

Y EL ENSAYO ENCONTRÓ UNA TRAMPA que `plutil -lint` no ve: el
Entitlements.plist llevaba `--entitlements` dentro de un comentario XML, y
dos guiones seguidos ahí son XML inválido. plutil lo aceptaba; AMFI, que es
quien lo lee de verdad dentro de codesign, contestaba «Failed to parse
entitlements» y la firma fallaba. Queda avisado en el propio archivo.

De paso se cae `com.apple.security.network.client`: es exclusiva del App
Sandbox y esta app no lo usa, así que no hacía nada.

LA RED QUE FALTABA: un paso nuevo comprueba la firma del .app y TUMBA la
publicación si los entitlements no llegaron. Que esto estuviera roto no lo
dijo nada durante meses; se descubrió a mano persiguiendo otra cosa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 11:41:13 -05:00
Munka
b4c4875cf6 v1.15.0: las descargas preguntan dónde guardar
Al bajar un archivo desde una pestaña, la app abre el «Guardar como» nativo (rfd directo, modal en el hilo del webview) con el nombre sugerido; cancelar cancela la descarga. Antes caía en silencio en Descargas. El aviso al terminar usa la ruta elegida (en macOS WKDownload no la devuelve).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 17:46:21 -05:00
Labstream2026
261acc0201 Merge pull request #1 from Labstream2026/codex/portadas-mac-autenticadas
Desktop 1.14.1: portadas autenticadas y apertura fiable de Google
2026-09-15 07:39:54 -05:00
Jonathan David Florez Salomón
1bdc7d0bba fix(ci): use a portable Node test path on Windows 2026-09-15 07:27:39 -05:00
Jonathan David Florez Salomón
013d1e7759 fix(desktop): authenticated previews and reliable Google document opening 2026-09-15 07:25:34 -05:00
Se han modificado 13 ficheros con 639 adiciones y 191 borrados

Ver fichero

@@ -51,6 +51,65 @@ jobs:
- name: Generar iconos (desde app-icon.png) - name: Generar iconos (desde app-icon.png)
run: npm run icons run: npm run icons
- name: Probar el cliente
run: npm test
# ── FIRMA DE macOS ──────────────────────────────────────────────────────────────────
# Hasta el 2026-09-19 este build NO firmaba la app de macOS: no se le pasaba ninguna
# identidad, así que el bundler de Tauri se saltaba `codesign` entero y el .dmg salía con
# la firma ad hoc que deja el enlazador. Se veía así en la app instalada:
# flags=0x20002(adhoc,linker-signed) · Info.plist=not bound · Sealed Resources=none
# Consecuencias: los entitlements (micrófono, cámara, JIT) NO se aplicaban —el
# Entitlements.plist del repo era decoración—, el Info.plist y los recursos no iban
# sellados, y la app no se podía notarizar de ninguna manera.
#
# `APPLE_SIGNING_IDENTITY` decide qué pasa, y por eso se calcula aquí y no en el `env:`
# del paso de abajo: un secreto que no existe llega como cadena VACÍA, y Tauri
# interpreta `APPLE_CERTIFICATE=""` como «hay certificado» y revienta al importarlo.
# Solo se exporta lo que de verdad tiene valor.
#
# · CON los secretos de Developer ID → firma real + notarizado + grapado.
# · SIN ellos → identidad "-", que es la firma AD HOC: no engaña a Gatekeeper (habrá
# que seguir abriendo con clic derecho), pero SÍ sella los recursos, ata el
# Info.plist y aplica los entitlements. Estrictamente mejor que no firmar.
#
# El hardened runtime no se configura: en Tauri v2 `bundle.macOS.hardenedRuntime` ya
# viene en true, y es lo que hace que los entitlements signifiquen algo.
- name: Preparar la firma de macOS
if: matrix.platform == 'macos-latest'
shell: bash
env:
CERT: ${{ secrets.APPLE_CERTIFICATE }}
CERT_PW: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
NOTARIZA_ID: ${{ secrets.APPLE_ID }}
NOTARIZA_PW: ${{ secrets.APPLE_PASSWORD }}
NOTARIZA_TEAM: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
# Los valores van por heredoc con delimitador: un base64 largo o con saltos rompería
# la forma `CLAVE=valor`. Nada se imprime: los secretos no pasan por el log.
exportar() { printf '%s<<__FIN__\n%s\n__FIN__\n' "$1" "$2" >> "$GITHUB_ENV"; }
if [ -n "${CERT:-}" ] && [ -n "${CERT_PW:-}" ]; then
exportar APPLE_CERTIFICATE "$CERT"
exportar APPLE_CERTIFICATE_PASSWORD "$CERT_PW"
exportar APPLE_SIGNING_IDENTITY "${IDENTITY:-Developer ID Application}"
echo "Firma: Developer ID (certificado del repo)."
else
exportar APPLE_SIGNING_IDENTITY "-"
echo "Firma: AD HOC. Sella recursos y aplica entitlements, pero Gatekeeper seguirá avisando."
echo "Para firma de verdad, añade los secrets APPLE_CERTIFICATE y APPLE_CERTIFICATE_PASSWORD."
fi
# Notarizar solo tiene sentido con Developer ID; las tres claves van juntas o ninguna.
if [ -n "${CERT:-}" ] && [ -n "${NOTARIZA_ID:-}" ] && [ -n "${NOTARIZA_PW:-}" ] && [ -n "${NOTARIZA_TEAM:-}" ]; then
exportar APPLE_ID "$NOTARIZA_ID"
exportar APPLE_PASSWORD "$NOTARIZA_PW"
exportar APPLE_TEAM_ID "$NOTARIZA_TEAM"
echo "Notarizado: activado."
else
echo "Notarizado: omitido (faltan APPLE_ID, APPLE_PASSWORD o APPLE_TEAM_ID, o no hay certificado)."
fi
- name: Construir, firmar y publicar - name: Construir, firmar y publicar
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
@@ -58,11 +117,53 @@ jobs:
# Firma del updater: la clave privada y su contraseña van como secrets del repo. # Firma del updater: la clave privada y su contraseña van como secrets del repo.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Las APPLE_* las puso el paso de arriba en $GITHUB_ENV, solo si tenían valor.
with: with:
# En un tag usa su nombre (v1.0.0); en ejecución manual, deriva vX.Y.Z de la versión. # En un tag usa su nombre (v1.0.0); en ejecución manual, deriva vX.Y.Z de la versión.
tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__VERSION__' }} tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__VERSION__' }}
releaseName: "Labstream OS Desktop __VERSION__" releaseName: "Labstream OS Desktop __VERSION__"
releaseBody: "Instaladores de escritorio de Labstream OS.\n\n- Windows: el archivo `.exe` (NSIS).\n- macOS: el archivo `.dmg` universal (Intel + Apple Silicon).\n\nActualización automática activada: las apps ya instaladas se actualizan solas al publicar una versión nueva." releaseBody: "Instaladores de escritorio de Labstream OS.\n\n- Windows: el archivo `.exe` (NSIS).\n- macOS: el archivo `.dmg` universal (Intel + Apple Silicon).\n\nActualización automática activada: las apps ya instaladas se actualizan solas al publicar una versión nueva."
releaseDraft: false releaseDraft: true
prerelease: false prerelease: false
args: ${{ matrix.args }} args: ${{ matrix.args }}
# ── LA RED QUE FALTABA ──────────────────────────────────────────────────────────────
# Que el build no firmara la app de macOS no lo dijo nada durante meses: el .dmg salía,
# se instalaba y funcionaba. Se descubrió a mano, con `codesign -dv`, persiguiendo otra
# cosa. Este paso hace esa comprobación en cada publicación y TUMBA el build si los
# entitlements no llegaron: es barato y es el único aviso que habría existido.
- name: Comprobar la firma de macOS
if: matrix.platform == 'macos-latest'
shell: bash
run: |
set -euo pipefail
APP=$(find src-tauri/target/universal-apple-darwin/release/bundle/macos -maxdepth 1 -name "*.app" | head -1)
if [ -z "$APP" ]; then echo "::error::No apareció ningún .app donde se esperaba"; exit 1; fi
echo "== $APP =="
codesign -dv --verbose=4 "$APP" 2>&1 || true
echo "== entitlements =="
codesign -d --entitlements - --xml "$APP" 2>/dev/null | plutil -convert xml1 -o - - || true
echo "== integridad del bundle =="
codesign --verify --strict --verbose=2 "$APP"
# Gatekeeper: con firma ad hoc esto SIEMPRE dice «rejected», y es esperado. Se
# informa sin tumbar nada; lo que sí es obligatorio es lo de abajo.
echo "== Gatekeeper (informativo) =="
spctl -a -t exec -vv "$APP" 2>&1 || true
echo "== obligatorio: los entitlements tienen que estar =="
if ! codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q "com.apple.security.cs.allow-jit"; then
echo "::error::La app salió SIN entitlements. Revisa el paso «Preparar la firma de macOS» y bundle.macOS.entitlements en tauri.conf.json."
exit 1
fi
echo "Entitlements aplicados."
activar:
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publicar solo cuando ambos instaladores estan completos
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(node -p "require('./package.json').version")
gh release edit "v$VERSION" --draft=false --latest

Ver fichero

@@ -44,6 +44,9 @@ jobs:
- name: Instalar dependencias - name: Instalar dependencias
run: npm install run: npm install
- name: Probar el script del cliente
run: npm test
# build.rs de Tauri valida que existan los iconos que declara tauri.conf.json. # build.rs de Tauri valida que existan los iconos que declara tauri.conf.json.
- name: Generar iconos (desde app-icon.png) - name: Generar iconos (desde app-icon.png)
run: npm run icons run: npm run icons

100
README.md
Ver fichero

@@ -84,6 +84,28 @@ pestañas son webviews hijos `tab-*`. Shell ↔ Rust se hablan por eventos `ls-*
El servidor objetivo se configura en `src-tauri/src/lib.rs` → constante `SERVER_URL`. El servidor objetivo se configura en `src-tauri/src/lib.rs` → constante `SERVER_URL`.
### Portadas autenticadas (1.14.1)
Las imagenes conservan su URL HTTPS y se cargan con la sesion del webview, igual
que en el navegador. Ya no se reescriben a `lsthumb://`: el descargador Rust no
comparte las cookies y fallaba con miniaturas sin token o con token vencido.
Tampoco se modifica el DOM de las imagenes durante la hidratacion.
Se usa la cache HTTP con los ETag y Cache-Control del servidor. Una respuesta
304 reutiliza los bytes descargados; no implica fabricar otro fotograma. El
cajon antiguo no se borra, pero no se usa para las nuevas peticiones de imagenes.
No cambia el almacenamiento de originales, la cache HLS de LabTem ni los datos offline.
Google Docs, Sheets, Drive y el inicio de sesion de Google se abren en el navegador
del sistema. Google no admite OAuth en webviews controlados por una app; ademas,
WKWebView no debe anunciarse como Chrome. Se conserva el agente de usuario nativo
y no se borran cookies ni sesiones. Las hojas conectadas siguen disponibles como
tabla dentro del area contable. Al reiniciar se restauran solo pestanas de Labstream.
Pruebas: `npm test`; con Playwright disponible en Node,
`node tests/images.browser.cjs` verifica sesion, 304, carga dinamica y lienzo.
La prueba de navegador usa un servidor local y una cookie ficticia, sin datos reales.
Salidas: Salidas:
- **Windows** → instalador `.exe` (NSIS). - **Windows** → instalador `.exe` (NSIS).
@@ -207,20 +229,72 @@ Es la forma de mirar un cambio visual —la barra, los colores— sin sacárselo
--- ---
## Avisos de seguridad del sistema (instalación interna sin firmar) ## Firma de macOS
Como los instaladores **no están firmados** con certificado de pago, la primera ### Lo que pasaba hasta la 1.15.0
vez cada sistema muestra una advertencia. Es normal para apps internas:
- **Windows (SmartScreen):** pantalla azul *"Windows protegió su PC"* → El build **no firmaba** la app de macOS. No se le pasaba ninguna identidad, así que
**Más información → Ejecutar de todos modos**. el bundler de Tauri se saltaba `codesign` entero y el `.dmg` salía con la firma ad hoc
- **macOS (Gatekeeper):** *"no se puede abrir porque proviene de un desarrollador que deja el enlazador. En la app instalada se veía así:
no identificado"* → clic derecho sobre la app → **Abrir** → **Abrir**. (Si
insiste, ejecutar una vez: `xattr -cr "/Applications/Labstream OS.app"`.)
Si en el futuro se distribuye a clientes externos, conviene firmar: ```
*code signing* en Windows (~150–400 USD/año) y un *Apple Developer ID* + notarizado $ codesign -dv "/Applications/Labstream OS.app"
en Mac (99 USD/año) para quitar estos avisos. Identifier=labstream_desktop-0a5819ba37119701
CodeDirectory ... flags=0x20002(adhoc,linker-signed)
Info.plist=not bound
Sealed Resources=none
TeamIdentifier=not set
```
Tres cosas mal, y ninguna avisaba:
- **`Entitlements.plist` no se aplicaba.** Los permisos de micrófono, cámara y JIT
eran decoración: se escriben al firmar, y no se firmaba.
- **`Info.plist=not bound` y `Sealed Resources=none`.** Ni el Info.plist ni los
recursos iban dentro de la firma: cualquiera podía cambiarlos sin romperla.
- **Identificador de enlazador** (`labstream_desktop-<hash>`) en vez del de la app
(`co.labstream.os`), que es por lo que macOS recuerda los permisos concedidos.
### Lo que hace ahora (desde la 1.16.0)
`build.yml` decide según los secrets que haya en el repo:
| Secrets | Resultado |
| --- | --- |
| ninguno | Firma **ad hoc** (`-s -`) con hardened runtime y los entitlements. Gatekeeper sigue avisando, pero el bundle va sellado y los permisos se aplican. |
| `APPLE_CERTIFICATE` + `APPLE_CERTIFICATE_PASSWORD` | Firma real con **Developer ID**. Gatekeeper deja de avisar en cualquier Mac. |
| …y además `APPLE_ID` + `APPLE_PASSWORD` + `APPLE_TEAM_ID` | Firma real + **notarizado y grapado**. Ni el primer arranque avisa. |
Y hay un paso que **tumba la publicación** si los entitlements no llegaron al `.app`.
Ese paso es la lección: que esto estuviera roto no lo dijo nada durante meses.
### Pasar a Developer ID (99 USD/año)
1. Alta en el Apple Developer Program.
2. En *Certificates* crear uno de tipo **Developer ID Application** y exportarlo del
Llavero como `.p12` con contraseña.
3. Convertirlo a texto: `base64 -i certificado.p12 | pbcopy`.
4. En GitHub → *Settings ▸ Secrets and variables ▸ Actions*, añadir:
- `APPLE_CERTIFICATE` — lo que quedó en el portapapeles.
- `APPLE_CERTIFICATE_PASSWORD` — la contraseña del `.p12`.
- `APPLE_SIGNING_IDENTITY` — opcional; por defecto `Developer ID Application`.
5. Para notarizar, además:
- `APPLE_ID` — el correo de la cuenta de desarrollador.
- `APPLE_PASSWORD` — una **contraseña específica de app** (appleid.apple.com, no la
del Apple ID).
- `APPLE_TEAM_ID` — el identificador de equipo de 10 caracteres.
No hay que tocar ni un archivo: en cuanto los secrets existan, la siguiente etiqueta
sale firmada.
### Mientras siga ad hoc
- **macOS (Gatekeeper):** *"no se puede abrir porque proviene de un desarrollador no
identificado"* → clic derecho sobre la app → **Abrir** → **Abrir**. (Si insiste:
`xattr -cr "/Applications/Labstream OS.app"`.)
- **Windows (SmartScreen):** *"Windows protegió su PC"* → **Más información →
Ejecutar de todos modos**. Windows es aparte: su firma cuesta 150–400 USD/año y no
la toca nada de lo de arriba.
--- ---
@@ -235,10 +309,10 @@ labstream-desktop/
│ ├── tauri.conf.json nombre, versión, iconos, instaladores, entitlements │ ├── tauri.conf.json nombre, versión, iconos, instaladores, entitlements
│ ├── Cargo.toml dependencias Rust + versión │ ├── Cargo.toml dependencias Rust + versión
│ ├── Info.plist textos de permiso (micrófono/cámara) en macOS │ ├── Info.plist textos de permiso (micrófono/cámara) en macOS
│ ├── Entitlements.plist entitlements de macOS (audio-input, cámara, red) │ ├── Entitlements.plist entitlements de macOS (micrófono, cámara, JIT)
│ ├── build.rs │ ├── build.rs
│ ├── capabilities/default.json │ ├── capabilities/default.json
│ ├── icons/ se generan desde app-icon.png │ ├── icons/ se generan desde app-icon.png
│ └── src/{main.rs,lib.rs} lib.rs crea la ventana + SERVER_URL + tray │ └── src/{main.rs,lib.rs} lib.rs crea la ventana + SERVER_URL + tray
└── .github/workflows/build.yml build automático de .exe (Windows) y .dmg (macOS) └── .github/workflows/build.yml build de .exe (Windows) y .dmg (macOS) + firma de macOS
``` ```

97
dist/index.html vendido
Ver fichero

@@ -35,7 +35,16 @@
/* Sobre una franja casi blanca, un hover blanco no se vería: aquí oscurece. */ /* Sobre una franja casi blanca, un hover blanco no se vería: aquí oscurece. */
--hover: rgba(0, 0, 0, 0.06); --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); /* aro del favicon mientras carga */
/* PROFUNDIDAD. La inactiva no lleva un relleno gris —un bloque sólido pesa y compite
con la activa— sino un velo casi imperceptible y una sombra INTERIOR: parece estar un
plano por debajo. Al pasar el ratón cambia de signo, la sombra sale hacia fuera y la
pestaña sube: por eso el hover va hacia el BLANCO de la activa y no hacia el gris. */
--tab-atras: rgba(0, 0, 0, 0.035);
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.055);
--tab-hover-bg: rgba(255, 255, 255, 0.85);
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.10);
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.07);
} }
/* ── Quién decide el tema ── /* ── Quién decide el tema ──
La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su
@@ -57,6 +66,13 @@
--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);
/* En oscuro el movimiento es el mismo pero con luz: la inactiva se hunde con un velo
blanco muy tenue y una sombra negra dentro; al pasar el ratón sube hacia la luz. */
--tab-atras: rgba(255, 255, 255, 0.035);
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.35);
--tab-hover-bg: rgba(255, 255, 255, 0.10);
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.45);
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.35);
} }
} }
:root.oscuro { :root.oscuro {
@@ -69,6 +85,13 @@
--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);
/* En oscuro el movimiento es el mismo pero con luz: la inactiva se hunde con un velo
blanco muy tenue y una sombra negra dentro; al pasar el ratón sube hacia la luz. */
--tab-atras: rgba(255, 255, 255, 0.035);
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.35);
--tab-hover-bg: rgba(255, 255, 255, 0.10);
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.45);
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.35);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { html, body {
@@ -119,53 +142,46 @@
height: 30px; height: 30px;
margin-bottom: 5px; margin-bottom: 5px;
padding: 0 6px 0 10px; padding: 0 6px 0 10px;
border-radius: 9px; /* inactiva: píldora redondeada (hover) */ border-radius: 9px; /* inactiva: píldora redondeada */
position: relative; position: relative;
overflow: visible; /* deja asomar las curvas de la base de la activa */ overflow: visible; /* las sombras viven fuera de la caja */
transition: background 140ms ease, box-shadow 140ms ease, color 140ms ease;
} }
.tab:not(.active):hover { background: var(--hover); } /* ── PROFUNDIDAD: las que no mandan están detrás ──────────────────────────────────
/* Separador fino a la IZQUIERDA de cada inactiva; se oculta pegado a la activa, Antes la inactiva no era nada: texto suelto sobre la franja, al mismo plano que la
al hover y en la primera (igual que Chrome). */ activa, y lo único que las separaba era un filete de 1px. Ahora la inactiva se HUNDE
.tab:not(.active)::before { —velo tenue y sombra interior— y al pasar el ratón el gesto se invierte: la sombra sale
content: ""; hacia fuera y el fondo tira hacia el blanco de la activa, así que la pestaña sube hacia
position: absolute; ti. Es sombra y no relleno a propósito: un bloque gris sólido pesa tanto como la activa
left: -1px; y acaba compitiendo con ella.
top: 50%; Con fondo propio el separador vertical entre pestañas sobra —ya se ve dónde acaba cada
transform: translateY(-50%); una—, así que se retiró. */
width: 1px; .tab:not(.active) {
height: 16px; background: var(--tab-atras);
background: var(--sep); box-shadow: var(--tab-atras-sombra);
}
.tab:not(.active):hover {
background: var(--tab-hover-bg);
box-shadow: var(--tab-hover-sombra);
color: var(--tab-active-fg);
} }
.tab:first-child::before,
.tab:hover::before,
.tab.active + .tab::before,
.tab:hover + .tab::before { display: none; }
.tab.active { .tab.active {
background: var(--tab-active-bg); background: var(--tab-active-bg);
color: var(--tab-active-fg); color: var(--tab-active-fg);
height: 35px; height: 35px;
margin-bottom: 0; /* su base cae 1px SOBRE el borde de la barra y lo tapa */ margin-bottom: 0; /* su base cae 1px SOBRE el borde de la barra y lo tapa */
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
/* Levantada, apenas: la sombra va HACIA ARRIBA porque abajo la pestaña está fundida con
el contenido y una sombra ahí dibujaría una línea donde justamente no debe haberla. */
box-shadow: var(--tab-activa-sombra);
z-index: 1; z-index: 1;
} }
/* Curvas cóncavas de Chrome en la base de la pestaña activa: dos piezas de 8px con /* AQUÍ ESTABAN LAS CURVAS CÓNCAVAS DE CHROME: dos piezas de 8px a los lados de la activa
un cuarto de círculo transparente que "derrama" el fondo hacia los lados. */ con un cuarto de círculo transparente que derramaba su fondo hacia fuera. El truco de
.tab.active::before, Chrome funciona porque allí NO hay línea bajo la barra; aquí sí la hay, y esas dos
.tab.active::after { piezas blancas la partían en dos justo a los costados de la pestaña — la «fluctuación»
content: ""; de la esquina. La activa ya se funde con el contenido por su propio fondo, que tapa la
position: absolute; línea exactamente por su ancho; sin las piezas no asoma nada. */
bottom: 0;
width: 8px;
height: 8px;
}
.tab.active::before {
left: -8px;
background: radial-gradient(circle at 0 0, transparent 8px, var(--tab-active-bg) 8.5px);
}
.tab.active::after {
right: -8px;
background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px);
}
/* ── Color de pestaña ──────────────────────────────────────────────────────────── /* ── Color de pestaña ────────────────────────────────────────────────────────────
Misma idea que los grupos de Chrome: teñir para agrupar de un vistazo («todo lo de 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, este cliente, en verde»). El color se elige con clic derecho sobre la pestaña ▸ Color,
@@ -185,8 +201,6 @@
.tab[data-color="naranja"] { --tc: 250, 144, 62; } .tab[data-color="naranja"] { --tc: 250, 144, 62; }
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); } .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.32); } .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)); } .tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
@media (prefers-color-scheme: dark) { @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) { background: rgba(var(--tc), 0.36); }
@@ -227,6 +241,11 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* La ACTIVA en negrilla: el relieve dice dónde está el plano de delante, pero el peso de
la letra es lo que se lee sin mirar. Va en el título y no en la pestaña entera para que
el aspa de cerrar no engorde con él. 600 y no 700: a este tamaño el bold entero
emborrona, y lo que hace falta es que destaque, no que grite. */
.tab.active .titulo { font-weight: 600; }
.tab .cerrar { .tab .cerrar {
flex: 0 0 auto; flex: 0 0 auto;
width: 18px; width: 18px;

4
package-lock.json generado
Ver fichero

@@ -1,12 +1,12 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.4.0", "version": "1.14.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.4.0", "version": "1.14.1",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
} }

Ver fichero

@@ -1,9 +1,10 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.14.0", "version": "1.18.0",
"private": true, "private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": { "scripts": {
"test": "node --test tests/images.test.cjs",
"icons": "tauri icon app-icon.png", "icons": "tauri icon app-icon.png",
"dev": "npm run icons && tauri dev", "dev": "npm run icons && tauri dev",
"build": "npm run icons && tauri build", "build": "npm run icons && tauri build",

Ver fichero

@@ -2366,7 +2366,7 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.14.0" version = "1.18.0"
dependencies = [ dependencies = [
"active-win-pos-rs", "active-win-pos-rs",
"device_query", "device_query",

Ver fichero

@@ -1,6 +1,6 @@
[package] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.14.0" version = "1.18.0"
description = "Cliente de escritorio de Labstream OS" description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"] authors = ["Labstream SAS"]
edition = "2021" edition = "2021"
@@ -28,6 +28,19 @@ tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# ── Diálogo «Guardar como» de las descargas (on_download en create_tab, lib.rs) ──
# rfd DIRECTO y no tauri-plugin-dialog: el manejador de descargas de wry exige la respuesta en
# el mismo hilo y de forma síncrona (WebView2 DownloadStarting / WKDownload
# decideDestination, ambos en el hilo principal). El `blocking_save_file()` del plugin monta
# el diálogo ASÍNCRONO (rfd::AsyncFileDialog) y espera en un canal; en macOS ese diálogo se
# presenta como hoja (beginSheetModalForWindow) y necesita que el run loop principal siga
# girando… que es justo el hilo que está bloqueado esperando el canal → cuelgue. El
# `rfd::FileDialog` síncrono corre un bucle modal anidado en el propio hilo
# (IFileDialog::Show / NSSavePanel.runModal), que es lo que hace falta aquí.
# Mismas features que usa el plugin oficial (common-controls-v6 = estilo moderno en Windows).
rfd = { version = "0.17", default-features = false, features = ["common-controls-v6"] }
# Para decodificar el último segmento de la URL cuando no hay nombre sugerido (macOS).
percent-encoding = "2"
# ── Rastreador de trabajo efectivo (src/tracker.rs) ── # ── Rastreador de trabajo efectivo (src/tracker.rs) ──
# active-win-pos-rs: la ventana al frente (app + título) en Win/Mac. # active-win-pos-rs: la ventana al frente (app + título) en Win/Mac.
# device_query: ¿hubo entrada? (huella de mouse/teclas presionadas; NUNCA qué se tecleó). # device_query: ¿hubo entrada? (huella de mouse/teclas presionadas; NUNCA qué se tecleó).

Ver fichero

@@ -1,17 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
ENTITLEMENTS DE macOS.
OJO AL ESCRIBIR AQUÍ: nada de dos guiones seguidos dentro de un comentario. XML los prohíbe,
y el que lee este archivo NO es plutil (que los deja pasar) sino AMFI, dentro de codesign,
que se planta con «Failed to parse entitlements». Por eso abajo las opciones de codesign se
nombran sin sus guiones.
Estas claves solo sirven de algo si la app se FIRMA pasándole este archivo y con el hardened
runtime puesto. Hasta el 2026-09-19 no era el caso: el build no firmaba nada y la app salía
con la firma ad hoc del enlazador (flags=0x20002 adhoc linker-signed, Info.plist sin atar,
recursos sin sellar). O sea, este archivo era decoración. Lo que lo arregla está en
.github/workflows/build.yml.
Son entitlements del HARDENED RUNTIME, no del App Sandbox (esta app no usa sandbox). El
hardened runtime cierra por defecto el micrófono, la cámara y la memoria ejecutable; cada
clave vuelve a abrir una, y sin ella la función no falla con un error claro: simplemente no
hay permiso.
Se cayó com.apple.security.network.client: esa es exclusiva del App Sandbox y, sin
com.apple.security.app-sandbox, no hace absolutamente nada. Si algún día se mete la app en el
sandbox, vuelve, junto con las de acceso a archivos que harán falta.
-->
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Micrófono: notas de voz del chat (getUserMedia en el webview). --> <!-- Micrófono: notas de voz del chat (getUserMedia en el webview). El texto que ve la
persona al conceder el permiso vive en Info.plist (NSMicrophoneUsageDescription). -->
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<!-- Cámara: adjuntar foto/video en el chat. --> <!-- Cámara: adjuntar foto y video en el chat (NSCameraUsageDescription). -->
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<!-- Cliente de red (conecta con os.labstreamsas.com). --> <!-- WKWebView compila JavaScript en caliente. Bajo hardened runtime eso es memoria
<key>com.apple.security.network.client</key> ejecutable y hay que pedirlo explícitamente. -->
<true/>
<!-- Requerido por WKWebView (JIT de JavaScript) bajo hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key> <key>com.apple.security.cs.allow-jit</key>
<true/> <true/>
</dict> </dict>

Ver fichero

@@ -23,6 +23,8 @@
// - Globo de NO-LEÍDOS en el icono (evento `ls-badge` desde la web app): número en el // - Globo de NO-LEÍDOS en el icono (evento `ls-badge` desde la web app): número en el
// Dock (macOS), punto rojo en la barra de tareas (Windows) y tooltip de la bandeja. // Dock (macOS), punto rojo en la barra de tareas (Windows) y tooltip de la bandeja.
// - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones. // - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones.
// - DESCARGAS con «Guardar como»: el sistema pregunta dónde guardar cada archivo (con el
// nombre que sugiere el servidor) en vez de dejarlo caer en silencio en Descargas.
// - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana. // - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana.
// - single-instance (solo release): abrir la app de nuevo enfoca la ventana existente. // - single-instance (solo release): abrir la app de nuevo enfoca la ventana existente.
// - macOS: clic en el Dock reabre la ventana oculta (RunEvent::Reopen) y menú nativo con // - macOS: clic en el Dock reabre la ventana oculta (RunEvent::Reopen) y menú nativo con
@@ -35,7 +37,11 @@
mod cache; mod cache;
mod tracker; mod tracker;
use std::sync::Mutex; use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Mutex,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -54,11 +60,7 @@ use tauri_plugin_opener::OpenerExt;
const SERVER_URL: &str = "https://os.labstreamsas.com"; const SERVER_URL: &str = "https://os.labstreamsas.com";
// Los documentos del contador viven en Google (pestaña Contable). Se abren como PESTANAS // Google requiere el navegador del sistema para autenticar y editar con compatibilidad completa.
// de esta app, no en el navegador externo: asi el WebView guarda la sesion de Google del
// usuario (cookies persistentes) y no hay que iniciar sesion cada vez. La lista es cerrada
// a proposito: solo hosts propios de Google (docs/drive/sheets y accounts para el login).
// Todo lo demas sigue yendo al navegador del sistema.
const GOOGLE_HOSTS: &[&str] = &[ const GOOGLE_HOSTS: &[&str] = &[
"docs.google.com", "docs.google.com",
"drive.google.com", "drive.google.com",
@@ -67,29 +69,29 @@ const GOOGLE_HOSTS: &[&str] = &[
"myaccount.google.com", "myaccount.google.com",
]; ];
// Cadena de navegador de escritorio. Sin esto, WebView2 se anuncia como control embebido y
// Google Sheets/Docs se quedan en blanco. Se declara por plataforma porque Google tambien
// mira la coherencia entre el sistema declarado y el resto de la cadena.
#[cfg(target_os = "windows")]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
#[cfg(target_os = "macos")]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
const UA_ESCRITORIO: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
fn is_google_host(host: &str) -> bool { fn is_google_host(host: &str) -> bool {
let h = host.to_ascii_lowercase(); let h = host.to_ascii_lowercase();
GOOGLE_HOSTS.iter().any(|d| h == *d || h.ends_with(&format!(".{}", d))) GOOGLE_HOSTS.iter().any(|d| h == *d || h.ends_with(&format!(".{}", d)))
} }
fn es_pestana_interna(target: &Url, server: &Url) -> bool { fn es_pestana_interna(target: &Url, server: &Url) -> bool {
if target.origin() == server.origin() { target.origin() == server.origin()
return true;
} }
if target.scheme() != "https" {
return false; #[cfg(test)]
mod navigation_tests {
use super::*;
#[test]
fn only_labstream_is_an_embedded_tab() {
let server = Url::parse(SERVER_URL).unwrap();
assert!(es_pestana_interna(&server.join("/revisiones/1").unwrap(), &server));
for url in ["https://docs.google.com/document/d/1/edit", "https://accounts.google.com", "https://os.labstreamsas.com.evil.example", "http://os.labstreamsas.com"] {
assert!(!es_pestana_interna(&Url::parse(url).unwrap(), &server));
}
assert!(is_google_host("docs.google.com"));
assert!(!is_google_host("docs.google.com.evil.example"));
} }
target.host_str().map(is_google_host).unwrap_or(false)
} }
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome) const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
const CHROME: &str = "chrome"; const CHROME: &str = "chrome";
@@ -152,6 +154,15 @@ impl Default for Shell {
type ShellState = Mutex<Shell>; type ShellState = Mutex<Shell>;
// Rutas elegidas en «Guardar como» de las descargas EN CURSO, por URL de origen. Hace falta para
// nombrar el archivo al terminar: en macOS `DownloadEvent::Finished` llega SIEMPRE con
// `path: None` (wry-0.55.1/src/wkwebview/download.rs:98-99 pasa `None` a mano), así que la única
// forma de decir «Descargado: informe.xlsx» es recordar aquí lo que el usuario eligió. La URL es
// la misma en Requested y en Finished en ambos motores (DownloadOperation.Uri en WebView2;
// originalRequest.URL en WKDownload).
#[derive(Default)]
struct Descargas(Mutex<HashMap<String, PathBuf>>);
#[derive(Clone, Copy, Serialize, Deserialize)] #[derive(Clone, Copy, Serialize, Deserialize)]
struct WinRect { struct WinRect {
x: f64, x: f64,
@@ -243,12 +254,7 @@ const INIT_JS_TPL: &str = r#"
// descargar fallaban solo en la app de Windows, y no en Mac ni en el navegador. // 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. // 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; } try { if (window.top !== window.self) return; } catch (e) { return; }
// Hosts de Google que la app abre como pestana propia (pestana Contable). En esas if (location.origin !== ORIGIN) return;
// paginas solo reportamos titulo a la barra; NO tocamos clics ni window.open para no
// romper el editor de Google.
var GOOGLE_RE = /^(?:.*\.)?(?:docs|drive|sheets|accounts|myaccount)\.google\.com$/i;
var esGoogle = GOOGLE_RE.test(location.hostname);
if (location.origin !== ORIGIN && !esGoogle) return;
if (window.__lsShell) return; if (window.__lsShell) return;
window.__lsShell = true; window.__lsShell = true;
@@ -259,8 +265,7 @@ const INIT_JS_TPL: &str = r#"
function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } } function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } }
function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); } function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); }
function isApp(u) { return isHttp(u) && u.origin === ORIGIN; } function isApp(u) { return isHttp(u) && u.origin === ORIGIN; }
function isGoogleUrl(u) { return isHttp(u) && GOOGLE_RE.test(u.hostname); } function isInterno(u) { return isApp(u); }
function isInterno(u) { return isApp(u) || isGoogleUrl(u); }
function isExt(u) { return isHttp(u) && !isInterno(u); } function isExt(u) { return isHttp(u) && !isInterno(u); }
function emit(name, payload) { try { T.event.emit(name, payload); } catch (e) {} } function emit(name, payload) { try { T.event.emit(name, payload); } catch (e) {} }
function openExternal(url) { function openExternal(url) {
@@ -269,73 +274,10 @@ const INIT_JS_TPL: &str = r#"
} }
function newTab(url) { emit('ls-new-tab', { url: url }); } function newTab(url) { emit('ls-new-tab', { url: url }); }
// ── Miniaturas por el CAJÓN de la app (lsthumb://) ── // Las imagenes conservan su URL HTTPS y la sesion del webview. El descargador
// Dentro de la app de escritorio, las miniaturas de la cuadrícula de fotos se piden por el // lsthumb no comparte cookies: falla con miniaturas sin token y omite la
// esquema lsthumb:// en vez de directo al servidor. Así las sirve el cajón local (Rust): del // revalidacion de permisos al leer del disco. La cache HTTP respeta los ETag
// disco si ya están —sobreviven a cerrar la app, que es lo que se pedía— y del NAS solo la // y Cache-Control del servidor sin modificar src, srcset ni la hidratacion.
// primera vez. Fuera de la app (navegador normal) este script no corre y todo sigue igual.
//
// Se reescribe SOLO la miniatura de 480 px (thumb=1): es la que se repite 500 veces por set y
// ahoga la cuadrícula. El visor grande (thumb=xl) y la anotación sobre lienzo NO se tocan —
// cargar la imagen del lienzo por otro esquema lo «ensuciaría» y rompería «guardar el dibujo».
try {
function porCajon(src) {
if (!src || src.lastIndexOf('lsthumb:', 0) === 0) return src; // ya reescrita
var u = abs(src);
if (!u || u.origin !== ORIGIN) return src; // solo lo del propio servidor
if (!/\/api\/files-asset\//.test(u.pathname) || u.searchParams.get('thumb') !== '1') return src;
return 'lsthumb://c/?u=' + encodeURIComponent(u.href);
}
var proto = HTMLImageElement.prototype;
// 1) La propiedad .src (lo que usa React al asignar la imagen).
var d = Object.getOwnPropertyDescriptor(proto, 'src');
if (d && d.get && d.set) {
Object.defineProperty(proto, 'src', {
configurable: true, enumerable: d.enumerable,
get: function () { return d.get.call(this); },
set: function (v) { d.set.call(this, porCajon(String(v))); },
});
}
// 2) setAttribute('src', …), que no pasa por el setter de la propiedad. Se parchea SOLO en
// <img> (prototipo de HTMLImageElement), no en todos los elementos.
var setA = proto.setAttribute;
proto.setAttribute = function (name, value) {
if (arguments.length >= 2 && String(name).toLowerCase() === 'src') {
return setA.call(this, name, porCajon(String(value)));
}
return setA.apply(this, arguments);
};
// 3) Las que ya vienen en el HTML del servidor (SSR) o llegan luego. porCajon es idempotente
// (una ya reescrita se devuelve igual), así que barrer no entra en bucle con el observer.
function barrer(raiz) {
if (!raiz || !raiz.querySelectorAll) return;
var imgs = raiz.querySelectorAll('img[src]');
for (var i = 0; i < imgs.length; i++) {
var cur = imgs[i].getAttribute('src');
var nue = porCajon(cur);
if (nue !== cur) imgs[i].setAttribute('src', nue);
}
}
if (window.MutationObserver) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var m = muts[i];
if (m.type === 'attributes' && m.target && m.target.tagName === 'IMG') {
var cur = m.target.getAttribute('src'), nue = porCajon(cur);
if (nue !== cur) m.target.setAttribute('src', nue);
}
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (!n || n.nodeType !== 1) continue;
if (n.tagName === 'IMG') { var c = n.getAttribute('src'), x = porCajon(c); if (x !== c) n.setAttribute('src', x); }
else barrer(n);
}
}
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { barrer(document); });
else barrer(document);
} catch (e) { /* si algo falla, las imágenes cargan directo del servidor como siempre */ }
// ── ¿La página se VE oscura? ── // ── ¿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 // Señales, de la más directa a la más física: la clase `dark` (next-themes) y, si no
@@ -388,7 +330,6 @@ 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); });
if (esGoogle) return; // en Google solo miramos el titulo
// Pulsar la luna cambia clases/estilos sin navegar: se vigilan <html> y <body> (cuando // 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 // 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. // por si el tema aterriza tarde con la hidratación.
@@ -411,9 +352,6 @@ const INIT_JS_TPL: &str = r#"
if (!u) return; if (!u) return;
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; } if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
var mod = e.metaKey || e.ctrlKey; var mod = e.metaKey || e.ctrlKey;
// Enlace a Google (docs, drive, sheets, accounts): SIEMPRE como pestana propia
// aunque no lleve target=_blank, para que la sesion viva dentro de la app.
if (isGoogleUrl(u)) { e.preventDefault(); newTab(u.href); return; }
if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); } if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); }
}, true); }, true);
@@ -443,6 +381,10 @@ const INIT_JS_TPL: &str = r#"
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey). // nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
if (e.altKey) return; if (e.altKey) return;
var mod = e.metaKey || e.ctrlKey; var mod = e.metaKey || e.ctrlKey;
// F5 recarga, como en cualquier navegador de Windows (y Ctrl+F5 / Shift+F5 también). WebView2
// tiene apagados sus propios atajos, así que F5 no hacía NADA: el único que recargaba era
// Ctrl+R, y va antes del filtro de abajo porque F5 no lleva modificador.
if (e.key === 'F5') { e.preventDefault(); location.reload(); return; }
if (!mod) return; if (!mod) return;
var k = e.key; var k = e.key;
if (k === 't' || k === 'T') { if (k === 't' || k === 'T') {
@@ -587,6 +529,99 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
save_persisted(app); save_persisted(app);
} }
// ── Descargas: «Guardar como» ──
//
// tauri::webview::DownloadEvent avisa DOS veces por descarga:
// - Requested { url, destination: &mut PathBuf }: antes de empezar. `destination` trae la ruta
// que el motor usaría por defecto (carpeta Descargas + nombre sugerido) y se puede
// SOBRESCRIBIR (ruta absoluta); devolver false cancela la descarga.
// - Finished { url, path: Option<PathBuf>, success }: al terminar, bien o mal.
//
// Cómo llega `destination` según el motor:
// - Windows (WebView2): ICoreWebView2DownloadStartingEventArgs::ResultFilePath, que ya lleva el
// nombre del Content-Disposition (o del último segmento de la URL) y « (1)», « (2)»… si el
// archivo ya existe en Descargas. Si devolvemos true, wry hace SetResultFilePath(destination)
// + SetHandled(true) (wry-0.55.1/src/webview2/mod.rs:849-864); si false, SetCancel(true).
// - macOS (WKWebView): wry compone ~/Downloads/<suggestedFilename de WebKit> con el mismo
// esquema de « (n)» y, si devolvemos true, entrega esa ruta al completion handler de
// WKDownload como destino (wry-0.55.1/src/wkwebview/download.rs:49-91). El nombre viene
// siempre relleno. LÍMITE: WKWebView solo trata como descarga (a) los enlaces con atributo
// `download` (shouldPerformDownload) y (b) las respuestas cuyo MIME no sabe mostrar
// (canShowMIMEType == false; wkwebview/navigation.rs:59-73 y 93-100). WebKit NO mira
// Content-Disposition al decidir (WebPage::canShowResponse consulta solo el MIME), así que un
// PDF o una imagen servidos como attachment se MUESTRAN en la pestaña en vez de bajar; un
// .xlsx/.docx/.zip sí dispara la descarga porque WebKit no los renderiza.
// Abre el «Guardar como» nativo y deja en `destination` la ruta elegida. Devuelve false (y wry
// cancela la descarga) si el usuario cierra el diálogo; en ese caso no se avisa de nada.
//
// El diálogo es SÍNCRONO a propósito: el manejador de wry tiene que responder antes de volver y
// en el mismo hilo (el principal, donde WebView2 y WKWebView despachan estos eventos; wry no
// expone el deferral de WebView2). Por eso va rfd::FileDialog directo y no tauri-plugin-dialog:
// su `blocking_save_file()` monta un rfd::AsyncFileDialog y espera en un canal (macro
// blocking_fn en plugins/dialog/src/lib.rs); en macOS ese diálogo asíncrono se presenta como
// hoja sobre la ventana principal (beginSheetModalForWindow) y solo se muestra y resuelve si el
// run loop principal gira (rfd/src/backend/macos/modal_future.rs:79-102)… que es justo el hilo
// que estaría parado en recv() → cuelgue. rfd::FileDialog::save_file corre un bucle modal
// anidado en el propio hilo (NSSavePanel.runModal / IFileDialog::Show), que es lo que hace
// falta aquí. La ventana va como padre para que en Windows el diálogo sea modal a la app y no
// se quede detrás. No se fuerza carpeta inicial: el sistema recuerda la última usada.
fn pedir_destino_descarga<R: Runtime>(webview: &Webview<R>, url: &Url, destination: &mut PathBuf) -> bool {
let ventana = webview.window();
let dialogo = rfd::FileDialog::new()
.set_title("Guardar como")
.set_file_name(nombre_sugerido(url, destination))
.set_parent(&ventana);
match dialogo.save_file() {
Some(ruta) => {
webview.state::<Descargas>().0.lock().unwrap().insert(url.to_string(), ruta.clone());
*destination = ruta;
true
}
None => false,
}
}
// Nombre que propone el diálogo: el que trae `destination` (WebView2 lo saca del
// Content-Disposition que manda el servidor; en macOS wry lo toma del suggestedFilename de
// WebKit) y, si viniera vacío, el último segmento de la URL decodificado. Nunca vacío.
fn nombre_sugerido(url: &Url, destination: &Path) -> String {
let del_motor = destination
.file_name()
.map(|n| n.to_string_lossy().trim().to_string())
.filter(|n| !n.is_empty());
if let Some(n) = del_motor {
return n;
}
url.path_segments()
.and_then(|segs| segs.rev().find(|s| !s.is_empty()))
.map(|s| percent_encoding::percent_decode_str(s).decode_utf8_lossy().trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "descarga".to_string())
}
// Notificación nativa al terminar la descarga. En Windows `path` es la ruta final real (puede
// diferir de la elegida si el motor tuvo que renombrar); en macOS llega None y se usa la ruta
// que el usuario eligió en el diálogo (ver `Descargas`).
fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path: Option<PathBuf>, success: bool) {
let elegida = webview.state::<Descargas>().0.lock().unwrap().remove(url.as_str());
let cuerpo = if !success {
"No se pudo completar la descarga.".to_string()
} else {
match path.or(elegida).as_deref().and_then(Path::file_name) {
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
None => "Descarga terminada.".to_string(),
}
};
let _ = webview
.app_handle()
.notification()
.builder()
.title("Labstream OS")
.body(cuerpo)
.show();
}
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío). // Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) { fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
let target = url.unwrap_or_else(|| SERVER_URL.to_string()); let target = url.unwrap_or_else(|| SERVER_URL.to_string());
@@ -612,10 +647,19 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL); let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL);
let app_zoom = app.clone(); let app_zoom = app.clone();
let app_navigation = app.clone();
let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed)) let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed))
.user_agent(UA_ESCRITORIO)
.initialization_script(&init) .initialization_script(&init)
// Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar. .on_navigation(move |url| {
if url.scheme() == "https" && url.host_str().map(is_google_host).unwrap_or(false) {
let _ = app_navigation.opener().open_url(url.as_str(), None::<&str>);
return false;
}
true
})
// Descargas (entregables, exportaciones, enlaces `?download=1`): antes de empezar se
// PREGUNTA dónde guardar con el «Guardar como» nativo (ver pedir_destino_descarga) y al
// terminar se avisa con una notificación (ver avisar_descarga_terminada).
// El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE // 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), // 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) // asi que DownloadStarting se registra siempre y siempre responde SetHandled(true)
@@ -623,30 +667,14 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
// se encarga la app: ni barra de progreso, ni globo de «descarga terminada». El // 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. // 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. // Quitar este manejador NO devuelve la interfaz nativa: el de wry sigue puesto.
.on_download(|webview, event| { .on_download(|webview, event| match event {
if let DownloadEvent::Finished { path, success, .. } = &event { DownloadEvent::Requested { url, destination } => pedir_destino_descarga(&webview, &url, destination),
let cuerpo = if !*success { DownloadEvent::Finished { url, path, success, .. } => {
"No se pudo completar la descarga.".to_string() avisar_descarga_terminada(&webview, &url, path, success);
} 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 true
}
// El enum es #[non_exhaustive]: si wry añade eventos, dejarlos pasar.
_ => true,
}) })
// El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación). // El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación).
.on_page_load(move |wv, payload| { .on_page_load(move |wv, payload| {
@@ -1237,6 +1265,7 @@ pub fn run() {
let app = builder let app = builder
.manage(ShellState::default()) .manage(ShellState::default())
.manage(Descargas::default())
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
let saved = load_persisted(&handle); let saved = load_persisted(&handle);

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.14.0", "version": "1.18.0",
"identifier": "co.labstream.os", "identifier": "co.labstream.os",
"build": { "build": {
"frontendDist": "../dist" "frontendDist": "../dist"

84
tests/images.browser.cjs Archivo normal
Ver fichero

@@ -0,0 +1,84 @@
// Run with Playwright available: node tests/images.browser.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const { chromium, webkit } = require('playwright');
const root = path.join(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'src-tauri/src/lib.rs'), 'utf8');
const template = source.match(/const INIT_JS_TPL: &str = r#"([\s\S]*?)"#;/)[1];
const png = fs.readFileSync(path.join(root, 'app-icon.png'));
const urls = [
'/api/files-asset/session?thumb=1',
'/api/files-asset/signed?thumb=1&t=expired.sig',
'/api/files-asset/poster?poster=1',
'/api/files-asset/large?thumb=xl',
'/api/files-asset/original',
];
const counts = { authenticated: 0, revalidated: 0, unauthorized: 0 };
const server = http.createServer((req, res) => {
if (req.url.startsWith('/api/files-asset/')) {
if (req.headers.cookie !== 'test-session=ok') {
counts.unauthorized++;
res.writeHead(401, { 'Cache-Control': 'no-store' }).end();
return;
}
counts.authenticated++;
const headers = { 'Content-Type': 'image/png', 'Cache-Control': 'private, no-cache', ETag: '"cover-1"' };
if (req.headers['if-none-match'] === '"cover-1"') {
counts.revalidated++;
res.writeHead(304, headers).end();
} else res.writeHead(200, headers).end(png);
return;
}
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
res.end(`<html><title>Portadas: prueba local</title><body>
${urls.map((url, i) => `<img id="image-${i}" width="120" height="120" src="${url}">`).join('')}
<canvas width="16" height="16"></canvas></body></html>`);
});
(async () => {
let browser;
try {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
browser = await (process.env.TEST_BROWSER === 'webkit' ? webkit : chromium).launch({ headless: true });
const context = await browser.newContext();
await context.addCookies([{ name: 'test-session', value: 'ok', url: origin, httpOnly: true }]);
await context.addInitScript(`window.__TAURI__ = { event: { emit() {} } };`);
await context.addInitScript(template.replaceAll('__ORIGIN__', origin).replaceAll('__LABEL__', 'tab-test'));
const page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(origin);
await page.waitForFunction(() => [...document.images].every((image) => image.complete && image.naturalWidth > 0));
assert.deepEqual(await page.locator('img').evaluateAll((images) => images.map((image) => image.getAttribute('src'))), urls);
// Cached images must still be readable by the annotation canvas.
assert.equal(await page.evaluate(() => {
const canvas = document.querySelector('canvas');
canvas.getContext('2d').drawImage(document.images[0], 0, 0, 16, 16);
return canvas.toDataURL().startsWith('data:image/png;');
}), true);
await page.reload();
await page.waitForFunction(() => [...document.images].every((image) => image.complete && image.naturalWidth > 0));
assert.ok(counts.revalidated >= urls.length, 'HTTP cache revalidation expected on reload');
await page.evaluate(() => {
const image = new Image();
image.id = 'dynamic';
image.setAttribute('src', '/api/files-asset/dynamic?thumb=1');
document.body.appendChild(image);
});
await page.waitForFunction(() => document.getElementById('dynamic').naturalWidth > 0);
// A native downloader without the webview session reproduces the 401.
assert.equal((await fetch(origin + urls[0])).status, 401);
await context.clearCookies();
await page.reload();
assert.equal(await page.locator('img').evaluateAll((images) => images.filter((image) => image.naturalWidth > 0).length), 0);
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, covers: urls.length, dynamic: true, canvas: true, ...counts }));
} finally {
if (browser) await browser.close();
await new Promise((resolve) => server.close(resolve));
}
})().catch((error) => { console.error(error); process.exitCode = 1; });

102
tests/images.test.cjs Archivo normal
Ver fichero

@@ -0,0 +1,102 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src-tauri/src/lib.rs'), 'utf8');
const template = source.match(/const INIT_JS_TPL: &str = r#"([\s\S]*?)"#;/)[1];
const origin = 'https://os.labstreamsas.com';
function shell({ frame = false, host = origin, images = [] } = {}) {
class Image {
constructor(src = '') { this.attrs = { src }; }
get src() { return this.attrs.src; }
set src(value) { this.attrs.src = value; }
getAttribute(name) { return this.attrs[name]; }
setAttribute(name, value) { this.attrs[name] = value; }
}
const setter = Object.getOwnPropertyDescriptor(Image.prototype, 'src').set;
const setAttribute = Image.prototype.setAttribute;
const emitted = [];
const opened = [];
const listeners = {};
const nodes = images.map((src) => new Image(src));
const root = { classList: { contains: () => false } };
const win = {
__TAURI__: { event: { emit: (...args) => emitted.push(args) }, core: { invoke: (...args) => opened.push(args) } },
open: () => 'original',
};
win.self = win;
win.top = frame ? {} : win;
const sandbox = {
window: win, URL, HTMLImageElement: Image,
location: new URL(host + '/proyectos/ejemplo'),
document: {
readyState: 'complete', title: 'Entregables', documentElement: root, body: root,
querySelector: () => null, querySelectorAll: () => nodes,
addEventListener: (name, callback) => { listeners[name] = callback; },
},
history: {}, navigator: { platform: 'MacIntel' },
getComputedStyle: () => ({ backgroundColor: 'rgb(255, 255, 255)' }),
addEventListener: (name, callback) => { listeners[name] = callback; },
setTimeout: () => 0,
};
vm.runInNewContext(template.replaceAll('__ORIGIN__', origin).replaceAll('__LABEL__', 'tab-1'), sandbox);
return { Image, nodes, setter, setAttribute, win, emitted, opened, listeners };
}
test('does not replace native image setters or rewrite server-rendered covers', () => {
const urls = ['/api/files-asset/cover?thumb=1', '/api/files-asset/cover?t=123.sig&thumb=1'];
const state = shell({ images: urls });
assert.equal(Object.getOwnPropertyDescriptor(state.Image.prototype, 'src').set, state.setter);
assert.equal(state.Image.prototype.setAttribute, state.setAttribute);
assert.deepEqual(state.nodes.map((image) => image.src), urls);
});
for (const url of [
'/api/files-asset/cover?thumb=1',
'/api/files-asset/cover?t=expired.sig&thumb=1&v=abc',
'/api/files-asset/cover?thumb=xl',
'/api/files-asset/video?poster=1',
'/api/files-asset/original',
'https://other.example/image.webp',
]) {
test(`keeps authenticated browser transport for ${url}`, () => {
const { Image } = shell();
const image = new Image();
image.src = url;
assert.equal(image.src, url);
image.setAttribute('src', url + '&r=1');
assert.equal(image.src, url + '&r=1');
});
}
test('tab opening and title reports still work', () => {
const { win, emitted } = shell();
win.open('/revisiones/123', '_blank');
assert.equal(emitted[0][0], 'ls-title');
assert.equal(emitted[0][1].title, 'Entregables');
assert.equal(emitted[1][0], 'ls-new-tab');
assert.equal(emitted[1][1].url, origin + '/revisiones/123');
});
test('does not interfere with embedded editors or unrelated origins', () => {
for (const options of [{ frame: true }, { host: 'https://docs.example.com' }, { host: 'https://docs.google.com' }]) {
const { win, emitted } = shell(options);
assert.equal(win.__lsShell, undefined);
assert.equal(win.open(), 'original');
assert.equal(emitted.length, 0);
}
});
test('Google editors and login use the system browser, never an embedded tab', () => {
for (const host of ['docs', 'sheets', 'drive', 'accounts']) {
const { win, emitted, opened } = shell();
const url = `https://${host}.google.com/example`;
win.open(url, '_blank');
assert.equal(emitted.filter(([name]) => name === 'ls-new-tab').length, 0);
assert.equal(opened[0][0], 'plugin:opener|open_url');
assert.equal(opened[0][1].url, url);
}
});