Steven
Steven10 min de lecture

Les deux modes de défaillance d'un overlay click-through

La fenêtre de GeekBye flotte au-dessus de tout et laisse vos clics la traverser — sauf là où elle a des boutons. C'est un contrat à deux faces, et la v1.8.5 et la v1.8.14 sont ce que ça donne quand chaque face casse : une release où l'overlay a avalé une boîte de dialogue système, une autre où il a volé vos frappes clavier. Le correctif gagnant pour la seconde a été de supprimer du code.

Ingénierie
Electron
Desktop
Versions de GeekBye
Les deux modes de défaillance d'un overlay click-through

La fenêtre principale de GeekBye n'est pas une fenêtre normale. Elle est sans cadre, transparente et toujours au-dessus — une plaque de verre flottant au-dessus de ce que vous faites, avec quelques contrôles peints dessus. L'ingénierie intéressante n'est pas qu'elle flotte ; c'est que votre souris est censée traverser le verre de part en part partout sauf sur les contrôles. Cliquez un bouton sur l'overlay et l'overlay reçoit le clic. Cliquez l'espace vide à côté et le clic atterrit sur votre app de réunion en dessous, comme si l'overlay n'était pas là du tout.

C'est un contrat à deux faces : recevoir l'input exactement là où il y a de l'UI, et être complètement invisible à l'input partout ailleurs. Les deux faces doivent tenir à chaque instant, et chaque bug dans cette partie de l'app est une violation de l'une ou l'autre face. Deux releases, à deux mois d'écart, en sont l'illustration la plus nette possible — parce que chacune a cassé une face différente.

Le contrat, mécaniquement

L'implémentation est un hook, useMousePassthrough, et il est petit. La fenêtre est click-through par défaut. Un listener global de mousemove pose une seule question à chaque mouvement : le curseur est-il sur un contrôle ? Les contrôles sont marqués d'un attribut data-ui-region, si bien que le test est littéralement target.closest('[data-ui-region]'). Au-dessus d'un contrôle, il appelle setIgnoreMouseEvents(false) et la fenêtre capture les clics. Au-dessus d'un espace transparent, il appelle setIgnoreMouseEvents(true, { forward: true }) et les clics passent à travers.

Ce forward: true est le détail qui porte tout le poids. Une fois qu'une fenêtre ignore les événements de souris, elle cesse normalement de les recevoir entièrement — y compris les événements mousemove dont le hook a besoin pour remarquer le curseur qui revient sur un contrôle. forward: true dit à Electron de continuer à transmettre les mouvements à la fenêtre qui les ignore quand même, pour qu'elle puisse se réarmer. Sans lui, l'overlay passerait en click-through une fois et ne reviendrait jamais. Le hook ne franchit aussi la frontière IPC que sur les transitions d'état — le curseur franchissant la limite entre UI et verre — pas à chaque pixel de mouvement. Retenez cette optimisation ; elle a un trou, et la v1.8.5 est tombée dedans.

v1.8.5 : quand la face transparence casse

La face transparence du contrat, c'est « être invisible à l'input partout où il n'y a pas d'UI ». La v1.8.5 est ce que ça donne quand l'overlay est trop présent — quand il capture ou bloque de l'input qu'il aurait dû laisser passer. Le déclencheur était les boîtes de dialogue de permission de macOS, et il y avait deux défaillances distinctes empilées l'une sur l'autre.

L'overlay était physiquement au-dessus de la boîte de dialogue. Une fenêtre always-on-top a un niveau, et l'overlay de GeekBye siégeait au plus haut que macOS propose — screen-saver. C'est au-dessus de presque tout, y compris les propres boîtes de dialogue de permission Microphone et Screen Recording du système. Alors pendant l'onboarding, au moment exact où macOS demandait la permission, la boîte de dialogue pouvait se rendre en dessous de l'overlay et être invisible. Le correctif, setPermissionMode, est macOS seulement et fait la chose contre-intuitive : il abaisse l'overlay au niveau floating le temps d'une demande de permission, puis le restaure ensuite. Et le restaurer nécessitait une astuce précise, documentée à même le commit : macOS « doesn't always visually re-raise a window when setting the same level it was previously at », alors le code fait passer la fenêtre par un niveau intermédiaire — floating → status → screen-saver — pour forcer le serveur de fenêtres à recalculer l'ordre d'empilement. Vous ne fixez pas le niveau que vous voulez ; vous marchez jusqu'à lui.

