|
|
@@ -12,6 +12,7 @@ import WebKit
|
|
12
|
12
|
|
|
13
|
13
|
class ViewController: NSViewController {
|
|
14
|
14
|
private let googleOAuth = GoogleOAuthService.shared
|
|
|
15
|
+ private let zoomOAuth = ZoomOAuthService.shared
|
|
15
|
16
|
private let sidebarWidth: CGFloat = 78
|
|
16
|
17
|
private let appBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
|
|
17
|
18
|
private let sidebarBackground = NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
|
|
|
@@ -28,15 +29,16 @@ class ViewController: NSViewController {
|
|
28
|
29
|
private var loginView: NSView?
|
|
29
|
30
|
private var homeView: NSView?
|
|
30
|
31
|
private weak var googleButton: NSButton?
|
|
|
32
|
+ private weak var nextSignInButton: NSButton?
|
|
|
33
|
+ private weak var zoomSocialButton: NSButton?
|
|
31
|
34
|
private weak var timeLabel: NSTextField?
|
|
32
|
35
|
private weak var dateLabel: NSTextField?
|
|
33
|
|
- private weak var meetingTitleLabel: NSTextField?
|
|
34
|
|
- private weak var meetingDetailLabel: NSTextField?
|
|
35
|
|
- private weak var meetingHostLabel: NSTextField?
|
|
36
|
36
|
private weak var emptyMeetingLabel: NSTextField?
|
|
37
|
|
- private weak var meetingCard: NSView?
|
|
|
37
|
+ private weak var meetingsListStack: NSStackView?
|
|
|
38
|
+ private weak var meetingsStatusLabel: NSTextField?
|
|
38
|
39
|
private var clockTimer: Timer?
|
|
39
|
40
|
private var isSigningIn = false
|
|
|
41
|
+ private var isPromptingZoomCredentials = false
|
|
40
|
42
|
|
|
41
|
43
|
override func viewDidLoad() {
|
|
42
|
44
|
super.viewDidLoad()
|
|
|
@@ -68,6 +70,10 @@ class ViewController: NSViewController {
|
|
68
|
70
|
clockTimer?.invalidate()
|
|
69
|
71
|
homeView?.removeFromSuperview()
|
|
70
|
72
|
homeView = nil
|
|
|
73
|
+ isSigningIn = false
|
|
|
74
|
+ nextSignInButton?.title = "Next"
|
|
|
75
|
+ nextSignInButton?.isEnabled = true
|
|
|
76
|
+ zoomSocialButton?.isEnabled = true
|
|
71
|
77
|
|
|
72
|
78
|
if loginView == nil {
|
|
73
|
79
|
loginView = makeLoginView()
|
|
|
@@ -125,6 +131,58 @@ class ViewController: NSViewController {
|
|
125
|
131
|
}
|
|
126
|
132
|
}
|
|
127
|
133
|
|
|
|
134
|
+ /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
|
|
|
135
|
+ @objc private func zoomPrimarySignInTapped() {
|
|
|
136
|
+ guard isSigningIn == false else { return }
|
|
|
137
|
+ isSigningIn = true
|
|
|
138
|
+ nextSignInButton?.title = "Signing in…"
|
|
|
139
|
+ nextSignInButton?.isEnabled = false
|
|
|
140
|
+ zoomSocialButton?.isEnabled = false
|
|
|
141
|
+ googleButton?.isEnabled = false
|
|
|
142
|
+
|
|
|
143
|
+ Task {
|
|
|
144
|
+ do {
|
|
|
145
|
+ let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
|
|
|
146
|
+ guard configured else {
|
|
|
147
|
+ await MainActor.run { self.resetLoginSigningInState() }
|
|
|
148
|
+ return
|
|
|
149
|
+ }
|
|
|
150
|
+
|
|
|
151
|
+ let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
152
|
+ let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
|
|
|
153
|
+ let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
|
|
|
154
|
+
|
|
|
155
|
+ await MainActor.run {
|
|
|
156
|
+ self.resetLoginSigningInState()
|
|
|
157
|
+ self.showHomeView(profile: profile)
|
|
|
158
|
+ }
|
|
|
159
|
+ } catch {
|
|
|
160
|
+ await MainActor.run {
|
|
|
161
|
+ self.resetLoginSigningInState()
|
|
|
162
|
+ self.showSimpleError("Zoom sign-in failed", error: error)
|
|
|
163
|
+ }
|
|
|
164
|
+ }
|
|
|
165
|
+ }
|
|
|
166
|
+ }
|
|
|
167
|
+
|
|
|
168
|
+ @MainActor
|
|
|
169
|
+ private func resetLoginSigningInState() {
|
|
|
170
|
+ isSigningIn = false
|
|
|
171
|
+ nextSignInButton?.title = "Next"
|
|
|
172
|
+ nextSignInButton?.isEnabled = true
|
|
|
173
|
+ zoomSocialButton?.isEnabled = true
|
|
|
174
|
+ googleButton?.isEnabled = true
|
|
|
175
|
+ }
|
|
|
176
|
+
|
|
|
177
|
+ /// Returns false if the user cancelled or left credentials empty.
|
|
|
178
|
+ @MainActor
|
|
|
179
|
+ private func ensureZoomOAuthClientConfigured() -> Bool {
|
|
|
180
|
+ if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
|
|
|
181
|
+ return true
|
|
|
182
|
+ }
|
|
|
183
|
+ return presentZoomOAuthCredentialPrompt()
|
|
|
184
|
+ }
|
|
|
185
|
+
|
|
128
|
186
|
private func showSimpleError(_ title: String, error: Error) {
|
|
129
|
187
|
let alert = NSAlert()
|
|
130
|
188
|
alert.alertStyle = .warning
|
|
|
@@ -138,91 +196,210 @@ class ViewController: NSViewController {
|
|
138
|
196
|
let start: Date
|
|
139
|
197
|
let end: Date?
|
|
140
|
198
|
let host: String
|
|
|
199
|
+ let source: String
|
|
141
|
200
|
}
|
|
142
|
201
|
|
|
143
|
202
|
@MainActor
|
|
144
|
|
- private func applyMeeting(_ meeting: ScheduledMeeting?) {
|
|
145
|
|
- if let meeting {
|
|
146
|
|
- let dateFormatter = DateFormatter()
|
|
147
|
|
- dateFormatter.dateFormat = "EEE, MMM d"
|
|
148
|
|
- let timeFormatter = DateFormatter()
|
|
149
|
|
- timeFormatter.dateFormat = "h:mm a"
|
|
150
|
|
- let startText = timeFormatter.string(from: meeting.start)
|
|
151
|
|
- let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
|
|
152
|
|
- let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
|
|
153
|
|
-
|
|
154
|
|
- meetingTitleLabel?.stringValue = meeting.title
|
|
155
|
|
- meetingDetailLabel?.stringValue = "\(dateFormatter.string(from: meeting.start))\n\(range)"
|
|
156
|
|
- meetingHostLabel?.stringValue = "Host: \(meeting.host)"
|
|
157
|
|
- meetingCard?.isHidden = false
|
|
158
|
|
- emptyMeetingLabel?.isHidden = true
|
|
159
|
|
- } else {
|
|
160
|
|
- meetingCard?.isHidden = true
|
|
|
203
|
+ private func applyMeetings(_ meetings: [ScheduledMeeting]) {
|
|
|
204
|
+ guard let stack = meetingsListStack else { return }
|
|
|
205
|
+ stack.arrangedSubviews.forEach { view in
|
|
|
206
|
+ stack.removeArrangedSubview(view)
|
|
|
207
|
+ view.removeFromSuperview()
|
|
|
208
|
+ }
|
|
|
209
|
+
|
|
|
210
|
+ let ordered = meetings.sorted(by: { $0.start < $1.start })
|
|
|
211
|
+ if ordered.isEmpty {
|
|
161
|
212
|
emptyMeetingLabel?.isHidden = false
|
|
|
213
|
+ meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
|
|
|
214
|
+ return
|
|
|
215
|
+ }
|
|
|
216
|
+
|
|
|
217
|
+ emptyMeetingLabel?.isHidden = true
|
|
|
218
|
+ meetingsStatusLabel?.stringValue = "Zoom meetings"
|
|
|
219
|
+ for meeting in ordered {
|
|
|
220
|
+ stack.addArrangedSubview(makeMeetingRowCard(meeting))
|
|
162
|
221
|
}
|
|
163
|
222
|
}
|
|
164
|
223
|
|
|
165
|
224
|
private func loadScheduledMeetings() async {
|
|
166
|
225
|
do {
|
|
167
|
|
- let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
|
|
168
|
|
- let meetings = try await fetchGoogleScheduledMeetings(accessToken: token)
|
|
|
226
|
+ let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
227
|
+ let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
|
|
169
|
228
|
await MainActor.run {
|
|
170
|
|
- self.applyMeeting(meetings.first)
|
|
|
229
|
+ self.applyMeetings(zoomMeetings)
|
|
171
|
230
|
}
|
|
172
|
231
|
} catch {
|
|
173
|
232
|
await MainActor.run {
|
|
174
|
|
- self.applyMeeting(nil)
|
|
|
233
|
+ self.applyMeetings([])
|
|
|
234
|
+ if case ZoomOAuthError.missingClientId = error {
|
|
|
235
|
+ self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
236
|
+ self.promptForZoomOAuthCredentialsIfNeeded()
|
|
|
237
|
+ } else if case ZoomOAuthError.missingClientSecret = error {
|
|
|
238
|
+ self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
239
|
+ self.promptForZoomOAuthCredentialsIfNeeded()
|
|
|
240
|
+ } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
|
|
|
241
|
+ self.zoomOAuth.clearSavedTokens()
|
|
|
242
|
+ self.meetingsStatusLabel?.stringValue = "Zoom OAuth scope missing. Add required scopes in Marketplace, click Add app now, then sign in again. (\(scopeMessage))"
|
|
|
243
|
+ } else {
|
|
|
244
|
+ self.meetingsStatusLabel?.stringValue = "Zoom API error: \(error.localizedDescription)"
|
|
|
245
|
+ }
|
|
175
|
246
|
}
|
|
176
|
247
|
}
|
|
177
|
248
|
}
|
|
178
|
249
|
|
|
179
|
|
- private func fetchGoogleScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
|
|
180
|
|
- var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
|
|
181
|
|
- let now = ISO8601DateFormatter().string(from: Date())
|
|
182
|
|
- components.queryItems = [
|
|
183
|
|
- URLQueryItem(name: "singleEvents", value: "true"),
|
|
184
|
|
- URLQueryItem(name: "orderBy", value: "startTime"),
|
|
185
|
|
- URLQueryItem(name: "timeMin", value: now),
|
|
186
|
|
- URLQueryItem(name: "maxResults", value: "10")
|
|
187
|
|
- ]
|
|
188
|
|
- var request = URLRequest(url: components.url!)
|
|
|
250
|
+ @MainActor
|
|
|
251
|
+ private func presentZoomOAuthCredentialPrompt() -> Bool {
|
|
|
252
|
+ let alert = NSAlert()
|
|
|
253
|
+ alert.alertStyle = .informational
|
|
|
254
|
+ alert.messageText = "Configure Zoom OAuth"
|
|
|
255
|
+ 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."
|
|
|
256
|
+
|
|
|
257
|
+ let wrapper = NSStackView()
|
|
|
258
|
+ wrapper.orientation = .vertical
|
|
|
259
|
+ wrapper.spacing = 8
|
|
|
260
|
+ wrapper.translatesAutoresizingMaskIntoConstraints = false
|
|
|
261
|
+
|
|
|
262
|
+ let clientIdField = NSTextField()
|
|
|
263
|
+ clientIdField.placeholderString = "Zoom Client ID"
|
|
|
264
|
+ clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
|
|
|
265
|
+ let clientSecretField = NSSecureTextField()
|
|
|
266
|
+ clientSecretField.placeholderString = "Zoom Client Secret"
|
|
|
267
|
+ clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
|
|
|
268
|
+ [clientIdField, clientSecretField].forEach { field in
|
|
|
269
|
+ field.translatesAutoresizingMaskIntoConstraints = false
|
|
|
270
|
+ field.widthAnchor.constraint(equalToConstant: 420).isActive = true
|
|
|
271
|
+ wrapper.addArrangedSubview(field)
|
|
|
272
|
+ }
|
|
|
273
|
+ alert.accessoryView = wrapper
|
|
|
274
|
+
|
|
|
275
|
+ alert.addButton(withTitle: "Save")
|
|
|
276
|
+ alert.addButton(withTitle: "Cancel")
|
|
|
277
|
+
|
|
|
278
|
+ let result = alert.runModal()
|
|
|
279
|
+ if result == .alertFirstButtonReturn {
|
|
|
280
|
+ var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
281
|
+ var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
282
|
+ if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
|
|
|
283
|
+ if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
|
|
|
284
|
+ if clientId.isEmpty == false, clientSecret.isEmpty == false {
|
|
|
285
|
+ zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
|
|
|
286
|
+ return true
|
|
|
287
|
+ }
|
|
|
288
|
+ meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
|
|
|
289
|
+ }
|
|
|
290
|
+ return false
|
|
|
291
|
+ }
|
|
|
292
|
+
|
|
|
293
|
+ @MainActor
|
|
|
294
|
+ private func promptForZoomOAuthCredentialsIfNeeded() {
|
|
|
295
|
+ guard isPromptingZoomCredentials == false else { return }
|
|
|
296
|
+ isPromptingZoomCredentials = true
|
|
|
297
|
+ defer { isPromptingZoomCredentials = false }
|
|
|
298
|
+
|
|
|
299
|
+ if presentZoomOAuthCredentialPrompt() {
|
|
|
300
|
+ meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
|
|
|
301
|
+ Task { await self.loadScheduledMeetings() }
|
|
|
302
|
+ }
|
|
|
303
|
+ }
|
|
|
304
|
+
|
|
|
305
|
+ private struct ZoomUserMeResponse: Decodable {
|
|
|
306
|
+ let first_name: String?
|
|
|
307
|
+ let last_name: String?
|
|
|
308
|
+ let display_name: String?
|
|
|
309
|
+ let email: String?
|
|
|
310
|
+ let pic_url: String?
|
|
|
311
|
+
|
|
|
312
|
+ var displayName: String? {
|
|
|
313
|
+ if let display_name, display_name.isEmpty == false { return display_name }
|
|
|
314
|
+ let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
|
|
|
315
|
+ return parts.isEmpty ? nil : parts.joined(separator: " ")
|
|
|
316
|
+ }
|
|
|
317
|
+
|
|
|
318
|
+ var pictureURL: String? {
|
|
|
319
|
+ guard let pic_url, pic_url.isEmpty == false else { return nil }
|
|
|
320
|
+ return pic_url
|
|
|
321
|
+ }
|
|
|
322
|
+ }
|
|
|
323
|
+
|
|
|
324
|
+ private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
|
|
|
325
|
+ let url = URL(string: "https://api.zoom.us/v2/users/me")!
|
|
|
326
|
+ var request = URLRequest(url: url)
|
|
189
|
327
|
request.httpMethod = "GET"
|
|
190
|
328
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
191
|
329
|
|
|
192
|
330
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
193
|
331
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
194
|
|
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load meetings")
|
|
|
332
|
+ throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
|
|
195
|
333
|
}
|
|
|
334
|
+ return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
|
|
|
335
|
+ }
|
|
196
|
336
|
|
|
197
|
|
- struct APIResponse: Decodable {
|
|
198
|
|
- struct Item: Decodable {
|
|
199
|
|
- struct TimeValue: Decodable { let dateTime: String?; let date: String? }
|
|
200
|
|
- let summary: String?
|
|
201
|
|
- let creator: Creator?
|
|
202
|
|
- struct Creator: Decodable { let displayName: String?; let email: String? }
|
|
203
|
|
- let start: TimeValue
|
|
204
|
|
- let end: TimeValue?
|
|
205
|
|
- }
|
|
206
|
|
- let items: [Item]
|
|
|
337
|
+ private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
|
|
|
338
|
+ struct ZoomMeeting: Decodable {
|
|
|
339
|
+ let topic: String?
|
|
|
340
|
+ let start_time: String?
|
|
|
341
|
+ let duration: Int?
|
|
|
342
|
+ let host_id: String?
|
|
207
|
343
|
}
|
|
208
|
344
|
|
|
209
|
|
- let decoded = try JSONDecoder().decode(APIResponse.self, from: data)
|
|
210
|
|
- let iso = ISO8601DateFormatter()
|
|
211
|
|
- let dateOnly = DateFormatter()
|
|
212
|
|
- dateOnly.dateFormat = "yyyy-MM-dd"
|
|
213
|
|
- dateOnly.timeZone = TimeZone.current
|
|
|
345
|
+ struct ZoomMeetingsPage: Decodable {
|
|
|
346
|
+ let meetings: [ZoomMeeting]
|
|
|
347
|
+ let next_page_token: String?
|
|
|
348
|
+ }
|
|
214
|
349
|
|
|
215
|
|
- return decoded.items.compactMap { item in
|
|
216
|
|
- let start = (item.start.dateTime.flatMap { iso.date(from: $0) }) ?? (item.start.date.flatMap { dateOnly.date(from: $0) })
|
|
217
|
|
- let end = item.end?.dateTime.flatMap { iso.date(from: $0) } ?? item.end?.date.flatMap { dateOnly.date(from: $0) }
|
|
218
|
|
- guard let start else { return nil }
|
|
219
|
|
- return ScheduledMeeting(
|
|
220
|
|
- title: item.summary?.isEmpty == false ? item.summary! : "Scheduled meeting",
|
|
221
|
|
- start: start,
|
|
222
|
|
- end: end,
|
|
223
|
|
- host: item.creator?.displayName ?? item.creator?.email ?? "Google Calendar"
|
|
224
|
|
- )
|
|
|
350
|
+ let iso = ISO8601DateFormatter()
|
|
|
351
|
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
352
|
+ let fallbackISO = ISO8601DateFormatter()
|
|
|
353
|
+ fallbackISO.formatOptions = [.withInternetDateTime]
|
|
|
354
|
+
|
|
|
355
|
+ func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
|
|
|
356
|
+ raw.compactMap { meeting in
|
|
|
357
|
+ guard let startRaw = meeting.start_time else { return nil }
|
|
|
358
|
+ let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
|
|
|
359
|
+ guard let start else { return nil }
|
|
|
360
|
+ let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
|
|
|
361
|
+ return ScheduledMeeting(
|
|
|
362
|
+ title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
|
|
|
363
|
+ start: start,
|
|
|
364
|
+ end: end,
|
|
|
365
|
+ host: meeting.host_id ?? "Zoom Host",
|
|
|
366
|
+ source: "Zoom"
|
|
|
367
|
+ )
|
|
|
368
|
+ }
|
|
225
|
369
|
}
|
|
|
370
|
+
|
|
|
371
|
+ var allMeetings: [ZoomMeeting] = []
|
|
|
372
|
+ var nextPageToken: String?
|
|
|
373
|
+ repeat {
|
|
|
374
|
+ var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
|
|
|
375
|
+ var items: [URLQueryItem] = [
|
|
|
376
|
+ URLQueryItem(name: "type", value: "scheduled"),
|
|
|
377
|
+ URLQueryItem(name: "page_size", value: "30")
|
|
|
378
|
+ ]
|
|
|
379
|
+ if let nextPageToken, nextPageToken.isEmpty == false {
|
|
|
380
|
+ items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
|
|
|
381
|
+ }
|
|
|
382
|
+ components.queryItems = items
|
|
|
383
|
+ var request = URLRequest(url: components.url!)
|
|
|
384
|
+ request.httpMethod = "GET"
|
|
|
385
|
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
|
386
|
+
|
|
|
387
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
388
|
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
|
389
|
+ let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
|
|
|
390
|
+ if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
391
|
+ throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
392
|
+ }
|
|
|
393
|
+ throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
394
|
+ }
|
|
|
395
|
+
|
|
|
396
|
+ let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
|
|
|
397
|
+ allMeetings.append(contentsOf: decoded.meetings)
|
|
|
398
|
+ let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
399
|
+ nextPageToken = (token?.isEmpty == false) ? token : nil
|
|
|
400
|
+ } while nextPageToken != nil
|
|
|
401
|
+
|
|
|
402
|
+ return mapMeetings(allMeetings)
|
|
226
|
403
|
}
|
|
227
|
404
|
|
|
228
|
405
|
// MARK: - Login UI
|
|
|
@@ -264,13 +441,13 @@ class ViewController: NSViewController {
|
|
264
|
441
|
emailField.layer?.backgroundColor = cardBackground.cgColor
|
|
265
|
442
|
emailField.focusRingType = .none
|
|
266
|
443
|
|
|
267
|
|
- let nextButton = NSButton(title: "Next", target: nil, action: nil)
|
|
|
444
|
+ let nextButton = NSButton(title: "Next", target: self, action: #selector(zoomPrimarySignInTapped))
|
|
268
|
445
|
nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
269
|
446
|
nextButton.isBordered = false
|
|
270
|
447
|
nextButton.wantsLayer = true
|
|
271
|
448
|
nextButton.layer?.cornerRadius = 10
|
|
272
|
449
|
nextButton.layer?.backgroundColor = cardBackground.cgColor
|
|
273
|
|
- nextButton.contentTintColor = mutedText
|
|
|
450
|
+ nextButton.contentTintColor = primaryText
|
|
274
|
451
|
|
|
275
|
452
|
let divider = NSBox()
|
|
276
|
453
|
divider.boxType = .separator
|
|
|
@@ -280,10 +457,12 @@ class ViewController: NSViewController {
|
|
280
|
457
|
let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
|
|
281
|
458
|
let apple = makeSocialButton(icon: "", text: "Apple")
|
|
282
|
459
|
let facebook = makeSocialButton(icon: "f", text: "Facebook")
|
|
283
|
|
- let microsoft = makeSocialButton(icon: "■", text: "Microsoft")
|
|
|
460
|
+ let zoomSocial = makeSocialButton(icon: "Z", text: "Zoom", action: #selector(zoomPrimarySignInTapped))
|
|
284
|
461
|
self.googleButton = google.button
|
|
|
462
|
+ self.nextSignInButton = nextButton
|
|
|
463
|
+ self.zoomSocialButton = zoomSocial.button
|
|
285
|
464
|
|
|
286
|
|
- let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, microsoft.container])
|
|
|
465
|
+ let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, zoomSocial.container])
|
|
287
|
466
|
social.orientation = .horizontal
|
|
288
|
467
|
social.spacing = 14
|
|
289
|
468
|
social.distribution = .fillEqually
|
|
|
@@ -382,16 +561,19 @@ class ViewController: NSViewController {
|
|
382
|
561
|
panel.layer?.cornerRadius = 14
|
|
383
|
562
|
|
|
384
|
563
|
let panelHeader = makeLabel("Today, Apr 14", size: 32, color: primaryText, weight: .semibold, centered: true)
|
|
|
564
|
+ let meetingsStatus = makeLabel("Zoom meetings", size: 12, color: secondaryText, weight: .regular, centered: false)
|
|
385
|
565
|
let noMeeting = makeLabel("No meetings scheduled.", size: 32, color: secondaryText, weight: .regular, centered: true)
|
|
386
|
|
- let scheduledMeetingCard = NSView()
|
|
387
|
|
- scheduledMeetingCard.wantsLayer = true
|
|
388
|
|
- scheduledMeetingCard.layer?.backgroundColor = NSColor(calibratedRed: 35 / 255, green: 40 / 255, blue: 56 / 255, alpha: 1).cgColor
|
|
389
|
|
- scheduledMeetingCard.layer?.cornerRadius = 16
|
|
390
|
|
-
|
|
391
|
|
- let meetingTitle = makeLabel("Scheduled meeting", size: 35, color: primaryText, weight: .semibold, centered: false)
|
|
392
|
|
- let meetingDetails = makeLabel("Today\n--:--", size: 22, color: secondaryText, weight: .regular, centered: false)
|
|
393
|
|
- meetingDetails.maximumNumberOfLines = 2
|
|
394
|
|
- let meetingHost = makeLabel("Host: Google Calendar", size: 20, color: secondaryText, weight: .regular, centered: false)
|
|
|
566
|
+ let meetingsScrollView = NSScrollView()
|
|
|
567
|
+ meetingsScrollView.drawsBackground = false
|
|
|
568
|
+ meetingsScrollView.hasVerticalScroller = true
|
|
|
569
|
+ meetingsScrollView.hasHorizontalScroller = false
|
|
|
570
|
+ meetingsScrollView.autohidesScrollers = true
|
|
|
571
|
+
|
|
|
572
|
+ let meetingsDocument = NSView()
|
|
|
573
|
+ let meetingsStack = NSStackView()
|
|
|
574
|
+ meetingsStack.orientation = .vertical
|
|
|
575
|
+ meetingsStack.spacing = 10
|
|
|
576
|
+ meetingsStack.alignment = .leading
|
|
395
|
577
|
let openRecordings = makeLabel("Open recordings ›", size: 30, color: secondaryText, weight: .regular, centered: false)
|
|
396
|
578
|
openRecordings.wantsLayer = true
|
|
397
|
579
|
openRecordings.layer?.backgroundColor = NSColor(calibratedRed: 31 / 255, green: 33 / 255, blue: 39 / 255, alpha: 1).cgColor
|
|
|
@@ -400,14 +582,14 @@ class ViewController: NSViewController {
|
|
400
|
582
|
contentColumn.translatesAutoresizingMaskIntoConstraints = false
|
|
401
|
583
|
content.addSubview(contentColumn)
|
|
402
|
584
|
|
|
403
|
|
- [topBar, search, name, timeTitle, dateTitle, actions, panel, panelHeader, noMeeting, scheduledMeetingCard, openRecordings].forEach {
|
|
|
585
|
+ [topBar, search, name, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
|
|
404
|
586
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
405
|
587
|
contentColumn.addSubview($0)
|
|
406
|
588
|
}
|
|
407
|
|
- [meetingTitle, meetingDetails, meetingHost].forEach {
|
|
408
|
|
- $0.translatesAutoresizingMaskIntoConstraints = false
|
|
409
|
|
- scheduledMeetingCard.addSubview($0)
|
|
410
|
|
- }
|
|
|
589
|
+ meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
|
|
|
590
|
+ meetingsStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
591
|
+ meetingsScrollView.documentView = meetingsDocument
|
|
|
592
|
+ meetingsDocument.addSubview(meetingsStack)
|
|
411
|
593
|
|
|
412
|
594
|
NSLayoutConstraint.activate([
|
|
413
|
595
|
contentColumn.topAnchor.constraint(equalTo: content.topAnchor),
|
|
|
@@ -443,23 +625,21 @@ class ViewController: NSViewController {
|
|
443
|
625
|
|
|
444
|
626
|
panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 15),
|
|
445
|
627
|
panelHeader.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
|
|
|
628
|
+ meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
|
|
|
629
|
+ meetingsStatus.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
|
|
446
|
630
|
noMeeting.centerXAnchor.constraint(equalTo: panel.centerXAnchor),
|
|
447
|
631
|
noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
|
|
448
|
632
|
|
|
449
|
|
- scheduledMeetingCard.topAnchor.constraint(equalTo: panel.topAnchor, constant: 58),
|
|
450
|
|
- scheduledMeetingCard.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
|
|
451
|
|
- scheduledMeetingCard.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
|
|
452
|
|
- scheduledMeetingCard.bottomAnchor.constraint(equalTo: openRecordings.topAnchor, constant: -14),
|
|
453
|
|
-
|
|
454
|
|
- meetingTitle.topAnchor.constraint(equalTo: scheduledMeetingCard.topAnchor, constant: 16),
|
|
455
|
|
- meetingTitle.leadingAnchor.constraint(equalTo: scheduledMeetingCard.leadingAnchor, constant: 16),
|
|
456
|
|
- meetingTitle.trailingAnchor.constraint(equalTo: scheduledMeetingCard.trailingAnchor, constant: -16),
|
|
|
633
|
+ meetingsScrollView.topAnchor.constraint(equalTo: panel.topAnchor, constant: 58),
|
|
|
634
|
+ meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
|
|
|
635
|
+ meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
|
|
|
636
|
+ meetingsScrollView.bottomAnchor.constraint(equalTo: openRecordings.topAnchor, constant: -14),
|
|
457
|
637
|
|
|
458
|
|
- meetingDetails.topAnchor.constraint(equalTo: meetingTitle.bottomAnchor, constant: 8),
|
|
459
|
|
- meetingDetails.leadingAnchor.constraint(equalTo: meetingTitle.leadingAnchor),
|
|
460
|
|
-
|
|
461
|
|
- meetingHost.topAnchor.constraint(equalTo: meetingDetails.bottomAnchor, constant: 8),
|
|
462
|
|
- meetingHost.leadingAnchor.constraint(equalTo: meetingTitle.leadingAnchor),
|
|
|
638
|
+ meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
|
|
|
639
|
+ meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
|
|
|
640
|
+ meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
|
|
|
641
|
+ meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
|
|
|
642
|
+ meetingsStack.bottomAnchor.constraint(equalTo: meetingsDocument.bottomAnchor),
|
|
463
|
643
|
|
|
464
|
644
|
openRecordings.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
|
|
465
|
645
|
openRecordings.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
|
|
|
@@ -469,12 +649,9 @@ class ViewController: NSViewController {
|
|
469
|
649
|
|
|
470
|
650
|
timeLabel = timeTitle
|
|
471
|
651
|
dateLabel = dateTitle
|
|
472
|
|
- meetingCard = scheduledMeetingCard
|
|
473
|
|
- meetingTitleLabel = meetingTitle
|
|
474
|
|
- meetingDetailLabel = meetingDetails
|
|
475
|
|
- meetingHostLabel = meetingHost
|
|
|
652
|
+ meetingsListStack = meetingsStack
|
|
|
653
|
+ meetingsStatusLabel = meetingsStatus
|
|
476
|
654
|
emptyMeetingLabel = noMeeting
|
|
477
|
|
- scheduledMeetingCard.isHidden = true
|
|
478
|
655
|
updateClock()
|
|
479
|
656
|
return root
|
|
480
|
657
|
}
|
|
|
@@ -577,6 +754,49 @@ class ViewController: NSViewController {
|
|
577
|
754
|
return root
|
|
578
|
755
|
}
|
|
579
|
756
|
|
|
|
757
|
+ private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
|
|
|
758
|
+ let card = NSView()
|
|
|
759
|
+ card.wantsLayer = true
|
|
|
760
|
+ card.layer?.backgroundColor = NSColor(calibratedRed: 35 / 255, green: 40 / 255, blue: 56 / 255, alpha: 1).cgColor
|
|
|
761
|
+ card.layer?.cornerRadius = 14
|
|
|
762
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
763
|
+ card.heightAnchor.constraint(equalToConstant: 110).isActive = true
|
|
|
764
|
+
|
|
|
765
|
+ let dateFormatter = DateFormatter()
|
|
|
766
|
+ dateFormatter.dateFormat = "EEE, MMM d"
|
|
|
767
|
+ let timeFormatter = DateFormatter()
|
|
|
768
|
+ timeFormatter.dateFormat = "h:mm a"
|
|
|
769
|
+ let startText = timeFormatter.string(from: meeting.start)
|
|
|
770
|
+ let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
|
|
|
771
|
+ let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
|
|
|
772
|
+
|
|
|
773
|
+ let title = makeLabel(meeting.title, size: 17, color: primaryText, weight: .semibold, centered: false)
|
|
|
774
|
+ let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 14, color: secondaryText, weight: .regular, centered: false)
|
|
|
775
|
+ detail.maximumNumberOfLines = 2
|
|
|
776
|
+ let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 13, color: secondaryText, weight: .regular, centered: false)
|
|
|
777
|
+
|
|
|
778
|
+ [title, detail, host].forEach {
|
|
|
779
|
+ $0.translatesAutoresizingMaskIntoConstraints = false
|
|
|
780
|
+ card.addSubview($0)
|
|
|
781
|
+ }
|
|
|
782
|
+
|
|
|
783
|
+ NSLayoutConstraint.activate([
|
|
|
784
|
+ title.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
|
|
|
785
|
+ title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
|
|
786
|
+ title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
|
|
787
|
+
|
|
|
788
|
+ detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
|
|
|
789
|
+ detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
790
|
+ detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
|
|
|
791
|
+
|
|
|
792
|
+ host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 6),
|
|
|
793
|
+ host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
794
|
+ host.trailingAnchor.constraint(equalTo: title.trailingAnchor)
|
|
|
795
|
+ ])
|
|
|
796
|
+
|
|
|
797
|
+ return card
|
|
|
798
|
+ }
|
|
|
799
|
+
|
|
580
|
800
|
private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
|
|
581
|
801
|
let label = NSTextField(labelWithString: text)
|
|
582
|
802
|
label.font = .systemFont(ofSize: size, weight: weight)
|
|
|
@@ -628,6 +848,244 @@ struct GoogleUserProfile: Codable, Equatable {
|
|
628
|
848
|
var picture: String?
|
|
629
|
849
|
}
|
|
630
|
850
|
|
|
|
851
|
+struct ZoomOAuthTokens: Codable, Equatable {
|
|
|
852
|
+ var accessToken: String
|
|
|
853
|
+ var refreshToken: String?
|
|
|
854
|
+ var expiresAt: Date
|
|
|
855
|
+ var scope: String?
|
|
|
856
|
+ var tokenType: String?
|
|
|
857
|
+}
|
|
|
858
|
+
|
|
|
859
|
+enum ZoomOAuthError: Error {
|
|
|
860
|
+ case missingClientId
|
|
|
861
|
+ case missingClientSecret
|
|
|
862
|
+ case invalidCallbackURL
|
|
|
863
|
+ case missingAuthorizationCode
|
|
|
864
|
+ case tokenExchangeFailed(String)
|
|
|
865
|
+ case missingRequiredScope(String)
|
|
|
866
|
+ case unableToOpenBrowser
|
|
|
867
|
+ case authenticationTimedOut
|
|
|
868
|
+}
|
|
|
869
|
+
|
|
|
870
|
+final class ZoomOAuthTokenStore {
|
|
|
871
|
+ private let defaultsKey: String
|
|
|
872
|
+ private let defaults: UserDefaults
|
|
|
873
|
+
|
|
|
874
|
+ init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
|
|
|
875
|
+ account: String = "zoomOAuthTokens",
|
|
|
876
|
+ defaults: UserDefaults = .standard) {
|
|
|
877
|
+ self.defaultsKey = "\(service).\(account)"
|
|
|
878
|
+ self.defaults = defaults
|
|
|
879
|
+ }
|
|
|
880
|
+
|
|
|
881
|
+ func readTokens() throws -> ZoomOAuthTokens? {
|
|
|
882
|
+ guard let data = defaults.data(forKey: defaultsKey) else { return nil }
|
|
|
883
|
+ return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
|
|
|
884
|
+ }
|
|
|
885
|
+
|
|
|
886
|
+ func writeTokens(_ tokens: ZoomOAuthTokens) throws {
|
|
|
887
|
+ let data = try JSONEncoder().encode(tokens)
|
|
|
888
|
+ defaults.set(data, forKey: defaultsKey)
|
|
|
889
|
+ }
|
|
|
890
|
+
|
|
|
891
|
+ func clearTokens() {
|
|
|
892
|
+ defaults.removeObject(forKey: defaultsKey)
|
|
|
893
|
+ }
|
|
|
894
|
+}
|
|
|
895
|
+
|
|
|
896
|
+final class ZoomOAuthService: NSObject {
|
|
|
897
|
+ static let shared = ZoomOAuthService()
|
|
|
898
|
+
|
|
|
899
|
+ private let tokenStore = ZoomOAuthTokenStore()
|
|
|
900
|
+ private let clientIdDefaultsKey = "zoom.oauth.clientId"
|
|
|
901
|
+ private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
|
|
|
902
|
+ private let infoPlistClientIdKey = "ZoomOAuthClientId"
|
|
|
903
|
+ private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
|
|
|
904
|
+ // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
|
|
|
905
|
+ /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
|
|
|
906
|
+ private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
|
|
|
907
|
+ /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
|
|
|
908
|
+ private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
|
|
|
909
|
+
|
|
|
910
|
+ func setClientCredentials(clientId: String, clientSecret: String) {
|
|
|
911
|
+ UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
|
|
|
912
|
+ UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
|
|
|
913
|
+ }
|
|
|
914
|
+
|
|
|
915
|
+ func configuredClientId() -> String? {
|
|
|
916
|
+ if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
|
|
|
917
|
+ let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
918
|
+ if trimmed.isEmpty == false { return trimmed }
|
|
|
919
|
+ }
|
|
|
920
|
+ let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
|
|
|
921
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
922
|
+ if let value, value.isEmpty == false { return value }
|
|
|
923
|
+ return bundledClientId.isEmpty ? nil : bundledClientId
|
|
|
924
|
+ }
|
|
|
925
|
+
|
|
|
926
|
+ func configuredClientSecret() -> String? {
|
|
|
927
|
+ if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
|
|
|
928
|
+ let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
929
|
+ if trimmed.isEmpty == false { return trimmed }
|
|
|
930
|
+ }
|
|
|
931
|
+ let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
|
|
|
932
|
+ .trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
933
|
+ if let value, value.isEmpty == false { return value }
|
|
|
934
|
+ return bundledClientSecret.isEmpty ? nil : bundledClientSecret
|
|
|
935
|
+ }
|
|
|
936
|
+
|
|
|
937
|
+ func clearSavedTokens() {
|
|
|
938
|
+ tokenStore.clearTokens()
|
|
|
939
|
+ }
|
|
|
940
|
+
|
|
|
941
|
+ func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
|
|
|
942
|
+ if let tokens = try tokenStore.readTokens(),
|
|
|
943
|
+ tokens.expiresAt.timeIntervalSinceNow > 60,
|
|
|
944
|
+ tokenHasRequiredScope(tokens.scope) {
|
|
|
945
|
+ return tokens.accessToken
|
|
|
946
|
+ } else if var tokens = try tokenStore.readTokens(),
|
|
|
947
|
+ let refreshed = try await refreshTokens(tokens) {
|
|
|
948
|
+ tokens = refreshed
|
|
|
949
|
+ try tokenStore.writeTokens(tokens)
|
|
|
950
|
+ return tokens.accessToken
|
|
|
951
|
+ }
|
|
|
952
|
+
|
|
|
953
|
+ let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
|
|
|
954
|
+ try tokenStore.writeTokens(tokens)
|
|
|
955
|
+ return tokens.accessToken
|
|
|
956
|
+ }
|
|
|
957
|
+
|
|
|
958
|
+ private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
|
|
|
959
|
+ _ = presentingWindow
|
|
|
960
|
+ guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
|
|
|
961
|
+ guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
|
|
|
962
|
+
|
|
|
963
|
+ let loopback = try await OAuthLoopbackServer.start()
|
|
|
964
|
+ defer { loopback.stop() }
|
|
|
965
|
+ let redirectURI = loopback.redirectURI
|
|
|
966
|
+ let state = UUID().uuidString
|
|
|
967
|
+
|
|
|
968
|
+ var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
|
|
|
969
|
+ // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
|
|
|
970
|
+ components.queryItems = [
|
|
|
971
|
+ URLQueryItem(name: "response_type", value: "code"),
|
|
|
972
|
+ URLQueryItem(name: "client_id", value: clientId),
|
|
|
973
|
+ URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
|
974
|
+ URLQueryItem(name: "state", value: state)
|
|
|
975
|
+ ]
|
|
|
976
|
+ guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
977
|
+ let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
|
|
|
978
|
+ guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
|
|
|
979
|
+
|
|
|
980
|
+ let callbackURL = try await loopback.waitForCallback()
|
|
|
981
|
+ let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
|
|
|
982
|
+ guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
|
|
|
983
|
+ guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
|
|
|
984
|
+ throw ZoomOAuthError.missingAuthorizationCode
|
|
|
985
|
+ }
|
|
|
986
|
+
|
|
|
987
|
+ return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
|
|
|
988
|
+ }
|
|
|
989
|
+
|
|
|
990
|
+ private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
|
|
|
991
|
+ var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
|
|
|
992
|
+ request.httpMethod = "POST"
|
|
|
993
|
+ request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
|
994
|
+ request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
|
|
|
995
|
+ request.httpBody = Self.formURLEncoded([
|
|
|
996
|
+ "grant_type": "authorization_code",
|
|
|
997
|
+ "code": code,
|
|
|
998
|
+ "redirect_uri": redirectURI
|
|
|
999
|
+ ])
|
|
|
1000
|
+
|
|
|
1001
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
1002
|
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
|
1003
|
+ throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
|
|
|
1004
|
+ }
|
|
|
1005
|
+
|
|
|
1006
|
+ struct TokenResponse: Decodable {
|
|
|
1007
|
+ let access_token: String
|
|
|
1008
|
+ let refresh_token: String?
|
|
|
1009
|
+ let expires_in: Double
|
|
|
1010
|
+ let scope: String?
|
|
|
1011
|
+ let token_type: String?
|
|
|
1012
|
+ }
|
|
|
1013
|
+
|
|
|
1014
|
+ let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
|
1015
|
+ return ZoomOAuthTokens(
|
|
|
1016
|
+ accessToken: decoded.access_token,
|
|
|
1017
|
+ refreshToken: decoded.refresh_token,
|
|
|
1018
|
+ expiresAt: Date().addingTimeInterval(decoded.expires_in),
|
|
|
1019
|
+ scope: decoded.scope,
|
|
|
1020
|
+ tokenType: decoded.token_type
|
|
|
1021
|
+ )
|
|
|
1022
|
+ }
|
|
|
1023
|
+
|
|
|
1024
|
+ private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
|
|
|
1025
|
+ guard let refreshToken = tokens.refreshToken else { return nil }
|
|
|
1026
|
+ guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
|
|
|
1027
|
+ guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
|
|
|
1028
|
+
|
|
|
1029
|
+ var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
|
|
|
1030
|
+ request.httpMethod = "POST"
|
|
|
1031
|
+ request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
|
1032
|
+ request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
|
|
|
1033
|
+ request.httpBody = Self.formURLEncoded([
|
|
|
1034
|
+ "grant_type": "refresh_token",
|
|
|
1035
|
+ "refresh_token": refreshToken
|
|
|
1036
|
+ ])
|
|
|
1037
|
+
|
|
|
1038
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
1039
|
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
|
1040
|
+ return nil
|
|
|
1041
|
+ }
|
|
|
1042
|
+
|
|
|
1043
|
+ struct RefreshResponse: Decodable {
|
|
|
1044
|
+ let access_token: String
|
|
|
1045
|
+ let refresh_token: String?
|
|
|
1046
|
+ let expires_in: Double
|
|
|
1047
|
+ let scope: String?
|
|
|
1048
|
+ let token_type: String?
|
|
|
1049
|
+ }
|
|
|
1050
|
+
|
|
|
1051
|
+ let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
|
|
|
1052
|
+ return ZoomOAuthTokens(
|
|
|
1053
|
+ accessToken: decoded.access_token,
|
|
|
1054
|
+ refreshToken: decoded.refresh_token ?? refreshToken,
|
|
|
1055
|
+ expiresAt: Date().addingTimeInterval(decoded.expires_in),
|
|
|
1056
|
+ scope: decoded.scope ?? tokens.scope,
|
|
|
1057
|
+ tokenType: decoded.token_type ?? tokens.tokenType
|
|
|
1058
|
+ )
|
|
|
1059
|
+ }
|
|
|
1060
|
+
|
|
|
1061
|
+ private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
|
|
|
1062
|
+ guard let scopeValue, scopeValue.isEmpty == false else { return false }
|
|
|
1063
|
+ let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
|
|
|
1064
|
+ return parts.contains { part in
|
|
|
1065
|
+ part == "meeting:read"
|
|
|
1066
|
+ || part == "meeting:read:admin"
|
|
|
1067
|
+ || part.contains("meeting:read")
|
|
|
1068
|
+ || part.contains("list_meetings")
|
|
|
1069
|
+ || part.contains("list_user_meetings")
|
|
|
1070
|
+ }
|
|
|
1071
|
+ }
|
|
|
1072
|
+
|
|
|
1073
|
+ private static func basicAuth(clientId: String, clientSecret: String) -> String {
|
|
|
1074
|
+ let joined = "\(clientId):\(clientSecret)"
|
|
|
1075
|
+ return Data(joined.utf8).base64EncodedString()
|
|
|
1076
|
+ }
|
|
|
1077
|
+
|
|
|
1078
|
+ private static func formURLEncoded(_ params: [String: String]) -> Data {
|
|
|
1079
|
+ let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
|
|
|
1080
|
+ let pairs = params.map { key, value in
|
|
|
1081
|
+ let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
|
|
|
1082
|
+ let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
|
|
1083
|
+ return "\(k)=\(v)"
|
|
|
1084
|
+ }.joined(separator: "&")
|
|
|
1085
|
+ return Data(pairs.utf8)
|
|
|
1086
|
+ }
|
|
|
1087
|
+}
|
|
|
1088
|
+
|
|
631
|
1089
|
enum GoogleOAuthError: Error {
|
|
632
|
1090
|
case missingClientId
|
|
633
|
1091
|
case missingClientSecret
|
|
|
@@ -797,6 +1255,9 @@ final class KeychainTokenStore {
|
|
797
|
1255
|
}
|
|
798
|
1256
|
|
|
799
|
1257
|
private final class OAuthLoopbackServer {
|
|
|
1258
|
+ /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
|
|
|
1259
|
+ private static let loopbackOAuthPort: UInt16 = 8742
|
|
|
1260
|
+
|
|
800
|
1261
|
private let queue = DispatchQueue(label: "google.oauth.loopback.server")
|
|
801
|
1262
|
private let listener: NWListener
|
|
802
|
1263
|
private var readyContinuation: CheckedContinuation<Void, Error>?
|
|
|
@@ -808,7 +1269,10 @@ private final class OAuthLoopbackServer {
|
|
808
|
1269
|
}
|
|
809
|
1270
|
|
|
810
|
1271
|
static func start() async throws -> OAuthLoopbackServer {
|
|
811
|
|
- let listener = try NWListener(using: .tcp, on: .any)
|
|
|
1272
|
+ guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
|
|
|
1273
|
+ throw GoogleOAuthError.invalidCallbackURL
|
|
|
1274
|
+ }
|
|
|
1275
|
+ let listener = try NWListener(using: .tcp, on: port)
|
|
812
|
1276
|
let server = OAuthLoopbackServer(listener: listener)
|
|
813
|
1277
|
try await server.startListening()
|
|
814
|
1278
|
return server
|
|
|
@@ -954,3 +1418,26 @@ extension GoogleOAuthError: LocalizedError {
|
|
954
|
1418
|
}
|
|
955
|
1419
|
}
|
|
956
|
1420
|
|
|
|
1421
|
+extension ZoomOAuthError: LocalizedError {
|
|
|
1422
|
+ var errorDescription: String? {
|
|
|
1423
|
+ switch self {
|
|
|
1424
|
+ case .missingClientId:
|
|
|
1425
|
+ return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
|
|
|
1426
|
+ case .missingClientSecret:
|
|
|
1427
|
+ return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
|
|
|
1428
|
+ case .invalidCallbackURL:
|
|
|
1429
|
+ return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect (must match OAuthLoopbackServer.loopbackOAuthPort in this target)."
|
|
|
1430
|
+ case .missingAuthorizationCode:
|
|
|
1431
|
+ return "Zoom did not return an authorization code."
|
|
|
1432
|
+ case .tokenExchangeFailed(let details):
|
|
|
1433
|
+ return details
|
|
|
1434
|
+ case .missingRequiredScope(let details):
|
|
|
1435
|
+ return "The Zoom access token is missing required scopes. \(details)"
|
|
|
1436
|
+ case .unableToOpenBrowser:
|
|
|
1437
|
+ return "Could not open the system browser for Zoom sign-in."
|
|
|
1438
|
+ case .authenticationTimedOut:
|
|
|
1439
|
+ return "Zoom sign-in timed out waiting for the browser redirect."
|
|
|
1440
|
+ }
|
|
|
1441
|
+ }
|
|
|
1442
|
+}
|
|
|
1443
|
+
|