Steven
Steven8 min czytania

Odróżnić rozmowę od otwartej aplikacji

GeekBye potrafi zauważyć, że dołączyłeś do spotkania wideo, i zaproponować jego nagranie. Wykrywanie okazuje się łatwiejszą połową — natywny plik Swift czytający tytuły okien co dziesięć sekund. Trudną połową jest precyzja: nie odpalać, gdy Zoom jest tylko otwarty, nie proponować nagrywania spotkania, które już nagrywasz, i nie wyciszać mikrofonu w rozmowie, w której faktycznie jesteś. Trzy wydania, a każde z nich to zabezpieczenie, które musiało nauczyć się nie pokonywać samego siebie.

Inżynieria
macOS
Desktop
Wydania GeekBye
Odróżnić rozmowę od otwartej aplikacji

GeekBye ma funkcję, która brzmi trywialnie, a taka nie jest: zauważa, kiedy dołączyłeś do spotkania wideo, i proponuje, jednym kliknięciem, rozpocząć jego nagrywanie. Zajawka mieści się w jednym zdaniu. Implementacja to trzy wydania nauki, że interesującym problemem nigdy nie było „wykryć spotkanie" — było nim „mieć co do niego rację". Rację co do tego, czy naprawdę jesteś w rozmowie, czy tylko masz otwartą aplikację. Rację co do niepytania dwa razy. Rację co do niewyciszania niewłaściwego mikrofonu. Każde z trzech wydań to w gruncie rzeczy zabezpieczenie, które trzeba było nauczyć, by nie potykało się o samo siebie.

Tytuły okien, nie procesy

Pierwsza i najbardziej brzemienna w skutki decyzja jest zapisana w komunikacie commita jako deklaracja projektowa, nie przypis:

Detection uses window title matching (not process detection) to distinguish "app is open" from "in an active call."

To rozróżnienie jest całą funkcją. Gdybyś wykrywał spotkania, patrząc, które aplikacje działają, odpalałbyś w chwili uruchomienia Zoom — a Zoom bywa otwarty na wielu pulpitach cały dzień bez ani jednej rozmowy. Więc GeekBye nie obserwuje procesów. Na macOS uruchamia mały natywny plik binarny Swift, MeetingMonitor, który co dziesięć sekund wywołuje CGWindowListCopyWindowInfo dla widocznych okien i czyta tytuł każdego z nich przez kCGWindowName. Okno o tytule "Zoom Meeting" znaczy, że jesteś w rozmowie; okno "Zoom Workplace" znaczy, że aplikacja jest tylko otwarta. Sygnałem nie jest „czy Zoom żyje", lecz „czy okno na twoim ekranie wygląda jak rozmowa na żywo".

