Comparar commits
8 Commits
v1.14.1
...
reposo-pes
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
|
5797c478bc | ||
|
|
4d066157cf | ||
|
|
5a6f6c918f | ||
|
|
7b0c031a41 | ||
|
|
641c9660cc | ||
|
|
73486aa3fe | ||
|
|
a37a0bcc0b | ||
|
|
b4c4875cf6 |
86
.github/workflows/build.yml
vendido
86
.github/workflows/build.yml
vendido
@@ -54,6 +54,62 @@ jobs:
|
||||
- name: Probar el cliente
|
||||
run: npm test
|
||||
|
||||
# ── FIRMA DE macOS ──────────────────────────────────────────────────────────────────
|
||||
# Hasta el 2026-09-19 este build NO firmaba la app de macOS: no se le pasaba ninguna
|
||||
# identidad, así que el bundler de Tauri se saltaba `codesign` entero y el .dmg salía con
|
||||
# la firma ad hoc que deja el enlazador. Se veía así en la app instalada:
|
||||
# flags=0x20002(adhoc,linker-signed) · Info.plist=not bound · Sealed Resources=none
|
||||
# Consecuencias: los entitlements (micrófono, cámara, JIT) NO se aplicaban —el
|
||||
# Entitlements.plist del repo era decoración—, el Info.plist y los recursos no iban
|
||||
# sellados, y la app no se podía notarizar de ninguna manera.
|
||||
#
|
||||
# `APPLE_SIGNING_IDENTITY` decide qué pasa, y por eso se calcula aquí y no en el `env:`
|
||||
# del paso de abajo: un secreto que no existe llega como cadena VACÍA, y Tauri
|
||||
# interpreta `APPLE_CERTIFICATE=""` como «hay certificado» y revienta al importarlo.
|
||||
# Solo se exporta lo que de verdad tiene valor.
|
||||
#
|
||||
# · CON los secretos de Developer ID → firma real + notarizado + grapado.
|
||||
# · SIN ellos → identidad "-", que es la firma AD HOC: no engaña a Gatekeeper (habrá
|
||||
# que seguir abriendo con clic derecho), pero SÍ sella los recursos, ata el
|
||||
# Info.plist y aplica los entitlements. Estrictamente mejor que no firmar.
|
||||
#
|
||||
# El hardened runtime no se configura: en Tauri v2 `bundle.macOS.hardenedRuntime` ya
|
||||
# viene en true, y es lo que hace que los entitlements signifiquen algo.
|
||||
- name: Preparar la firma de macOS
|
||||
if: matrix.platform == 'macos-latest'
|
||||
shell: bash
|
||||
env:
|
||||
CERT: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CERT_PW: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
NOTARIZA_ID: ${{ secrets.APPLE_ID }}
|
||||
NOTARIZA_PW: ${{ secrets.APPLE_PASSWORD }}
|
||||
NOTARIZA_TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Los valores van por heredoc con delimitador: un base64 largo o con saltos rompería
|
||||
# la forma `CLAVE=valor`. Nada se imprime: los secretos no pasan por el log.
|
||||
exportar() { printf '%s<<__FIN__\n%s\n__FIN__\n' "$1" "$2" >> "$GITHUB_ENV"; }
|
||||
if [ -n "${CERT:-}" ] && [ -n "${CERT_PW:-}" ]; then
|
||||
exportar APPLE_CERTIFICATE "$CERT"
|
||||
exportar APPLE_CERTIFICATE_PASSWORD "$CERT_PW"
|
||||
exportar APPLE_SIGNING_IDENTITY "${IDENTITY:-Developer ID Application}"
|
||||
echo "Firma: Developer ID (certificado del repo)."
|
||||
else
|
||||
exportar APPLE_SIGNING_IDENTITY "-"
|
||||
echo "Firma: AD HOC. Sella recursos y aplica entitlements, pero Gatekeeper seguirá avisando."
|
||||
echo "Para firma de verdad, añade los secrets APPLE_CERTIFICATE y APPLE_CERTIFICATE_PASSWORD."
|
||||
fi
|
||||
# Notarizar solo tiene sentido con Developer ID; las tres claves van juntas o ninguna.
|
||||
if [ -n "${CERT:-}" ] && [ -n "${NOTARIZA_ID:-}" ] && [ -n "${NOTARIZA_PW:-}" ] && [ -n "${NOTARIZA_TEAM:-}" ]; then
|
||||
exportar APPLE_ID "$NOTARIZA_ID"
|
||||
exportar APPLE_PASSWORD "$NOTARIZA_PW"
|
||||
exportar APPLE_TEAM_ID "$NOTARIZA_TEAM"
|
||||
echo "Notarizado: activado."
|
||||
else
|
||||
echo "Notarizado: omitido (faltan APPLE_ID, APPLE_PASSWORD o APPLE_TEAM_ID, o no hay certificado)."
|
||||
fi
|
||||
|
||||
- name: Construir, firmar y publicar
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -61,6 +117,7 @@ jobs:
|
||||
# Firma del updater: la clave privada y su contraseña van como secrets del repo.
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# Las APPLE_* las puso el paso de arriba en $GITHUB_ENV, solo si tenían valor.
|
||||
with:
|
||||
# En un tag usa su nombre (v1.0.0); en ejecución manual, deriva vX.Y.Z de la versión.
|
||||
tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__VERSION__' }}
|
||||
@@ -70,6 +127,35 @@ jobs:
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# ── LA RED QUE FALTABA ──────────────────────────────────────────────────────────────
|
||||
# Que el build no firmara la app de macOS no lo dijo nada durante meses: el .dmg salía,
|
||||
# se instalaba y funcionaba. Se descubrió a mano, con `codesign -dv`, persiguiendo otra
|
||||
# cosa. Este paso hace esa comprobación en cada publicación y TUMBA el build si los
|
||||
# entitlements no llegaron: es barato y es el único aviso que habría existido.
|
||||
- name: Comprobar la firma de macOS
|
||||
if: matrix.platform == 'macos-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP=$(find src-tauri/target/universal-apple-darwin/release/bundle/macos -maxdepth 1 -name "*.app" | head -1)
|
||||
if [ -z "$APP" ]; then echo "::error::No apareció ningún .app donde se esperaba"; exit 1; fi
|
||||
echo "== $APP =="
|
||||
codesign -dv --verbose=4 "$APP" 2>&1 || true
|
||||
echo "== entitlements =="
|
||||
codesign -d --entitlements - --xml "$APP" 2>/dev/null | plutil -convert xml1 -o - - || true
|
||||
echo "== integridad del bundle =="
|
||||
codesign --verify --strict --verbose=2 "$APP"
|
||||
# Gatekeeper: con firma ad hoc esto SIEMPRE dice «rejected», y es esperado. Se
|
||||
# informa sin tumbar nada; lo que sí es obligatorio es lo de abajo.
|
||||
echo "== Gatekeeper (informativo) =="
|
||||
spctl -a -t exec -vv "$APP" 2>&1 || true
|
||||
echo "== obligatorio: los entitlements tienen que estar =="
|
||||
if ! codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q "com.apple.security.cs.allow-jit"; then
|
||||
echo "::error::La app salió SIN entitlements. Revisa el paso «Preparar la firma de macOS» y bundle.macOS.entitlements en tauri.conf.json."
|
||||
exit 1
|
||||
fi
|
||||
echo "Entitlements aplicados."
|
||||
|
||||
activar:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
84
README.md
84
README.md
@@ -39,6 +39,12 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
|
||||
resultado. Es un popup nativo del sistema, igual en Windows y macOS, y vive
|
||||
solo en el shell (la web app no se toca).
|
||||
|
||||
- **Pestañas en reposo** (v1.19.0, solo Windows): cada pestaña es la web app entera, con su
|
||||
conexión del chat y sus sondeos. Una que lleva 5 minutos oculta se **suspende** (WebView2
|
||||
`TrySuspend`: deja de gastar CPU y el sistema puede recuperar su memoria) y despierta sola,
|
||||
donde estaba, al volver a ella. No se duerme la activa, ni una con una subida, una descarga o
|
||||
audio en curso, ni un editor de documentos (`/docs/`).
|
||||
|
||||
- **Rastreador de trabajo efectivo** (v1.10.0, `src-tauri/src/tracker.rs`): mide
|
||||
cuánto tiempo se trabaja de verdad y en qué aplicación, y lo publica en el
|
||||
panel **/rastreo** del servidor. Cada 5 s mira la ventana al frente y si
|
||||
@@ -229,20 +235,72 @@ Es la forma de mirar un cambio visual —la barra, los colores— sin sacárselo
|
||||
|
||||
---
|
||||
|
||||
## Avisos de seguridad del sistema (instalación interna sin firmar)
|
||||
## Firma de macOS
|
||||
|
||||
Como los instaladores **no están firmados** con certificado de pago, la primera
|
||||
vez cada sistema muestra una advertencia. Es normal para apps internas:
|
||||
### Lo que pasaba hasta la 1.15.0
|
||||
|
||||
- **Windows (SmartScreen):** pantalla azul *"Windows protegió su PC"* →
|
||||
**Más información → Ejecutar de todos modos**.
|
||||
- **macOS (Gatekeeper):** *"no se puede abrir porque proviene de un desarrollador
|
||||
no identificado"* → clic derecho sobre la app → **Abrir** → **Abrir**. (Si
|
||||
insiste, ejecutar una vez: `xattr -cr "/Applications/Labstream OS.app"`.)
|
||||
El build **no firmaba** la app de macOS. No se le pasaba ninguna identidad, así que
|
||||
el bundler de Tauri se saltaba `codesign` entero y el `.dmg` salía con la firma ad hoc
|
||||
que deja el enlazador. En la app instalada se veía así:
|
||||
|
||||
Si en el futuro se distribuye a clientes externos, conviene firmar:
|
||||
*code signing* en Windows (~150–400 USD/año) y un *Apple Developer ID* + notarizado
|
||||
en Mac (99 USD/año) para quitar estos avisos.
|
||||
```
|
||||
$ codesign -dv "/Applications/Labstream OS.app"
|
||||
Identifier=labstream_desktop-0a5819ba37119701
|
||||
CodeDirectory ... flags=0x20002(adhoc,linker-signed)
|
||||
Info.plist=not bound
|
||||
Sealed Resources=none
|
||||
TeamIdentifier=not set
|
||||
```
|
||||
|
||||
Tres cosas mal, y ninguna avisaba:
|
||||
|
||||
- **`Entitlements.plist` no se aplicaba.** Los permisos de micrófono, cámara y JIT
|
||||
eran decoración: se escriben al firmar, y no se firmaba.
|
||||
- **`Info.plist=not bound` y `Sealed Resources=none`.** Ni el Info.plist ni los
|
||||
recursos iban dentro de la firma: cualquiera podía cambiarlos sin romperla.
|
||||
- **Identificador de enlazador** (`labstream_desktop-<hash>`) en vez del de la app
|
||||
(`co.labstream.os`), que es por lo que macOS recuerda los permisos concedidos.
|
||||
|
||||
### Lo que hace ahora (desde la 1.16.0)
|
||||
|
||||
`build.yml` decide según los secrets que haya en el repo:
|
||||
|
||||
| Secrets | Resultado |
|
||||
| --- | --- |
|
||||
| ninguno | Firma **ad hoc** (`-s -`) con hardened runtime y los entitlements. Gatekeeper sigue avisando, pero el bundle va sellado y los permisos se aplican. |
|
||||
| `APPLE_CERTIFICATE` + `APPLE_CERTIFICATE_PASSWORD` | Firma real con **Developer ID**. Gatekeeper deja de avisar en cualquier Mac. |
|
||||
| …y además `APPLE_ID` + `APPLE_PASSWORD` + `APPLE_TEAM_ID` | Firma real + **notarizado y grapado**. Ni el primer arranque avisa. |
|
||||
|
||||
Y hay un paso que **tumba la publicación** si los entitlements no llegaron al `.app`.
|
||||
Ese paso es la lección: que esto estuviera roto no lo dijo nada durante meses.
|
||||
|
||||
### Pasar a Developer ID (99 USD/año)
|
||||
|
||||
1. Alta en el Apple Developer Program.
|
||||
2. En *Certificates* crear uno de tipo **Developer ID Application** y exportarlo del
|
||||
Llavero como `.p12` con contraseña.
|
||||
3. Convertirlo a texto: `base64 -i certificado.p12 | pbcopy`.
|
||||
4. En GitHub → *Settings ▸ Secrets and variables ▸ Actions*, añadir:
|
||||
- `APPLE_CERTIFICATE` — lo que quedó en el portapapeles.
|
||||
- `APPLE_CERTIFICATE_PASSWORD` — la contraseña del `.p12`.
|
||||
- `APPLE_SIGNING_IDENTITY` — opcional; por defecto `Developer ID Application`.
|
||||
5. Para notarizar, además:
|
||||
- `APPLE_ID` — el correo de la cuenta de desarrollador.
|
||||
- `APPLE_PASSWORD` — una **contraseña específica de app** (appleid.apple.com, no la
|
||||
del Apple ID).
|
||||
- `APPLE_TEAM_ID` — el identificador de equipo de 10 caracteres.
|
||||
|
||||
No hay que tocar ni un archivo: en cuanto los secrets existan, la siguiente etiqueta
|
||||
sale firmada.
|
||||
|
||||
### Mientras siga ad hoc
|
||||
|
||||
- **macOS (Gatekeeper):** *"no se puede abrir porque proviene de un desarrollador no
|
||||
identificado"* → clic derecho sobre la app → **Abrir** → **Abrir**. (Si insiste:
|
||||
`xattr -cr "/Applications/Labstream OS.app"`.)
|
||||
- **Windows (SmartScreen):** *"Windows protegió su PC"* → **Más información →
|
||||
Ejecutar de todos modos**. Windows es aparte: su firma cuesta 150–400 USD/año y no
|
||||
la toca nada de lo de arriba.
|
||||
|
||||
---
|
||||
|
||||
@@ -257,10 +315,10 @@ labstream-desktop/
|
||||
│ ├── tauri.conf.json nombre, versión, iconos, instaladores, entitlements
|
||||
│ ├── Cargo.toml dependencias Rust + versión
|
||||
│ ├── Info.plist textos de permiso (micrófono/cámara) en macOS
|
||||
│ ├── Entitlements.plist entitlements de macOS (audio-input, cámara, red)
|
||||
│ ├── Entitlements.plist entitlements de macOS (micrófono, cámara, JIT)
|
||||
│ ├── build.rs
|
||||
│ ├── capabilities/default.json
|
||||
│ ├── icons/ se generan desde app-icon.png
|
||||
│ └── src/{main.rs,lib.rs} lib.rs crea la ventana + SERVER_URL + tray
|
||||
└── .github/workflows/build.yml build automático de .exe (Windows) y .dmg (macOS)
|
||||
└── .github/workflows/build.yml build de .exe (Windows) y .dmg (macOS) + firma de macOS
|
||||
```
|
||||
|
||||
97
dist/index.html
vendido
97
dist/index.html
vendido
@@ -35,7 +35,16 @@
|
||||
/* Sobre una franja casi blanca, un hover blanco no se vería: aquí oscurece. */
|
||||
--hover: rgba(0, 0, 0, 0.06);
|
||||
--btn-hover: rgba(0, 0, 0, 0.08);
|
||||
--sep: rgba(0, 0, 0, 0.22); /* separador fino entre pestañas inactivas */
|
||||
--sep: rgba(0, 0, 0, 0.22); /* aro del favicon mientras carga */
|
||||
/* PROFUNDIDAD. La inactiva no lleva un relleno gris —un bloque sólido pesa y compite
|
||||
con la activa— sino un velo casi imperceptible y una sombra INTERIOR: parece estar un
|
||||
plano por debajo. Al pasar el ratón cambia de signo, la sombra sale hacia fuera y la
|
||||
pestaña sube: por eso el hover va hacia el BLANCO de la activa y no hacia el gris. */
|
||||
--tab-atras: rgba(0, 0, 0, 0.035);
|
||||
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.055);
|
||||
--tab-hover-bg: rgba(255, 255, 255, 0.85);
|
||||
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.10);
|
||||
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
/* ── Quién decide el tema ──
|
||||
La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su
|
||||
@@ -57,6 +66,13 @@
|
||||
--hover: rgba(255, 255, 255, 0.08);
|
||||
--btn-hover: rgba(255, 255, 255, 0.1);
|
||||
--sep: rgba(255, 255, 255, 0.25);
|
||||
/* En oscuro el movimiento es el mismo pero con luz: la inactiva se hunde con un velo
|
||||
blanco muy tenue y una sombra negra dentro; al pasar el ratón sube hacia la luz. */
|
||||
--tab-atras: rgba(255, 255, 255, 0.035);
|
||||
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.35);
|
||||
--tab-hover-bg: rgba(255, 255, 255, 0.10);
|
||||
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
:root.oscuro {
|
||||
@@ -69,6 +85,13 @@
|
||||
--hover: rgba(255, 255, 255, 0.08);
|
||||
--btn-hover: rgba(255, 255, 255, 0.1);
|
||||
--sep: rgba(255, 255, 255, 0.25);
|
||||
/* En oscuro el movimiento es el mismo pero con luz: la inactiva se hunde con un velo
|
||||
blanco muy tenue y una sombra negra dentro; al pasar el ratón sube hacia la luz. */
|
||||
--tab-atras: rgba(255, 255, 255, 0.035);
|
||||
--tab-atras-sombra: inset 0 1px 1.5px rgba(0, 0, 0, 0.35);
|
||||
--tab-hover-bg: rgba(255, 255, 255, 0.10);
|
||||
--tab-hover-sombra: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
--tab-activa-sombra: 0 -1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -119,53 +142,46 @@
|
||||
height: 30px;
|
||||
margin-bottom: 5px;
|
||||
padding: 0 6px 0 10px;
|
||||
border-radius: 9px; /* inactiva: píldora redondeada (hover) */
|
||||
border-radius: 9px; /* inactiva: píldora redondeada */
|
||||
position: relative;
|
||||
overflow: visible; /* deja asomar las curvas de la base de la activa */
|
||||
overflow: visible; /* las sombras viven fuera de la caja */
|
||||
transition: background 140ms ease, box-shadow 140ms ease, color 140ms ease;
|
||||
}
|
||||
.tab:not(.active):hover { background: var(--hover); }
|
||||
/* Separador fino a la IZQUIERDA de cada inactiva; se oculta pegado a la activa,
|
||||
al hover y en la primera (igual que Chrome). */
|
||||
.tab:not(.active)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--sep);
|
||||
/* ── PROFUNDIDAD: las que no mandan están detrás ──────────────────────────────────
|
||||
Antes la inactiva no era nada: texto suelto sobre la franja, al mismo plano que la
|
||||
activa, y lo único que las separaba era un filete de 1px. Ahora la inactiva se HUNDE
|
||||
—velo tenue y sombra interior— y al pasar el ratón el gesto se invierte: la sombra sale
|
||||
hacia fuera y el fondo tira hacia el blanco de la activa, así que la pestaña sube hacia
|
||||
ti. Es sombra y no relleno a propósito: un bloque gris sólido pesa tanto como la activa
|
||||
y acaba compitiendo con ella.
|
||||
Con fondo propio el separador vertical entre pestañas sobra —ya se ve dónde acaba cada
|
||||
una—, así que se retiró. */
|
||||
.tab:not(.active) {
|
||||
background: var(--tab-atras);
|
||||
box-shadow: var(--tab-atras-sombra);
|
||||
}
|
||||
.tab:not(.active):hover {
|
||||
background: var(--tab-hover-bg);
|
||||
box-shadow: var(--tab-hover-sombra);
|
||||
color: var(--tab-active-fg);
|
||||
}
|
||||
.tab:first-child::before,
|
||||
.tab:hover::before,
|
||||
.tab.active + .tab::before,
|
||||
.tab:hover + .tab::before { display: none; }
|
||||
.tab.active {
|
||||
background: var(--tab-active-bg);
|
||||
color: var(--tab-active-fg);
|
||||
height: 35px;
|
||||
margin-bottom: 0; /* su base cae 1px SOBRE el borde de la barra y lo tapa */
|
||||
border-radius: 10px 10px 0 0;
|
||||
/* Levantada, apenas: la sombra va HACIA ARRIBA porque abajo la pestaña está fundida con
|
||||
el contenido y una sombra ahí dibujaría una línea donde justamente no debe haberla. */
|
||||
box-shadow: var(--tab-activa-sombra);
|
||||
z-index: 1;
|
||||
}
|
||||
/* Curvas cóncavas de Chrome en la base de la pestaña activa: dos piezas de 8px con
|
||||
un cuarto de círculo transparente que "derrama" el fondo hacia los lados. */
|
||||
.tab.active::before,
|
||||
.tab.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.tab.active::before {
|
||||
left: -8px;
|
||||
background: radial-gradient(circle at 0 0, transparent 8px, var(--tab-active-bg) 8.5px);
|
||||
}
|
||||
.tab.active::after {
|
||||
right: -8px;
|
||||
background: radial-gradient(circle at 8px 0, transparent 8px, var(--tab-active-bg) 8.5px);
|
||||
}
|
||||
/* AQUÍ ESTABAN LAS CURVAS CÓNCAVAS DE CHROME: dos piezas de 8px a los lados de la activa
|
||||
con un cuarto de círculo transparente que derramaba su fondo hacia fuera. El truco de
|
||||
Chrome funciona porque allí NO hay línea bajo la barra; aquí sí la hay, y esas dos
|
||||
piezas blancas la partían en dos justo a los costados de la pestaña — la «fluctuación»
|
||||
de la esquina. La activa ya se funde con el contenido por su propio fondo, que tapa la
|
||||
línea exactamente por su ancho; sin las piezas no asoma nada. */
|
||||
/* ── Color de pestaña ────────────────────────────────────────────────────────────
|
||||
Misma idea que los grupos de Chrome: teñir para agrupar de un vistazo («todo lo de
|
||||
este cliente, en verde»). El color se elige con clic derecho sobre la pestaña ▸ Color,
|
||||
@@ -185,8 +201,6 @@
|
||||
.tab[data-color="naranja"] { --tc: 250, 144, 62; }
|
||||
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
|
||||
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.32); }
|
||||
/* Con fondo propio el separador vertical sobra: ya se ve dónde acaba cada pestaña. */
|
||||
.tab[data-color]:not(.active)::before { display: none; }
|
||||
.tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
|
||||
@@ -227,6 +241,11 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* La ACTIVA en negrilla: el relieve dice dónde está el plano de delante, pero el peso de
|
||||
la letra es lo que se lee sin mirar. Va en el título y no en la pestaña entera para que
|
||||
el aspa de cerrar no engorde con él. 600 y no 700: a este tamaño el bold entero
|
||||
emborrona, y lo que hace falta es que destaque, no que grite. */
|
||||
.tab.active .titulo { font-weight: 600; }
|
||||
.tab .cerrar {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
|
||||
4
package-lock.json
generado
4
package-lock.json
generado
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "labstream-desktop",
|
||||
"version": "1.14.1",
|
||||
"version": "1.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "labstream-desktop",
|
||||
"version": "1.14.1",
|
||||
"version": "1.19.0",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "labstream-desktop",
|
||||
"version": "1.14.1",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
|
||||
"scripts": {
|
||||
|
||||
28
src-tauri/Cargo.lock
generado
28
src-tauri/Cargo.lock
generado
@@ -2366,11 +2366,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "labstream-desktop"
|
||||
version = "1.14.1"
|
||||
version = "1.19.0"
|
||||
dependencies = [
|
||||
"active-win-pos-rs",
|
||||
"device_query",
|
||||
"gethostname 0.5.0",
|
||||
"percent-encoding",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -2382,6 +2384,7 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"ureq",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
@@ -3603,6 +3606,29 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "labstream-desktop"
|
||||
version = "1.14.1"
|
||||
version = "1.19.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ó).
|
||||
@@ -51,6 +64,8 @@ device_query = "4"
|
||||
# Solo Windows: en macOS no hay API pública sencilla de nivel de audio por app (el equipo de
|
||||
# edición es Windows). El `windows` crate ya está en la caché por Tauri.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Pestañas en reposo (1.19): ICoreWebView2_3::TrySuspend. Es la misma versión que ya trae Tauri.
|
||||
webview2-com = "0.38"
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Com",
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
ENTITLEMENTS DE macOS.
|
||||
|
||||
OJO AL ESCRIBIR AQUÍ: nada de dos guiones seguidos dentro de un comentario. XML los prohíbe,
|
||||
y el que lee este archivo NO es plutil (que los deja pasar) sino AMFI, dentro de codesign,
|
||||
que se planta con «Failed to parse entitlements». Por eso abajo las opciones de codesign se
|
||||
nombran sin sus guiones.
|
||||
|
||||
Estas claves solo sirven de algo si la app se FIRMA pasándole este archivo y con el hardened
|
||||
runtime puesto. Hasta el 2026-09-19 no era el caso: el build no firmaba nada y la app salía
|
||||
con la firma ad hoc del enlazador (flags=0x20002 adhoc linker-signed, Info.plist sin atar,
|
||||
recursos sin sellar). O sea, este archivo era decoración. Lo que lo arregla está en
|
||||
.github/workflows/build.yml.
|
||||
|
||||
Son entitlements del HARDENED RUNTIME, no del App Sandbox (esta app no usa sandbox). El
|
||||
hardened runtime cierra por defecto el micrófono, la cámara y la memoria ejecutable; cada
|
||||
clave vuelve a abrir una, y sin ella la función no falla con un error claro: simplemente no
|
||||
hay permiso.
|
||||
|
||||
Se cayó com.apple.security.network.client: esa es exclusiva del App Sandbox y, sin
|
||||
com.apple.security.app-sandbox, no hace absolutamente nada. Si algún día se mete la app en el
|
||||
sandbox, vuelve, junto con las de acceso a archivos que harán falta.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Micrófono: notas de voz del chat (getUserMedia en el webview). -->
|
||||
<!-- Micrófono: notas de voz del chat (getUserMedia en el webview). El texto que ve la
|
||||
persona al conceder el permiso vive en Info.plist (NSMicrophoneUsageDescription). -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<!-- Cámara: adjuntar foto/video en el chat. -->
|
||||
<!-- Cámara: adjuntar foto y video en el chat (NSCameraUsageDescription). -->
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<!-- Cliente de red (conecta con os.labstreamsas.com). -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<!-- Requerido por WKWebView (JIT de JavaScript) bajo hardened runtime. -->
|
||||
<!-- WKWebView compila JavaScript en caliente. Bajo hardened runtime eso es memoria
|
||||
ejecutable y hay que pedirlo explícitamente. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -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,12 @@
|
||||
mod cache;
|
||||
mod tracker;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -106,6 +113,10 @@ struct TabRec {
|
||||
// Color elegido a mano desde el menú de la pestaña (None = sin teñir). Aquí solo vive el
|
||||
// NOMBRE; el tono exacto y cómo se pinta son cosa del CSS de la barra.
|
||||
color: Option<String>,
|
||||
// Reposo (ver «Pestañas en reposo»): desde cuándo está oculta (None = es la activa) y si ya
|
||||
// se suspendió.
|
||||
oculta_desde: Option<Instant>,
|
||||
dormida: bool,
|
||||
}
|
||||
|
||||
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
|
||||
@@ -148,6 +159,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,
|
||||
@@ -259,6 +279,43 @@ const INIT_JS_TPL: &str = r#"
|
||||
}
|
||||
function newTab(url) { emit('ls-new-tab', { url: url }); }
|
||||
|
||||
// ── Reposo ──
|
||||
// El shell suspende las pestañas que llevan un rato ocultas (dejan de gastar CPU y sueltan
|
||||
// memoria). Antes PREGUNTA: una pestaña con una subida o una descarga en curso, o con algo
|
||||
// sonando, no se duerme. Se cuentan las peticiones vivas (fetch y XHR; el stream del chat es
|
||||
// EventSource y no cuenta) y, ante cualquier duda, la cuenta se queda arriba: no dormir es
|
||||
// siempre el lado seguro.
|
||||
var enCurso = 0;
|
||||
try {
|
||||
var _send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
var hecho = false;
|
||||
enCurso++;
|
||||
this.addEventListener('loadend', function () { if (!hecho) { hecho = true; enCurso--; } });
|
||||
try { return _send.apply(this, arguments); }
|
||||
catch (e) { if (!hecho) { hecho = true; enCurso--; } throw e; }
|
||||
};
|
||||
var _fetch = window.fetch;
|
||||
if (_fetch) {
|
||||
window.fetch = function () {
|
||||
enCurso++;
|
||||
var baja = function () { enCurso--; };
|
||||
var p;
|
||||
try { p = _fetch.apply(this, arguments); } catch (e) { baja(); throw e; }
|
||||
p.then(baja, baja);
|
||||
return p;
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
window.__lsReposo = function () {
|
||||
var suena = false;
|
||||
try {
|
||||
var medios = document.querySelectorAll('video,audio');
|
||||
for (var i = 0; i < medios.length; i++) { if (!medios[i].paused && !medios[i].ended) suena = true; }
|
||||
} catch (e) { suena = true; }
|
||||
emit('ls-reposo', { label: LABEL, libre: enCurso <= 0 && !suena });
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -366,6 +423,10 @@ const INIT_JS_TPL: &str = r#"
|
||||
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
|
||||
if (e.altKey) return;
|
||||
var mod = e.metaKey || e.ctrlKey;
|
||||
// F5 recarga, como en cualquier navegador de Windows (y Ctrl+F5 / Shift+F5 también). WebView2
|
||||
// tiene apagados sus propios atajos, así que F5 no hacía NADA: el único que recargaba era
|
||||
// Ctrl+R, y va antes del filtro de abajo porque F5 no lleva modificador.
|
||||
if (e.key === 'F5') { e.preventDefault(); location.reload(); return; }
|
||||
if (!mod) return;
|
||||
var k = e.key;
|
||||
if (k === 't' || k === 'T') {
|
||||
@@ -404,6 +465,89 @@ const INIT_JS_TPL: &str = r#"
|
||||
})();
|
||||
"#;
|
||||
|
||||
// ── Pestañas en reposo ──
|
||||
//
|
||||
// Cada pestaña es la web app ENTERA: su árbol de React, su conexión del chat, sus sondeos. Con
|
||||
// seis abiertas corrían seis, aunque solo se mire una. Una pestaña que lleva REPOSO_TRAS oculta
|
||||
// se SUSPENDE (WebView2 `TrySuspend`: se congelan sus temporizadores y el sistema puede recuperar
|
||||
// su memoria) y despierta sola, donde estaba, al volver a ella. La activa nunca se duerme, así
|
||||
// que los avisos y el globo de no leídos siguen llegando por ella.
|
||||
//
|
||||
// No se duerme: la que está cargando, la que dice estar ocupada (subida, descarga o audio en
|
||||
// curso: ver `__lsReposo` en el script de la pestaña) ni un editor de documentos (`/docs/`:
|
||||
// OnlyOffice mantiene su propia conexión de edición y no conviene cortarla).
|
||||
// Solo Windows: WKWebView (macOS) no ofrece nada equivalente y ya frena lo oculto por su cuenta.
|
||||
const REPOSO_TRAS: Duration = Duration::from_secs(5 * 60);
|
||||
const REPOSO_RONDA: Duration = Duration::from_secs(60);
|
||||
|
||||
fn puede_reposar(t: &TabRec, activa: bool) -> bool {
|
||||
!activa
|
||||
&& !t.dormida
|
||||
&& !t.loading
|
||||
&& !t.url.contains("/docs/")
|
||||
&& t.oculta_desde.map(|d| d.elapsed() >= REPOSO_TRAS).unwrap_or(false)
|
||||
}
|
||||
|
||||
// Cada minuto: a las candidatas se les PREGUNTA si están libres. La respuesta llega por el
|
||||
// evento `ls-reposo` y es ahí donde se suspende.
|
||||
fn ronda_de_reposo<R: Runtime>(app: &AppHandle<R>) {
|
||||
let candidatas: Vec<String> = {
|
||||
let shell = app.state::<ShellState>();
|
||||
let s = shell.lock().unwrap();
|
||||
s.tabs.iter().enumerate().filter(|(i, t)| puede_reposar(t, *i == s.active)).map(|(_, t)| t.label.clone()).collect()
|
||||
};
|
||||
for label in candidatas {
|
||||
if let Some(wv) = get_webview(app, &label) {
|
||||
let _ = wv.eval("window.__lsReposo && window.__lsReposo()");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dormir_pestana<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||||
{
|
||||
let shell = app.state::<ShellState>();
|
||||
let mut s = shell.lock().unwrap();
|
||||
let activa = s.active;
|
||||
let Some((i, t)) = s.tabs.iter_mut().enumerate().find(|(_, t)| t.label == label) else { return };
|
||||
// Pudo volverse la activa (o empezar a cargar) mientras contestaba.
|
||||
if !puede_reposar(t, i == activa) {
|
||||
return;
|
||||
}
|
||||
t.dormida = true;
|
||||
}
|
||||
if let Some(wv) = get_webview(app, label) {
|
||||
dormir_webview(&wv);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn dormir_webview<R: Runtime>(wv: &Webview<R>) {
|
||||
let _ = wv.with_webview(|pw| unsafe {
|
||||
use webview2_com::{Microsoft::Web::WebView2::Win32::ICoreWebView2_3, TrySuspendCompletedHandler};
|
||||
use windows::core::Interface;
|
||||
let Ok(core) = pw.controller().CoreWebView2() else { return };
|
||||
let Ok(core3) = core.cast::<ICoreWebView2_3>() else { return };
|
||||
// Si WebView2 no puede (la pestaña está visible, o usa algo que lo impide), no pasa nada.
|
||||
let _ = core3.TrySuspend(&TrySuspendCompletedHandler::create(Box::new(|_, _| Ok(()))));
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn dormir_webview<R: Runtime>(_wv: &Webview<R>) {}
|
||||
|
||||
// WebView2 ya despierta sola una vista al hacerla visible; se pide igual, por si acaso.
|
||||
#[cfg(windows)]
|
||||
fn despertar_webview<R: Runtime>(wv: &Webview<R>) {
|
||||
let _ = wv.with_webview(|pw| unsafe {
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_3;
|
||||
use windows::core::Interface;
|
||||
let Ok(core) = pw.controller().CoreWebView2() else { return };
|
||||
let Ok(core3) = core.cast::<ICoreWebView2_3>() else { return };
|
||||
let _ = core3.Resume();
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn despertar_webview<R: Runtime>(_wv: &Webview<R>) {}
|
||||
|
||||
// ── Utilidades del shell ──
|
||||
|
||||
fn main_window<R: Runtime>(app: &AppHandle<R>) -> Option<Window<R>> {
|
||||
@@ -493,6 +637,17 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Reposo: la activa deja de contar; las demás empiezan a contar desde que se ocultan.
|
||||
let ahora = Instant::now();
|
||||
let activa = s.active;
|
||||
for (i, t) in s.tabs.iter_mut().enumerate() {
|
||||
if i == activa {
|
||||
t.oculta_desde = None;
|
||||
t.dormida = false;
|
||||
} else if t.oculta_desde.is_none() {
|
||||
t.oculta_desde = Some(ahora);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(win) = main_window(app) else { return };
|
||||
for wv in win.webviews() {
|
||||
@@ -500,6 +655,7 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
||||
continue;
|
||||
}
|
||||
if wv.label() == label {
|
||||
despertar_webview(&wv);
|
||||
let _ = wv.show();
|
||||
let _ = wv.set_focus();
|
||||
} else {
|
||||
@@ -510,6 +666,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());
|
||||
@@ -525,7 +774,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
||||
let mut s = shell.lock().unwrap();
|
||||
let label = format!("tab-{}", s.next_id);
|
||||
s.next_id += 1;
|
||||
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None });
|
||||
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false });
|
||||
label
|
||||
};
|
||||
|
||||
@@ -545,7 +794,9 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
||||
}
|
||||
true
|
||||
})
|
||||
// Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar.
|
||||
// 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)
|
||||
@@ -553,30 +804,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
|
||||
}
|
||||
// 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| {
|
||||
@@ -1167,6 +1402,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);
|
||||
@@ -1419,6 +1655,19 @@ pub fn run() {
|
||||
broadcast(&h);
|
||||
});
|
||||
let h = handle.clone();
|
||||
handle.listen_any("ls-reposo", move |e| {
|
||||
if field_bool(e.payload(), "libre") == Some(true) {
|
||||
if let Some(l) = field(e.payload(), "label") {
|
||||
dormir_pestana(&h, &l);
|
||||
}
|
||||
}
|
||||
});
|
||||
let h = handle.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(REPOSO_RONDA);
|
||||
ronda_de_reposo(&h);
|
||||
});
|
||||
let h = handle.clone();
|
||||
handle.listen_any("ls-new-tab", move |e| {
|
||||
let url = field(e.payload(), "url");
|
||||
create_tab(&h, url, true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Labstream OS",
|
||||
"version": "1.14.1",
|
||||
"version": "1.19.0",
|
||||
"identifier": "co.labstream.os",
|
||||
"build": {
|
||||
"frontendDist": "../dist"
|
||||
|
||||
Referencia en una nueva incidencia
Block a user