Steven
Steven8 min läsning

De två fellägena hos ett klick-genom-överlägg

GeekByes fönster svävar ovanpå allt och låter dina klick passera genom det — utom där det har knappar. Det är ett tvåsidigt kontrakt, och v1.8.5 och v1.8.14 är hur det ser ut när vardera sidan går sönder: en release där överlägget svalde en systemdialog, en där det stal dina tangenttryckningar. Den vinnande fixen för den andra var att radera kod.

Utveckling
Electron
Desktop
GeekBye-releaser
De två fellägena hos ett klick-genom-överlägg

GeekByes huvudfönster är inte ett normalt fönster. Det är ramlöst, transparent och alltid överst — en glasskiva som svävar över vad du än gör, med några kontroller målade på sig. Den intressanta ingenjörskonsten är inte att det svävar; det är att din mus ska gå rakt genom glaset överallt utom på kontrollerna. Klicka på en knapp på överlägget och överlägget får klicket. Klicka på det tomma utrymmet bredvid och klicket landar i din mötesapp under, som om överlägget inte fanns där alls.

Det är ett kontrakt med två sidor: ta emot indata exakt där det finns UI, och var helt osynligt för indata överallt annars. Båda sidorna måste hålla i varje ögonblick, och varje bugg i den här delen av appen är en kränkning av den ena eller andra sidan. Två releaser, två månader isär, är den renast möjliga illustrationen — för att var och en bröt en olika sida.

Kontraktet, mekaniskt

Implementationen är en hook, useMousePassthrough, och den är liten. Fönstret är klick-genom som standard. En global mousemove-lyssnare ställer en fråga vid varje rörelse: är markören över en kontroll? Kontroller är taggade med ett data-ui-region-attribut, så testet är bokstavligen target.closest('[data-ui-region]'). Över en kontroll anropar den setIgnoreMouseEvents(false) och fönstret fångar klick. Över transparent utrymme anropar den setIgnoreMouseEvents(true, { forward: true }) och klicken faller igenom.

Det där forward: true är den bärande detaljen. När ett fönster väl ignorerar mushändelser slutar det normalt ta emot dem helt — inklusive de mousemove-händelser som hooken behöver för att märka markören komma tillbaka över en kontroll. forward: true säger åt Electron att fortsätta vidarebefordra rörelser till det ignorerande fönstret ändå, så att det kan ladda om sig självt. Utan det skulle överlägget gå klick-genom en gång och aldrig komma tillbaka. Hooken anropar också bara över IPC-gränsen vid tillstånds_övergångar_ — markören som korsar gränsen mellan UI och glas — inte vid varje pixel av rörelse. Kom ihåg den optimeringen; den har ett hål, och v1.8.5 föll i det.

v1.8.5: när transparenssidan går sönder

Kontraktets transparensside är "var osynligt för indata överallt där det inte finns UI." v1.8.5 är hur det ser ut när överlägget är för närvarande — när det fångar eller blockerar indata som det borde ha släppt förbi. Utlösaren var macOS behörighetsdialoger, och det fanns två distinkta fel staplade på varandra.

Överlägget var fysiskt ovanför dialogen. Ett alltid-överst-fönster har en nivå, och GeekByes överlägg satt på den högsta macOS erbjuder — screen-saver. Det är ovanför nästan allt, inklusive systemets egna Microphone- och Screen Recording-behörighetsdialoger. Så under onboarding, i exakt det ögonblick macOS bad om behörighet, kunde dialogen renderas under överlägget och vara osynlig. Fixen, setPermissionMode, är endast macOS och gör det ointuitiva: den sänker överlägget till floating-nivån under en behörighetsbegäran, och återställer det sedan efteråt. Och att återställa det krävde ett specifikt trick, dokumenterat direkt i commiten: macOS "doesn't always visually re-raise a window when setting the same level it was previously at," så koden stegar fönstret genom en mellannivå — floating → status → screen-saver — för att tvinga fönsterservern att räkna om staplingsordningen. Du sätter inte nivån du vill ha; du går till den.

Överlägget åt dialogens klick. Onboarding målar en mörk helfönsterbakgrund, och passthrough-hooken specialbehandlar den: medan den bakgrunden är uppe tvingar den klick-fångst på hela fönstret så att guiden är fullt interagerbar. Men "fånga varje klick" är precis fel när en systemdialog försöker samla in ett. Fixen tar bort bakgrundens markörattribut under en behörighetsbegäran, så att hooken faller ur fångstläget och dialogens knappar får sina klick. Överlägget håller sina egna kontroller interagerbara; det slutar bara låtsas att det tomma glaset är en knapp.

Hålet i optimeringen. Kom ihåg att hooken bara agerar på övergångar. När fönstret lämnade fångstläget körde det om hovertestet — men om din markör redan satt på en kontroll i det ögonblicket var svaret på "är vi över UI?" redan "ja," så ingen övergång avfyrades, och klick-genom inaktiverades aldrig. Kontrollen såg levande ut och var död. Fixen (1b6eb20, citerad) slutar lita på övergången: "when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call." När du byter läge väntar du inte på att nästa rörelse ska berätta var markören är — du tittar.

