|
|
@@ -31,6 +31,11 @@ private enum AppExternalURLs {
|
|
|
private let embeddedZoomWebViewUserAgent =
|
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"
|
|
|
|
|
|
+/// Shared cookie jar for embedded Zoom meetings and Zoom OAuth so the in-app browser reuses the same Zoom web session.
|
|
|
+private let zoomEmbeddedWebsiteDataStore = WKWebsiteDataStore.default()
|
|
|
+/// Shared renderer process so OAuth cookies are visible to the embedded meeting web client.
|
|
|
+private let zoomEmbeddedWebProcessPool = WKProcessPool()
|
|
|
+
|
|
|
/// Errors from the main-window embedded OAuth web session (right panel).
|
|
|
private enum EmbeddedOAuthFlowError: Error {
|
|
|
case webViewUnavailable
|
|
|
@@ -418,6 +423,9 @@ class ViewController: NSViewController {
|
|
|
private var embeddedBrowserMainColumnCollapsedWidthConstraint: NSLayoutConstraint?
|
|
|
private var oauthSessionContinuation: CheckedContinuation<URL, Error>?
|
|
|
private var oauthSessionCallbackPrefix: String?
|
|
|
+ /// ZAK + display name for the next embedded join; used to re-auth the web client if the guest name screen appears.
|
|
|
+ private var embeddedZoomPendingWebAuth: (zak: String, displayName: String?)?
|
|
|
+ private var zoomSignedInDisplayName: String?
|
|
|
/// Gap between main column and right web panel (must match Auto Layout constant).
|
|
|
private let homeEmbeddedBrowserColumnGap: CGFloat = 10
|
|
|
private weak var homePremiumLockOverlay: NSButton?
|
|
|
@@ -739,6 +747,22 @@ class ViewController: NSViewController {
|
|
|
ensureUsageSessionStartDate()
|
|
|
scheduleUsageBasedRatingPromptIfNeeded()
|
|
|
recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
+ zoomOAuth.embeddedOAuthSession = { [weak self] startURL, callbackPrefix, timeout in
|
|
|
+ guard let self else { throw ZoomOAuthError.unableToOpenBrowser }
|
|
|
+ do {
|
|
|
+ return try await self.runEmbeddedOAuthSession(
|
|
|
+ startURL: startURL,
|
|
|
+ callbackURLPrefix: callbackPrefix,
|
|
|
+ timeoutSeconds: timeout
|
|
|
+ )
|
|
|
+ } catch EmbeddedOAuthFlowError.userCancelled {
|
|
|
+ throw ZoomOAuthError.authenticationCancelled
|
|
|
+ } catch EmbeddedOAuthFlowError.timedOut {
|
|
|
+ throw ZoomOAuthError.authenticationTimedOut
|
|
|
+ } catch EmbeddedOAuthFlowError.webViewUnavailable {
|
|
|
+ throw ZoomOAuthError.unableToOpenBrowser
|
|
|
+ }
|
|
|
+ }
|
|
|
NotificationCenter.default.addObserver(
|
|
|
forName: MeetingRecordingManager.recordingsDidChangeNotification,
|
|
|
object: nil,
|
|
|
@@ -1024,6 +1048,7 @@ class ViewController: NSViewController {
|
|
|
UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
|
|
|
}
|
|
|
self.completeLoginAndRestart(profile: profile)
|
|
|
+ self.primeZoomEmbeddedWebSession()
|
|
|
}
|
|
|
} catch {
|
|
|
await MainActor.run {
|
|
|
@@ -2031,32 +2056,219 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
|
|
|
- private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
|
|
|
+ /// Maps `zoom.us` join links to `app.zoom.us` (Workplace web client host).
|
|
|
+ private func zoomEmbeddedWebHostURL(for url: URL) -> URL {
|
|
|
+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
|
|
|
+ let host = components.host?.lowercased() ?? ""
|
|
|
+ if host == "zoom.us" || host.hasSuffix(".zoom.us") {
|
|
|
+ components.host = "app.zoom.us"
|
|
|
+ }
|
|
|
+ return components.url ?? url
|
|
|
+ }
|
|
|
+
|
|
|
+ private func zakQueryValueFromZoomURL(_ url: URL) -> String? {
|
|
|
+ let fromQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
|
|
+ .queryItems?
|
|
|
+ .first(where: { $0.name.caseInsensitiveCompare("zak") == .orderedSame })?
|
|
|
+ .value?
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if let fromQuery, fromQuery.isEmpty == false { return fromQuery }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd` + optional `zak`).
|
|
|
+ private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String, zak: String? = nil, displayName: String? = nil) -> URL? {
|
|
|
guard meetingIdDigits.isEmpty == false else { return nil }
|
|
|
var components = URLComponents()
|
|
|
components.scheme = "https"
|
|
|
- components.host = "zoom.us"
|
|
|
+ components.host = "app.zoom.us"
|
|
|
components.path = "/wc/join/\(meetingIdDigits)"
|
|
|
- let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
- if trimmed.isEmpty == false {
|
|
|
- components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
|
|
|
+ var queryItems: [URLQueryItem] = []
|
|
|
+ let trimmedPasscode = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if trimmedPasscode.isEmpty == false {
|
|
|
+ queryItems.append(URLQueryItem(name: "pwd", value: trimmedPasscode))
|
|
|
+ }
|
|
|
+ let trimmedZAK = zak?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ if trimmedZAK.isEmpty == false {
|
|
|
+ queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
|
|
|
+ }
|
|
|
+ let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ if trimmedName.isEmpty == false {
|
|
|
+ queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
|
|
|
+ }
|
|
|
+ if queryItems.isEmpty == false {
|
|
|
+ components.queryItems = queryItems
|
|
|
}
|
|
|
return components.url
|
|
|
}
|
|
|
|
|
|
+ /// Adds ZAK and display name so the embedded web client joins as the OAuth-signed-in user (not a guest).
|
|
|
+ private func zoomMeetingURLByAppendingWebAuth(_ url: URL, zak: String, displayName: String?) -> URL {
|
|
|
+ let normalizedHost = zoomEmbeddedWebHostURL(for: url)
|
|
|
+ let trimmedZAK = zak.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ guard trimmedZAK.isEmpty == false else { return normalizedHost }
|
|
|
+ var components = URLComponents(url: normalizedHost, resolvingAgainstBaseURL: false) ?? URLComponents()
|
|
|
+ var queryItems = components.queryItems ?? []
|
|
|
+ queryItems.removeAll { $0.name.caseInsensitiveCompare("zak") == .orderedSame }
|
|
|
+ queryItems.removeAll { $0.name.caseInsensitiveCompare("uname") == .orderedSame }
|
|
|
+ queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
|
|
|
+ let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ if trimmedName.isEmpty == false {
|
|
|
+ queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
|
|
|
+ }
|
|
|
+ components.queryItems = queryItems
|
|
|
+ return components.url ?? normalizedHost
|
|
|
+ }
|
|
|
+
|
|
|
+ private func javaScriptStringLiteral(_ value: String) -> String {
|
|
|
+ let escaped = value
|
|
|
+ .replacingOccurrences(of: "\\", with: "\\\\")
|
|
|
+ .replacingOccurrences(of: "'", with: "\\'")
|
|
|
+ .replacingOccurrences(of: "\n", with: "\\n")
|
|
|
+ .replacingOccurrences(of: "\r", with: "")
|
|
|
+ return "'\(escaped)'"
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Loads a Zoom Workplace page in the shared web view so OAuth cookies apply to later joins (panel may stay closed).
|
|
|
+ @MainActor
|
|
|
+ private func primeZoomEmbeddedWebSession() {
|
|
|
+ guard isUserLoggedIn(), let webView = embeddedBrowserWebView else { return }
|
|
|
+ guard let homeURL = URL(string: "https://app.zoom.us/wc/home") else { return }
|
|
|
+ webView.load(URLRequest(url: homeURL))
|
|
|
+ }
|
|
|
+
|
|
|
+ private func zoomOAuthErrorIndicatesMissingZAKScope(_ error: Error) -> Bool {
|
|
|
+ if case ZoomOAuthError.missingRequiredScope(let raw) = error {
|
|
|
+ let lower = raw.lowercased()
|
|
|
+ return lower.contains("user_zak") || lower.contains("zak")
|
|
|
+ }
|
|
|
+ let lower = error.localizedDescription.lowercased()
|
|
|
+ return lower.contains("user_zak") || lower.contains("does not contain scopes")
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Fetches ZAK for embedded join. If the saved token predates `user_zak:read`, opens Zoom OAuth once (you stay signed in to the app).
|
|
|
+ @MainActor
|
|
|
+ private func fetchEmbeddedZoomZAKAllowingReConsent() async -> String? {
|
|
|
+ do {
|
|
|
+ let accessToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
+ return try await fetchZoomUserZAK(accessToken: accessToken)
|
|
|
+ } catch {
|
|
|
+ if case ZoomOAuthError.authenticationCancelled = error { return nil }
|
|
|
+ guard zoomOAuthErrorIndicatesMissingZAKScope(error) else {
|
|
|
+ NSLog("ViewController: ZAK unavailable for embedded join — \(error.localizedDescription)")
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ do {
|
|
|
+ zoomOAuth.clearSavedTokens()
|
|
|
+ let accessToken = try await zoomOAuth.validAccessToken(
|
|
|
+ presentingWindow: view.window,
|
|
|
+ requiringAnyOf: ["user_zak:read"]
|
|
|
+ )
|
|
|
+ return try await fetchZoomUserZAK(accessToken: accessToken)
|
|
|
+ } catch {
|
|
|
+ if case ZoomOAuthError.authenticationCancelled = error { return nil }
|
|
|
+ NSLog("ViewController: ZAK unavailable after OAuth refresh — \(error.localizedDescription)")
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func zoomMeetingURLWithSignedInDisplayNameFallback(_ url: URL) -> URL {
|
|
|
+ guard let name = zoomSignedInDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
+ name.isEmpty == false else { return url }
|
|
|
+ var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents()
|
|
|
+ var queryItems = components.queryItems ?? []
|
|
|
+ if queryItems.contains(where: { $0.name.caseInsensitiveCompare("uname") == .orderedSame }) == false {
|
|
|
+ queryItems.append(URLQueryItem(name: "uname", value: name))
|
|
|
+ }
|
|
|
+ components.queryItems = queryItems
|
|
|
+ return components.url ?? url
|
|
|
+ }
|
|
|
+
|
|
|
+ /// When signed in, attach a fresh ZAK so `/wc/join/` loads with the same Zoom account as native OAuth.
|
|
|
+ @MainActor
|
|
|
+ private func embeddedZoomMeetingLoadURL(for url: URL) async -> URL {
|
|
|
+ let normalized = zoomEmbeddedWebHostURL(for: url)
|
|
|
+ guard isZoomMeetingJoinOrStartURL(normalized), isUserLoggedIn() else { return normalized }
|
|
|
+
|
|
|
+ if let existingZAK = zakQueryValueFromZoomURL(normalized) {
|
|
|
+ let displayName = zoomSignedInDisplayName
|
|
|
+ embeddedZoomPendingWebAuth = (zak: existingZAK, displayName: displayName)
|
|
|
+ return zoomMeetingURLByAppendingWebAuth(normalized, zak: existingZAK, displayName: displayName)
|
|
|
+ }
|
|
|
+
|
|
|
+ primeZoomEmbeddedWebSession()
|
|
|
+
|
|
|
+ if let zak = await fetchEmbeddedZoomZAKAllowingReConsent() {
|
|
|
+ let displayName = zoomSignedInDisplayName
|
|
|
+ embeddedZoomPendingWebAuth = (zak: zak, displayName: displayName)
|
|
|
+ return zoomMeetingURLByAppendingWebAuth(normalized, zak: zak, displayName: displayName)
|
|
|
+ }
|
|
|
+
|
|
|
+ // No ZAK (scope not on OAuth app yet): still pre-fill name and rely on shared web cookies.
|
|
|
+ embeddedZoomPendingWebAuth = nil
|
|
|
+ return zoomMeetingURLWithSignedInDisplayNameFallback(normalized)
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func applyEmbeddedZoomWebAuthenticationIfNeeded(webView: WKWebView) {
|
|
|
+ guard let pending = embeddedZoomPendingWebAuth else { return }
|
|
|
+ guard let pageURL = webView.url, isZoomMeetingJoinOrStartURL(pageURL) else { return }
|
|
|
+ let title = webView.title ?? ""
|
|
|
+ let onGuestJoinScreen = title.localizedCaseInsensitiveContains("Enter Meeting Info")
|
|
|
+ || title.localizedCaseInsensitiveContains("Enter your name")
|
|
|
+ guard onGuestJoinScreen else { return }
|
|
|
+
|
|
|
+ let zakLiteral = javaScriptStringLiteral(pending.zak)
|
|
|
+ let nameLiteral = pending.displayName.map(javaScriptStringLiteral) ?? "null"
|
|
|
+ let script = """
|
|
|
+ (function() {
|
|
|
+ var zak = \(zakLiteral);
|
|
|
+ var uname = \(nameLiteral);
|
|
|
+ try {
|
|
|
+ var next = new URL(window.location.href);
|
|
|
+ if (!next.searchParams.get('zak')) {
|
|
|
+ next.searchParams.set('zak', zak);
|
|
|
+ if (uname) next.searchParams.set('uname', uname);
|
|
|
+ window.location.replace(next.toString());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ try {
|
|
|
+ sessionStorage.setItem('zak', zak);
|
|
|
+ if (uname) sessionStorage.setItem('uname', uname);
|
|
|
+ } catch (e2) {}
|
|
|
+ })();
|
|
|
+ """
|
|
|
+ webView.evaluateJavaScript(script, completionHandler: nil)
|
|
|
+ }
|
|
|
+
|
|
|
/// 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? {
|
|
|
+ private func embeddedWebClientURLForZoomMeeting(
|
|
|
+ meetingId: Int?,
|
|
|
+ joinURLString: String?,
|
|
|
+ startURLString: String? = nil
|
|
|
+ ) -> URL? {
|
|
|
let trimmedJoin = joinURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ let trimmedStart = startURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
var passcode: String?
|
|
|
+ var zakFromAPI: String?
|
|
|
+ if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
|
|
|
+ zakFromAPI = zakQueryValueFromZoomURL(startURL)
|
|
|
+ }
|
|
|
if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
|
|
|
if joinURL.path.contains("/wc/join/") {
|
|
|
- return joinURL
|
|
|
+ return zoomEmbeddedWebHostURL(for: joinURL)
|
|
|
}
|
|
|
passcode = URLComponents(url: joinURL, resolvingAgainstBaseURL: false)?
|
|
|
.queryItems?
|
|
|
.first(where: { $0.name == "pwd" })?
|
|
|
.value
|
|
|
+ if zakFromAPI == nil {
|
|
|
+ zakFromAPI = zakQueryValueFromZoomURL(joinURL)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
let numericId: String? = {
|
|
|
@@ -2064,11 +2276,19 @@ class ViewController: NSViewController {
|
|
|
if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
|
|
|
return meetingIdDigitsFromZoomJoinPath(joinURL.path)
|
|
|
}
|
|
|
+ if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
|
|
|
+ return meetingIdDigitsFromZoomJoinPath(startURL.path)
|
|
|
+ }
|
|
|
return nil
|
|
|
}()
|
|
|
|
|
|
guard let numericId, numericId.isEmpty == false else { return nil }
|
|
|
- return zoomWebClientJoinURL(meetingIdDigits: numericId, passcode: passcode ?? "")
|
|
|
+ return zoomWebClientJoinURL(
|
|
|
+ meetingIdDigits: numericId,
|
|
|
+ passcode: passcode ?? "",
|
|
|
+ zak: zakFromAPI,
|
|
|
+ displayName: zoomSignedInDisplayName
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
private func meetingIdDigitsFromZoomJoinPath(_ path: String) -> String? {
|
|
|
@@ -2331,10 +2551,14 @@ class ViewController: NSViewController {
|
|
|
if isZoomMeetingJoinOrStartURL(url) {
|
|
|
Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
|
|
|
}
|
|
|
- webView.load(URLRequest(url: url))
|
|
|
- updateEmbeddedMeetingRecordingPresence(for: url)
|
|
|
- refreshEmbeddedBrowserNavButtons()
|
|
|
- view.window?.contentView?.layoutSubtreeIfNeeded()
|
|
|
+ Task { @MainActor in
|
|
|
+ let loadURL = await self.embeddedZoomMeetingLoadURL(for: url)
|
|
|
+ guard self.embeddedBrowserWebView === webView else { return }
|
|
|
+ webView.load(URLRequest(url: loadURL))
|
|
|
+ self.updateEmbeddedMeetingRecordingPresence(for: loadURL)
|
|
|
+ self.refreshEmbeddedBrowserNavButtons()
|
|
|
+ self.view.window?.contentView?.layoutSubtreeIfNeeded()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/// Sandbox camera entitlement + TCC prompt so `WKWebView` can enumerate devices for Zoom web meetings.
|
|
|
@@ -2412,7 +2636,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
|
|
|
@MainActor
|
|
|
- private func closeEmbeddedBrowserPanel() {
|
|
|
+ private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
|
|
|
if MeetingRecordingManager.shared.isRecording {
|
|
|
endZoomMeetingRecordingAutomatically()
|
|
|
}
|
|
|
@@ -2421,7 +2645,10 @@ class ViewController: NSViewController {
|
|
|
oauthSessionCallbackPrefix = nil
|
|
|
cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
|
|
|
}
|
|
|
- embeddedBrowserWebView?.stopLoading()
|
|
|
+ if stoppingWebLoad {
|
|
|
+ embeddedBrowserWebView?.stopLoading()
|
|
|
+ }
|
|
|
+ embeddedZoomPendingWebAuth = nil
|
|
|
stopEmbeddedMeetingRecordingMonitor()
|
|
|
MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
|
|
|
embeddedBrowserPanelWidthConstraint?.constant = 0
|
|
|
@@ -2463,8 +2690,10 @@ class ViewController: NSViewController {
|
|
|
await MainActor.run {
|
|
|
self.oauthSessionContinuation = nil
|
|
|
self.oauthSessionCallbackPrefix = nil
|
|
|
- self.closeEmbeddedBrowserPanel()
|
|
|
- webView.load(URLRequest(url: URL(string: "about:blank")!))
|
|
|
+ if let primeURL = URL(string: "https://app.zoom.us/wc/home") {
|
|
|
+ webView.load(URLRequest(url: primeURL))
|
|
|
+ }
|
|
|
+ self.closeEmbeddedBrowserPanel(stoppingWebLoad: false)
|
|
|
}
|
|
|
return url
|
|
|
}
|
|
|
@@ -2503,6 +2732,8 @@ class ViewController: NSViewController {
|
|
|
clearMeetingsScrollObserver()
|
|
|
googleOAuth.clearSavedTokens()
|
|
|
zoomOAuth.clearSavedTokens()
|
|
|
+ zoomSignedInDisplayName = nil
|
|
|
+ embeddedZoomPendingWebAuth = nil
|
|
|
persistLoggedInState(false)
|
|
|
allScheduledMeetings = []
|
|
|
applyAuthenticationUIState(triggerInitialRefresh: false)
|
|
|
@@ -3032,6 +3263,8 @@ class ViewController: NSViewController {
|
|
|
@MainActor
|
|
|
private func completeLoginAndRestart(profile: GoogleUserProfile?) {
|
|
|
persistLoggedInState(true)
|
|
|
+ let trimmedName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
+ zoomSignedInDisplayName = trimmedName.isEmpty ? nil : trimmedName
|
|
|
showHomeView(profile: profile)
|
|
|
if let window = view.window {
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
|
@@ -3841,7 +4074,7 @@ class ViewController: NSViewController {
|
|
|
let alert = NSAlert()
|
|
|
alert.alertStyle = .informational
|
|
|
alert.messageText = "Configure Zoom OAuth"
|
|
|
- alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable granular scopes for listing meetings (meeting:read:list_meetings), scheduling (meeting:write:meeting), listing AI summaries (meeting:read:list_summaries), and reading a single summary (meeting:read:summary)."
|
|
|
+ alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable granular scopes for listing meetings (meeting:read:list_meetings), scheduling (meeting:write:meeting), listing AI summaries (meeting:read:list_summaries), reading a single summary (meeting:read:summary), and embedded web join (user_zak:read)."
|
|
|
|
|
|
let wrapper = NSStackView()
|
|
|
wrapper.orientation = .vertical
|
|
|
@@ -3924,6 +4157,45 @@ class ViewController: NSViewController {
|
|
|
return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
|
|
|
}
|
|
|
|
|
|
+ private struct ZoomZAKTokenResponse: Decodable {
|
|
|
+ let token: String
|
|
|
+ }
|
|
|
+
|
|
|
+ /// ZAK authenticates the embedded Zoom web client as the signed-in OAuth user ([`GET /users/me/token`](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/userZAK)).
|
|
|
+ private func fetchZoomUserZAK(accessToken: String) async throws -> String {
|
|
|
+ var components = URLComponents(string: "https://api.zoom.us/v2/users/me/token")!
|
|
|
+ components.queryItems = [URLQueryItem(name: "type", value: "zak")]
|
|
|
+ guard let url = components.url else {
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed("Invalid ZAK request URL")
|
|
|
+ }
|
|
|
+ var request = URLRequest(url: url)
|
|
|
+ request.httpMethod = "GET"
|
|
|
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
|
+
|
|
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
+ guard let http = response as? HTTPURLResponse else {
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (ZAK)")
|
|
|
+ }
|
|
|
+ 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 load ZAK"
|
|
|
+ if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
+ throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
+ }
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ }
|
|
|
+ let decoded = try JSONDecoder().decode(ZoomZAKTokenResponse.self, from: data)
|
|
|
+ let trimmed = decoded.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ guard trimmed.isEmpty == false else {
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed("Empty ZAK from Zoom")
|
|
|
+ }
|
|
|
+ return trimmed
|
|
|
+ }
|
|
|
+
|
|
|
private struct ZoomCreateMeetingBody: Encodable {
|
|
|
let topic: String
|
|
|
let type: Int
|
|
|
@@ -6871,6 +7143,8 @@ class ViewController: NSViewController {
|
|
|
webToolBarStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
let embeddedBrowserConfiguration = WKWebViewConfiguration()
|
|
|
+ embeddedBrowserConfiguration.websiteDataStore = zoomEmbeddedWebsiteDataStore
|
|
|
+ embeddedBrowserConfiguration.processPool = zoomEmbeddedWebProcessPool
|
|
|
embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
|
|
embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
|
|
|
let meetingLifecycleController = WKUserContentController()
|
|
|
@@ -9380,7 +9654,8 @@ class ViewController: NSViewController {
|
|
|
let result = try await createZoomInstantMeeting(accessToken: token)
|
|
|
guard let joinURL = self.embeddedWebClientURLForZoomMeeting(
|
|
|
meetingId: result.id,
|
|
|
- joinURLString: result.join_url
|
|
|
+ joinURLString: result.join_url,
|
|
|
+ startURLString: result.start_url
|
|
|
) else {
|
|
|
await MainActor.run {
|
|
|
self.showSimpleAlert(
|
|
|
@@ -9865,6 +10140,10 @@ extension ViewController: WKNavigationDelegate, WKUIDelegate {
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
|
guard webView === embeddedBrowserWebView else { return }
|
|
|
updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
|
|
|
+ applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
|
|
|
+ if let pageURL = webView.url, isZoomEmbeddedWebClientInCallURL(pageURL) {
|
|
|
+ embeddedZoomPendingWebAuth = nil
|
|
|
+ }
|
|
|
refreshEmbeddedBrowserNavButtons()
|
|
|
}
|
|
|
|
|
|
@@ -10274,6 +10553,10 @@ final class ZoomOAuthService: NSObject {
|
|
|
static let shared = ZoomOAuthService()
|
|
|
private var inAppOAuthWindowController: InAppOAuthWindowController?
|
|
|
|
|
|
+ /// When set (by `ViewController`), Zoom OAuth runs in the right-panel web view so cookies match embedded meetings.
|
|
|
+ @MainActor
|
|
|
+ var embeddedOAuthSession: (@MainActor (URL, String, Double) async throws -> URL)?
|
|
|
+
|
|
|
private let tokenStore = ZoomOAuthTokenStore()
|
|
|
private let clientIdDefaultsKey = "zoom.oauth.clientId"
|
|
|
private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
|
|
|
@@ -10394,7 +10677,8 @@ final class ZoomOAuthService: NSObject {
|
|
|
"meeting:read:list_meetings",
|
|
|
"meeting:write:meeting",
|
|
|
"meeting:read:summary",
|
|
|
- "zoomapp:inmeeting"
|
|
|
+ "zoomapp:inmeeting",
|
|
|
+ "user_zak:read"
|
|
|
]
|
|
|
|
|
|
var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
|
|
|
@@ -10410,14 +10694,19 @@ final class ZoomOAuthService: NSObject {
|
|
|
URLQueryItem(name: "state", value: state)
|
|
|
]
|
|
|
guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
- let oauthWindow = await MainActor.run { makeOAuthWindowController() }
|
|
|
do {
|
|
|
- let callbackURL = try await oauthWindow.authenticate(
|
|
|
- startURL: authURL,
|
|
|
- callbackURLPrefix: redirectURI,
|
|
|
- presentingWindow: presentingWindow,
|
|
|
- timeoutSeconds: 180
|
|
|
- )
|
|
|
+ let callbackURL: URL
|
|
|
+ if let runEmbeddedOAuth = await MainActor.run(body: { embeddedOAuthSession }) {
|
|
|
+ callbackURL = try await runEmbeddedOAuth(authURL, redirectURI, 180)
|
|
|
+ } else {
|
|
|
+ let oauthWindow = await MainActor.run { makeOAuthWindowController() }
|
|
|
+ callbackURL = try await oauthWindow.authenticate(
|
|
|
+ startURL: authURL,
|
|
|
+ callbackURLPrefix: redirectURI,
|
|
|
+ presentingWindow: presentingWindow,
|
|
|
+ timeoutSeconds: 180
|
|
|
+ )
|
|
|
+ }
|
|
|
let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
|
guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
|
@@ -10436,7 +10725,11 @@ final class ZoomOAuthService: NSObject {
|
|
|
@MainActor
|
|
|
private func makeOAuthWindowController() -> InAppOAuthWindowController {
|
|
|
if let existing = inAppOAuthWindowController { return existing }
|
|
|
- let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
|
|
|
+ let controller = InAppOAuthWindowController(
|
|
|
+ title: "Zoom Sign-In",
|
|
|
+ websiteDataStore: zoomEmbeddedWebsiteDataStore,
|
|
|
+ processPool: zoomEmbeddedWebProcessPool
|
|
|
+ )
|
|
|
inAppOAuthWindowController = controller
|
|
|
return controller
|
|
|
}
|
|
|
@@ -10535,6 +10828,8 @@ final class ZoomOAuthService: NSObject {
|
|
|
return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
|
|
|
case "meeting:write:meeting":
|
|
|
return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
|
|
|
+ case "user_zak:read":
|
|
|
+ return granted.contains("user_zak") || granted.contains("user:read:zak")
|
|
|
default:
|
|
|
return false
|
|
|
}
|
|
|
@@ -10775,9 +11070,16 @@ private final class InAppOAuthWindowController: NSWindowController, WKNavigation
|
|
|
/// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
|
|
|
private var suppressUserCloseCancellation = false
|
|
|
|
|
|
- init(title: String) {
|
|
|
+ init(
|
|
|
+ title: String,
|
|
|
+ websiteDataStore: WKWebsiteDataStore = .nonPersistent(),
|
|
|
+ processPool: WKProcessPool? = nil
|
|
|
+ ) {
|
|
|
let configuration = WKWebViewConfiguration()
|
|
|
- configuration.websiteDataStore = .nonPersistent()
|
|
|
+ configuration.websiteDataStore = websiteDataStore
|
|
|
+ if let processPool {
|
|
|
+ configuration.processPool = processPool
|
|
|
+ }
|
|
|
self.webView = WKWebView(frame: .zero, configuration: configuration)
|
|
|
let container = OAuthWebViewContainerView(webView: webView)
|
|
|
let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
|