
แยกการโทรออกจากแอปที่แค่เปิดค้างไว้
GeekBye สังเกตได้ว่าคุณเข้าร่วมการประชุมวิดีโอแล้ว และเสนอบันทึกมันให้ด้วยคลิกเดียว การตรวจจับกลายเป็นครึ่งที่ง่าย — Swift binary ที่อ่านชื่อหน้าต่างทุกสิบวินาที ครึ่งที่ยากคือความแม่นยำ: ไม่ยิงตอน Zoom แค่เปิดค้างอยู่, ไม่เตือนสำหรับการประชุมที่คุณกำลังบันทึกอยู่แล้ว, และไม่ mute ไมค์ในการโทรที่คุณอยู่จริง ๆ สามรีลีส และแต่ละอันคือ guard ที่ต้องเรียนรู้ที่จะไม่เอาชนะตัวเอง
GeekBye มีฟีเจอร์ที่ฟังดูกระจอกแต่ไม่ใช่: มันสังเกตได้ว่าคุณเข้าร่วมการประชุมวิดีโอเมื่อไร และเสนอ, ด้วยคลิกเดียว, ที่จะเริ่มบันทึกมัน คำโปรยคือประโยคเดียว ส่วน implementation คือสามรีลีสของการเรียนรู้ว่าปัญหาที่น่าสนใจไม่เคยเป็น «ตรวจจับการประชุม» — มันคือ «แม่นเรื่องนั้น» แม่นว่าคุณอยู่ในสายจริง ๆ หรือแค่เปิดแอปค้างไว้ แม่นว่าไม่ถามซ้ำสอง แม่นว่าไม่ mute ไมค์ผิดตัว แต่ละอันในสามรีลีสนั้น, ที่แก่นของมัน, คือ guard ที่ต้องถูกสอนให้ไม่สะดุดขาตัวเอง
ชื่อหน้าต่าง, ไม่ใช่ process
การตัดสินใจแรกและมีน้ำหนักที่สุดถูกเขียนไว้ใน commit message ในฐานะคำประกาศดีไซน์ ไม่ใช่เชิงอรรถ:
Detection uses window title matching (not process detection) to distinguish "app is open" from "in an active call."
ความต่างนั้นคือทั้งฟีเจอร์ ถ้าคุณตรวจจับการประชุมด้วยการดูว่าแอปไหนกำลัง_รัน_ คุณจะยิงทันทีที่ Zoom เปิดขึ้น — และ Zoom เปิดค้างอยู่บนเดสก์ท็อปมากมายทั้งวันโดยไม่มีการโทรสักสาย ดังนั้น GeekBye ไม่เฝ้าดู process บน macOS มันรัน native Swift binary เล็ก ๆ, MeetingMonitor, ที่ทุกสิบวินาทีเรียก CGWindowListCopyWindowInfo สำหรับหน้าต่างบนจอ และอ่านชื่อของแต่ละอันผ่าน kCGWindowName หน้าต่างที่ชื่อ "Zoom Meeting" แปลว่าคุณอยู่ในสาย; หน้าต่าง "Zoom Workplace" แปลว่าแอปแค่เปิดอยู่ สัญญาณไม่ใช่ «Zoom ยังอยู่ไหม» แต่คือ «หน้าต่างบนจอของคุณดูเหมือนสายที่กำลังโทรอยู่หรือเปล่า»
ช่วงเวลา poll เป็นค่าคงที่แข็ง ๆ — pollIntervalSeconds: Double = 10.0 — บวกการตรวจทันทีหนึ่งครั้งตอน startup ดังนั้นการประชุมถูกสังเกตเห็นภายในราวสิบวินาทีหลังหน้าต่างของมันปรากฏ (นั่นเป็นค่าในโค้ด ไม่ใช่ latency ที่วัดได้; ให้ถือ «ไม่เกิน ~10 วิ» เป็นเลขคณิต ไม่ใช่ benchmark)
การประชุมบน browser ต้องการอีกขั้น เพราะหน้าต่าง Chrome ที่ชื่อ "Meet - Weekly Sync" นั้นมีลุ้น แต่ความแน่นอนอยู่ใน URL ดังนั้นเมื่อแอปหน้าสุดเป็น browser ที่รู้จัก — Chrome, Safari, Edge, Brave, และอีกกำมือที่ระบุด้วย bundle id — binary ดึง URL ของแท็บที่ active ผ่าน AppleScript แล้วเทียบกับ pattern อย่าง meet.google.com/, zoom.us/j/, และ teams.microsoft.com/ AppleScript เข้าไปในอีกแอปอาจค้างได้ ดังนั้น call นั้นรันบน background DispatchQueue ที่คุมด้วย DispatchSemaphore พร้อม timeout 3 วินาที; browser ที่ค้างจะ timeout แทนที่จะแช่แข็ง detector ทั้งตัว
ส่วนที่ยากคือการไม่ยิง
นี่คือส่วนที่ทำให้ผมประหลาดใจตอนอ่านรายการ pattern การตรวจจับสายคือการ match string ไม่กี่ครั้ง การ_ไม่_ตรวจจับสาย — การไม่ยิงตอนคุณไม่อยากให้มันยิง — ต่างหากที่ความใส่ใจทั้งหมดไปลง รายการ pattern ของชื่อหน้าต่างอ่านเหมือนชุดของการยกเว้นอย่างจงใจ:
- Google Meet match
"Meet - "และรุ่น en-dash ของมัน"Meet – ", แต่ไม่ match string เปล่า ๆ"Google Meet"— เพราะนั่นคือหน้า landing ที่คุณเปิดก่อนโทร - Zoom match
"Zoom Meeting"และ"Zoom Webinar", แต่ไม่ match"Zoom Workplace"หรือ"Zoom"เปล่า ๆ — แอปที่นั่งเปิดอยู่ - Teams match
"Meeting with","Meeting in"," | Call"," | Meeting"— รูปทรงของชื่อระหว่างสาย, ไม่ใช่ client ที่ว่างอยู่ - Slack huddle, Webex room, และ WhatsApp call ต่างได้ชิ้นส่วนเฉพาะ-ระหว่างสายของตัวเอง
ทุกอันในนั้นคือการตัดสินใจที่จะเงียบไว้ในกรณีปกติที่แอปเปิดอยู่แต่คุณไม่ได้อยู่ในการประชุม เป้าหมายดีไซน์ไม่ใช่ recall สูงสุด; มันคือการไม่เป็นเครื่องมือที่เด้ง banner «อยากบันทึกไหม?» ขึ้นมาตอนคุณกำลังอ่าน Slack false positive ตรงนี้แพงในแบบที่ false negative ไม่แพง: พลาดสายไปแล้ว user ก็เริ่มบันทึกเอง; ยิงพลาดบนแอปที่เปิดอยู่ user ก็เรียนรู้ที่จะเมิน banner ของคุณ heuristic ทั้งชุดถูกจูนไปทางให้ความล้มเหลวแบบที่สองเกิดยาก
การเอ่ยขีดจำกัดของ heuristic ที่เรียบง่ายขนาดนี้เป็นเรื่องซื่อสัตย์ pattern เป็นภาษาอังกฤษล้วน — OS ที่ localize หรือแอปประชุมที่ไม่ใช่อังกฤษจะไม่ match, ข้อจำกัด v1 ที่ยอมรับกัน และเพราะชื่อหน้าต่างของแอปอื่นมองเห็นได้เฉพาะเมื่อได้รับสิทธิ์ Screen Recording, การตรวจจับจึงเสื่อมเงียบ ๆ เหลือศูนย์ถ้าไม่มีมัน เรียบง่าย, อ่านออก, และไม่สมบูรณ์ในแบบที่ทีมเขียนจดไว้แทนที่จะซ่อน
คำเสนอ, และกลไกที่กันมันไม่ให้จู้จี้
เมื่อ pattern match, GeekBye แสดง banner เล็ก ๆ ไร้กรอบที่ลอยบนสุดเสมอที่มุมจอ — หน้าต่างของมันเอง, ไม่ใช่ notification ของ OS — พร้อมปุ่ม Start Recording คลิกมันแล้ว IPC message (meeting:start-recording) ซ่อน banner, เรียกหน้าต่างหลักขึ้นมา, และบอก renderer ให้เริ่ม session ปัดมันทิ้งแล้ว banner ก็หายไป
แต่รุ่นไร้เดียงสาของสิ่งนี้คงทำให้บ้า ชื่อเปลี่ยนตลอดเวลาระหว่างสาย — ผู้เข้าร่วมเข้ามา, คุณเปลี่ยนชื่อการประชุม, การแชร์จอสลับชื่อหน้าต่าง — และการเปลี่ยนแต่ละครั้งจะจุด detector ที่ผูกกับ string เป๊ะ ๆ ให้ยิงใหม่ ดังนั้น detector ใน MeetingDetector.ts พก state machine เล็ก ๆ ที่งานทั้งหมดของมันคือความยับยั้งชั่งใจ:
meetingIdที่เสถียรต่อแพลตฟอร์ม ฝั่ง Swift ปล่อย id อย่างgoogle-meet-active, จงใจ_ไม่_ให้มาจากชื่อหน้าต่างที่ผันผวน, เพื่อให้การเข้า, ออก, และเปลี่ยนชื่อภายในสายเดียวไม่ถูกอ่านเป็นการประชุมใหม่dismissedMeetings: Set<string>ปัด banner แล้ว id ของการประชุมนั้นถูกเมิน — จนกว่าการประชุมจะจบจริง, ณ จุดนั้น id เคลียร์เพื่อให้การประชุมจริง_อันถัดไป_เตือนได้ใหม่- boolean
notificationActivebanner เดียวต่อครั้ง, เพื่อให้ Slack huddle ที่เด้งขึ้นตอนคุณอยู่ใน Meet อยู่แล้ว ไม่ซ้อนคำเตือนอันที่สอง
นั่นแหละคือฟีเจอร์นี้จริง ๆ: การตรวจจับนิดหน่อย และ «อย่ากวนใจ» เยอะ ๆ ซึ่งเป็นเหตุผลเป๊ะ ๆ ว่าทำไมสองรีลีสถัดมาถึงมีอยู่ — เพราะ guard สองอันในนั้นกลายเป็นเอาชนะตัวเอง
การ mute ที่ mute การประชุมจริงของคุณ (v1.8.18)
การบันทึกมีปุ่ม mute, และ implementation แรกรับ «mute» ตามตัวอักษรที่สุด ship ใน v1.8.15, มัน mute ไมค์ด้วยการตั้ง input volume ของระบบปฏิบัติการเป็นศูนย์:
osascript -e "set volume input volume 0"
พร้อมกับ volume ก่อนหน้าที่เซฟลงฐานข้อมูลเพื่อคืนค่าได้, ตัวเทียบเท่าฝั่ง Windows ผ่าน waveInSetVolume, และตาข่ายกันภัย before-quit เพื่อให้แน่ใจว่ามัน un-mute โค้ดถึงกับบรรยายมันเป็นฟีเจอร์ — mute «ที่ระดับ TWO», ระบบและแอป, เพื่อให้ไมค์ถูกบล็อกสำหรับ_ทุก_แอป
วลีสุดท้ายนั้นคือบั๊กที่พูดออกมาเป็นคำอวด การบล็อกไมค์สำหรับทุกแอปแปลว่าบล็อกมันสำหรับสาย Zoom หรือ Meet ที่คุณนั่งอยู่จริง ๆ คุณกด mute ใน GeekBye เพื่อหยุด_มัน_ไม่ให้ได้ยินคุณ แล้วคุณก็เงียบไปในการประชุมจริงของคุณ การแก้ v1.8.18 คือการลดขั้น: เลิกแตะ input volume ของ OS ทั้งหมด, และ mute เฉพาะ audio pipeline ของ GeekBye เอง ใน Listen mode, RealtimeAudioCaptureService แทน frame ไมค์ที่ mute ด้วย buffer ศูนย์เพื่อให้ความเงียบไหลไปที่ mixer; ใน video mode, ScreenRecordingService ตั้ง GainNode ของ Web Audio เป็นศูนย์ การ mute ทั้งระบบ, การกู้ volume ที่เซฟไว้, กลไกข้ามแพลตฟอร์มทั้งชุด — ลบหมด mute ควรแปลว่า «แอปนี้หยุดฟัง», ไม่ใช่ «ไมค์ของคุณปิดสำหรับทุกคน»
guard ที่ reset ตัวเอง (v1.8.19)
guard เอาชนะตัวเองอีกอันคือ notificationActive บนกระดาษมันตอบ «อย่าโชว์ banner ถ้ามีอันหนึ่งขึ้นอยู่» ไปแล้ว — และมันดูเหมือนควรจะครอบ «อย่าเสนอบันทึกการประชุมที่ฉันกำลังบันทึกอยู่แล้ว» ด้วย มันไม่ได้ครอบ, และ commit ที่แก้มันอธิบายว่าทำไมอย่างแม่นยำ:
The notificationActive flag resets when the meeting detector cycles through ended/detected states, causing notifications to reappear mid-recording.
detector คือ poll สด ๆ; ตลอดสายยาว ๆ มันอาจอ่าน «ended» ชั่วครู่แล้วอ่าน «detected» อีกที — ชื่อกะพริบ, หน้าต่างหลุดจอชั่วขณะ — และแต่ละรอบ reset flag ดังนั้น guard เลยเงียบ ๆ ขึ้นลำใหม่ให้ตัวเองตอนคุณกำลังบันทึก, และ banner ก็จะโผล่กลับมาเสนอบันทึกสายที่คุณกำลังบันทึกอยู่แล้ว การแก้เลิกเชื่อ flag ในเครื่องและถามแหล่งความจริงแทน: callback recordingActiveChecker, inject จาก main.ts และต่อสายไปที่สถานะ isRealtimeTranscriptionActive จริง, ถูกตรวจก่อน notification ใด ๆ จะโชว์
// Don't show notification if recording/transcription is already active
if (this.recordingActiveChecker?.()) {
return
}
บทเรียนเล็กและกว้าง: guard ที่สถานะเป็นของคุณ อาจถูก reset จากใต้เท้าคุณด้วย control flow ของคุณเอง guard ที่อ่านสถานะจริงที่มันปกป้อง ทำแบบนั้นไม่ได้ notificationActive กำลัง track proxy ของ «เรากำลังบันทึกไหม»; การแก้ถามว่าเรากำลังบันทึกอยู่หรือเปล่า
สามสิ่งที่รีลีสเหล่านี้สอนเรา
- ตรวจจับสถานะที่คุณหมายถึง, ไม่ใช่สถานะที่มองเห็นง่าย «แอปกำลังรัน» ตรวจง่ายและผิด «มีหน้าต่างที่ดูเหมือนสายที่กำลังโทรอยู่» ยากขึ้นนิดและถูก ช่องว่างระหว่างสองอันนั้นคือทั้งฟีเจอร์
- สำหรับฟีเจอร์เชิงรุก ความแม่นยำคือตัวผลิตภัณฑ์ ไม่มีใครจำการประชุมที่คุณตรวจจับถูก; เขาจำสามครั้งที่คุณขัดจังหวะเขาตอนเขาไม่ได้อยู่ในสาย ข้อยกเว้น pattern และ state machine กันจู้จี้ไม่ใช่การขัดเงา — มันคือความต่างระหว่าง banner ที่ช่วยได้ กับ banner ที่คุณฝึกตัวเองให้เมิน
- guard ที่ reset ตัวเองได้ไม่ใช่ guard สองรีลีสตามมามีรูปทรงเดียวกัน: กลไกความปลอดภัยที่อ้าง proxy ที่มันควบคุมเอง (volume ที่เซฟไว้, boolean ในเครื่อง) แทนสถานะจริง (ไมค์ของ OS, สถานะ transcription) จ่อ guard ไปที่ความจริง, ไม่ใช่ที่สำเนาของมัน
สำหรับบทก่อนหน้าในเรื่องยุค v1 ดูเอา backend ออกจากเส้นทางอัปโหลด (v1.8.11–v1.8.13); และสำหรับภาพรวมทั้งเส้นเรื่อง ดูกายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