Och gör avståendet återställbart. Den subtila risken i allt detta är att överlägget nu tillfälligt ger upp sin z-ordning och sin klick-fångst, och något måste ge dem tillbaka. Om återställningsanropet kastar — en IPC-hicka, ett fönster nedrivet mitt i begäran — kunde appen bli strandad med isRequestingPermission fast på true och en mörk bakgrund som döljer hela din skärm, permanent. Så utgångsvägen lindades in så att tillståndsåterställningen "alltid körs," plus en flerminuters säkerhetstimeout och en städning vid fönsterförstörelse. Regeln som faller ut: varje gång ett överlägg avstår indata måste av-avståendet ligga i ett finally, inte i en lyckad väg.

v1.8.14: när fångstsidan går sönder

Kontraktets andra sida är "ta emot indata där det finns UI." v1.8.14 är hur det ser ut när överlägget är för närigt — när att interagera med det kostar dig indatafokus för appen du faktiskt bryr dig om.

Symptomet i ändringsloggen är fokusstöld, och här är den ärliga versionen, för att git-historiken är mer lärorik än releasenoten. Överläggets genvägshanterare — växla synlighet på Cmd+B, öppna chattfönstret, scrolla det — anropade var och en förr mainWindow.focus(), och på macOS app.focus({ steal: true }), för att säkerställa att överlägget var främst när du utlöste det. Problemet är att fokusera överlägget deaktiverar vad som än var främst innan — ditt möte, din editor — så en tangenttryckning avsedd för dem kunde landa ingenstans, eller på fel plats. Överlägget grep en sak det inte faktiskt behövde.

Den sista delen är hela lärdomen, och teamet fann den den hårda vägen. Grenen som blev v1.8.14 försökte först lösa specifikt dragning — för dragning har sin egen vackra version av den här buggen: när du drar ett helskärmsöverlägg rör sig fönstret under en stillastående markör, så hovermodellen tror att pekaren "lämnade" kontrollen och slår om till klick-genom mitt i dragningen, tappar dragningen. Så de lade till ett dragLock. Sedan försökte de göra hela fönstret icke-fokuserbart — vilket bröt skrivandet och dragningen, så de återställde fokuserbarheten, sedan smalnade av icke-fokuserbarheten till bara textinmatningar, sedan slet ut växlingen. De byggde ett eget drag-IPC för att ersätta Electrons nativa dragning. De lade till oskärpa-vid-oväntat-fokus. En vecka av detta.

Sedan raderade de nästan allt. Commiten som skeppades återställde det egna draget, fokuserbarhetsväxlingen och oskärpehanteraren, och behöll exakt en idé: sluta anropa focus(). Insikten, nedskriven i en kodkommentar, är att överlägget aldrig behövde OS-fokus från början — "IPC messages (webContents.send) work without window focus." Överlägget driver sin egen renderare direkt; fokus var ren kollateralskada. Den skeppbara fixen var inte den smarta egen-drag-omskrivningen byggd på en dag. Det var att subtrahera de fokusanrop som inte borde ha varit där. (Värt att säga rent ut: ändringsloggen säger "while dragging it," men det som faktiskt skeppades var att ta bort fokusstöld från tangentbordsgenvägshanterarna — det drag-specifika maskineriet utforskades och återställdes samma vecka.)

Insatserna, gjorda bokstavliga

Om du vill ha bevis på att det här kontraktet inte är akademiskt, titta på vad som hände månader senare när överlägget fastnade på fel sida av det. I en mycket senare release kraschade renderarprocessen medan överlägget var i fångstläge — setIgnoreMouseEvents(false), fönstret griper alla klick — och utan något levande UI att släppa det satt det osynliga helskrivbordsfönstret där och åt varje klick på användarens maskin tills de tvångsavslutade. Ett transparent fönster fast i fångstläge är inte en död knapp; det är en skrivbordsomfattande klickfälla. (Den fick sin egen diagnos — se när din AI-antecknare slutar spela in mitt i mötet, som parar ihop den med en väldigt annorlunda bugg i samma release.) Det är det skarpaste uttrycket för hela problemet: få växeln att fastna åt ena hållet och ditt UI är dött; fast åt andra hållet och hela ditt skrivbord är det.

Tre saker dessa releaser lärde oss

  1. Ett klick-genom-överlägg är ett tvåsidigt kontrakt, och båda sidorna felar olika. För transparent och dina egna kontroller går döda; för närigt och du sväljer andra appars indata. De flesta överläggsbuggar är bara en av dessa två, och att namnge vilken sida du är på talar om var du ska titta.
  2. Den högsta alltid-överst-nivån är inget pris. Att sitta ovanför allt betyder att sitta ovanför de systemdialoger du behöver att användaren ser. Designa för när ditt överlägg artigt bör stiga ned — och gör att stiga upp igen återställbart i ett finally, för ett halvfärdigt avstående är värre än att aldrig avstå.
  3. Subtraktion slår smarthet. Den vinnande v1.8.14-fixen var att radera focus()-anrop som överlägget aldrig behövde, funna först efter en vecka av att bygga och återställa egen draghantering. När en funktion fortsätter slåss mot plattformen är frågan att ställa först om du kan ta bort det som slåss.

För det föregående kapitlet i v1-historien, att lägga releasen i CI, två gånger (v1.8.4); och för hela bågen, anatomin i att skeppa mjukvara till perfektion.