Steven
Steven7 분 소요

열려 있는 앱과 통화 중을 가려내다

GeekBye는 당신이 화상 회의에 들어간 것을 알아채고 한 번의 클릭으로 녹화를 권할 수 있습니다. 감지는 알고 보니 쉬운 절반이었습니다 — 10초마다 창 제목을 읽는 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를 돌리는데, 그것은 10초마다 화면 위 창들에 대해 CGWindowListCopyWindowInfo를 호출하고 kCGWindowName을 통해 각각의 제목을 읽습니다. "Zoom Meeting"이라는 제목의 창은 통화 중임을 뜻합니다; "Zoom Workplace" 창은 앱이 그저 열려만 있음을 뜻합니다. 신호는 「Zoom이 살아 있는가」가 아니라 「당신 화면의 창이 라이브 통화처럼 보이는가」입니다.

폴링 간격은 단단한 상수 — pollIntervalSeconds: Double = 10.0 — 에 시작 시 한 번의 즉시 확인을 더한 것이라, 회의는 그 창이 나타난 뒤 약 10초 안에 알아채집니다. (그것은 코드 값이지 측정된 지연이 아닙니다; 「최대 ~10초」는 벤치마크가 아니라 산수로 받아들이세요.)

브라우저 회의에는 한 단계가 더 필요합니다, 왜냐하면 "Meet - Weekly Sync"라는 제목의 Chrome 창은 유망하지만 확신은 URL 안에 있기 때문입니다. 그래서 맨 앞 앱이 알려진 브라우저 — Chrome, Safari, Edge, Brave, 그리고 번들 id로 식별되는 한 줌 더 — 일 때, 바이너리는 AppleScript를 통해 활성 탭의 URL을 뽑아내어 meet.google.com/, zoom.us/j/, teams.microsoft.com/ 같은 패턴에 대해 확인합니다. 다른 앱으로의 AppleScript는 멈출 수 있으므로, 그 호출은 3초 타임아웃이 걸린 DispatchSemaphore로 지켜지는 백그라운드 DispatchQueue에서 돕니다; 꽉 막힌 브라우저는 감지기 전체를 얼어붙게 하는 대신 타임아웃됩니다.

어려운 부분은 발화하지 않기

패턴 목록을 읽었을 때 나를 놀라게 한 부분이 여기입니다. 통화를 감지하는 것은 몇 번의 문자열 매칭입니다. 통화를 감지하지 않는 것 — 당신이 원치 않을 때 발화하지 않는 것 — 이야말로 모든 주의가 들어간 곳입니다. 창-제목 패턴들의 목록은 의도적인 배제의 한 벌처럼 읽힙니다:

  • Google Meet은 "Meet - "와 그 en-대시 변형 "Meet – "에 매칭하지만, 헐벗은 문자열 "Google Meet"에는 매칭하지 않습니다 — 그것은 통화 전에 여는 랜딩 페이지이기 때문입니다.
  • Zoom은 "Zoom Meeting""Zoom Webinar"에 매칭하지만, "Zoom Workplace"나 헐벗은 "Zoom"에는 매칭하지 않습니다 — 열린 채 앉아 있는 앱입니다.
  • Teams는 "Meeting with", "Meeting in", " | Call", " | Meeting"에 매칭합니다 — 통화 중 제목의 형태이지, 유휴 상태의 클라이언트가 아닙니다.
  • Slack 허들, Webex 룸, 그리고 WhatsApp 통화는 각각 자기만의 통화 중 전용 조각을 얻습니다.

그 하나하나가, 앱은 열려 있지만 회의 중은 아닌 흔한 경우에 조용히 있겠다는 결정입니다. 설계 목표는 최대 재현율이 아닙니다; 그것은 당신이 Slack을 읽는 동안 「녹화할까요?」 배너를 튀어나오게 하는 도구가 되지 않기입니다. 여기서의 오탐은 미탐이 그렇지 않은 방식으로 비쌉니다: 통화를 놓치면 사용자는 수동으로 녹화를 시작합니다; 열린 앱에서 오발화하면 사용자는 당신의 배너를 무시하기를 배웁니다. 휴리스틱 전체가 두 번째 실패가 드물어지는 쪽으로 조율되어 있습니다.

이렇게 단순한 휴리스틱의 한계를 이름 붙이는 것은 정직한 일입니다. 패턴은 영어 전용입니다 — 현지화된 OS나 비영어 회의 앱은 매칭되지 않습니다, 인정된 v1 한계입니다. 그리고 다른 앱의 창 제목은 화면 녹화 권한이 부여되었을 때에만 보이므로, 그것이 없으면 감지는 조용히 아무것도 아닌 것으로 퇴락합니다. 단순하고, 읽히고, 그리고 팀이 숨기기보다 적어 둔 방식으로 불완전합니다.

