Comparar commits

..

7 Commits

Autor SHA1 Mensaje Fecha
Jonathan David Florez Salomón
e7c69fe0ea Barra al estilo Notion: una sola fila, pestanas con nombre y reordenables
La ventana gastaba DOS filas en cromo (titulo de macOS + barra de pestanas).
Ahora es una sola: se recuperan 28 px de alto en cada ventana.

- INTEGRADA AL TITULO (macOS): la ventana usa titulo en overlay y sin texto, asi
  que la barra ocupa esa fila. Deja libres 78 px para el semaforo (que sigue
  siendo el nativo) y el hueco vacio arrastra la ventana / doble clic la
  maximiza (data-tauri-drag-region + permiso core🪟allow-start-dragging).
  En Windows NO cambia nada: titulo del sistema y barra debajo, como hasta ahora
  (fusionarlas alli obligaria a dibujar a mano minimizar/maximizar/cerrar).
  Como el area util pasa a ser todo el marco, desaparece la compensacion de
  v1.4.2: los webviews vuelven a colocarse en 0 / TAB_BAR_H.
- NOMBRES QUE SE DISTINGUEN: la web pone el mismo <title> en todas las paginas
  («(25) Labstream OS»), asi que las tres pestanas se llamaban igual. Ahora el
  nombre sale de la RUTA (mismas etiquetas que la barra superior de la web) con
  su emoji: Inicio, Chats, Calendario, Cliente, Proyecto, Operaciones… Si algun
  dia la web pone titulos por pagina, mandan esos: se limpia el contador de no
  leidos y el sufijo de la app y, si queda algo propio, se usa.
- ANCHO ELASTICO: las pestanas se reparten el sitio y se encogen al abrir mas;
  por debajo de ~112 px queda solo el icono (y la x solo en la activa), con el
  nombre completo en el tooltip. Igual que Chrome.
- REORDENAR ARRASTRANDO: con eventos de PUNTERO, no drag&drop de HTML5 (en el
  webview de la barra en macOS los eventos dragstart/dragover no llegan aunque
  se desactive el manejador nativo). El nodo se mueve en vivo y al soltar se
  manda el orden a Rust, que valida que sea una permutacion exacta y lo guarda.
  El clic que cierra el arrastre no activa la pestana.
- CLIC DERECHO EN UNA PESTANA: menu NATIVO (la barra mide 40 px; uno dibujado
  dentro quedaria recortado) con Duplicar, Copiar enlace, Cerrar pestana, Cerrar
  las demas y Cerrar las de la derecha — estas dos se deshabilitan solas cuando
  no aplican. «Copiar enlace» usa tauri-plugin-clipboard-manager.
- SENAL DE CARGA: el icono de la pestana se vuelve un aro girando mientras la
  pagina carga (PageLoadEvent Started/Finished), respetando prefers-reduced-motion.

Verificado en el Mac con la app real: barra en la fila del titulo con el
semaforo intacto, ventana movida arrastrando la barra (15,53 -> 147,243),
nombres con emoji, 14 pestanas en modo solo-icono, menu contextual con sus
opciones deshabilitadas donde toca, «Cerrar las demas», duplicar, y reordenar
(tab-1,tab-2,tab-3 -> tab-3,tab-1,tab-2) persistido entre arranques.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 10:01:24 -05:00
Jonathan David Florez Salomón
bfc4e1172c La barra de pestanas ya no queda debajo del titulo nativo (macOS)
La barra existia pero estaba DIBUJADA DETRAS de la barra de titulo de macOS:
solo asomaban ~12 px por debajo, y como el titulo nativo es translucido hasta
se tenia del color de la barra. Con el rediseno estilo Chrome el sintoma se
volvio evidente (no se veian ni las pestanas ni los controles), pero el fallo
venia de antes: no lo causo el CSS.

Causa (medida en vivo con instrumentacion): en macOS las coordenadas de los
webviews HIJOS son relativas al MARCO de la ventana (incluye el titulo) y
ademas `inner_size()` devuelve el marco, no el area de contenido
(inner_size == outer_size e inner_position == outer_position). Al colocar la
barra en y=0 quedaba bajo el titulo, y el contenido en y=40 arrancaba 12 px
por debajo, tapando el resto.

Arreglo: `top_offset()` compensa el alto del titulo (28 pt) SOLO cuando hace
falta. La deteccion es en caliente -- si el marco y el interior miden lo mismo
hay que compensar; el dia que Tauri lo corrija la resta dejara de ser 0 y no se
compensara nada -- y en Windows/Linux devuelve 0 (ahi las coordenadas ya son
del area de cliente). Se aplica en los tres sitios que colocan webviews:
layout_all, la creacion de la barra y la creacion de cada pestana.

Verificado en el Mac con la app real: barra completa y alineada bajo el
titulo, redimensionado, pestana nueva, cierre de pestana y cambio de pestana.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 08:55:56 -05:00
Jonathan David Florez Salomón
5108b06ae1 v1.4.1: barra de pestanas al estilo Chrome
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 07:18:19 -05:00
Jonathan David Florez Salomón
c56d4cb97d Barra de pestanas al estilo Chrome
La barra del shell ahora se ve como las pestanas de un navegador de verdad:

- La barra es una FRANJA mas oscura (claro #dee1e6 / oscuro #202124) y la
  pestana ACTIVA es una tarjeta del color del contenido con esquinas
  superiores redondeadas y las curvas concavas de Chrome en la base (dos
  piezas radial-gradient de 8px que "derraman" el fondo hacia los lados).
  El contenedor de pestanas se extiende 1px sobre el borde inferior para que
  la activa lo tape y quede FUNDIDA con la pagina; en oscuro su fondo es
  #18181b (el mismo de la app) para que no haya costura.
- Favicon por pestana: mini logo Labstream (SVG inline data-URI, sin red).
- Pestanas inactivas: pildora redondeada al hover y separador fino vertical
  entre ellas, oculto junto a la activa, al hover y en la primera (CSS puro
  con .active + .tab, sin :has).
- El boton de cerrar es un circulito y los botones de la barra pasan a
  hover circular, como en Chrome.

Sin cambios en Rust: TAB_BAR_H sigue en 40 y los eventos ls-* intactos.
Verificado con arnes local (stub de __TAURI__ + 4 pestanas de prueba) en
claro y oscuro: curvas, fusion con el contenido, separadores y favicon OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 07:17:06 -05:00
Jonathan David Florez Salomón
d8a35f5bae Atrás/adelante en la barra + reabrir pestaña + globo de no-leídos en el icono
- ATRÁS/ADELANTE por fin: botones ← → en la barra de pestañas, ⌘[ / ⌘] en
  ambas plataformas, Alt+←/→ en Windows (fuera de campos de texto) y menú
  «Historial» en macOS. Cierra el callejón sin salida: una página sin menú
  propio (p. ej. el portal de revisión) dejaba al usuario atrapado sin forma
  de volver.
- «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T, tope de 20): en la barra ⌘⇧T
  antes caía en «nueva pestaña» por no mirar el Shift.
- Globo de NO-LEÍDOS en el icono: la web app emite `ls-badge {count}` (mismo
  conteo del «(N)» del título) y el shell pinta el número en el Dock (macOS),
  un punto rojo dibujado a mano sobre el icono de la barra de tareas (Windows,
  sin assets) y el tooltip de la bandeja («— N sin leer»).

Verificado: cargo check limpio (macOS) y firmas de las APIs solo-Windows
(set_overlay_icon, set_badge_count, Image::new_owned) cotejadas contra el
código fuente de tauri 2.11.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:11:37 -05:00
Jonathan David Florez Salomón
d682b06ab9 Versión visible en la barra de la app (clic = buscar actualización)
La barra muestra la versión real del paquete (viene de Rust en el payload
ls-tabs, no quemada en el HTML) junto al menú ⋮. Clic en la etiqueta →
"Buscar actualización…" con aviso del resultado (evento ls-check-update).
Versión 1.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:02:30 -05:00
Jonathan David Florez Salomón
329ea1195e Menú de opciones ⋮ de la app (popup nativo) + preferencia de autostart
Botón ⋮ en la barra de pestañas (y clic derecho sobre ella) → menú NATIVO del
sistema, idéntico en Windows y macOS, solo del shell (la web app no se toca):

