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