그 권유, 그리고 그것이 잔소리가 되지 않도록 붙드는 기계 장치

패턴이 매칭되면, GeekBye는 구석에 작은 프레임 없는 항상-위 배너 — OS 알림이 아니라 그 자신의 창 — 를 Start Recording 버튼과 함께 보여 줍니다. 그것을 클릭하면 IPC 메시지(meeting:start-recording)가 배너를 숨기고, 메인 창을 띄우며, 렌더러에게 세션을 시작하라고 이릅니다. 그것을 물리치면 배너는 사라집니다.

하지만 이것의 순진한 판본은 미칠 노릇일 겁니다. 제목은 통화 중에 끊임없이 바뀝니다 — 참가자가 들어오고, 당신이 회의 이름을 바꾸고, 화면 공유가 창 이름을 바꿔치기하고 — 그리고 그 변화 하나하나가, 정확한 문자열을 키로 삼은 감지기를 재발화시킬 겁니다. 그래서 MeetingDetector.ts 안의 감지기는, 그 일 전부가 자제인 작은 상태 기계를 지니고 있습니다:

  • 플랫폼별로 안정적인 meetingId. Swift 쪽은 google-meet-active 같은 id를 내보내는데, 의도적으로 변덕스러운 창 제목에서 유도되지 않은 것이라, 한 통화 안에서의 참가, 퇴장, 이름 변경이 새 회의로 읽히지 않습니다.
  • dismissedMeetings: Set<string>. 배너를 물리치면 그 회의의 id는 무시됩니다 — 회의가 실제로 끝날 때까지는, 그리고 그 시점에 id가 비워져 다음 진짜 회의가 새로 권할 수 있게 됩니다.
  • notificationActive 불리언. 한 번에 배너 하나뿐이라, 당신이 이미 Meet에 있는 동안 Slack 허들이 튀어나와도 두 번째 권유를 쌓지 않습니다.

그것이 정말로 이 기능입니다: 약간의 감지와 많은 「성가시지 않기」. 바로 그래서 다음 두 릴리스가 존재합니다 — 왜냐하면 그 가드들 중 둘이 자기 자신을 이기는 것으로 드러났기 때문입니다.

당신의 진짜 회의를 음소거해 버린 음소거 (v1.8.18)

녹화에는 음소거 버튼이 있고, 첫 구현은 「음소거」를 가장 문자 그대로 받아들였습니다. v1.8.15에서 출시된 그것은, 운영체제의 입력 볼륨을 0으로 설정함으로써 마이크를 음소거했습니다:

osascript -e "set volume input volume 0"

복원할 수 있도록 이전 볼륨은 데이터베이스에 저장되었고, waveInSetVolume을 통한 Windows 등가물이 있었으며, 음소거 해제를 확실히 하기 위한 before-quit 안전망이 있었습니다. 코드는 그것을 기능으로까지 서술했습니다 — 「두 레벨에서」, 시스템과 앱에서, 음소거한다고, 그래서 마이크는 모든 앱에 대해 차단되었습니다.

그 마지막 어구가 자랑으로 서술된 버그입니다. 모든 앱에 대해 마이크를 차단한다는 것은, 당신이 실제로 앉아 있는 Zoom이나 Meet 통화에 대해 그것을 차단한다는 뜻입니다. 당신은 GeekBye에서 음소거를 눌러 _그것_이 당신을 듣는 것을 멈추려는 것인데, 당신은 진짜 회의에서 침묵해 버립니다. v1.8.18의 수정은 강등입니다: OS 입력 볼륨을 건드리는 것을 완전히 멈추고, GeekBye 자신의 오디오 파이프라인만 음소거합니다. Listen 모드에서는 RealtimeAudioCaptureService가 음소거된 마이크 프레임을 0 버퍼로 대체해 침묵이 믹서로 흐르게 하고; 비디오 모드에서는 ScreenRecordingService가 Web Audio GainNode를 0으로 설정합니다. 시스템 전체 음소거, 저장한 볼륨 복구, 크로스플랫폼 장치 전체 — 모두 삭제되었습니다. 음소거는 「이 앱이 듣기를 멈춘다」를 뜻해야지, 결코 「당신의 마이크가 모두에게 꺼진다」가 아닙니다.

