Преглед на файлове

Handle rate limit and settings navigation

Slow meetings polling and back off on Zoom 429s with a friendly status message; make Settings selectable with a stable gear icon.

Made-with: Cursor
huzaifahayat12 преди 5 дни
родител
ревизия
ef9a36bc96
променени са 1 файла, в които са добавени 53 реда и са изтрити 8 реда
  1. 53 8
      zoom_app/ViewController.swift

+ 53 - 8
zoom_app/ViewController.swift

@@ -79,8 +79,10 @@ class ViewController: NSViewController {
79
     private var meetingsScrollObserver: NSObjectProtocol?
79
     private var meetingsScrollObserver: NSObjectProtocol?
80
     private var lastMeetingsRefreshAt = Date.distantPast
80
     private var lastMeetingsRefreshAt = Date.distantPast
81
     private var lastScrollEdgeRefreshAt = Date.distantPast
81
     private var lastScrollEdgeRefreshAt = Date.distantPast
82
-    private let meetingsRefreshInterval: TimeInterval = 8
82
+    // Keep this conservative to avoid Zoom API rate limits.
83
+    private let meetingsRefreshInterval: TimeInterval = 60
83
     private let scrollRefreshCooldown: TimeInterval = 3
84
     private let scrollRefreshCooldown: TimeInterval = 3
85
+    private var meetingsRateLimitedUntil: Date?
84
     
86
     
85
     private enum SidebarStyle {
87
     private enum SidebarStyle {
86
         case login
88
         case login
@@ -321,6 +323,9 @@ class ViewController: NSViewController {
321
 
323
 
322
     private func triggerMeetingsRefresh(force: Bool = false) {
324
     private func triggerMeetingsRefresh(force: Bool = false) {
323
         let now = Date()
325
         let now = Date()
326
+        if let until = meetingsRateLimitedUntil, now < until {
327
+            return
328
+        }
324
         if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
329
         if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
325
             return
330
             return
326
         }
331
         }
@@ -604,6 +609,7 @@ class ViewController: NSViewController {
604
             let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
609
             let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
605
             let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
610
             let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
606
             await MainActor.run {
611
             await MainActor.run {
612
+                self.meetingsRateLimitedUntil = nil
607
                 self.applyMeetings(zoomMeetings)
613
                 self.applyMeetings(zoomMeetings)
608
             }
614
             }
609
         } catch {
615
         } catch {
@@ -617,9 +623,14 @@ class ViewController: NSViewController {
617
                     self.promptForZoomOAuthCredentialsIfNeeded()
623
                     self.promptForZoomOAuthCredentialsIfNeeded()
618
                 } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
624
                 } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
619
                     self.zoomOAuth.clearSavedTokens()
625
                     self.zoomOAuth.clearSavedTokens()
620
-                    self.meetingsStatusLabel?.stringValue = "Zoom OAuth scope missing. Add required scopes in Marketplace, click Add app now, then sign in again. (\(scopeMessage))"
626
+                    self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
627
+                } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
628
+                    let seconds = max(retryAfterSeconds ?? 300, 30)
629
+                    self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
630
+                    let minutes = Int(ceil(Double(seconds) / 60.0))
631
+                    self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
621
                 } else {
632
                 } else {
622
-                    self.meetingsStatusLabel?.stringValue = "Zoom API error: \(error.localizedDescription)"
633
+                    self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
623
                 }
634
                 }
624
             }
635
             }
625
         }
636
         }
