Explorar o código

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 hai 2 meses
pai
achega
8888f566b2
Modificáronse 1 ficheiros con 114 adicións e 23 borrados
  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).
     /// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
     private var embeddedMeetingAbsentCallConsecutiveMisses = 0
     private var embeddedMeetingAbsentCallConsecutiveMisses = 0
     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).
+    private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
 
 
     private enum LocalRecordingNotesStatus: String {
     private enum LocalRecordingNotesStatus: String {
         case notRequested
         case notRequested
@@ -1858,7 +1860,7 @@ class ViewController: NSViewController {
         }
         }
         guard let url = zoomURL else { return }
         guard let url = zoomURL else { return }
 
 
-        openWebURLPreferringInApp(url, zoomMeetingDisplayName: meetingName, recordingConsentWindowTitle: "Join Meeting")
+        joinZoomMeetingFromLink(url.absoluteString, displayName: meetingName)
     }
     }
 
 
     private enum MeetingRecordingConsentChoice {
     private enum MeetingRecordingConsentChoice {
@@ -2319,6 +2321,53 @@ class ViewController: NSViewController {
         return url
         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).
     /// 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.
     /// 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,
     /// 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,
         zoomMeetingDisplayName: String? = nil,
         recordingConsentWindowTitle: String = "Meeting"
         recordingConsentWindowTitle: String = "Meeting"
     ) {
     ) {
+        let targetURL = isZoomMeetingJoinOrStartURL(url) ? normalizedEmbeddedJoinURL(for: url) : url
+
         let shouldRecordLocally: Bool
         let shouldRecordLocally: Bool
-        if isZoomMeetingJoinOrStartURL(url) {
+        if isZoomMeetingJoinOrStartURL(targetURL) {
             let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
             let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
             if consentChoice == .dismissed { return }
             if consentChoice == .dismissed { return }
             shouldRecordLocally = (consentChoice == .allowAndContinue)
             shouldRecordLocally = (consentChoice == .allowAndContinue)
@@ -2339,16 +2390,19 @@ class ViewController: NSViewController {
         }
         }
 
 
         let opened: Bool
         let opened: Bool
-        if isZoomMeetingJoinOrStartURL(url), embeddedBrowserWebView != nil {
-            presentURLInEmbeddedRightPanel(url)
+        if isZoomMeetingJoinOrStartURL(targetURL), embeddedBrowserWebView != nil {
+            presentURLInEmbeddedRightPanel(targetURL)
             opened = true
             opened = true
         } else {
         } else {
-            opened = openURLInDefaultBrowser(url)
+            opened = openURLInDefaultBrowser(targetURL)
         }
         }
         if opened {
         if opened {
             dismissJoinMeetingPanel()
             dismissJoinMeetingPanel()
             if shouldRecordLocally {
             if shouldRecordLocally {
-                beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: zoomMeetingDisplayName)
+                armZoomMeetingRecordingIfAppropriate(
+                    sourceURL: targetURL,
+                    meetingDisplayName: zoomMeetingDisplayName
+                )
             }
             }
         } else {
         } else {
             showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
             showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
@@ -2372,12 +2426,12 @@ class ViewController: NSViewController {
         return false
         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 {
     private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
         if isZoomMeetingExplicitLeaveURL(url) { return false }
         if isZoomMeetingExplicitLeaveURL(url) { return false }
+        if isZoomEmbeddedWebClientJoinLobbyURL(url) { return false }
         let path = url.path.lowercased()
         let path = url.path.lowercased()
-        if path.contains("/wc/join") { return true }
         if path.contains("/wc/inmeeting") { return true }
         if path.contains("/wc/inmeeting") { return true }
         if path.contains("/wc/video") { 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.
         // 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
     @MainActor
     private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
     private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
+        startPendingZoomMeetingRecordingIfNeeded(for: url, webViewTitle: webViewTitle)
+
         guard MeetingRecordingManager.shared.isRecording else {
         guard MeetingRecordingManager.shared.isRecording else {
-            stopEmbeddedMeetingRecordingMonitor()
+            if pendingZoomMeetingRecording == nil {
+                stopEmbeddedMeetingRecordingMonitor()
+            }
             return
             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 explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
         let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
         let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
         let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
         let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
@@ -2480,7 +2539,7 @@ class ViewController: NSViewController {
     @MainActor
     @MainActor
     private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
     private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
         stopEmbeddedMeetingRecordingMonitor()
         stopEmbeddedMeetingRecordingMonitor()
-        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard MeetingRecordingManager.shared.isRecording || pendingZoomMeetingRecording != nil else { return }
         guard embeddedBrowserWebView != nil else { return }
         guard embeddedBrowserWebView != nil else { return }
         embeddedMeetingReachedInCallPage = false
         embeddedMeetingReachedInCallPage = false
         embeddedMeetingAbsentCallConsecutiveMisses = 0
         embeddedMeetingAbsentCallConsecutiveMisses = 0
@@ -2519,12 +2578,40 @@ class ViewController: NSViewController {
     }
     }
 
 
     @MainActor
     @MainActor
-    private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
+    private func armZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
         guard MeetingRecordingManager.shared.isRecording == false 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
         Task { @MainActor in
             do {
             do {
-                try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
+                try await MeetingRecordingManager.shared.startRecording(
+                    sourceURL: pending.sourceURL,
+                    meetingDisplayName: pending.displayName
+                )
                 self.updateAiCompanionRecordingUI()
                 self.updateAiCompanionRecordingUI()
                 if self.embeddedBrowserWebView != nil,
                 if self.embeddedBrowserWebView != nil,
                    (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
                    (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
@@ -2637,6 +2724,7 @@ class ViewController: NSViewController {
 
 
     @MainActor
     @MainActor
     private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
     private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
+        pendingZoomMeetingRecording = nil
         if MeetingRecordingManager.shared.isRecording {
         if MeetingRecordingManager.shared.isRecording {
             endZoomMeetingRecordingAutomatically()
             endZoomMeetingRecordingAutomatically()
         }
         }
@@ -3163,8 +3251,8 @@ class ViewController: NSViewController {
 
 
     @objc private func meetingSearchResultTapped(_ sender: NSButton) {
     @objc private func meetingSearchResultTapped(_ sender: NSButton) {
         guard let meeting = globalSearchResults[safe: sender.tag] else { return }
         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 {
         } else {
             let pasteboard = NSPasteboard.general
             let pasteboard = NSPasteboard.general
             pasteboard.clearContents()
             pasteboard.clearContents()
@@ -7980,6 +8068,11 @@ class ViewController: NSViewController {
                 "Recording… Stops automatically when you leave or the meeting ends."
                 "Recording… Stops automatically when you leave or the meeting ends."
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.textColor = secondaryText
             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 {
         } else {
             aiCompanionStatusLabel?.stringValue = "Previous meetings"
             aiCompanionStatusLabel?.stringValue = "Previous meetings"
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
             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
         center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
             guard let self else { return }
             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?) {
     func menuBarOpenTopMeeting(joinLink: String?) {
         if let link = joinLink?.trimmingCharacters(in: .whitespacesAndNewlines),
         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
             return
         }
         }
         menuBarShowSection(.meetings)
         menuBarShowSection(.meetings)
@@ -10007,8 +10098,8 @@ class ViewController: NSViewController {
             url: meeting.webURL,
             url: meeting.webURL,
             hoverBackground: hoverBackground,
             hoverBackground: hoverBackground,
             hoverBorder: hoverBorder,
             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
         card.wantsLayer = true