Просмотр исходного кода

Stop camera and recording when the embedded web panel closes.

Tear down WKWebView media on panel close, halt capture hardware immediately, and cancel stale async recording starts so closing without joining does not leave the camera or recorder running.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 месяцев назад
Родитель
Сommit
3bdbceccef
2 измененных файлов с 84 добавлено и 7 удалено
  1. 11 0
      zoom_app/MeetingRecordingManager.swift
  2. 73 7
      zoom_app/ViewController.swift

+ 11 - 0
zoom_app/MeetingRecordingManager.swift

@@ -243,6 +243,17 @@ final class MeetingRecordingManager: NSObject {
         }
         }
     }
     }
 
 
+    /// Stops microphone and system-audio capture immediately so hardware releases before async finalize.
+    @MainActor
+    func stopCaptureHardwareImmediately() {
+        stopAutomaticStopMonitoringOnMain()
+        stopMicRecorder()
+        if #available(macOS 13.0, *), let recorder = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
+            Task { try? await recorder.stop() }
+        }
+        systemAudioRecorder = nil
+    }
+
     /// Embedded Zoom web client reports whether the current page looks like an active join / in-call flow.
     /// Embedded Zoom web client reports whether the current page looks like an active join / in-call flow.
     @MainActor
     @MainActor
     func setEmbeddedWebClientCallUIVisible(_ visible: Bool) {
     func setEmbeddedWebClientCallUIVisible(_ visible: Bool) {

+ 73 - 7
zoom_app/ViewController.swift

@@ -66,6 +66,43 @@ class ViewController: NSViewController {
       setInterval(notify, 1500);
       setInterval(notify, 1500);
     })();
     })();
     """
     """
