176 lines
7.1 KiB
Swift
176 lines
7.1 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import QuartzCore
|
|
|
|
extension StatusItemController {
|
|
private static let defaultDeferredMenuInteractionRefreshDelay: Duration = .milliseconds(250)
|
|
private static let slowMenuOperationThreshold: TimeInterval = 0.15
|
|
private static let slowChartRenderThreshold: TimeInterval = 0.050
|
|
|
|
#if DEBUG
|
|
private static var deferredMenuInteractionRefreshDelayForTesting: Duration = .milliseconds(250)
|
|
|
|
static func setDeferredMenuInteractionRefreshDelayForTesting(_ delay: Duration) {
|
|
self.deferredMenuInteractionRefreshDelayForTesting = delay
|
|
}
|
|
|
|
static func resetDeferredMenuInteractionRefreshDelayForTesting() {
|
|
self.deferredMenuInteractionRefreshDelayForTesting = self.defaultDeferredMenuInteractionRefreshDelay
|
|
}
|
|
#endif
|
|
|
|
private static var deferredMenuInteractionRefreshDelay: Duration {
|
|
#if DEBUG
|
|
deferredMenuInteractionRefreshDelayForTesting
|
|
#else
|
|
defaultDeferredMenuInteractionRefreshDelay
|
|
#endif
|
|
}
|
|
|
|
struct MenuOperationTrace {
|
|
let operation: String
|
|
let startedAt: CFTimeInterval
|
|
}
|
|
|
|
/// Pairs the slow-operation timing log with a watchdog breadcrumb so a hang during
|
|
/// the operation is attributed to it even when the operation never finishes logging.
|
|
func beginMenuOperationTrace(
|
|
_ operation: String,
|
|
breadcrumb: @autoclosure () -> String) -> MenuOperationTrace
|
|
{
|
|
MainThreadActivityBreadcrumb.push(breadcrumb())
|
|
return MenuOperationTrace(operation: operation, startedAt: CACurrentMediaTime())
|
|
}
|
|
|
|
func endMenuOperationTrace(_ trace: MenuOperationTrace, menu: NSMenu, provider: UsageProvider?) {
|
|
MainThreadActivityBreadcrumb.pop()
|
|
self.logMenuOperationDurationIfSlow(
|
|
trace.operation,
|
|
startedAt: trace.startedAt,
|
|
menu: menu,
|
|
provider: provider)
|
|
}
|
|
|
|
func logMenuOperationDurationIfSlow(
|
|
_ operation: String,
|
|
startedAt: CFTimeInterval,
|
|
menu: NSMenu,
|
|
provider: UsageProvider?)
|
|
{
|
|
let elapsed = CACurrentMediaTime() - startedAt
|
|
guard elapsed >= Self.slowMenuOperationThreshold else { return }
|
|
self.menuLogger.warning(
|
|
"slow menu operation",
|
|
metadata: [
|
|
"operation": operation,
|
|
"durationMs": String(format: "%.1f", elapsed * 1000),
|
|
"items": "\(menu.items.count)",
|
|
"provider": provider?.rawValue ?? "nil",
|
|
"openMenus": "\(self.openMenus.count)",
|
|
"storeRefreshing": self.store.isRefreshing ? "1" : "0",
|
|
])
|
|
}
|
|
|
|
func logChartRenderDurationIfSlow(_ label: String, startedAt: CFTimeInterval) {
|
|
let elapsed = CACurrentMediaTime() - startedAt
|
|
guard elapsed >= Self.slowChartRenderThreshold else { return }
|
|
self.menuLogger.warning(
|
|
"slow chart render",
|
|
metadata: [
|
|
"section": label,
|
|
"durationMs": String(format: "%.1f", elapsed * 1000),
|
|
])
|
|
}
|
|
|
|
func deferMenuInteractionRefreshIfNeeded(providers: [UsageProvider]) {
|
|
guard !self.store.isRefreshing else { return }
|
|
self.deferredMenuInteractionRefreshProviders.formUnion(providers)
|
|
}
|
|
|
|
func clearSatisfiedDeferredMenuInteractionRefreshes(for providers: [UsageProvider]) {
|
|
for provider in providers
|
|
where !self.store.needsUsageRefreshRetry(for: provider)
|
|
{
|
|
self.deferredMenuInteractionRefreshProviders.remove(provider)
|
|
}
|
|
}
|
|
|
|
func deferOpenAIDashboardRefreshUntilMenuCloses(reason: String) {
|
|
if let existingReason = self.deferredOpenAIDashboardRefreshReason {
|
|
self.deferredOpenAIDashboardRefreshReason = "\(existingReason), \(reason)"
|
|
} else {
|
|
self.deferredOpenAIDashboardRefreshReason = reason
|
|
}
|
|
}
|
|
|
|
func cancelDeferredMenuInteractionRefreshTask() {
|
|
self.deferredMenuInteractionRefreshTask?.cancel()
|
|
self.deferredMenuInteractionRefreshTask = nil
|
|
}
|
|
|
|
func scheduleDeferredMenuInteractionRefreshIfNeeded(delay: Duration? = nil) {
|
|
guard self.openMenus.isEmpty else { return }
|
|
guard self.deferredMenuInteractionRefreshPending || self.deferredOpenAIDashboardRefreshReason != nil else {
|
|
return
|
|
}
|
|
guard !self.hasPreparedForAppShutdown else { return }
|
|
|
|
self.cancelDeferredMenuInteractionRefreshTask()
|
|
let delay = delay ?? Self.deferredMenuInteractionRefreshDelay
|
|
self.deferredMenuInteractionRefreshTask = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: delay)
|
|
guard let self, !Task.isCancelled else { return }
|
|
guard self.openMenus.isEmpty else {
|
|
self.deferredMenuInteractionRefreshTask = nil
|
|
return
|
|
}
|
|
let pendingProviders = self.deferredMenuInteractionRefreshProviders
|
|
let hasProviderRefreshInFlight = pendingProviders.contains {
|
|
self.store.refreshingProviders.contains($0)
|
|
}
|
|
guard !self.store.isRefreshing,
|
|
!self.store.hasForcedRefreshEnrichmentInFlight,
|
|
!hasProviderRefreshInFlight
|
|
else {
|
|
self.deferredMenuInteractionRefreshTask = nil
|
|
self.scheduleDeferredMenuInteractionRefreshIfNeeded(
|
|
delay: Self.defaultDeferredMenuInteractionRefreshDelay)
|
|
return
|
|
}
|
|
self.clearSatisfiedDeferredMenuInteractionRefreshes(for: Array(pendingProviders))
|
|
let shouldRefreshStore = self.deferredMenuInteractionRefreshPending
|
|
let openAIDashboardRefreshReason = self.deferredOpenAIDashboardRefreshReason
|
|
guard shouldRefreshStore || openAIDashboardRefreshReason != nil else {
|
|
self.deferredMenuInteractionRefreshTask = nil
|
|
return
|
|
}
|
|
guard !self.hasPreparedForAppShutdown else {
|
|
self.deferredMenuInteractionRefreshTask = nil
|
|
return
|
|
}
|
|
self.deferredMenuInteractionRefreshTask = nil
|
|
self.deferredMenuInteractionRefreshProviders.removeAll()
|
|
self.deferredOpenAIDashboardRefreshReason = nil
|
|
#if DEBUG
|
|
self.onDeferredMenuInteractionRefreshForTesting?()
|
|
#endif
|
|
if shouldRefreshStore {
|
|
await self.performStoreRefresh(
|
|
forceTokenUsage: false,
|
|
refreshOpenMenusWhenComplete: false,
|
|
interaction: .background)
|
|
guard !Task.isCancelled else { return }
|
|
}
|
|
if let openAIDashboardRefreshReason {
|
|
guard self.openMenus.isEmpty else {
|
|
self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: openAIDashboardRefreshReason)
|
|
return
|
|
}
|
|
// Keep menu-originated automatic dashboard refreshes non-interactive:
|
|
// opening a menu is not consent to show macOS Keychain prompts.
|
|
self.store.requestOpenAIDashboardRefreshIfStale(reason: openAIDashboardRefreshReason)
|
|
}
|
|
}
|
|
}
|
|
}
|