L'overlay mangeait les clics de la boîte de dialogue. L'onboarding peint un fond sombre couvrant toute la fenêtre, et le hook de passthrough en fait un cas spécial : tant que ce fond est là, il force la capture des clics sur toute la fenêtre pour que l'assistant soit pleinement interactif. Mais « capturer chaque clic » est exactement faux quand une boîte de dialogue système essaie d'en recueillir un. Le correctif retire l'attribut marqueur du fond pendant une demande de permission, si bien que le hook sort du mode capture et que les boutons de la boîte de dialogue reçoivent leurs clics. L'overlay garde ses propres contrôles interactifs ; il arrête juste de faire semblant que le verre vide est un bouton.

Le trou dans l'optimisation. Rappelez-vous que le hook n'agit que sur les transitions. Quand la fenêtre quittait le mode capture, elle relançait le test de survol — mais si votre curseur était déjà posé sur un contrôle à ce moment-là, la réponse à « sommes-nous sur de l'UI ? » était déjà « oui », donc aucune transition ne se déclenchait, et le click-through n'était jamais désactivé. Le contrôle avait l'air vivant et était mort. Le correctif (1b6eb20, cité) cesse de faire confiance à la transition : « when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call ». Quand vous changez de mode, vous n'attendez pas que le prochain mouvement vous dise où est le curseur — vous regardez.

Et rendez la cession réversible. Le risque subtil dans tout ça, c'est que l'overlay renonce désormais temporairement à son z-order et à sa capture de clics, et que quelque chose doit les lui rendre. Si l'appel de restauration lève une exception — un hoquet d'IPC, une fenêtre démontée en pleine demande — l'app pourrait rester échouée avec isRequestingPermission bloqué à true et un fond sombre cachant tout votre écran, pour toujours. Alors le chemin de sortie a été enveloppé pour que la réinitialisation d'état « s'exécute toujours », plus un timeout de sécurité de plusieurs minutes et un nettoyage à la destruction de la fenêtre. La règle qui en découle : chaque fois qu'un overlay cède de l'input, le dé-céder doit être dans un finally, pas dans un chemin heureux.

v1.8.14 : quand la face capture casse

L'autre face du contrat, c'est « recevoir l'input là où il y a de l'UI ». La v1.8.14 est ce que ça donne quand l'overlay est trop accapareur — quand interagir avec lui vous coûte le focus d'input de l'app qui vous importe vraiment.

Le symptôme dans le changelog est le vol de focus, et voici la version honnête, parce que l'historique git est plus instructif que la note de release. Les gestionnaires de raccourcis de l'overlay — basculer la visibilité sur Cmd+B, ouvrir la fenêtre de chat, la faire défiler — appelaient chacun mainWindow.focus(), et sur macOS app.focus({ steal: true }), pour s'assurer que l'overlay était au premier plan quand vous le déclenchiez. Le problème, c'est que donner le focus à l'overlay désactive ce qui était au premier plan avant — votre réunion, votre éditeur — si bien qu'une frappe destinée à eux pouvait atterrir nulle part, ou au mauvais endroit. L'overlay accaparait une chose dont il n'avait pas réellement besoin.

Cette dernière partie est toute la leçon, et l'équipe l'a découverte à la dure. La branche devenue la v1.8.14 a d'abord essayé de résoudre le drag spécifiquement — parce que le drag a sa propre belle version de ce bug : quand vous faites glisser un overlay plein écran, la fenêtre bouge sous un curseur immobile, si bien que le modèle de survol croit que le pointeur a « quitté » le contrôle et bascule en click-through en plein drag, lâchant le drag. Alors ils ont ajouté un dragLock. Puis ils ont essayé de rendre toute la fenêtre non focusable — ce qui cassait la saisie et le drag, alors ils ont restauré la focusabilité, puis restreint la non-focusabilité aux seuls champs de texte, puis arraché le toggle. Ils ont construit une IPC de drag sur mesure pour remplacer le drag natif d'Electron. Ils ont ajouté un blur-sur-focus-inattendu. Une semaine de ça.

