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

Enable camera/mic access for embedded Zoom web meetings.

Add sandbox camera entitlement and camera usage description, pre-request device permissions before loading meeting URLs, and implement WKWebView media-capture permission handling so Zoom can detect camera and microphone.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 месяцев назад
Родитель
Сommit
63db4fb8f0
3 измененных файлов с 119 добавлено и 39 удалено
  1. 4 2
      zoom_app.xcodeproj/project.pbxproj
  2. 113 37
      zoom_app/ViewController.swift
  3. 2 0
      zoom_app/zoom_app.entitlements

+ 4 - 2
zoom_app.xcodeproj/project.pbxproj

@@ -260,7 +260,8 @@
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
-				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App needs camera access for video in embedded Zoom meetings.";
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App needs microphone access for audio in embedded Zoom meetings and for Ai Companion recordings.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
 				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Zoom App uses Apple speech recognition to generate transcripts from your saved meeting audio in Ai Companion.";
@@ -299,7 +300,8 @@
 				INFOPLIST_KEY_CFBundleName = "Zoom App";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
-				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App records microphone audio during meetings so you can replay them in Ai Companion.";
+				INFOPLIST_KEY_NSCameraUsageDescription = "Zoom App needs camera access for video in embedded Zoom meetings.";
+				INFOPLIST_KEY_NSMicrophoneUsageDescription = "Zoom App needs microphone access for audio in embedded Zoom meetings and for Ai Companion recordings.";
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSScreenCaptureUsageDescription = "Zoom App captures system audio during meetings (when allowed) so recordings include computer sound in Ai Companion.";
 				INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Zoom App uses Apple speech recognition to generate transcripts from your saved meeting audio in Ai Companion.";

+ 113 - 37
zoom_app/ViewController.swift

@@ -1976,6 +1976,44 @@ class ViewController: NSViewController {
         return components.url
     }
 
+    /// Prefer `/wc/join/` so the embedded web client loads directly (avoids Workplace app vs browser chooser on `/s/` start links).
+    private func embeddedWebClientURLForZoomMeeting(meetingId: Int?, joinURLString: String?) -> URL? {
+        let trimmedJoin = joinURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        var passcode: String?
+        if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
+            if joinURL.path.contains("/wc/join/") {
+                return joinURL
+            }
+            passcode = URLComponents(url: joinURL, resolvingAgainstBaseURL: false)?
+                .queryItems?
+                .first(where: { $0.name == "pwd" })?
+                .value
+        }
+
+        let numericId: String? = {
+            if let meetingId { return String(meetingId) }
+            if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
+                return meetingIdDigitsFromZoomJoinPath(joinURL.path)
+            }
+            return nil
+        }()
+
+        guard let numericId, numericId.isEmpty == false else { return nil }
+        return zoomWebClientJoinURL(meetingIdDigits: numericId, passcode: passcode ?? "")
+    }
+
+    private func meetingIdDigitsFromZoomJoinPath(_ path: String) -> String? {
+        let parts = path.split(separator: "/").map(String.init)
+        for (index, segment) in parts.enumerated() {
+            let lowered = segment.lowercased()
+            if (lowered == "j" || lowered == "join" || lowered == "wc"), index + 1 < parts.count {
+                let digits = parts[index + 1].filter(\.isNumber)
+                if digits.count >= 9 { return digits }
+            }
+        }
+        return nil
+    }
+
     private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
         let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
         guard trimmed.isEmpty == false else { return nil }
@@ -2071,11 +2109,46 @@ class ViewController: NSViewController {
             return
         }
         updateEmbeddedRightPanelWidthForOpenState()
+        if isZoomMeetingJoinOrStartURL(url) {
+            Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
+        }
         webView.load(URLRequest(url: url))
         refreshEmbeddedBrowserNavButtons()
         view.window?.contentView?.layoutSubtreeIfNeeded()
     }
 
+    /// Sandbox camera entitlement + TCC prompt so `WKWebView` can enumerate devices for Zoom web meetings.
+    private func ensureMediaCaptureAccessForEmbeddedZoomWebClient() async {
+        _ = await cameraAccessGrantedForWebCapture()
+        _ = await microphoneAccessGrantedForWebCapture()
+    }
+
+    private func cameraAccessGrantedForWebCapture() async -> Bool {
+        switch AVCaptureDevice.authorizationStatus(for: .video) {
+        case .authorized:
+            return true
+        case .notDetermined:
+            return await AVCaptureDevice.requestAccess(for: .video)
+        case .denied, .restricted:
+            return false
+        @unknown default:
+            return false
+        }
+    }
+
+    private func microphoneAccessGrantedForWebCapture() async -> Bool {
+        switch AVCaptureDevice.authorizationStatus(for: .audio) {
+        case .authorized:
+            return true
+        case .notDetermined:
+            return await AVCaptureDevice.requestAccess(for: .audio)
+        case .denied, .restricted:
+            return false
+        @unknown default:
+            return false
+        }
+    }
+
     /// Makes the embedded web panel fill the entire right-side content area (to match the reference behavior).
     @MainActor
     private func updateEmbeddedRightPanelWidthForOpenState() {
@@ -3620,7 +3693,7 @@ 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.
+    /// [Instant meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate) (type 1). Use `join_url` → `/wc/join/` in the UI (same as scheduled meetings); `start_url` shows a Workplace app chooser in `WKWebView`.
     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)
