Steven
Steven9 min leestijd

De twee faalmodi van een click-through-overlay

GeekBye's venster zweeft boven alles en laat je klikken erdoorheen vallen — behalve waar het knoppen heeft. Dat is een tweezijdig contract, en v1.8.5 en v1.8.14 laten zien hoe het eruitziet als elke kant breekt: één release waarin de overlay een systeemdialoog opslokte, één waarin hij je toetsaanslagen stal. De winnende fix voor de tweede was code verwijderen.

Engineering
Electron
Desktop
GeekBye Releases
De twee faalmodi van een click-through-overlay

GeekBye's hoofdvenster is geen normaal venster. Het is frameloos, transparant en altijd bovenop — een glasplaat die over alles wat je doet zweeft, met een paar bedieningselementen erop getekend. De interessante engineering is niet dát het zweeft; het is dat je muis dwars door het glas heen hoort te gaan overal behalve bij de bedieningselementen. Klik op een knop op de overlay en de overlay krijgt de klik. Klik op de lege ruimte ernaast en de klik landt op je meeting-app eronder, alsof de overlay er helemaal niet was.

Dat is een contract met twee kanten: ontvang input precies waar er UI is, en wees volledig onzichtbaar voor input overal elders. Beide kanten moeten op elk moment standhouden, en elke bug in dit deel van de app is een schending van de ene of de andere kant. Twee releases, twee maanden uit elkaar, zijn de best denkbare illustratie — omdat elk een andere kant brak.

Het contract, mechanisch

De implementatie is een hook, useMousePassthrough, en hij is klein. Het venster is standaard click-through. Een globale mousemove-listener stelt bij elke beweging één vraag: staat de cursor boven een bedieningselement? Bedieningselementen zijn gemarkeerd met een data-ui-region-attribuut, dus de test is letterlijk target.closest('[data-ui-region]'). Boven een bedieningselement roept hij setIgnoreMouseEvents(false) aan en vangt het venster klikken op. Boven transparante ruimte roept hij setIgnoreMouseEvents(true, { forward: true }) aan en vallen klikken erdoorheen.

Dat forward: true is het dragende detail. Zodra een venster muis-events negeert, stopt het normaal gesproken helemaal met ze te ontvangen — inclusief de mousemove-events die de hook nodig heeft om te merken dat de cursor terug over een bedieningselement komt. forward: true zegt tegen Electron om bewegingen tóch naar het negerende venster te blijven doorsturen, zodat het zichzelf opnieuw kan activeren. Zonder dat zou de overlay één keer click-through worden en nooit meer terugkomen. De hook roept ook alleen over de IPC-grens aan bij toestands_overgangen_ — de cursor die de grens tussen UI en glas oversteekt — niet bij elke pixel beweging. Onthoud die optimalisatie; er zit een gat in, en v1.8.5 viel erin.

v1.8.5: wanneer de transparantiekant breekt

De transparantiekant van het contract is "wees onzichtbaar voor input overal waar geen UI is." v1.8.5 laat zien hoe het eruitziet wanneer de overlay te aanwezig is — wanneer hij input opvangt of blokkeert die hij had moeten doorlaten. De aanleiding waren macOS-permissiedialogen, en er stapelden zich twee afzonderlijke fouten op elkaar.

De overlay lag fysiek boven de dialoog. Een altijd-bovenop-venster heeft een niveau, en GeekBye's overlay zat op het hoogste dat macOS biedt — screen-saver. Dat is boven vrijwel alles, inclusief de eigen Microphone- en Screen Recording-permissiedialogen van het systeem. Dus tijdens onboarding, op precies het moment dat macOS om permissie vroeg, kon de dialoog onder de overlay renderen en onzichtbaar zijn. De fix, setPermissionMode, is alleen voor macOS en doet het niet-intuïtieve: hij verlaagt de overlay naar het floating-niveau voor de duur van een permissieverzoek, en herstelt hem daarna. En dat herstellen vereiste een specifieke truc, gedocumenteerd pal in de commit: macOS "doesn't always visually re-raise a window when setting the same level it was previously at," dus stapt de code het venster door een tussenliggend niveau — floating → status → screen-saver — om de window server te dwingen de stapelvolgorde opnieuw te berekenen. Je stelt niet het niveau in dat je wilt; je loopt ernaartoe.

De overlay at de klikken van de dialoog op. Onboarding tekent een donkere venstervullende achtergrond, en de passthrough-hook behandelt die als speciaal geval: zolang die achtergrond er staat, forceert hij click-capture op het hele venster zodat de wizard volledig interactief is. Maar "vang elke klik op" is precies verkeerd wanneer een systeemdialoog er een probeert te verzamelen. De fix haalt het markeringsattribuut van de achtergrond weg tijdens een permissieverzoek, zodat de hook uit de capture-modus valt en de knoppen van de dialoog hun klikken krijgen. De overlay houdt zijn eigen bedieningselementen interactief; hij doet alleen niet langer alsof het lege glas een knop is.

Het gat in de optimalisatie. Onthoud dat de hook alleen op overgangen reageert. Toen het venster de capture-modus verliet, draaide het de hovertest opnieuw — maar als je cursor op dat moment al op een bedieningselement stond, was het antwoord op "staan we boven UI?" al "ja," dus vuurde er geen overgang en werd click-through nooit uitgeschakeld. Het bedieningselement zag er levend uit en was dood. De fix (1b6eb20, geciteerd) stopt met de overgang te vertrouwen: "when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call." Wanneer je van modus wisselt, wacht je niet tot de volgende beweging je vertelt waar de cursor is — je kijkt.