Interwał odpytywania to twarda stała — pollIntervalSeconds: Double = 10.0 — plus jedno natychmiastowe sprawdzenie przy starcie, więc spotkanie jest zauważane w około dziesięć sekund od pojawienia się jego okna. (To wartość z kodu, nie zmierzone opóźnienie; traktuj „do ~10 s" jako arytmetykę, nie benchmark.)

Spotkania w przeglądarce wymagają jeszcze jednego kroku, bo okno Chrome o tytule "Meet - Weekly Sync" jest obiecujące, ale pewność jest w URL. Więc gdy aplikacją na wierzchu jest znana przeglądarka — Chrome, Safari, Edge, Brave i garść innych rozpoznawanych po bundle id — plik binarny wyciąga URL aktywnej karty przez AppleScript i sprawdza go pod kątem wzorców takich jak meet.google.com/, zoom.us/j/ i teams.microsoft.com/. AppleScript do innej aplikacji może się zawiesić, więc to wywołanie działa w tle na DispatchQueue, strzeżone przez DispatchSemaphore z 3-sekundowym timeoutem; zaklinowana przeglądarka przekracza limit czasu, zamiast zamrozić cały detektor.

Trudną częścią jest nieodpalanie

Oto część, która mnie zaskoczyła, gdy przeczytałem listę wzorców. Wykrycie rozmowy to kilka dopasowań tekstu. Niewykrycie jej — nieodpalanie, gdy tego nie chcesz — to miejsce, w które poszła cała staranność. Lista wzorców tytułów okien czyta się jak zbiór celowych wykluczeń:

  • Google Meet dopasowuje "Meet - " i jego wariant z półpauzą "Meet – ", ale nie samo "Google Meet" — bo to strona startowa, którą otwierasz przed rozmową.
  • Zoom dopasowuje "Zoom Meeting" i "Zoom Webinar", ale nie "Zoom Workplace" czy samo "Zoom" — aplikację po prostu otwartą.
  • Teams dopasowuje "Meeting with", "Meeting in", " | Call", " | Meeting" — kształty tytułów w trakcie rozmowy, nie bezczynnego klienta.
  • Slack huddle, pokoje Webex i rozmowy WhatsApp dostają każde własne fragmenty występujące tylko w trakcie rozmowy.

Każde z nich to decyzja, by milczeć w częstym przypadku, gdy aplikacja jest otwarta, ale nie jesteś na spotkaniu. Celem projektowym nie jest maksymalna czułość; jest nim niebycie narzędziem, które wyskakuje z banerem „chcesz nagrać?", gdy czytasz Slacka. Fałszywy alarm jest tu kosztowny w sposób, w jaki fałszywe przeoczenie nie jest: przegap rozmowę, a użytkownik zacznie nagrywać ręcznie; odpal się przy otwartej aplikacji, a użytkownik nauczy się ignorować twój baner. Cała heurystyka jest dostrojona tak, by drugi rodzaj porażki był rzadki.

Uczciwie jest nazwać granice tak prostej heurystyki. Wzorce są tylko po angielsku — zlokalizowany system czy nieanglojęzyczna aplikacja do spotkań się nie dopasuje, to uznane ograniczenie wersji v1. A ponieważ tytuły okien innych aplikacji są widoczne tylko przy przyznanym uprawnieniu Nagrywania ekranu, bez niego wykrywanie po cichu degraduje się do zera. Proste, czytelne i niedoskonałe w sposób, który zespół raczej spisał, niż ukrył.

Propozycja i maszyneria, która nie pozwala jej się naprzykrzać

Gdy wzorzec zostanie dopasowany, GeekBye pokazuje w rogu mały bezramkowy baner zawsze-na-wierzchu — własne okno, nie powiadomienie systemowe — z przyciskiem Rozpocznij nagrywanie. Kliknij go, a wiadomość IPC (meeting:start-recording) chowa baner, przywołuje główne okno i mówi rendererowi, by rozpoczął sesję. Odrzuć go, a baner znika.

Ale naiwna wersja tego doprowadzałaby do szału. Tytuły zmieniają się bez przerwy w trakcie rozmowy — dołącza uczestnik, zmieniasz nazwę spotkania, udostępnianie ekranu podmienia nazwę okna — a każda zmiana odpalałaby ponownie detektor, który kluczował na dokładny ciąg znaków. Więc detektor, w MeetingDetector.ts, niesie w sobie małą maszynę stanów, której całym zadaniem jest powściągliwość:

  • Stabilny meetingId na platformę. Strona Swift emituje id takie jak google-meet-active, celowo nie wyprowadzone z ulotnego tytułu okna, więc dołączenia, wyjścia i zmiany nazwy w obrębie jednej rozmowy nie są odczytywane jako nowe spotkanie.
  • dismissedMeetings: Set<string>. Odrzuć baner, a id tego spotkania jest ignorowane — dopóki spotkanie faktycznie się nie skończy, w którym to momencie id się czyści, by następne prawdziwe spotkanie mogło zaczepić na nowo.
  • Wartość logiczna notificationActive. Tylko jeden baner naraz, więc Slack huddle wyskakujący, gdy już jesteś w Meet, nie dokłada drugiej propozycji.

To właśnie jest ta funkcja: trochę wykrywania i mnóstwo „nie bądź irytujący". I właśnie dlatego istnieją dwa kolejne wydania — bo dwa z tych zabezpieczeń okazały się pokonywać same siebie.

Wyciszenie, które wyciszyło twoje prawdziwe spotkanie (v1.8.18)

Nagrywanie ma przycisk wyciszenia, a pierwsza implementacja wzięła „wyciszenie" najbardziej dosłownie. Wydana w v1.8.15, wyciszała mikrofon, ustawiając głośność wejścia systemu operacyjnego na zero:

osascript -e "set volume input volume 0"

z poprzednią głośnością zapisaną w bazie danych, by można ją było przywrócić, odpowiednikiem na Windows przez waveInSetVolume i siatką bezpieczeństwa before-quit, by upewnić się, że wyciszenie zostanie cofnięte. Kod nawet opisywał to jako funkcję — wyciszanie „na DWÓCH poziomach", systemowym i aplikacji, tak że mikrofon był blokowany dla wszystkich aplikacji.

To ostatnie zdanie to błąd wypowiedziany jako przechwałka. Blokowanie mikrofonu dla wszystkich aplikacji znaczy blokowanie go dla rozmowy Zoom lub Meet, w której faktycznie siedzisz. Naciskasz wyciszenie w GeekBye, by przestało ono cię słyszeć, a zamilkłeś na swoim prawdziwym spotkaniu. Poprawka v1.8.18 to degradacja: przestań w ogóle dotykać głośności wejścia systemu i wyciszaj tylko własny potok audio GeekBye. W trybie Listen RealtimeAudioCaptureService zastępuje wyciszone ramki mikrofonu zerowym buforem, tak że do miksera płynie cisza; w trybie wideo ScreenRecordingService ustawia webaudiowy GainNode na zero. Ogólnosystemowe wyciszenie, przywracanie zapisanej głośności, cały wieloplatformowy aparat — wszystko usunięte. Wyciszenie powinno znaczyć „ta aplikacja przestaje słuchać", nigdy „twój mikrofon wyłącza się dla wszystkich".

Zabezpieczenie, które samo się resetowało (v1.8.19)

Drugim zabezpieczeniem pokonującym samo siebie było notificationActive. Na papierze odpowiadało już na „nie pokazuj banera, jeśli jakiś jest wyświetlony" — i wyglądało, jakby powinno też obejmować „nie proponuj nagrywania spotkania, które już nagrywam". Nie obejmowało, a commit, który to naprawia, wyjaśnia dlaczego dokładnie:

The notificationActive flag resets when the meeting detector cycles through ended/detected states, causing notifications to reappear mid-recording.

Detektor to odpytywanie na żywo; w trakcie długiej rozmowy może na chwilę odczytać „zakończone", a potem znów „wykryte" — migotanie tytułu, okno na moment poza ekranem — i każdy cykl resetował flagę. Więc zabezpieczenie po cichu wciąż uzbrajało się na nowo, gdy nagrywałeś, i baner wracał, proponując nagranie rozmowy, którą już nagrywałeś. Poprawka przestaje ufać lokalnej fladze i pyta zamiast tego źródło prawdy: callback recordingActiveChecker, wstrzykiwany z main.ts i podpięty do prawdziwego stanu isRealtimeTranscriptionActive, sprawdzany przed pokazaniem jakiegokolwiek powiadomienia.

// Don't show notification if recording/transcription is already active
if (this.recordingActiveChecker?.()) {
  return
}

Lekcja jest mała i ogólna: zabezpieczenie, którego stan jest twój, może zostać zresetowane spod ciebie przez twój własny przepływ sterowania. Zabezpieczenie, które czyta rzeczywisty stan, jaki chroni, nie może. notificationActive śledziło zastępnik dla „czy nagrywamy"; poprawka pyta, czy nagrywamy.

Trzy rzeczy, których nauczyły nas te wydania

  1. Wykrywaj stan, który masz na myśli, nie ten, który łatwo zobaczyć. „Aplikacja działa" jest trywialne do sprawdzenia i błędne. „Istnieje okno, które wygląda jak rozmowa na żywo" jest odrobinę trudniejsze i poprawne. Przepaść między tymi dwoma to cała funkcja.
  2. Dla proaktywnej funkcji precyzja jest produktem. Nikt nie pamięta spotkania, które wykryłeś poprawnie; pamiętają te trzy razy, gdy przeszkodziłeś im, choć na żadnym nie byli. Wyjątki we wzorcach i maszyna stanów przeciw-naprzykrzaniu to nie szlif — to różnica między pomocnym banerem a takim, który sam uczysz się ignorować.
  3. Zabezpieczenie, które potrafi samo się zresetować, nie jest zabezpieczeniem. Oba kolejne wydania mają ten sam kształt: mechanizm bezpieczeństwa, który odwoływał się do zastępnika, jaki kontrolował (zapisana głośność, lokalna wartość logiczna), zamiast do prawdziwego stanu (mikrofon systemu, status transkrypcji). Kieruj zabezpieczenia na prawdę, nie na jej kopię.

Po poprzedni rozdział historii v1 — wyjmowanie backendu ze ścieżki przesyłania (v1.8.11–v1.8.13); a po cały łuk — anatomia dostarczania oprogramowania do perfekcji.

Powiązane artykuły

Trzy czasowniki, które utrzymują Web Audio przy życiu
Steven
Steven8 min czytania

Trzy czasowniki, które utrzymują Web Audio przy życiu

Dwa wydania punktowe GeekBye, dwa miesiące od siebie i w dwóch różnych plikach, nauczyły nasz kod audio tej samej lekcji z przeciwnych stron: przestań traktować przeglądarkowy AudioContext jak obiekt jednorazowy. Jedno wydanie nauczyło się resume() kontekstu, który macOS po cichu zawiesił w środku nagrywania; drugie nauczyło się suspend() zamiast close(), żeby kolejne po sobie sesje przestały uderzać w narzucony przez Chromium pułap mniej więcej sześciu kontekstów. Resume, suspend, close — oto cała fabuła.

Inżynieria
Audio
Desktop
Wyjmowanie backendu ze ścieżki przesyłania
Steven
Steven7 min czytania

Wyjmowanie backendu ze ścieżki przesyłania

GeekBye nagrywa twój ekran i zapisuje wideo na twój Google Drive. Pierwsza wersja przesyłała każde nagranie przez własne serwery GeekBye po drodze; wydanie później plik szedł prosto z twojej maszyny na Drive, a backend został zdegradowany do trzymania jednego wskaźnika. Ciekawą częścią jest to, jak mało kodu naprawdę zawiera wersja „bezpośrednia, wznawialna" — bo wznawialność wzięła się z usunięcia proxy, a nie z napisania go.

Inżynieria
Architektura
Desktop
Narodzony i naprawiony w dwanaście minut
Steven
Steven7 min czytania

Narodzony i naprawiony w dwanaście minut

Changelog GeekBye v1.8.10 mówi, że naprawiono awarię podczas edycji skrótów klawiszowych. Owszem — ale awarię wprowadzono i naprawiono w tym samym pull requeście, dwanaście minut od siebie, i nigdy nie dotarła do ani jednego użytkownika. Prawdziwą historią jest kaskada niezawodności, która ją wytworzyła: mała, poprawna zmiana tego, kiedy skróty są zawieszane, i błąd rozbiórki Reacta, który wypadł z jednej linijki mającej uczynić edytor bezpieczniejszym.

Inżynieria
React
Desktop