Steven
Steven8 min läsning

Att skilja ett samtal från en öppen app

GeekBye kan märka att du har gått med i ett videomöte och erbjuda sig att spela in det. Detekteringen visar sig vara den lätta halvan — en Swift-binär som läser fönstertitlar var tionde sekund. Den svåra halvan är precision: att inte lösa ut när Zoom bara är öppet, att inte fråga om ett möte du redan spelar in, och att inte tysta mikrofonen i samtalet du faktiskt sitter i. Tre releaser, och var och en är en skyddsspärr som måste lära sig att inte besegra sig själv.

Utveckling
macOS
Desktop
GeekBye-releaser
Att skilja ett samtal från en öppen app

GeekBye har en funktion som låter trivial och inte är det: den märker när du har gått med i ett videomöte och erbjuder sig, med ett klick, att börja spela in det. Pitchen är en enda mening. Implementationen är tre releaser av att lära sig att det intressanta problemet aldrig var "detektera ett möte" — det var "att ha rätt om det." Rätt om huruvida du faktiskt sitter i ett samtal kontra att bara ha appen öppen. Rätt om att inte fråga två gånger. Rätt om att inte tysta fel mikrofon. Var och en av de tre releaserna är, i grunden, en skyddsspärr som måste läras att inte snubbla över sig själv.

Fönstertitlar, inte processer

Det första och mest följdrika beslutet är inskrivet i ett commit-meddelande som ett designuttalande, inte en fotnot:

Detection uses window title matching (not process detection) to distinguish "app is open" from "in an active call."

Den distinktionen är hela funktionen. Om du detekterade möten genom att titta på vilka appar som körs skulle du lösa ut i samma ögonblick som Zoom startade — och Zoom är öppet på många skrivbord hela dagen utan ett enda samtal. Så GeekBye bevakar inte processer. På macOS kör den en liten nativ Swift-binär, MeetingMonitor, som var tionde sekund anropar CGWindowListCopyWindowInfo för fönstren på skärmen och läser varje fönsters titel via kCGWindowName. Ett fönster med titeln "Zoom Meeting" betyder att du är i ett samtal; fönstret "Zoom Workplace" betyder att appen bara är öppen. Signalen är inte "lever Zoom," den är "ser ett fönster på din skärm ut som ett pågående samtal."

Pollningsintervallet är en hård konstant — pollIntervalSeconds: Double = 10.0 — plus en omedelbar kontroll vid uppstart, så ett möte upptäcks inom ungefär tio sekunder efter att dess fönster dyker upp. (Det är ett kodvärde, inte en uppmätt latens; behandla "upp till ~10 s" som aritmetik, inte en benchmark.)

Webbläsarmöten behöver ett steg till, eftersom ett Chrome-fönster med titeln "Meet - Weekly Sync" är lovande men säkerheten ligger i URL:en. Så när den främsta appen är en känd webbläsare — Chrome, Safari, Edge, Brave, och en handfull till identifierade via bundle-id — extraherar binären den aktiva flikens URL genom AppleScript och kontrollerar den mot mönster som meet.google.com/, zoom.us/j/, och teams.microsoft.com/. AppleScript in i en annan app kan hänga, så det anropet körs på en bakgrunds-DispatchQueue skyddad av en DispatchSemaphore med en 3-sekunders timeout; en fastkilad webbläsare når timeout i stället för att frysa hela detektorn.

Det svåra är att inte lösa ut

Här är delen som överraskade mig när jag läste mönsterlistan. Att detektera ett samtal är några få strängmatchningar. Att inte detektera ett — att inte lösa ut när du inte vill — är dit all omsorg gick. Listan av fönstertitel-mönster läses som en uppsättning medvetna undantag:

  • Google Meet matchar "Meet - " och dess en-dash-variant "Meet – ", men inte den nakna strängen "Google Meet" — eftersom det är landningssidan du öppnar före ett samtal.
  • Zoom matchar "Zoom Meeting" och "Zoom Webinar", men inte "Zoom Workplace" eller ett naket "Zoom" — appen som står öppen.
  • Teams matchar "Meeting with", "Meeting in", " | Call", " | Meeting" — titelformerna under samtal, inte den vilande klienten.
  • Slack-huddles, Webex-rum och WhatsApp-samtal får var och en sina egna fragment som bara gäller under samtal.