- Nueva/cerrar pestaña, recargar, acercar/alejar/tamaño real.
- "Abrir esta página en el navegador" (la pestaña activa, vía opener).
- "Iniciar con el sistema" con check: ahora la elección SE RECUERDA
  (antes el arranque re-activaba el autostart en cada apertura).
- "Buscar actualización…" manual con aviso del resultado (instalando /
  ya estás al día / sin red), además del chequeo silencioso de siempre.
- Versión visible y "Salir de Labstream OS".

El manejador de menús pasa a ser global (antes solo existía dentro del cfg de
macOS): lo comparten el menú de macOS y el popup ⋮ de ambas plataformas. El
menú de macOS gana "Buscar actualización…" y "Abrir en el navegador".

Versión 1.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:50:46 -05:00
Se han modificado 9 ficheros con 1109 adiciones y 91 borrados

Ver fichero

@@ -25,6 +25,11 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
en español (Edición/Vista/Archivo) con los atajos. en español (Edición/Vista/Archivo) con los atajos.
- **Arranque automático** al iniciar sesión y **sesión persistente** (no re-loguea). - **Arranque automático** al iniciar sesión y **sesión persistente** (no re-loguea).
- **Una sola instancia** (release): abrirla de nuevo enfoca la ventana existente. - **Una sola instancia** (release): abrirla de nuevo enfoca la ventana existente.
- **Menú de opciones ⋮** (v1.2.0) en la barra — también con clic derecho sobre ella:
pestañas, zoom, recargar, abrir la página en el navegador, **Iniciar con el
sistema** (elección recordada) y **Buscar actualización…** con aviso del
resultado. Es un popup nativo del sistema, igual en Windows y macOS, y vive
solo en el shell (la web app no se toca).
La barra de pestañas vive en `dist/index.html` (webview local «chrome»); las La barra de pestañas vive en `dist/index.html` (webview local «chrome»); las
pestañas son webviews hijos `tab-*`. Shell ↔ Rust se hablan por eventos `ls-*`. pestañas son webviews hijos `tab-*`. Shell ↔ Rust se hablan por eventos `ls-*`.

353
dist/index.html vendido
Ver fichero

