
Dwa tryby awarii nakładki przepuszczającej kliknięcia
Okno GeekBye unosi się nad wszystkim i przepuszcza twoje kliknięcia na wylot — poza miejscami, gdzie ma przyciski. To dwustronny kontrakt, a v1.8.5 i v1.8.14 pokazują, jak wygląda złamanie każdej ze stron: jedno wydanie, w którym nakładka połknęła systemowe okno dialogowe, i drugie, w którym ukradła twoje naciśnięcia klawiszy. Zwycięskim lekarstwem na to drugie było skasowanie kodu.
Główne okno GeekBye nie jest normalnym oknem. Jest pozbawione ramki, przezroczyste i zawsze na wierzchu — tafla szkła unosząca się nad tym, co akurat robisz, z kilkoma kontrolkami namalowanymi na wierzchu. Ciekawa inżynieria nie polega na tym, że się unosi; polega na tym, że twoja mysz ma przelatywać prosto przez szkło wszędzie poza kontrolkami. Kliknij przycisk na nakładce, a kliknięcie dostaje nakładka. Kliknij pustą przestrzeń obok, a kliknięcie ląduje na twojej aplikacji do spotkań pod spodem, jak gdyby nakładki wcale tam nie było.
To kontrakt o dwóch stronach: przyjmuj input dokładnie tam, gdzie jest UI, i bądź całkowicie niewidzialna dla inputu wszędzie indziej. Obie strony muszą trzymać w każdej chwili, a każdy błąd w tej części aplikacji to naruszenie jednej albo drugiej. Dwa wydania, odległe o dwa miesiące, są najczystszą możliwą ilustracją — bo każde złamało inną stronę.
Kontrakt, od strony mechaniki
Implementacją jest hook, useMousePassthrough, i jest mały. Okno domyślnie przepuszcza kliknięcia. Globalny nasłuchiwacz mousemove przy każdym ruchu zadaje jedno pytanie: czy kursor jest nad kontrolką? Kontrolki są oznaczone atrybutem data-ui-region, więc test to dosłownie target.closest('[data-ui-region]'). Nad kontrolką wywołuje setIgnoreMouseEvents(false) i okno przechwytuje kliknięcia. Nad przezroczystą przestrzenią wywołuje setIgnoreMouseEvents(true, { forward: true }) i kliknięcia przelatują na wylot.
To forward: true jest szczegółem, na którym wszystko się opiera. Gdy okno ignoruje zdarzenia myszy, normalnie przestaje je dostawać w ogóle — w tym zdarzenia mousemove, których hook potrzebuje, by zauważyć, że kursor wraca nad kontrolkę. forward: true mówi Electronowi, by mimo wszystko dalej przekazywał ruchy do ignorującego okna, żeby mogło się ono ponownie uzbroić. Bez tego nakładka przeszłaby w tryb przepuszczania raz i nigdy by nie wróciła. Hook przechodzi przez granicę IPC tylko przy przejściach stanu — gdy kursor przekracza granicę między UI a szkłem — a nie przy każdym pikselu ruchu. Zapamiętaj tę optymalizację; ma dziurę, a v1.8.5 w nią wpadło.
v1.8.5: gdy łamie się strona przezroczystości
Strona przezroczystości kontraktu brzmi „bądź niewidzialna dla inputu wszędzie tam, gdzie nie ma UI". v1.8.5 pokazuje, jak wygląda nakładka zbyt obecna — gdy przechwytuje albo blokuje input, który powinna była przepuścić. Wyzwalaczem były okna dialogowe uprawnień macOS, a piętrzyły się na sobie dwie odrębne awarie.
Nakładka była fizycznie nad oknem dialogowym. Okno zawsze na wierzchu ma poziom, a nakładka GeekBye siedziała na najwyższym, jaki oferuje macOS — screen-saver. To ponad niemal wszystkim, w tym ponad systemowymi oknami dialogowymi uprawnień Microphone i Screen Recording. Więc podczas onboardingu, w dokładnym momencie, gdy macOS pytał o uprawnienie, okno dialogowe mogło wyrenderować się pod nakładką i być niewidoczne. Lekarstwo, setPermissionMode, działa tylko na macOS i robi rzecz nieintuicyjną: obniża nakładkę do poziomu floating na czas żądania uprawnień, a potem ją przywraca. A przywrócenie wymagało konkretnej sztuczki, udokumentowanej wprost w commicie: macOS „doesn't always visually re-raise a window when setting the same level it was previously at", więc kod przeprowadza okno przez poziom pośredni — floating → status → screen-saver — by zmusić serwer okien do przeliczenia kolejności nakładania. Nie ustawiasz poziomu, którego chcesz; dochodzisz do niego krok po kroku.
Nakładka zjadała kliknięcia okna dialogowego. Onboarding maluje ciemne tło na całe okno, a hook przepuszczania traktuje je jako przypadek szczególny: dopóki to tło jest na wierzchu, wymusza przechwytywanie kliknięć na całym oknie, żeby kreator był w pełni interaktywny. Ale „przechwytuj każde kliknięcie" jest dokładnie błędne, gdy systemowe okno dialogowe próbuje jedno zebrać. Lekarstwo zdejmuje atrybut-znacznik tła na czas żądania uprawnień, więc hook wypada z trybu przechwytywania i przyciski okna dialogowego dostają swoje kliknięcia. Nakładka zachowuje interaktywność własnych kontrolek; po prostu przestaje udawać, że puste szkło jest przyciskiem.
Dziura w optymalizacji. Pamiętaj, że hook działa tylko przy przejściach. Gdy okno wychodziło z trybu przechwytywania, ponownie uruchamiał test najechania — ale jeśli twój kursor już siedział na kontrolce w tym momencie, odpowiedź na pytanie „czy jesteśmy nad UI?" brzmiała już „tak", więc żadne przejście się nie odpaliło i przepuszczanie kliknięć nigdy nie zostało wyłączone. Kontrolka wyglądała na żywą, a była martwa. Lekarstwo (1b6eb20, zacytowane) przestaje ufać przejściu: „when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call". Kiedy zmieniasz tryb, nie czekasz, aż następny ruch powie ci, gdzie jest kursor — patrzysz.
I uczyń oddanie odwracalnym. Subtelne ryzyko w tym wszystkim polega na tym, że nakładka tymczasowo oddaje teraz swój z-order i swoje przechwytywanie kliknięć, a coś musi je oddać z powrotem. Jeśli wywołanie przywracające rzuci wyjątkiem — czkawka IPC, okno zburzone w trakcie żądania — aplikacja mogłaby utknąć z isRequestingPermission zablokowanym na true i ciemnym tłem zasłaniającym cały twój ekran na stałe. Więc ścieżkę wyjścia owinięto tak, by reset stanu „zawsze się wykonywał", plus wielominutowy awaryjny timeout i sprzątanie przy niszczeniu okna. Reguła, która z tego wynika: za każdym razem, gdy nakładka oddaje input, cofnięcie tego oddania musi być w finally, a nie na szczęśliwej ścieżce.
v1.8.14: gdy łamie się strona przechwytywania
Druga strona kontraktu brzmi „przyjmuj input tam, gdzie jest UI". v1.8.14 pokazuje, jak wygląda nakładka zbyt zachłanna — gdy interakcja z nią kosztuje cię focus inputu aplikacji, na której naprawdę ci zależy.
Objawem w changelogu jest kradzież focusa, a oto uczciwa wersja, bo historia gita jest bardziej pouczająca niż notka o wydaniu. Handlery skrótów nakładki — przełączanie widoczności na Cmd+B, otwieranie okna czatu, jego przewijanie — każdy z nich wywoływał kiedyś mainWindow.focus(), a na macOS app.focus({ steal: true }), by mieć pewność, że nakładka jest na wierzchu, gdy ją wyzwolisz. Problem w tym, że sfokusowanie nakładki dezaktywuje to, co było na wierzchu wcześniej — twoje spotkanie, twój edytor — więc naciśnięcie klawisza przeznaczone dla nich mogło wylądować nigdzie albo w złym miejscu. Nakładka chwytała rzecz, której tak naprawdę nie potrzebowała.
Ta ostatnia część to cała lekcja, a zespół doszedł do niej po trudnej drodze. Gałąź, która stała się v1.8.14, najpierw próbowała rozwiązać konkretnie przeciąganie — bo przeciąganie ma własną piękną wersję tego błędu: gdy przeciągasz nakładkę na pełny ekran, okno przesuwa się pod nieruchomym kursorem, więc model najechania myśli, że wskaźnik „opuścił" kontrolkę, i przełącza się w tryb przepuszczania kliknięć w trakcie przeciągania, gubiąc przeciągnięcie. Więc dodali dragLock. Potem spróbowali uczynić całe okno niefokusowalnym — co zepsuło pisanie i przeciąganie, więc przywrócili fokusowalność, potem zawęzili niefokusowalność tylko do pól tekstowych, a potem wyrwali ten przełącznik. Zbudowali własne IPC przeciągania, by zastąpić natywne przeciąganie Electrona. Dodali rozmycie-przy-nieoczekiwanym-focusie. Tydzień tego.
Potem skasowali z tego niemal wszystko. Commit, który trafił do wydania, cofnął własne przeciąganie, przełącznik fokusowalności i handler rozmycia, a zachował dokładnie jeden pomysł: przestań wywoływać focus(). Uświadomienie, zapisane w komentarzu w kodzie, brzmi tak, że nakładka od samego początku nigdy nie potrzebowała focusa systemu operacyjnego — „IPC messages (webContents.send) work without window focus". Nakładka steruje własnym rendererem bezpośrednio; focus był czystą stratą uboczną. Nadające się do wysyłki lekarstwo nie było sprytnym przepisaniem własnego przeciągania zbudowanym w jeden dzień. Było odjęciem wywołań focusa, których nie powinno tam być. (Warto powiedzieć wprost: changelog mówi „while dragging it", ale tym, co faktycznie trafiło do wydania, było usunięcie kradzieży focusa z handlerów skrótów klawiszowych — maszyneria specyficzna dla przeciągania została zbadana i cofnięta w tym samym tygodniu.)
Stawka, dosłownie
Jeśli chcesz dowodu, że ten kontrakt nie jest akademicki, spójrz, co stało się miesiące później, gdy nakładka utknęła po jego niewłaściwej stronie. W dużo późniejszym wydaniu proces renderera się wysypał, gdy nakładka była w trybie przechwytywania — setIgnoreMouseEvents(false), okno łapie wszystkie kliknięcia — i nie mając żywego UI, które by je zwolniło, niewidzialne okno na cały pulpit siedziało tam, zjadając każde kliknięcie na maszynie użytkownika, aż wymusił zamknięcie. Przezroczyste okno utknięte w trybie przechwytywania nie jest martwym przyciskiem; to pułapka na kliknięcia obejmująca cały pulpit. (Tamto dostało własną diagnozę — zobacz gdy twój AI-notatnik przestaje nagrywać w środku spotkania, która zestawia je z zupełnie innym błędem z tego samego wydania.) To najostrzejsze sformułowanie całego problemu: zablokuj przełącznik w jedną stronę, a twoje UI jest martwe; zablokowany w drugą — martwy jest cały twój pulpit.
Trzy rzeczy, których nauczyły nas te wydania
- Nakładka przepuszczająca kliknięcia to dwustronny kontrakt, a każda strona zawodzi inaczej. Zbyt przezroczysta — i twoje własne kontrolki stają się martwe; zbyt zachłanna — i połykasz input innych aplikacji. Większość błędów nakładki to po prostu jedna z tych dwóch stron, a nazwanie, po której jesteś, mówi ci, gdzie patrzeć.
- Najwyższy poziom always-on-top to nie nagroda. Siedzenie nad wszystkim oznacza siedzenie nad systemowymi oknami dialogowymi, które użytkownik musi zobaczyć. Projektuj z myślą o chwili, gdy twoja nakładka powinna grzecznie ustąpić — i uczyń powrót na górę odwracalnym w
finally, bo do połowy dokonane oddanie jest gorsze niż nieoddawanie w ogóle. - Odejmowanie bije spryt. Zwycięskim lekarstwem v1.8.14 było skasowanie wywołań
focus(), których nakładka nigdy nie potrzebowała, odnalezione dopiero po tygodniu budowania i cofania własnej obsługi przeciągania. Gdy funkcja wciąż walczy z platformą, pierwsze pytanie brzmi, czy da się usunąć to, co tę walkę prowadzi.
Po poprzedni rozdział historii v1 — wpięcie wydań do CI, dwa razy (v1.8.4); a po cały łuk — anatomia dostarczania oprogramowania do perfekcji.