
Отличить звонок от открытого приложения
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); а про всю дугу — анатомию доведения софта до совершенства.