|
@@ -1554,7 +1554,7 @@ class ViewController: NSViewController {
|
|
|
root.layer?.backgroundColor = appBackground.cgColor
|
|
root.layer?.backgroundColor = appBackground.cgColor
|
|
|
|
|
|
|
|
let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
|
|
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))
|
|
let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
|
|
|
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -1988,7 +1988,8 @@ class ViewController: NSViewController {
|
|
|
return url
|
|
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,
|
|
/// 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).
|
|
/// then optionally begins local audio recording (when the user allows and recording is not already active).
|
|
|
@MainActor
|
|
@MainActor
|
|
@@ -2006,7 +2007,13 @@ class ViewController: NSViewController {
|
|
|
shouldRecordLocally = false
|
|
shouldRecordLocally = false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let opened = openURLInDefaultBrowser(url)
|
|
|
|
|
|
|
+ let opened: Bool
|
|
|
|
|
+ if isZoomMeetingJoinOrStartURL(url), embeddedBrowserWebView != nil {
|
|
|
|
|
+ presentURLInEmbeddedRightPanel(url)
|
|
|
|
|
+ opened = true
|
|
|
|
|
+ } else {
|
|
|
|
|
+ opened = openURLInDefaultBrowser(url)
|
|
|
|
|
+ }
|
|
|
if opened {
|
|
if opened {
|
|
|
joinMeetingWindow?.performClose(nil)
|
|
joinMeetingWindow?.performClose(nil)
|
|
|
if shouldRecordLocally {
|
|
if shouldRecordLocally {
|
|
@@ -2028,6 +2035,7 @@ class ViewController: NSViewController {
|
|
|
if path.contains("/join/") { return true }
|
|
if path.contains("/join/") { return true }
|
|
|
if path.contains("/my/") { return true }
|
|
if path.contains("/my/") { return true }
|
|
|
if path.contains("/w/") { return true }
|
|
if path.contains("/w/") { return true }
|
|
|
|
|
+ if path.contains("/s/") { return true }
|
|
|
if path.contains("videomeeting") { return true }
|
|
if path.contains("videomeeting") { return true }
|
|
|
if path.contains("/start") && path.contains("meeting") { return true }
|
|
if path.contains("/start") && path.contains("meeting") { return true }
|
|
|
return false
|
|
return false
|
|
@@ -2074,8 +2082,10 @@ class ViewController: NSViewController {
|
|
|
if rowW < 1 {
|
|
if rowW < 1 {
|
|
|
rowW = view.bounds.width
|
|
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.
|
|
// Collapse the left main content column while web is visible.
|
|
|
homeMainContentColumnView?.isHidden = true
|
|
homeMainContentColumnView?.isHidden = true
|
|
@@ -3562,6 +3572,12 @@ class ViewController: NSViewController {
|
|
|
private struct ZoomCreateMeetingAPIResult: Decodable {
|
|
private struct ZoomCreateMeetingAPIResult: Decodable {
|
|
|
let id: Int?
|
|
let id: Int?
|
|
|
let join_url: String?
|
|
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).
|
|
/// 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)
|
|
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] {
|
|
private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
|
|
|
struct ZoomMeeting: Decodable {
|
|
struct ZoomMeeting: Decodable {
|
|
|
let id: Int?
|
|
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.")
|
|
showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
|
|
|
return
|
|
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() {
|
|
func menuBarTriggerJoinMeeting() {
|