@@ -3702,38 +3775,7 @@ class ViewController: NSViewController {
                     .flatMap { self.meetingTimeZoneOverrides[$0] }
                     .flatMap { TimeZone(identifier: $0) }
                 let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
-                let webURL: URL? = {
-                    func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
-                        var components = URLComponents()
-                        components.scheme = "https"
-                        components.host = "zoom.us"
-                        components.path = "/wc/join/\(meetingId)"
-                        if let pwd, pwd.isEmpty == false {
-                            components.queryItems = [URLQueryItem(name: "pwd", value: pwd)]
-                        }
-                        return components.url
-                    }
-
-                    if let join = meeting.join_url, let url = URL(string: join), url.scheme != nil {
-                        // Prefer the Zoom Web Client join URL so a click joins in the browser.
-                        // join_url is often `https://zoom.us/j/<id>?pwd=...`
-                        if url.path.contains("/wc/join/") {
-                            return url
-                        }
-                        if let id = meeting.id {
-                            let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
-                                .queryItems?
-                                .first(where: { $0.name == "pwd" })?
-                                .value
-                            return wcJoinURL(meetingId: id, pwd: pwd)
-                        }
-                        return url
-                    }
-                    if let id = meeting.id {
-                        return wcJoinURL(meetingId: id, pwd: nil)
-                    }
-                    return nil
-                }()
+                let webURL = embeddedWebClientURLForZoomMeeting(meetingId: meeting.id, joinURLString: meeting.join_url)
                 return ScheduledMeeting(
                     title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
                     start: start,
@@ -6551,6 +6593,7 @@ class ViewController: NSViewController {
 
         let embeddedBrowserConfiguration = WKWebViewConfiguration()
         embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
+        embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
         let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
         embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
         embeddedWK.translatesAutoresizingMaskIntoConstraints = false
@@ -9037,19 +9080,21 @@ class ViewController: NSViewController {
                     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 {
+                guard let joinURL = self.embeddedWebClientURLForZoomMeeting(
+                    meetingId: result.id,
+                    joinURLString: result.join_url
+                ) 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."
+                            message: "Zoom did not return a join link for this meeting. Confirm your Marketplace app includes meeting creation (meeting:write:meeting), then sign in again."
                         )
                     }
                     return
                 }
                 await MainActor.run {
                     self.openWebURLPreferringInApp(
-                        startURL,
+                        joinURL,
                         zoomMeetingDisplayName: "Instant meeting",
                         recordingConsentWindowTitle: "New Meeting"
                     )
@@ -9514,6 +9559,37 @@ extension ViewController: WKNavigationDelegate, WKUIDelegate {
         }
         return nil
     }
+
+    func webView(
+        _ webView: WKWebView,
+        requestMediaCapturePermissionFor origin: WKSecurityOrigin,
+        initiatedByFrame frame: WKFrameInfo,
+        type: WKMediaCaptureType,
+        decisionHandler: @escaping (WKPermissionDecision) -> Void
+    ) {
+        guard webView === embeddedBrowserWebView else {
+            decisionHandler(.prompt)
+            return
+        }
+        Task {
+            let grant: Bool
+            switch type {
+            case .camera:
+                grant = await cameraAccessGrantedForWebCapture()
+            case .microphone:
+                grant = await microphoneAccessGrantedForWebCapture()
+            case .cameraAndMicrophone:
+                let camera = await cameraAccessGrantedForWebCapture()
+                let microphone = await microphoneAccessGrantedForWebCapture()
+                grant = camera && microphone
+            @unknown default:
+                grant = false
+            }
+            await MainActor.run {
+                decisionHandler(grant ? .grant : .deny)
+            }
+        }
+    }
 }
 
 extension ViewController: NSWindowDelegate {

+ 2 - 0
zoom_app/zoom_app.entitlements

@@ -6,6 +6,8 @@
 	<true/>
 	<key>com.apple.security.device.audio-input</key>
 	<true/>
+	<key>com.apple.security.device.camera</key>
+	<true/>
 	<key>com.apple.security.network.client</key>
 	<true/>
 	<key>com.apple.security.network.server</key>