|
@@ -1976,6 +1976,44 @@ class ViewController: NSViewController {
|
|
|
return components.url
|
|
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? {
|
|
private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
|
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
guard trimmed.isEmpty == false else { return nil }
|
|
guard trimmed.isEmpty == false else { return nil }
|
|
@@ -2071,11 +2109,46 @@ class ViewController: NSViewController {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
updateEmbeddedRightPanelWidthForOpenState()
|
|
updateEmbeddedRightPanelWidthForOpenState()
|
|
|
|
|
+ if isZoomMeetingJoinOrStartURL(url) {
|
|
|
|
|
+ Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
|
|
|
|
|
+ }
|
|
|
webView.load(URLRequest(url: url))
|
|
webView.load(URLRequest(url: url))
|
|
|
refreshEmbeddedBrowserNavButtons()
|
|
refreshEmbeddedBrowserNavButtons()
|
|
|
view.window?.contentView?.layoutSubtreeIfNeeded()
|
|
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).
|
|
/// Makes the embedded web panel fill the entire right-side content area (to match the reference behavior).
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func updateEmbeddedRightPanelWidthForOpenState() {
|
|
private func updateEmbeddedRightPanelWidthForOpenState() {
|
|
@@ -3620,7 +3693,7 @@ class ViewController: NSViewController {
|
|
|
return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
|
|
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 {
|
|
private func createZoomInstantMeeting(accessToken: String, topic: String = "Instant meeting") async throws -> ZoomCreateMeetingAPIResult {
|
|
|
let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
|
|
let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
|
|
|
var request = URLRequest(url: url)
|
|
var request = URLRequest(url: url)
|
|
@@ -3702,38 +3775,7 @@ class ViewController: NSViewController {
|
|
|
.flatMap { self.meetingTimeZoneOverrides[$0] }
|
|
.flatMap { self.meetingTimeZoneOverrides[$0] }
|
|
|
.flatMap { TimeZone(identifier: $0) }
|
|
.flatMap { TimeZone(identifier: $0) }
|
|
|
let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
|
|
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(
|
|
return ScheduledMeeting(
|
|
|
title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
|
|
title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
|
|
|
start: start,
|
|
start: start,
|
|
@@ -6551,6 +6593,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
let embeddedBrowserConfiguration = WKWebViewConfiguration()
|
|
let embeddedBrowserConfiguration = WKWebViewConfiguration()
|
|
|
embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
|
|
|
+ embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
|
|
|
let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
|
|
let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
|
|
|
embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
|
|
embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
|
|
|
embeddedWK.translatesAutoresizingMaskIntoConstraints = false
|
|
embeddedWK.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -9037,19 +9080,21 @@ class ViewController: NSViewController {
|
|
|
requiringAnyOf: ["meeting:write:meeting"]
|
|
requiringAnyOf: ["meeting:write:meeting"]
|
|
|
)
|
|
)
|
|
|
let result = try await createZoomInstantMeeting(accessToken: token)
|
|
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 {
|
|
await MainActor.run {
|
|
|
self.showSimpleAlert(
|
|
self.showSimpleAlert(
|
|
|
title: "Unable to start",
|
|
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
|
|
return
|
|
|
}
|
|
}
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
|
self.openWebURLPreferringInApp(
|
|
self.openWebURLPreferringInApp(
|
|
|
- startURL,
|
|
|
|
|
|
|
+ joinURL,
|
|
|
zoomMeetingDisplayName: "Instant meeting",
|
|
zoomMeetingDisplayName: "Instant meeting",
|
|
|
recordingConsentWindowTitle: "New Meeting"
|
|
recordingConsentWindowTitle: "New Meeting"
|
|
|
)
|
|
)
|
|
@@ -9514,6 +9559,37 @@ extension ViewController: WKNavigationDelegate, WKUIDelegate {
|
|
|
}
|
|
}
|
|
|
return nil
|
|
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 {
|
|
extension ViewController: NSWindowDelegate {
|