En maak de afstand omkeerbaar. Het subtiele risico in dit alles is dat de overlay nu tijdelijk zijn z-volgorde en zijn click-capture opgeeft, en iets moet ze teruggeven. Als de herstelaanroep een fout gooit — een IPC-hapering, een venster dat midden in het verzoek wordt afgebroken — kon de app stranden met isRequestingPermission blijvend op true en een donkere achtergrond die je hele scherm verbergt, voorgoed. Dus het afsluitpad werd zo verpakt dat de state-reset "altijd wordt uitgevoerd," plus een veiligheidstimeout van meerdere minuten en een opschoning bij het vernietigen van het venster. De regel die daaruit volgt: elke keer dat een overlay input afstaat, moet het terug-afstaan in een finally staan, niet in een happy path.

v1.8.14: wanneer de opvangkant breekt

De andere kant van het contract is "ontvang input waar er UI is." v1.8.14 laat zien hoe het eruitziet wanneer de overlay te grijpgraag is — wanneer ermee interacteren je de input-focus kost van de app die je eigenlijk belangrijk vindt.

Het symptoom in de changelog is focus-stelen, en hier is de eerlijke versie, want de git-geschiedenis is leerzamer dan de release-notitie. De shortcut-handlers van de overlay — zichtbaarheid schakelen met Cmd+B, het chatvenster openen, erin scrollen — riepen elk mainWindow.focus() aan, en op macOS app.focus({ steal: true }), om ervoor te zorgen dat de overlay vooraan stond wanneer je hem triggerde. Het probleem is dat het focussen van de overlay deactiveert wat er daarvoor vooraan stond — je meeting, je editor — zodat een toetsaanslag die voor hen bedoeld was nergens kon landen, of op de verkeerde plek. De overlay greep iets dat het eigenlijk niet nodig had.

Dat laatste is de hele les, en het team ontdekte het op de harde manier. De branch die v1.8.14 werd, probeerde eerst specifiek slepen op te lossen — want slepen heeft zijn eigen prachtige versie van deze bug: wanneer je een schermvullende overlay sleept, beweegt het venster onder een stilstaande cursor, dus denkt het hover-model dat de aanwijzer het bedieningselement "verliet" en klapt het midden in het slepen om naar click-through, waardoor het slepen wegvalt. Dus voegden ze een dragLock toe. Toen probeerden ze het hele venster niet-focusseerbaar te maken — wat typen en slepen brak, dus herstelden ze de focusseerbaarheid, versmalden vervolgens de niet-focusseerbaarheid tot alleen tekstvelden, en trokken toen de schakelaar er weer uit. Ze bouwden een custom drag-IPC om Electrons native drag te vervangen. Ze voegden blur-bij-onverwachte-focus toe. Een week hiervan.

Toen verwijderden ze bijna alles. De commit die shipte draaide de custom drag, de focusseerbaar-schakelaar en de blur-handler terug, en behield precies één idee: stop met focus() aanroepen. Het inzicht, opgeschreven in een code-comment, is dat de overlay om te beginnen nooit OS-focus nodig had — "IPC messages (webContents.send) work without window focus." De overlay stuurt zijn eigen renderer direct aan; focus was pure collaterale schade. De shipbare fix was niet de slimme custom-drag-herschrijving die in een dag was gebouwd. Het was het weglaten van de focus-aanroepen die er nooit hadden moeten zijn. (Het is de moeite waard om het duidelijk te zeggen: de changelog zegt "while dragging it," maar wat er daadwerkelijk shipte was het verwijderen van focus-stelen uit de keyboard-shortcut-handlers — de drag-specifieke machinerie werd verkend en dezelfde week teruggedraaid.)

De inzet, letterlijk gemaakt

Wil je bewijs dat dit contract niet academisch is, kijk dan naar wat er maanden later gebeurde toen de overlay aan de verkeerde kant ervan vast kwam te zitten. In een veel latere release crashte het renderer-proces terwijl de overlay in de capture-modus stond — setIgnoreMouseEvents(false), venster dat alle klikken opgrijpt — en zonder levende UI om hem los te laten, bleef het onzichtbare desktopvullende venster daar elke klik op de machine van de gebruiker opeten totdat die geforceerd afsloot. Een transparant venster dat vastzit in de capture-modus is geen dode knop; het is een desktopbrede klikval. (Die kreeg zijn eigen diagnose — zie wanneer je AI-notulist midden in een meeting stopt met opnemen, die hem koppelt aan een heel andere bug in dezelfde release.) Het is de scherpste formulering van het hele probleem: krijg de schakelaar op de ene manier vast en je UI is dood; op de andere manier vast en je hele desktop is dat.

Drie dingen die deze releases ons leerden

  1. Een click-through-overlay is een tweezijdig contract, en beide kanten falen anders. Te transparant en je eigen bedieningselementen gaan dood; te grijpgraag en je slokt de input van andere apps op. De meeste overlay-bugs zijn gewoon een van die twee, en benoemen aan welke kant je zit vertelt je waar je moet zoeken.
  2. Het hoogste altijd-bovenop-niveau is geen prijs. Boven alles zitten betekent boven de systeemdialogen zitten die je de gebruiker moet laten zien. Ontwerp voor het moment waarop je overlay beleefd een stapje terug moet doen — en maak weer omhoogstappen omkeerbaar in een finally, want een half voltooide afstand is erger dan nooit afstaan.
  3. Weglaten wint van slimmigheid. De winnende fix van v1.8.14 was het verwijderen van focus()-aanroepen die de overlay nooit nodig had, pas gevonden na een week bouwen en terugdraaien van custom drag-afhandeling. Wanneer een feature het platform blijft bevechten, is de eerste vraag om te stellen of je het ding dat het gevecht voert kunt weghalen.

Voor het vorige hoofdstuk in het v1-verhaal, de release in CI zetten, twee keer (v1.8.4); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.