@@ -7,30 +7,39 @@
<!-- <!--
Barra de pestañas del shell de escritorio (webview "chrome", local). Barra de pestañas del shell de escritorio (webview "chrome", local).
Habla con Rust por eventos (__TAURI__.event): Habla con Rust por eventos (__TAURI__.event):
recibe ls-tabs {tabs:[{label,title,active}], zoom} recibe ls-tabs {tabs:[{label,title,url,loading,active}], zoom, version, mac}
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle, ls-activate-n emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle,
ls-activate-n, ls-nav, ls-menu, ls-check-update, ls-tab-menu, ls-reorder
En macOS la ventana lleva el título en OVERLAY: esta barra ES la fila del título, así que
deja libre el hueco del semáforo (clase `mac`) y el espacio vacío arrastra la ventana
(`data-tauri-drag-region`). En Windows hay título nativo aparte y no se reserva nada.
--> -->
<style> <style>
/* Paleta estilo Chrome: la barra es una FRANJA más oscura y la pestaña activa es una
tarjeta del color del contenido que se funde con él (sin línea debajo). */
:root { :root {
--bg: #f4f4f5; /* zinc-100 */ --bg: #dee1e6; /* franja de pestañas (Chrome claro) */
--fg: #3f3f46; /* zinc-700 */ --fg: #45474a;
--muted: #a1a1aa; /* zinc-400 */ --muted: #5f6368;
--tab-bg: transparent;
--tab-active-bg: #ffffff; --tab-active-bg: #ffffff;
--tab-active-fg: #18181b; --tab-active-fg: #202124;
--accent: #6366f1; /* indigo-500, color de Labstream */ --accent: #6366f1; /* indigo-500, color de Labstream */
--border: #e4e4e7; /* zinc-200 */ --border: #c9ccd1; /* línea bajo la barra (la tapa la pestaña activa) */
--hover: rgba(0, 0, 0, 0.05); --hover: rgba(255, 255, 255, 0.55); /* píldora de hover en pestañas inactivas */
--btn-hover: rgba(0, 0, 0, 0.08);
--sep: rgba(0, 0, 0, 0.22); /* separador fino entre pestañas inactivas */
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #18181b; --bg: #202124; /* franja (Chrome oscuro) */
--fg: #d4d4d8; --fg: #bdc1c6;
--muted: #71717a; --muted: #80868b;
--tab-active-bg: #27272a; --tab-active-bg: #18181b; /* mismo fondo que la app → pestaña fundida con el contenido */
--tab-active-fg: #fafafa; --tab-active-fg: #e8eaed;
--border: #27272a; --border: #101013;
--hover: rgba(255, 255, 255, 0.07); --hover: rgba(255, 255, 255, 0.08);
--btn-hover: rgba(255, 255, 255, 0.1);
--sep: rgba(255, 255, 255, 0.25);
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -48,47 +57,109 @@
} }
#bar { #bar {
display: flex; display: flex;
align-items: stretch; align-items: center;
gap: 4px; gap: 2px;
height: 40px; height: 40px;
padding: 5px 8px 0; padding: 0 6px 0 8px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
/* macOS: hueco para los botones rojo/amarillo/verde, que son NATIVOS y se pintan encima. */
body.mac #bar { padding-left: 78px; }
#tabs { #tabs {
display: flex; display: flex;
align-items: stretch; align-items: flex-end;
gap: 2px;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
overflow-x: auto; /* Se extiende 1px sobre el borde inferior de la barra para que la pestaña ACTIVA
scrollbar-width: none; lo tape con su propio fondo y quede FUNDIDA con el contenido (look Chrome). */
align-self: stretch;
margin-bottom: -1px;
padding: 0 4px;
overflow: hidden;
} }
#tabs::-webkit-scrollbar { display: none; }
.tab { .tab {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 7px;
flex: 0 1 200px; /* Elástico como en Chrome: se reparten el ancho y se encogen al abrir más. */
min-width: 96px; flex: 1 1 220px;
padding: 0 6px 0 12px; min-width: 42px;
border-radius: 8px 8px 0 0; max-width: 240px;
background: var(--tab-bg); height: 30px;
margin-bottom: 5px;
padding: 0 6px 0 10px;
border-radius: 9px; /* inactiva: píldora redondeada (hover) */
position: relative; position: relative;
overflow: hidden; overflow: visible; /* deja asomar las curvas de la base de la activa */
} }
.tab:hover { background: var(--hover); } .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);
}
.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);
box-shadow: 0 -1px 0 var(--border), 1px 0 0 var(--border), -1px 0 0 var(--border); height: 35px;
margin-bottom: 0; /* su base cae 1px SOBRE el borde de la barra y lo tapa */
border-radius: 10px 10px 0 0;
z-index: 1;
} }
.tab.active::before { /* 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: ""; content: "";
position: absolute; position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
height: 2px; width: 8px;
background: var(--accent); height: 8px;
border-radius: 2px 2px 0 0; }
.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);
}
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
macOS los eventos no llegan), sino eventos de puntero. Se apaga el arrastre nativo de
WebKit para que no salga la imagen fantasma al mover una pestaña. */
.tab { -webkit-user-drag: none; }
.tab.arrastrando { opacity: 0.5; }
.tab .favicon {
flex: 0 0 auto;
width: 15px;
height: 15px;
display: grid;
place-items: center;
font-size: 12.5px;
line-height: 1;
}
/* Señal de carga: el icono se sustituye por un aro girando. */
.tab .favicon.cargando {
border: 1.6px solid var(--sep);
border-top-color: var(--accent);
border-radius: 50%;
animation: giro 0.7s linear infinite;
font-size: 0;
}
@keyframes giro { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.tab .favicon.cargando { animation-duration: 2.4s; }
} }
.tab .titulo { .tab .titulo {
flex: 1 1 auto; flex: 1 1 auto;
@@ -103,30 +174,35 @@
height: 18px; height: 18px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 5px; border-radius: 50%; /* circulito, como el × de Chrome */
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
line-height: 1; line-height: 1;
visibility: hidden; visibility: hidden;
} }
.tab:hover .cerrar, .tab.active .cerrar { visibility: visible; } .tab:hover .cerrar, .tab.active .cerrar { visibility: visible; }
.tab .cerrar:hover { background: var(--hover); color: var(--tab-active-fg); } .tab .cerrar:hover { background: var(--btn-hover); color: var(--tab-active-fg); }
/* Muchas pestañas abiertas: solo el icono (el nombre completo queda en el tooltip),
y la × únicamente en la activa — como hace Chrome al quedarse sin sitio. */
#tabs.compactas .tab { padding: 0 6px; justify-content: center; }
#tabs.compactas .tab .titulo { display: none; }
#tabs.compactas .tab:not(.active) .cerrar { display: none; }
button { button {
appearance: none; appearance: none;
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--fg); color: var(--fg);
font: inherit; font: inherit;
border-radius: 7px; border-radius: 50%;
cursor: default; cursor: default;
display: grid; display: grid;
place-items: center; place-items: center;
} }
button:hover { background: var(--hover); } button:hover { background: var(--btn-hover); }
.icono { .icono {
flex: 0 0 auto; flex: 0 0 auto;
width: 30px; width: 30px;
margin: 2px 0 7px; height: 26px;
font-size: 15px; font-size: 15px;
} }
#zoom { #zoom {
@@ -134,23 +210,38 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1px; gap: 1px;
margin: 2px 0 7px;
} }
#zoom button { height: 100%; } #z-menos, #z-mas { width: 26px; height: 26px; font-size: 14px; }
#z-menos, #z-mas { width: 26px; font-size: 14px; }
#z-pct { #z-pct {
min-width: 44px; min-width: 44px;
height: 26px;
padding: 0 4px; padding: 0 4px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--muted); color: var(--muted);
font-size: 11.5px; font-size: 11.5px;
border-radius: 13px;
} }
#z-pct.activo { color: var(--accent); font-weight: 600; } #z-pct.activo { color: var(--accent); font-weight: 600; }
#version {
flex: 0 0 auto;
height: 26px;
padding: 0 7px;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
border-radius: 13px;
}
#version:hover { color: var(--accent); }
</style> </style>
</head> </head>
<body> <body>
<div id="bar"> <!-- data-tauri-drag-region: el hueco libre de la barra mueve la ventana y el doble clic
<div id="tabs" title=""></div> la maximiza (en macOS ya no hay barra de título nativa de la que tirar). -->
<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>
<div id="tabs" data-tauri-drag-region></div>
<button class="icono" id="nueva" title="Nueva pestaña (⌘/Ctrl+T)">+</button> <button class="icono" id="nueva" title="Nueva pestaña (⌘/Ctrl+T)">+</button>
<button class="icono" id="recargar" title="Recargar (⌘/Ctrl+R)">⟳</button> <button class="icono" id="recargar" title="Recargar (⌘/Ctrl+R)">⟳</button>
<div id="zoom"> <div id="zoom">
@@ -158,6 +249,8 @@
<button id="z-pct" title="Restablecer zoom (⌘/Ctrl+0)">100%</button> <button id="z-pct" title="Restablecer zoom (⌘/Ctrl+0)">100%</button>
<button id="z-mas" title="Acercar (⌘/Ctrl++)">+</button> <button id="z-mas" title="Acercar (⌘/Ctrl++)">+</button>
</div> </div>
<button id="version" title="Versión de la app · clic: buscar actualización"></button>
<button class="icono" id="menu" title="Opciones de la app">⋮</button>
</div> </div>
<script> <script>
const T = window.__TAURI__; const T = window.__TAURI__;
@@ -170,17 +263,138 @@
const $tabs = document.getElementById("tabs"); const $tabs = document.getElementById("tabs");
const $pct = document.getElementById("z-pct"); const $pct = document.getElementById("z-pct");
const $ver = document.getElementById("version");
// Nombre + icono de cada pestaña a partir de su RUTA (mismas etiquetas que la barra
// superior de la web). Sin esto todas las pestañas se llamaban «(25) Labstream OS» y
// no había forma de distinguirlas.
const RUTAS = [
[/^\/$/, "🏠", "Inicio"],
[/^\/inicio/, "🏠", "Inicio"],
[/^\/mis-tareas/, "✅", "Mis tareas"],
[/^\/mis-entregas/, "📦", "Mis entregas"],
[/^\/estados/, "💬", "Chat del día"],
[/^\/chat/, "💬", "Chats"],
[/^\/notas/, "📝", "Notas"],
[/^\/recordatorios/, "⏰", "Recordatorios"],
[/^\/proyectos\/nuevo/, "✨", "Nuevo proyecto"],
[/^\/proyectos\/[^/]+/, "🎬", "Proyecto"],
[/^\/proyectos/, "🎬", "Proyectos"],
[/^\/plantillas/, "🗂️", "Plantillas"],
[/^\/calendario/, "📅", "Calendario"],
[/^\/wiki\/inventario/, "📦", "Inventario"],
[/^\/wiki\/ubicacion/, "📍", "Ubicación"],
[/^\/wiki/, "📚", "Wiki"],
[/^\/clientes\/nuevo/, "✨", "Nuevo cliente"],
[/^\/clientes\/[^/]+/, "👤", "Cliente"],
[/^\/clientes/, "👤", "Clientes"],
[/^\/operaciones/, "🗄️", "Operaciones"],
[/^\/revisiones/, "🎞️", "Revisiones"],
[/^\/entregas-finales/, "🏁", "Entregas finales"],
[/^\/cotizaciones/, "🧾", "Cotizaciones"],
[/^\/propuestas/, "📄", "Propuestas"],
[/^\/facturacion/, "💵", "Facturación"],
[/^\/comercial/, "📈", "Comercial"],
[/^\/biblioteca/, "📚", "Biblioteca"],
[/^\/reportes/, "📊", "Reportes"],
[/^\/timeline/, "🧭", "Cronograma"],
[/^\/asistente/, "🤖", "Marcebot"],
[/^\/configuracion/, "⚙️", "Configuración"],
[/^\/ajustes/, "⚙️", "Ajustes"],
[/^\/perfil/, "👤", "Mi perfil"],
[/^\/papelera/, "🗑️", "Papelera"],
[/^\/docs\//, "📄", "Documento"],
];
function rutaMeta(url) {
let ruta = "/";
try { ruta = new URL(url).pathname; } catch (e) {}
for (const [re, emoji, nombre] of RUTAS) {
if (re.test(ruta)) return { emoji, nombre };
}
return { emoji: "🗂️", nombre: "Labstream OS" };
}
// El título de la web es «(25) Labstream OS» en todas las páginas: se le quita el
// contador de no leídos y el nombre de la app. Si queda algo propio, ese manda (así,
// el día que la web ponga títulos por página, la barra los aprovecha sola).
function limpiarTitulo(t) {
if (!t) return "";
let x = String(t).replace(/^\(\d+\)\s*/, "").trim();
x = x.replace(/\s*[·|—–-]\s*Labstream(\s+OS)?\s*$/i, "").trim();
return /^labstream(\s+os)?$/i.test(x) ? "" : x;
}
function nombreDe(t) {
const meta = rutaMeta(t.url || "");
return { emoji: meta.emoji, texto: limpiarTitulo(t.title) || meta.nombre };
}
// Reordenar arrastrando, con eventos de PUNTERO: el drag&drop de HTML5 no llega al
// webview de la barra en macOS. Se mueve el nodo en vivo y al soltar se manda el orden.
let arrastre = null; // {el, x0, movido}
let tragarClick = false; // el clic que cierra el arrastre no debe activar la pestaña
function ordenActual() {
return Array.from($tabs.children).map((el) => el.dataset.label);
}
addEventListener("mousemove", (e) => {
if (!arrastre) return;
if (!arrastre.movido) {
if (Math.abs(e.clientX - arrastre.x0) < 5) return; // todavía es un clic, no un arrastre
arrastre.movido = true;
arrastre.el.classList.add("arrastrando");
}
for (const otro of Array.from($tabs.children)) {
if (otro === arrastre.el) continue;
const r = otro.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right) {
const despues = e.clientX > r.left + r.width / 2;
$tabs.insertBefore(arrastre.el, despues ? otro.nextSibling : otro);
break;
}
}
});
addEventListener("mouseup", () => {
if (!arrastre) return;
const { el, movido } = arrastre;
arrastre = null;
el.classList.remove("arrastrando");
if (movido) {
tragarClick = true;
emitir("ls-reorder", { order: ordenActual().join(",") });
}
});
// Modo solo-icono: se mide el ancho REAL de una pestaña ya pintada (el reparto lo hace
// flex, así que todas miden igual) en vez de estimarlo dividiendo el contenedor.
function ajustarCompactas() {
const primera = $tabs.firstElementChild;
if (!primera) return;
const ancho = primera.getBoundingClientRect().width;
if (!ancho) return;
$tabs.classList.toggle("compactas", ancho < 112);
}
function render() { function render() {
$tabs.textContent = ""; $tabs.textContent = "";
for (const t of estado.tabs) { for (const t of estado.tabs) {
const { emoji, texto } = nombreDe(t);
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "tab" + (t.active ? " active" : ""); el.className = "tab" + (t.active ? " active" : "");
el.title = t.title; el.dataset.label = t.label;
el.title = texto;
const fav = document.createElement("span");
fav.className = "favicon" + (t.loading ? " cargando" : "");
fav.textContent = t.loading ? "" : emoji;
el.appendChild(fav);
const titulo = document.createElement("span"); const titulo = document.createElement("span");
titulo.className = "titulo"; titulo.className = "titulo";
titulo.textContent = t.title || "Labstream OS"; titulo.textContent = texto;
el.appendChild(titulo); el.appendChild(titulo);
const x = document.createElement("button"); const x = document.createElement("button");
@@ -193,18 +407,40 @@
}); });
el.appendChild(x); el.appendChild(x);
el.addEventListener("click", () => emitir("ls-activate", { label: t.label })); el.addEventListener("click", () => {
if (tragarClick) { tragarClick = false; return; } // veníamos de reordenar
emitir("ls-activate", { label: t.label });
});
// Clic central sobre la pestaña = cerrarla (como en el navegador). // Clic central sobre la pestaña = cerrarla (como en el navegador).
el.addEventListener("auxclick", (e) => { el.addEventListener("auxclick", (e) => {
if (e.button === 1) emitir("ls-close-tab", { label: t.label }); if (e.button === 1) emitir("ls-close-tab", { label: t.label });
}); });
// Clic derecho: menú NATIVO de la pestaña (duplicar, cerrar otras, copiar enlace…).
// La barra mide 40px: un menú dibujado aquí dentro quedaría recortado.
el.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
emitir("ls-tab-menu", { label: t.label });
});
// Reordenar arrastrando: lo gestionan los listeners de puntero de más arriba.
el.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
arrastre = { el, x0: e.clientX, movido: false };
});
$tabs.appendChild(el); $tabs.appendChild(el);
} }
requestAnimationFrame(ajustarCompactas);
const activa = $tabs.querySelector(".tab.active"); const activa = $tabs.querySelector(".tab.active");
if (activa) activa.scrollIntoView({ block: "nearest", inline: "nearest" }); if (activa) activa.scrollIntoView({ block: "nearest", inline: "nearest" });
$pct.textContent = estado.zoom + "%"; $pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100); $pct.classList.toggle("activo", estado.zoom !== 100);
$ver.textContent = estado.version ? "v" + estado.version : "";
// En macOS la barra ES la fila del título: hay que dejar libre el semáforo.
document.body.classList.toggle("mac", !!estado.mac);
} }
if (E) { if (E) {
@@ -213,9 +449,20 @@
render(); render();
}); });
} }
// Al cambiar el ancho de la ventana se recalcula si caben los nombres.
addEventListener("resize", ajustarCompactas);
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("nueva").addEventListener("click", () => emitir("ls-new-tab", {}));
document.getElementById("recargar").addEventListener("click", () => emitir("ls-reload", {})); 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", {}));
$ver.addEventListener("click", () => emitir("ls-check-update", {}));
document.getElementById("bar").addEventListener("contextmenu", (e) => {
e.preventDefault();
emitir("ls-menu", {});
});
document.getElementById("z-menos").addEventListener("click", () => emitir("ls-zoom", { dir: -1 })); document.getElementById("z-menos").addEventListener("click", () => emitir("ls-zoom", { dir: -1 }));
document.getElementById("z-mas").addEventListener("click", () => emitir("ls-zoom", { dir: 1 })); document.getElementById("z-mas").addEventListener("click", () => emitir("ls-zoom", { dir: 1 }));
$pct.addEventListener("click", () => emitir("ls-zoom", { dir: 0 })); $pct.addEventListener("click", () => emitir("ls-zoom", { dir: 0 }));
@@ -229,9 +476,11 @@
const mod = e.metaKey || e.ctrlKey; const mod = e.metaKey || e.ctrlKey;
if (!mod) return; if (!mod) return;
const k = e.key; const k = e.key;
if (k === "t" || k === "T") { e.preventDefault(); emitir("ls-new-tab", {}); } if (k === "t" || k === "T") { e.preventDefault(); emitir(e.shiftKey ? "ls-reopen" : "ls-new-tab", {}); }
else if (k === "w" || k === "W") { e.preventDefault(); emitir("ls-close-tab", {}); } else if (k === "w" || k === "W") { e.preventDefault(); emitir("ls-close-tab", {}); }
else if ((k === "r" || k === "R") && !e.shiftKey) { e.preventDefault(); emitir("ls-reload", {}); } else if ((k === "r" || k === "R") && !e.shiftKey) { e.preventDefault(); emitir("ls-reload", {}); }
else if (k === "[") { e.preventDefault(); emitir("ls-nav", { dir: -1 }); }
else if (k === "]") { e.preventDefault(); emitir("ls-nav", { dir: 1 }); }
else if (k === "=" || k === "+") { e.preventDefault(); emitir("ls-zoom", { dir: 1 }); } else if (k === "=" || k === "+") { e.preventDefault(); emitir("ls-zoom", { dir: 1 }); }
else if (k === "-") { e.preventDefault(); emitir("ls-zoom", { dir: -1 }); } else if (k === "-") { e.preventDefault(); emitir("ls-zoom", { dir: -1 }); }
else if (k === "0") { e.preventDefault(); emitir("ls-zoom", { dir: 0 }); } else if (k === "0") { e.preventDefault(); emitir("ls-zoom", { dir: 0 }); }

