Steven
Steven8 min læsning

At skelne et opkald fra en åben app

GeekBye kan bemærke, at du er gået med i et videomøde, og tilbyde at optage det. Detekteringen viser sig at være den lette halvdel — en Swift-binær, der læser vinduestitler hvert tiende sekund. Den svære halvdel er præcision: ikke at udløse, når Zoom bare er åben, ikke at spørge om et møde, du allerede optager, og ikke at dæmpe mikrofonen i det opkald, du faktisk sidder i. Tre releases, og hver enkelt er et værn, der måtte lære ikke at besejre sig selv.

Udvikling
macOS
Desktop
GeekBye-releaser
At skelne et opkald fra en åben app

GeekBye har en funktion, der lyder triviel og ikke er det: den bemærker, når du er gået med i et videomøde, og tilbyder med ét klik at begynde at optage det. Pitchet er en enkelt sætning. Implementeringen er tre releases af at lære, at det interessante problem aldrig var "detektér et møde" — det var "at have ret om det." Ret om, hvorvidt du faktisk sidder i et opkald kontra bare at have appen åben. Ret om ikke at spørge to gange. Ret om ikke at dæmpe den forkerte mikrofon. Hver af de tre releases er, i sin kerne, et værn, der måtte læres ikke at snuble over sig selv.

Vinduestitler, ikke processer

Den første og mest følgesvangre beslutning er skrevet ind i en commit-besked som et designudsagn, ikke en fodnote:

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

Den distinktion er hele funktionen. Hvis du detekterede møder ved at kigge på, hvilke apps der kører, ville du udløse i det øjeblik Zoom startede — og Zoom er åben på mange skriveborde hele dagen uden et eneste opkald. Så GeekBye overvåger ikke processer. På macOS kører den en lille nativ Swift-binær, MeetingMonitor, der hvert tiende sekund kalder CGWindowListCopyWindowInfo for vinduerne på skærmen og læser hvert vindues titel via kCGWindowName. Et vindue med titlen "Zoom Meeting" betyder, at du er i et opkald; vinduet "Zoom Workplace" betyder, at appen blot er åben. Signalet er ikke "lever Zoom," det er "ser et vindue på din skærm ud som et igangværende opkald."

Pollingintervallet er en hård konstant — pollIntervalSeconds: Double = 10.0 — plus ét øjeblikkeligt tjek ved opstart, så et møde bemærkes inden for cirka ti sekunder efter, at dets vindue dukker op. (Det er en kodeværdi, ikke en målt latens; behandl "op til ~10 s" som aritmetik, ikke en benchmark.)

Browsermøder kræver ét skridt mere, fordi et Chrome-vindue med titlen "Meet - Weekly Sync" er lovende, men sikkerheden ligger i URL'en. Så når den forreste app er en kendt browser — Chrome, Safari, Edge, Brave og en håndfuld flere identificeret via bundle-id — udtrækker binæren den aktive fanes URL gennem AppleScript og tjekker den mod mønstre som meet.google.com/, zoom.us/j/ og teams.microsoft.com/. AppleScript ind i en anden app kan hænge, så det kald kører på en baggrunds-DispatchQueue beskyttet af en DispatchSemaphore med en 3-sekunders timeout; en fastlåst browser rammer timeout i stedet for at fryse hele detektoren.

Det svære er ikke at udløse

Her er den del, der overraskede mig, da jeg læste mønsterlisten. At detektere et opkald er nogle få strengmatch. Ikke at detektere et — ikke at udløse, når du ikke vil — er der, al omhuen gik hen. Listen af vinduestitel-mønstre læses som et sæt bevidste undtagelser:

  • Google Meet matcher "Meet - " og dens en-dash-variant "Meet – ", men ikke den nøgne streng "Google Meet" — fordi det er landingssiden, du åbner før et opkald.
  • Zoom matcher "Zoom Meeting" og "Zoom Webinar", men ikke "Zoom Workplace" eller et nøgent "Zoom" — appen, der står åben.
  • Teams matcher "Meeting with", "Meeting in", " | Call", " | Meeting" — titelformerne under opkald, ikke den inaktive klient.
  • Slack-huddles, Webex-rum og WhatsApp-opkald får hver deres egne fragmenter, der kun gælder under opkald.

