
Os dois modos de falha de um overlay click-through
A janela da GeekBye flutua por cima de tudo e deixa seus cliques passarem através dela — exceto onde tem botões. É um contrato de duas faces, e a v1.8.5 e a v1.8.14 são o que se vê quando cada face quebra: uma release onde o overlay engoliu um diálogo do sistema, outra onde roubou suas teclas digitadas. A correção vencedora para a segunda foi apagar código.
A janela principal da GeekBye não é uma janela normal. É sem moldura, transparente e sempre por cima — uma lâmina de vidro flutuando sobre o que quer que você esteja fazendo, com alguns poucos controles pintados nela. O interessante da engenharia não é que ela flutua; é que o seu mouse deveria atravessar o vidro de ponta a ponta em todo lugar exceto nos controles. Clique num botão do overlay e o overlay recebe o clique. Clique no espaço vazio ao lado e o clique aterrissa no seu app de reunião que está embaixo, como se o overlay não estivesse ali de jeito nenhum.
Isso é um contrato com duas faces: receber input exatamente onde há UI, e ser completamente invisível ao input em todo o resto. As duas faces têm que valer a cada instante, e cada bug nesta parte do app é uma violação de uma face ou da outra. Duas releases, com dois meses de diferença, são a ilustração mais limpa possível — porque cada uma quebrou uma face diferente.
O contrato, mecanicamente
A implementação é um hook, useMousePassthrough, e é pequeno. A janela é click-through por padrão. Um listener global de mousemove faz uma única pergunta a cada movimento: o cursor está sobre um controle? Os controles são marcados com um atributo data-ui-region, então o teste é literalmente target.closest('[data-ui-region]'). Sobre um controle, ele chama setIgnoreMouseEvents(false) e a janela captura os cliques. Sobre espaço transparente, ele chama setIgnoreMouseEvents(true, { forward: true }) e os cliques caem através.
Esse forward: true é o detalhe que carrega todo o peso. Uma vez que uma janela ignora os eventos do mouse, ela normalmente para de recebê-los por completo — incluindo os eventos mousemove que o hook precisa para notar o cursor voltando sobre um controle. forward: true diz ao Electron para continuar encaminhando os movimentos para a janela que os ignora mesmo assim, para que ela consiga se rearmar. Sem ele, o overlay iria para click-through uma vez e nunca mais voltaria. O hook também só cruza a fronteira IPC nas transições de estado — o cursor cruzando o limite entre UI e vidro — não a cada pixel de movimento. Guarde essa otimização; ela tem um buraco, e a v1.8.5 caiu nele.
v1.8.5: quando a face da transparência quebra
A face da transparência do contrato é «ser invisível ao input em todo lugar onde não há UI». A v1.8.5 é o que se vê quando o overlay está presente demais — quando captura ou bloqueia input que deveria ter deixado passar. O gatilho foram os diálogos de permissão do macOS, e havia duas falhas distintas empilhadas uma sobre a outra.
O overlay estava fisicamente acima do diálogo. Uma janela always-on-top tem um nível, e o overlay da GeekBye ficava no mais alto que o macOS oferece — screen-saver. Esse fica acima de quase tudo, incluindo os próprios diálogos de permissão de Microphone e Screen Recording do sistema. Então durante o onboarding, no exato momento em que o macOS pedia permissão, o diálogo podia renderizar por baixo do overlay e ficar invisível. A correção, setPermissionMode, é só para macOS e faz a coisa contraintuitiva: ela baixa o overlay para o nível floating durante um pedido de permissão, e depois o restaura. E restaurá-lo exigia um truque específico, documentado no próprio commit: o macOS «doesn't always visually re-raise a window when setting the same level it was previously at», então o código faz a janela passar por um nível intermediário — floating → status → screen-saver — para forçar o servidor de janelas a recalcular a ordem de empilhamento. Você não define o nível que quer; você caminha até ele.
O overlay estava comendo os cliques do diálogo. O onboarding pinta um fundo escuro cobrindo a janela inteira, e o hook de passthrough o trata como caso especial: enquanto esse fundo está no ar, ele força a captura de cliques na janela toda para que o assistente seja totalmente interativo. Mas «capturar cada clique» é exatamente o errado quando um diálogo do sistema está tentando recolher um. A correção retira o atributo marcador do fundo durante um pedido de permissão, então o hook sai do modo captura e os botões do diálogo recebem seus cliques. O overlay mantém seus próprios controles interativos; ele só para de fingir que o vidro vazio é um botão.
O buraco na otimização. Lembre que o hook só age nas transições. Quando a janela saía do modo captura, ela rodava de novo o teste de hover — mas se o seu cursor já estava pousado sobre um controle naquele momento, a resposta a «estamos sobre UI?» já era «sim», então nenhuma transição disparava, e o click-through nunca era desativado. O controle parecia vivo e estava morto. A correção (1b6eb20, citada) para de confiar na transição: «when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call». Quando você muda de modo, não espera o próximo movimento te dizer onde está o cursor — você olha.
E faça a cessão reversível. O risco sutil em tudo isso é que o overlay agora abre mão temporariamente do seu z-order e da sua captura de cliques, e algo tem que devolvê-los. Se a chamada de restauração lança uma exceção — um soluço de IPC, uma janela desmontada no meio do pedido — o app poderia ficar encalhado com isRequestingPermission travado em true e um fundo escuro escondendo a sua tela inteira, para sempre. Então o caminho de saída foi envolvido para que o reset de estado «sempre execute», mais um timeout de segurança de vários minutos e uma limpeza na destruição da janela. A regra que decorre daí: toda vez que um overlay cede input, o des-ceder tem que estar em um finally, não em um caminho feliz.
v1.8.14: quando a face da captura quebra
A outra face do contrato é «receber input onde há UI». A v1.8.14 é o que se vê quando o overlay está agarrado demais — quando interagir com ele te custa o foco de input do app com que você realmente se importa.
O sintoma no changelog é o roubo de foco, e aqui vai a versão honesta, porque o histórico do git é mais instrutivo que a nota de release. Os handlers de atalho do overlay — alternar a visibilidade no Cmd+B, abrir a janela de chat, rolar ela — cada um chamava mainWindow.focus(), e no macOS app.focus({ steal: true }), para garantir que o overlay estivesse na frente quando você o acionasse. O problema é que dar foco ao overlay desativa o que quer que estivesse na frente antes — sua reunião, seu editor — então uma tecla destinada a eles podia aterrissar em lugar nenhum, ou no lugar errado. O overlay estava agarrando uma coisa de que na verdade não precisava.
Essa última parte é a lição toda, e o time a descobriu do jeito difícil. O branch que virou a v1.8.14 primeiro tentou resolver o arrasto especificamente — porque o arrasto tem a sua própria e bela versão desse bug: quando você arrasta um overlay em tela cheia, a janela se move sob um cursor parado, então o modelo de hover acha que o ponteiro «saiu» do controle e vira click-through no meio do arrasto, largando o arrasto. Então adicionaram um dragLock. Depois tentaram deixar a janela inteira não focável — o que quebrou a digitação e o arrasto, então restauraram a focabilidade, depois estreitaram a não-focabilidade só para os campos de texto, e depois arrancaram o toggle. Construíram uma IPC de arrasto personalizada para substituir o arrasto nativo do Electron. Adicionaram um blur-no-foco-inesperado. Uma semana disso.
Depois apagaram quase tudo. O commit que foi publicado reverteu o arrasto personalizado, o toggle de focabilidade e o handler de blur, e manteve exatamente uma ideia: parar de chamar focus(). A constatação, escrita num comentário de código, é que o overlay nunca precisou do foco do SO para começar — «IPC messages (webContents.send) work without window focus». O overlay governa o próprio renderer diretamente; o foco era puro dano colateral. A correção publicável não foi a esperta reescrita de arrasto personalizado construída em um dia. Foi subtrair as chamadas a focus() que não deviam estar ali. (Vale dizer com clareza: o changelog diz «while dragging it», mas o que de fato foi publicado foi remover o roubo de foco dos handlers de atalho de teclado — a maquinaria específica do arrasto foi explorada e revertida na mesma semana.)
O que está em jogo, tornado literal
Se você quer prova de que esse contrato não é acadêmico, olhe o que aconteceu meses depois quando o overlay ficou preso do lado errado dele. Numa release bem posterior, o processo renderer travou enquanto o overlay estava em modo captura — setIgnoreMouseEvents(false), a janela agarrando todos os cliques — e sem uma UI viva para liberá-lo, a janela invisível que cobria a área de trabalho inteira ficou ali comendo cada clique na máquina do usuário até ele forçar o encerramento. Uma janela transparente presa em modo captura não é um botão morto; é uma armadilha de cliques do tamanho da área de trabalho toda. (Esse teve o seu próprio diagnóstico — veja porque é que o seu notetaker com IA para de gravar a meio da reunião, que o emparelha com um bug bem diferente na mesma release.) É a formulação mais afiada do problema todo: deixe o toggle preso de um jeito e sua UI está morta; preso do outro jeito, e a sua área de trabalho inteira está.
Três coisas que estas releases nos ensinaram
- Um overlay click-through é um contrato de duas faces, e as duas faces falham de formas diferentes. Transparente demais e seus próprios controles morrem; agarrado demais e você engole o input de outros apps. A maioria dos bugs de overlay é só um desses dois, e nomear em que face você está te diz onde olhar.
- O nível always-on-top mais alto não é um prêmio. Ficar acima de tudo significa ficar acima dos diálogos do sistema que você precisa que o usuário veja. Projete para quando o seu overlay deva educadamente dar um passo atrás — e faça o passo de volta para cima reversível em um
finally, porque uma cessão pela metade é pior do que nunca ceder. - A subtração vence a esperteza. A correção vencedora da v1.8.14 foi apagar chamadas a
focus()de que o overlay nunca precisou, achadas só depois de uma semana construindo e revertendo tratamento de arrasto personalizado. Quando uma funcionalidade não para de brigar com a plataforma, a primeira pergunta a fazer é se você consegue remover a coisa que está brigando.
Para o capítulo anterior da história v1, colocar a release na CI, duas vezes (v1.8.4); e para todo o arco, a anatomia de entregar software até a perfeição.