瀏覽代碼

Fix meeting recordings stopping seconds after join.

Debounce embedded web leave detection with a grace period and desktop
Zoom presence fallback so transient SPA redirects no longer end capture early.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 月之前
父節點
當前提交
ca6bba6ae4
共有 3 個文件被更改,包括 78 次插入16 次删除
  1. 9 0
      zoom_app/MeetingRecordingManager.swift
  2. 53 14
      zoom_app/ViewController.swift
  3. 16 2
      zoom_app/ZoomMeetingPresenceWatcher.swift

+ 9 - 0
zoom_app/MeetingRecordingManager.swift

@@ -81,6 +81,15 @@ final class MeetingRecordingManager: NSObject {
     private(set) var recordings: [RecordedMeeting] = []
     private(set) var isRecording: Bool = false
 
+    /// Elapsed time since the current recording session began (0 when not recording).
+    var currentRecordingElapsed: TimeInterval {
+        guard let startedAt = recordingStartedAt else { return 0 }
+        return Date().timeIntervalSince(startedAt)
+    }
+
+    /// Grace period after recording starts before ambiguous “left meeting” signals may auto-stop.
+    static let recordingLeaveGraceInterval: TimeInterval = automaticStopGraceInterval
+
     private var recordingID: String?
     private var recordingStartedAt: Date?
     private var recordingSourceURLString: String?

+ 53 - 14
zoom_app/ViewController.swift

@@ -473,6 +473,9 @@ class ViewController: NSViewController {
     /// True after the embedded web client loaded a join/in-call URL during the current recording session.
     private var embeddedMeetingReachedInCallPage = false
     private var embeddedMeetingRecordingMonitorTimer: Timer?
+    /// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
+    private var embeddedMeetingAbsentCallConsecutiveMisses = 0
+    private static let embeddedMeetingAbsentCallMissThreshold = 5
 
     private enum LocalRecordingNotesStatus: String {
         case notRequested
@@ -2110,25 +2113,32 @@ class ViewController: NSViewController {
     /// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
     private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
-        if isZoomMeetingLeaveOrEndURL(url) { return false }
+        if isZoomMeetingExplicitLeaveURL(url) { return false }
         let path = url.path.lowercased()
         if path.contains("/wc/join") { return true }
         if path.contains("/wc/inmeeting") { return true }
         if path.contains("/wc/video") { return true }
-        // Non–web-client join links opened in the embedded panel (rare).
-        if path.contains("/wc/") == false {
-            return isZoomMeetingJoinOrStartURL(url)
+        // Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
+        if path.contains("/wc/") {
+            return isZoomWorkplaceHubURL(url) == false
         }
-        return false
+        // Non–web-client join links opened in the embedded panel (rare).
+        return isZoomMeetingJoinOrStartURL(url)
     }
 
-    /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
-    private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
+    /// User explicitly left or the meeting ended (stop recording soon after this is detected).
+    private func isZoomMeetingExplicitLeaveURL(_ url: URL) -> Bool {
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
         let path = url.path.lowercased()
         let absolute = url.absoluteString.lowercased()
         if path.contains("/leave") || path.contains("postattendee") || path.contains("/feedback") { return true }
-        if absolute.contains("meeting_ended") || absolute.contains("meeting-ended") { return true }
+        return absolute.contains("meeting_ended") || absolute.contains("meeting-ended")
+    }
+
+    /// Workplace hub, sign-in, or marketing home — not an active call (may appear briefly during join).
+    private func isZoomWorkplaceHubURL(_ url: URL) -> Bool {
+        guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
+        let path = url.path.lowercased()
 
         let workplaceHubMarkers = [
             "/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
@@ -2142,6 +2152,11 @@ class ViewController: NSViewController {
         return false
     }
 
+    /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
+    private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
+        isZoomMeetingExplicitLeaveURL(url) || isZoomWorkplaceHubURL(url)
+    }
+
     private func embeddedWebViewTitleIndicatesLeftMeeting(_ title: String) -> Bool {
         let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         guard t.isEmpty == false else { return false }
@@ -2161,21 +2176,43 @@ class ViewController: NSViewController {
             return
         }
 
-        let inCallURL = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
-        let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
+        let inCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
+        let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
+        let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
         let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
-        let inCall = inCallURL && !leftByURL
 
         if inCall {
             embeddedMeetingReachedInCallPage = true
+            embeddedMeetingAbsentCallConsecutiveMisses = 0
         }
         MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
 
-        let shouldStopNow = leftByURL || leftByTitle
-            || (embeddedMeetingReachedInCallPage && !inCall)
-        if shouldStopNow {
+        // Definite leave/end pages and hub titles stop recording immediately.
+        if explicitLeaveURL || leftByTitle {
             endZoomMeetingRecordingAutomatically()
+            return
+        }
+
+        let elapsed = MeetingRecordingManager.shared.currentRecordingElapsed
+        if elapsed < MeetingRecordingManager.recordingLeaveGraceInterval {
+            return
+        }
+
+        // User may have joined via the desktop Zoom app while the embedded panel shows a hub page.
+        if ZoomMeetingPresenceWatcher.callPresenceLikelyVisible() {
+            embeddedMeetingAbsentCallConsecutiveMisses = 0
+            return
+        }
+
+        let absentFromCall = workplaceHubURL || (embeddedMeetingReachedInCallPage && !inCall)
+        if absentFromCall {
+            embeddedMeetingAbsentCallConsecutiveMisses += 1
+        } else {
+            embeddedMeetingAbsentCallConsecutiveMisses = 0
         }
+
+        guard embeddedMeetingAbsentCallConsecutiveMisses >= Self.embeddedMeetingAbsentCallMissThreshold else { return }
+        endZoomMeetingRecordingAutomatically()
     }
 
     @MainActor
@@ -2184,6 +2221,7 @@ class ViewController: NSViewController {
         guard MeetingRecordingManager.shared.isRecording else { return }
         guard embeddedBrowserWebView != nil else { return }
         embeddedMeetingReachedInCallPage = false
+        embeddedMeetingAbsentCallConsecutiveMisses = 0
         let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
             guard let self else { return }
             let webView = self.embeddedBrowserWebView
@@ -2198,6 +2236,7 @@ class ViewController: NSViewController {
         embeddedMeetingRecordingMonitorTimer?.invalidate()
         embeddedMeetingRecordingMonitorTimer = nil
         embeddedMeetingReachedInCallPage = false
+        embeddedMeetingAbsentCallConsecutiveMisses = 0
     }
 
     @MainActor

+ 16 - 2
zoom_app/ZoomMeetingPresenceWatcher.swift

@@ -116,7 +116,21 @@ enum ZoomMeetingPresenceWatcher {
     }
 
     private static func titleIndicatesEmbeddedZoom(_ title: String) -> Bool {
-        title.contains("zoom meeting") || title.contains("zoom webinar")
-            || title.contains("join meeting") || title.contains("| zoom")
+        let hubExclusions = [
+            "zoom workplace", "meetings | zoom", "meeting list", "calendar | zoom", "mail | zoom",
+            "team chat | zoom", "chat | zoom", "scheduler | zoom", "notes | zoom", "recordings | zoom",
+            "whiteboard | zoom", "marketplace | zoom", "sign in", "sign-in", "settings | zoom",
+            "preferences", "home | zoom", "contacts | zoom", "zoom rooms", "my profile"
+        ]
+        if hubExclusions.contains(where: { title.contains($0) }) { return false }
+
+        let inCallHints = [
+            "zoom meeting", "zoom webinar", "join meeting", "launch meeting",
+            "waiting for the host", "waiting for host", "gallery view", "speaker view",
+            "immersive view", "side-by-side", "personal meeting room",
+            "you are screen sharing", "screen sharing", "sharing screen",
+            "viewing ", "'s screen", "remote control", "share sound", "breakout room"
+        ]
+        return inCallHints.contains(where: { title.contains($0) })
     }
 }