Comparar commits
6 Commits
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
|
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
|
- name: Probar el cliente
|
||||||
run: npm test
|
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
|
- name: Construir, firmar y publicar
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
@@ -61,6 +117,7 @@ jobs:
|
|||||||
# Firma del updater: la clave privada y su contraseña van como secrets del repo.
|
# 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: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
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:
|
with:
|
||||||
# En un tag usa su nombre (v1.0.0); en ejecución manual, deriva vX.Y.Z de la versión.
|
# 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__' }}
|
tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__VERSION__' }}
|
||||||
@@ -70,6 +127,35 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
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:
|
activar:
|
||||||
needs: publish
|
needs: publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -229,20 +229,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
|
### Lo que pasaba hasta la 1.15.0
|
||||||
vez cada sistema muestra una advertencia. Es normal para apps internas:
|
|
||||||
|
|
||||||
- **Windows (SmartScreen):** pantalla azul *"Windows protegió su PC"* →
|
El build **no firmaba** la app de macOS. No se le pasaba ninguna identidad, así que
|
||||||
**Más información → Ejecutar de todos modos**.
|
el bundler de Tauri se saltaba `codesign` entero y el `.dmg` salía con la firma ad hoc
|
||||||
- **macOS (Gatekeeper):** *"no se puede abrir porque proviene de un desarrollador
|
que deja el enlazador. En la app instalada se veía así:
|
||||||
no identificado"* → clic derecho sobre la app → **Abrir** → **Abrir**. (Si
|
|
||||||
insiste, ejecutar una vez: `xattr -cr "/Applications/Labstream OS.app"`.)
|
|
||||||
|
|
||||||
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
|
$ codesign -dv "/Applications/Labstream OS.app"
|
||||||
en Mac (99 USD/año) para quitar estos avisos.
|
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 +309,10 @@ labstream-desktop/
|
|||||||
│ ├── tauri.conf.json nombre, versión, iconos, instaladores, entitlements
|
│ ├── tauri.conf.json nombre, versión, iconos, instaladores, entitlements
|
||||||
│ ├── Cargo.toml dependencias Rust + versión
|
│ ├── Cargo.toml dependencias Rust + versión
|
||||||
│ ├── Info.plist textos de permiso (micrófono/cámara) en macOS
|
│ ├── 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
|
│ ├── build.rs
|
||||||
│ ├── capabilities/default.json
|
│ ├── capabilities/default.json
|
||||||
│ ├── icons/ se generan desde app-icon.png
|
│ ├── icons/ se generan desde app-icon.png
|
||||||
│ └── src/{main.rs,lib.rs} lib.rs crea la ventana + SERVER_URL + tray
|
│ └── 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. */
|
/* Sobre una franja casi blanca, un hover blanco no se vería: aquí oscurece. */
|
||||||
--hover: rgba(0, 0, 0, 0.06);
|
--hover: rgba(0, 0, 0, 0.06);
|
||||||
--btn-hover: rgba(0, 0, 0, 0.08);
|
--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 ──
|
/* ── Quién decide el tema ──
|
||||||
La APP, no Windows: la web elige su modo con su propio botón (clase `dark` en su
|
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);
|
--hover: rgba(255, 255, 255, 0.08);
|
||||||
--btn-hover: rgba(255, 255, 255, 0.1);
|
--btn-hover: rgba(255, 255, 255, 0.1);
|
||||||
--sep: rgba(255, 255, 255, 0.25);
|
--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 {
|
:root.oscuro {
|
||||||
@@ -69,6 +85,13 @@
|
|||||||
--hover: rgba(255, 255, 255, 0.08);
|
--hover: rgba(255, 255, 255, 0.08);
|
||||||
--btn-hover: rgba(255, 255, 255, 0.1);
|
--btn-hover: rgba(255, 255, 255, 0.1);
|
||||||
--sep: rgba(255, 255, 255, 0.25);
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
html, body {
|
html, body {
|
||||||
@@ -119,53 +142,46 @@
|
|||||||
height: 30px;
|
height: 30px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
padding: 0 6px 0 10px;
|
padding: 0 6px 0 10px;
|
||||||
border-radius: 9px; /* inactiva: píldora redondeada (hover) */
|
border-radius: 9px; /* inactiva: píldora redondeada */
|
||||||
position: relative;
|
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); }
|
/* ── PROFUNDIDAD: las que no mandan están detrás ──────────────────────────────────
|
||||||
/* Separador fino a la IZQUIERDA de cada inactiva; se oculta pegado a la activa,
|
Antes la inactiva no era nada: texto suelto sobre la franja, al mismo plano que la
|
||||||
al hover y en la primera (igual que Chrome). */
|
activa, y lo único que las separaba era un filete de 1px. Ahora la inactiva se HUNDE
|
||||||
.tab:not(.active)::before {
|
—velo tenue y sombra interior— y al pasar el ratón el gesto se invierte: la sombra sale
|
||||||
content: "";
|
hacia fuera y el fondo tira hacia el blanco de la activa, así que la pestaña sube hacia
|
||||||
position: absolute;
|
ti. Es sombra y no relleno a propósito: un bloque gris sólido pesa tanto como la activa
|
||||||
left: -1px;
|
y acaba compitiendo con ella.
|
||||||
top: 50%;
|
Con fondo propio el separador vertical entre pestañas sobra —ya se ve dónde acaba cada
|
||||||
transform: translateY(-50%);
|
una—, así que se retiró. */
|
||||||
width: 1px;
|
.tab:not(.active) {
|
||||||
height: 16px;
|
background: var(--tab-atras);
|
||||||
background: var(--sep);
|
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 {
|
.tab.active {
|
||||||
background: var(--tab-active-bg);
|
background: var(--tab-active-bg);
|
||||||
color: var(--tab-active-fg);
|
color: var(--tab-active-fg);
|
||||||
height: 35px;
|
height: 35px;
|
||||||
margin-bottom: 0; /* su base cae 1px SOBRE el borde de la barra y lo tapa */
|
margin-bottom: 0; /* su base cae 1px SOBRE el borde de la barra y lo tapa */
|
||||||
border-radius: 10px 10px 0 0;
|
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;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
/* Curvas cóncavas de Chrome en la base de la pestaña activa: dos piezas de 8px con
|
/* AQUÍ ESTABAN LAS CURVAS CÓNCAVAS DE CHROME: dos piezas de 8px a los lados de la activa
|
||||||
un cuarto de círculo transparente que "derrama" el fondo hacia los lados. */
|
con un cuarto de círculo transparente que derramaba su fondo hacia fuera. El truco de
|
||||||
.tab.active::before,
|
Chrome funciona porque allí NO hay línea bajo la barra; aquí sí la hay, y esas dos
|
||||||
.tab.active::after {
|
piezas blancas la partían en dos justo a los costados de la pestaña — la «fluctuación»
|
||||||
content: "";
|
de la esquina. La activa ya se funde con el contenido por su propio fondo, que tapa la
|
||||||
position: absolute;
|
línea exactamente por su ancho; sin las piezas no asoma nada. */
|
||||||
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);
|
|
||||||
}
|
|
||||||
/* ── Color de pestaña ────────────────────────────────────────────────────────────
|
/* ── Color de pestaña ────────────────────────────────────────────────────────────
|
||||||
Misma idea que los grupos de Chrome: teñir para agrupar de un vistazo («todo lo de
|
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,
|
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="naranja"] { --tc: 250, 144, 62; }
|
||||||
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
|
.tab[data-color]:not(.active) { background: rgba(var(--tc), 0.18); }
|
||||||
.tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.32); }
|
.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)); }
|
.tab[data-color].active { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
|
:root:not(.claro) .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
|
||||||
@@ -227,6 +241,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.tab .cerrar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "labstream-desktop",
|
"name": "labstream-desktop",
|
||||||
"version": "1.14.1",
|
"version": "1.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
|
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generado
2
src-tauri/Cargo.lock
generado
@@ -2366,7 +2366,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "labstream-desktop"
|
name = "labstream-desktop"
|
||||||
version = "1.14.1"
|
version = "1.18.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"active-win-pos-rs",
|
"active-win-pos-rs",
|
||||||
"device_query",
|
"device_query",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "labstream-desktop"
|
name = "labstream-desktop"
|
||||||
version = "1.14.1"
|
version = "1.18.0"
|
||||||
description = "Cliente de escritorio de Labstream OS"
|
description = "Cliente de escritorio de Labstream OS"
|
||||||
authors = ["Labstream SAS"]
|
authors = ["Labstream SAS"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -28,6 +28,19 @@ tauri-plugin-updater = "2"
|
|||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
# ── Diálogo «Guardar como» de las descargas (on_download en create_tab, lib.rs) ──
|
||||||
|
# rfd DIRECTO y no tauri-plugin-dialog: el manejador de descargas de wry exige la respuesta en
|
||||||
|
# el mismo hilo y de forma síncrona (WebView2 DownloadStarting / WKDownload
|
||||||
|
# decideDestination, ambos en el hilo principal). El `blocking_save_file()` del plugin monta
|
||||||
|
# el diálogo ASÍNCRONO (rfd::AsyncFileDialog) y espera en un canal; en macOS ese diálogo se
|
||||||
|
# presenta como hoja (beginSheetModalForWindow) y necesita que el run loop principal siga
|
||||||
|
# girando… que es justo el hilo que está bloqueado esperando el canal → cuelgue. El
|
||||||
|
# `rfd::FileDialog` síncrono corre un bucle modal anidado en el propio hilo
|
||||||
|
# (IFileDialog::Show / NSSavePanel.runModal), que es lo que hace falta aquí.
|
||||||
|
# Mismas features que usa el plugin oficial (common-controls-v6 = estilo moderno en Windows).
|
||||||
|
rfd = { version = "0.17", default-features = false, features = ["common-controls-v6"] }
|
||||||
|
# Para decodificar el último segmento de la URL cuando no hay nombre sugerido (macOS).
|
||||||
|
percent-encoding = "2"
|
||||||
# ── Rastreador de trabajo efectivo (src/tracker.rs) ──
|
# ── Rastreador de trabajo efectivo (src/tracker.rs) ──
|
||||||
# active-win-pos-rs: la ventana al frente (app + título) en Win/Mac.
|
# active-win-pos-rs: la ventana al frente (app + título) en Win/Mac.
|
||||||
# device_query: ¿hubo entrada? (huella de mouse/teclas presionadas; NUNCA qué se tecleó).
|
# device_query: ¿hubo entrada? (huella de mouse/teclas presionadas; NUNCA qué se tecleó).
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!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">
|
<plist version="1.0">
|
||||||
<dict>
|
<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>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<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>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
<!-- Cliente de red (conecta con os.labstreamsas.com). -->
|
<!-- WKWebView compila JavaScript en caliente. Bajo hardened runtime eso es memoria
|
||||||
<key>com.apple.security.network.client</key>
|
ejecutable y hay que pedirlo explícitamente. -->
|
||||||
<true/>
|
|
||||||
<!-- Requerido por WKWebView (JIT de JavaScript) bajo hardened runtime. -->
|
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
// - Globo de NO-LEÍDOS en el icono (evento `ls-badge` desde la web app): número en el
|
// - Globo de NO-LEÍDOS en el icono (evento `ls-badge` desde la web app): número en el
|
||||||
// Dock (macOS), punto rojo en la barra de tareas (Windows) y tooltip de la bandeja.
|
// Dock (macOS), punto rojo en la barra de tareas (Windows) y tooltip de la bandeja.
|
||||||
// - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones.
|
// - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones.
|
||||||
|
// - DESCARGAS con «Guardar como»: el sistema pregunta dónde guardar cada archivo (con el
|
||||||
|
// nombre que sugiere el servidor) en vez de dejarlo caer en silencio en Descargas.
|
||||||
// - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana.
|
// - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana.
|
||||||
// - single-instance (solo release): abrir la app de nuevo enfoca la ventana existente.
|
// - single-instance (solo release): abrir la app de nuevo enfoca la ventana existente.
|
||||||
// - macOS: clic en el Dock reabre la ventana oculta (RunEvent::Reopen) y menú nativo con
|
// - macOS: clic en el Dock reabre la ventana oculta (RunEvent::Reopen) y menú nativo con
|
||||||
@@ -35,7 +37,11 @@
|
|||||||
mod cache;
|
mod cache;
|
||||||
mod tracker;
|
mod tracker;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -148,6 +154,15 @@ impl Default for Shell {
|
|||||||
|
|
||||||
type ShellState = Mutex<Shell>;
|
type ShellState = Mutex<Shell>;
|
||||||
|
|
||||||
|
// Rutas elegidas en «Guardar como» de las descargas EN CURSO, por URL de origen. Hace falta para
|
||||||
|
// nombrar el archivo al terminar: en macOS `DownloadEvent::Finished` llega SIEMPRE con
|
||||||
|
// `path: None` (wry-0.55.1/src/wkwebview/download.rs:98-99 pasa `None` a mano), así que la única
|
||||||
|
// forma de decir «Descargado: informe.xlsx» es recordar aquí lo que el usuario eligió. La URL es
|
||||||
|
// la misma en Requested y en Finished en ambos motores (DownloadOperation.Uri en WebView2;
|
||||||
|
// originalRequest.URL en WKDownload).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Descargas(Mutex<HashMap<String, PathBuf>>);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||||
struct WinRect {
|
struct WinRect {
|
||||||
x: f64,
|
x: f64,
|
||||||
@@ -366,6 +381,10 @@ const INIT_JS_TPL: &str = r#"
|
|||||||
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
|
// nota y en cualquier campo de la app. En macOS no aplica (Option no toca ctrlKey).
|
||||||
if (e.altKey) return;
|
if (e.altKey) return;
|
||||||
var mod = e.metaKey || e.ctrlKey;
|
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;
|
if (!mod) return;
|
||||||
var k = e.key;
|
var k = e.key;
|
||||||
if (k === 't' || k === 'T') {
|
if (k === 't' || k === 'T') {
|
||||||
@@ -510,6 +529,99 @@ fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
|
|||||||
save_persisted(app);
|
save_persisted(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Descargas: «Guardar como» ──
|
||||||
|
//
|
||||||
|
// tauri::webview::DownloadEvent avisa DOS veces por descarga:
|
||||||
|
// - Requested { url, destination: &mut PathBuf }: antes de empezar. `destination` trae la ruta
|
||||||
|
// que el motor usaría por defecto (carpeta Descargas + nombre sugerido) y se puede
|
||||||
|
// SOBRESCRIBIR (ruta absoluta); devolver false cancela la descarga.
|
||||||
|
// - Finished { url, path: Option<PathBuf>, success }: al terminar, bien o mal.
|
||||||
|
//
|
||||||
|
// Cómo llega `destination` según el motor:
|
||||||
|
// - Windows (WebView2): ICoreWebView2DownloadStartingEventArgs::ResultFilePath, que ya lleva el
|
||||||
|
// nombre del Content-Disposition (o del último segmento de la URL) y « (1)», « (2)»… si el
|
||||||
|
// archivo ya existe en Descargas. Si devolvemos true, wry hace SetResultFilePath(destination)
|
||||||
|
// + SetHandled(true) (wry-0.55.1/src/webview2/mod.rs:849-864); si false, SetCancel(true).
|
||||||
|
// - macOS (WKWebView): wry compone ~/Downloads/<suggestedFilename de WebKit> con el mismo
|
||||||
|
// esquema de « (n)» y, si devolvemos true, entrega esa ruta al completion handler de
|
||||||
|
// WKDownload como destino (wry-0.55.1/src/wkwebview/download.rs:49-91). El nombre viene
|
||||||
|
// siempre relleno. LÍMITE: WKWebView solo trata como descarga (a) los enlaces con atributo
|
||||||
|
// `download` (shouldPerformDownload) y (b) las respuestas cuyo MIME no sabe mostrar
|
||||||
|
// (canShowMIMEType == false; wkwebview/navigation.rs:59-73 y 93-100). WebKit NO mira
|
||||||
|
// Content-Disposition al decidir (WebPage::canShowResponse consulta solo el MIME), así que un
|
||||||
|
// PDF o una imagen servidos como attachment se MUESTRAN en la pestaña en vez de bajar; un
|
||||||
|
// .xlsx/.docx/.zip sí dispara la descarga porque WebKit no los renderiza.
|
||||||
|
|
||||||
|
// Abre el «Guardar como» nativo y deja en `destination` la ruta elegida. Devuelve false (y wry
|
||||||
|
// cancela la descarga) si el usuario cierra el diálogo; en ese caso no se avisa de nada.
|
||||||
|
//
|
||||||
|
// El diálogo es SÍNCRONO a propósito: el manejador de wry tiene que responder antes de volver y
|
||||||
|
// en el mismo hilo (el principal, donde WebView2 y WKWebView despachan estos eventos; wry no
|
||||||
|
// expone el deferral de WebView2). Por eso va rfd::FileDialog directo y no tauri-plugin-dialog:
|
||||||
|
// su `blocking_save_file()` monta un rfd::AsyncFileDialog y espera en un canal (macro
|
||||||
|
// blocking_fn en plugins/dialog/src/lib.rs); en macOS ese diálogo asíncrono se presenta como
|
||||||
|
// hoja sobre la ventana principal (beginSheetModalForWindow) y solo se muestra y resuelve si el
|
||||||
|
// run loop principal gira (rfd/src/backend/macos/modal_future.rs:79-102)… que es justo el hilo
|
||||||
|
// que estaría parado en recv() → cuelgue. rfd::FileDialog::save_file corre un bucle modal
|
||||||
|
// anidado en el propio hilo (NSSavePanel.runModal / IFileDialog::Show), que es lo que hace
|
||||||
|
// falta aquí. La ventana va como padre para que en Windows el diálogo sea modal a la app y no
|
||||||
|
// se quede detrás. No se fuerza carpeta inicial: el sistema recuerda la última usada.
|
||||||
|
fn pedir_destino_descarga<R: Runtime>(webview: &Webview<R>, url: &Url, destination: &mut PathBuf) -> bool {
|
||||||
|
let ventana = webview.window();
|
||||||
|
let dialogo = rfd::FileDialog::new()
|
||||||
|
.set_title("Guardar como")
|
||||||
|
.set_file_name(nombre_sugerido(url, destination))
|
||||||
|
.set_parent(&ventana);
|
||||||
|
match dialogo.save_file() {
|
||||||
|
Some(ruta) => {
|
||||||
|
webview.state::<Descargas>().0.lock().unwrap().insert(url.to_string(), ruta.clone());
|
||||||
|
*destination = ruta;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombre que propone el diálogo: el que trae `destination` (WebView2 lo saca del
|
||||||
|
// Content-Disposition que manda el servidor; en macOS wry lo toma del suggestedFilename de
|
||||||
|
// WebKit) y, si viniera vacío, el último segmento de la URL decodificado. Nunca vacío.
|
||||||
|
fn nombre_sugerido(url: &Url, destination: &Path) -> String {
|
||||||
|
let del_motor = destination
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().trim().to_string())
|
||||||
|
.filter(|n| !n.is_empty());
|
||||||
|
if let Some(n) = del_motor {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
url.path_segments()
|
||||||
|
.and_then(|segs| segs.rev().find(|s| !s.is_empty()))
|
||||||
|
.map(|s| percent_encoding::percent_decode_str(s).decode_utf8_lossy().trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "descarga".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notificación nativa al terminar la descarga. En Windows `path` es la ruta final real (puede
|
||||||
|
// diferir de la elegida si el motor tuvo que renombrar); en macOS llega None y se usa la ruta
|
||||||
|
// que el usuario eligió en el diálogo (ver `Descargas`).
|
||||||
|
fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path: Option<PathBuf>, success: bool) {
|
||||||
|
let elegida = webview.state::<Descargas>().0.lock().unwrap().remove(url.as_str());
|
||||||
|
let cuerpo = if !success {
|
||||||
|
"No se pudo completar la descarga.".to_string()
|
||||||
|
} else {
|
||||||
|
match path.or(elegida).as_deref().and_then(Path::file_name) {
|
||||||
|
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
|
||||||
|
None => "Descarga terminada.".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = webview
|
||||||
|
.app_handle()
|
||||||
|
.notification()
|
||||||
|
.builder()
|
||||||
|
.title("Labstream OS")
|
||||||
|
.body(cuerpo)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
|
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
|
||||||
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
|
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
|
||||||
let target = url.unwrap_or_else(|| SERVER_URL.to_string());
|
let target = url.unwrap_or_else(|| SERVER_URL.to_string());
|
||||||
@@ -545,7 +657,9 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
|||||||
}
|
}
|
||||||
true
|
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
|
// El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE
|
||||||
// apagada. wry trae un manejador de descargas por defecto (wry-0.55.1/src/lib.rs:830),
|
// apagada. wry trae un manejador de descargas por defecto (wry-0.55.1/src/lib.rs:830),
|
||||||
// asi que DownloadStarting se registra siempre y siempre responde SetHandled(true)
|
// asi que DownloadStarting se registra siempre y siempre responde SetHandled(true)
|
||||||
@@ -553,30 +667,14 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
|
|||||||
// se encarga la app: ni barra de progreso, ni globo de «descarga terminada». El
|
// se encarga la app: ni barra de progreso, ni globo de «descarga terminada». El
|
||||||
// archivo bajaba bien a la carpeta de Descargas, pero NADA lo decia y parecia roto.
|
// archivo bajaba bien a la carpeta de Descargas, pero NADA lo decia y parecia roto.
|
||||||
// Quitar este manejador NO devuelve la interfaz nativa: el de wry sigue puesto.
|
// Quitar este manejador NO devuelve la interfaz nativa: el de wry sigue puesto.
|
||||||
.on_download(|webview, event| {
|
.on_download(|webview, event| match event {
|
||||||
if let DownloadEvent::Finished { path, success, .. } = &event {
|
DownloadEvent::Requested { url, destination } => pedir_destino_descarga(&webview, &url, destination),
|
||||||
let cuerpo = if !*success {
|
DownloadEvent::Finished { url, path, success, .. } => {
|
||||||
"No se pudo completar la descarga.".to_string()
|
avisar_descarga_terminada(&webview, &url, path, success);
|
||||||
} else if let Some(p) = path {
|
true
|
||||||
// En macOS `path` llega siempre vacio (limite de WKDownload:
|
|
||||||
// wry-0.55.1/src/wkwebview/download.rs:98), asi que en la practica
|
|
||||||
// este brazo con nombre de archivo es el de Windows.
|
|
||||||
match p.file_name() {
|
|
||||||
Some(n) => format!("Descargado: {}", n.to_string_lossy()),
|
|
||||||
None => "Descarga terminada.".to_string(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
"Descarga terminada. Esta en tu carpeta de Descargas.".to_string()
|
|
||||||
};
|
|
||||||
let _ = webview
|
|
||||||
.app_handle()
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.title("Labstream OS")
|
|
||||||
.body(cuerpo)
|
|
||||||
.show();
|
|
||||||
}
|
}
|
||||||
true
|
// El enum es #[non_exhaustive]: si wry añade eventos, dejarlos pasar.
|
||||||
|
_ => true,
|
||||||
})
|
})
|
||||||
// El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación).
|
// El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación).
|
||||||
.on_page_load(move |wv, payload| {
|
.on_page_load(move |wv, payload| {
|
||||||
@@ -1167,6 +1265,7 @@ pub fn run() {
|
|||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
.manage(ShellState::default())
|
.manage(ShellState::default())
|
||||||
|
.manage(Descargas::default())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
let saved = load_persisted(&handle);
|
let saved = load_persisted(&handle);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Labstream OS",
|
"productName": "Labstream OS",
|
||||||
"version": "1.14.1",
|
"version": "1.18.0",
|
||||||
"identifier": "co.labstream.os",
|
"identifier": "co.labstream.os",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user