|
|
@@ -79,8 +79,10 @@ class ViewController: NSViewController {
|
|
79
|
79
|
private var meetingsScrollObserver: NSObjectProtocol?
|
|
80
|
80
|
private var lastMeetingsRefreshAt = Date.distantPast
|
|
81
|
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
|
84
|
private let scrollRefreshCooldown: TimeInterval = 3
|
|
|
85
|
+ private var meetingsRateLimitedUntil: Date?
|
|
84
|
86
|
|
|
85
|
87
|
private enum SidebarStyle {
|
|
86
|
88
|
case login
|
|
|
@@ -321,6 +323,9 @@ class ViewController: NSViewController {
|
|
321
|
323
|
|
|
322
|
324
|
private func triggerMeetingsRefresh(force: Bool = false) {
|
|
323
|
325
|
let now = Date()
|
|
|
326
|
+ if let until = meetingsRateLimitedUntil, now < until {
|
|
|
327
|
+ return
|
|
|
328
|
+ }
|
|
324
|
329
|
if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
|
|
325
|
330
|
return
|
|
326
|
331
|
}
|
|
|
@@ -604,6 +609,7 @@ class ViewController: NSViewController {
|
|
604
|
609
|
let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
605
|
610
|
let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
|
|
606
|
611
|
await MainActor.run {
|
|
|
612
|
+ self.meetingsRateLimitedUntil = nil
|
|
607
|
613
|
self.applyMeetings(zoomMeetings)
|
|
608
|
614
|
}
|
|
609
|
615
|
} catch {
|
|
|
@@ -617,9 +623,14 @@ class ViewController: NSViewController {
|
|
617
|
623
|
self.promptForZoomOAuthCredentialsIfNeeded()
|
|
618
|
624
|
} else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
|
|
619
|
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
|
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
|
811
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
801
|
812
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
802
|
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
|
819
|
if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
804
|
820
|
throw ZoomOAuthError.missingRequiredScope(raw)
|
|
805
|
821
|
}
|
|
|
@@ -1478,7 +1494,8 @@ class ViewController: NSViewController {
|
|
1478
|
1494
|
let settingsRow = NSView()
|
|
1479
|
1495
|
settingsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
1480
|
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
|
1499
|
settingsRow.layer?.cornerRadius = 12
|
|
1483
|
1500
|
settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
|
|
1484
|
1501
|
settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
|
|
|
@@ -1489,16 +1506,26 @@ class ViewController: NSViewController {
|
|
1489
|
1506
|
|
|
1490
|
1507
|
let iconView = NSImageView()
|
|
1491
|
1508
|
iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
1492
|
|
- iconView.contentTintColor = primaryText
|
|
|
1509
|
+ iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
|
|
1493
|
1510
|
iconView.imageScaling = .scaleProportionallyUpOrDown
|
|
1494
|
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
|
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
|
1518
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
1500
|
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
|
1529
|
NSLayoutConstraint.activate([
|
|
1503
|
1530
|
iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
|
|
1504
|
1531
|
iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
|
|
|
@@ -1512,9 +1539,18 @@ class ViewController: NSViewController {
|
|
1512
|
1539
|
|
|
1513
|
1540
|
label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
|
|
1514
|
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
|
1554
|
stack.addArrangedSubview(settingsRow)
|
|
1519
|
1555
|
}
|
|
1520
|
1556
|
|
|
|
@@ -1598,6 +1634,9 @@ class ViewController: NSViewController {
|
|
1598
|
1634
|
case "Scheduler":
|
|
1599
|
1635
|
// `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
|
|
1600
|
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
|
1640
|
case "Hub":
|
|
1602
|
1641
|
return "square.grid.3x3"
|
|
1603
|
1642
|
case "More":
|
|
|
@@ -1978,6 +2017,7 @@ enum ZoomOAuthError: Error {
|
|
1978
|
2017
|
case missingAuthorizationCode
|
|
1979
|
2018
|
case tokenExchangeFailed(String)
|
|
1980
|
2019
|
case missingRequiredScope(String)
|
|
|
2020
|
+ case rateLimited(retryAfterSeconds: Int?)
|
|
1981
|
2021
|
case unableToOpenBrowser
|
|
1982
|
2022
|
case authenticationTimedOut
|
|
1983
|
2023
|
}
|
|
|
@@ -2556,6 +2596,11 @@ extension ZoomOAuthError: LocalizedError {
|
|
2556
|
2596
|
return details
|
|
2557
|
2597
|
case .missingRequiredScope(let details):
|
|
2558
|
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
|
2604
|
case .unableToOpenBrowser:
|
|
2560
|
2605
|
return "Could not open the system browser for Zoom sign-in."
|
|
2561
|
2606
|
case .authenticationTimedOut:
|