Var och en av dem är ett beslut att hålla tyst i det vanliga fallet där en app är öppen men du inte är i ett möte. Designmålet är inte maximal täckning; det är att inte vara verktyget som slänger upp en "vill du spela in?"-banner medan du läser Slack. En falsk positiv här är dyr på ett sätt som en falsk negativ inte är: missa ett samtal och användaren börjar spela in manuellt; lös ut fel på en öppen app och användaren lär sig att ignorera din banner. Hela heuristiken är intrimmad mot att det andra felet ska vara sällsynt.

Det är ärligt att namnge begränsningarna hos en heuristik så här enkel. Mönstren är enbart på engelska — ett lokaliserat OS eller en icke-engelsk mötesapp matchar inte, en erkänd v1-begränsning. Och eftersom fönstertitlar för andra appar bara är synliga med behörigheten Skärminspelning beviljad, degraderas detekteringen tyst till ingenting utan den. Enkel, läsbar och ofullkomlig på sätt som teamet skrev ner snarare än dolde.

Erbjudandet, och maskineriet som håller det från att tjata

När ett mönster matchar visar GeekBye en liten ramlös alltid-överst-banner i hörnet — sitt eget fönster, inte en OS-notis — med en Start Recording-knapp. Klicka på den och ett IPC-meddelande (meeting:start-recording) döljer bannern, tar fram huvudfönstret och säger åt rendereraren att starta en session. Avfärda den och bannern försvinner.

Men en naiv version av detta skulle vara till att bli galen av. Titlar ändras hela tiden under ett samtal — en deltagare går med, du byter namn på mötet, en skärmdelning byter fönstrets namn — och varje ändring skulle återutlösa en detektor som nycklade på den exakta strängen. Så detektorn, i MeetingDetector.ts, bär en liten tillståndsmaskin vars hela uppgift är återhållsamhet:

  • Ett stabilt meetingId per plattform. Swift-sidan avger ett id som google-meet-active, medvetet inte härlett från den flyktiga fönstertiteln, så att gå med, lämna och byta namn inom ett samtal inte läses som ett nytt möte.
  • En dismissedMeetings: Set<string>. Avfärda bannern och det mötets id ignoreras — tills mötet faktiskt slutar, varvid id:t rensas så att nästa verkliga möte kan fråga på nytt.
  • En notificationActive-boolean. Bara en banner åt gången, så att en Slack-huddle som dyker upp medan du redan är i en Meet inte staplar en andra fråga.

Det är funktionen, egentligen: lite detektering och mycket "var inte irriterande." Vilket är precis varför de nästa två releaserna finns — eftersom två av de där skyddsspärrarna visade sig besegra sig själva.

Tystningen som tystade ditt faktiska möte (v1.8.18)

Inspelning har en tystningsknapp, och den första implementationen tog "tysta" i dess mest bokstavliga bemärkelse. Levererad i v1.8.15 tystade den mikrofonen genom att sätta operativsystemets ingångsvolym till noll:

osascript -e "set volume input volume 0"

med den tidigare volymen sparad i databasen så att den kunde återställas, en Windows-motsvarighet genom waveInSetVolume, och ett before-quit-skyddsnät för att säkerställa att den avtystades. Koden beskrev det till och med som en funktion — att tysta "på TVÅ nivåer," system och app, så att mikrofonen blockerades för alla appar.

Den sista frasen är buggen formulerad som en skryt. Att blockera mikrofonen för alla appar betyder att blockera den för Zoom- eller Meet-samtalet du faktiskt sitter i. Du trycker på tysta i GeekBye för att hindra den från att höra dig, och du har blivit tyst i ditt riktiga möte. v1.8.18-fixen är en degradering: sluta röra OS:ets ingångsvolym helt, och tysta bara GeekByes egen ljudpipeline. I Listen-läge ersätter RealtimeAudioCaptureService tystade mikrofonrutor med en nollbuffert så att tystnad flödar till mixern; i videoläge sätter ScreenRecordingService en Web Audio-GainNode till noll. Den systemomfattande tystningen, återställningen av sparad volym, hela den plattformsövergripande apparaten — allt raderat. Tysta borde betyda "den här appen slutar lyssna," aldrig "din mikrofon stängs av för alla."

Skyddsspärren som återställde sig själv (v1.8.19)

Den andra självbesegrande skyddsspärren var notificationActive. På pappret svarade den redan på "visa inte en banner om en redan är uppe" — och det såg ut som att den också borde täcka "erbjud dig inte att spela in ett möte jag redan spelar in." Det gjorde den inte, och commiten som fixar det förklarar exakt varför:

