소스 검색

Fix meetings list hitting Zoom rate limits after initial load.

Coalesce refresh requests, lengthen polling cooldowns, preserve cached meetings on transient errors, and skip API calls while rate limited.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 달 전
부모
커밋
5aa9ec947f
1개의 변경된 파일113개의 추가작업 그리고 34개의 파일을 삭제
  1. 113 34
      zoom_app/ViewController.swift

+ 113 - 34
zoom_app/ViewController.swift

@@ -521,16 +521,20 @@ class ViewController: NSViewController {
     private var isSigningIn = false
     private var isPromptingZoomCredentials = false
     private var isLoadingMeetings = false
+    private var meetingsLoadTask: Task<Void, Never>?
+    private var hasBuiltInitialHomeView = false
     private var meetingsScrollObserver: NSObjectProtocol?
     private var meetingsPageScrollObserver: NSObjectProtocol?
     private var lastMeetingsRefreshAt = Date.distantPast
     private var lastScrollEdgeRefreshAt = Date.distantPast
     private var lastMeetingsPageAutoLoadAt = Date.distantPast
     // Keep this conservative to avoid Zoom API rate limits.
-    private let meetingsRefreshInterval: TimeInterval = 60
-    private let scrollRefreshCooldown: TimeInterval = 3
+    private let meetingsRefreshInterval: TimeInterval = 120
+    private let scrollRefreshCooldown: TimeInterval = 30
     private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
+    private let maxMeetingListPages = 5
     private var meetingsRateLimitedUntil: Date?
+    private static let zoomAccountTimeZoneDefaultsKey = "zoomAccountTimeZoneIdentifier"
     private var zoomAccountTimeZone: TimeZone?
     private var meetingTimeZoneOverrides: [String: String] = [:]
     private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
@@ -785,6 +789,10 @@ class ViewController: NSViewController {
         }
         setupUI()
         setupWidgetBridge()
+        if let tzId = UserDefaults.standard.string(forKey: Self.zoomAccountTimeZoneDefaultsKey),
+           let tz = TimeZone(identifier: tzId) {
+            zoomAccountTimeZone = tz
+        }
         startStoreKit()
     }
 
@@ -803,7 +811,12 @@ class ViewController: NSViewController {
             installWindowFullScreenObservers(for: window)
         }
         alignNativeTrafficLights()
-        showHomeView(profile: nil)
+        if hasBuiltInitialHomeView == false {
+            hasBuiltInitialHomeView = true
+            showHomeView(profile: nil, refreshMeetings: true)
+        } else if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
+            triggerMeetingsRefresh(force: false)
+        }
         syncBrandPositionForCurrentWindowState(animated: false)
         scheduleLaunchPaywallIfNeeded()
         if storeKitCoordinator.hasPremiumAccess {
@@ -880,7 +893,7 @@ class ViewController: NSViewController {
         }
     }
 
