
Да различиш обаждане от отворено приложение
GeekBye може да забележи, че си се присъединил към видео среща, и да ти предложи да я запише. Разпознаването се оказва лесната половина — Swift binary, който чете заглавия на прозорци на всеки десет секунди. Трудната половина е прецизността: да не се задейства, когато Zoom е просто отворен, да не пита за среща, която вече записваш, и да не заглушава микрофона в обаждането, в което всъщност си. Три издания, всяко от които е предпазител, който трябваше да се научи да не побеждава сам себе си.
GeekBye има функция, която звучи тривиално, но не е: забелязва, когато си се присъединил към видео среща, и предлага, с едно кликване, да започне да я записва. Представянето е едно изречение. Реализацията е три издания на учене, че интересният проблем никога не е бил „разпознай среща" — бил е „бъди прав за нея". Прав за това дали наистина си в обаждане, или просто имаш приложението отворено. Прав за това да не питаш два пъти. Прав за това да не заглушиш грешния микрофон. Всяко от трите издания по същество е предпазител, който трябваше да бъде научен да не се спъва в самия себе си.
Заглавия на прозорци, не процеси
Първото и най-съществено решение е записано в commit съобщение като изявление за дизайн, не като бележка под линия:
Detection uses window title matching (not process detection) to distinguish "app is open" from "in an active call."
Това разграничение е цялата функция. Ако разпознаваше срещи, гледайки кои приложения работят, щеше да се задействаш в мига, в който Zoom стартира — а Zoom стои отворен на много десктопи цял ден без нито едно обаждане. Затова GeekBye не наблюдава процеси. На macOS пуска малък нативен Swift binary, MeetingMonitor, който на всеки десет секунди извиква CGWindowListCopyWindowInfo за прозорците на екрана и чете заглавието на всеки през kCGWindowName. Прозорец, озаглавен "Zoom Meeting", означава, че си в обаждане; прозорецът "Zoom Workplace" означава, че приложението просто е отворено. Сигналът не е „жив ли е Zoom", а „изглежда ли някой прозорец на екрана ти като живо обаждане".
Интервалът на polling е твърда константа — pollIntervalSeconds: Double = 10.0 — плюс една незабавна проверка при стартиране, така че среща се забелязва в рамките на около десет секунди от появата на прозореца ѝ. (Това е стойност от кода, не измерено закъснение; третирай „до ~10 s" като аритметика, не като benchmark.)
Срещите в браузър се нуждаят от още една стъпка, защото Chrome прозорец, озаглавен "Meet - Weekly Sync", е обещаващ, но сигурността е в URL-а. Затова, когато приложението на преден план е познат браузър — Chrome, Safari, Edge, Brave и още шепа, идентифицирани по bundle id — binary-то извлича URL-а на активния таб през AppleScript и го проверява спрямо шаблони като meet.google.com/, zoom.us/j/ и teams.microsoft.com/. AppleScript към друго приложение може да блокира, затова това извикване се изпълнява на фонова DispatchQueue, пазена от DispatchSemaphore с 3-секунден timeout; заклещен браузър изтича по време вместо да замрази целия детектор.
Трудната част е да не се задейства
Ето частта, която ме изненада, когато прочетох списъка с шаблони. Да разпознаеш обаждане са няколко съвпадения на низове. Да не разпознаеш такова — да не се задействаш, когато не искаш — е мястото, където отиде цялата грижа. Списъкът с шаблони за заглавия на прозорци се чете като набор от умишлени изключения:
- Google Meet съвпада с
"Meet - "и en-dash варианта му"Meet – ", но не с голия низ"Google Meet"— защото това е началната страница, която отваряш преди обаждане. - Zoom съвпада с
"Zoom Meeting"и"Zoom Webinar", но не с"Zoom Workplace"или голо"Zoom"— приложението, което стои отворено. - Teams съвпада с
"Meeting with","Meeting in"," | Call"," | Meeting"— формите на заглавие по време на обаждане, не бездействащия клиент. - Slack huddle-ите, Webex стаите и WhatsApp обажданията получават всяко свои фрагменти, валидни само по време на обаждане.
Всяко от тях е решение да останеш тих в честия случай, в който приложение е отворено, но ти не си в среща. Целта на дизайна не е максимален recall; тя е да не си инструментът, който изкарва banner „искаш ли да запишеш?", докато четеш Slack. Фалшив положителен тук е скъп по начин, по който фалшив отрицателен не е: пропусни обаждане и потребителят започва да записва ръчно; задействай се погрешно на отворено приложение и потребителят се научава да игнорира твоя banner. Цялата евристика е настроена така, че вторият провал да е рядък.
Честно е да назовем ограниченията на толкова проста евристика. Шаблоните са само на английски — локализирана операционна система или приложение за срещи на друг език няма да съвпадне, признато ограничение на v1. И понеже заглавията на прозорците на други приложения са видими само с предоставено разрешение Screen Recording, разпознаването тихо се деградира до нищо без него. Просто, четимо и несъвършено по начини, които екипът записа, вместо да скрие.
Предложението и механизмът, който го пази да не досажда
Когато шаблон съвпадне, GeekBye показва малък безрамков always-on-top banner в ъгъла — собствен прозорец, не известие на операционната система — с бутон Start Recording. Кликни го и IPC съобщение (meeting:start-recording) скрива banner-а, извежда главния прозорец и казва на renderer-а да започне сесия. Отхвърли го и banner-ът изчезва.
Но наивна версия на това би била влудяваща. Заглавията се променят постоянно по време на обаждане — присъединява се участник, преименуваш срещата, споделяне на екран сменя името на прозореца — и всяка промяна би задействала наново детектор, който се опира на точния низ. Затова детекторът, в MeetingDetector.ts, носи малка машина на състоянията, чиято единствена задача е сдържаност:
- Стабилен
meetingIdза всяка платформа. Swift страната излъчва id катоgoogle-meet-active, умишлено не производен от нестабилното заглавие на прозореца, така че влизания, излизания и преименувания в рамките на едно обаждане да не се четат като нова среща. - Едно
dismissedMeetings: Set<string>. Отхвърли banner-а и id-то на тази среща се игнорира — докато срещата наистина не приключи, в който момент id-то се изчиства, за да може следващата реална среща да попита наново. - Един boolean
notificationActive. Само един banner наведнъж, така че Slack huddle, който изскача, докато вече си в Meet, да не натрупа второ запитване.
Това е функцията, всъщност: малко разпознаване и много „не досаждай". Точно затова съществуват следващите две издания — защото два от тези предпазители се оказаха да побеждават сами себе си.
Заглушаването, което заглуши реалната ти среща (v1.8.18)
Записът има бутон за заглушаване, и първата реализация взе „заглуши" в най-буквалния му смисъл. Пусната в v1.8.15, тя заглушаваше микрофона, като задаваше входния звук на операционната система на нула:
osascript -e "set volume input volume 0"
с предишния звук, запазен в базата данни, за да може да бъде възстановен, Windows еквивалент през waveInSetVolume и предпазна мрежа before-quit, която да гарантира разглушаване. Кодът дори я описваше като функция — заглушаване „на ДВЕ нива", система и приложение, така че микрофонът беше блокиран за всички приложения.
Тази последна фраза е бъгът, изречен като хвалба. Блокирането на микрофона за всички приложения означава блокирането му за Zoom или Meet обаждането, в което всъщност седиш. Натискаш заглуши в GeekBye, за да спреш него да те чува, и си онемял в реалната си среща. Поправката на v1.8.18 е понижение: спри да пипаш входния звук на операционната система изцяло и заглуши само собствения audio pipeline на GeekBye. В режим Listen RealtimeAudioCaptureService заменя заглушените микрофонни кадри със занулен буфер, така че тишина тече към миксера; в режим видео ScreenRecordingService задава Web Audio GainNode на нула. Заглушаването на ниво система, възстановяването на запазения звук, целият cross-platform апарат — всички изтрити. Заглуши трябва да означава „това приложение спира да слуша", никога „микрофонът ти се изключва за всички".
Предпазителят, който се самонулираше (v1.8.19)
Другият предпазител, който побеждаваше сам себе си, беше notificationActive. На хартия той вече отговаряше на „не показвай banner, ако вече има един" — и изглеждаше, че би трябвало да покрива и „не предлагай да запишеш среща, която вече записвам". Не го правеше, а commit-ът, който го поправя, обяснява защо точно:
The notificationActive flag resets when the meeting detector cycles through ended/detected states, causing notifications to reappear mid-recording.
Детекторът е polling в реално време; в хода на дълго обаждане той може за кратко да прочете „приключило" и после „разпознато" отново — трепване на заглавие, прозорец за момент извън екрана — и всеки цикъл нулираше флага. Така предпазителят продължаваше тихо да се превъоръжава, докато записваше, и banner-ът се появяваше отново, предлагайки да запише обаждането, което вече записваше. Поправката спира да се доверява на локален флаг и вместо това пита източника на истината: callback recordingActiveChecker, инжектиран от main.ts и свързан с реалното състояние isRealtimeTranscriptionActive, проверяван преди което и да е известие да се покаже.
// Don't show notification if recording/transcription is already active
if (this.recordingActiveChecker?.()) {
return
}
Поуката е малка и обща: предпазител, чието състояние притежаваш, може да бъде нулиран изпод теб от собствения ти контролен поток. Предпазител, който чете реалното състояние, което пази, не може. notificationActive следеше прокси за „записваме ли"; поправката пита дали записваме.
Три неща, на които ни научиха тези издания
- Разпознавай състоянието, което имаш предвид, не онова, което е лесно за виждане. „Приложението работи" е тривиално за проверка и грешно. „Съществува прозорец, който изглежда като живо обаждане" е малко по-трудно и вярно. Разстоянието между двете е цялата функция.
- За проактивна функция прецизността е продуктът. Никой не помни срещата, която си разпознал правилно; помнят трите пъти, когато си ги прекъснал, когато не са били в такава. Изключенията в шаблоните и машината на състоянията срещу досаждане не са лустро — те са разликата между полезен banner и такъв, който сам се научаваш да игнорираш.
- Предпазител, който може да се самонулира, не е предпазител. И двете последващи издания са с една и съща форма: механизъм за безопасност, който сочеше към прокси, което контролираше (запазен звук, локален boolean), вместо към реалното състояние (микрофона на операционната система, статуса на транскрипцията). Насочвай предпазителите към истината, не към копие от нея.
За предишната глава от историята на v1 — изваждане на backend-а от пътя на качването (v1.8.11–v1.8.13); а за цялата дъга — анатомията на доставянето на софтуер до съвършенство.