4
package-lock.json generado
Ver fichero

@@ -1,12 +1,12 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.0.0", "version": "1.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.0.0", "version": "1.4.0",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
} }

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.1.0", "version": "1.4.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": {

351
src-tauri/Cargo.lock generado
Ver fichero

@@ -56,6 +56,27 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -354,6 +375,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.0" version = "1.12.0"
@@ -485,6 +512,15 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -587,6 +623,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -827,13 +869,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [ dependencies = [
"bit-set", "bit-set",
"cssparser", "cssparser",
"foldhash", "foldhash 0.2.0",
"html5ever", "html5ever",
"precomputed-hash", "precomputed-hash",
"selectors", "selectors",
"tendril", "tendril",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -959,6 +1007,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.1" version = "5.4.1"
@@ -986,6 +1040,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@@ -1021,6 +1081,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -1037,6 +1103,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -1272,6 +1344,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -1454,12 +1536,32 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -1740,6 +1842,20 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -1946,13 +2062,14 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.1.0" version = "1.5.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart", "tauri-plugin-autostart",
"tauri-plugin-clipboard-manager",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
@@ -2117,6 +2234,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.19.3" version = "0.19.3"
@@ -2168,6 +2295,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.18.0" version = "4.18.0"
@@ -2239,6 +2375,7 @@ dependencies = [
"block2", "block2",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2467,6 +2604,16 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "osakit" name = "osakit"
version = "0.3.1" version = "0.3.1"
@@ -2547,6 +2694,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
@@ -2631,7 +2789,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.14.0", "indexmap 2.14.0",
"quick-xml", "quick-xml 0.39.4",
"serde", "serde",
"time", "time",
] ]
@@ -2768,6 +2926,18 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"
@@ -2777,6 +2947,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -3801,6 +3980,21 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
dependencies = [
"arboard",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.3" version = "2.3.3"
@@ -4065,6 +4259,20 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.49" version = "0.3.49"
@@ -4375,6 +4583,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -4646,6 +4865,76 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "wayland-backend"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.102" version = "0.3.102"
@@ -4757,6 +5046,12 @@ dependencies = [
"windows-core 0.61.2", "windows-core 0.61.2",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -5240,6 +5535,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"
@@ -5311,6 +5624,23 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]] [[package]]
name = "xattr" name = "xattr"
version = "1.6.1" version = "1.6.1"
@@ -5503,6 +5833,21 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.12.0" version = "5.12.0"

Ver fichero

@@ -1,6 +1,6 @@
[package] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.1.0" version = "1.5.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"
@@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] }
# `unstable` habilita multiwebview: varios webviews (pestañas) dentro de UNA ventana. # `unstable` habilita multiwebview: varios webviews (pestañas) dentro de UNA ventana.
tauri = { version = "2", features = ["tray-icon", "unstable"] } tauri = { version = "2", features = ["tray-icon", "unstable"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-autostart = "2" tauri-plugin-autostart = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"

Ver fichero

@@ -2,14 +2,22 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Permisos del shell de escritorio: la barra local (chrome) y las pestañas remotas (tab-*).", "description": "Permisos del shell de escritorio: la barra local (chrome) y las pestañas remotas (tab-*).",
"windows": ["main"], "windows": [
"webviews": ["chrome", "tab-*"], "main"
],
"webviews": [
"chrome",
"tab-*"
],
"remote": { "remote": {
"urls": ["https://os.labstreamsas.com"] "urls": [
"https://os.labstreamsas.com"
]
}, },
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"notification:default" "notification:default",
"core:window:allow-start-dragging"
] ]
} }