Puis ils ont presque tout supprimé. Le commit qui a été livré a reverté le drag sur mesure, le toggle focusable et le gestionnaire de blur, et n'a gardé qu'une seule idée : arrêter d'appeler focus(). La prise de conscience, écrite dans un commentaire de code, c'est que l'overlay n'avait jamais eu besoin du focus de l'OS au départ — « IPC messages (webContents.send) work without window focus ». L'overlay pilote son propre renderer directement ; le focus n'était que dommage collatéral. Le correctif livrable n'était pas la maligne réécriture du drag sur mesure bâtie en un jour. C'était soustraire les appels à focus() qui n'auraient pas dû être là. (À dire clairement : le changelog dit « while dragging it », mais ce qui a réellement été livré, c'est le retrait du vol de focus des gestionnaires de raccourcis clavier — la machinerie spécifique au drag a été explorée et revertie la même semaine.)

Ce qui est en jeu, rendu littéral

Si vous voulez la preuve que ce contrat n'est pas académique, regardez ce qui est arrivé des mois plus tard quand l'overlay est resté coincé du mauvais côté. Dans une release bien plus tardive, le processus renderer a planté pendant que l'overlay était en mode capture — setIgnoreMouseEvents(false), la fenêtre agrippant tous les clics — et sans UI vivante pour le relâcher, la fenêtre invisible couvrant tout le bureau est restée là à manger chaque clic sur la machine de l'utilisateur jusqu'à ce qu'il force la fermeture. Une fenêtre transparente coincée en mode capture n'est pas un bouton mort ; c'est un piège à clics à l'échelle de tout le bureau. (Celui-là a eu son propre diagnostic — voir pourquoi votre notetaker IA arrête d'enregistrer en pleine réunion, qui l'associe à un bug très différent dans la même release.) C'est la formulation la plus tranchante de tout le problème : coincez le toggle d'un côté et votre UI est morte ; coincé de l'autre côté, et c'est tout votre bureau qui l'est.

Trois choses que ces releases nous ont apprises

  1. Un overlay click-through est un contrat à deux faces, et les deux faces échouent différemment. Trop transparent et vos propres contrôles meurent ; trop accapareur et vous avalez l'input des autres apps. La plupart des bugs d'overlay ne sont que l'un de ces deux, et nommer la face où vous êtes vous dit où regarder.
  2. Le niveau always-on-top le plus haut n'est pas un prix. Siéger au-dessus de tout signifie siéger au-dessus des boîtes de dialogue système que vous avez besoin que l'utilisateur voie. Concevez pour le moment où votre overlay devrait poliment s'effacer — et rendez le fait de remonter réversible dans un finally, parce qu'une cession à moitié achevée est pire que ne jamais céder.
  3. La soustraction bat l'astuce. Le correctif gagnant de la v1.8.14 a été de supprimer des appels à focus() dont l'overlay n'avait jamais eu besoin, trouvés seulement après une semaine à construire et reverter un drag sur mesure. Quand une fonctionnalité n'arrête pas de se battre avec la plateforme, la première question à poser est de savoir si vous pouvez retirer la chose qui se bat.

Pour le chapitre précédent de l'histoire v1, mettre la release dans la CI, deux fois (v1.8.4) ; et pour tout l'arc, l'anatomie de livrer un logiciel jusqu'à la perfection.

Articles Connexes

De l'OCR aux pixels sans perdre le fallback
Steven
Steven9 min de lecture

De l'OCR aux pixels sans perdre le fallback

GeekBye a cessé de lire vos captures d'écran avec l'OCR et s'est mis à envoyer l'image réelle à des modèles de vision — ce qui est la partie facile. La partie instructive, c'est l'optimisation qui a sauté l'OCR entièrement pour les utilisateurs vision, a cassé sans bruit le fallback vers les modèles sans vision, et a dû être défaite jusqu'à une version où le filet de sécurité tourne en parallèle et ne coûte rien.

Ingénierie
AI
Desktop
Mettre la release dans la CI, deux fois
Steven
Steven9 min de lecture

Mettre la release dans la CI, deux fois

GeekBye v1.8.4 a déplacé les builds de release vers la CI pour macOS et Windows. Ce que le changelog ne dit pas, c'est qu'on avait déjà tenté le coup une fois, qu'on l'a supprimé un mois plus tard à cause du coût des runners, et qu'on ne l'a fait tenir qu'à la deuxième fois — parce qu'à ce moment-là le script de release marchait déjà à la main.

Ingénierie
CI/CD
Desktop
Livrer trente langues sans filet de sécurité
Steven
Steven9 min de lecture

Livrer trente langues sans filet de sécurité

GeekBye v1.8.3 a câblé react-i18next et traduit toute l'app en 30 langues. La partie librairie était de la routine. L'intéressant, c'est ce qui manquait : pas de contrôle de parité des clés ni d'extracteur de chaînes — si bien que de l'anglais codé en dur a fui, une coquille danoise est passée en prod, et le changelog n'arrivait pas à se mettre d'accord sur si l'app parlait 28, 29 ou 30 langues.

Ingénierie
i18n
Desktop