diff --git a/dist/index.html b/dist/index.html index 4cd232c..9d57e7c 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,8 +7,12 @@ -
+ +
-
+
@@ -231,31 +265,136 @@ const $pct = document.getElementById("z-pct"); const $ver = document.getElementById("version"); - // Favicon de las pestañas (look Chrome): mini logo Labstream en SVG inline — - // cuadrado indigo con la "L" — para no depender de archivos ni de la red. - const FAVICON = "data:image/svg+xml," + encodeURIComponent( - "" + - "" + - "" + - "", - ); + // 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() { $tabs.textContent = ""; for (const t of estado.tabs) { + const { emoji, texto } = nombreDe(t); const el = document.createElement("div"); el.className = "tab" + (t.active ? " active" : ""); - el.title = t.title; + el.dataset.label = t.label; + el.title = texto; - const fav = document.createElement("img"); - fav.className = "favicon"; - fav.src = FAVICON; - fav.alt = ""; + const fav = document.createElement("span"); + fav.className = "favicon" + (t.loading ? " cargando" : ""); + fav.textContent = t.loading ? "" : emoji; el.appendChild(fav); const titulo = document.createElement("span"); titulo.className = "titulo"; - titulo.textContent = t.title || "Labstream OS"; + titulo.textContent = texto; el.appendChild(titulo); const x = document.createElement("button"); @@ -268,19 +407,40 @@ }); 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). el.addEventListener("auxclick", (e) => { 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); } + requestAnimationFrame(ajustarCompactas); + const activa = $tabs.querySelector(".tab.active"); if (activa) activa.scrollIntoView({ block: "nearest", inline: "nearest" }); $pct.textContent = estado.zoom + "%"; $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) { @@ -289,6 +449,8 @@ 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 })); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cf6741c..993d60a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -56,6 +56,27 @@ dependencies = [ "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]] name = "async-broadcast" version = "0.7.2" @@ -354,6 +375,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -485,6 +512,15 @@ dependencies = [ "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]] name = "combine" version = "4.6.7" @@ -587,6 +623,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -827,13 +869,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -959,6 +1007,12 @@ dependencies = [ "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]] name = "event-listener" version = "5.4.1" @@ -986,6 +1040,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1021,6 +1081,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1037,6 +1103,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1272,6 +1344,16 @@ dependencies = [ "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]] name = "getrandom" version = "0.2.17" @@ -1454,12 +1536,32 @@ dependencies = [ "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]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "hashbrown" version = "0.17.1" @@ -1740,6 +1842,20 @@ dependencies = [ "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]] name = "indexmap" version = "1.9.3" @@ -1946,13 +2062,14 @@ dependencies = [ [[package]] name = "labstream-desktop" -version = "1.4.2" +version = "1.5.0" dependencies = [ "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", @@ -2117,6 +2234,16 @@ dependencies = [ "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]] name = "muda" version = "0.19.3" @@ -2168,6 +2295,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -2239,6 +2375,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -2467,6 +2604,16 @@ dependencies = [ "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]] name = "osakit" version = "0.3.1" @@ -2547,6 +2694,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "phf" version = "0.13.1" @@ -2631,7 +2789,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -2768,6 +2926,18 @@ dependencies = [ "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]] name = "quick-xml" version = "0.39.4" @@ -2777,6 +2947,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -3801,6 +3980,21 @@ dependencies = [ "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]] name = "tauri-plugin-notification" version = "2.3.3" @@ -4065,6 +4259,20 @@ dependencies = [ "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]] name = "time" version = "0.3.49" @@ -4375,6 +4583,17 @@ dependencies = [ "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]] name = "try-lock" version = "0.2.5" @@ -4646,6 +4865,76 @@ dependencies = [ "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]] name = "web-sys" version = "0.3.102" @@ -4757,6 +5046,12 @@ dependencies = [ "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]] name = "winapi" version = "0.3.9" @@ -5240,6 +5535,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "writeable" version = "0.6.3" @@ -5311,6 +5624,23 @@ dependencies = [ "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]] name = "xattr" version = "1.6.1" @@ -5503,6 +5833,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zvariant" version = "5.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4d31bce..6b292cf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labstream-desktop" -version = "1.4.2" +version = "1.5.0" description = "Cliente de escritorio de Labstream OS" authors = ["Labstream SAS"] edition = "2021" @@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] } # `unstable` habilita multiwebview: varios webviews (pestañas) dentro de UNA ventana. tauri = { version = "2", features = ["tray-icon", "unstable"] } tauri-plugin-opener = "2" +tauri-plugin-clipboard-manager = "2" tauri-plugin-notification = "2" tauri-plugin-autostart = "2" tauri-plugin-updater = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ebd2f1e..ac959e4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,14 +2,22 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Permisos del shell de escritorio: la barra local (chrome) y las pestañas remotas (tab-*).", - "windows": ["main"], - "webviews": ["chrome", "tab-*"], + "windows": [ + "main" + ], + "webviews": [ + "chrome", + "tab-*" + ], "remote": { - "urls": ["https://os.labstreamsas.com"] + "urls": [ + "https://os.labstreamsas.com" + ] }, "permissions": [ "core:default", "opener:default", - "notification:default" + "notification:default", + "core:window:allow-start-dragging" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b8011a..707b399 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,14 +46,14 @@ use tauri::{ }; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; 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 TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome) const CHROME: &str = "chrome"; -// Alto de la barra de título nativa de macOS (ventana estándar). Ver `top_offset`. -#[cfg(target_os = "macos")] -const MACOS_TITLEBAR_H: f64 = 28.0; +// 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%. 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]; @@ -65,6 +65,7 @@ struct TabRec { label: String, title: String, url: String, + loading: bool, // la barra pinta un giro en el icono mientras carga } struct Shell { @@ -75,11 +76,12 @@ struct Shell { win: Option, autostart: Option, // elección del usuario en el menú ⋮ (None = por defecto, activado) closed: Vec, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T) + menu_tab: Option, // pestaña sobre la que se abrió el menú de clic derecho } impl Default for Shell { fn default() -> Self { - Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new() } + Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new(), menu_tab: None } } } @@ -271,40 +273,19 @@ fn get_webview(app: &AppHandle, label: &str) -> Option main_window(app).and_then(|w| w.webviews().into_iter().find(|v| v.label() == label)) } -// En macOS las coordenadas de los webviews HIJOS son relativas al MARCO de la ventana -// (incluye la barra de título) y `inner_size()` devuelve también el marco. Sin compensar, -// la barra de pestañas se dibuja DEBAJO del título nativo y solo asoman ~12 px (además el -// título translúcido se teñía del color de la barra). Se detecta en caliente: si el marco y -// el interior miden lo mismo hay que compensar; si Tauri lo corrige, la resta deja de ser 0 -// y no se compensa nada. En Windows/Linux siempre es 0 (ahí las coordenadas ya son del área -// de cliente). -fn top_offset(win: &Window, scale: f64) -> f64 { - #[cfg(target_os = "macos")] - if let (Ok(outer), Ok(inner)) = (win.outer_size(), win.inner_size()) { - let o = outer.to_logical::(scale).height; - let i = inner.to_logical::(scale).height; - if (o - i).abs() < 1.0 { - return MACOS_TITLEBAR_H; - } - } - let _ = (win, scale); - 0.0 -} - // Geometría: barra arriba (alto fijo), contenido debajo ocupando el resto. fn layout_all(app: &AppHandle) { let Some(win) = main_window(app) else { return }; let scale = win.scale_factor().unwrap_or(1.0); let Ok(size) = win.inner_size() else { return }; let size = size.to_logical::(scale); - let top = top_offset(&win, scale); - let content_h = (size.height - top - TAB_BAR_H).max(0.0); + let content_h = (size.height - TAB_BAR_H).max(0.0); for wv in win.webviews() { if wv.label() == CHROME { - let _ = wv.set_position(LogicalPosition::new(0.0, top)); + let _ = wv.set_position(LogicalPosition::new(0.0, 0.0)); let _ = wv.set_size(LogicalSize::new(size.width, TAB_BAR_H)); } else { - let _ = wv.set_position(LogicalPosition::new(0.0, top + TAB_BAR_H)); + let _ = wv.set_position(LogicalPosition::new(0.0, TAB_BAR_H)); let _ = wv.set_size(LogicalSize::new(size.width, content_h)); } } @@ -325,17 +306,34 @@ fn broadcast(app: &AppHandle) { .tabs .iter() .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(); 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); } +// Marca una pestaña como «cargando» y refresca la barra (giro en el icono). +fn set_loading(app: &AppHandle, label: &str, loading: bool) { + { + let shell = app.state::(); + 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. fn activate_tab(app: &AppHandle, label: &str) { { @@ -378,7 +376,7 @@ fn create_tab(app: &AppHandle, url: Option, activate: boo let mut s = shell.lock().unwrap(); let label = format!("tab-{}", s.next_id); s.next_id += 1; - s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone() }); + s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true }); label }; @@ -394,16 +392,17 @@ fn create_tab(app: &AppHandle, url: Option, activate: boo .on_download(|_webview, _event| true) // El zoom guardado se re-aplica en cada carga (el factor no siempre sobrevive a la navegación). .on_page_load(move |wv, payload| { - if let PageLoadEvent::Finished = payload.event() { + let cargando = matches!(payload.event(), PageLoadEvent::Started); + if !cargando { let _ = wv.set_zoom(current_zoom(&app_zoom)); } + set_loading(&app_zoom, wv.label(), cargando); }); - let top = top_offset(&win, scale); let created = win.add_child( builder, - LogicalPosition::new(0.0, top + TAB_BAR_H), - LogicalSize::new(size.width, (size.height - top - TAB_BAR_H).max(0.0)), + LogicalPosition::new(0.0, TAB_BAR_H), + LogicalSize::new(size.width, (size.height - TAB_BAR_H).max(0.0)), ); match created { @@ -642,6 +641,101 @@ fn show_app_menu(app: &AppHandle) { } } +// 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(app: &AppHandle, label: &str) { + let Some(win) = main_window(app) else { return }; + let (idx, total) = { + let shell = app.state::(); + 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> { + 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(app: &AppHandle, action: &str) { + let target = { + let shell = app.state::(); + 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::(); + 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::>(), + s.tabs.iter().skip(i + 1).map(|t| t.label.clone()).collect::>(), + ) + }; + 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(app: &AppHandle, order: Vec) { + { + let shell = app.state::(); + 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 = 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(app: &AppHandle) { @@ -748,6 +842,7 @@ pub fn run() { builder = builder .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)); @@ -785,10 +880,18 @@ pub fn run() { } // 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") .title("Labstream OS") .resizable(true) .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 { Some(r) if r.w >= 600.0 && r.h >= 400.0 => { wb = wb.inner_size(r.w, r.h).position(r.x, r.y); @@ -900,6 +1003,9 @@ pub fn run() { 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)] @@ -916,8 +1022,12 @@ pub fn run() { let scale = win.scale_factor().unwrap_or(1.0); let size = win.inner_size()?.to_logical::(scale); win.add_child( - WebviewBuilder::new(CHROME, WebviewUrl::App("index.html".into())), - LogicalPosition::new(0.0, top_offset(&win, scale)), + 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), LogicalSize::new(size.width, TAB_BAR_H), )?; @@ -1009,6 +1119,21 @@ pub fn run() { 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 = 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); }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cf106ca..cf32e7e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Labstream OS", - "version": "1.4.2", + "version": "1.5.0", "identifier": "co.labstream.os", "build": { "frontendDist": "../dist"