Steven
Steven9 min de leitura

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.

Engenharia
macOS
Desktop
Lançamentos da GeekBye
Distinguir uma chamada de um app aberto

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 meetingId estável por plataforma. O lado Swift emite um id como google-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

  1. 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.
  2. 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.
  3. 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.

Artigos Relacionados

Os três verbos que mantêm o Web Audio vivo
Steven
Steven10 min de leitura

Os três verbos que mantêm o Web Audio vivo

Duas releases pontuais da GeekBye, com dois meses de diferença e em dois arquivos distintos, ensinaram ao nosso código de áudio a mesma lição a partir de extremos opostos: parar de tratar o AudioContext do navegador como descartável. Uma release aprendeu a fazer resume() de um contexto que o macOS havia suspendido silenciosamente no meio da gravação; a outra aprendeu a fazer suspend() em vez de close() para que sessões consecutivas parem de se espatifar contra o teto de aproximadamente seis contextos do Chromium. Resume, suspend, close — é esse o enredo inteiro.

Engenharia
Audio
Desktop
Tirando o backend do caminho de upload
Steven
Steven9 min de leitura

Tirando o backend do caminho de upload

A GeekBye grava sua tela e salva o vídeo no seu Google Drive. A primeira versão enviava cada gravação pelos próprios servidores da GeekBye no caminho; uma release depois, o arquivo ia direto da sua máquina para o Drive, e o backend foi rebaixado a segurar um único ponteiro. A parte interessante é o quão pouco código a versão «direta e retomável» realmente contém — porque a retomabilidade veio de apagar um proxy, não de escrever um.

Engenharia
Arquitetura
Desktop
Nascido e corrigido em doze minutos
Steven
Steven8 min de leitura

Nascido e corrigido em doze minutos

O changelog da GeekBye v1.8.10 diz que corrigiu um crash ao editar os atalhos de teclado. Corrigiu — mas o crash foi introduzido e corrigido no mesmo pull request, com doze minutos de diferença, e nunca chegou a um único usuário. A história real é a cascata de confiabilidade que o produziu: uma mudança pequena e correta sobre quando os atalhos são suspensos, e o bug de teardown de React que caiu de uma linha pensada para tornar o editor mais seguro.

Engenharia
React
Desktop