
Cele două moduri de eșec ale unui overlay click-through
Fereastra GeekBye plutește peste tot și lasă click-urile tale să treacă prin ea — cu excepția locurilor unde are butoane. Ăsta e un contract cu două fețe, iar v1.8.5 și v1.8.14 arată cum e când se rupe fiecare față: o lansare în care overlay-ul a înghițit un dialog de sistem, una în care ți-a furat tastele. Fix-ul câștigător pentru a doua a fost ștergerea de cod.
Fereastra principală a GeekBye nu e o fereastră normală. E fără ramă, transparentă și mereu deasupra — o foaie de sticlă care plutește peste orice faci, cu câteva controale pictate pe ea. Partea interesantă din punct de vedere ingineresc nu e că plutește; e că mouse-ul tău ar trebui să treacă drept prin sticlă peste tot, cu excepția controalelor. Dai click pe un buton de pe overlay și overlay-ul primește click-ul. Dai click pe spațiul gol de lângă el și click-ul aterizează pe aplicația ta de meeting de dedesubt, ca și cum overlay-ul n-ar fi deloc acolo.
Ăsta e un contract cu două fețe: primește input exact acolo unde există UI și fii complet invizibil la input peste tot în rest. Ambele fețe trebuie să reziste în orice moment, iar fiecare bug din această parte a aplicației e o încălcare a uneia sau alteia dintre fețe. Două lansări, la două luni distanță, sunt cea mai curată ilustrare posibilă — pentru că fiecare a rupt o față diferită.
Contractul, mecanic
Implementarea e un hook, useMousePassthrough, și e mic. Fereastra e click-through implicit. Un listener global mousemove pune o singură întrebare la fiecare mișcare: e cursorul deasupra unui control? Controalele sunt marcate cu un atribut data-ui-region, așa că testul e literalmente target.closest('[data-ui-region]'). Deasupra unui control, apelează setIgnoreMouseEvents(false) și fereastra captează click-urile. Deasupra spațiului transparent, apelează setIgnoreMouseEvents(true, { forward: true }) și click-urile trec prin.
Acel forward: true e detaliul pe care se sprijină totul. Odată ce o fereastră ignoră evenimentele de mouse, în mod normal încetează să le mai primească complet — inclusiv evenimentele mousemove de care hook-ul are nevoie ca să observe cursorul revenind înapoi deasupra unui control. forward: true îi spune lui Electron să continue totuși să trimită mișcările către fereastra care ignoră, ca ea să se poată reînarma. Fără el, overlay-ul ar deveni click-through o dată și nu s-ar mai întoarce niciodată. În plus, hook-ul apelează peste granița IPC doar la tranzițiile de stare — cursorul trecând granița dintre UI și sticlă — nu la fiecare pixel de mișcare. Ține minte optimizarea asta; are o gaură, iar v1.8.5 a căzut în ea.
v1.8.5: când se rupe fața transparenței
Fața de transparență a contractului e „fii invizibil la input peste tot unde nu există UI." v1.8.5 arată cum e când overlay-ul e prea prezent — când captează sau blochează input pe care ar fi trebuit să-l lase să treacă. Declanșatorul au fost dialogurile de permisiuni macOS, și existau două eșecuri distincte suprapuse unul peste altul.
Overlay-ul era fizic deasupra dialogului. O fereastră always-on-top are un nivel, iar overlay-ul GeekBye stătea la cel mai înalt pe care-l oferă macOS — screen-saver. Ăsta e deasupra aproape a tot, inclusiv a propriilor dialoguri de permisiuni Microphone și Screen Recording ale sistemului. Așa că în timpul onboarding-ului, exact în momentul în care macOS cerea permisiunea, dialogul putea fi randat dedesubtul overlay-ului și să fie invizibil. Fix-ul, setPermissionMode, e doar pentru macOS și face lucrul contraintuitiv: coboară overlay-ul la nivelul floating pe durata unei cereri de permisiune, apoi îl restaurează după aceea. Iar restaurarea lui avea nevoie de un truc anume, documentat chiar în commit: macOS "doesn't always visually re-raise a window when setting the same level it was previously at," așa că codul trece fereastra printr-un nivel intermediar — floating → status → screen-saver — ca să forțeze serverul de ferestre să recalculeze ordinea de stivuire. Nu setezi nivelul pe care-l vrei; te apropii de el pas cu pas.
Overlay-ul mânca click-urile dialogului. Onboarding-ul pictează un fundal întunecat pe toată fereastra, iar hook-ul de passthrough îl tratează special: cât timp acel fundal e ridicat, forțează captarea click-urilor pe toată fereastra, ca wizard-ul să fie complet interactiv. Dar „captează fiecare click" e exact greșit când un dialog de sistem încearcă să adune unul. Fix-ul elimină atributul-marcaj al fundalului în timpul unei cereri de permisiune, așa că hook-ul iese din modul de captare și butoanele dialogului își primesc click-urile. Overlay-ul își păstrează propriile controale interactive; pur și simplu încetează să mai pretindă că sticla goală e un buton.
Gaura din optimizare. Ține minte că hook-ul acționează doar la tranziții. Când fereastra a ieșit din modul de captare, a rulat din nou testul de hover — dar dacă la acel moment cursorul tău stătea deja pe un control, răspunsul la „suntem deasupra UI?" era deja „da," așa că nu s-a declanșat nicio tranziție, iar click-through-ul n-a fost niciodată dezactivat. Controlul părea viu și era mort. Fix-ul (1b6eb20, citat) încetează să mai aibă încredere în tranziție: "when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call." Când schimbi modurile, nu aștepți ca următoarea mișcare să-ți spună unde e cursorul — te uiți.
Și fă cedarea reversibilă. Riscul subtil în toate astea e că overlay-ul renunță acum temporar la z-order-ul și la captarea de click-uri, și ceva trebuie să i le dea înapoi. Dacă apelul de restaurare aruncă o eroare — un sughiț IPC, o fereastră distrusă în mijlocul cererii — aplicația ar putea rămâne blocată cu isRequestingPermission înțepenit pe true și un fundal întunecat care-ți ascunde tot ecranul, permanent. Așa că traseul de ieșire a fost învelit astfel încât resetarea stării „se execută întotdeauna," plus un timeout de siguranță de câteva minute și o curățare la distrugerea ferestrei. Regula care rezultă: de fiecare dată când un overlay cedează input, ne-cedarea trebuie să fie într-un finally, nu pe traseul fericit.
v1.8.14: când se rupe fața captării
Cealaltă față a contractului e „primește input acolo unde există UI." v1.8.14 arată cum e când overlay-ul e prea acaparator — când interacțiunea cu el te costă focusul de input al aplicației de care chiar îți pasă.
Simptomul din changelog e furtul de focus, și iată versiunea sinceră, pentru că istoricul git e mai instructiv decât nota de lansare. Handler-ele de scurtături ale overlay-ului — comută vizibilitatea pe Cmd+B, deschide fereastra de chat, o derulează — apelau fiecare mainWindow.focus(), iar pe macOS app.focus({ steal: true }), ca să se asigure că overlay-ul era în față când îl declanșai. Problema e că focusarea overlay-ului dezactivează orice era în față înainte — meeting-ul tău, editorul tău — așa că o tastă menită lor putea ateriza nicăieri, sau în locul greșit. Overlay-ul apuca un lucru de care de fapt nu avea nevoie.
Ultima parte e toată lecția, iar echipa a descoperit-o pe calea grea. Ramura care a devenit v1.8.14 a încercat mai întâi să rezolve anume dragging-ul — pentru că dragging-ul are propria lui versiune frumoasă a acestui bug: când tragi un overlay pe tot ecranul, fereastra se mișcă sub un cursor staționar, așa că modelul de hover crede că pointer-ul „a părăsit" controlul și comută la click-through în mijlocul drag-ului, scăpând drag-ul. Așa că au adăugat un dragLock. Apoi au încercat să facă toată fereastra non-focusabilă — ceea ce a stricat tastarea și dragging-ul, așa că au restaurat focusabilitatea, apoi au restrâns non-focusabilitatea doar la input-urile de text, apoi au smuls afară comutatorul. Au construit un IPC de drag custom ca să înlocuiască drag-ul nativ al lui Electron. Au adăugat blur-la-focus-neașteptat. O săptămână din asta.
Apoi au șters aproape tot. Commit-ul care a fost livrat a revenit asupra drag-ului custom, a comutatorului de focusabilitate și a handler-ului de blur, și a păstrat exact o singură idee: oprește apelurile focus(). Realizarea, scrisă într-un comentariu de cod, e că overlay-ul n-a avut niciodată nevoie de focus-ul OS-ului de la bun început — "IPC messages (webContents.send) work without window focus." Overlay-ul își conduce direct propriul renderer; focus-ul era pur și simplu daună colaterală. Fix-ul livrabil n-a fost rescrierea inteligentă de drag custom construită într-o zi. A fost scăderea apelurilor de focus care n-ar fi trebuit să fie acolo. (Merită spus pe șleau: changelog-ul zice "while dragging it," dar ce s-a livrat de fapt a fost eliminarea furtului de focus din handler-ele de scurtături de tastatură — mașinăria specifică drag-ului a fost explorată și revenită în aceeași săptămână.)
Miza, făcută literală
Dacă vrei dovada că acest contract nu e academic, uită-te la ce s-a întâmplat luni mai târziu, când overlay-ul a rămas blocat pe fața greșită a lui. Într-o lansare mult mai târzie, procesul de renderer s-a prăbușit în timp ce overlay-ul era în modul de captare — setIgnoreMouseEvents(false), fereastra apucând toate click-urile — și, fără niciun UI viu care să-l elibereze, fereastra invizibilă cât tot desktopul stătea acolo mâncând fiecare click de pe mașina utilizatorului până când acesta a forțat închiderea. O fereastră transparentă blocată în modul de captare nu e un buton mort; e o capcană de click-uri cât tot desktopul. (Aceea și-a primit propriul diagnostic — vezi când aplicația ta AI de notițe se oprește din înregistrat în mijlocul unui meeting, care o pune în pereche cu un bug foarte diferit din aceeași lansare.) E cea mai tăioasă formulare a întregii probleme: blochează comutatorul într-un fel și UI-ul tău e mort; blocat în celălalt fel și tot desktopul tău e.
Trei lucruri pe care ni le-au predat aceste lansări
- Un overlay click-through e un contract cu două fețe, iar cele două fețe eșuează diferit. Prea transparent și propriile tale controale mor; prea acaparator și înghiți input-ul altor aplicații. Majoritatea bug-urilor de overlay sunt doar una dintre cele două, iar a numi pe ce față ești îți spune unde să te uiți.
- Cel mai înalt nivel always-on-top nu e un premiu. A sta deasupra a tot înseamnă a sta deasupra dialogurilor de sistem pe care ai nevoie ca utilizatorul să le vadă. Proiectează pentru momentul în care overlay-ul tău ar trebui să se dea politicos la o parte — și fă urcarea înapoi reversibilă într-un
finally, pentru că o cedare pe jumătate finalizată e mai rea decât a nu ceda niciodată. - Scăderea bate istețimea. Fix-ul câștigător din v1.8.14 a fost ștergerea apelurilor
focus()de care overlay-ul n-a avut niciodată nevoie, găsit abia după o săptămână de construit și revenit asupra gestionării de drag custom. Când o funcționalitate se luptă în continuu cu platforma, prima întrebare de pus e dacă poți elimina lucrul care se luptă.
Pentru capitolul anterior al poveștii v1, punând lansarea în CI, de două ori (v1.8.4); iar pentru întregul arc, anatomia livrării de software până la perfecțiune.