-    private func showHomeView(profile: GoogleUserProfile?) {
+    private func showHomeView(profile: GoogleUserProfile?, refreshMeetings: Bool = true) {
         clearMeetingsScrollObserver()
         clearMeetingsPageScrollObserver()
         removeSearchFieldObserver()
@@ -928,7 +941,7 @@ class ViewController: NSViewController {
         if let homeView { attachToRoot(homeView) }
         installSearchShortcutMonitor()
         startClock()
-        applyAuthenticationUIState(triggerInitialRefresh: true)
+        applyAuthenticationUIState(triggerInitialRefresh: refreshMeetings)
         updateHomeSidebarHighlight()
         updateSelectedHomeSectionUI()
     }
@@ -1003,6 +1016,9 @@ class ViewController: NSViewController {
                 await MainActor.run {
                     self.resetLoginSigningInState()
                     self.zoomAccountTimeZone = profileTimeZone
+                    if let profileTimeZone {
+                        UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
+                    }
                     self.completeLoginAndRestart(profile: profile)
                 }
             } catch {
@@ -2454,6 +2470,10 @@ class ViewController: NSViewController {
     @objc private func logoutTapped() {
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
+        meetingsLoadTask?.cancel()
+        meetingsLoadTask = nil
+        isLoadingMeetings = false
+        meetingsRateLimitedUntil = nil
         clearMeetingsScrollObserver()
         googleOAuth.clearSavedTokens()
         zoomOAuth.clearSavedTokens()
@@ -2631,23 +2651,29 @@ class ViewController: NSViewController {
         meetingsRefreshTimer?.invalidate()
         // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
         meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
-            guard let self else { return }
-            self.triggerMeetingsRefresh()
+            Task { @MainActor [weak self] in
+                self?.triggerMeetingsRefresh()
+            }
         }
     }
 
+    @MainActor
     private func triggerMeetingsRefresh(force: Bool = false) {
         guard isUserLoggedIn() else { return }
         guard storeKitCoordinator.hasPremiumAccess else { return }
         let now = Date()
         if let until = meetingsRateLimitedUntil, now < until {
+            applyMeetingsRateLimitStatusLabels()
             return
         }
         if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
             return
         }
+        if isLoadingMeetings { return }
         lastMeetingsRefreshAt = now
-        Task { await self.loadScheduledMeetings() }
+        isLoadingMeetings = true
+        meetingsLoadTask?.cancel()
+        meetingsLoadTask = Task { await self.loadScheduledMeetings() }
     }
 
     private func clearMeetingsScrollObserver() {
@@ -3057,12 +3083,14 @@ class ViewController: NSViewController {
 
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
-        meetingsRateLimitedUntil = nil
+        if loggedIn == false {
+            meetingsRateLimitedUntil = nil
+        }
         if loggedIn {
             if storeKitCoordinator.hasPremiumAccess {
                 startMeetingsAutoRefresh()
                 if triggerInitialRefresh {
-                    triggerMeetingsRefresh(force: true)
+                    triggerMeetingsRefresh(force: false)
                 }
             } else {
                 applyMeetings([])
@@ -3073,6 +3101,25 @@ class ViewController: NSViewController {
         }
     }
 
+    @MainActor
+    private func applyMeetingsRateLimitStatusLabels() {
+        guard let until = meetingsRateLimitedUntil, until > Date() else { return }
+        let minutes = max(1, Int(ceil(until.timeIntervalSinceNow / 60.0)))
+        let message = "Zoom rate limit reached. Please try again in \(minutes) min."
+        meetingsStatusLabel?.stringValue = message
+        meetingsPageStatusLabel?.stringValue = message
+    }
+
+    @MainActor
+    private func persistZoomAccountTimeZone(_ timeZone: TimeZone?) {
+        zoomAccountTimeZone = timeZone
+        if let timeZone {
+            UserDefaults.standard.set(timeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
+        } else {
+            UserDefaults.standard.removeObject(forKey: Self.zoomAccountTimeZoneDefaultsKey)
+        }
+    }
+
     /// Returns false if the user cancelled or left credentials empty.
     @MainActor
     private func ensureZoomOAuthClientConfigured() -> Bool {
@@ -3164,6 +3211,10 @@ class ViewController: NSViewController {
             return
         }
 
+        if let until = meetingsRateLimitedUntil, until > Date() {
+            applyMeetingsRateLimitStatusLabels()
+        }
+
         let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         let calendar = Calendar.current
         let dayStart = selectedMeetingsDayStart
@@ -3185,12 +3236,15 @@ class ViewController: NSViewController {
         let ordered = filtered.sorted(by: { $0.start < $1.start })
         if ordered.isEmpty {
             emptyMeetingLabel?.isHidden = showingHomeSection == false
-            if source.isEmpty {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
-                emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
-            } else {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
-                emptyMeetingLabel?.stringValue = "No meetings match your search."
+            let isRateLimited = meetingsRateLimitedUntil.map { $0 > Date() } ?? false
+            if isRateLimited == false {
+                if source.isEmpty {
+                    meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                    emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
+                } else {
+                    meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                    emptyMeetingLabel?.stringValue = "No meetings match your search."
+                }
             }
             applySchedulerMeetingsForSelectedDate()
             renderSchedulerCalendarGrid()
@@ -3665,48 +3719,64 @@ class ViewController: NSViewController {
     private func loadScheduledMeetings() async {
         guard isUserLoggedIn() else {
             await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
                 self.applyMeetings([])
                 self.applyGuestLoginMessages()
             }
             return
         }
-        guard storeKitCoordinator.hasPremiumAccess else {
+        let hasPremium = await MainActor.run { self.storeKitCoordinator.hasPremiumAccess }
+        guard hasPremium else {
             await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
                 self.applyMeetings([])
                 self.applyPremiumLockMessages()
             }
             return
         }
-        if isLoadingMeetings { return }
-        isLoadingMeetings = true
+        let shouldSkipForRateLimit = await MainActor.run { () -> Bool in
+            if let until = self.meetingsRateLimitedUntil, until > Date() {
+                self.applyMeetingsRateLimitStatusLabels()
+                return true
+            }
+            self.setMeetingsLoadingUI(true)
+            return false
+        }
+        if shouldSkipForRateLimit {
+            await MainActor.run {
+                self.isLoadingMeetings = false
+                self.setMeetingsLoadingUI(false)
+            }
+            return
+        }
         defer {
-            isLoadingMeetings = false
             Task { @MainActor in
+                self.isLoadingMeetings = false
                 self.setMeetingsLoadingUI(false)
             }
         }
-        await MainActor.run {
-            self.setMeetingsLoadingUI(true)
-        }
         do {
             let zoomToken = try await zoomOAuth.validAccessToken(
                 presentingWindow: view.window,
                 requiringAnyOf: ["meeting:read:list_meetings"]
             )
-            if zoomAccountTimeZone == nil {
+            let needsProfileTimeZone = await MainActor.run { self.zoomAccountTimeZone == nil }
+            if needsProfileTimeZone {
                 if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
                     let profileTZ = parseZoomTimeZone(profile.timezone)
-                    await MainActor.run { self.zoomAccountTimeZone = profileTZ }
+                    await MainActor.run { self.persistZoomAccountTimeZone(profileTZ) }
                 }
             }
-            let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: zoomAccountTimeZone)
+            let fallbackTZ = await MainActor.run { self.zoomAccountTimeZone }
+            let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: fallbackTZ)
             await MainActor.run {
                 self.meetingsRateLimitedUntil = nil
                 self.applyMeetings(zoomMeetings)
             }
         } catch {
             await MainActor.run {
-                self.applyMeetings([])
                 if case ZoomOAuthError.missingClientId = error {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
@@ -3722,13 +3792,20 @@ class ViewController: NSViewController {
                 } 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."
-                    self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
+                    self.applyMeetingsRateLimitStatusLabels()
                 } else {
                     self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                     self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                 }
+                if case ZoomOAuthError.missingClientId = error {
+                    self.applyMeetings([])
+                } else if case ZoomOAuthError.missingClientSecret = error {
+                    self.applyMeetings([])
+                } else if case ZoomOAuthError.missingRequiredScope = error {
+                    self.applyMeetings([])
+                }
+                self.applyFilteredMeetings()
+                self.applyMeetingsPageFilters(resetVisible: false)
             }
         }
     }
@@ -3784,7 +3861,7 @@ class ViewController: NSViewController {
 
         if presentZoomOAuthCredentialPrompt() {
             meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
-            Task { await self.loadScheduledMeetings() }
+            triggerMeetingsRefresh(force: true)
         }
     }
 
@@ -3976,7 +4053,9 @@ class ViewController: NSViewController {
 
         var allMeetings: [ZoomMeeting] = []
         var nextPageToken: String?
+        var pageCount = 0
         repeat {
+            pageCount += 1
             var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
             var items: [URLQueryItem] = [
                 URLQueryItem(name: "type", value: "scheduled"),
@@ -4008,7 +4087,7 @@ class ViewController: NSViewController {
             allMeetings.append(contentsOf: decoded.meetings)
             let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
             nextPageToken = (token?.isEmpty == false) ? token : nil
-        } while nextPageToken != nil
+        } while nextPageToken != nil && pageCount < maxMeetingListPages
 
         return mapMeetings(allMeetings)
     }
@@ -4899,7 +4978,7 @@ class ViewController: NSViewController {
                 self.reconcileDesktopWidgetsForPremiumAccess()
                 if (wasPremium == false) && newIsPremium {
                     self.startMeetingsAutoRefresh()
-                    self.triggerMeetingsRefresh(force: true)
+                    self.triggerMeetingsRefresh(force: false)
                     self.schedulePremiumRatingPromptIfNeeded()
                 }
                 if wasPremium && !newIsPremium {
@@ -5173,7 +5252,7 @@ class ViewController: NSViewController {
 
         let keepSelected = selectedHomeSidebarItem
         selectedHomeSidebarItem = keepSelected
-        showHomeView(profile: nil)
+        showHomeView(profile: nil, refreshMeetings: false)
         updatePremiumLockedSections()
     }