Hver eneste af dem er en beslutning om at holde tyst i det almindelige tilfælde, hvor en app er åben, men du ikke er i et møde. Designmålet er ikke maksimal recall; det er ikke at være værktøjet, der popper en "vil du optage?"-banner op, mens du læser Slack. En falsk positiv her er dyr på en måde, en falsk negativ ikke er: gå glip af et opkald, og brugeren begynder at optage manuelt; udløs forkert på en åben app, og brugeren lærer at ignorere din banner. Hele heuristikken er trimmet mod, at den anden fejl er sjælden.

Det er ærligt at nævne grænserne for en heuristik så simpel som denne. Mønstrene er kun på engelsk — et lokaliseret OS eller en ikke-engelsk mødeapp matcher ikke, en anerkendt v1-begrænsning. Og fordi vinduestitler for andre apps kun er synlige med tilladelsen Skærmoptagelse givet, degraderer detekteringen stille til ingenting uden den. Simpel, læsbar og ufuldkommen på måder, som teamet skrev ned frem for at skjule.

Tilbuddet, og maskineriet, der holder det fra at plage

Når et mønster matcher, viser GeekBye en lille rammeløs altid-øverst-banner i hjørnet — sit eget vindue, ikke en OS-notifikation — med en Start Recording-knap. Klik på den, og en IPC-besked (meeting:start-recording) skjuler banneret, henter hovedvinduet frem og beder rendereren om at starte en session. Afvis den, og banneret forsvinder.

Men en naiv version af dette ville være til at blive vanvittig af. Titler ændrer sig konstant under et opkald — en deltager går med, du omdøber mødet, en skærmdeling skifter vinduets navn — og hver ændring ville genudløse en detektor, der nøglede på den nøjagtige streng. Så detektoren, i MeetingDetector.ts, bærer en lille tilstandsmaskine, hvis hele opgave er tilbageholdenhed:

  • Et stabilt meetingId per platform. Swift-siden udsender et id som google-meet-active, bevidst ikke afledt af den flygtige vinduestitel, så tilslutninger, frakoblinger og omdøbninger inden for ét opkald ikke læses som et nyt møde.
  • Et dismissedMeetings: Set<string>. Afvis banneret, og det mødes id ignoreres — indtil mødet faktisk slutter, hvorpå id'et ryddes, så det næste virkelige møde kan spørge på ny.
  • En notificationActive-boolean. Kun én banner ad gangen, så en Slack-huddle, der dukker op, mens du allerede er i en Meet, ikke stabler en anden prompt.

Det er funktionen, egentlig: lidt detektering og en masse "vær ikke irriterende." Hvilket er præcis derfor, de næste to releases eksisterer — fordi to af de værn viste sig at besejre sig selv.

Dæmpningen, der dæmpede dit faktiske møde (v1.8.18)

Optagelse har en dæmpningsknap, og den første implementering tog "dæmp" i dens mest bogstavelige forstand. Leveret i v1.8.15 dæmpede den mikrofonen ved at sætte operativsystemets inputvolumen til nul:

osascript -e "set volume input volume 0"

med den forrige volumen gemt i databasen, så den kunne gendannes, en Windows-ækvivalent gennem waveInSetVolume og et before-quit-sikkerhedsnet for at sikre, at den blev afdæmpet. Koden beskrev det endda som en funktion — at dæmpe "på TO niveauer," system og app, så mikrofonen blev blokeret for alle apps.

Den sidste sætning er buggen udtrykt som en pralen. At blokere mikrofonen for alle apps betyder at blokere den for det Zoom- eller Meet-opkald, du faktisk sidder i. Du trykker på dæmp i GeekBye for at forhindre den i at høre dig, og du er blevet tavs i dit rigtige møde. v1.8.18-fixet er en degradering: hold helt op med at røre OS'ets inputvolumen, og dæmp kun GeekByes egen lydpipeline. I Listen-tilstand erstatter RealtimeAudioCaptureService dæmpede mikrofon-frames med en nulbuffer, så stilhed flyder til mixeren; i videotilstand sætter ScreenRecordingService en Web Audio-GainNode til nul. Den systemomfattende dæmpning, gendannelsen af den gemte volumen, hele det tværplatforms-apparat — alt sammen slettet. Dæmp bør betyde "denne app holder op med at lytte," aldrig "din mikrofon slukker for alle."

Værnet, der nulstillede sig selv (v1.8.19)

