ソースを参照

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 3 ヶ月 前
コミット
ef9a36bc96
1 ファイル変更53 行追加8 行削除
  1. 53 8
      zoom_app/ViewController.swift

+ 53 - 8
zoom_app/ViewController.swift

@@ -79,8 +79,10 @@ class ViewController: NSViewController {
     private var meetingsScrollObserver: NSObjectProtocol?
     private var lastMeetingsRefreshAt = Date.distantPast
     private var lastScrollEdgeRefreshAt = Date.distantPast
-    private let meetingsRefreshInterval: TimeInterval = 8
+    // Keep this conservative to avoid Zoom API rate limits.
+    private let meetingsRefreshInterval: TimeInterval = 60
     private let scrollRefreshCooldown: TimeInterval = 3
+    private var meetingsRateLimitedUntil: Date?
     
     private enum SidebarStyle {
         case login
@@ -321,6 +323,9 @@ class ViewController: NSViewController {
 
     private func triggerMeetingsRefresh(force: Bool = false) {
         let now = Date()
+        if let until = meetingsRateLimitedUntil, now < until {
+            return
+        }
         if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
             return
         }
@@ -604,6 +609,7 @@ class ViewController: NSViewController {
             let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
             let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
             await MainActor.run {
+                self.meetingsRateLimitedUntil = nil
                 self.applyMeetings(zoomMeetings)
             }
         } catch {
@@ -617,9 +623,14 @@ class ViewController: NSViewController {
                     self.promptForZoomOAuthCredentialsIfNeeded()
                 } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
                     self.zoomOAuth.clearSavedTokens()
-                    self.meetingsStatusLabel?.stringValue = "Zoom OAuth scope missing. Add required scopes in Marketplace, click Add app now, then sign in again. (\(scopeMessage))"
+                    self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
+                } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
+                    let seconds = max(retryAfterSeconds ?? 300, 30)
+                    self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
+                    let minutes = Int(ceil(Double(seconds) / 60.0))
+                    self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
                 } else {
-                    self.meetingsStatusLabel?.stringValue = "Zoom API error: \(error.localizedDescription)"
+                    self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                 }
             }
         }
@@ -800,6 +811,11 @@ class ViewController: NSViewController {
             let (data, response) = try await URLSession.shared.data(for: request)
             guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
                 let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
+                if (response as? HTTPURLResponse)?.statusCode == 429 {
+                    let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
+                    let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
+                    throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
+                }
                 if raw.localizedCaseInsensitiveContains("does not contain scopes") {
                     throw ZoomOAuthError.missingRequiredScope(raw)
                 }
@@ -1478,7 +1494,8 @@ class ViewController: NSViewController {
             let settingsRow = NSView()
             settingsRow.translatesAutoresizingMaskIntoConstraints = false
             settingsRow.wantsLayer = true
-            settingsRow.layer?.backgroundColor = NSColor.clear.cgColor
+            let settingsSelected = selected == "Settings"
+            settingsRow.layer?.backgroundColor = settingsSelected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
             settingsRow.layer?.cornerRadius = 12
             settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
             settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
@@ -1489,16 +1506,26 @@ class ViewController: NSViewController {
 
             let iconView = NSImageView()
             iconView.translatesAutoresizingMaskIntoConstraints = false
-            iconView.contentTintColor = primaryText
+            iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
             iconView.imageScaling = .scaleProportionallyUpOrDown
             iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
-            iconView.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
+            let settingsSymbolPreferred = settingsSelected ? "gearshape.fill" : "gearshape"
+            iconView.image = NSImage(systemSymbolName: settingsSymbolPreferred, accessibilityDescription: "Settings")
+                ?? NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
             iconContainer.addSubview(iconView)
 
-            let label = makeLabel("Settings", size: 10, color: secondaryText, weight: .regular, centered: true)
+            let label = makeLabel("Settings", size: 10, color: settingsSelected ? primaryText : secondaryText, weight: .regular, centered: true)
             label.translatesAutoresizingMaskIntoConstraints = false
             settingsRow.addSubview(label)
 
+            let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
+            hit.identifier = NSUserInterfaceItemIdentifier("Settings")
+            hit.isBordered = false
+            hit.bezelStyle = .shadowlessSquare
+            hit.focusRingType = .none
+            hit.translatesAutoresizingMaskIntoConstraints = false
+            settingsRow.addSubview(hit, positioned: .above, relativeTo: nil)
+
             NSLayoutConstraint.activate([
                 iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
                 iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
@@ -1512,9 +1539,18 @@ class ViewController: NSViewController {
 
                 label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
                 label.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
-                label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8)
+                label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8),
+
+                hit.leadingAnchor.constraint(equalTo: settingsRow.leadingAnchor),
+                hit.trailingAnchor.constraint(equalTo: settingsRow.trailingAnchor),
+                hit.topAnchor.constraint(equalTo: settingsRow.topAnchor),
+                hit.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor)
             ])
 
+            homeSidebarRowViews["Settings"] = settingsRow
+            homeSidebarIconViews["Settings"] = iconView
+            homeSidebarLabels["Settings"] = label
+
             stack.addArrangedSubview(settingsRow)
         }
 
@@ -1598,6 +1634,9 @@ class ViewController: NSViewController {
         case "Scheduler":
             // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
             return "calendar.badge.clock"
+        case "Settings":
+            // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
+            return filled ? "gearshape.fill" : "gearshape"
         case "Hub":
             return "square.grid.3x3"
         case "More":
@@ -1978,6 +2017,7 @@ enum ZoomOAuthError: Error {
     case missingAuthorizationCode
     case tokenExchangeFailed(String)
     case missingRequiredScope(String)
+    case rateLimited(retryAfterSeconds: Int?)
     case unableToOpenBrowser
     case authenticationTimedOut
 }
@@ -2556,6 +2596,11 @@ extension ZoomOAuthError: LocalizedError {
             return details
         case .missingRequiredScope(let details):
             return "The Zoom access token is missing required scopes. \(details)"
+        case .rateLimited(let retryAfterSeconds):
+            if let retryAfterSeconds {
+                return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
+            }
+            return "Zoom rate limit reached. Try again later."
         case .unableToOpenBrowser:
             return "Could not open the system browser for Zoom sign-in."
         case .authenticationTimedOut: