
Los dos modos de fallo de un overlay click-through
La ventana de GeekBye flota por encima de todo y deja que tus clics la atraviesen — salvo donde tiene botones. Es un contrato de dos caras, y la v1.8.5 y la v1.8.14 son lo que se ve cuando cada cara se rompe: una release donde el overlay se tragó un diálogo del sistema, otra donde te robó las pulsaciones de teclas. El arreglo ganador para la segunda fue borrar código.
La ventana principal de GeekBye no es una ventana normal. Es sin marco, transparente y siempre encima — una lámina de cristal flotando sobre lo que sea que estés haciendo, con unos pocos controles pintados encima. Lo interesante de la ingeniería no es que flote; es que se supone que tu ratón debe atravesar el cristal de lado a lado en todas partes salvo en los controles. Haz clic en un botón del overlay y el overlay recibe el clic. Haz clic en el espacio vacío junto a él y el clic aterriza en tu app de reuniones que está debajo, como si el overlay no estuviera ahí en absoluto.
Eso es un contrato con dos caras: recibir input exactamente donde hay UI, y ser completamente invisible al input en todo lo demás. Ambas caras tienen que cumplirse en todo instante, y cada bug en esta parte de la app es una violación de una cara o de la otra. Dos releases, con dos meses de diferencia, son la ilustración más limpia posible — porque cada una rompió una cara distinta.
El contrato, mecánicamente
La implementación es un hook, useMousePassthrough, y es pequeño. La ventana es click-through por defecto. Un listener global de mousemove hace una sola pregunta en cada movimiento: ¿está el cursor sobre un control? Los controles llevan un atributo data-ui-region, así que el test es literalmente target.closest('[data-ui-region]'). Sobre un control, llama a setIgnoreMouseEvents(false) y la ventana captura los clics. Sobre espacio transparente, llama a setIgnoreMouseEvents(true, { forward: true }) y los clics caen a través.
Ese forward: true es el detalle que carga con todo el peso. Una vez que una ventana ignora los eventos del ratón, normalmente deja de recibirlos por completo — incluidos los eventos mousemove que el hook necesita para notar que el cursor vuelve sobre un control. forward: true le dice a Electron que siga reenviando los movimientos a la ventana que los ignora de todos modos, para que pueda rearmarse. Sin él, el overlay se volvería click-through una vez y no volvería nunca. El hook además solo cruza la frontera IPC en las transiciones de estado — cuando el cursor cruza el límite entre UI y cristal — no en cada píxel de movimiento. Recuerda esa optimización; tiene un agujero, y la v1.8.5 cayó en él.
v1.8.5: cuando se rompe la cara de la transparencia
La cara de la transparencia del contrato es «ser invisible al input en todas partes donde no hay UI». La v1.8.5 es lo que se ve cuando el overlay está demasiado presente — cuando captura o bloquea input que debería haber dejado pasar. El detonante fueron los diálogos de permisos de macOS, y había dos fallos distintos apilados uno sobre otro.
El overlay estaba físicamente por encima del diálogo. Una ventana always-on-top tiene un nivel, y el overlay de GeekBye estaba en el más alto que ofrece macOS — screen-saver. Ese está por encima de casi todo, incluidos los propios diálogos de permiso de Microphone y Screen Recording del sistema. Así que durante el onboarding, en el momento exacto en que macOS pedía permiso, el diálogo podía renderizarse por debajo del overlay y quedar invisible. El arreglo, setPermissionMode, es solo para macOS y hace lo poco intuitivo: baja el overlay al nivel floating mientras dura una petición de permiso, y luego lo restaura. Y restaurarlo requería un truco concreto, documentado en el propio commit: macOS «doesn't always visually re-raise a window when setting the same level it was previously at», así que el código hace pasar la ventana por un nivel intermedio — floating → status → screen-saver — para forzar al servidor de ventanas a recalcular el orden de apilamiento. No fijas el nivel que quieres; caminas hasta él.
El overlay se comía los clics del diálogo. El onboarding pinta un fondo oscuro que cubre toda la ventana, y el hook de passthrough lo trata como caso especial: mientras ese fondo está puesto, fuerza la captura de clics en toda la ventana para que el asistente sea plenamente interactivo. Pero «capturar cada clic» es exactamente lo incorrecto cuando un diálogo del sistema intenta recoger uno. El arreglo quita el atributo marcador del fondo durante una petición de permiso, así que el hook sale del modo captura y los botones del diálogo reciben sus clics. El overlay mantiene interactivos sus propios controles; solo deja de fingir que el cristal vacío es un botón.
El agujero en la optimización. Recuerda que el hook solo actúa en las transiciones. Cuando la ventana salía del modo captura, volvía a correr el test de hover — pero si tu cursor ya estaba sobre un control en ese momento, la respuesta a «¿estamos sobre UI?» ya era «sí», así que no se disparaba ninguna transición, y el click-through nunca se desactivaba. El control parecía vivo y estaba muerto. El arreglo (1b6eb20, citado) deja de confiar en la transición: «when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call». Cuando cambias de modo, no esperas a que el siguiente movimiento te diga dónde está el cursor — miras.
Y haz que la cesión sea reversible. El riesgo sutil en todo esto es que el overlay ahora renuncia temporalmente a su z-order y a su captura de clics, y algo tiene que devolvérselos. Si la llamada de restauración lanza una excepción — un tropiezo de IPC, una ventana desmontada a media petición — la app podría quedar varada con isRequestingPermission atascado en true y un fondo oscuro ocultando toda tu pantalla, para siempre. Así que la ruta de salida se envolvió para que el reset de estado «siempre se ejecute», más un timeout de seguridad de varios minutos y una limpieza al destruir la ventana. La regla que se desprende: cada vez que un overlay cede input, el des-ceder tiene que estar en un finally, no en un happy path.
v1.8.14: cuando se rompe la cara de la captura
La otra cara del contrato es «recibir input donde hay UI». La v1.8.14 es lo que se ve cuando el overlay está demasiado acaparador — cuando interactuar con él te cuesta el foco de input de la app que de verdad te importa.
El síntoma en el changelog es el robo de foco, y aquí va la versión honesta, porque la historia de git es más instructiva que la nota de la release. Los manejadores de atajos del overlay — alternar la visibilidad con Cmd+B, abrir la ventana de chat, hacer scroll en ella — cada uno llamaba a mainWindow.focus(), y en macOS a app.focus({ steal: true }), para asegurarse de que el overlay estuviera al frente cuando lo activabas. El problema es que enfocar el overlay desactiva lo que estuviera al frente antes — tu reunión, tu editor — así que una pulsación destinada a ellos podía aterrizar en ninguna parte, o en el lugar equivocado. El overlay estaba acaparando algo que en realidad no necesitaba.
Esa última parte es toda la lección, y el equipo la descubrió por las malas. La rama que se convirtió en la v1.8.14 primero intentó resolver el arrastre en concreto — porque el arrastre tiene su propia y hermosa versión de este bug: cuando arrastras un overlay a pantalla completa, la ventana se mueve bajo un cursor inmóvil, así que el modelo de hover cree que el puntero «salió» del control y salta a click-through a mitad del arrastre, soltando el arrastre. Así que añadieron un dragLock. Luego intentaron hacer toda la ventana no enfocable — lo que rompió la escritura y el arrastre, así que restauraron la enfocabilidad, luego acotaron la no-enfocabilidad solo a los campos de texto, y luego arrancaron el toggle. Construyeron una IPC de arrastre personalizada para reemplazar el arrastre nativo de Electron. Añadieron un blur-al-foco-inesperado. Una semana de esto.
Luego borraron casi todo. El commit que se publicó revirtió el arrastre personalizado, el toggle de enfocabilidad y el manejador de blur, y conservó exactamente una idea: dejar de llamar a focus(). La constatación, escrita en un comentario de código, es que el overlay nunca necesitó el foco del SO para empezar — «IPC messages (webContents.send) work without window focus». El overlay gobierna su propio renderer directamente; el foco era puro daño colateral. El arreglo publicable no fue la ingeniosa reescritura de arrastre personalizado construida en un día. Fue restar las llamadas a focus() que no debían haber estado ahí. (Vale decirlo con claridad: el changelog dice «while dragging it», pero lo que de verdad se publicó fue quitar el robo de foco de los manejadores de atajos de teclado — la maquinaria específica del arrastre se exploró y se revirtió esa misma semana.)
Lo que está en juego, hecho literal
Si quieres una prueba de que este contrato no es académico, mira lo que pasó meses después cuando el overlay se quedó atascado en la cara equivocada. En una release mucho posterior, el proceso renderer se cayó mientras el overlay estaba en modo captura — setIgnoreMouseEvents(false), la ventana agarrando todos los clics — y sin una UI viva que lo liberara, la ventana invisible que cubría todo el escritorio se quedó ahí comiéndose cada clic en la máquina del usuario hasta que forzó el cierre. Una ventana transparente atascada en modo captura no es un botón muerto; es una trampa de clics que abarca todo el escritorio. (Ese tuvo su propio diagnóstico — mira por qué tu notetaker con IA deja de grabar en mitad de la reunión, que lo empareja con un bug muy distinto en la misma release.) Es la formulación más aguda de todo el problema: deja el toggle atascado de un lado y tu UI está muerta; atascado del otro lado, y lo está todo tu escritorio.
Tres cosas que estas releases nos enseñaron
- Un overlay click-through es un contrato de dos caras, y cada cara falla distinto. Demasiado transparente y tus propios controles quedan muertos; demasiado acaparador y te tragas el input de otras apps. La mayoría de los bugs de overlay son solo uno de esos dos, y nombrar en qué cara estás te dice dónde mirar.
- El nivel always-on-top más alto no es un premio. Estar por encima de todo significa estar por encima de los diálogos del sistema que necesitas que el usuario vea. Diseña para cuando tu overlay deba apartarse educadamente — y haz que volver a subir sea reversible en un
finally, porque una cesión a medio completar es peor que no ceder nunca. - La sustracción le gana al ingenio. El arreglo ganador de la v1.8.14 fue borrar llamadas a
focus()que el overlay nunca necesitó, halladas solo tras una semana de construir y revertir un manejo de arrastre personalizado. Cuando una funcionalidad no para de pelearse con la plataforma, la primera pregunta que hacerse es si puedes quitar la cosa que está peleando.
Para el capítulo anterior de la historia v1, poner la release en CI, dos veces (v1.8.4); y para todo el arco, la anatomía de publicar software hasta la perfección.