فهرست منبع

Fix join-link flow: direct embedded join and deferred recording.

Normalize invite URLs to the web client, open meetings without an extra Join step, and start local recording only after the user enters the call—not on the lobby page.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 ماه پیش
والد
کامیت
8888f566b2
1فایلهای تغییر یافته به همراه114 افزوده شده و 23 حذف شده
  1. 114 23
      zoom_app/ViewController.swift

+ 114 - 23
zoom_app/ViewController.swift

@@ -489,6 +489,8 @@ class ViewController: NSViewController {
     /// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
     private var embeddedMeetingAbsentCallConsecutiveMisses = 0
     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?)?
 
     private enum LocalRecordingNotesStatus: String {
         case notRequested
@@ -1858,7 +1860,7 @@ class ViewController: NSViewController {
         }
         guard let url = zoomURL else { return }
 
-        openWebURLPreferringInApp(url, zoomMeetingDisplayName: meetingName, recordingConsentWindowTitle: "Join Meeting")
+        joinZoomMeetingFromLink(url.absoluteString, displayName: meetingName)
     }
 
     private enum MeetingRecordingConsentChoice {
@@ -2319,6 +2321,53 @@ class ViewController: NSViewController {
         return url
     }
 
+    /// Converts invite links (`zoom.us/j/…`) into embedded web-client join URLs (`app.zoom.us/wc/join/…`).
+    private func normalizedEmbeddedJoinURL(for url: URL) -> URL {
+        if let embedded = embeddedWebClientURLForZoomMeeting(meetingId: nil, joinURLString: url.absoluteString) {
+            return embedded
+        }
+        return zoomEmbeddedWebHostURL(for: url)
+    }
+
+    /// Join lobby / invite URL — not an active call (recording must not start here).
+    private func isZoomEmbeddedWebClientJoinLobbyURL(_ url: URL) -> Bool {
+        guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
+        let path = url.path.lowercased()
+        if path.contains("/wc/join") { return true }
+        if path.range(of: #"/j/\d"#, options: .regularExpression) != nil { return true }
+        return false
+    }
+
+    private func embeddedWebViewTitleIndicatesActiveCall(_ title: String) -> Bool {
+        let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard t.isEmpty == false else { return false }
+        return t.contains("zoom meeting") || t.contains("zoom webinar")
+    }
+
+    /// Opens a Zoom join link in the embedded web panel (skips the Join sheet).
+    @MainActor
+    private func joinZoomMeetingFromLink(_ rawLink: String, displayName: String?) {
+        guard let parsed = parseZoomJoinURLFromUserInput(rawLink) else {
+            showSimpleAlert(
+                title: "Invalid link",
+                message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…)."
+            )
+            return
+        }
+        let url = normalizedEmbeddedJoinURL(for: parsed)
+        openWebURLPreferringInApp(
+            url,
+            zoomMeetingDisplayName: displayName,
+            recordingConsentWindowTitle: "Join Meeting"
+        )
+    }
+
+    @MainActor
+    private func joinScheduledMeeting(_ meeting: ScheduledMeeting) {
+        guard let url = meeting.webURL else { return }
+        joinZoomMeetingFromLink(url.absoluteString, displayName: meeting.title)
+    }
+
     /// Opens Zoom meeting join/start links in the right-panel `WKWebView` (same store as embedded Zoom OAuth).
     /// Other URLs open in the system default browser.
     /// For Zoom meeting join/start links, asks for recording consent first, persists transcription languages from that sheet,
@@ -2329,8 +2378,10 @@ class ViewController: NSViewController {
         zoomMeetingDisplayName: String? = nil,
         recordingConsentWindowTitle: String = "Meeting"
     ) {
+        let targetURL = isZoomMeetingJoinOrStartURL(url) ? normalizedEmbeddedJoinURL(for: url) : url
+
         let shouldRecordLocally: Bool
-        if isZoomMeetingJoinOrStartURL(url) {
+        if isZoomMeetingJoinOrStartURL(targetURL) {
             let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
             if consentChoice == .dismissed { return }
             shouldRecordLocally = (consentChoice == .allowAndContinue)
@@ -2339,16 +2390,19 @@ class ViewController: NSViewController {
         }
 
         let opened: Bool
-        if isZoomMeetingJoinOrStartURL(url), embeddedBrowserWebView != nil {
-            presentURLInEmbeddedRightPanel(url)
+        if isZoomMeetingJoinOrStartURL(targetURL), embeddedBrowserWebView != nil {
+            presentURLInEmbeddedRightPanel(targetURL)
             opened = true
         } else {
-            opened = openURLInDefaultBrowser(url)
+            opened = openURLInDefaultBrowser(targetURL)
         }
         if opened {
             dismissJoinMeetingPanel()
             if shouldRecordLocally {
-                beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: zoomMeetingDisplayName)
+                armZoomMeetingRecordingIfAppropriate(
+                    sourceURL: targetURL,
+                    meetingDisplayName: zoomMeetingDisplayName
+                )
             }
         } else {
             showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
@@ -2372,12 +2426,12 @@ class ViewController: NSViewController {
         return false
     }
 
-    /// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
+    /// Active in-call pages in the embedded Zoom web client (not join lobby or Workplace hub routes).
     private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
         if isZoomMeetingExplicitLeaveURL(url) { return false }
+        if isZoomEmbeddedWebClientJoinLobbyURL(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 }
         // Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
@@ -2433,12 +2487,17 @@ class ViewController: NSViewController {
 
     @MainActor
     private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
+        startPendingZoomMeetingRecordingIfNeeded(for: url, webViewTitle: webViewTitle)
+
         guard MeetingRecordingManager.shared.isRecording else {
-            stopEmbeddedMeetingRecordingMonitor()
+            if pendingZoomMeetingRecording == nil {
+                stopEmbeddedMeetingRecordingMonitor()
+            }
             return
         }
 
-        let inCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
+        let inCall = (url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false)
+            || (webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false)
         let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
         let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
         let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
@@ -2480,7 +2539,7 @@ class ViewController: NSViewController {
     @MainActor
     private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
         stopEmbeddedMeetingRecordingMonitor()
-        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard MeetingRecordingManager.shared.isRecording || pendingZoomMeetingRecording != nil else { return }
         guard embeddedBrowserWebView != nil else { return }
         embeddedMeetingReachedInCallPage = false
         embeddedMeetingAbsentCallConsecutiveMisses = 0
@@ -2519,12 +2578,40 @@ class ViewController: NSViewController {
     }
 
     @MainActor
-    private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
+    private func armZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
         guard MeetingRecordingManager.shared.isRecording == false else { return }
+        pendingZoomMeetingRecording = (normalizedEmbeddedJoinURL(for: sourceURL), meetingDisplayName)
+        updateAiCompanionRecordingUI()
+        if embeddedBrowserWebView != nil,
+           (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
+            startEmbeddedMeetingRecordingMonitorIfNeeded()
+        }
+    }
+
+    @MainActor
+    private func startPendingZoomMeetingRecordingIfNeeded(for url: URL?, webViewTitle: String?) {
+        guard pendingZoomMeetingRecording != nil else { return }
+        if MeetingRecordingManager.shared.isRecording {
+            pendingZoomMeetingRecording = nil
+            updateAiCompanionRecordingUI()
+            return
+        }
+
+        let urlInCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
+        let titleInCall = webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false
+        let desktopInCall = ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
+        guard urlInCall || titleInCall || desktopInCall else { return }
+
+        guard let pending = pendingZoomMeetingRecording else { return }
+        pendingZoomMeetingRecording = nil
+
         Task { @MainActor in
             do {
-                try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
+                try await MeetingRecordingManager.shared.startRecording(
+                    sourceURL: pending.sourceURL,
+                    meetingDisplayName: pending.displayName
+                )
                 self.updateAiCompanionRecordingUI()
                 if self.embeddedBrowserWebView != nil,
                    (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
@@ -2637,6 +2724,7 @@ class ViewController: NSViewController {
 
     @MainActor
     private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
+        pendingZoomMeetingRecording = nil
         if MeetingRecordingManager.shared.isRecording {
             endZoomMeetingRecordingAutomatically()
         }
@@ -3163,8 +3251,8 @@ class ViewController: NSViewController {
 
     @objc private func meetingSearchResultTapped(_ sender: NSButton) {
         guard let meeting = globalSearchResults[safe: sender.tag] else { return }
-        if let url = meeting.webURL {
-            openWebURLPreferringInApp(url, zoomMeetingDisplayName: meeting.title, recordingConsentWindowTitle: "Join Meeting")
+        if meeting.webURL != nil {
+            joinScheduledMeeting(meeting)
         } else {
             let pasteboard = NSPasteboard.general
             pasteboard.clearContents()
@@ -7980,6 +8068,11 @@ class ViewController: NSViewController {
                 "Recording… Stops automatically when you leave or the meeting ends."
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.textColor = secondaryText
+        } else if pendingZoomMeetingRecording != nil {
+            aiCompanionStatusLabel?.stringValue =
+                "Waiting to join… Recording starts when you enter the meeting."
+            aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
+            aiCompanionStatusLabel?.textColor = secondaryText
         } else {
             aiCompanionStatusLabel?.stringValue = "Previous meetings"
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
@@ -9114,9 +9207,8 @@ class ViewController: NSViewController {
         }
         center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
             guard let self else { return }
-            guard let link = notification.userInfo?["link"] as? String,
-                  let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
-            self.openWebURLPreferringInApp(url, zoomMeetingDisplayName: nil, recordingConsentWindowTitle: "Join Meeting")
+            guard let link = notification.userInfo?["link"] as? String else { return }
+            self.joinZoomMeetingFromLink(link, displayName: nil)
         }
     }
 
@@ -9625,9 +9717,8 @@ class ViewController: NSViewController {
 
     func menuBarOpenTopMeeting(joinLink: String?) {
         if let link = joinLink?.trimmingCharacters(in: .whitespacesAndNewlines),
-           !link.isEmpty,
-           let url = URL(string: link) {
-            openWebURLPreferringInApp(url, zoomMeetingDisplayName: nil, recordingConsentWindowTitle: "Join Meeting")
+           link.isEmpty == false {
+            joinZoomMeetingFromLink(link, displayName: nil)
             return
         }
         menuBarShowSection(.meetings)
@@ -10007,8 +10098,8 @@ class ViewController: NSViewController {
             url: meeting.webURL,
             hoverBackground: hoverBackground,
             hoverBorder: hoverBorder,
-            onTapURL: { [weak self] url in
-                self?.openWebURLPreferringInApp(url, zoomMeetingDisplayName: meeting.title, recordingConsentWindowTitle: "Join Meeting")
+            onTapURL: { [weak self] _ in
+                self?.joinScheduledMeeting(meeting)
             }
         )
         card.wantsLayer = true