Det andet selvbesejrende værn var notificationActive. På papiret svarede det allerede på "vis ikke en banner, hvis en er oppe" — og det så ud til, at det også burde dække "tilbyd ikke at optage et møde, jeg allerede optager." Det gjorde det ikke, og commiten, der fikser det, forklarer præcis hvorfor:

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

Detektoren er en levende polling; over et langt opkald kan den kortvarigt læse "ended" og så "detected" igen — et titel-flimmer, et vindue et øjeblik uden for skærmen — og hver cyklus nulstillede flaget. Så værnet blev ved med stille at genaktivere sig selv, mens du optog, og banneret dukkede op igen og tilbød at optage det opkald, du allerede optog. Fixet holder op med at stole på et lokalt flag og spørger sandhedskilden i stedet: et recordingActiveChecker-callback, indsprøjtet fra main.ts og koblet til den virkelige isRealtimeTranscriptionActive-tilstand, tjekket før nogen notifikation vises.

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

Lektien er lille og generel: et værn, hvis tilstand du ejer, kan blive nulstillet under dig af dit eget kontrolflow. Et værn, der læser den faktiske tilstand, det beskytter, kan ikke. notificationActive sporede en proxy for "optager vi"; fixet spørger, om vi optager.

Tre ting, disse releases lærte os

  1. Detektér den tilstand, du mener, ikke den, der er let at se. "Appen kører" er trivielt at tjekke og forkert. "Der findes et vindue, der ligner et igangværende opkald" er lidt sværere og korrekt. Kløften mellem de to er hele funktionen.
  2. For en proaktiv funktion er præcision produktet. Ingen husker mødet, du detekterede korrekt; de husker de tre gange, du afbrød dem, da de ikke var i ét. Mønsterundtagelserne og anti-plage-tilstandsmaskinen er ikke finpudsning — de er forskellen mellem en hjælpsom banner og en, du træner dig selv til at ignorere.
  3. Et værn, der kan nulstille sig selv, er ikke et værn. Begge opfølgende releases har samme form: en sikkerhedsmekanisme, der refererede til en proxy, den kontrollerede (en gemt volumen, en lokal boolean), i stedet for den virkelige tilstand (OS-mikrofonen, transskriptionsstatussen). Ret værn mod sandheden, ikke mod en kopi af den.

For det forrige kapitel i v1-historien, at tage backend ud af upload-stien (v1.8.11–v1.8.13); og for hele buen, anatomien i at shippe software til perfektion.

Relaterede artikler

De tre verber, der holder Web Audio i live
Steven
Steven9 min læsning

De tre verber, der holder Web Audio i live

To GeekBye-punktreleases, to måneder fra hinanden og i to forskellige filer, lærte vores lydkode den samme lektie fra modsatte ender: hold op med at behandle browserens AudioContext som engangsbrug. Den ene release lærte at kalde resume() på en kontekst, macOS stille havde suspenderet midt i optagelsen; den anden lærte at kalde suspend() i stedet for close(), så optagelser i træk holder op med at buldre ind i Chromiums loft på cirka seks kontekster. Resume, suspend, close — det er hele handlingen.

Udvikling
Audio
Desktop
At tage backend ud af upload-stien
Steven
Steven8 min læsning

At tage backend ud af upload-stien

GeekBye optager din skærm og gemmer videoen til din Google Drive. Den første version sendte hver optagelse gennem GeekByes egne servere på vejen dertil; en release senere gik filen direkte fra din maskine til Drive, og backend blev degraderet til at holde en enkelt pointer. Den interessante del er, hvor lidt kode den 'direkte, genoptagelige' version faktisk indeholder — fordi genoptageligheden kom fra at slette en proxy, ikke fra at skrive en.

Udvikling
Arkitektur
Desktop
Født og fixet på tolv minutter
Steven
Steven7 min læsning

Født og fixet på tolv minutter

GeekBye v1.8.10s ændringslog siger, at den fixede et nedbrud under redigering af tastaturgenveje. Det gjorde den — men nedbruddet blev introduceret og fixet i den samme pull request, tolv minutter fra hinanden, og nåede aldrig en eneste bruger. Den rigtige historie er den pålidelighedskaskade, der producerede det: en lille, korrekt ændring af, hvornår genveje sættes på pause, og React-nedrivningsbuggen, der faldt ud af én linje, der skulle gøre editoren sikrere.

Udvikling
React
Desktop