Ver fichero

@@ -17,6 +17,11 @@
// - plugin `updater`: busca versión nueva en los Releases de GitHub y se actualiza sola. // - plugin `updater`: busca versión nueva en los Releases de GitHub y se actualiza sola.
// - bandeja (tray) + "cerrar = ocultar": sigue corriendo y notificando en segundo plano. // - bandeja (tray) + "cerrar = ocultar": sigue corriendo y notificando en segundo plano.
// Y lo nuevo: // Y lo nuevo:
// - ATRÁS/ADELANTE: botones ← → en la barra, ⌘[ / ⌘] (Alt+←/→ en Windows) y menú
// Historial (macOS). Antes una página sin menú propio era un callejón sin salida.
// - «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T), como en el navegador.
// - 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. // - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones.
// - 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.
@@ -32,7 +37,7 @@ use std::sync::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tauri::{ use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}, menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
webview::{PageLoadEvent, Webview, WebviewBuilder}, webview::{PageLoadEvent, Webview, WebviewBuilder},
window::{Window, WindowBuilder}, window::{Window, WindowBuilder},
@@ -41,10 +46,14 @@ use tauri::{
}; };
use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
const SERVER_URL: &str = "https://os.labstreamsas.com"; const SERVER_URL: &str = "https://os.labstreamsas.com";
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome) const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
const CHROME: &str = "chrome"; const CHROME: &str = "chrome";
// Hueco que ocupan los botones rojo/amarillo/verde en macOS: la barra deja libre ese
// espacio a la izquierda (lo aplica el CSS de la barra con la bandera `mac` del payload).
// Escalones de zoom (como un navegador). 1.0 = 100%. // Escalones de zoom (como un navegador). 1.0 = 100%.
const ZOOM_STEPS: &[f64] = &[0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5]; const ZOOM_STEPS: &[f64] = &[0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5];
@@ -56,6 +65,7 @@ struct TabRec {
label: String, label: String,
title: String, title: String,
url: String, url: String,
loading: bool, // la barra pinta un giro en el icono mientras carga
} }
struct Shell { struct Shell {
@@ -64,11 +74,14 @@ struct Shell {
zoom_idx: usize, zoom_idx: usize,
next_id: u64, next_id: u64,
win: Option<WinRect>, win: Option<WinRect>,
autostart: Option<bool>, // elección del usuario en el menú ⋮ (None = por defecto, activado)
closed: Vec<String>, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T)
menu_tab: Option<String>, // pestaña sobre la que se abrió el menú de clic derecho
} }
impl Default for Shell { impl Default for Shell {
fn default() -> Self { fn default() -> Self {
Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None } Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None }
} }
} }
@@ -93,6 +106,8 @@ struct Persisted {
tabs: Vec<String>, tabs: Vec<String>,
#[serde(default)] #[serde(default)]
active: usize, active: usize,
#[serde(default)]
autostart: Option<bool>,
} }
fn state_path<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> { fn state_path<R: Runtime>(app: &AppHandle<R>) -> Option<std::path::PathBuf> {
@@ -116,6 +131,7 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
win: s.win, win: s.win,
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(), tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
active: s.active, active: s.active,
autostart: s.autostart,
} }
}; };
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
@@ -211,9 +227,14 @@ const INIT_JS_TPL: &str = r#"
var mod = e.metaKey || e.ctrlKey; var mod = e.metaKey || e.ctrlKey;
if (!mod) return; if (!mod) return;
var k = e.key; var k = e.key;
if (k === 't' || k === 'T') { e.preventDefault(); newTab(null); } if (k === 't' || k === 'T') {
e.preventDefault();
if (e.shiftKey) emit('ls-reopen', {}); else newTab(null);
}
else if (k === 'w' || k === 'W') { e.preventDefault(); emit('ls-close-tab', { label: LABEL }); } else if (k === 'w' || k === 'W') { e.preventDefault(); emit('ls-close-tab', { label: LABEL }); }
else if ((k === 'r' || k === 'R') && !e.shiftKey) { e.preventDefault(); location.reload(); } else if ((k === 'r' || k === 'R') && !e.shiftKey) { e.preventDefault(); location.reload(); }
else if (k === '[') { e.preventDefault(); history.back(); }
else if (k === ']') { e.preventDefault(); history.forward(); }
else if (k === '=' || k === '+') { e.preventDefault(); emit('ls-zoom', { dir: 1 }); } else if (k === '=' || k === '+') { e.preventDefault(); emit('ls-zoom', { dir: 1 }); }
else if (k === '-') { e.preventDefault(); emit('ls-zoom', { dir: -1 }); } else if (k === '-') { e.preventDefault(); emit('ls-zoom', { dir: -1 }); }
else if (k === '0') { e.preventDefault(); emit('ls-zoom', { dir: 0 }); } else if (k === '0') { e.preventDefault(); emit('ls-zoom', { dir: 0 }); }
@@ -221,6 +242,18 @@ const INIT_JS_TPL: &str = r#"
else if (k >= '1' && k <= '9') { e.preventDefault(); emit('ls-activate-n', { n: +k }); } else if (k >= '1' && k <= '9') { e.preventDefault(); emit('ls-activate-n', { n: +k }); }
}, true); }, true);
// Alt+←/→ = atrás/adelante (convención de Windows/Linux; en Mac es ⌘[ / ⌘] y Alt+flecha
// se respeta para saltar palabras al escribir). Fuera de campos de texto solamente.
var MAC = /Mac/i.test(navigator.platform || '');
function editable(el) {
return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || ''));
}
addEventListener('keydown', function (e) {
if (MAC || !e.altKey || e.metaKey || e.ctrlKey || e.shiftKey || editable(e.target)) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); history.back(); }
else if (e.key === 'ArrowRight') { e.preventDefault(); history.forward(); }
}, true);
// Ctrl+rueda = zoom (convención de navegador; en Mac también con Ctrl). // Ctrl+rueda = zoom (convención de navegador; en Mac también con Ctrl).
addEventListener('wheel', function (e) { addEventListener('wheel', function (e) {
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
@@ -264,7 +297,7 @@ fn current_zoom<R: Runtime>(app: &AppHandle<R>) -> f64 {
ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len() - 1)] ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len() - 1)]
} }
// Manda el estado de pestañas + zoom a la barra (webview "chrome"). // Manda el estado de pestañas + zoom + versión a la barra (webview "chrome").
fn broadcast<R: Runtime>(app: &AppHandle<R>) { fn broadcast<R: Runtime>(app: &AppHandle<R>) {
let payload = { let payload = {
let shell = app.state::<ShellState>(); let shell = app.state::<ShellState>();
@@ -273,13 +306,34 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
.tabs .tabs
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "active": i == s.active })) .map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active }))
.collect(); .collect();
json!({ "tabs": tabs, "zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32 }) json!({
"tabs": tabs,
"zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32,
"version": app.package_info().version.to_string(),
// En macOS la barra ocupa la fila del título (ventana con título en overlay) y
// debe dejar libre el hueco del semáforo; en Windows hay título nativo aparte.
"mac": cfg!(target_os = "macos"),
})
}; };
let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload); let _ = app.emit_to(EventTarget::webview(CHROME), "ls-tabs", payload);
} }
// Marca una pestaña como «cargando» y refresca la barra (giro en el icono).
fn set_loading<R: Runtime>(app: &AppHandle<R>, label: &str, loading: bool) {
{
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let Some(t) = s.tabs.iter_mut().find(|t| t.label == label) else { return };
if t.loading == loading {
return; // sin cambios: no repintar la barra
}
t.loading = loading;
}
broadcast(app);
}
// Muestra la pestaña `label` y esconde las demás. Devuelve el foco al contenido. // Muestra la pestaña `label` y esconde las demás. Devuelve el foco al contenido.
fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) { fn activate_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
{ {
@@ -322,7 +376,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let mut s = shell.lock().unwrap(); let mut s = shell.lock().unwrap();
let label = format!("tab-{}", s.next_id); let label = format!("tab-{}", s.next_id);
s.next_id += 1; s.next_id += 1;
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone() }); s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true });
label label
}; };
@@ -338,9 +392,11 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
.on_download(|_webview, _event| true) .on_download(|_webview, _event| 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| {
if let PageLoadEvent::Finished = payload.event() { let cargando = matches!(payload.event(), PageLoadEvent::Started);
if !cargando {
let _ = wv.set_zoom(current_zoom(&app_zoom)); let _ = wv.set_zoom(current_zoom(&app_zoom));
} }
set_loading(&app_zoom, wv.label(), cargando);
}); });
let created = win.add_child( let created = win.add_child(
@@ -374,6 +430,12 @@ fn close_tab<R: Runtime>(app: &AppHandle<R>, label: &str) {
let mut s = shell.lock().unwrap(); let mut s = shell.lock().unwrap();
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return }; let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
let was_active = i == s.active; let was_active = i == s.active;
// Recuerda la URL para «Reabrir pestaña cerrada» (tope de 20, las más recientes).
let url = s.tabs[i].url.clone();
s.closed.push(url);
if s.closed.len() > 20 {
s.closed.remove(0);
}
s.tabs.remove(i); s.tabs.remove(i);
if s.tabs.is_empty() { if s.tabs.is_empty() {
s.active = 0; s.active = 0;
@@ -474,6 +536,255 @@ fn activate_nth<R: Runtime>(app: &AppHandle<R>, n: usize) {
activate_tab(app, &label); activate_tab(app, &label);
} }
fn active_url<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
let shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
s.tabs.get(s.active).map(|t| t.url.clone())
}
// Atrás/adelante en el HISTORIAL de la pestaña activa. Antes no existía por ningún lado y
// una página sin menú propio (p. ej. el portal de revisión) era un callejón sin salida.
fn nav_tab<R: Runtime>(app: &AppHandle<R>, dir: i32) {
if let Some(l) = active_label(app) {
if let Some(wv) = get_webview(app, &l) {
let _ = wv.eval(if dir < 0 { "history.back()" } else { "history.forward()" });
}
}
}
// «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T): la más reciente primero, como en el navegador.
fn reopen_tab<R: Runtime>(app: &AppHandle<R>) {
let url = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
s.closed.pop()
};
if let Some(u) = url {
create_tab(app, Some(u), true);
}
}
// Punto rojo para el icono de la barra de tareas de Windows (dibujado a mano: sin assets).
#[cfg(target_os = "windows")]
fn red_dot() -> tauri::image::Image<'static> {
const S: usize = 16;
let mut px = vec![0u8; S * S * 4];
let c = (S as f64 - 1.0) / 2.0;
for y in 0..S {
for x in 0..S {
let (dx, dy) = (x as f64 - c, y as f64 - c);
if (dx * dx + dy * dy).sqrt() <= c {
let i = (y * S + x) * 4;
px[i] = 225;
px[i + 1] = 29;
px[i + 2] = 72;
px[i + 3] = 255;
}
}
}
tauri::image::Image::new_owned(px, S as u32, S as u32)
}
// Globo de NO-LEÍDOS en el icono, alimentado por la web app (evento `ls-badge {count}`):
// macOS = número en el icono del Dock; Windows = punto rojo sobre el icono de la barra de
// tareas; y en ambos, el tooltip de la bandeja dice cuántos hay.
fn set_badge<R: Runtime>(app: &AppHandle<R>, count: i64) {
if let Some(win) = main_window(app) {
#[cfg(target_os = "macos")]
let _ = win.set_badge_count(if count > 0 { Some(count) } else { None });
#[cfg(target_os = "windows")]
let _ = win.set_overlay_icon(if count > 0 { Some(red_dot()) } else { None });
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = &win;
}
if let Some(tray) = app.tray_by_id("main-tray") {
let tip = if count > 0 { format!("Labstream OS — {count} sin leer") } else { "Labstream OS".to_string() };
let _ = tray.set_tooltip(Some(tip));
}
}
// Menú de OPCIONES de la app (botón ⋮ / clic derecho en la barra): un popup NATIVO del sistema,
// igual en Windows y macOS. Se construye fresco en cada apertura para que el check de
// "Iniciar con el sistema" y la versión estén siempre al día. Solo opciones del SHELL — la web
// app no se toca.
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);
let build = || -> tauri::Result<Menu<R>> {
Ok(Menu::with_items(
app,
&[
&MenuItem::with_id(app, "nav-back", "Atrás", true, Some("CmdOrCtrl+["))?,
&MenuItem::with_id(app, "nav-forward", "Adelante", true, Some("CmdOrCtrl+]"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "new-tab", "Nueva pestaña", true, Some("CmdOrCtrl+T"))?,
&MenuItem::with_id(app, "close-tab", "Cerrar pestaña", true, Some("CmdOrCtrl+W"))?,
&MenuItem::with_id(app, "reopen-tab", "Reabrir pestaña cerrada", true, Some("CmdOrCtrl+Shift+T"))?,
&MenuItem::with_id(app, "reload", "Recargar", true, Some("CmdOrCtrl+R"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "zoom-in", "Acercar", true, Some("CmdOrCtrl+="))?,
&MenuItem::with_id(app, "zoom-out", "Alejar", true, Some("CmdOrCtrl+-"))?,
&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>)?,
&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)?,
&MenuItem::with_id(app, "version", &version, false, None::<&str>)?,
&MenuItem::with_id(app, "quit-app", "Salir de Labstream OS", true, None::<&str>)?,
],
)?)
};
if let Ok(menu) = build() {
let _ = win.popup_menu(&menu);
}
}
// 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) {
let Some(win) = main_window(app) else { return };
let (idx, total) = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
s.menu_tab = Some(label.to_string());
(i, s.tabs.len())
};
let build = || -> tauri::Result<Menu<R>> {
Ok(Menu::with_items(
app,
&[
&MenuItem::with_id(app, "tab-duplicate", "Duplicar pestaña", true, None::<&str>)?,
&MenuItem::with_id(app, "tab-copy-url", "Copiar enlace", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "tab-close", "Cerrar pestaña", true, None::<&str>)?,
&MenuItem::with_id(app, "tab-close-others", "Cerrar las demás", total > 1, None::<&str>)?,
&MenuItem::with_id(app, "tab-close-right", "Cerrar las de la derecha", idx + 1 < total, None::<&str>)?,
],
)?)
};
if let Ok(menu) = build() {
let _ = win.popup_menu(&menu);
}
}
// Acciones del menú de pestaña. `menu_tab` guarda sobre cuál se abrió (puede no ser la activa).
fn tab_menu_action<R: Runtime>(app: &AppHandle<R>, action: &str) {
let target = {
let shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
s.menu_tab.clone()
};
let Some(label) = target else { return };
// Las que hay que cerrar se calculan ANTES de cerrar nada (los índices se mueven).
let (url, otras, derecha) = {
let shell = app.state::<ShellState>();
let s = shell.lock().unwrap();
let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return };
(
s.tabs[i].url.clone(),
s.tabs.iter().filter(|t| t.label != label).map(|t| t.label.clone()).collect::<Vec<_>>(),
s.tabs.iter().skip(i + 1).map(|t| t.label.clone()).collect::<Vec<_>>(),
)
};
match action {
"tab-duplicate" => create_tab(app, Some(url), true),
"tab-copy-url" => {
let _ = app.clipboard().write_text(url);
}
"tab-close" => close_tab(app, &label),
"tab-close-others" => {
for l in otras {
close_tab(app, &l);
}
}
"tab-close-right" => {
for l in derecha {
close_tab(app, &l);
}
}
_ => {}
}
}
// Reordena las pestañas al soltar una en otra posición (arrastrar en la barra). Llega el
// orden completo de etiquetas; se conserva cuál estaba activa y se guarda la sesión.
fn reorder_tabs<R: Runtime>(app: &AppHandle<R>, order: Vec<String>) {
{
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
// Solo se acepta una permutación EXACTA de las pestañas actuales (nada de perder o
// duplicar si la barra manda algo raro).
if order.len() != s.tabs.len() || !order.iter().all(|l| s.tabs.iter().any(|t| &t.label == l)) {
return;
}
let activa = s.tabs.get(s.active).map(|t| t.label.clone());
let mut nuevas: Vec<TabRec> = Vec::with_capacity(order.len());
for l in &order {
if let Some(i) = s.tabs.iter().position(|t| &t.label == l) {
nuevas.push(s.tabs.remove(i));
}
}
s.tabs = nuevas;
if let Some(a) = activa {
s.active = s.tabs.iter().position(|t| t.label == a).unwrap_or(0);
}
}
broadcast(app);
save_persisted(app);
}
// Activa/desactiva el arranque con el sistema y RECUERDA la elección (si no, el arranque
// la re-activaba en cada apertura).
fn toggle_autostart<R: Runtime>(app: &AppHandle<R>) {
let mgr = app.autolaunch();
let was_on = mgr.is_enabled().unwrap_or(false);
let _ = if was_on { mgr.disable() } else { mgr.enable() };
{
let shell = app.state::<ShellState>();
shell.lock().unwrap().autostart = Some(!was_on);
}
save_persisted(app);
let _ = app
.notification()
.builder()
.title("Labstream OS")
.body(if was_on {
"Ya no se iniciará con el sistema."
} else {
"Se iniciará automáticamente con el sistema."
})
.show();
}
// "Buscar actualización…" del menú: como la silenciosa, pero SIEMPRE responde con un aviso
// (instalando / ya estás al día / sin red).
#[cfg(desktop)]
async fn check_update_manual(handle: tauri::AppHandle) {
use tauri_plugin_updater::UpdaterExt;
let notify = |body: String| {
let _ = handle.notification().builder().title("Labstream OS").body(body).show();
};
let Ok(updater) = handle.updater() else {
notify("El actualizador no está disponible en esta instalación.".into());
return;
};
match updater.check().await {
Ok(Some(update)) => {
notify(format!("Instalando la versión {}…", update.version));
if update.download_and_install(|_, _| {}, || {}).await.is_ok() {
handle.restart();
} else {
notify("No se pudo instalar la actualización. Intenta de nuevo más tarde.".into());
}
}
Ok(None) => notify(format!("Estás en la última versión ({}).", handle.package_info().version)),
Err(_) => notify("No se pudo comprobar la actualización (revisa la red).".into()),
}
}
// Muestra y enfoca la ventana principal (tray, dock, segunda instancia). // Muestra y enfoca la ventana principal (tray, dock, segunda instancia).
fn show_main<R: Runtime>(app: &AppHandle<R>) { fn show_main<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = main_window(app) { if let Some(w) = main_window(app) {
@@ -531,6 +842,7 @@ pub fn run() {
builder = builder builder = builder
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)); .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None));
@@ -538,8 +850,19 @@ pub fn run() {
let app = builder let app = builder
.manage(ShellState::default()) .manage(ShellState::default())
.setup(|app| { .setup(|app| {
// Arranque automático al iniciar sesión (idempotente). let handle = app.handle().clone();
let saved = load_persisted(&handle);
// Arranque automático al iniciar sesión: por defecto activado, pero se respeta
// lo que el usuario elija en el menú ⋮ ("Iniciar con el sistema").
match saved.autostart {
Some(false) => {
let _ = app.autolaunch().disable();
}
_ => {
let _ = app.autolaunch().enable(); let _ = app.autolaunch().enable();
}
}
// Al abrir, busca una actualización en segundo plano (no bloquea el arranque). // Al abrir, busca una actualización en segundo plano (no bloquea el arranque).
#[cfg(desktop)] #[cfg(desktop)]
@@ -548,20 +871,27 @@ pub fn run() {
tauri::async_runtime::spawn(check_update(handle)); tauri::async_runtime::spawn(check_update(handle));
} }
let handle = app.handle().clone();
let saved = load_persisted(&handle);
{ {
let shell = handle.state::<ShellState>(); let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap(); let mut s = shell.lock().unwrap();
s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1); s.zoom_idx = saved.zoom_idx.unwrap_or(ZOOM_100).min(ZOOM_STEPS.len() - 1);
s.win = saved.win; s.win = saved.win;
s.autostart = saved.autostart;
} }
// Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay. // Ventana principal (contenedora). Tamaño/posición de la sesión anterior si los hay.
// macOS: título en OVERLAY y sin texto → el contenido (y por tanto la barra de
// pestañas) ocupa también la fila del título, al estilo de Notion/VS Code. Los
// botones del semáforo siguen siendo los nativos; la barra les deja su hueco.
// En Windows se conserva el título del sistema con la barra debajo.
let mut wb = WindowBuilder::new(app, "main") let mut wb = WindowBuilder::new(app, "main")
.title("Labstream OS") .title("Labstream OS")
.resizable(true) .resizable(true)
.min_inner_size(1024.0, 700.0); .min_inner_size(1024.0, 700.0);
#[cfg(target_os = "macos")]
{
wb = wb.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true);
}
match saved.win { match saved.win {
Some(r) if r.w >= 600.0 && r.h >= 400.0 => { Some(r) if r.w >= 600.0 && r.h >= 400.0 => {
wb = wb.inner_size(r.w, r.h).position(r.x, r.y); wb = wb.inner_size(r.w, r.h).position(r.x, r.y);
@@ -582,6 +912,8 @@ pub fn run() {
"Labstream OS", "Labstream OS",
true, true,
&[ &[
&MenuItem::with_id(h, "check-update", "Buscar actualización…", true, None::<&str>)?,
&PredefinedMenuItem::separator(h)?,
&PredefinedMenuItem::hide(h, Some("Ocultar Labstream OS"))?, &PredefinedMenuItem::hide(h, Some("Ocultar Labstream OS"))?,
&PredefinedMenuItem::hide_others(h, Some("Ocultar otros"))?, &PredefinedMenuItem::hide_others(h, Some("Ocultar otros"))?,
&PredefinedMenuItem::show_all(h, Some("Mostrar todo"))?, &PredefinedMenuItem::show_all(h, Some("Mostrar todo"))?,
@@ -596,6 +928,9 @@ pub fn run() {
&[ &[
&MenuItem::with_id(h, "new-tab", "Nueva pestaña", true, Some("CmdOrCtrl+T"))?, &MenuItem::with_id(h, "new-tab", "Nueva pestaña", true, Some("CmdOrCtrl+T"))?,
&MenuItem::with_id(h, "close-tab", "Cerrar pestaña", true, Some("CmdOrCtrl+W"))?, &MenuItem::with_id(h, "close-tab", "Cerrar pestaña", true, Some("CmdOrCtrl+W"))?,
&MenuItem::with_id(h, "reopen-tab", "Reabrir pestaña cerrada", true, Some("CmdOrCtrl+Shift+T"))?,
&PredefinedMenuItem::separator(h)?,
&MenuItem::with_id(h, "open-browser", "Abrir esta página en el navegador", true, None::<&str>)?,
], ],
)?; )?;
let edicion = Submenu::with_items( let edicion = Submenu::with_items(
@@ -626,6 +961,15 @@ pub fn run() {
&PredefinedMenuItem::fullscreen(h, Some("Pantalla completa"))?, &PredefinedMenuItem::fullscreen(h, Some("Pantalla completa"))?,
], ],
)?; )?;
let historial = Submenu::with_items(
h,
"Historial",
true,
&[
&MenuItem::with_id(h, "nav-back", "Atrás", true, Some("CmdOrCtrl+["))?,
&MenuItem::with_id(h, "nav-forward", "Adelante", true, Some("CmdOrCtrl+]"))?,
],
)?;
let ventana = Submenu::with_items( let ventana = Submenu::with_items(
h, h,
"Ventana", "Ventana",
@@ -635,28 +979,54 @@ pub fn run() {
&PredefinedMenuItem::maximize(h, Some("Zoom"))?, &PredefinedMenuItem::maximize(h, Some("Zoom"))?,
], ],
)?; )?;
let menu = Menu::with_items(h, &[&app_menu, &archivo, &edicion, &vista, &ventana])?; let menu = Menu::with_items(h, &[&app_menu, &archivo, &edicion, &vista, &historial, &ventana])?;
app.set_menu(menu)?; app.set_menu(menu)?;
}
// Manejador de TODOS los menús (el de macOS y el popup ⋮ de ambas plataformas).
app.on_menu_event(|app, event| match event.id().as_ref() { app.on_menu_event(|app, event| match event.id().as_ref() {
"nav-back" => nav_tab(app, -1),
"nav-forward" => nav_tab(app, 1),
"new-tab" => create_tab(app, None, true), "new-tab" => create_tab(app, None, true),
"close-tab" => { "close-tab" => {
if let Some(l) = active_label(app) { if let Some(l) = active_label(app) {
close_tab(app, &l); close_tab(app, &l);
} }
} }
"reopen-tab" => reopen_tab(app),
"reload" => reload_tab(app, None), "reload" => reload_tab(app, None),
"zoom-in" => zoom_step(app, 1), "zoom-in" => zoom_step(app, 1),
"zoom-out" => zoom_step(app, -1), "zoom-out" => zoom_step(app, -1),
"zoom-0" => zoom_step(app, 0), "zoom-0" => zoom_step(app, 0),
"open-browser" => {
if let Some(url) = active_url(app) {
let _ = app.opener().open_url(url, None::<&str>);
}
}
id @ ("tab-duplicate" | "tab-copy-url" | "tab-close" | "tab-close-others" | "tab-close-right") => {
tab_menu_action(app, id)
}
"autostart-toggle" => toggle_autostart(app),
"check-update" => {
#[cfg(desktop)]
tauri::async_runtime::spawn(check_update_manual(app.clone()));
}
"quit-app" => {
save_persisted(app);
app.exit(0);
}
_ => {} _ => {}
}); });
}
// Barra de pestañas (webview local, arriba). // Barra de pestañas (webview local, arriba).
let scale = win.scale_factor().unwrap_or(1.0); let scale = win.scale_factor().unwrap_or(1.0);
let size = win.inner_size()?.to_logical::<f64>(scale); let size = win.inner_size()?.to_logical::<f64>(scale);
win.add_child( win.add_child(
WebviewBuilder::new(CHROME, WebviewUrl::App("index.html".into())), WebviewBuilder::new(CHROME, WebviewUrl::App("index.html".into()))
// Sin esto Tauri instala su manejador de arrastre del SISTEMA en el
// webview y se traga los eventos HTML5 → no se podrían reordenar las
// pestañas arrastrando. La barra no recibe archivos, así que sobra.
.disable_drag_drop_handler(),
LogicalPosition::new(0.0, 0.0), LogicalPosition::new(0.0, 0.0),
LogicalSize::new(size.width, TAB_BAR_H), LogicalSize::new(size.width, TAB_BAR_H),
)?; )?;
@@ -734,6 +1104,46 @@ pub fn run() {
} }
}); });
let h = handle.clone(); let h = handle.clone();
handle.listen_any("ls-nav", move |e| {
let dir = field_i64(e.payload(), "dir").unwrap_or(-1) as i32;
nav_tab(&h, dir);
});
let h = handle.clone();
handle.listen_any("ls-reopen", move |_| {
reopen_tab(&h);
});
// Globo de no-leídos: lo emite la web app cuando cambia el conteo del chat.
let h = handle.clone();
handle.listen_any("ls-badge", move |e| {
let n = field_i64(e.payload(), "count").unwrap_or(0);
set_badge(&h, n);
});
let h = handle.clone();
let h_tabmenu = handle.clone();
handle.listen_any("ls-tab-menu", move |e| {
if let Some(label) = field(e.payload(), "label") {
show_tab_menu(&h_tabmenu, &label);
}
});
let h_reorder = handle.clone();
handle.listen_any("ls-reorder", move |e| {
// `order` llega como lista de etiquetas separadas por coma (el payload de los
// eventos del shell es plano: strings y números).
if let Some(orden) = field(e.payload(), "order") {
let v: Vec<String> = orden.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
reorder_tabs(&h_reorder, v);
}
});
handle.listen_any("ls-menu", move |_| {
show_app_menu(&h);
});
// Clic en la etiqueta de versión de la barra → buscar actualización (con aviso).
let h = handle.clone();
handle.listen_any("ls-check-update", move |_| {
#[cfg(desktop)]
tauri::async_runtime::spawn(check_update_manual(h.clone()));
});
let h = handle.clone();
handle.listen_any("ls-title", move |e| { handle.listen_any("ls-title", move |e| {
let (label, title, url) = ( let (label, title, url) = (
field(e.payload(), "label"), field(e.payload(), "label"),

Ver fichero

@@ -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.1.0", "version": "1.5.0",
"identifier": "co.labstream.os", "identifier": "co.labstream.os",
"build": { "build": {
"frontendDist": "../dist" "frontendDist": "../dist"