The notificationActive flag resets when the meeting detector cycles through ended/detected states, causing notifications to reappear mid-recording.

Detektorn är en levande pollning; under ett långt samtal kan den kort läsa "ended" och sedan "detected" igen — en titel som flimrar, ett fönster tillfälligt utanför skärmen — och varje cykel återställde flaggan. Så skyddsspärren fortsatte att tyst återaktivera sig själv medan du spelade in, och bannern dök upp igen och erbjöd sig att spela in samtalet du redan spelade in. Fixen slutar lita på en lokal flagga och frågar sanningskällan i stället: en recordingActiveChecker-callback, injicerad från main.ts och kopplad till det verkliga isRealtimeTranscriptionActive-tillståndet, kontrollerad innan någon notis visas.

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

Lärdomen är liten och allmän: en skyddsspärr vars tillstånd du äger kan återställas under dig av ditt eget kontrollflöde. En skyddsspärr som läser det faktiska tillståndet den skyddar kan det inte. notificationActive spårade en proxy för "spelar vi in"; fixen frågar om vi spelar in.

Tre saker dessa releaser lärde oss

  1. Detektera tillståndet du menar, inte det som är lätt att se. "Appen körs" är trivialt att kontrollera och fel. "Ett fönster som ser ut som ett pågående samtal finns" är lite svårare och rätt. Klyftan mellan de två är hela funktionen.
  2. För en proaktiv funktion är precisionen produkten. Ingen minns mötet du detekterade korrekt; de minns de tre gångerna du avbröt dem när de inte var i något. Mönsterundantagen och anti-tjat-tillståndsmaskinen är inte polering — de är skillnaden mellan en hjälpsam banner och en du tränar dig själv att ignorera.
  3. En skyddsspärr som kan återställa sig själv är ingen skyddsspärr. Båda uppföljningsreleaserna har samma form: en säkerhetsmekanism som refererade till en proxy den kontrollerade (en sparad volym, en lokal boolean) i stället för det verkliga tillståndet (OS-mikrofonen, transkriberingsstatusen). Rikta skyddsspärrar mot sanningen, inte mot en kopia av den.

För det föregående kapitlet i v1-historien, att ta backend ur uppladdningsvägen (v1.8.11–v1.8.13); och för hela bågen, anatomin i att skeppa mjukvara till perfektion.

Relaterade artiklar

De tre verben som håller Web Audio vid liv
Steven
Steven8 min läsning

De tre verben som håller Web Audio vid liv

Två GeekBye-punktreleaser, två månader isär och i två olika filer, lärde vår ljudkod samma läxa från motsatta håll: sluta behandla webbläsarens AudioContext som slit-och-släng. Den ena releasen lärde sig att anropa resume() på en kontext som macOS tyst hade suspenderat mitt i inspelningen; den andra lärde sig att anropa suspend() i stället för close() så att inspelningar i följd slutar dundra in i Chromiums tak på ungefär sex kontexter. Resume, suspend, close — det är hela handlingen.

Utveckling
Audio
Desktop
Att ta backend ur uppladdningsvägen
Steven
Steven8 min läsning

Att ta backend ur uppladdningsvägen

GeekBye spelar in din skärm och sparar videon till din Google Drive. Den första versionen skickade varje inspelning genom GeekByes egna servrar på vägen dit; en release senare gick filen rakt från din maskin till Drive, och backend degraderades till att hålla en enda pekare. Den intressanta delen är hur lite kod den 'direkta, återupptagbara' versionen faktiskt innehåller — eftersom återupptagbarheten kom från att radera en proxy, inte från att skriva en.

Utveckling
Arkitektur
Desktop
Född och fixad på tolv minuter
Steven
Steven7 min läsning

Född och fixad på tolv minuter

GeekBye v1.8.10:s ändringslogg säger att den fixade en krasch vid redigering av tangentbordsgenvägar. Det gjorde den — men kraschen introducerades och fixades i samma pull request, tolv minuter isär, och nådde aldrig en enda användare. Den verkliga historien är tillförlitlighetskaskaden som producerade den: en liten, korrekt ändring av när genvägar pausas, och React-nedmonteringsbuggen som föll ut ur en enda rad som var tänkt att göra editorn säkrare.

Utveckling
React
Desktop