+    /// Stops camera/mic tracks and attempts to leave an in-progress Zoom web meeting before tearing down the web view.
+    private static let embeddedZoomWebClientTeardownJavaScript = """
+    (function() {
+      function stopAllMediaTracks() {
+        try {
+          document.querySelectorAll('video, audio').forEach(function(node) {
+            var stream = node.srcObject;
+            if (stream && typeof stream.getTracks === 'function') {
+              stream.getTracks().forEach(function(track) { try { track.stop(); } catch (e) {} });
+            }
+            node.srcObject = null;
+          });
+        } catch (e) {}
+      }
+      function tryLeaveMeeting() {
+        var selectors = [
+          'button[aria-label*="Leave"]',
+          'button[aria-label*="leave"]',
+          'button[aria-label*="End meeting"]',
+          'button[aria-label*="end meeting"]',
+          'button[aria-label*="Leave Meeting"]',
+          '[data-testid="leave-meeting-button"]'
+        ];
+        for (var i = 0; i < selectors.length; i++) {
+          try {
+            var btn = document.querySelector(selectors[i]);
+            if (btn && typeof btn.click === 'function') {
+              btn.click();
+              return;
+            }
+          } catch (e) {}
+        }
+      }
+      tryLeaveMeeting();
+      stopAllMediaTracks();
+    })();
+    """
 
 
     enum HomeSection: String {
     enum HomeSection: String {
         case home = "Home"
         case home = "Home"
@@ -491,6 +528,8 @@ class ViewController: NSViewController {
     private static let embeddedMeetingAbsentCallMissThreshold = 5
     private static let embeddedMeetingAbsentCallMissThreshold = 5
     /// Armed after recording consent; capture starts once the user is in the call (not on the join/lobby page).
     /// Armed after recording consent; capture starts once the user is in the call (not on the join/lobby page).
     private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
     private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
+    /// Bumped when the embedded web panel closes or loads a new meeting URL so stale async work cannot start recording.
+    private var embeddedZoomWebSessionID: UInt = 0
 
 
     private enum LocalRecordingNotesStatus: String {
     private enum LocalRecordingNotesStatus: String {
         case notRequested
         case notRequested
@@ -2610,18 +2649,21 @@ class ViewController: NSViewController {
 
 
         guard let pending = pendingZoomMeetingRecording else { return }
         guard let pending = pendingZoomMeetingRecording else { return }
         pendingZoomMeetingRecording = nil
         pendingZoomMeetingRecording = nil
+        let sessionID = embeddedZoomWebSessionID
 
 
         Task { @MainActor in
         Task { @MainActor in
+            guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else { return }
             do {
             do {
                 try await MeetingRecordingManager.shared.startRecording(
                 try await MeetingRecordingManager.shared.startRecording(
                     sourceURL: pending.sourceURL,
                     sourceURL: pending.sourceURL,
                     meetingDisplayName: pending.displayName
                     meetingDisplayName: pending.displayName
                 )
                 )
-                self.updateAiCompanionRecordingUI()
-                if self.embeddedBrowserWebView != nil,
-                   (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
-                    self.startEmbeddedMeetingRecordingMonitorIfNeeded()
+                guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else {
+                    self.endZoomMeetingRecordingAutomatically()
+                    return
                 }
                 }
+                self.updateAiCompanionRecordingUI()
+                self.startEmbeddedMeetingRecordingMonitorIfNeeded()
             } catch {
             } catch {
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
             }
             }
@@ -2633,12 +2675,18 @@ class ViewController: NSViewController {
         NSWorkspace.shared.open(url)
         NSWorkspace.shared.open(url)
     }
     }
 
 
+    @MainActor
+    private var isEmbeddedBrowserPanelOpen: Bool {
+        (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
+    }
+
     @MainActor
     @MainActor
     private func presentURLInEmbeddedRightPanel(_ url: URL) {
     private func presentURLInEmbeddedRightPanel(_ url: URL) {
         guard let webView = embeddedBrowserWebView else {
         guard let webView = embeddedBrowserWebView else {
             NSWorkspace.shared.open(url)
             NSWorkspace.shared.open(url)
             return
             return
         }
         }
+        embeddedZoomWebSessionID &+= 1
         updateEmbeddedRightPanelWidthForOpenState()
         updateEmbeddedRightPanelWidthForOpenState()
         if isZoomMeetingJoinOrStartURL(url) {
         if isZoomMeetingJoinOrStartURL(url) {
             Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
             Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
@@ -2727,23 +2775,41 @@ class ViewController: NSViewController {
         closeEmbeddedBrowserPanel()
         closeEmbeddedBrowserPanel()
     }
     }
 
 
+    /// Leaves any in-call Zoom web session, stops camera/mic tracks, and navigates away so WKWebView releases hardware.
+    @MainActor
+    private func teardownEmbeddedZoomWebClient() {
+        guard let webView = embeddedBrowserWebView else { return }
+        webView.evaluateJavaScript(Self.embeddedZoomWebClientTeardownJavaScript) { [weak self] _, _ in
+            Task { @MainActor in
+                guard let self, self.embeddedBrowserWebView === webView else { return }
+                webView.stopLoading()
+                if let blankURL = URL(string: "about:blank") {
+                    webView.load(URLRequest(url: blankURL))
+                }
+            }
+        }
+    }
+
     @MainActor
     @MainActor
     private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
     private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
+        embeddedZoomWebSessionID &+= 1
         pendingZoomMeetingRecording = nil
         pendingZoomMeetingRecording = nil
+        stopEmbeddedMeetingRecordingMonitor()
+        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
         if MeetingRecordingManager.shared.isRecording {
         if MeetingRecordingManager.shared.isRecording {
+            MeetingRecordingManager.shared.stopCaptureHardwareImmediately()
             endZoomMeetingRecordingAutomatically()
             endZoomMeetingRecordingAutomatically()
         }
         }
+        updateAiCompanionRecordingUI()
         if let cont = oauthSessionContinuation {
         if let cont = oauthSessionContinuation {
             oauthSessionContinuation = nil
             oauthSessionContinuation = nil
             oauthSessionCallbackPrefix = nil
             oauthSessionCallbackPrefix = nil
             cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
             cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
         }
         }
         if stoppingWebLoad {
         if stoppingWebLoad {
-            embeddedBrowserWebView?.stopLoading()
+            teardownEmbeddedZoomWebClient()
         }
         }
         embeddedZoomPendingWebAuth = nil
         embeddedZoomPendingWebAuth = nil
-        stopEmbeddedMeetingRecordingMonitor()
-        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
         embeddedBrowserPanelWidthConstraint?.constant = 0
         embeddedBrowserPanelWidthConstraint?.constant = 0
         embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
         embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
         homeMainContentColumnView?.isHidden = false
         homeMainContentColumnView?.isHidden = false