스스로를 리셋한 가드 (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」를 읽을 수 있습니다 — 제목의 깜빡임, 잠시 화면 밖으로 나간 창 — 그리고 그 순환 하나하나가 플래그를 리셋했습니다. 그래서 가드는 당신이 녹화하는 동안 조용히 스스로를 재무장했고, 배너는 당신이 이미 녹화 중인 통화를 녹화하려 권하며 다시 나타나곤 했습니다. 수정은 로컬 플래그를 믿는 것을 멈추고 대신 진리의 원천에 묻습니다: main.ts에서 주입되고 진짜 isRealtimeTranscriptionActive 상태에 배선된 recordingActiveChecker 콜백으로, 어떤 알림이 보이기 전에도 확인됩니다.

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

교훈은 작고 일반적입니다: 당신이 상태를 소유하는 가드는 당신 자신의 제어 흐름에 의해 발밑에서 리셋될 수 있습니다. 그것이 지키는 실제 상태를 읽는 가드는 그럴 수 없습니다. notificationActive는 「우리가 녹화 중인가」의 _대리_를 추적하고 있었습니다; 수정은 우리가 녹화 중인지를 묻습니다.

이 릴리스들이 우리에게 가르친 세 가지

  1. 보기 쉬운 상태가 아니라, 당신이 뜻하는 상태를 감지하라. 「앱이 실행 중이다」는 확인하기 사소하고 틀렸습니다. 「라이브 통화처럼 보이는 창이 존재한다」는 조금 더 어렵고 옳습니다. 그 둘 사이의 간극이 곧 기능 전부입니다.
  2. 선제적 기능에서는 정밀도가 곧 제품이다. 당신이 올바르게 감지한 회의를 아무도 기억하지 못합니다; 그들이 기억하는 것은 그들이 회의 중이 아닐 때 당신이 방해한 세 번입니다. 패턴 예외와 잔소리 방지 상태 기계는 광택이 아닙니다 — 그것들은 도움 되는 배너와, 당신이 스스로를 훈련해 무시하게 되는 배너 사이의 차이입니다.
  3. 스스로를 리셋할 수 있는 가드는 가드가 아니다. 두 후속 릴리스는 같은 모양입니다: 실제 상태(OS 마이크, 전사 상태) 대신 자기가 제어하는 대리(저장한 볼륨, 로컬 불리언)를 참조한 안전 장치입니다. 가드를 진리의 사본이 아니라, 진리에 겨누세요.

v1 이야기의 이전 장은 업로드 경로에서 백엔드를 걷어내다(v1.8.11–v1.8.13)를; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.

관련 글

Web Audio를 살아 있게 하는 세 개의 동사
Steven
Steven7 분 소요

Web Audio를 살아 있게 하는 세 개의 동사

두 달 간격으로 서로 다른 파일에서 나온 두 번의 GeekBye 포인트 릴리스가, 우리의 오디오 코드에 같은 교훈을 정반대의 끝에서 가르쳤습니다: 브라우저의 AudioContext를 일회용처럼 다루기를 멈춰라. 한 릴리스는 macOS가 녹화 도중 조용히 suspend해 버린 컨텍스트를 resume()하는 법을 배웠고; 다른 하나는 연이은 세션들이 Chromium의 대략 여섯 컨텍스트 한계에 들이받는 것을 멈추도록 close() 대신 suspend()하는 법을 배웠습니다. resume, suspend, close — 그것이 줄거리 전부입니다.

엔지니어링
Audio
데스크톱
업로드 경로에서 백엔드를 걷어내다
Steven
Steven7 분 소요

업로드 경로에서 백엔드를 걷어내다

GeekBye는 당신의 화면을 녹화하고 그 영상을 당신의 Google Drive에 저장합니다. 첫 버전은 그곳에 이르는 길에 모든 녹화를 GeekBye 자신의 서버를 거쳐 보냈습니다; 한 릴리스 뒤에는, 파일이 당신의 기계에서 곧장 Drive로 갔고, 백엔드는 포인터 하나를 쥐고 있는 것으로 강등되었습니다. 흥미로운 대목은 그 「직접, 재개 가능한」 버전이 실제로 얼마나 적은 코드를 담고 있는가입니다 — 재개 가능성은 프록시를 쓰는 데서가 아니라, 그것을 지우는 데서 왔기 때문입니다.

엔지니어링
아키텍처
데스크톱
십이 분 만에 태어나고 고쳐지다
Steven
Steven6 분 소요

십이 분 만에 태어나고 고쳐지다

GeekBye v1.8.10의 changelog는 키보드 단축키를 편집하는 중의 크래시를 고쳤다고 말합니다. 고친 것은 사실입니다 — 하지만 그 크래시는 같은 풀 리퀘스트 안에서, 십이 분 간격을 두고, 도입되고 또 고쳐졌으며, 단 한 명의 사용자에게도 닿지 않았습니다. 진짜 이야기는 그것을 낳은 신뢰성의 연쇄입니다: 단축키를 언제 일시 중단할지에 대한 작고 올바른 변경과, 에디터를 더 안전하게 만들려던 한 줄에서 굴러떨어진 React 해체(teardown) 버그.

엔지니어링
React
데스크톱