Comparar commits

...

7 Commits

Autor SHA1 Mensaje Fecha
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
Munka
a10805c51b v1.14.0: las pestanas se presentan como navegador de escritorio
Google degrada sus apps cuando detecta un WebView embebido: Sheets abria en blanco y la
sesion no terminaba de asentarse aunque la cuenta ya estuviera iniciada. Ahora cada
pestana declara un User-Agent de Chrome de escritorio (por plataforma), que es lo que
hacen los clientes de escritorio que embeben Google.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:12:37 -05:00
Munka
d287b4e647 v1.13.0: los documentos del contador se abren dentro de la app
Bump de version. La novedad esta en el commit anterior: hosts de Google (docs, drive,
sheets, accounts, myaccount) se abren como pestanas propias del shell en vez de mandarse
al navegador del sistema, para que la sesion de Google viva dentro de la app y las hojas
del contador se vean en la pestana Contable sin pedir cookies cada vez.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-14 11:46:02 -05:00
Munka
d847aca9a7 Los documentos del contador (Google) se abren como pestanas de esta app
La app de escritorio es un navegador aparte sin la sesion de Google del usuario. Antes
un enlace de docs.google.com se abria en el navegador del sistema y Google pedia
Permitir cookies dentro de un marco que nadie podia contestar. Ahora hosts de Google
(docs / drive / sheets / accounts / myaccount) se abren como PESTANAS internas del shell,
y el WebView2 guarda la sesion de Google como cualquier otra cookie: se inicia sesion
UNA vez y las hojas del contador se ven y editan en la pestana Contable.

- create_tab acepta URLs con scheme https y host en GOOGLE_HOSTS ademas del origen propio.
- La restauracion de sesion respeta el mismo criterio (una pestana de Google guardada
  reabre como pestana de Google).
- El script inyectado detecta docs.google.com/... y hace newTab en vez de openExternal;
  en paginas de Google solo reporta el titulo (sin tocar clics, atajos ni window.open)
  para no interferir con el editor de Google.

Falta probar el instalador de prueba antes de etiquetar la version.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-14 11:45:40 -05:00
Se han modificado 11 ficheros con 420 adiciones y 105 borrados

Ver fichero

@@ -51,6 +51,9 @@ 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
- name: Construir, firmar y publicar - name: Construir, firmar y publicar
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
@@ -63,6 +66,18 @@ jobs:
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 }}
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

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).

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.12.0", "version": "1.15.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.12.0" version = "1.15.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.12.0" version = "1.15.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

@@ -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;
@@ -53,6 +59,40 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
const SERVER_URL: &str = "https://os.labstreamsas.com"; const SERVER_URL: &str = "https://os.labstreamsas.com";
// Google requiere el navegador del sistema para autenticar y editar con compatibilidad completa.
const GOOGLE_HOSTS: &[&str] = &[
"docs.google.com",
"drive.google.com",
"sheets.google.com",
"accounts.google.com",
"myaccount.google.com",
];
fn is_google_host(host: &str) -> bool {
let h = host.to_ascii_lowercase();
GOOGLE_HOSTS.iter().any(|d| h == *d || h.ends_with(&format!(".{}", d)))
}
fn es_pestana_interna(target: &Url, server: &Url) -> bool {
target.origin() == server.origin()
}
#[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"));
}
}
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";
// Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese // Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese
@@ -114,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,
@@ -205,7 +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; }
if (location.origin !== ORIGIN) return; // p. ej. el login del SSO: no intervenir if (location.origin !== ORIGIN) return;
if (window.__lsShell) return; if (window.__lsShell) return;
window.__lsShell = true; window.__lsShell = true;
@@ -216,7 +265,8 @@ 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 isExt(u) { return isHttp(u) && u.origin !== ORIGIN; } function isInterno(u) { return isApp(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) {
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); } try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
@@ -224,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
@@ -375,14 +362,14 @@ const INIT_JS_TPL: &str = r#"
if (!a) return; if (!a) return;
var u = abs(a.getAttribute('href')); var u = abs(a.getAttribute('href'));
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; } if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
if (isApp(u)) { e.preventDefault(); newTab(u.href); } if (isInterno(u)) { e.preventDefault(); newTab(u.href); }
}, true); }, true);
var _open = window.open; var _open = window.open;
window.open = function (url, name, feats) { window.open = function (url, name, feats) {
var u = url ? abs(url) : null; var u = url ? abs(url) : null;
if (isExt(u)) { openExternal(u.href); return null; } if (isExt(u)) { openExternal(u.href); return null; }
if (isApp(u)) { newTab(u.href); return null; } if (isInterno(u)) { newTab(u.href); return null; }
return _open ? _open.call(window, url, name, feats) : null; return _open ? _open.call(window, url, name, feats) : null;
}; };
@@ -538,13 +525,106 @@ 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());
// Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador. // Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador.
let Ok(parsed) = Url::parse(&target) else { return }; let Ok(parsed) = Url::parse(&target) else { return };
let Ok(server) = Url::parse(SERVER_URL) else { return }; let Ok(server) = Url::parse(SERVER_URL) else { return };
if parsed.origin() != server.origin() { if !es_pestana_interna(&parsed, &server) {
return; return;
} }
@@ -563,9 +643,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))
.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)
@@ -573,30 +663,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| {
@@ -1187,6 +1261,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);
@@ -1400,7 +1475,7 @@ pub fn run() {
.tabs .tabs
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, u)| Url::parse(u).map(|p| p.origin() == server.origin()).unwrap_or(false)) .filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12) .take(12)
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default())) .map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
.collect() .collect()

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.12.0", "version": "1.15.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);
}
});