Jelajahi Sumber

Improve in-app Zoom meetings: API instant start, embedded web, layout fix.

Host instant meetings via Zoom REST start_url so OAuth maps to the web client; open join/start links in the right WKWebView; subtract the column gap from panel width to avoid widening the window.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 bulan lalu
induk
melakukan
a9c4438483
1 mengubah file dengan 95 tambahan dan 9 penghapusan
  1. 95 9
      zoom_app/ViewController.swift

+ 95 - 9
zoom_app/ViewController.swift

@@ -1554,7 +1554,7 @@ class ViewController: NSViewController {
         root.layer?.backgroundColor = appBackground.cgColor
 
         let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
-        let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
+        let subtitleLabel = makeLabel("Opens in the app web panel", size: 12, color: mutedText, weight: .regular, centered: false)
 
         let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
         closeButton.translatesAutoresizingMaskIntoConstraints = false
@@ -1988,7 +1988,8 @@ class ViewController: NSViewController {
         return url
     }
 
-    /// Opens non-sign-in web content in the system default browser.
+    /// 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,
     /// then optionally begins local audio recording (when the user allows and recording is not already active).
     @MainActor
@@ -2006,7 +2007,13 @@ class ViewController: NSViewController {
             shouldRecordLocally = false
         }
 
-        let opened = openURLInDefaultBrowser(url)
+        let opened: Bool
+        if isZoomMeetingJoinOrStartURL(url), embeddedBrowserWebView != nil {
+            presentURLInEmbeddedRightPanel(url)
+            opened = true
+        } else {
+            opened = openURLInDefaultBrowser(url)
+        }
         if opened {
             joinMeetingWindow?.performClose(nil)
             if shouldRecordLocally {
@@ -2028,6 +2035,7 @@ class ViewController: NSViewController {
         if path.contains("/join/") { return true }
         if path.contains("/my/") { return true }
         if path.contains("/w/") { return true }
+        if path.contains("/s/") { return true }
         if path.contains("videomeeting") { return true }
         if path.contains("/start") && path.contains("meeting") { return true }
         return false
@@ -2074,8 +2082,10 @@ class ViewController: NSViewController {
         if rowW < 1 {
             rowW = view.bounds.width
         }
-        // Fill the entire body row so the embedded web experience matches a dedicated right-side panel.
-        widthConstraint.constant = max(320, floor(rowW))
+        // Right panel spans from (bodyRow.leading + gap) to bodyRow.trailing when the main column is collapsed (width 0).
+        // Using the full row width here over-constrains by the column gap and nudges the window wider.
+        let available = max(0, rowW - homeEmbeddedBrowserColumnGap)
+        widthConstraint.constant = max(320, floor(available))
 
         // Collapse the left main content column while web is visible.
         homeMainContentColumnView?.isHidden = true
@@ -3562,6 +3572,12 @@ class ViewController: NSViewController {
     private struct ZoomCreateMeetingAPIResult: Decodable {
         let id: Int?
         let join_url: String?
+        let start_url: String?
+    }
+
+    private struct ZoomInstantMeetingBody: Encodable {
+        let topic: String
+        let type: Int
     }
 
     /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
@@ -3600,6 +3616,34 @@ class ViewController: NSViewController {
         return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
     }
 
+    /// [Instant meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate) (type 1). Response `start_url` opens the web client as host (ZAK), unlike `/start/videomeeting` which is an unauthenticated browser flow.
+    private func createZoomInstantMeeting(accessToken: String, topic: String = "Instant meeting") async throws -> ZoomCreateMeetingAPIResult {
+        let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try JSONEncoder().encode(ZoomInstantMeetingBody(topic: topic, type: 1))
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
+        }
+        if http.statusCode == 429 {
+            let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
+            let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
+            throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
+        }
+        guard (200..<300).contains(http.statusCode) else {
+            let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
+            if raw.localizedCaseInsensitiveContains("does not contain scopes") {
+                throw ZoomOAuthError.missingRequiredScope(raw)
+            }
+            throw GoogleOAuthError.tokenExchangeFailed(raw)
+        }
+        return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
+    }
+
     private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
         struct ZoomMeeting: Decodable {
             let id: Int?
@@ -8978,11 +9022,53 @@ class ViewController: NSViewController {
             showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
             return
         }
-        guard let url = URL(string: "https://zoom.us/start/videomeeting") else {
-            showSimpleAlert(title: "Unable to open", message: "Could not start a new Zoom meeting right now.")
-            return
+        Task {
+            do {
+                let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
+                guard configured else { return }
+                let parentWindow = await MainActor.run { self.view.window }
+                let token = try await zoomOAuth.validAccessToken(
+                    presentingWindow: parentWindow,
+                    requiringAnyOf: ["meeting:write:meeting"]
+                )
+                let result = try await createZoomInstantMeeting(accessToken: token)
+                let startRaw = result.start_url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+                guard startRaw.isEmpty == false, let startURL = URL(string: startRaw) else {
+                    await MainActor.run {
+                        self.showSimpleAlert(
+                            title: "Unable to start",
+                            message: "Zoom did not return a host start link. Confirm your Marketplace app includes meeting creation (meeting:write:meeting), then sign in again."
+                        )
+                    }
+                    return
+                }
+                await MainActor.run {
+                    self.openWebURLPreferringInApp(
+                        startURL,
+                        zoomMeetingDisplayName: "Instant meeting",
+                        recordingConsentWindowTitle: "New Meeting"
+                    )
+                }
+            } catch {
+                await MainActor.run {
+                    if case ZoomOAuthError.missingRequiredScope(_) = error {
+                        self.zoomOAuth.clearSavedTokens()
+                        self.showSimpleAlert(
+                            title: "Zoom permissions",
+                            message: "Your Zoom app needs the meeting:write:meeting scope to start meetings from the app. Add it in the Zoom Marketplace, then sign in again."
+                        )
+                    } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
+                        let seconds = max(retryAfter ?? 300, 30)
+                        let minutes = Int(ceil(Double(seconds) / 60.0))
+                        self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before starting another meeting. Try again in about \(minutes) min.")
+                    } else if case ZoomOAuthError.authenticationCancelled = error {
+                        return
+                    } else {
+                        self.showSimpleError("Could not start meeting", error: error)
+                    }
+                }
+            }
         }
-        openWebURLPreferringInApp(url, zoomMeetingDisplayName: "Instant meeting", recordingConsentWindowTitle: "New Meeting")
     }
 
     func menuBarTriggerJoinMeeting() {