diff --git a/docs/build-linux.md b/docs/build-linux.md index e41d133..7581e67 100644 --- a/docs/build-linux.md +++ b/docs/build-linux.md @@ -191,6 +191,25 @@ Celdas: `medido` (con evidencia de esta sesión/CI), `pendiente contribuidor` > no se marcan como soportadas; la política de soporte (§5) es el único > compromiso aceptado. +### 6.1 Posicionamiento de la tira: Wayland nativo y alternativa XWayland (#180) + +- **Wayland nativo**: ni `gtk_window_move` ni `xdg-shell` exponen posición + absoluta al cliente. El arrastre de la tira usa el gesto del compositor + (`startDragging`), sin imán al borde ni persistencia de `dockEdge`/`dockOffset` + al soltar: no se escribe `config.json` con coordenadas que el cliente no + puede verificar. La fila «Posicionamiento de la ventana» del bloque + «Sesión de escritorio» reporta `limited` aquí. +- **Alternativa XWayland**: arrancar con `GDK_BACKEND=x11` fuerza el pipeline + X11 completo (imán al borde + persistencia de la posición; la fila reporta + `ok`). GTK acepta la variable como lista separada por comas y decide el + backend efectivo en runtime, así que bajo XWayland la app ve X11 + (clase `GdkX11Display`) aunque la sesión del escritorio sea Wayland. + **El valor tiene que llegar al proceso de la app**: lanzalo en la misma + línea (`GDK_BACKEND=x11 npm run app:dev` o `GDK_BACKEND=x11 quorb`) o + expórtalo antes (`export GDK_BACKEND=x11`). Un comando separado con `&` + (por ejemplo `GDK_BACKEND=x11 &` en su propia línea) aplica la variable solo + a la subshell y la app no la hereda. + ## 7. Riesgos priorizados (L0a→L0d) | Riesgo | Prob. | Impacto | Mitigación | Evidencia / entrada de código | diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 1890003..64dd101 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `e59862776cb98564eb2fc5680c5ecb6991629266` and `src-tauri/src/` = `cda8f14546cb8d691b994ef70f9e1696811884bd`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `e20c9a429fa6256025d82e690d475f6686c042e7` and `src-tauri/src/` = `8dca82fa4e3ed90f594534a2e6a9cfcc2321c432`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -18,9 +18,9 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a | Area | Distinct texts | Sites | |---|---:|---:| -| React (`src/`) | 852 | 995 | -| Rust (`src-tauri/src/`) | 405 | 484 | -| Total | 1222 | 1479 | +| React (`src/`) | 854 | 997 | +| Rust (`src-tauri/src/`) | 409 | 488 | +| Total | 1227 | 1485 | ## src-tauri/src/channel.rs @@ -439,6 +439,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a ## src-tauri/src/widget_placement.rs - Invalid widget placement scale: {} +- Wayland - Widget bounds UI callback was dropped - the native window could not be read - the native window origin could not be read @@ -527,8 +528,10 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Click-through del widget no implementado: el ratón no atraviesa la tira; solo el overlay de efectos lo usa. - El compositor aplica transparencia. - El icono de bandeja se muestra en los gestores que la evalúan. +- El posicionamiento absoluto de ventanas no está disponible bajo xdg-shell en Wayland: el arrastre de la tira se delega al compositor. - El registro puede denegarse en Wayland: el error se muestra en Ajustes › Atajos. - El soporte de siempre-encima depende del compositor; si la tira se oculta, usa «Auto-hide». +- En X11 (y bajo XWayland con GDK_BACKEND=x11) el anclaje al borde y el arrastre con imán funcionan; la tira alcanza el borde del área de trabajo (#205). - En X11 la tira se mantiene sobre las demás ventanas. - GNOME Wayland (Mutter) puede ignorar hints de always-on-top en algunos escenarios. La tira recurre a posición fija + skip_taskbar. - Host StatusNotifier detectado en la sesión: hay dónde mostrar el icono. La extensión «AppIndicator» de Ubuntu está habilitada. Solo se acredita la disponibilidad del host, no la visibilidad del icono. @@ -544,6 +547,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Los atajos globales dependen del registro por portal; si falla, el error se ve en Ajustes › Atajos. - Los atajos globales funcionan desde cualquier app. - Mutter admite hints de siempre-encima de forma parcial; si la tira se oculta tras ventanas, usa «Auto-hide». +- Mutter no expone posicionamiento absoluto de ventanas (gtk_window_move no tiene efecto): el arrastre usa el gesto del compositor y, al soltar, no hay imán ni se persiste la posición. - Mutter puede ignorar la región de input: el ratón puede quedar capturado en el hueco; prueba a mover la tira. - No se detectó ningún host StatusNotifier en la sesión: no hay dónde mostrar el icono de bandeja. La extensión «AppIndicator» está habilitada, pero la barra activa no provee el host (¿el dock fue reemplazado?). - No se detectó ningún host StatusNotifier en la sesión: no hay dónde mostrar el icono. Instalá la extensión «AppIndicator» de Ubuntu para proveerlo. @@ -1078,6 +1082,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - What gets checked and how each provider connects. - What happens when a limit is exceeded and when it resets. - When to alert +- Window positioning - With sound - Works - You can also drag it from its handle: it sticks to the closest edge. @@ -1287,6 +1292,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Peticiones premium del mes - Plus / Pro · sesión y semanal de Codex - Por encima del resto de ventanas. +- Posicionamiento de la ventana - Posición en el borde - Presupuesto - Principal diff --git a/odd/tasks/180-wayland-compositor-drag.md b/odd/tasks/180-wayland-compositor-drag.md new file mode 100644 index 0000000..5877be3 --- /dev/null +++ b/odd/tasks/180-wayland-compositor-drag.md @@ -0,0 +1,262 @@ +# ODD · #180 — Wayland nativo: arrastre por compositor + estado de posicionamiento + +Issue: https://github.com/egdev6/quorb/issues/180 +Label: `status:approved` · `type:bug` · Assignee: SrStamm (verificado vía `gh api` 2026-10-30) +Contribuidor: SrStamm · Rama propuesta: `fix/180-wayland-compositor-drag` desde `origin/main` · PR desde fork `SrStamm/quorb` (convención del repo). + +**Plan APROBADO** por egdev6 (comentario 2026-10-29T16:58 en #180) con corrección en WU1 y +WU4: la decisión de modo/superficie **no** se toma con el tipo de sesión, sino con el **backend +GDK efectivo** (runtime: tipo de display de GDK; si no se puede, `GDK_BACKEND`). Motivo: +`is_wayland_session_from_env` (`src-tauri/src/wm_compat.rs:509-513`, verificado) solo mira +`WAYLAND_DISPLAY`/`XDG_SESSION_TYPE` y **ignora `GDK_BACKEND=x11`** — si se quedara como estaba +el plan, en XWayland `dragMode()` devolvería `'compositor'` y se perdería imán y persistencia +justo en el modo donde las capturas del hilo mostraron que funcionan, rompiendo la propia +capa alternativa de la vía 3. + +Base de la rama: `main` @ `a0e999b` (post #201). El `main` local ya está en `a0e999b` y al día +con `origin/main`; no hace falta rebase adicional al crear la rama. + +## Alcance aprobado por el mantenedor (comentario final de egdev6, #180) + +1. **Vía 1 limitada (Wayland nativo):** arrastre real con el gesto del compositor + (`startDragging()` → `xdg_toplevel.move`) durante la sesión, **sin imán al soltar y sin + persistir** `dockEdge`/`dockOffset`/`dockMonitor` en esa sesión (los datos calculados con + coordenadas no fiables no deben entrar al `config.json` que también usa X11). +2. **Vía 3:** estado visible en «Sesión de escritorio» indicando que el posicionamiento no + está disponible en este compositor. **La documentación de `GDK_BACKEND=x11` como + alternativa queda condicionada a integrar #205** (que ajusta la ventana al tamaño de la + tira): NO entra en esta entrega. +3. **Fuera de alcance:** layer-shell (Mutter no implementa `zwlr_layer_shell_v1`), selector de + monitor (#113), márgenes de anclaje (#179/#205). + +## Causa raíz (verificada por el reportero, el mantenedor y este repo) + +`Widget.tsx` (`setPosition` del arrastre) y `detail_geometry.rs:56-84` (`set_position` del +anclaje) terminan en `gtk_window_move()`, que **no opera bajo xdg-shell** (tao 0.37.1 lo marca +Unsupported; Debian wonfix; tao#566). El cálculo funciona; el transporte es no-op. Única vía: +mover la ventana con el serial de un evento de entrada (`begin_move_drag`). + +Sin imán al soltar: el cliente xdg-shell no conoce su posición absoluta; `e.screenX/Y` sin +posición de ventana no es coordenada global; y el `pointerup` del grab lo gestiona el +compositor. Persistir un borde calculado así podría corromper la config usada por X11. + +## Evidencia de exploración (main, 2026-10-30) + +- `src/Widget.tsx:791-827` `onGripDown` (origen sincrónico de `widgetBounds`; override async con + `outerPosition()`), `:829-842` `onGripMove` (`setPosition` en rAF, `:837`), `:843-925` + `onGripUp` (imán + `api.patchConfig({dockEdge, dockOffset, dockMonitor})` en `:880`). +- `src/lib/dock.ts:21-31` `nearestEdge(area, px, py)` — pura, usa punto del grip. +- `src/Widget.tsx:482` el efecto de colocación salta durante el drag; `:530-558` auditoría + `widget-recovery` (`shouldAuditNativePosition` en `src/lib/widget-recovery.ts`). +- Detección: `src-tauri/src/wm_compat.rs` (`detect_wm_info_from_env`, `surfaces_for` con ramas + X11/Hyprland/GNOME/KDE/genérica-Wayland en `:206-299`) → comando `get_wm_info` → + `src/lib/wm-detect.ts:12-39` (`isWayland`, `isHyprland`, `surfaces`, `state: 'ok'|'limited'`). +- Bloque de sesión: `DesktopSessionNotice` (`src/Settings.tsx:1402-1421`), filas vía + `desktopCompatRows` (`src/lib/desktop-compat.ts:16-41`), i18n `src/i18n/es.ts:154-178` / + `en.ts:147-171`; inventario con `node scripts/i18n-inventory.mjs --out + docs/i18n-inventory.md` (`--check` es gate de CI y **escanea src-tauri**, cambiar notas del + backend lo invalida). +- Patrones de test: `src/Settings-providers-i18n.test.tsx:269-279` (render SSR de + `DesktopSessionNotice`), `src/lib/shortcut-notices.test.tsx` (aviso Wayland, PR #183), tests + Rust in-crate de `surfaces_for` (`wm_compat.rs:574`+). + +## Plan de trabajo (work units, commits convencionales) + +- **WU1 · Decisión de estrategia de arrastre (frontend/bridge, pure) — CORREGIDA por el mantenedor.** + Clave: decidir con el **backend GDK efectivo**, no con el tipo de sesión. + Preferente: comprobar en runtime el tipo de display de GDK (Wayland vs X11) — fuente única, + sin ambiguos de env (`GDK_BACKEND` acepta listas tipo `"x11,*"`). Fallback si no es viable: + leer `GDK_BACKEND` aplicando la semántica GTK (`x11` fuerza X11; unset/automático usa + Wayland si está disponible → verificar con prueba). El backend efectivo se lee en un solo + sitio y se inyecta a las superficies que lo necesitan, de modo que WU1/WU4 usen el mismo dato. + (comando Rust o variable inyectada) para que WU1/WU4 usen el mismo dato. + Nuevo módulo pequeño (propuesta: `src/lib/drag-session.ts`): `dragMode(effectiveBackend)`: + `'native'` con display GDK X11; `'compositor'` con display GDK Wayland (incluye + Hyprland/KWin — el gesto interactivo es protocolo xdg-shell y vale para todos). + Tests deterministas explícitos: + - sesión Wayland + display X11 (`GDK_BACKEND=x11`) → `native`, con snap y persistencia; + - sesión Wayland sin override → `compositor`; + - sesión X11 → `native`. + API mínima `{mode, allowSnap, allowPersist}` (snap+persist solo con `mode==='native'`). + Nota: `is_wayland_session_from_env` del backend quedó **fuera** de esta decisión. + +- **WU2 · Widget.tsx: rama de arrastre por compositor.** + `onGripDown` en modo compositor: llamar `getCurrentWindow().startDragging()` (serial del + pointerdown) en lugar del pipeline delta/rAF; `onGripMove` no-op (el compositor mueve la + ventana); `onGripUp`: **sin** `outerPosition()`, **sin** `nearestEdge`, **sin** + `patchConfig`, **sin** `resolveGeo`; re-bloquear/limpiar timers igual que hoy. La colocación + persistida vieja (si la hay) queda intacta; el tamaño/posición la decide el compositor. + Tests: modo compositor nunca llama `setPosition` ni `patchConfig`; modo nativo mantiene el + flujo actual (regresión). + +- **WU3 · Auditoría de recuperación: exempt Wayland.** + Aclaración del mantenedor (2026-10-29): ya existe — `src/lib/widget-recovery.test.ts:119-135` + garantiza que `shouldAuditNativePosition` audita solo en Windows (Linux excluido: en Wayland + `outerPosition()===(0,0)` haría que cada tick «corrigiera» una ventana sana). Verificar que + la rama de WU2 no la reactiva y **añadir el test de regresión específico**, sin lógica nueva. + +- **WU4 · Backend: superficie `windowPositioning` — CORREGIDA por el mantenedor.** + Nuevo campo en `DesktopSurfaces` de `wm_compat.rs`, **también decidido por el backend GDK + efectivo** (mismo dato inyectado que WU1). Bajo XWayland (`GDK_BACKEND=x11` / display GDK + X11) debe reportar **`ok`**, no `limited`. Ramas objetivo: + - display GDK X11 → `ok` (independiente del tipo de sesión); + - display GDK Wayland, compositor GNOME → `limited` (nota Mutter: posicionamiento absoluto + no disponible, arrastre por compositor); + - display GDK Wayland, módulo KDE/Hyprland/genérica → `limited` (nota genérica; exacta por + compositor a decidir en T4, postura conservadora). + Tests Rust por rama (patrón `surfaces_for`), incluido: sesión Wayland + backend X11 → `ok`. + +- **WU5 · Frontend: fila en «Sesión de escritorio» + i18n.** + Mirror TS (`wm-detect.ts`), `SURFACE_LABELS` + clave `settings.shell.surfaces.windowPositioning` + es/en, textos en tono declarado ("no disponible en este compositor"), render test SSR es/en + (patrón `Settings-providers-i18n`), regenerar `docs/i18n-inventory.md`. + +- **WU6 · Verificación y evidencia.** + Suite: `npm test`, `npm run typecheck`, `cd src-tauri && cargo test` (y `node + scripts/i18n-inventory.mjs --check`, cargo fmt clean en los archivos tocados). + Smoke manual GNOME Wayland nativo: drag visible que mueve la tira, al soltar **sin** imán, + `config.json` sin cambios en dockEdge/dockOffset (comparar bytes), indicador visible en + Ajustes. Evidencia en el issue + PR acotada (sin #205/#179/#113). + +## Interacciones y límites (de la PR) + +- #205 (márgenes/ventana vs tira) sigue abierta: under XWayland/`GDK_BACKEND=x11` la doc de la + alternativa **no** se incluye acá. El efecto de colocación (`setWidgetBounds`) aplica size + pero posición es no-op en Wayland → debe quedar sin efecto visual adverso; verificado en + smoke. +- #183 (PR abierta sobre degradaciones/avisos) no se toca; solo reutilizamos el patrón de su + test, nada de su territorio. +- El gate `cargo fmt` ya está en CI desde #184: los archivos tocados quedan fmt-clean. +- `core:window:allow-start-dragging` **ya está** en `src-tauri/capabilities/default.json:8`: + `startDragging()` no necesita cambios de permisos (mantenedor, 2026-10-29). +- Autodeclaración: la decisión de modo/superficie usa el **backend GDK efectivo** (tipo de + display de GDK en runtime; fallback `GDK_BACKEND`), no el tipo de sesión ni `isWayland` + (corrección del mantenedor en WU1/WU4 ver arriba). + +## Criterios de aceptación + +1. En Wayland nativo, el arrastre del asa mueve la ventana (compositor) y al soltar no cambia + `config.json` (ni imán ni persistencia). +2. En XWayland (`GDK_BACKEND=x11`, sesión Wayland) se conserva el modo nativo: imán al soltar, + persistencia en `config.json`, dragMode `native` y `windowPositioning` `ok`. Test explícito + de sesión Wayland + `GDK_BACKEND=x11` → `native` con snap y persistencia (pedido por el + mantenedor). +3. En X11 el comportamiento actual intacto. +2. «Sesión de escritorio» muestra la superficie nueva con estado coherente por compositor. +3. Tests deterministas de la decisión + detección de sesión (Rust + frontend); suite verde + completa; inventario i18n `--check` verde. +4. Sin cambios de alcance (#205/#179/#113/layer-shell fuera). Manual smoke con evidencia en la PR. + +## Estado + +Orden de implementación ajustado a dependencias reales: WU4 (Rust provee el backend GDK efectivo) antes de WU1/WU2. + +- [x] T4 (WU4) · Rust: DisplayBackend + windowPositioning + probe GDK — commit `9c3700f` +- [x] T1 (WU1) · drag-session.ts + tests RED→GREEN — commit `0cddf6f` +- [x] T2 (WU2) · Rama drag compositor en Widget.tsx — commit `a07a29f` +- [x] T3 (WU3) · Pin de regresión de la auditoría — commit `6baf9d1` +- [x] T5 (WU5) · Fila Ajustes + i18n + inventario — commit `e0ab9a3` +- [x] WU2 follow-up · Store de decisión a nivel de módulo (fix de 15 regresiones del harness SSR) — commit `98282a9` +- [x] T6 (WU6) · Suite verde (60 archivos vitest + 258 node:test, cargo 45 verde, inventario --check verde) + smoke manual COMPLETO (2026-10-30): + - A (Wayland nativo): drag mueve la ventana (compositor); al soltar sin imán y config.json SIN CAMBIOS (diff antes/después → SIN CAMBIOS). Observación para el hilo: el contenedor (ventana 320×260) traspasa bordes y la pila no — territorio #179/#205. + - B (XWayland con GDK_BACKEND=x11): imán funciona y persiste (dockEdge right→bottom, dockOffset 0.451→0.259); degradación derecha/abajo = #179 documentado en la nota de la superficie. + - Fix intermedio importante: sonda GDK pasaba en spawn_blocking (UB de GTK fuera del hilo principal) — movida al hook setup con cache OnceLock (commit `4f48f5a`); ambos escenarios se repararon con ese cambio. +- [x] T7 · Push a fork + PR — PR #210 abierta (https://github.com/egdev6/quorb/pull/210) con autorización del usuario; evidencia del smoke publicada en el issue. Pendiente por permisos (SrStamm sin triage): asignar `type:bug` y la PR a SrStamm — debe hacerlo el mantenedor. + +Evidencia del smoke lista para comentar en el issue (redactar contra los +checks básicos arriba). Decisions pendientes para el mantenedor en la PR: +tests DOM diferidos (jsdom/happy-dom). + +## Avisos para la PR (decisiones para el mantenedor) + +- Tests de eventos de puntero sobre `Widget.tsx` diferidos: el entorno de + tests del repo no tiene DOM (sin jsdom/happy-dom en package.json; tests + .tsx existentes solo SSR). Instalar un paquete DOM es cambio de + dependencia que corresponde aprobar al mantenedor; la cobertura + determinista pedida vive en WU1 (decisión) + Rust (detección e + integración XWayland), y el cableado lo cubre el smoke manual exigido. +- Los mensajes de commit de esta rama están en español (convención del + repo), decisión confirmada por el contribuidor. + +## Revisión del mantenedor (PR #210, changes requested 2026-10-29) + +- Review del mantenedor con 5 cambios: (1) rebase sobre `f71039b`; (2) una sola + detección del backend GDK; (3) bug `dragging` pegado en compositor (salida + robusta si no llega el pointerup; probar apertura del panel post-soltura); + (4) condicionales a #205 (nota X11, 4 capturas GDK_BACKEND=x11, doc de la + variable); (5) descope de `odd/tasks/184-rustfmt-ci-format-gate.md`. +- [x] (1) Rebase hecho sobre `f71039b` con backup `backup/fix-180-pr210-rebase` + (=`e148f6a` pre-rebase). Conflictos: `Cargo.toml` (gtk 0.18 duplicado) y + `docs/i18n-inventory.md` (regenerado con `node scripts/i18n-inventory.mjs`, + `--check` verde). +- [x] (2) Unificación: la sonda WU1 en `setup` (commit `4f48f5a`) quedó + eliminada como fuente duplicada; la fuente única es `widget_placement`: + `display_kind()` (OnceLock, sólo hilo de UI) clasifica X11/Wayland/Unknown, + `transport()` y `wm_compat::get_wm_info` derivan de esa misma caché. El + command ahora sonda vía `display_backend_on_ui(&window)` (no spawn_blocking) + y `Unknown`/fallo degrada al fallback por env `display_backend_from_env` + (precedencia WU4 intacta). Verificación post-rebase: cargo 367 passed / + 3 ignored, cargo fmt --check limpio, vitest src 762 passed (0 failed), + node --test scripts 0 fail, typecheck 0, i18n-inventory --check verde. +- [ ] (3) Bug `dragging` compositor: verificar `npm run app:dev` y + `GDK_BACKEND=x11 npm run app:dev` (acordado con el usuario); añadir salida + robusta si el `pointerup` no llega. +- [ ] Menores: fallback a `compositor` solo Linux; comentario `dragMode()` + sobre `storedDragMode`; comentario huérfano en `Widget.tsx` ~219; enlace de + aprobación en la descripción. +- [x] (5) Descope: `odd/tasks/184-rustfmt-ci-format-gate.md` fuera de la PR + (abandon de `review-27bd42743acae960` autorizado por el usuario y commit de + remoción; review nativo relanzado sobre el candidato corregido). + +## Validación manual del usuario post-rebase (2026-10-29) + +- Drag y reposicionamiento funcionan tras el rebase (ambos modos probó el usuario). +- Pila a la derecha con `GDK_BACKEND=x11`: el contenedor invisible traspasa hasta + la mitad de la pila — territorio preexistente #179/#205 (ventana 320×260 vs + tira), base-only, nunca introducido por esta rama. +- Sin caja translúcida del contenedor en general; visible SOLO mientras se + muestra el aviso de API_KEY no aplicada para el proveedor detectado: + nota de evidencia concreta para las capturas pedidas (punto 4 del review). + +## Validación manual del usuario post-rebase, 2ª ronda (2026-10-29) + +- Punto 3 (bug `dragging` pegado): NO reproducible — drag e inmediata apertura + del panel de detalle en varias repeticiones (usuario, Wayland nativo). + Decisión del review pedía fix sólo si el `pointerup` no llegaba; queda + documentado como no reproducible + verificación pendiente de más intentos. +- Aviso de proveedor sin credencial NO configurada: en Wayland nativo NO + aparece el error que sí aparece con `GDK_BACKEND=x11`. Hipótesis alineada + con la superficie `windowPositioning` limitada: la ventana/mensaje que lo + muestra se posiciona por X11 (`set_position` sin efecto en native Wayland, + H1 del smoke #107) — base-only, atribución a confirmar con el mantenedor. +- El contenedor visible bloquea clicks detrás de él fuera de la pila: + limitación declarada de click-through (documentada en Ajustes), territorio + #179/#205, preexistente. +- [x] Red de seguridad del punto 3 agregada (commit posterior, decisión del + usuario): efecto en `src/Widget.tsx` que en modo compositor limpia + `setDragging(false)` al llegar un `pointermove` con `buttons === 0` + (señal de soltura que la webview sí recibe tras terminar el grab). Sólo + compositor: el pipeline nativo conserva su soltura en `onGripUp`. Verificación: + typecheck 0 + vitest 762/61 verde (RED inaplicable — sin entorno DOM, + decision jsdom/happy-dom sigue diferida al mantenedor); revalidación manual + pendiente del usuario. +- [x] Menores del review (2026-10-29, empleado test-first en el store puro): + (a) fallback por fallo de detección ahora Linux-only vía nueva + `fallbackDragMode(platform)` en `drag-session.ts` (RED→GREEN: 3 aserciones; + Windows/macOS conservan `native`/imán; Linux degrada a `compositor`), con + consumo en el catch del efecto de decisión de `Widget.tsx`; (b) doc block de + `dragMode()` reubicado sobre su función en `drag-session.ts`; (c) bloque de + comentario huérfano `#/#180: drag strategy…` eliminado de `Widget.tsx` + (la store ya vive en `drag-session.ts`); (d) link `issuecomment-*` literal + de la descripción de la PR reemplazado por el comentario real de aprobación + del alcance (6503078792). Verificación: typecheck 0; vitest 763/61; i18n + regenerado al final (`c577e9a`). +- [x] (4) Punto 4 del review cerrado (2026-10-29): nota de la superficie X11 + sin los márgenes de #179 (resueltos por #205, nota viaja con referencia a + #205) y documentación de `GDK_BACKEND=x11` como alternativa XWayland en + `docs/build-linux.md` §6.1, con el aviso de colocar la variable en la + misma línea del comando o exportarla (commits `6f55ceb` + inventario + `b69c671`). Evidencia con capturas (4 bordes + aviso con contenedor + visible) publicada por el usuario en issue #180, comentario 6509379529. + Restante para integrar: force-push del fork (decisión del usuario). +- [x] Force-push al fork completado (2026-10-29, ejecutado por el usuario): PR #210 apuntando a `5cf41b7` (e148f6a...5cf41b7, forced update). Los 5 puntos del review y los 4 menores quedaron cerrados y verificados; pendiente solo la re-revision del mantenedor. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e7a211b..2e11a66 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -65,9 +65,10 @@ windows-sys = { version = "0.61", features = [ [target.'cfg(unix)'.dependencies] libc = "0.2" -# Solo Linux: la superficie GTK del ciclo de colocación nativa (#179). La misma -# dependencia que ya resuelven tauri y tao (gtk 0.18 sin features): no sube el -# piso de GTK (3.22) ni añade crates nuevos. +# Solo Linux: la superficie GTK del ciclo de colocación nativa (#179) y la +# sonda del backend GDK efectivo que de ella depende (#180, sonda única). La +# misma dependencia que ya resuelven tauri y tao (gtk 0.18 sin features): no +# sube el piso de GTK (3.22) ni añade crates nuevos. [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" diff --git a/src-tauri/src/widget_placement.rs b/src-tauri/src/widget_placement.rs index 3420166..c04c6d0 100644 --- a/src-tauri/src/widget_placement.rs +++ b/src-tauri/src/widget_placement.rs @@ -177,8 +177,10 @@ pub enum Transport { Other, } -/// Clasifica el tipo del display de GDK: X11 y XWayland comparten `GdkX11Display`. -#[cfg(any(target_os = "linux", test))] +/// Clasifica el tipo del display de GDK: X11 y XWayland comparten +/// `GdkX11Display`. Tras la unificación (#180) `transport()` deriva de +/// `kind_from_type_name`; esta función queda como pin pura de tests. +#[cfg(test)] pub fn transport_from_type_name(name: &str) -> Transport { if name.contains("X11") { Transport::X11 @@ -187,6 +189,32 @@ pub fn transport_from_type_name(name: &str) -> Transport { } } +/// Clasificación única del display GDK efectivo (issue #180, revisión del +/// mantenedor: una sola detección del backend). X11 y XWayland comparten +/// `GdkX11Display`; Wayland nativo es `GdkWaylandDisplay`; lo demás +/// (broadway u otro backend exótico) o un display sin inicializar es +/// desconocido. `transport()` (colocación) y `wm_compat::get_wm_info` +/// (Ajustes/arrastre) derivan de esta misma caché para no poder discrepar. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisplayKind { + X11, + Wayland, + Unknown, +} + +/// Clasifica el nombre del tipo del display de GDK (puro, testeable sin +/// tocar GDK). +#[cfg(any(target_os = "linux", test))] +pub fn kind_from_type_name(name: &str) -> DisplayKind { + if name.contains("X11") { + DisplayKind::X11 + } else if name.contains("Wayland") { + DisplayKind::Wayland + } else { + DisplayKind::Unknown + } +} + /// Píxeles lógicos que pide GTK para un tamaño físico (GTK mide en lógicos). pub fn to_logical(physical: i32, scale: f64) -> i32 { (physical as f64 / scale).round() as i32 @@ -745,20 +773,30 @@ impl Surface for UiSurface { } } -/// El transporte de la superficie nativa, detectado del display real. -/// **Sólo en el hilo de UI**: GDK no se puede tocar desde otro hilo. +/// La única lectura del display GDK del binario (issue #180, revisión del +/// mantenedor: una sola detección del backend): cacheada en `OnceLock` y +/// **sólo en el hilo de UI** — GDK no se puede tocar desde otro hilo. +/// `transport()` (colocación) y `wm_compat::get_wm_info` (Ajustes/arrastre) +/// derivan de esta misma caché para no poder discrepar. #[cfg(target_os = "linux")] -fn transport() -> Transport { - static TRANSPORT: std::sync::OnceLock = std::sync::OnceLock::new(); - *TRANSPORT.get_or_init(detect_transport) +fn display_kind() -> DisplayKind { + static DISPLAY_KIND: std::sync::OnceLock = std::sync::OnceLock::new(); + *DISPLAY_KIND.get_or_init(|| { + use gtk::prelude::*; + match gtk::gdk::Display::default() { + Some(display) => kind_from_type_name(display.type_().name()), + None => DisplayKind::Unknown, + } + }) } +/// El transporte de la superficie nativa, derivado del display real (caché +/// de `display_kind`, hilo de UI): X11/XWayland o native Wayland. #[cfg(target_os = "linux")] -fn detect_transport() -> Transport { - use gtk::prelude::*; - match gtk::gdk::Display::default() { - Some(display) => transport_from_type_name(display.type_().name()), - None => Transport::Other, +fn transport() -> Transport { + match display_kind() { + DisplayKind::X11 => Transport::X11, + DisplayKind::Wayland | DisplayKind::Unknown => Transport::Other, } } @@ -769,3 +807,19 @@ async fn transport_on_ui(window: &tauri::WebviewWindow) -> Transport { .await .unwrap_or(Transport::Other) } + +/// Sonda del backend GDK efectivo para `wm_compat` (issue #180): la misma +/// lectura cacheada que decide la colocación, ejecutada en el hilo de UI. +/// Best-effort: sin display, tipo desconocido o hilo de UI que no responde +/// a tiempo degrada a `None` y decide el fallback por env +/// (`wm_compat::display_backend_from_env`). +#[cfg(target_os = "linux")] +pub async fn display_backend_on_ui( + window: &tauri::WebviewWindow, +) -> Option { + match on_ui(window, |_| display_kind()).await? { + DisplayKind::X11 => Some(crate::wm_compat::DisplayBackend::X11), + DisplayKind::Wayland => Some(crate::wm_compat::DisplayBackend::Wayland), + DisplayKind::Unknown => None, + } +} diff --git a/src-tauri/src/widget_placement_test.rs b/src-tauri/src/widget_placement_test.rs index baf8e4f..6fc3fa7 100644 --- a/src-tauri/src/widget_placement_test.rs +++ b/src-tauri/src/widget_placement_test.rs @@ -646,6 +646,22 @@ fn the_origin_comes_from_the_root_or_there_is_no_reading_at_all() { assert_eq!(rooted_origin(0, 0, false), None); } +#[test] +fn the_display_kind_classification_matches_the_transport_one() { + // Fuente única del backend GDK efectivo (issue #180): X11 (y XWayland), + // Wayland nativo, y lo demás desconocido (degrada al fallback por env). + assert_eq!(kind_from_type_name("GdkX11Display"), DisplayKind::X11); + assert_eq!( + kind_from_type_name("GdkWaylandDisplay"), + DisplayKind::Wayland + ); + assert_eq!( + kind_from_type_name("GdkBroadwayDisplay"), + DisplayKind::Unknown + ); + assert_eq!(kind_from_type_name(""), DisplayKind::Unknown); +} + #[test] fn the_transport_comes_from_the_real_gdk_display_type() { assert_eq!(transport_from_type_name("GdkX11Display"), Transport::X11); diff --git a/src-tauri/src/wm_compat.rs b/src-tauri/src/wm_compat.rs index 4cd2fa8..850926f 100644 --- a/src-tauri/src/wm_compat.rs +++ b/src-tauri/src/wm_compat.rs @@ -17,6 +17,17 @@ use serde::Serialize; +/// Effective GDK display backend (issue #180, WU1/WU4 maintainer correction): +/// drag mode and the `windowPositioning` surface branch on THIS value — the +/// backend GTK actually uses at runtime — never on the session type. E.g. a +/// full Wayland session with `GDK_BACKEND=x11` is X11. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DisplayBackend { + X11, + Wayland, +} + /// Resumen de compositor y caveats para Ajustes. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -39,6 +50,10 @@ pub struct WindowManagerInfo { /// Recomendación accionable para Ajustes: qué hacer con always-on-top, /// click-through y edge-docking en este entorno. pub recommendation: &'static str, + /// Backend GDK efectivo (issue #180): decide drag mode y la superficie + /// `windowPositioning`. Corresponde al display de GDK en runtime (sonda + /// best-effort) o, si no resolvió, al fallback por `GDK_BACKEND`/sesión. + pub display_backend: DisplayBackend, /// Estado y nota por superficie (widget, bandeja, atajos, autostart, /// notificaciones y transparencia) según la sesión detectada. Solo /// informativo: el frontend lo muestra tal cual en Ajustes. @@ -46,7 +61,9 @@ pub struct WindowManagerInfo { } /// Estado de una superficie de escritorio para la sesión detectada. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +/// `Default` es derivable para encadenar `..Default::default()` en literales +/// (la superficie nueva `window_positioning` siempre se recálcula explícita). +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)] pub struct SurfaceStatus { /// `ok` si la superficie se espera completa; `limited` límites explícitos. pub state: &'static str, @@ -56,7 +73,9 @@ pub struct SurfaceStatus { /// Snapshot de superficies del escritorio (issue #107). Comónico a /// X11/compositores Wayland: la variación se concentra en las notas. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +/// `Default` es derivable para `surfaces_for`: las ramas por sesión no +/// necesitan repetir el literal de `window_positioning` (ver wrapper). +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct DesktopSurfaces { pub always_on_top: SurfaceStatus, @@ -66,15 +85,22 @@ pub struct DesktopSurfaces { pub autostart: SurfaceStatus, pub notifications: SurfaceStatus, pub transparency: SurfaceStatus, + /// Posicionamiento/arrastre de la tira (issue #180): SOLO decide por el + /// backend GDK efectivo, nunca por el tipo de sesión. Bajo XWayland + /// (`GDK_BACKEND=x11`) debe reportar `ok`. + pub window_positioning: SurfaceStatus, } -/// Las 4 env vars que miramos, normalizadas: `None` si ausentes o vacías. +/// Las 5 env vars que miramos, normalizadas: `None` si ausentes o vacías. +/// `gdk_backend` es `GDK_BACKEND` (issue #180): GTK lo acepta como lista +/// separada por comas (p. ej. `"x11,*"`) y decide el backend GDK efectivo. #[derive(Debug, Default, Clone)] pub struct Env { pub wayland_display: Option, pub xdg_session_type: Option, pub desktop_session: Option, pub xdg_current_desktop: Option, + pub gdk_backend: Option, } impl Env { @@ -93,6 +119,7 @@ impl Env { xdg_current_desktop: std::env::var("XDG_CURRENT_DESKTOP") .ok() .filter(|s| !s.is_empty()), + gdk_backend: std::env::var("GDK_BACKEND").ok().filter(|s| !s.is_empty()), } } } @@ -105,58 +132,18 @@ pub fn detect_wm_info_from_env(env: &Env, tray: TrayDiagnostics) -> WindowManage // que `Env::from_process`: si el caller construye el `Env` a mano y le // pasa `Some("")` (los tests lo hacen), un string vacío no debe contar // como señal de Wayland ni aparecer en el struct. - let non_empty = |opt: &Option| opt.clone().filter(|s| !s.is_empty()); - let env_n = Env { - wayland_display: non_empty(&env.wayland_display), - xdg_session_type: non_empty(&env.xdg_session_type), - desktop_session: non_empty(&env.desktop_session), - xdg_current_desktop: non_empty(&env.xdg_current_desktop), - }; + let env_n = normalize_env(env); + // Single-source session signals: reused verbatim by the display-backend + // fallback and the runtime-probe override so nothing drifts. + let is_hyprland = is_hyprland_signal(&env_n); // Protocolo Wayland: solo evidencia de protocolo, no de escritorio. // GNOME/KDE cotidianamente corren sobre Xorg: su token en // XDG_CURRENT_DESKTOP no debe marcar Wayland. Sway/Hyprland/wlroots y // el resto de tokens solo-Wayland no tienen X11, así que sí lo señalan. - let is_hyprland = env_n - .desktop_session - .as_deref() - .map(|s| s.eq_ignore_ascii_case("hyprland")) - .unwrap_or(false) - || env_n - .xdg_current_desktop - .as_deref() - .map(|s| s.split(':').any(|p| p.eq_ignore_ascii_case("Hyprland"))) - .unwrap_or(false); - - // Hyprland es un compositor Wayland-only: si `DESKTOP_SESSION=hyprland` o - // `XDG_CURRENT_DESKTOP=Hyprland` sin más señales, el usuario está en - // Wayland (no en X11) — `surfaces_for` debe tomar la rama Hyprland, no - // la X11. `is_hyprland` se usa como señal adicional de Wayland. - let is_wayland = env_n.wayland_display.is_some() - || env_n - .xdg_session_type - .as_deref() - .map(|s| s.eq_ignore_ascii_case("wayland")) - .unwrap_or(false) - || env_n - .xdg_current_desktop - .as_deref() - .map(|s| s.split(':').any(is_wayland_only_session)) - .unwrap_or(false) - || is_hyprland; - - // `env_n` ya clonó los `Option` desde el `&Env` input (y filtró - // vacíos); se mueven al struct sin clones adicionales. - let is_gnome = env_n - .desktop_session - .as_deref() - .map(|s| s.eq_ignore_ascii_case("gnome")) - .unwrap_or(false) - || env_n - .xdg_current_desktop - .as_deref() - .map(|s| s.split(':').any(|p| p.eq_ignore_ascii_case("GNOME"))) - .unwrap_or(false); + let is_wayland = is_wayland_protocol(&env_n, is_hyprland); + let is_gnome = is_gnome_signal(&env_n); + let display_backend = display_backend_from_env(&env_n); let is_kde = env_n .desktop_session .as_deref() @@ -198,10 +185,138 @@ pub fn detect_wm_info_from_env(env: &Env, tray: TrayDiagnostics) -> WindowManage is_wayland, is_hyprland, recommendation, - surfaces: surfaces_for(is_wayland, is_hyprland, is_gnome, is_kde, tray), + display_backend, + surfaces: surfaces_for( + is_wayland, + is_hyprland, + is_gnome, + is_kde, + display_backend, + tray, + ), + } +} + +/// Normaliza strings vacíos como "ausente" en TODOS los campos, igual que +/// `Env::from_process`: si el caller construye el `Env` a mano y le pasa +/// `Some("")` (los tests lo hacen), un string vacío no debe contar como señal. +fn normalize_env(env: &Env) -> Env { + let non_empty = |opt: &Option| opt.clone().filter(|s| !s.is_empty()); + Env { + wayland_display: non_empty(&env.wayland_display), + xdg_session_type: non_empty(&env.xdg_session_type), + desktop_session: non_empty(&env.desktop_session), + xdg_current_desktop: non_empty(&env.xdg_current_desktop), + gdk_backend: non_empty(&env.gdk_backend), } } +/// Señal Hyprland (`DESKTOP_SESSION=hyprland` o token `Hyprland` en +/// `XDG_CURRENT_DESKTOP`). Extraída de `detect_wm_info_from_env` para que el +/// fallback del backend efectivo la reutilice sin duplicar los matches. +fn is_hyprland_signal(env_n: &Env) -> bool { + env_n + .desktop_session + .as_deref() + .map(|s| s.eq_ignore_ascii_case("hyprland")) + .unwrap_or(false) + || env_n + .xdg_current_desktop + .as_deref() + .map(|s| s.split(':').any(|p| p.eq_ignore_ascii_case("Hyprland"))) + .unwrap_or(false) +} + +/// ¿La sesión habla protocolo Wayland? Semática idéntica a la rama de +/// `detect_wm_info_from_env` (evidencia de protocolo, no de escritorio). +fn is_wayland_protocol(env_n: &Env, is_hyprland: bool) -> bool { + env_n.wayland_display.is_some() + || env_n + .xdg_session_type + .as_deref() + .map(|s| s.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) + || env_n + .xdg_current_desktop + .as_deref() + .map(|s| s.split(':').any(is_wayland_only_session)) + .unwrap_or(false) + || is_hyprland +} + +/// Señal GNOME (`DESKTOP_SESSION=gnome` o token `GNOME` en +/// `XDG_CURRENT_DESKTOP`). Única fuente para la rama de superficies y para el +/// recálculo de `windowPositioning` en el override de la sonda runtime. +fn is_gnome_signal(env_n: &Env) -> bool { + env_n + .desktop_session + .as_deref() + .map(|s| s.eq_ignore_ascii_case("gnome")) + .unwrap_or(false) + || env_n + .xdg_current_desktop + .as_deref() + .map(|s| s.split(':').any(|p| p.eq_ignore_ascii_case("GNOME"))) + .unwrap_or(false) +} + +/// Effective GDK display backend from environment evidence (issue #180, +/// WU1/WU4 maintainer correction). Pure; approximation of GTK semantics: +/// GTK walks the comma-separated `GDK_BACKEND` list and initializes the first +/// backend it can successfully set up (`*` means the platform default order). +/// We approximate without initializing GTK: the first decisive token in the +/// list decides (`x11` → X11, `wayland` → Wayland); any other token (`*`, +/// unknown backend names) is skipped, i.e. "let the GTK default happen". With +/// no decisive token the session-based default applies: Wayland when the +/// session signals the Wayland protocol, else X11 (same detection as +/// `is_wayland_session_from_env`). +/// +/// Known approximation limits (accepted; the fallback must stand alone): +/// an `"x11,wayland"` list resolves to X11 even if no X server exists and +/// GTK would have fallen through to Wayland. The runtime probe applied in +/// `get_wm_info` is the authoritative refinement when it resolves. +pub fn display_backend_from_env(env: &Env) -> DisplayBackend { + let env_n = normalize_env(env); + if let Some(raw) = env_n.gdk_backend.as_deref() { + for token in raw.split(',') { + match token.trim().to_ascii_lowercase().as_str() { + "x11" => return DisplayBackend::X11, + "wayland" => return DisplayBackend::Wayland, + // "*" and unknown names: keep walking (GTK default order). + _ => continue, + } + } + } + let is_hyprland = is_hyprland_signal(&env_n); + if is_wayland_protocol(&env_n, is_hyprland) { + DisplayBackend::Wayland + } else { + DisplayBackend::X11 + } +} + +/// Runtime override of the effective display backend (issue #180, WU4): the +/// live GDK probe, when it resolves, MUST win over the env fallback. Pure +/// helper, applied at the command level: rewrites `display_backend` and +/// recomputes ONLY `surfaces.window_positioning`; every other surface stays +/// untouched. +pub fn with_display_backend( + mut info: WindowManagerInfo, + backend: DisplayBackend, +) -> WindowManagerInfo { + info.display_backend = backend; + let desktop_env = Env { + wayland_display: None, + xdg_session_type: None, + desktop_session: info.desktop_session.clone(), + xdg_current_desktop: info.xdg_current_desktop.clone(), + gdk_backend: None, + }; + info.surfaces.window_positioning = + window_positioning_status(backend, is_gnome_signal(&desktop_env)); + info +} + /// Token de escritorio KDE en `XDG_CURRENT_DESKTOP` (lista separada por `:`). fn is_kde_token(token: &str) -> bool { matches!( @@ -213,7 +328,29 @@ fn is_kde_token(token: &str) -> bool { /// Límites por superficie derivados de la sesión detectada. Pura y /// determinista: los tests fijan lo que Ajustes va a mostrar. El diagnóstico /// de bandeja (`tray`) se inyecta: solo la rama GNOME lo consume. +/// +/// Issue #180 (WU4): `display_backend` es el backend GDK efectivo, ya +/// calculado por `display_backend_from_env`; `surfaces_for` lo recibe para +/// poblar SOLO la superficie nueva `window_positioning`, el resto de ramas +/// sigue dependiendo del tipo de sesión/compositor como siempre. fn surfaces_for( + is_wayland: bool, + is_hyprland: bool, + is_gnome: bool, + is_kde: bool, + display_backend: DisplayBackend, + tray: TrayDiagnostics, +) -> DesktopSurfaces { + // windowPositioning NO depende del tipo de sesión: solo del backend GDK + // efectivo (el input de compositor solo refina la nota en GNOME). + DesktopSurfaces { + window_positioning: window_positioning_status(display_backend, is_gnome), + ..desktop_surfaces_by_session(is_wayland, is_hyprland, is_gnome, is_kde, tray) + } +} + +/// Superficies por sesión/compositor (el cuerpo histórico de `surfaces_for`). +fn desktop_surfaces_by_session( is_wayland: bool, is_hyprland: bool, is_gnome: bool, @@ -232,6 +369,7 @@ fn surfaces_for( autostart: SurfaceStatus { state: "ok", note: "Arranque al iniciar sesión vía XDG autostart (.desktop)." }, notifications: SurfaceStatus { state: "ok", note: "Notificaciones por org.freedesktop.Notifications (daemon del escritorio)." }, transparency: SurfaceStatus { state: "ok", note: "Requiere composición activa (GNOME, KDE y XFCE la traen)." }, + ..Default::default() }; } if is_hyprland { @@ -243,6 +381,7 @@ fn surfaces_for( autostart: SurfaceStatus { state: "ok", note: "Arranque al iniciar sesión vía XDG autostart (.desktop)." }, notifications: SurfaceStatus { state: "ok", note: "Notificaciones por org.freedesktop.Notifications (daemon del escritorio)." }, transparency: SurfaceStatus { state: "ok", note: "El compositor aplica transparencia." }, + ..Default::default() }; } if is_gnome { @@ -257,6 +396,7 @@ fn surfaces_for( autostart: SurfaceStatus { state: "ok", note: "Arranque al iniciar sesión vía XDG autostart (.desktop)." }, notifications: SurfaceStatus { state: "ok", note: "Notificaciones por org.freedesktop.Notifications (daemon del escritorio)." }, transparency: SurfaceStatus { state: "ok", note: "El compositor aplica transparencia." }, + ..Default::default() }; } if is_kde { @@ -268,6 +408,7 @@ fn surfaces_for( autostart: SurfaceStatus { state: "ok", note: "Arranque al iniciar sesión vía XDG autostart (.desktop)." }, notifications: SurfaceStatus { state: "ok", note: "Notificaciones por org.freedesktop.Notifications (daemon del escritorio)." }, transparency: SurfaceStatus { state: "ok", note: "El compositor aplica transparencia." }, + ..Default::default() }; } // Wayland genérico (sway, wlroots, otros de la matriz): notas prudentes @@ -280,6 +421,18 @@ fn surfaces_for( autostart: SurfaceStatus { state: "ok", note: "Arranque al iniciar sesión vía XDG autostart (.desktop)." }, notifications: SurfaceStatus { state: "ok", note: "Notificaciones por org.freedesktop.Notifications (daemon del escritorio)." }, transparency: SurfaceStatus { state: "ok", note: "El compositor aplica transparencia." }, + ..Default::default() + } +} + +/// Estado de `windowPositioning` (issue #180, WU4): las ramas deciden SOLO +/// por el backend GDK efectivo, nunca por el tipo de sesión; el input de +/// compositor (`is_gnome`) solo refina la nota bajo Wayland. +pub fn window_positioning_status(display_backend: DisplayBackend, is_gnome: bool) -> SurfaceStatus { + match display_backend { + DisplayBackend::X11 => SurfaceStatus { state: "ok", note: "En X11 (y bajo XWayland con GDK_BACKEND=x11) el anclaje al borde y el arrastre con imán funcionan; la tira alcanza el borde del área de trabajo (#205)." }, + DisplayBackend::Wayland if is_gnome => SurfaceStatus { state: "limited", note: "Mutter no expone posicionamiento absoluto de ventanas (gtk_window_move no tiene efecto): el arrastre usa el gesto del compositor y, al soltar, no hay imán ni se persiste la posición." }, + DisplayBackend::Wayland => SurfaceStatus { state: "limited", note: "El posicionamiento absoluto de ventanas no está disponible bajo xdg-shell en Wayland: el arrastre de la tira se delega al compositor." }, } } @@ -564,6 +717,30 @@ fn recommendation( /// (spawn + espera + drenaje, hasta ~2,25 s nominales) corren en el pool de /// tareas bloqueantes y nunca en el hilo de UI; los commands Tauri síncronos /// NO garantizan sacar el trabajo del hilo principal. +/// +/// Issue #180 (revisión del mantenedor, una sola detección del backend GDK): +/// la sonda runtime del backend GDK efectivo es la de `widget_placement` +/// (`display_backend_on_ui`, hilo de UI + caché `OnceLock` compartida con +/// `transport()`); aquí solo la consumimos. Cuando resuelve manda sobre el +/// fallback por env; si no (`Unknown`, sin display, o el hilo de UI no +/// responde a tiempo), decide `display_backend_from_env`. Sólo Linux tiene +/// sonda: las sondas de bandeja viven igualmente fuera del hilo de UI. +#[cfg(target_os = "linux")] +#[tauri::command] +pub async fn get_wm_info(window: tauri::WebviewWindow) -> WindowManagerInfo { + let mut info = tauri::async_runtime::spawn_blocking(detect_wm_info) + .await + .expect("la tarea de sondeo no debe fallar antes del cierre de la app"); + if let Some(backend) = crate::widget_placement::display_backend_on_ui(&window).await { + info = with_display_backend(info, backend); + } + info +} + +/// Non-Linux: command equal to the pre-#180 one; no probe exists and the +/// env fallback (`display_backend_from_env`, baked into `detect_wm_info`) +/// decides. +#[cfg(not(target_os = "linux"))] #[tauri::command] pub async fn get_wm_info() -> WindowManagerInfo { tauri::async_runtime::spawn_blocking(detect_wm_info) @@ -586,6 +763,7 @@ mod tests { xdg_session_type: xdg_session_type.map(str::to_string), desktop_session: desktop_session.map(str::to_string), xdg_current_desktop: xdg_current_desktop.map(str::to_string), + gdk_backend: None, } } @@ -1192,7 +1370,159 @@ mod tests { ("autostart", d.autostart), ("notifications", d.notifications), ("transparency", d.transparency), + ("window_positioning", d.window_positioning), ] } } + + /// Full Wayland session (GNOME) as the deterministic backdrop for the + /// GDK-backend cases: WAYLAND_DISPLAY set, XDG_SESSION_TYPE=wayland, + /// XDG_CURRENT_DESKTOP=GNOME:GNOME. + fn wayland_session_env() -> Env { + env( + Some("wayland-0"), + Some("wayland"), + Some("gnome"), + Some("GNOME:GNOME"), + ) + } + + /// #180 (WU4): the display backend is decided by the EFFECTIVE GDK backend, + /// never by the session type. + #[test] + fn display_backend_gdk_x11_wins_on_wayland_session() { + let env = Env { + gdk_backend: Some("x11".to_string()), + ..wayland_session_env() + }; + assert_eq!(display_backend_from_env(&env), DisplayBackend::X11); + } + + #[test] + fn display_backend_unset_defaults_to_session_wayland() { + assert_eq!( + display_backend_from_env(&wayland_session_env()), + DisplayBackend::Wayland + ); + } + + #[test] + fn display_backend_gdk_list_wayland_first_wins() { + let env = Env { + gdk_backend: Some("wayland,x11".to_string()), + ..wayland_session_env() + }; + assert_eq!(display_backend_from_env(&env), DisplayBackend::Wayland); + } + + #[test] + fn display_backend_gdk_wildcard_is_not_decisive() { + let env = Env { + gdk_backend: Some("*".to_string()), + ..wayland_session_env() + }; + assert_eq!(display_backend_from_env(&env), DisplayBackend::Wayland); + } + + #[test] + fn display_backend_gdk_x11_with_wildcard_is_decisive() { + let env = Env { + gdk_backend: Some("x11,*".to_string()), + ..wayland_session_env() + }; + assert_eq!(display_backend_from_env(&env), DisplayBackend::X11); + } + + /// #180: serde contract (WU5 mirror): camelCase keys and lowercase + /// display-backend variants are what the frontend will consume. + #[test] + fn serde_output_uses_display_backend_and_window_positioning_keys() { + let info = detect_wm_info_from_env(&wayland_session_env(), tray()); + let json = serde_json::to_value(&info).expect("serializable"); + assert_eq!(json["displayBackend"], "wayland"); + assert_eq!(json["surfaces"]["windowPositioning"]["state"], "limited"); + + let overridden = with_display_backend(info, DisplayBackend::X11); + let json = serde_json::to_value(&overridden).expect("serializable"); + assert_eq!(json["displayBackend"], "x11"); + assert_eq!(json["surfaces"]["windowPositioning"]["state"], "ok"); + } + + #[test] + fn display_backend_x11_session_defaults_to_x11() { + // Generic X11 session with GDK_BACKEND unset: no Wayland evidence → X11. + let env = env(None, Some("x11"), Some("gnome"), Some("GNOME")); + assert_eq!(display_backend_from_env(&env), DisplayBackend::X11); + } + + /// #180 (WU4): the surface status branches ONLY on the display backend. + #[test] + fn window_positioning_status_x11_is_ok() { + let s = window_positioning_status(DisplayBackend::X11, true); + assert_eq!(s.state, "ok"); + assert!(s.note.contains("XWayland"), "{}", s.note); + let s_nongnome = window_positioning_status(DisplayBackend::X11, false); + assert_eq!(s_nongnome.state, "ok"); + } + + #[test] + fn window_positioning_status_gnome_wayland_is_limited_with_mutter_note() { + let s = window_positioning_status(DisplayBackend::Wayland, true); + assert_eq!(s.state, "limited"); + assert!(s.note.contains("Mutter"), "{}", s.note); + } + + #[test] + fn window_positioning_status_other_wayland_is_limited_generic() { + let s = window_positioning_status(DisplayBackend::Wayland, false); + assert_eq!(s.state, "limited"); + assert!(s.note.contains("xdg-shell"), "{}", s.note); + assert!(!s.note.contains("Mutter"), "{}", s.note); + } + + /// #180 (WU4 integration): a Wayland session with GDK_BACKEND=x11 reports + /// `ok` for windowPositioning (the maintainer-requested XWayland case). + #[test] + fn wayland_session_with_gdk_x11_gets_window_positioning_ok() { + let env = Env { + gdk_backend: Some("x11".to_string()), + ..wayland_session_env() + }; + let info = detect_wm_info_from_env(&env, tray()); + assert_eq!(info.display_backend, DisplayBackend::X11); + assert_eq!(info.surfaces.window_positioning.state, "ok"); + } + + /// #180 (WU4): the runtime probe override rewrites display_backend and + /// recomputes ONLY windowPositioning; every other surface stays intact. + #[test] + fn with_display_backend_overrides_backend_and_window_positioning_only() { + let info = detect_wm_info_from_env(&wayland_session_env(), tray()); + assert_eq!(info.display_backend, DisplayBackend::Wayland); + assert_eq!(info.surfaces.window_positioning.state, "limited"); + + let overridden = with_display_backend(info.clone(), DisplayBackend::X11); + assert_eq!(overridden.display_backend, DisplayBackend::X11); + assert_eq!(overridden.surfaces.window_positioning.state, "ok"); + assert_eq!(overridden.surfaces.tray_icon, info.surfaces.tray_icon); + assert_eq!( + overridden.surfaces.always_on_top, + info.surfaces.always_on_top + ); + assert_eq!( + overridden.surfaces.click_through, + info.surfaces.click_through + ); + assert_eq!( + overridden.surfaces.global_shortcuts, + info.surfaces.global_shortcuts + ); + assert_eq!(overridden.surfaces.autostart, info.surfaces.autostart); + assert_eq!( + overridden.surfaces.notifications, + info.surfaces.notifications + ); + assert_eq!(overridden.surfaces.transparency, info.surfaces.transparency); + assert_eq!(overridden.is_wayland, info.is_wayland); + } } diff --git a/src/Settings-providers-i18n.test.tsx b/src/Settings-providers-i18n.test.tsx index d2f3ac1..403c8b0 100644 --- a/src/Settings-providers-i18n.test.tsx +++ b/src/Settings-providers-i18n.test.tsx @@ -246,7 +246,7 @@ describe('Save bar, unsaved changes and global notices (SSR, i18n)', () => { isHyprland: false, recommendation: 'texto del backend', surfaces: Object.fromEntries( - ['alwaysOnTop', 'clickThrough', 'trayIcon', 'globalShortcuts', 'autostart', 'notifications', 'transparency'].map((k, i) => [ + ['alwaysOnTop', 'clickThrough', 'trayIcon', 'globalShortcuts', 'autostart', 'notifications', 'transparency', 'windowPositioning'].map((k, i) => [ k, { state: i % 2 ? 'limited' : 'ok', note: 'nota del backend' }, ]), diff --git a/src/Widget.tsx b/src/Widget.tsx index 36bef0a..e03a0dc 100644 --- a/src/Widget.tsx +++ b/src/Widget.tsx @@ -25,6 +25,8 @@ import { accountTags } from './lib/accounts'; import { clipSurfaceSafely, measureSurfaceClip } from './lib/surface-clip'; import { peekRect, usesPeekWindow, waitForViewport, watchPreparation } from './lib/auto-hide'; import { useDockReveal } from './lib/useDockReveal'; +import { currentDragMode, dragMode, fallbackDragMode, resetDragMode, setDragMode } from './lib/drag-session'; +import { detectWindowManager } from './lib/wm-detect'; import { t } from './i18n'; import { localeFrom } from './i18n/use-locale'; import { StripFallback } from './components/StripFallback'; @@ -653,6 +655,22 @@ export function Widget() { return () => { disposed = true; }; }, [dragging, geo, autoHide, widgetQueue]); + // Safety net for the compositor drag (issue #180, maintainer review point + // 3): under the compositor's move grab the client may never receive the + // closing pointerup, which would leave `dragging` stuck true (detail panel + // blocked, auto-hide busy, placement not re-requested). A later buttonless + // pointermove is the reliable release signal the webview still sees after + // the grab ends. Compositor-only: the native branch owns its release + // pipeline in onGripUp and must not be bypassed. + useEffect(() => { + if (!dragging || currentDragMode() !== 'compositor') return; + const release = (e: { buttons: number }) => { + if (e.buttons === 0) setDragging(false); + }; + window.addEventListener('pointermove', release); + return () => window.removeEventListener('pointermove', release); + }, [dragging]); + // The detail window owns its dimensions; hovering never mutates widget geometry. useEffect(() => { const strip = geo && stripSize ? stripRect(geo.area, edge, offset, stripSize.w, stripSize.h) : null; @@ -826,6 +844,39 @@ export function Widget() { window.clearInterval(interval); }; }, [hovered, presenceRef]); + // #180: drag strategy decision, fetched ONCE at mount (single fast + // `get_wm_info` command). Two deterministic defaults, both recorded here: + // - While the decision is unresolved (`currentDragMode() === null`) the + // grip does nothing: starting the old delta pipeline on a Wayland + // session is precisely the corruption this issue fixes, so the drag is + // BLOCKED instead of guessed. Detection is a fast startup command, so + // blocking until it resolves is deterministic and imperceptible. + // - If detection fails, the fallback is Linux-only (#210 review minor): + // Linux degrades to 'compositor' because the gesture cannot touch + // config.json, while Windows/macOS keep 'native' so a one-off probe + // failure never removes their magnet (see fallbackDragMode in + // src/lib/drag-session.ts). + // - On unmount the store is cleared so a re-mounted widget re-detects + // instead of inheriting a stale decision. + useEffect(() => { + let disposed = false; + detectWindowManager() + .then((info) => { + if (disposed) return; + // `displayBackend` ships in the get_wm_info response (wm_compat.rs, + // WU4 serde camelCase) and the TS mirror declares it typed now (WU5). + // Detection failure still falls back to 'compositor' (catch below). + setDragMode(dragMode(info.displayBackend).mode); + }) + .catch(() => { + if (!disposed) setDragMode(fallbackDragMode(osFromUA(navigator.userAgent))); + }); + return () => { + disposed = true; + resetDragMode(); + }; + }, []); + useEffect(() => { dragMounted.current = true; return () => { @@ -859,7 +910,26 @@ export function Widget() { const onGripDown = (e: PointerEvent) => { if (e.button !== 0 || !geo || !dragMounted.current) return; + // #180: until the startup decision is known the grip does nothing (see + // the decision effect above for both defaults). + const mode = currentDragMode(); + if (mode == null) return; e.preventDefault(); + if (mode === 'compositor') { + // Compositor branch: the drag is delegated to the compositor via the + // xdg-shell move gesture. startDragging() consumes the pointerdown + // serial itself, so we set up no pointer capture and no `drag.current` + // delta state: under xdg-shell the client has no absolute position to + // delta from (see onGripUp for the release-side counterpart). + setDragging(true); + cancelTimers(); + setHovered(null); + getCurrentWindow().startDragging().catch((err) => { + setError(String(err)); + setDragging(false); + }); + return; + } e.currentTarget.setPointerCapture(e.pointerId); if (drag.current) cancelAnimationFrame(drag.current.raf); cancelTimers(); @@ -893,6 +963,10 @@ export function Widget() { }; const onGripMove = (e: PointerEvent) => { + // #180: in compositor mode the compositor moves the window during the + // gesture, so this is a no-op — early return before any arithmetic or + // rAF work. The native branch below keeps today's pipeline untouched. + if (currentDragMode() === 'compositor') return; const d = drag.current; if (!d?.start || d.ending || e.pointerId !== d.pointerId) return; const current = () => dragMounted.current && drag.current === d; @@ -908,6 +982,18 @@ export function Widget() { }; const onGripUp = async (e: PointerEvent) => { + // #180: in compositor mode the compositor owns the grab and the final + // position, so this is minimal cleanup only — mirror of the `finally` + // below, minus the native snap/persist work: no outerPosition(), no + // nearestEdge(), no patchConfig(), no resolveGeo(). In this mode we set + // up no pointer capture, no rAF and no `drag.current`, so there is + // genuinely nothing else to release; a persisted edge computed from + // untrustworthy coordinates would corrupt the config.json that X11 + // sessions also read. + if (currentDragMode() === 'compositor') { + setDragging(false); + return; + } const d = drag.current; if (!d || d.ending || e.pointerId !== d.pointerId) return; d.ending = true; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5d54038..0d9963b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -164,6 +164,9 @@ export const en = { autostart: 'Start at login', notifications: 'Notifications', transparency: 'Transparency', + // New surface #180: the backend decides its state by effective GDK + // backend; this is only the visible row label. + windowPositioning: 'Window positioning', }, }, providers: { diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 36c4e0f..6d3a034 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -173,6 +173,9 @@ export const es = { autostart: 'Arranque al iniciar sesión', notifications: 'Notificaciones', transparency: 'Transparencia', + // New surface #180: the only row label that names the window; the + // backend decides its state by the effective GDK backend. + windowPositioning: 'Posicionamiento de la ventana', }, }, // Página «Proveedores»: lista, panel lateral, comprobación y aviso del CLI. diff --git a/src/lib/auto-hide.test.ts b/src/lib/auto-hide.test.ts index 9a4cc4e..39038d0 100644 --- a/src/lib/auto-hide.test.ts +++ b/src/lib/auto-hide.test.ts @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import hook from './useDockReveal.ts?raw'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { finishReveal, peekRect, readyReveal, requestReveal, usesPeekWindow, waitForViewport, watchPreparation, REVEAL_DURATION, type RevealPhase } from './auto-hide'; +import { resetDragMode, setDragMode } from './drag-session'; import { contains, stripRect, type Edge } from './dock'; type GripHandlers = { @@ -65,6 +66,12 @@ vi.mock('./useDockReveal', () => ({ useDockReveal: () => ({ phase: 'open', markPrepared: vi.fn(), finishTransition: vi.fn() }), })); vi.mock('./useUpdater', () => ({ useUpdaterMode: () => 'unknown' })); +// #180 (WU2): the widget gates drag gestures behind the startup display +// decision; these tests exercise the native (X11) pipeline, so resolve the +// detection seam deterministically instead of letting it hang unresolved. +vi.mock('./wm-detect', () => ({ + detectWindowManager: vi.fn(async () => ({ displayBackend: 'x11' })), +})); vi.mock('./api', () => ({ api: { setWidgetBounds: widgetEffects.bounds, setSurfaceClip: widgetEffects.clip, @@ -298,12 +305,16 @@ describe('Widget effect mutation ordering', () => { borderTopLeftRadius: '14px', borderTopRightRadius: '14px', borderBottomRightRadius: '0px', borderBottomLeftRadius: '0px', })); + // SSR runs no React effects, so the #180 module decision store never sees + // the mount-effect write; these tests drive the native branch directly. + setDragMode('native'); }; afterEach(() => { widgetEffects.states = []; widgetEffects.refs = []; widgetEffects.effects = []; + resetDragMode(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); diff --git a/src/lib/desktop-compat.test.ts b/src/lib/desktop-compat.test.ts index b6cf86a..6485dfe 100644 --- a/src/lib/desktop-compat.test.ts +++ b/src/lib/desktop-compat.test.ts @@ -10,6 +10,8 @@ const surfaces: DesktopSurfaces = { autostart: { state: 'ok', note: 'ok-autostart' }, notifications: { state: 'ok', note: 'ok-notif' }, transparency: { state: 'ok', note: 'ok-transp' }, + // New surface #180 (WU4): decided by the effective GDK backend. + windowPositioning: { state: 'limited', note: 'no disponible en este compositor' }, }; describe('desktop-compat', () => { @@ -23,11 +25,25 @@ describe('desktop-compat', () => { 'Arranque al iniciar sesión', 'Notificaciones', 'Transparencia', + 'Posicionamiento de la ventana', ]); expect(rows[0]).toMatchObject({ state: 'ok', note: 'ok-aot' }); expect(rows[1]).toMatchObject({ state: 'limited', note: 'limitado' }); }); + it('the windowPositioning row (#180) goes last and passes the backend note through verbatim', () => { + const rows = desktopCompatRows(surfaces); + expect(rows).toHaveLength(8); + const row = rows[rows.length - 1]; + expect(row).toMatchObject({ + label: 'Posicionamiento de la ventana', + state: 'limited', + note: 'no disponible en este compositor', + }); + const en = desktopCompatRows(surfaces, 'en'); + expect(en[en.length - 1]).toMatchObject({ label: 'Window positioning', state: 'limited' }); + }); + it('names the surfaces in English when the locale is en, keeping the backend note (T2c-S2 PR-B)', () => { const rows = desktopCompatRows(surfaces, 'en'); expect(rows.map((r) => r.label)).toEqual([ @@ -38,6 +54,7 @@ describe('desktop-compat', () => { 'Start at login', 'Notifications', 'Transparency', + 'Window positioning', ]); // La nota la redacta el backend: es dato y se conserva tal cual. expect(rows[1]).toMatchObject({ state: 'limited', note: 'limitado' }); @@ -45,7 +62,14 @@ describe('desktop-compat', () => { it('detecta cuando alguna superficie tiene límites declarados', () => { expect(hasLimitedSurfaces(surfaces)).toBe(true); - expect(hasLimitedSurfaces({ ...surfaces, clickThrough: { state: 'ok', note: '' }, globalShortcuts: { state: 'ok', note: '' } })).toBe( + expect( + hasLimitedSurfaces({ + ...surfaces, + clickThrough: { state: 'ok', note: '' }, + globalShortcuts: { state: 'ok', note: '' }, + windowPositioning: { state: 'ok', note: '' }, + }) + ).toBe( false ); }); diff --git a/src/lib/desktop-compat.ts b/src/lib/desktop-compat.ts index 3f4c8d2..89fff44 100644 --- a/src/lib/desktop-compat.ts +++ b/src/lib/desktop-compat.ts @@ -22,6 +22,8 @@ const SURFACE_LABELS: { key: keyof DesktopSurfaces; label: MessageKey }[] = [ { key: 'autostart', label: 'settings.shell.surfaces.autostart' }, { key: 'notifications', label: 'settings.shell.surfaces.notifications' }, { key: 'transparency', label: 'settings.shell.surfaces.transparency' }, + // #180: order mirrors the Rust struct (`window_positioning` is the last surface). + { key: 'windowPositioning', label: 'settings.shell.surfaces.windowPositioning' }, ]; /** Filas del bloque «Sesión de escritorio», en orden fijo y sin filtrar. */ diff --git a/src/lib/drag-session.test.ts b/src/lib/drag-session.test.ts new file mode 100644 index 0000000..f04c5dc --- /dev/null +++ b/src/lib/drag-session.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { currentDragMode, dragMode, fallbackDragMode, resetDragMode, setDragMode } from './drag-session'; + +describe('dragMode', () => { + it('decides by effective GDK backend, not session type', () => { + // The maintainer-required case for #180: a Wayland session forced to + // GDK_BACKEND=x11 reports displayBackend 'x11' from get_wm_info, and the + // decision layer must treat it as native (snap + persistence keep working + // under XWayland). The session + override combination itself is covered by + // the Rust integration test in src-tauri/src/wm_compat.rs. + expect(dragMode('x11')).toEqual({ + mode: 'native', + allowSnap: true, + allowPersist: true, + }); + // Native GDK Wayland display: the compositor moves the window, so neither + // snap nor persistence is possible (see rationale in drag-session.ts). + expect(dragMode('wayland')).toEqual({ + mode: 'compositor', + allowSnap: false, + allowPersist: false, + }); + }); + + it.each([ + ['x11', 'native', true, true], + ['wayland', 'compositor', false, false], + ] as const)('keeps decision invariants for %s', (backend, mode, snap, persist) => { + const decision = dragMode(backend); + expect(decision.mode).toBe(mode); + expect(decision.allowSnap).toBe(decision.mode === 'native'); + expect(decision.allowPersist).toBe(snap); + expect(decision.allowPersist).toBe(persist); + }); +}); + +describe('fallbackDragMode (detection failure, #180 review minor)', () => { + // The maintainer review of #210: if `get_wm_info` fails, the 'compositor' + // degradation must not reach Windows/macOS, where a one-off probe failure + // would silently remove the only drag the platform has (the magnet). Linux + // keeps the no-touch gesture because the native pipeline could corrupt + // config.json in unknown conditions. + it('degrades to compositor on Linux only', () => { + expect(fallbackDragMode('linux')).toBe('compositor'); + expect(fallbackDragMode('windows')).toBe('native'); + expect(fallbackDragMode('macos')).toBe('native'); + }); +}); + +describe('module decision store', () => { + // The store is module-global, so every test starts from the unresolved + // default to stay independent of execution order. + beforeEach(() => { + resetDragMode(); + }); + + it('starts unresolved: currentDragMode() is null and the grip would be blocked', () => { + expect(currentDragMode()).toBeNull(); + }); + + it('records the mount-effect decision', () => { + setDragMode(dragMode('x11').mode); + expect(currentDragMode()).toBe('native'); + setDragMode(dragMode('wayland').mode); + expect(currentDragMode()).toBe('compositor'); + setDragMode('compositor'); // Detection-failure fallback path. + expect(currentDragMode()).toBe('compositor'); + }); + + it('resetDragMode returns to the unresolved state', () => { + setDragMode('native'); + expect(currentDragMode()).toBe('native'); + resetDragMode(); + expect(currentDragMode()).toBeNull(); + }); + + it('is pure with respect to dragMode(): the decision function stays stateless', () => { + setDragMode('compositor'); + expect(dragMode('x11')).toEqual(dragMode('x11')); + expect(dragMode('x11').mode).toBe('native'); + expect(dragMode('wayland').mode).toBe('compositor'); + expect(currentDragMode()).toBe('compositor'); + }); +}); diff --git a/src/lib/drag-session.ts b/src/lib/drag-session.ts new file mode 100644 index 0000000..d2f4829 --- /dev/null +++ b/src/lib/drag-session.ts @@ -0,0 +1,77 @@ +/** Mirror of the Rust DisplayBackend (wm_compat.rs, serde lowercase). */ +export type DisplayBackend = 'x11' | 'wayland'; + +/** + * 'native': the window positions itself (X11/XWayland). + * 'compositor': xdg-shell gesture (startDragging); the compositor moves it. + */ +export type DragMode = 'native' | 'compositor'; + +export interface DragDecision { + mode: DragMode; + allowSnap: boolean; + allowPersist: boolean; +} + +/** + * Module-level decision store for the CURRENT session's drag mode. + * + * A widget-local `useRef` cannot be observed or reset by outside code: the SSR + * test harness (`renderToStaticMarkup`) runs no React effects, so a ref stays + * `null` forever and every simulated grip is blocked; and on a re-mounted + * widget a ref either leaks a stale decision or re-detects from scratch with + * no observable state in between. A module store is observable by tests and + * resettable on unmount, and the decision is still fetched exactly once per + * mount via `get_wm_info` (see the decision effect in `src/Widget.tsx`). + * + * `null` = unresolved: the grip must NOT start any drag yet (both defaults + * from the mount effect). Otherwise 'native' or 'compositor', as decided by + * `dragMode()` for the effective GDK backend (or the safe fallback). + */ +let storedDragMode: DragMode | null = null; + +export const setDragMode = (mode: DragMode): void => { + storedDragMode = mode; +}; + +export const currentDragMode = (): DragMode | null => storedDragMode; + +export const resetDragMode = (): void => { + storedDragMode = null; +}; + +/** + * Single source of truth for #180 drag strategy, decided by the EFFECTIVE GDK + * display backend (exposed as `displayBackend` in the get_wm_info response), + * never by session type: `is_wayland_session_from_env` ignores + * GDK_BACKEND=x11, so deciding by session would lose snap and persistence + * exactly under XWayland, where the issue thread shows they work. + * + * X11 (including XWayland): gtk_window_move works, so snap on release and + * persisting dockEdge/dockOffset/dockMonitor are both allowed. + * + * Wayland: under xdg-shell only the compositor controls position, the client + * has no absolute position to consult, and a release edge computed from + * unreliable coordinates could corrupt the shared config.json that X11 + * sessions also read — so neither snap nor persistence runs there. + */ +export function dragMode(backend: DisplayBackend): DragDecision { + if (backend === 'x11') { + return { mode: 'native', allowSnap: true, allowPersist: true }; + } + return { mode: 'compositor', allowSnap: false, allowPersist: false }; +} + +/** Platform of the widget process, as classified by `osFromUA`. */ +export type WidgetPlatform = 'windows' | 'macos' | 'linux'; + +/** + * Detection-failure fallback when `get_wm_info` does not resolve (#180, + * maintainer review minor of #210). Linux-only degradation: the compositor + * gesture cannot touch config.json, so an unresolved probe is safe there. + * Windows/macOS keep 'native' — a one-off probe failure must never remove + * the magnet of the only drag pipeline those platforms have. + */ +export function fallbackDragMode(platform: WidgetPlatform): DragMode { + return platform === 'linux' ? 'compositor' : 'native'; +} diff --git a/src/lib/widget-recovery.test.ts b/src/lib/widget-recovery.test.ts index 525177b..33be6e2 100644 --- a/src/lib/widget-recovery.test.ts +++ b/src/lib/widget-recovery.test.ts @@ -133,4 +133,14 @@ describe('shouldAuditNativePosition', () => { expect(shouldAuditNativePosition('macos')).toBe(/Windows/.test(MAC_UA)); expect(shouldAuditNativePosition('linux')).toBe(/Windows/.test(LINUX_UA)); }); + + // #180 (WU3, regression pin): the widget now branches drag strategies by + // display backend (WU2), but the position audit must stay Windows-only. + // Under native Wayland the compositor owns placement (WU2/compositor branch) + // and outerPosition() is (0,0) by design, so auditing would fight the + // compositor's placement; under XWayland the audit gate must NOT reactivate + // either, keeping the pre-#180 behavior (Linux excluded) intact. + it('#180 regression: la auditoria no se reactiva con la rama de arrastre por compositor', () => { + expect(shouldAuditNativePosition('linux')).toBe(false); + }); }); diff --git a/src/lib/wm-detect.ts b/src/lib/wm-detect.ts index ecc3494..f2905ca 100644 --- a/src/lib/wm-detect.ts +++ b/src/lib/wm-detect.ts @@ -6,6 +6,9 @@ // El backend decide la recomendación; el frontend solo la presenta. import { invoke } from '@tauri-apps/api/core'; +// #180: the effective GDK backend owns the union (`./drag-session`, WU1); we +// only type the mirror field of the Rust `DisplayBackend` (wm_compat.rs). +import type { DisplayBackend } from './drag-session'; /** Estado de una superficie de escritorio según la sesión detectada. */ export interface SurfaceStatus { @@ -21,6 +24,9 @@ export interface DesktopSurfaces { autostart: SurfaceStatus; notifications: SurfaceStatus; transparency: SurfaceStatus; + // #180 (WU4): window positioning/drag of the strip, decided by the + // effective GDK backend (XWayland → ok; native Wayland → limited). + windowPositioning: SurfaceStatus; } export interface WindowManagerInfo { @@ -31,6 +37,9 @@ export interface WindowManagerInfo { isWayland: boolean; isHyprland: boolean; recommendation: string; + // #180: effective GDK backend (runtime display or `GDK_BACKEND` fallback); + // decides the drag mode (WU1) and the surface above. + displayBackend: DisplayBackend; surfaces: DesktopSurfaces; }