Files
steipete--codexbar/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift
T
2026-07-13 12:22:33 +08:00

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)
}
}
}
}