Forráskód Böngészése

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 hónapja
szülő
commit
3bdbceccef
2 módosított fájl, 84 hozzáadás és 7 törlés
  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.
     @MainActor
     func setEmbeddedWebClientCallUIVisible(_ visible: Bool) {

+ 73 - 7
zoom_app/ViewController.swift

@@ -66,6 +66,43 @@ class ViewController: NSViewController {
       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 {
         case home = "Home"
@@ -491,6 +528,8 @@ class ViewController: NSViewController {
     private static let embeddedMeetingAbsentCallMissThreshold = 5
     /// 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?)?
+    /// 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 {
         case notRequested
@@ -2610,18 +2649,21 @@ class ViewController: NSViewController {
 
         guard let pending = pendingZoomMeetingRecording else { return }
         pendingZoomMeetingRecording = nil
+        let sessionID = embeddedZoomWebSessionID
 
         Task { @MainActor in
+            guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else { return }
             do {
                 try await MeetingRecordingManager.shared.startRecording(
                     sourceURL: pending.sourceURL,
                     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 {
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
             }
@@ -2633,12 +2675,18 @@ class ViewController: NSViewController {
         NSWorkspace.shared.open(url)
     }
 
+    @MainActor
+    private var isEmbeddedBrowserPanelOpen: Bool {
+        (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
+    }
+
     @MainActor
     private func presentURLInEmbeddedRightPanel(_ url: URL) {
         guard let webView = embeddedBrowserWebView else {
             NSWorkspace.shared.open(url)
             return
         }
+        embeddedZoomWebSessionID &+= 1
         updateEmbeddedRightPanelWidthForOpenState()
         if isZoomMeetingJoinOrStartURL(url) {
             Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
@@ -2727,23 +2775,41 @@ class ViewController: NSViewController {
         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
     private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
+        embeddedZoomWebSessionID &+= 1
         pendingZoomMeetingRecording = nil
+        stopEmbeddedMeetingRecordingMonitor()
+        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
         if MeetingRecordingManager.shared.isRecording {
+            MeetingRecordingManager.shared.stopCaptureHardwareImmediately()
             endZoomMeetingRecordingAutomatically()
         }
+        updateAiCompanionRecordingUI()
         if let cont = oauthSessionContinuation {
             oauthSessionContinuation = nil
             oauthSessionCallbackPrefix = nil
             cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
         }
         if stoppingWebLoad {
-            embeddedBrowserWebView?.stopLoading()
+            teardownEmbeddedZoomWebClient()
         }
         embeddedZoomPendingWebAuth = nil
-        stopEmbeddedMeetingRecordingMonitor()
-        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
         embeddedBrowserPanelWidthConstraint?.constant = 0
         embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
         homeMainContentColumnView?.isHidden = false