Steven
Steven8 мин. чтения

Отличить звонок от открытого приложения

GeekBye умеет заметить, что ты присоединился к видеовстрече, и предложить её записать. Обнаружение оказывается лёгкой половиной — нативный Swift-бинарник читает заголовки окон каждые десять секунд. Трудная половина — точность: не срабатывать, когда Zoom просто открыт, не предлагать записать встречу, которую ты уже записываешь, и не заглушать микрофон в звонке, в котором ты на самом деле сидишь. Три релиза, и каждый из них — предохранитель, которому пришлось научиться не побеждать самого себя.

Инженерия
macOS
Десктоп
Релизы GeekBye
Отличить звонок от открытого приложения

У 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 отслеживал суррогат для «записываем ли мы»; фикс спрашивает, записываем ли мы.

Три вещи, которым нас научили эти релизы

  1. Обнаруживай то состояние, которое имеешь в виду, а не то, что легко увидеть. «Приложение запущено» тривиально проверить и неверно. «Существует окно, которое выглядит как живой звонок» чуть труднее и верно. Разрыв между этими двумя — это вся функция.
  2. Для проактивной функции точность — это продукт. Никто не помнит встречу, которую ты обнаружил верно; помнят те три раза, когда ты перебил их, а они ни на одной не были. Исключения в шаблонах и автомат против назойливости — не лоск — это разница между полезным баннером и таким, который ты сам приучаешься игнорировать.
  3. Предохранитель, который может сбросить сам себя, — не предохранитель. Оба последующих релиза одной формы: механизм безопасности, ссылавшийся на суррогат, который он контролировал (сохранённая громкость, локальный булев флаг), вместо настоящего состояния (микрофон ОС, статус транскрипции). Наводи предохранители на истину, а не на её копию.

Про предыдущую главу истории v1 — убираем backend с пути загрузки (v1.8.11–v1.8.13); а про всю дугу — анатомию доведения софта до совершенства.

Похожие статьи

Три глагола, что держат Web Audio живым
Steven
Steven8 мин. чтения

Три глагола, что держат Web Audio живым

Два точечных релиза GeekBye, разделённые двумя месяцами и в двух разных файлах, преподали нашему аудиокоду один и тот же урок с противоположных концов: перестань обращаться с браузерным AudioContext как с одноразовым. Один релиз научился делать resume() контексту, который macOS тихо приостановил посреди записи; другой научился suspend() вместо close(), чтобы идущие подряд сессии перестали врезаться в навязанный Chromium потолок примерно в шесть контекстов. Resume, suspend, close — вот и весь сюжет.

Инженерия
Аудио
Десктоп
Убираем backend с пути загрузки
Steven
Steven7 мин. чтения

Убираем backend с пути загрузки

GeekBye записывает твой экран и сохраняет видео в твой Google Drive. Первая версия прогоняла каждую запись через собственные серверы GeekBye по пути туда; релизом позже файл шёл прямо с твоей машины на Drive, а backend был понижен до хранения единственного указателя. Интересная часть в том, как мало кода на самом деле содержит «прямая, возобновляемая» версия — потому что возобновляемость пришла из удаления proxy, а не из его написания.

Инженерия
Архитектура
Десктоп
Рождён и исправлен за двенадцать минут
Steven
Steven7 мин. чтения

Рождён и исправлен за двенадцать минут

Changelog GeekBye v1.8.10 говорит, что исправлена авария при редактировании клавиатурных сочетаний. Так и есть — но авария была внесена и исправлена в одном и том же пул-реквесте, с разницей в двенадцать минут, и не добралась ни до одного пользователя. Настоящая история — это каскад надёжности, что её породил: маленькое, верное изменение того, когда сочетания приостанавливаются, и баг разборки React, что выпал из одной строки, призванной сделать редактор безопаснее.

Инженерия
React
Десктоп