Steven
Steven8 min czytania

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.

Inżynieria
Electron
Desktop
Wydania GeekBye
Dwa tryby awarii nakładki przepuszczającej kliknięcia

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

  1. 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ć.
  2. 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.
  3. 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.