@@ -800,6 +811,11 @@ class ViewController: NSViewController {
800
             let (data, response) = try await URLSession.shared.data(for: request)
811
             let (data, response) = try await URLSession.shared.data(for: request)
801
             guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
812
             guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
802
                 let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
813
                 let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
814
+                if (response as? HTTPURLResponse)?.statusCode == 429 {
815
+                    let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
816
+                    let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
817
+                    throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
818
+                }
803
                 if raw.localizedCaseInsensitiveContains("does not contain scopes") {
819
                 if raw.localizedCaseInsensitiveContains("does not contain scopes") {
804
                     throw ZoomOAuthError.missingRequiredScope(raw)
820
                     throw ZoomOAuthError.missingRequiredScope(raw)
805
                 }
821
                 }
@@ -1478,7 +1494,8 @@ class ViewController: NSViewController {
1478
             let settingsRow = NSView()
1494
             let settingsRow = NSView()
1479
             settingsRow.translatesAutoresizingMaskIntoConstraints = false
1495
             settingsRow.translatesAutoresizingMaskIntoConstraints = false
1480
             settingsRow.wantsLayer = true
1496
             settingsRow.wantsLayer = true
1481
-            settingsRow.layer?.backgroundColor = NSColor.clear.cgColor
1497
+            let settingsSelected = selected == "Settings"
1498
+            settingsRow.layer?.backgroundColor = settingsSelected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
1482
             settingsRow.layer?.cornerRadius = 12
1499
             settingsRow.layer?.cornerRadius = 12
1483
             settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
1500
             settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
1484
             settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
1501
             settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
@@ -1489,16 +1506,26 @@ class ViewController: NSViewController {
1489
 
1506
 
1490
             let iconView = NSImageView()
1507
             let iconView = NSImageView()
1491
             iconView.translatesAutoresizingMaskIntoConstraints = false
1508
             iconView.translatesAutoresizingMaskIntoConstraints = false
1492
-            iconView.contentTintColor = primaryText
1509
+            iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
1493
             iconView.imageScaling = .scaleProportionallyUpOrDown
1510
             iconView.imageScaling = .scaleProportionallyUpOrDown
1494
             iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
1511
             iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
1495
-            iconView.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
1512
+            let settingsSymbolPreferred = settingsSelected ? "gearshape.fill" : "gearshape"
1513
+            iconView.image = NSImage(systemSymbolName: settingsSymbolPreferred, accessibilityDescription: "Settings")
1514
+                ?? NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
1496
             iconContainer.addSubview(iconView)
1515
             iconContainer.addSubview(iconView)
1497
 
1516
 
1498
-            let label = makeLabel("Settings", size: 10, color: secondaryText, weight: .regular, centered: true)
1517
+            let label = makeLabel("Settings", size: 10, color: settingsSelected ? primaryText : secondaryText, weight: .regular, centered: true)
1499
             label.translatesAutoresizingMaskIntoConstraints = false
1518
             label.translatesAutoresizingMaskIntoConstraints = false
1500
             settingsRow.addSubview(label)
1519
             settingsRow.addSubview(label)
1501
 
1520
 
1521
+            let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
1522
+            hit.identifier = NSUserInterfaceItemIdentifier("Settings")
1523
+            hit.isBordered = false
1524
+            hit.bezelStyle = .shadowlessSquare
1525
+            hit.focusRingType = .none
1526
+            hit.translatesAutoresizingMaskIntoConstraints = false
1527
+            settingsRow.addSubview(hit, positioned: .above, relativeTo: nil)
1528
+
1502
             NSLayoutConstraint.activate([
1529
             NSLayoutConstraint.activate([
1503
                 iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
1530
                 iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
1504
                 iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
1531
                 iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
@@ -1512,9 +1539,18 @@ class ViewController: NSViewController {
1512
 
1539
 
1513
                 label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
1540
                 label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
1514
                 label.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
1541
                 label.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
1515
-                label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8)
1542
+                label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8),
1543
+
1544
+                hit.leadingAnchor.constraint(equalTo: settingsRow.leadingAnchor),
1545
+                hit.trailingAnchor.constraint(equalTo: settingsRow.trailingAnchor),
1546
+                hit.topAnchor.constraint(equalTo: settingsRow.topAnchor),
1547
+                hit.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor)
1516
             ])
1548
             ])
1517
 
1549
 
1550
+            homeSidebarRowViews["Settings"] = settingsRow
1551
+            homeSidebarIconViews["Settings"] = iconView
1552
+            homeSidebarLabels["Settings"] = label
1553
+
1518
             stack.addArrangedSubview(settingsRow)
1554
             stack.addArrangedSubview(settingsRow)
1519
         }
1555
         }
1520
 
1556
 
@@ -1598,6 +1634,9 @@ class ViewController: NSViewController {
1598
         case "Scheduler":
1634
         case "Scheduler":
1599
             // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
1635
             // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
1600
             return "calendar.badge.clock"
1636
             return "calendar.badge.clock"
1637
+        case "Settings":
1638
+            // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
1639
+            return filled ? "gearshape.fill" : "gearshape"
1601
         case "Hub":
1640
         case "Hub":
1602
             return "square.grid.3x3"
1641
             return "square.grid.3x3"
1603
         case "More":
1642
         case "More":
@@ -1978,6 +2017,7 @@ enum ZoomOAuthError: Error {
1978
     case missingAuthorizationCode
2017
     case missingAuthorizationCode
1979
     case tokenExchangeFailed(String)
2018
     case tokenExchangeFailed(String)
1980
     case missingRequiredScope(String)
2019
     case missingRequiredScope(String)
2020
+    case rateLimited(retryAfterSeconds: Int?)
1981
     case unableToOpenBrowser
2021
     case unableToOpenBrowser
1982
     case authenticationTimedOut
2022
     case authenticationTimedOut
1983
 }
2023
 }
@@ -2556,6 +2596,11 @@ extension ZoomOAuthError: LocalizedError {
2556
             return details
2596
             return details
2557
         case .missingRequiredScope(let details):
2597
         case .missingRequiredScope(let details):
2558
             return "The Zoom access token is missing required scopes. \(details)"
2598
             return "The Zoom access token is missing required scopes. \(details)"
2599
+        case .rateLimited(let retryAfterSeconds):
2600
+            if let retryAfterSeconds {
2601
+                return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
2602
+            }
2603
+            return "Zoom rate limit reached. Try again later."
2559
         case .unableToOpenBrowser:
2604
         case .unableToOpenBrowser:
2560
             return "Could not open the system browser for Zoom sign-in."
2605
             return "Could not open the system browser for Zoom sign-in."
2561
         case .authenticationTimedOut:
2606
         case .authenticationTimedOut: