Steven
Steven9 λεπτά ανάγνωσης

Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή

Το GeekBye μπορεί να παρατηρήσει ότι μπήκες σε μια βιντεοκλήση και να σου προσφέρει να την καταγράψει. Η ανίχνευση αποδεικνύεται το εύκολο μισό — ένα Swift binary που διαβάζει τίτλους παραθύρων κάθε δέκα δευτερόλεπτα. Το δύσκολο μισό είναι η ακρίβεια: να μην πυροδοτείται όταν το Zoom είναι απλώς ανοιχτό, να μη ρωτά για μια σύσκεψη που ήδη καταγράφεις, και να μη σιγάζει το μικρόφωνο στην κλήση στην οποία πραγματικά βρίσκεσαι. Τρεις εκδόσεις, και η καθεμία είναι μια δικλείδα που έπρεπε να μάθει να μην νικά τον εαυτό της.

Μηχανική
macOS
Desktop
Εκδόσεις GeekBye
Ξεχωρίζοντας μια κλήση από μια ανοιχτή εφαρμογή

Το GeekBye έχει μια λειτουργία που ακούγεται τετριμμένη αλλά δεν είναι: παρατηρεί πότε μπήκες σε μια βιντεοκλήση και προσφέρει, με ένα κλικ, να αρχίσει να την καταγράφει. Η παρουσίαση είναι μία μόνο πρόταση. Η υλοποίηση είναι τρεις εκδόσεις εκμάθησης ότι το ενδιαφέρον πρόβλημα δεν ήταν ποτέ „ανίχνευσε μια σύσκεψη" — ήταν „να έχεις δίκιο γι αυτήν". Δίκιο για το αν είσαι όντως σε κλήση ή απλώς έχεις την εφαρμογή ανοιχτή. Δίκιο για το να μη ρωτάς δύο φορές. Δίκιο για το να μη σιγάσεις το λάθος μικρόφωνο. Η καθεμία από τις τρεις εκδόσεις είναι, στην ουσία, μια δικλείδα που έπρεπε να διδαχτεί να μη σκοντάφτει στον εαυτό της.

Τίτλοι παραθύρων, όχι διεργασίες

Η πρώτη και πιο καθοριστική απόφαση είναι γραμμένη σε ένα commit message ως δήλωση σχεδιασμού, όχι ως υποσημείωση:

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

Αυτή η διάκριση είναι όλη η λειτουργία. Αν ανίχνευες συσκέψεις κοιτάζοντας ποιες εφαρμογές τρέχουν, θα πυροδοτούσες τη στιγμή που ξεκινά το Zoom — και το Zoom είναι ανοιχτό σε πολλά desktop όλη μέρα χωρίς ούτε μία κλήση. Έτσι το GeekBye δεν παρακολουθεί διεργασίες. Στο macOS τρέχει ένα μικρό native Swift binary, το MeetingMonitor, που κάθε δέκα δευτερόλεπτα καλεί το CGWindowListCopyWindowInfo για τα παράθυρα στην οθόνη και διαβάζει τον τίτλο του καθενός μέσω kCGWindowName. Ένα παράθυρο με τίτλο "Zoom Meeting" σημαίνει ότι είσαι σε κλήση· το παράθυρο "Zoom Workplace" σημαίνει ότι η εφαρμογή είναι απλώς ανοιχτή. Το σήμα δεν είναι „είναι ζωντανό το Zoom", είναι „μοιάζει κάποιο παράθυρο στην οθόνη σου με ζωντανή κλήση".

Το διάστημα του poll είναι μια σκληρή σταθερά — pollIntervalSeconds: Double = 10.0 — συν έναν άμεσο έλεγχο στην εκκίνηση, ώστε μια σύσκεψη να γίνεται αντιληπτή μέσα σε περίπου δέκα δευτερόλεπτα από την εμφάνιση του παραθύρου της. (Αυτή είναι μια τιμή κώδικα, όχι μια μετρημένη καθυστέρηση· αντιμετώπισε το „έως ~10 s" ως αριθμητική, όχι ως benchmark.)

Οι συσκέψεις σε browser χρειάζονται ένα ακόμη βήμα, επειδή ένα παράθυρο Chrome με τίτλο "Meet - Weekly Sync" είναι υποσχόμενο αλλά η βεβαιότητα είναι στο URL. Έτσι όταν η μπροστινή εφαρμογή είναι ένας γνωστός browser — Chrome, Safari, Edge, Brave, και μια χούφτα ακόμη που αναγνωρίζονται από bundle id — το binary εξάγει το URL της ενεργής καρτέλας μέσω AppleScript και το ελέγχει έναντι μοτίβων όπως meet.google.com/, zoom.us/j/, και teams.microsoft.com/. Το AppleScript προς άλλη εφαρμογή μπορεί να κολλήσει, οπότε αυτή η κλήση τρέχει σε μια background DispatchQueue που φυλάσσεται από ένα DispatchSemaphore με timeout 3 δευτερολέπτων· ένας σφηνωμένος browser λήγει χρονικά αντί να παγώσει ολόκληρο τον ανιχνευτή.

Το δύσκολο κομμάτι είναι να μην πυροδοτείσαι

Να το κομμάτι που με εξέπληξε όταν διάβασα τη λίστα μοτίβων. Η ανίχνευση μιας κλήσης είναι μερικές αντιστοιχίσεις συμβολοσειρών. Το να μην ανιχνεύσεις μία — να μην πυροδοτηθείς όταν δεν το θέλεις — είναι εκεί όπου πήγε όλη η φροντίδα. Η λίστα μοτίβων τίτλων παραθύρων διαβάζεται σαν ένα σύνολο σκόπιμων εξαιρέσεων:

  • Το Google Meet αντιστοιχεί στο "Meet - " και στην en-dash παραλλαγή του "Meet – ", αλλά όχι στη σκέτη συμβολοσειρά "Google Meet" — επειδή αυτή είναι η σελίδα προορισμού που ανοίγεις πριν από μια κλήση.
  • Το Zoom αντιστοιχεί στο "Zoom Meeting" και "Zoom Webinar", αλλά όχι στο "Zoom Workplace" ή σε σκέτο "Zoom" — την εφαρμογή που κάθεται ανοιχτή.
  • Το Teams αντιστοιχεί στο "Meeting with", "Meeting in", " | Call", " | Meeting" — τις μορφές τίτλου εντός κλήσης, όχι τον αδρανή client.
  • Τα Slack huddles, τα Webex rooms, και οι κλήσεις WhatsApp παίρνουν το καθένα τα δικά του θραύσματα που ισχύουν μόνο εντός κλήσης.

Κάθε ένα από αυτά είναι μια απόφαση να μείνεις σιωπηλός στη συνηθισμένη περίπτωση όπου μια εφαρμογή είναι ανοιχτή αλλά δεν είσαι σε σύσκεψη. Ο στόχος του σχεδιασμού δεν είναι μέγιστο recall· είναι το να μην είσαι το εργαλείο που πετάει ένα banner „θέλεις να καταγράψεις;" ενώ διαβάζεις Slack. Ένα ψευδώς θετικό εδώ είναι ακριβό με έναν τρόπο που ένα ψευδώς αρνητικό δεν είναι: χάσε μια κλήση και ο χρήστης αρχίζει να καταγράφει χειροκίνητα· πυροδότησε λανθασμένα σε μια ανοιχτή εφαρμογή και ο χρήστης μαθαίνει να αγνοεί το banner σου. Όλη η ευρετική είναι ρυθμισμένη προς το να είναι σπάνια η δεύτερη αποτυχία.

Είναι έντιμο να ονομάσουμε τα όρια μιας τόσο απλής ευρετικής. Τα μοτίβα είναι μόνο στα αγγλικά — ένα τοπικοποιημένο λειτουργικό ή μια μη αγγλική εφαρμογή συσκέψεων δεν θα αντιστοιχήσει, ένας αναγνωρισμένος περιορισμός της v1. Και επειδή οι τίτλοι παραθύρων για άλλες εφαρμογές είναι ορατοί μόνο με χορηγημένη άδεια Screen Recording, η ανίχνευση σιωπηλά υποβαθμίζεται στο τίποτα χωρίς αυτήν. Απλή, ευανάγνωστη, και ατελής με τρόπους που η ομάδα κατέγραψε αντί να κρύψει.

Η προσφορά, και ο μηχανισμός που την εμποδίζει να ενοχλεί

Όταν ένα μοτίβο αντιστοιχεί, το GeekBye δείχνει ένα μικρό frameless always-on-top banner στη γωνία — το δικό του παράθυρο, όχι μια ειδοποίηση του λειτουργικού — με ένα κουμπί Start Recording. Κάνε κλικ και ένα μήνυμα IPC (meeting:start-recording) κρύβει το banner, φέρνει το κύριο παράθυρο, και λέει στον renderer να ξεκινήσει μια session. Απόρριψέ το και το banner φεύγει.

Αλλά μια αφελής εκδοχή αυτού θα ήταν εξοργιστική. Οι τίτλοι αλλάζουν συνεχώς κατά τη διάρκεια μιας κλήσης — μπαίνει ένας συμμετέχων, μετονομάζεις τη σύσκεψη, ένα screen-share αλλάζει το όνομα του παραθύρου — και κάθε αλλαγή θα ξαναπυροδοτούσε έναν ανιχνευτή που βασιζόταν στην ακριβή συμβολοσειρά. Έτσι ο ανιχνευτής, στο MeetingDetector.ts, κουβαλά μια μικρή μηχανή καταστάσεων της οποίας όλη η δουλειά είναι η συγκράτηση:

  • Ένα σταθερό meetingId ανά πλατφόρμα. Η πλευρά Swift εκπέμπει ένα id όπως google-meet-active, σκόπιμα όχι προερχόμενο από τον ασταθή τίτλο του παραθύρου, ώστε οι είσοδοι, οι έξοδοι, και οι μετονομασίες εντός μιας κλήσης να μη διαβάζονται ως νέα σύσκεψη.
  • Ένα dismissedMeetings: Set<string>. Απόρριψε το banner και το id εκείνης της σύσκεψης αγνοείται — μέχρι η σύσκεψη πραγματικά να τελειώσει, οπότε το id καθαρίζει ώστε η επόμενη πραγματική σύσκεψη να μπορεί να ρωτήσει από την αρχή.
  • Ένα boolean notificationActive. Μόνο ένα banner τη φορά, ώστε ένα Slack huddle που ξεπροβάλλει ενώ είσαι ήδη σε ένα Meet να μη στοιβάζει μια δεύτερη ερώτηση.

Αυτή είναι η λειτουργία, στην πραγματικότητα: λίγη ανίχνευση και πολύ „μην είσαι ενοχλητικός". Που είναι ακριβώς ο λόγος που υπάρχουν οι επόμενες δύο εκδόσεις — επειδή δύο από αυτές τις δικλείδες αποδείχτηκε ότι νικούσαν τον εαυτό τους.

Η σίγαση που σίγασε την πραγματική σου σύσκεψη (v1.8.18)

Η καταγραφή έχει ένα κουμπί σίγασης, και η πρώτη υλοποίηση πήρε το „σίγαση" στην πιο κυριολεκτική του έννοια. Κυκλοφορώντας στη v1.8.15, σίγαζε το μικρόφωνο θέτοντας την ένταση εισόδου του λειτουργικού στο μηδέν:

osascript -e "set volume input volume 0"

με την προηγούμενη ένταση αποθηκευμένη στη βάση δεδομένων ώστε να μπορεί να αποκατασταθεί, ένα ισοδύναμο Windows μέσω waveInSetVolume, και ένα δίχτυ ασφαλείας before-quit για να διασφαλίσει ότι έκανε unmute. Ο κώδικας μάλιστα την περιέγραφε ως χαρακτηριστικό — σίγαση „σε ΔΥΟ επίπεδα", σύστημα και εφαρμογή, ώστε το μικρόφωνο ήταν μπλοκαρισμένο για όλες τις εφαρμογές.

Αυτή η τελευταία φράση είναι το bug δηλωμένο ως καύχημα. Το μπλοκάρισμα του μικροφώνου για όλες τις εφαρμογές σημαίνει το μπλοκάρισμά του για την κλήση Zoom ή Meet στην οποία πραγματικά κάθεσαι. Πατάς σίγαση στο GeekBye για να σταματήσεις αυτό από το να σε ακούει, και έχεις σιωπήσει στην πραγματική σου σύσκεψη. Η διόρθωση της v1.8.18 είναι ένας υποβιβασμός: σταμάτα να αγγίζεις την ένταση εισόδου του λειτουργικού εντελώς, και σίγασε μόνο το δικό του audio pipeline του GeekBye. Σε λειτουργία Listen, το RealtimeAudioCaptureService αντικαθιστά τα σιγασμένα μικροφωνικά frames με έναν μηδενικό buffer ώστε σιωπή να ρέει προς τον mixer· σε λειτουργία βίντεο, το ScreenRecordingService θέτει ένα Web Audio GainNode στο μηδέν. Η σίγαση σε επίπεδο συστήματος, η ανάκτηση της αποθηκευμένης έντασης, όλος ο cross-platform μηχανισμός — όλα διαγράφηκαν. Η σίγαση θα έπρεπε να σημαίνει „αυτή η εφαρμογή σταματά να ακούει", ποτέ „το μικρόφωνό σου κλείνει για όλους".

Η δικλείδα που μηδένιζε τον εαυτό της (v1.8.19)

Η άλλη δικλείδα που νικούσε τον εαυτό της ήταν το notificationActive. Στα χαρτιά απαντούσε ήδη στο „μη δείξεις banner αν υπάρχει ένα" — και έμοιαζε ότι θα έπρεπε να καλύπτει και το „μην προσφέρεις να καταγράψεις μια σύσκεψη που ήδη καταγράφω". Δεν το έκανε, και το commit που το διορθώνει εξηγεί γιατί με ακρίβεια:

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

Ο ανιχνευτής είναι ένα ζωντανό poll· κατά τη διάρκεια μιας μεγάλης κλήσης μπορεί για λίγο να διαβάσει „ended" και μετά „detected" ξανά — ένα τρεμόπαιγμα τίτλου, ένα παράθυρο στιγμιαία εκτός οθόνης — και κάθε κύκλος μηδένιζε το flag. Έτσι η δικλείδα συνέχιζε σιωπηλά να ξαναοπλίζεται ενώ κατέγραφες, και το banner ξαναεμφανιζόταν προσφέροντας να καταγράψει την κλήση που ήδη κατέγραφες. Η διόρθωση σταματά να εμπιστεύεται ένα τοπικό flag και ρωτά αντ αυτού την πηγή της αλήθειας: ένα callback recordingActiveChecker, εισαγόμενο από το main.ts και συνδεδεμένο με την πραγματική κατάσταση isRealtimeTranscriptionActive, ελεγχόμενο πριν εμφανιστεί οποιαδήποτε ειδοποίηση.

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

Το δίδαγμα είναι μικρό και γενικό: μια δικλείδα της οποίας την κατάσταση κατέχεις μπορεί να μηδενιστεί κάτω από τα πόδια σου από την ίδια σου τη ροή ελέγχου. Μια δικλείδα που διαβάζει την πραγματική κατάσταση που προστατεύει δεν μπορεί. Το notificationActive παρακολουθούσε ένα proxy για το „καταγράφουμε;"· η διόρθωση ρωτά αν καταγράφουμε.

Τρία πράγματα που μας έμαθαν αυτές οι εκδόσεις

  1. Ανίχνευσε την κατάσταση που εννοείς, όχι αυτή που είναι εύκολο να δεις. Το „η εφαρμογή τρέχει" είναι τετριμμένο να ελεγχθεί και λάθος. Το „υπάρχει ένα παράθυρο που μοιάζει με ζωντανή κλήση" είναι λίγο δυσκολότερο και σωστό. Το κενό ανάμεσα στα δύο είναι όλη η λειτουργία.
  2. Για μια προδραστική λειτουργία, η ακρίβεια είναι το προϊόν. Κανείς δεν θυμάται τη σύσκεψη που ανίχνευσες σωστά· θυμούνται τις τρεις φορές που τους διέκοψες όταν δεν ήταν σε μία. Οι εξαιρέσεις των μοτίβων και η μηχανή καταστάσεων κατά της ενόχλησης δεν είναι γυάλισμα — είναι η διαφορά ανάμεσα σε ένα χρήσιμο banner και σε ένα που εκπαιδεύεις τον εαυτό σου να αγνοεί.
  3. Μια δικλείδα που μπορεί να μηδενίσει τον εαυτό της δεν είναι δικλείδα. Και οι δύο επόμενες εκδόσεις έχουν το ίδιο σχήμα: έναν μηχανισμό ασφαλείας που αναφερόταν σε ένα proxy που έλεγχε (μια αποθηκευμένη ένταση, ένα τοπικό boolean) αντί για την πραγματική κατάσταση (το μικρόφωνο του λειτουργικού, το status της μεταγραφής). Στρέψε τις δικλείδες προς την αλήθεια, όχι προς ένα αντίγραφό της.

Για το προηγούμενο κεφάλαιο της ιστορίας του v1, βγάζοντας το backend από τη διαδρομή του upload (v1.8.11–v1.8.13)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.

Σχετικά Άρθρα

Τα τρία ρήματα που κρατούν το Web Audio ζωντανό
Steven
Steven9 λεπτά ανάγνωσης

Τα τρία ρήματα που κρατούν το Web Audio ζωντανό

Δύο point releases του GeekBye, με δύο μήνες απόσταση και σε δύο διαφορετικά αρχεία, δίδαξαν στον κώδικα ήχου μας το ίδιο μάθημα από αντίθετα άκρα: σταμάτα να αντιμετωπίζεις το AudioContext του browser ως αναλώσιμο. Μία έκδοση έμαθε να κάνει resume() ένα context που το macOS είχε σιωπηλά suspend στη μέση της καταγραφής· η άλλη έμαθε να κάνει suspend() αντί για close(), ώστε οι διαδοχικές sessions να σταματήσουν να χτυπούν στο ταβάνι των περίπου έξι context του Chromium. Resume, suspend, close — αυτή είναι όλη η πλοκή.

Μηχανική
Audio
Desktop
Βγάζοντας το backend από τη διαδρομή του upload
Steven
Steven9 λεπτά ανάγνωσης

Βγάζοντας το backend από τη διαδρομή του upload

Το GeekBye καταγράφει την οθόνη σου και αποθηκεύει το βίντεο στο Google Drive σου. Η πρώτη έκδοση έστελνε κάθε καταγραφή μέσα από τους ίδιους τους διακομιστές του GeekBye στη διαδρομή προς τα εκεί· μια έκδοση αργότερα, το αρχείο πήγαινε κατευθείαν από το μηχάνημά σου στο Drive, και το backend υποβιβάστηκε στο να κρατά έναν μοναδικό δείκτη. Το ενδιαφέρον κομμάτι είναι πόσο λίγο κώδικα περιέχει στην πραγματικότητα η «άμεση, resumable» έκδοση — επειδή η resumability προήλθε από τη διαγραφή ενός proxy, όχι από τη συγγραφή ενός.

Μηχανική
Αρχιτεκτονική
Desktop
Γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά
Steven
Steven8 λεπτά ανάγνωσης

Γεννήθηκε και διορθώθηκε σε δώδεκα λεπτά

Το changelog του GeekBye v1.8.10 λέει ότι διόρθωσε ένα crash κατά την επεξεργασία των συντομεύσεων πληκτρολογίου. Όντως το διόρθωσε — αλλά το crash εισήχθη και διορθώθηκε στο ίδιο pull request, με δώδεκα λεπτά διαφορά, και δεν έφτασε ποτέ σε ούτε έναν χρήστη. Η πραγματική ιστορία είναι ο καταρράκτης αξιοπιστίας που το παρήγαγε: μια μικρή, σωστή αλλαγή στο πότε αναστέλλονται οι συντομεύσεις, και το bug αποδόμησης του React που ξεπήδησε από μία γραμμή που προοριζόταν να κάνει τον επεξεργαστή ασφαλέστερο.

Μηχανική
React
Desktop