Przeglądaj źródła

Add Zoom OAuth login flow and scheduled meetings integration.

Wire Zoom as the primary sign-in path with browser OAuth token handling, refresh support, and meetings list rendering so users can authenticate and load scheduled meetings end-to-end.

Made-with: Cursor
huzaifahayat12 1 miesiąc temu
rodzic
commit
02428aa6dc
2 zmienionych plików z 587 dodań i 98 usunięć
  1. 2 0
      zoom_app.xcodeproj/project.pbxproj
  2. 585 98
      zoom_app/ViewController.swift

+ 2 - 0
zoom_app.xcodeproj/project.pbxproj

@@ -258,6 +258,7 @@
258 258
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
259 259
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
260 260
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
261
+				INFOPLIST_KEY_ZoomOAuthClientId = "isvIAKPhSPOhBxFUkiY2A";
261 262
 				LD_RUNPATH_SEARCH_PATHS = (
262 263
 					"$(inherited)",
263 264
 					"@executable_path/../Frameworks",
@@ -290,6 +291,7 @@
290 291
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
291 292
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
292 293
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
294
+				INFOPLIST_KEY_ZoomOAuthClientId = "isvIAKPhSPOhBxFUkiY2A";
293 295
 				LD_RUNPATH_SEARCH_PATHS = (
294 296
 					"$(inherited)",
295 297
 					"@executable_path/../Frameworks",

+ 585 - 98
zoom_app/ViewController.swift

@@ -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
+