Comparar commits
5 Commits
| 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)
|
||||
run: npm run icons
|
||||
|
||||
- name: Probar el cliente
|
||||
run: npm test
|
||||
|
||||
- name: Construir, firmar y publicar
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -63,6 +66,18 @@ jobs:
|
||||
tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__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."
|
||||
releaseDraft: false
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
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
|
||||
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.
|
||||
- name: Generar iconos (desde app-icon.png)
|
||||
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`.
|
||||
|
||||
### 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:
|
||||
|
||||
- **Windows** → instalador `.exe` (NSIS).
|
||||
|
||||
4
package-lock.json
generado
4
package-lock.json
generado
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "labstream-desktop",
|
||||
"version": "1.4.0",
|
||||
"version": "1.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "labstream-desktop",
|
||||
"version": "1.4.0",
|
||||
"version": "1.14.1",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "labstream-desktop",
|
||||
"version": "1.13.0",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
|
||||
"scripts": {
|
||||
"test": "node --test tests/images.test.cjs",
|
||||
"icons": "tauri icon app-icon.png",
|
||||
"dev": "npm run icons && tauri dev",
|
||||
"build": "npm run icons && tauri build",
|
||||
|
||||
2
src-tauri/Cargo.lock
generado
2
src-tauri/Cargo.lock
generado
@@ -2366,7 +2366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "labstream-desktop"
|
||||
version = "1.13.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"active-win-pos-rs",
|
||||
"device_query",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "labstream-desktop"
|
||||
version = "1.13.0"
|
||||
version = "1.15.0"
|
||||
description = "Cliente de escritorio de Labstream OS"
|
||||
authors = ["Labstream SAS"]
|
||||
edition = "2021"
|
||||
@@ -28,6 +28,19 @@ tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
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) ──
|
||||
# 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ó).
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
// - 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.
|
||||
// - 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.
|
||||
// - 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
|
||||
@@ -35,7 +37,11 @@
|
||||
mod cache;
|
||||
mod tracker;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -54,11 +60,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
const SERVER_URL: &str = "https://os.labstreamsas.com";
|
||||
|
||||
// Los documentos del contador viven en Google (pestaña Contable). Se abren como PESTANAS
|
||||
// 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.
|
||||
// Google requiere el navegador del sistema para autenticar y editar con compatibilidad completa.
|
||||
const GOOGLE_HOSTS: &[&str] = &[
|
||||
"docs.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 {
|
||||
if target.origin() == server.origin() {
|
||||
return true;
|
||||
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"));
|
||||
}
|
||||
if target.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
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 CHROME: &str = "chrome";
|
||||
@@ -142,6 +154,15 @@ impl Default for 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)]
|
||||
struct WinRect {
|
||||
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.
|
||||
// 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; }
|
||||
// Hosts de Google que la app abre como pestana propia (pestana Contable). En esas
|
||||
// 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 (location.origin !== ORIGIN) return;
|
||||
|
||||
if (window.__lsShell) return;
|
||||
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 isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); }
|
||||
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) || isGoogleUrl(u); }
|
||||
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 openExternal(url) {
|
||||
@@ -259,73 +274,10 @@ const INIT_JS_TPL: &str = r#"
|
||||
}
|
||||
function newTab(url) { emit('ls-new-tab', { url: url }); }
|
||||
|
||||
// ── Miniaturas por el CAJÓN de la app (lsthumb://) ──
|
||||
// Dentro de la app de escritorio, las miniaturas de la cuadrícula de fotos se piden por el
|
||||
// esquema lsthumb:// en vez de directo al servidor. Así las sirve el cajón local (Rust): del
|
||||
// disco si ya están —sobreviven a cerrar la app, que es lo que se pedía— y del NAS solo la
|
||||
// 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 */ }
|
||||
// Las imagenes conservan su URL HTTPS y la sesion del webview. El descargador
|
||||
// lsthumb no comparte cookies: falla con miniaturas sin token y omite la
|
||||
// revalidacion de permisos al leer del disco. La cache HTTP respeta los ETag
|
||||
// y Cache-Control del servidor sin modificar src, srcset ni la hidratacion.
|
||||
|
||||
// ── ¿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
|
||||
@@ -378,7 +330,6 @@ const INIT_JS_TPL: &str = r#"
|
||||
history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; };
|
||||
});
|
||||
addEventListener('popstate', function () { setTimeout(report, 0); });
|
||||
if (esGoogle) return; // en Google solo miramos el titulo
|
||||
// Pulsar la luna cambia clases/estilos sin navegar: se vigilan <html> y <body> (cuando
|
||||
// existan — a document-start aún no hay), y tras la carga se re-reporta un par de veces
|
||||
// por si el tema aterriza tarde con la hidratación.
|
||||
@@ -401,9 +352,6 @@ const INIT_JS_TPL: &str = r#"
|
||||
if (!u) return;
|
||||
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
|
||||
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); }
|
||||
}, true);
|
||||
|
||||
@@ -577,6 +525,99 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||||
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).
|
||||
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
|
||||
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 app_zoom = app.clone();
|
||||
let app_navigation = app.clone();
|
||||
let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed))
|
||||
.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
|
||||
// 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)
|
||||
@@ -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
|
||||
// archivo bajaba bien a la carpeta de Descargas, pero NADA lo decia y parecia roto.
|
||||
// Quitar este manejador NO devuelve la interfaz nativa: el de wry sigue puesto.
|
||||
.on_download(|webview, event| {
|
||||
if let DownloadEvent::Finished { path, success, .. } = &event {
|
||||
let cuerpo = if !*success {
|
||||
"No se pudo completar la descarga.".to_string()
|
||||
} else if let Some(p) = path {
|
||||
// En macOS `path` llega siempre vacio (limite de WKDownload:
|
||||
// wry-0.55.1/src/wkwebview/download.rs:98), asi que en la practica
|
||||
// este brazo con nombre de archivo es el de Windows.
|
||||
match p.file_name() {
|
||||
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
|
||||
None => "Descarga terminada.".to_string(),
|
||||
}
|
||||
} else {
|
||||
"Descarga terminada. Esta en tu carpeta de Descargas.".to_string()
|
||||
};
|
||||
let _ = webview
|
||||
.app_handle()
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Labstream OS")
|
||||
.body(cuerpo)
|
||||
.show();
|
||||
.on_download(|webview, event| match event {
|
||||
DownloadEvent::Requested { url, destination } => pedir_destino_descarga(&webview, &url, destination),
|
||||
DownloadEvent::Finished { url, path, success, .. } => {
|
||||
avisar_descarga_terminada(&webview, &url, path, success);
|
||||
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).
|
||||
.on_page_load(move |wv, payload| {
|
||||
@@ -1226,6 +1261,7 @@ pub fn run() {
|
||||
|
||||
let app = builder
|
||||
.manage(ShellState::default())
|
||||
.manage(Descargas::default())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
let saved = load_persisted(&handle);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Labstream OS",
|
||||
"version": "1.13.0",
|
||||
"version": "1.15.0",
|
||||
"identifier": "co.labstream.os",
|
||||
"build": {
|
||||
"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