
Distinguir uma chamada de um app aberto
A GeekBye pode notar que você entrou em uma reunião por vídeo e oferecer gravá-la. A detecção acaba sendo a metade fácil — um binário Swift que lê títulos de janela a cada dez segundos. A metade difícil é a precisão: não disparar quando o Zoom está meramente aberto, não oferecer gravar uma reunião que você já está gravando, e não silenciar o microfone na chamada em que você realmente está. Três releases, e cada uma é uma salvaguarda que teve que aprender a não se sabotar.
A GeekBye tem um recurso que soa trivial e não é: ela nota quando você entrou em uma reunião por vídeo e oferece, com um clique, começar a gravá-la. O argumento cabe em uma única frase. A implementação são três releases de aprender que o problema interessante nunca foi «detectar uma reunião» — foi «acertar sobre ela». Acertar sobre se você está de fato em uma chamada versus só ter o app aberto. Acertar sobre não perguntar duas vezes. Acertar sobre não silenciar o microfone errado. Cada uma das três releases é, no fundo, uma salvaguarda que precisou ser ensinada a não tropeçar em si mesma.
Títulos de janela, não processos
A primeira e mais consequente decisão está escrita numa mensagem de commit como uma declaração de design, não como uma nota de rodapé:
Detection uses window title matching (not process detection) to distinguish "app is open" from "in an active call."
Essa distinção é o recurso inteiro. Se você detectasse reuniões olhando quais apps estão em execução, você dispararia no instante em que o Zoom fosse lançado — e o Zoom fica aberto em um monte de desktops o dia todo sem uma única chamada. Então a GeekBye não vigia processos. No macOS ela roda um pequeno binário Swift nativo, MeetingMonitor, que a cada dez segundos chama CGWindowListCopyWindowInfo para as janelas na tela e lê o título de cada uma via kCGWindowName. Uma janela intitulada "Zoom Meeting" significa que você está em uma chamada; a janela "Zoom Workplace" significa que o app está meramente aberto. O sinal não é «o Zoom está vivo», é «alguma janela na sua tela parece uma chamada ao vivo».
O intervalo de consulta é uma constante fixa — pollIntervalSeconds: Double = 10.0 — mais uma verificação imediata na inicialização, então uma reunião é notada dentro de cerca de dez segundos depois que sua janela aparece. (Esse é um valor de código, não uma latência medida; trate «até ~10 s» como aritmética, não como um benchmark.)
Reuniões no navegador precisam de mais um passo, porque uma janela do Chrome intitulada "Meet - Weekly Sync" é promissora mas a certeza está na URL. Então quando o app em primeiro plano é um navegador conhecido — Chrome, Safari, Edge, Brave, e um punhado mais identificados por bundle id — o binário extrai a URL da aba ativa através do AppleScript e a verifica contra padrões como meet.google.com/, zoom.us/j/, e teams.microsoft.com/. AppleScript para outro app pode travar, então essa chamada roda em uma DispatchQueue em segundo plano protegida por um DispatchSemaphore com um timeout de 3 segundos; um navegador emperrado expira em vez de congelar o detector inteiro.
A parte difícil é não disparar
Aqui está a parte que me surpreendeu quando li a lista de padrões. Detectar uma chamada são algumas correspondências de string. Não detectar uma — não disparar quando você não quer — é onde foi todo o cuidado. A lista de padrões de título de janela se lê como um conjunto de exclusões deliberadas:
- O Google Meet casa com
"Meet - "e sua variante com travessão"Meet – ", mas não com a string pelada"Google Meet"— porque essa é a página inicial que você abre antes de uma chamada. - O Zoom casa com
"Zoom Meeting"e"Zoom Webinar", mas não com"Zoom Workplace"nem com um"Zoom"pelado — o app parado aberto. - O Teams casa com
"Meeting with","Meeting in"," | Call"," | Meeting"— as formas de título em chamada, não o cliente ocioso. - Huddles do Slack, salas do Webex e chamadas do WhatsApp recebem cada um seus próprios fragmentos exclusivos de estar em chamada.
Cada uma dessas é uma decisão de ficar quieto no caso comum em que um app está aberto mas você não está em uma reunião. O objetivo de design não é recall máximo; é não ser a ferramenta que solta um banner de «quer gravar?» enquanto você lê o Slack. Um falso positivo aqui é caro de um jeito que um falso negativo não é: perca uma chamada e o usuário começa a gravar manualmente; dispare errado sobre um app aberto e o usuário aprende a ignorar seu banner. Toda a heurística está ajustada para que a segunda falha seja rara.
É honesto nomear os limites de uma heurística tão simples. Os padrões são só em inglês — um sistema operacional localizado ou um app de reunião não anglófono não vai casar, uma limitação reconhecida da v1. E porque os títulos de janela de outros apps só são visíveis com a permissão de Gravação de Tela concedida, a detecção se degrada silenciosamente até nada sem ela. Simples, legível, e imperfeita de maneiras que o time anotou em vez de esconder.
A oferta, e o maquinário que a impede de amolar
Quando um padrão casa, a GeekBye mostra um pequeno banner sem moldura sempre-no-topo no canto — sua própria janela, não uma notificação do sistema operacional — com um botão Começar a gravar. Clique e uma mensagem IPC (meeting:start-recording) oculta o banner, traz a janela principal, e diz ao renderizador para iniciar uma sessão. Descarte e o banner some.
Mas uma versão ingênua disso seria enlouquecedora. Os títulos mudam constantemente durante uma chamada — um participante entra, você renomeia a reunião, um compartilhamento de tela troca o nome da janela — e cada mudança redispararia um detector fixado na string exata. Então o detector, em MeetingDetector.ts, carrega uma pequena máquina de estados cujo trabalho inteiro é contenção:
- Um
meetingIdestável por plataforma. O lado Swift emite um id comogoogle-meet-active, deliberadamente não derivado do volátil título de janela, então entradas, saídas e renomeações dentro de uma mesma chamada não se leem como uma reunião nova. - Um
dismissedMeetings: Set<string>. Descarte o banner e o id daquela reunião é ignorado — até a reunião de fato terminar, ponto em que o id se limpa para que a próxima reunião real possa oferecer do zero. - Um booleano
notificationActive. Só um banner por vez, então um huddle do Slack surgindo enquanto você já está em um Meet não empilha uma segunda oferta.
É isso o recurso, na verdade: um pouco de detecção e muito de «não ser irritante». Que é exatamente por que as duas releases seguintes existem — porque duas dessas salvaguardas acabaram se sabotando.
O silenciamento que silenciava sua reunião de verdade (v1.8.18)
A gravação tem um botão de silenciar, e a primeira implementação levou «silenciar» ao seu sentido mais literal. Publicado na v1.8.15, ele silenciava o microfone ajustando o volume de entrada do sistema operacional para zero:
osascript -e "set volume input volume 0"
com o volume anterior salvo no banco de dados para poder ser restaurado, um equivalente no Windows através de waveInSetVolume, e uma rede de segurança before-quit para garantir que ele tirasse o mudo. O código até o descrevia como um recurso — silenciar «em DOIS níveis», sistema e app, para que o microfone ficasse bloqueado para todos os apps.
Essa última frase é o bug enunciado como um vanglório. Bloquear o microfone para todos os apps significa bloqueá-lo para a chamada de Zoom ou Meet em que você realmente está sentado. Você aperta silenciar na GeekBye para impedir que ela te ouça, e você ficou mudo na sua reunião de verdade. A correção da v1.8.18 é um rebaixamento: parar de mexer no volume de entrada do sistema operacional por completo, e silenciar apenas o próprio pipeline de áudio da GeekBye. No modo Escuta, RealtimeAudioCaptureService substitui os frames de microfone silenciados por um buffer zerado para que silêncio flua ao mixer; no modo vídeo, ScreenRecordingService ajusta um GainNode do Web Audio para zero. O silenciamento em nível de sistema, a recuperação do volume salvo, todo o aparato multiplataforma — tudo apagado. Silenciar deveria significar «este app para de escutar», nunca «seu microfone desliga para todo mundo».
A salvaguarda que se reiniciava sozinha (v1.8.19)
A outra salvaguarda que se sabotava era notificationActive. No papel ela já respondia a «não mostre um banner se já há um» — e parecia que também deveria cobrir «não ofereça gravar uma reunião que já estou gravando». Não cobria, e o commit que a corrige explica por que com precisão:
The notificationActive flag resets when the meeting detector cycles through ended/detected states, causing notifications to reappear mid-recording.
O detector é uma consulta ao vivo; ao longo de uma chamada longa ele pode ler brevemente «terminado» e depois «detectado» de novo — uma piscada de título, uma janela momentaneamente fora da tela — e cada ciclo reiniciava o flag. Então a salvaguarda seguia se rearmando silenciosamente enquanto você gravava, e o banner reaparecia oferecendo gravar a chamada que você já estava gravando. A correção para de confiar num flag local e pergunta à fonte da verdade em vez disso: um callback recordingActiveChecker, injetado de main.ts e ligado ao estado real isRealtimeTranscriptionActive, verificado antes de qualquer notificação aparecer.
// Don't show notification if recording/transcription is already active
if (this.recordingActiveChecker?.()) {
return
}
A lição é pequena e geral: uma salvaguarda cujo estado você possui pode ser reiniciada por baixo de você pelo seu próprio fluxo de controle. Uma salvaguarda que lê o estado real que protege não pode. notificationActive estava rastreando um proxy de «estamos gravando»; a correção pergunta se estamos gravando.
Três coisas que estas releases nos ensinaram
- Detecte o estado que você quer dizer, não o que é fácil de ver. «O app está rodando» é trivial de verificar e errado. «Existe uma janela que parece uma chamada ao vivo» é um pouco mais difícil e certo. O vão entre esses dois é o recurso inteiro.
- Para um recurso proativo, a precisão é o produto. Ninguém lembra da reunião que você detectou corretamente; lembram das três vezes em que você os interrompeu quando não estavam em uma. As exclusões de padrões e a máquina de estados anti-amolação não são polimento — são a diferença entre um banner útil e um que você se treina para ignorar.
- Uma salvaguarda que pode se reiniciar sozinha não é uma salvaguarda. As duas releases de acompanhamento têm a mesma forma: um mecanismo de segurança que referenciava um proxy que controlava (um volume salvo, um booleano local) em vez do estado real (o microfone do sistema operacional, o status de transcrição). Aponte as salvaguardas para a verdade, não para uma cópia dela.
Para o capítulo anterior da história v1, tirando o backend do caminho de upload (v1.8.11–v1.8.13); e para todo o arco, a anatomia de entregar software até a perfeição.