Comparar commits
11 Commits
v1.10.0
...
descargas-
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
|
b4c4875cf6 | ||
|
|
261acc0201 | ||
|
|
1bdc7d0bba | ||
|
|
013d1e7759 | ||
|
|
a10805c51b | ||
|
|
d287b4e647 | ||
|
|
d847aca9a7 | ||
|
|
d5d24c6cb8 | ||
|
|
a4c91079d9 | ||
|
|
a202a37e75 | ||
|
|
f892dbbff7 |
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).
|
||||
|
||||
BIN
app-icon.png
BIN
app-icon.png
Archivo binario no mostrado.
|
Antes Anchura: | Altura: | Tamaño: 52 KiB Después Anchura: | Altura: | Tamaño: 66 KiB |
24
dist/index.html
vendido
24
dist/index.html
vendido
@@ -264,6 +264,17 @@
|
||||
height: 26px;
|
||||
font-size: 15px;
|
||||
}
|
||||
/* «+» de nueva pestaña, DENTRO de la tira y pegado a la última pestaña (estilo Chrome).
|
||||
Hereda de `button` (transparente, redondo, hover); solo fija tamaño, evita estirarse
|
||||
y se separa un pelín de la última pestaña. Bottom-alineado con las pestañas. */
|
||||
.nueva-tab {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 2px 6px 4px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
#zoom {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
@@ -325,8 +336,9 @@
|
||||
<div id="bar" data-tauri-drag-region>
|
||||
<button class="icono" id="atras" title="Atrás (⌘/Ctrl+[)">←</button>
|
||||
<button class="icono" id="adelante" title="Adelante (⌘/Ctrl+])">→</button>
|
||||
<!-- El botón «+» de nueva pestaña ya NO va aquí fijo: se dibuja DENTRO de #tabs, pegado a
|
||||
la última pestaña (estilo Chrome), en render(). Ver `.nueva-tab`. -->
|
||||
<div id="tabs" data-tauri-drag-region></div>
|
||||
<button class="icono" id="nueva" title="Nueva pestaña (⌘/Ctrl+T)">+</button>
|
||||
<button class="icono" id="recargar" title="Recargar (⌘/Ctrl+R)">⟳</button>
|
||||
<div id="zoom">
|
||||
<button id="z-menos" title="Alejar (⌘/Ctrl+−)">−</button>
|
||||
@@ -523,6 +535,15 @@
|
||||
|
||||
$tabs.appendChild(el);
|
||||
}
|
||||
// El «+» de nueva pestaña, PEGADO a la última (estilo Chrome): al vivir dentro de #tabs
|
||||
// como último elemento (flex 0 0 auto), queda justo tras la última pestaña en vez de
|
||||
// fijo a la derecha de la barra. El hueco libre después de él sigue moviendo la ventana.
|
||||
const mas = document.createElement("button");
|
||||
mas.className = "nueva-tab";
|
||||
mas.textContent = "+";
|
||||
mas.title = "Nueva pestaña (⌘/Ctrl+T)";
|
||||
mas.addEventListener("click", () => emitir("ls-new-tab", {}));
|
||||
$tabs.appendChild(mas);
|
||||
requestAnimationFrame(ajustarCompactas);
|
||||
|
||||
const activa = $tabs.querySelector(".tab.active");
|
||||
@@ -560,7 +581,6 @@
|
||||
|
||||
document.getElementById("atras").addEventListener("click", () => emitir("ls-nav", { dir: -1 }));
|
||||
document.getElementById("adelante").addEventListener("click", () => emitir("ls-nav", { dir: 1 }));
|
||||
document.getElementById("nueva").addEventListener("click", () => emitir("ls-new-tab", {}));
|
||||
document.getElementById("recargar").addEventListener("click", () => emitir("ls-reload", {}));
|
||||
// Menú de OPCIONES de la app (nativo, lo pinta Rust): botón ⋮ o clic derecho en la barra.
|
||||
document.getElementById("menu").addEventListener("click", () => emitir("ls-menu", {}));
|
||||
|
||||
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.10.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.10.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"active-win-pos-rs",
|
||||
"device_query",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "labstream-desktop"
|
||||
version = "1.10.0"
|
||||
version = "1.15.0"
|
||||
description = "Cliente de escritorio de Labstream OS"
|
||||
authors = ["Labstream SAS"]
|
||||
edition = "2021"
|
||||
@@ -28,16 +28,35 @@ 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ó).
|
||||
# ureq: subir los lotes a /api/tracker (bloqueante, corre en su propio hilo).
|
||||
# gethostname: el nombre del equipo, que bautiza al device en el servidor.
|
||||
active-win-pos-rs = "0.11"
|
||||
device_query = "4"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
gethostname = "0.5"
|
||||
|
||||
# device_query solo en Windows/Linux: la huella de entrada (posición del mouse + cuántas teclas
|
||||
# hay presionadas). En macOS NO se usa —leer el teclado global dispara el permiso de
|
||||
# Accesibilidad y el diálogo «… quiere controlar este ordenador»— y se sustituye por
|
||||
# CGEventSourceSecondsSinceLastEventType (idle_seconds_macos en tracker.rs), que no pide permiso.
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
device_query = "4"
|
||||
|
||||
# ── Excepción del video (Windows) ──
|
||||
# Para saber si «hay un video reproduciéndose» sin espiar el navegador: se mira el MEDIDOR de
|
||||
# la salida de audio por defecto (WASAPI, IAudioMeterInformation). Si al frente hay un navegador
|
||||
|
||||
339
src-tauri/src/cache.rs
Archivo normal
339
src-tauri/src/cache.rs
Archivo normal
@@ -0,0 +1,339 @@
|
||||
// ── El «cajón» de miniaturas de la app ──
|
||||
//
|
||||
// El problema que resuelve: las webviews guardan las imágenes en un caché del SISTEMA que es
|
||||
// pequeño y volátil — el sistema lo vacía cuando necesita espacio. En un Mac del equipo se
|
||||
// midieron 22 MB; un solo set de 500 fotos, entre cuadrícula y visor, pasa de 150 MB. No cabe,
|
||||
// se borra solo, y al reabrir la app hay que volver a bajarlo todo del NAS. Eso es lo que se
|
||||
// siente como «se relee cada vez que cierro».
|
||||
//
|
||||
// El cajón es un caché PROPIO de la app, en su carpeta interna (`app_cache_dir`, invisible en el
|
||||
// Finder), con un tope que decidimos nosotros y una poda por antigüedad — igual que la que el NAS
|
||||
// ya hace con sus miniaturas. Sobrevive a cerrar la app y se vacía con un botón.
|
||||
//
|
||||
// Cómo se usa: la web app, DENTRO de la app de escritorio, pide las miniaturas por el esquema
|
||||
// `lsthumb://` en vez de directamente al servidor (lo reescribe el script de inicio). Este módulo
|
||||
// es quien contesta ese esquema: si la miniatura está en el cajón la sirve del disco sin tocar la
|
||||
// red; si no, la trae del NAS una vez, la guarda y la sirve.
|
||||
//
|
||||
// La clave del cajón NO incluye el token firmado de la URL (que rota cada 24 h): se deriva de la
|
||||
// identidad estable de la miniatura (la ruta + su variante), así que la misma foto acierta en el
|
||||
// cajón un mes después. El token solo se usa para ir a buscarla al NAS cuando falta.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
|
||||
// El único servidor del que este cajón acepta traer bytes. Sin este cerrojo, una URL manipulada
|
||||
// convertiría la app en un proxy abierto que trae cualquier cosa de internet (un SSRF). Se compara
|
||||
// por ORIGEN exacto (esquema + host + puerto), no por «empieza por».
|
||||
const SERVER: &str = "https://os.labstreamsas.com";
|
||||
|
||||
// Tope del cajón: 3 GB, una sesión grande entera de miniaturas. Al pasarse, se borra lo más viejo
|
||||
// (por último uso) hasta bajar del tope. Es el mismo criterio y tamaño que el caché del NAS.
|
||||
const CAP_BYTES: u64 = 3 * 1024 * 1024 * 1024;
|
||||
|
||||
// Recorrer el directorio para podar es caro: no se hace en cada escritura, sino cada N.
|
||||
const PODA_CADA: u32 = 200;
|
||||
static DESDE_PODA: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
// Subcarpeta del caché de la app donde vive el cajón.
|
||||
const DIR: &str = "miniaturas";
|
||||
|
||||
// ── Piezas PURAS (probadas abajo) ─────────────────────────────────────────────
|
||||
|
||||
// ¿Se puede traer esta URL? Solo el origen del servidor, y solo rutas de su API. Cualquier otra
|
||||
// cosa se rechaza: el cajón no es un proxy de propósito general.
|
||||
pub fn permitido(url: &str) -> bool {
|
||||
let (Ok(u), Ok(srv)) = (tauri::Url::parse(url), tauri::Url::parse(SERVER)) else {
|
||||
return false;
|
||||
};
|
||||
u.origin() == srv.origin() && u.path().starts_with("/api/")
|
||||
}
|
||||
|
||||
// La clave estable de una miniatura: su ruta + los parámetros que la IDENTIFICAN, ordenados, y
|
||||
// SIN el token `t` (que rota). Se resume con FNV-1a a un nombre corto y seguro para el sistema de
|
||||
// archivos. Que no lleve el token es lo que hace que el cajón siga acertando cuando el token de la
|
||||
// página cambió: la foto es la misma.
|
||||
pub fn clave_de(url: &str) -> Option<String> {
|
||||
let u = tauri::Url::parse(url).ok()?;
|
||||
let mut params: Vec<(String, String)> = u
|
||||
.query_pairs()
|
||||
.filter(|(k, _)| k != "t") // el token NO forma parte de la identidad
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
params.sort();
|
||||
let mut base = u.path().to_string();
|
||||
for (k, v) in ¶ms {
|
||||
base.push('|');
|
||||
base.push_str(k);
|
||||
base.push('=');
|
||||
base.push_str(v);
|
||||
}
|
||||
Some(fnv1a_hex(&base))
|
||||
}
|
||||
|
||||
// Saca el parámetro `u` (la URL real del servidor, percent-encoded) de la URI del esquema. Funciona
|
||||
// sea cual sea la forma que la plataforma le dé al esquema — `lsthumb://c/?u=…` en macOS,
|
||||
// `http://lsthumb.localhost/…?u=…` en Windows —: en ambas `u` viaja como parámetro de consulta y
|
||||
// query_pairs lo decodifica.
|
||||
pub fn url_pedida(uri: &str) -> Option<String> {
|
||||
tauri::Url::parse(uri)
|
||||
.ok()?
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "u")
|
||||
.map(|(_, v)| v.into_owned())
|
||||
}
|
||||
|
||||
fn fnv1a_hex(s: &str) -> String {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
// Un archivo del cajón, para decidir la poda.
|
||||
#[derive(Clone)]
|
||||
struct Entrada {
|
||||
ruta: PathBuf,
|
||||
bytes: u64,
|
||||
usado: SystemTime,
|
||||
}
|
||||
|
||||
// La POLÍTICA de poda, aislada del disco para poder probarla: dado el contenido del cajón y el
|
||||
// tope, devuelve qué archivos borrar — los MÁS VIEJOS por último uso, hasta que el total baje del
|
||||
// tope. Si ya cabe, no borra nada.
|
||||
fn plan_de_poda(mut items: Vec<Entrada>, cap: u64) -> Vec<PathBuf> {
|
||||
let total: u64 = items.iter().map(|e| e.bytes).sum();
|
||||
if total <= cap {
|
||||
return Vec::new();
|
||||
}
|
||||
// Más viejo primero (el primero en salir).
|
||||
items.sort_by_key(|e| e.usado);
|
||||
let mut sobra = total - cap;
|
||||
let mut a_borrar = Vec::new();
|
||||
for e in items {
|
||||
if sobra == 0 {
|
||||
break;
|
||||
}
|
||||
a_borrar.push(e.ruta);
|
||||
sobra = sobra.saturating_sub(e.bytes);
|
||||
}
|
||||
a_borrar
|
||||
}
|
||||
|
||||
// ── Disco ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn dir<R: Runtime>(app: &AppHandle<R>) -> Option<PathBuf> {
|
||||
let base = app.path().app_cache_dir().ok()?;
|
||||
Some(base.join(DIR))
|
||||
}
|
||||
|
||||
// Lee una miniatura del cajón. `None` = no está (o no se pudo leer): el que llama irá al NAS.
|
||||
pub fn leer<R: Runtime>(app: &AppHandle<R>, clave: &str) -> Option<Vec<u8>> {
|
||||
let ruta = dir(app)?.join(clave);
|
||||
fs::read(&ruta).ok()
|
||||
}
|
||||
|
||||
// Guarda una miniatura en el cajón (escritura atómica: se escribe a un temporal y se renombra, así
|
||||
// una lectura nunca ve un archivo a medias). Cada cierto número de escrituras, poda.
|
||||
pub fn escribir<R: Runtime>(app: &AppHandle<R>, clave: &str, bytes: &[u8]) {
|
||||
let Some(d) = dir(app) else { return };
|
||||
if fs::create_dir_all(&d).is_err() {
|
||||
return;
|
||||
}
|
||||
let destino = d.join(clave);
|
||||
let tmp = d.join(format!("{clave}.tmp"));
|
||||
if fs::write(&tmp, bytes).is_ok() {
|
||||
let _ = fs::rename(&tmp, &destino);
|
||||
}
|
||||
if DESDE_PODA.fetch_add(1, Ordering::Relaxed) + 1 >= PODA_CADA {
|
||||
DESDE_PODA.store(0, Ordering::Relaxed);
|
||||
podar(&d);
|
||||
}
|
||||
}
|
||||
|
||||
fn podar(d: &Path) {
|
||||
let Ok(rd) = fs::read_dir(d) else { return };
|
||||
let mut items = Vec::new();
|
||||
for e in rd.flatten() {
|
||||
let Ok(meta) = e.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let nombre = e.file_name();
|
||||
if nombre.to_string_lossy().ends_with(".tmp") {
|
||||
continue; // un temporal a medio escribir no se poda ni se cuenta
|
||||
}
|
||||
// Último uso: la fecha de acceso si el sistema la lleva, si no la de modificación.
|
||||
let usado = meta.accessed().or_else(|_| meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
items.push(Entrada { ruta: e.path(), bytes: meta.len(), usado });
|
||||
}
|
||||
for ruta in plan_de_poda(items, CAP_BYTES) {
|
||||
let _ = fs::remove_file(ruta);
|
||||
}
|
||||
}
|
||||
|
||||
// Cuánto ocupa el cajón, en bytes (para la etiqueta «Vaciar caché (NN MB)» del menú).
|
||||
pub fn tamano<R: Runtime>(app: &AppHandle<R>) -> u64 {
|
||||
let Some(d) = dir(app) else { return 0 };
|
||||
let Ok(rd) = fs::read_dir(&d) else { return 0 };
|
||||
rd.flatten()
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter(|m| m.is_file())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
// Vacía el cajón entero. No pierde nada importante: se vuelve a llenar solo con el uso.
|
||||
pub fn vaciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(d) = dir(app) {
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
let _ = fs::create_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Traer del NAS (solo cuando falta en el cajón) ───────────────────────────────
|
||||
|
||||
// Tope de lo que se acepta bajar por miniatura. Una miniatura webp pesa cientos de KB; 64 MB es un
|
||||
// techo absurdo a propósito, solo para que una respuesta descomunal no llene la memoria.
|
||||
const MAX_DESCARGA: u64 = 64 * 1024 * 1024;
|
||||
|
||||
// Trae los bytes de una miniatura del servidor. Devuelve `(bytes, es_webp)`: solo se GUARDAN en el
|
||||
// cajón las que son webp (lo único que este esquema pide); una respuesta de otro tipo se sirve tal
|
||||
// cual pero no se cachea. `Err(codigo)` = no se pudo traer (para que la imagen quede rota como
|
||||
// quedaría sin la app, no en blanco).
|
||||
pub fn traer(url: &str) -> Result<(Vec<u8>, bool), u16> {
|
||||
let resp = match ureq::get(url).timeout(Duration::from_secs(30)).call() {
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(code, _)) => return Err(code),
|
||||
Err(_) => return Err(502), // sin red o error de transporte
|
||||
};
|
||||
let es_webp = resp
|
||||
.header("Content-Type")
|
||||
.map(|c| c.starts_with("image/webp"))
|
||||
.unwrap_or(false);
|
||||
let mut bytes = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(MAX_DESCARGA)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| 502u16)?;
|
||||
Ok((bytes, es_webp))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn url_pedida_saca_y_decodifica_la_url_real() {
|
||||
let real = "https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1";
|
||||
let uri = format!("lsthumb://c/?u={}", urlencoding_min(real));
|
||||
assert_eq!(url_pedida(&uri).as_deref(), Some(real));
|
||||
// Y también la forma que podría tomar en Windows.
|
||||
let uri_win = format!("http://lsthumb.localhost/?u={}", urlencoding_min(real));
|
||||
assert_eq!(url_pedida(&uri_win).as_deref(), Some(real));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_pedida_sin_u_es_none() {
|
||||
assert_eq!(url_pedida("lsthumb://c/?otra=cosa"), None);
|
||||
}
|
||||
|
||||
// Percent-encoding mínimo para las pruebas (: / ? & =), suficiente para estos casos.
|
||||
fn urlencoding_min(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
':' => "%3A".into(),
|
||||
'/' => "%2F".into(),
|
||||
'?' => "%3F".into(),
|
||||
'&' => "%26".into(),
|
||||
'=' => "%3D".into(),
|
||||
c => c.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permitido_solo_el_servidor_y_su_api() {
|
||||
assert!(permitido("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1"));
|
||||
// Otro host, aunque diga labstream: NO.
|
||||
assert!(!permitido("https://malo.labstreamsas.com.attacker.net/api/x"));
|
||||
// El servidor pero fuera de /api/: NO (nada de rutas arbitrarias).
|
||||
assert!(!permitido("https://os.labstreamsas.com/etc/passwd"));
|
||||
// http en vez de https: distinto origen, NO.
|
||||
assert!(!permitido("http://os.labstreamsas.com/api/x"));
|
||||
// Basura: NO.
|
||||
assert!(!permitido("no-soy-una-url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_clave_ignora_el_token_que_rota() {
|
||||
// La MISMA miniatura con dos tokens distintos (mañana, pasado) → la misma clave.
|
||||
let hoy = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1000.aaa&thumb=1");
|
||||
let man = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=2000.bbb&thumb=1");
|
||||
assert!(hoy.is_some());
|
||||
assert_eq!(hoy, man, "el token no debe formar parte de la clave");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_clave_distingue_foto_y_variante() {
|
||||
let mini = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1").unwrap();
|
||||
let xl = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=xl").unwrap();
|
||||
let otra = clave_de("https://os.labstreamsas.com/api/files-asset/def?t=1.x&thumb=1").unwrap();
|
||||
assert_ne!(mini, xl, "la miniatura y el visor son distintos");
|
||||
assert_ne!(mini, otra, "dos fotos distintas son claves distintas");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_clave_no_depende_del_orden_de_los_parametros() {
|
||||
let a = clave_de("https://os.labstreamsas.com/api/files-asset/abc?thumb=1&t=1.x").unwrap();
|
||||
let b = clave_de("https://os.labstreamsas.com/api/files-asset/abc?t=1.x&thumb=1").unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn la_clave_es_segura_para_el_sistema_de_archivos() {
|
||||
let k = clave_de("https://os.labstreamsas.com/api/files-asset/abc?thumb=1").unwrap();
|
||||
assert!(k.chars().all(|c| c.is_ascii_hexdigit()), "clave = {k}");
|
||||
}
|
||||
|
||||
fn e(ruta: &str, bytes: u64, hace_segundos: u64) -> Entrada {
|
||||
Entrada {
|
||||
ruta: PathBuf::from(ruta),
|
||||
bytes,
|
||||
usado: SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000 - hace_segundos),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poda_no_borra_nada_si_cabe() {
|
||||
let items = vec![e("a", 100, 10), e("b", 100, 5)];
|
||||
assert!(plan_de_poda(items, 1000).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poda_borra_lo_mas_viejo_primero_hasta_bajar_del_tope() {
|
||||
// Total 900, tope 500 → sobran 400. Debe borrar los más viejos hasta cubrir 400.
|
||||
let items = vec![
|
||||
e("nueva", 300, 1), // la más reciente
|
||||
e("media", 300, 100),
|
||||
e("vieja", 300, 1000), // la más antigua
|
||||
];
|
||||
let borra = plan_de_poda(items, 500);
|
||||
// Vieja (300) no basta (faltan 100) → también media. Nueva se salva.
|
||||
assert_eq!(borra, vec![PathBuf::from("vieja"), PathBuf::from("media")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poda_justo_en_el_tope_no_borra() {
|
||||
let items = vec![e("a", 250, 10), e("b", 250, 5)];
|
||||
assert!(plan_de_poda(items, 500).is_empty(), "500==500 cabe");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -32,9 +34,14 @@
|
||||
// `core:default` también para el origen remoto (capabilities/default.json). Así no hace falta
|
||||
// declarar comandos propios en el ACL para páginas remotas.
|
||||
|
||||
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;
|
||||
@@ -52,6 +59,40 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
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 CHROME: &str = "chrome";
|
||||
// Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese
|
||||
@@ -113,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,
|
||||
@@ -204,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.
|
||||
// En macOS estas dos líneas son un no-op: allí el script nunca ha corrido en subframes.
|
||||
try { if (window.top !== window.self) return; } catch (e) { return; }
|
||||
if (location.origin !== ORIGIN) return; // p. ej. el login del SSO: no intervenir
|
||||
if (location.origin !== ORIGIN) return;
|
||||
|
||||
if (window.__lsShell) return;
|
||||
window.__lsShell = true;
|
||||
@@ -215,7 +265,8 @@ 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 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 openExternal(url) {
|
||||
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
|
||||
@@ -223,6 +274,11 @@ const INIT_JS_TPL: &str = r#"
|
||||
}
|
||||
function newTab(url) { emit('ls-new-tab', { url: url }); }
|
||||
|
||||
// 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
|
||||
// está, el color de fondo REALMENTE pintado (luminancia del body) — vale sea cual sea
|
||||
@@ -306,14 +362,14 @@ const INIT_JS_TPL: &str = r#"
|
||||
if (!a) return;
|
||||
var u = abs(a.getAttribute('href'));
|
||||
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);
|
||||
|
||||
var _open = window.open;
|
||||
window.open = function (url, name, feats) {
|
||||
var u = url ? abs(url) : 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;
|
||||
};
|
||||
|
||||
@@ -469,13 +525,106 @@ 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());
|
||||
// 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(server) = Url::parse(SERVER_URL) else { return };
|
||||
if parsed.origin() != server.origin() {
|
||||
if !es_pestana_interna(&parsed, &server) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -494,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)
|
||||
@@ -504,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| {
|
||||
@@ -750,6 +893,10 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
|
||||
let Some(win) = main_window(app) else { return };
|
||||
let autostart_on = app.autolaunch().is_enabled().unwrap_or(false);
|
||||
let version = format!("Versión {}", app.package_info().version);
|
||||
// El cajón dice cuánto ocupa en su propia etiqueta: así vaciarlo es una decisión informada, y
|
||||
// de paso es la comprobación más simple de que el caché está funcionando (el número crece al
|
||||
// navegar fotos y vuelve a 0 al vaciar).
|
||||
let vaciar_txt = format!("Vaciar caché de imágenes ({})", tamano_legible(cache::tamano(app)));
|
||||
let build = || -> tauri::Result<Menu<R>> {
|
||||
Ok(Menu::with_items(
|
||||
app,
|
||||
@@ -767,6 +914,7 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
|
||||
&MenuItem::with_id(app, "zoom-0", "Tamaño real", true, Some("CmdOrCtrl+0"))?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&MenuItem::with_id(app, "open-browser", "Abrir esta página en el navegador", true, None::<&str>)?,
|
||||
&MenuItem::with_id(app, "clear-cache", &vaciar_txt, true, None::<&str>)?,
|
||||
&CheckMenuItem::with_id(app, "autostart-toggle", "Iniciar con el sistema", true, autostart_on, None::<&str>)?,
|
||||
&MenuItem::with_id(app, "check-update", "Buscar actualización…", true, None::<&str>)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
@@ -780,6 +928,24 @@ fn show_app_menu<R: Runtime>(app: &AppHandle<R>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bytes → «12 MB» / «340 KB» / «1.2 GB», para la etiqueta del menú.
|
||||
fn tamano_legible(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{} MB", bytes / MB)
|
||||
} else if bytes >= KB {
|
||||
format!("{} KB", bytes / KB)
|
||||
} else if bytes == 0 {
|
||||
"vacío".to_string()
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
// Menú de CLIC DERECHO sobre una pestaña. Nativo (la barra mide 40 px: un menú HTML dentro
|
||||
// del webview quedaría recortado). Recuerda en el estado sobre qué pestaña se abrió.
|
||||
fn show_tab_menu<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||||
@@ -1004,6 +1170,73 @@ fn field_bool(payload: &str, key: &str) -> Option<bool> {
|
||||
serde_json::from_str::<serde_json::Value>(payload).ok()?.get(key)?.as_bool()
|
||||
}
|
||||
|
||||
// ── Esquema lsthumb:// — el cajón de miniaturas ────────────────────────────────
|
||||
// La web app, dentro de la app, pide las miniaturas de la cuadrícula como
|
||||
// `lsthumb://c/?u=<URL real del servidor, con su token>`. Aquí se contesta: si está en el cajón,
|
||||
// del disco; si no, se trae del NAS UNA vez, se guarda y se sirve. Todo el trabajo de red y disco
|
||||
// va en un hilo aparte (ureq es bloqueante) para no congelar el webview.
|
||||
//
|
||||
// El módulo `cache` pone los cerrojos: `permitido` (solo el servidor y su API, nunca un proxy
|
||||
// abierto) y `clave_de` (identidad estable sin el token que rota).
|
||||
|
||||
fn respuesta_bytes(bytes: Vec<u8>) -> tauri::http::Response<Vec<u8>> {
|
||||
tauri::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", "image/webp")
|
||||
// Privada y de larga vida: quien manda de verdad es el cajón en disco; esto solo evita
|
||||
// que el propio webview volviera a pedirla dentro de la misma sesión.
|
||||
.header("Cache-Control", "private, max-age=86400")
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(bytes)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn respuesta_estado(code: u16) -> tauri::http::Response<Vec<u8>> {
|
||||
tauri::http::Response::builder()
|
||||
.status(code)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(Vec::new())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn responder_lsthumb<R: Runtime>(
|
||||
ctx: tauri::UriSchemeContext<'_, R>,
|
||||
req: tauri::http::Request<Vec<u8>>,
|
||||
responder: tauri::UriSchemeResponder,
|
||||
) {
|
||||
let app = ctx.app_handle().clone();
|
||||
let objetivo = match cache::url_pedida(&req.uri().to_string()) {
|
||||
Some(u) if cache::permitido(&u) => u,
|
||||
_ => {
|
||||
responder.respond(respuesta_estado(400));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(clave) = cache::clave_de(&objetivo) else {
|
||||
responder.respond(respuesta_estado(400));
|
||||
return;
|
||||
};
|
||||
|
||||
// ¿En el cajón? Del disco, sin tocar la red.
|
||||
if let Some(bytes) = cache::leer(&app, &clave) {
|
||||
responder.respond(respuesta_bytes(bytes));
|
||||
return;
|
||||
}
|
||||
|
||||
// Falta: traerla del NAS y guardarla para la próxima. Va en el POOL de hilos de bloqueo (no un
|
||||
// `std::thread::spawn` por petición): al abrir un set de 500 fotos podrían dispararse muchas a
|
||||
// la vez, y el pool las acota y reutiliza sus hilos en vez de crear cientos.
|
||||
tauri::async_runtime::spawn_blocking(move || match cache::traer(&objetivo) {
|
||||
Ok((bytes, es_webp)) => {
|
||||
if es_webp {
|
||||
cache::escribir(&app, &clave, &bytes);
|
||||
}
|
||||
responder.respond(respuesta_bytes(bytes));
|
||||
}
|
||||
Err(code) => responder.respond(respuesta_estado(code)),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default();
|
||||
@@ -1021,10 +1254,14 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None));
|
||||
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None))
|
||||
// El cajón de miniaturas (ver el módulo `cache` y el handler de arriba). Registrado en el
|
||||
// builder para que TODAS las webviews de pestaña puedan usarlo.
|
||||
.register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb);
|
||||
|
||||
let app = builder
|
||||
.manage(ShellState::default())
|
||||
.manage(Descargas::default())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
let saved = load_persisted(&handle);
|
||||
@@ -1193,6 +1430,18 @@ pub fn run() {
|
||||
}
|
||||
id if id.starts_with("tab-color-") => tab_menu_action(app, id),
|
||||
"autostart-toggle" => toggle_autostart(app),
|
||||
"clear-cache" => {
|
||||
let antes = cache::tamano(app);
|
||||
cache::vaciar(app);
|
||||
// Un aviso nativo confirma que se hizo (y cuánto se liberó): sin él, «vaciar»
|
||||
// no da ninguna señal y parece que no pasó nada.
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Labstream OS")
|
||||
.body(format!("Caché de imágenes vaciado ({} liberados).", tamano_legible(antes)))
|
||||
.show();
|
||||
}
|
||||
"check-update" => {
|
||||
#[cfg(desktop)]
|
||||
tauri::async_runtime::spawn(check_update_manual(app.clone()));
|
||||
@@ -1226,7 +1475,7 @@ pub fn run() {
|
||||
.tabs
|
||||
.iter()
|
||||
.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)
|
||||
.map(|(i, u)| (u.clone(), saved.colors.get(i).cloned().unwrap_or_default()))
|
||||
.collect()
|
||||
|
||||
@@ -102,6 +102,22 @@ struct MenuState<R: Runtime>(Mutex<MenuUi<R>>);
|
||||
fn ahora_seg() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
// Segundos desde la última entrada del usuario (teclado/mouse/trackpad), preguntados al
|
||||
// subsistema HID de macOS. CGEventSourceSecondsSinceLastEventType es de solo LECTURA y NO
|
||||
// exige permiso de Accesibilidad ni de Monitorización de entrada (lo usan el salvapantallas y
|
||||
// `ioreg HIDIdleTime`). Con esto el sensor sabe si hubo actividad SIN el diálogo «… quiere
|
||||
// controlar este ordenador» que salía al leer el teclado global con device_query.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn idle_seconds_macos() -> f64 {
|
||||
const HID_SYSTEM_STATE: i32 = 1; // kCGEventSourceStateHIDSystemState
|
||||
const ANY_INPUT_EVENT: u32 = 0xFFFF_FFFF; // kCGAnyInputEventType
|
||||
#[link(name = "CoreGraphics", kind = "framework")]
|
||||
extern "C" {
|
||||
fn CGEventSourceSecondsSinceLastEventType(state_id: i32, event_type: u32) -> f64;
|
||||
}
|
||||
unsafe { CGEventSourceSecondsSinceLastEventType(HID_SYSTEM_STATE, ANY_INPUT_EVENT) }
|
||||
}
|
||||
fn ahora_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
@@ -427,8 +443,10 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let device_state = device_query::DeviceState::new();
|
||||
let mut ultimo_input = ahora_seg();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let mut huella_anterior: (i32, i32, usize) = (0, 0, 0);
|
||||
let mut acumulado: HashMap<(String, String), Bloque> = HashMap::new();
|
||||
let mut ocios_pendientes: Vec<Ocio> = Vec::new();
|
||||
@@ -504,14 +522,27 @@ pub fn iniciar<R: Runtime>(app: &AppHandle<R>) {
|
||||
}
|
||||
tic_previo = ahora;
|
||||
|
||||
// ¿Hubo entrada desde el último tic? Solo se compara una huella (posición del
|
||||
// mouse + cuántas teclas hay presionadas): nunca se registra QUÉ se tecleó.
|
||||
use device_query::DeviceQuery;
|
||||
let mouse = device_state.get_mouse();
|
||||
let teclas = device_state.get_keys().len();
|
||||
let huella = (mouse.coords.0, mouse.coords.1, teclas);
|
||||
let hubo_input = huella != huella_anterior || teclas > 0;
|
||||
huella_anterior = huella;
|
||||
// ¿Hubo entrada desde el último tic? Solo interesa el HECHO de que hubo entrada,
|
||||
// nunca QUÉ se tecleó ni dónde.
|
||||
//
|
||||
// macOS: se pregunta al sistema cuántos segundos lleva SIN entrada (HID). Esa API
|
||||
// NO exige permiso de Accesibilidad ni de Monitorización de entrada, así que ya no
|
||||
// aparece el diálogo «… quiere controlar este ordenador». Inactividad < 1 tic =
|
||||
// hubo entrada. Aquí `device_state`/`huella_anterior` no existen.
|
||||
#[cfg(target_os = "macos")]
|
||||
let hubo_input = idle_seconds_macos() < (TICK_SEG as f64) + 1.0;
|
||||
// Windows/Linux: huella de entrada con device_query (posición del mouse + cuántas
|
||||
// teclas hay presionadas). Leer esto ahí no pide permiso especial.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let hubo_input = {
|
||||
use device_query::DeviceQuery;
|
||||
let mouse = device_state.get_mouse();
|
||||
let teclas = device_state.get_keys().len();
|
||||
let huella = (mouse.coords.0, mouse.coords.1, teclas);
|
||||
let cambio = huella != huella_anterior || teclas > 0;
|
||||
huella_anterior = huella;
|
||||
cambio
|
||||
};
|
||||
if hubo_input {
|
||||
ultimo_input = ahora;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Labstream OS",
|
||||
"version": "1.10.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