Comparar commits
5 Commits
google-tab
...
descargas-
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
|
b4c4875cf6 | ||
|
|
261acc0201 | ||
|
|
1bdc7d0bba | ||
|
|
013d1e7759 | ||
|
|
a10805c51b |
17
.github/workflows/build.yml
vendido
17
.github/workflows/build.yml
vendido
@@ -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
|
||||||
|
|||||||
3
.github/workflows/check.yml
vendido
3
.github/workflows/check.yml
vendido
@@ -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
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -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
4
package-lock.json
generado
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "labstream-desktop",
|
"name": "labstream-desktop",
|
||||||
"version": "1.13.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",
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generado
2
src-tauri/Cargo.lock
generado
@@ -2366,7 +2366,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "labstream-desktop"
|
name = "labstream-desktop"
|
||||||
version = "1.13.0"
|
version = "1.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"active-win-pos-rs",
|
"active-win-pos-rs",
|
||||||
"device_query",
|
"device_query",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "labstream-desktop"
|
name = "labstream-desktop"
|
||||||
version = "1.13.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ó).
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -73,13 +75,23 @@ fn is_google_host(host: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
}
|
}
|
||||||
if target.scheme() != "https" {
|
assert!(is_google_host("docs.google.com"));
|
||||||
return false;
|
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";
|
||||||
@@ -142,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,
|
||||||
@@ -233,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;
|
||||||
@@ -249,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) {
|
||||||
@@ -259,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
|
||||||
@@ -378,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.
|
||||||
@@ -401,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);
|
||||||
|
|
||||||
@@ -577,6 +525,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());
|
||||||
@@ -602,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)
|
||||||
@@ -612,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| {
|
||||||
@@ -1226,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);
|
||||||
|
|||||||
@@ -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.13.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
84
tests/images.browser.cjs
Archivo normal
@@ -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
102
tests/images.test.cjs
Archivo normal
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Referencia en una nueva incidencia
Block a user