diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift index 6d2b0e2ca7..437d08d780 100644 --- a/Sources/CodexBar/IconRemainingResolver.swift +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -23,7 +23,8 @@ enum IconRemainingResolver { static func resolvedWindows( snapshot: UsageSnapshot, - style: IconStyle) + style: IconStyle, + secondaryOverrideWindowID: String? = nil) -> (primary: RateWindow?, secondary: RateWindow?) { if style == .perplexity { @@ -44,6 +45,14 @@ enum IconRemainingResolver { primary: windows.first, secondary: windows.dropFirst().first) } + if style == .copilot, + let secondaryOverrideWindowID, + let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window + { + return ( + primary: snapshot.primary, + secondary: extraWindow) + } return ( primary: snapshot.primary, secondary: snapshot.secondary) @@ -51,7 +60,8 @@ enum IconRemainingResolver { static func resolvedRemaining( snapshot: UsageSnapshot, - style: IconStyle) + style: IconStyle, + secondaryOverrideWindowID: String? = nil) -> (primary: Double?, secondary: Double?) { if style == .perplexity { @@ -72,6 +82,14 @@ enum IconRemainingResolver { primary: windows.first?.remainingPercent, secondary: windows.dropFirst().first?.remainingPercent) } + if style == .copilot, + let secondaryOverrideWindowID, + let extraWindow = snapshot.extraRateWindows?.first(where: { $0.id == secondaryOverrideWindowID })?.window + { + return ( + primary: snapshot.primary?.remainingPercent, + secondary: extraWindow.remainingPercent) + } return ( primary: snapshot.primary?.remainingPercent, secondary: snapshot.secondary?.remainingPercent) @@ -80,10 +98,14 @@ enum IconRemainingResolver { static func resolvedPercents( snapshot: UsageSnapshot, style: IconStyle, - showUsed: Bool) + showUsed: Bool, + secondaryOverrideWindowID: String? = nil) -> (primary: Double?, secondary: Double?) { - let windows = Self.resolvedWindows(snapshot: snapshot, style: style) + let windows = Self.resolvedWindows( + snapshot: snapshot, + style: style, + secondaryOverrideWindowID: secondaryOverrideWindowID) return ( primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 8e45c3bbe0..d54b2e92b7 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -206,6 +206,9 @@ extension UsageMenuCardView.Model { if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { return [] } + if input.provider == .copilot, !input.copilotBudgetExtrasEnabled { + return [] + } return extraRateWindows.map { namedWindow in let paceDetail = Self.extraRateWindowPaceDetail( provider: input.provider, diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1c3532da14..e6817a4254 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -716,6 +716,7 @@ extension UsageMenuCardView.Model { let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool + let copilotBudgetExtrasEnabled: Bool let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool @@ -742,6 +743,7 @@ extension UsageMenuCardView.Model { resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, showOptionalCreditsAndExtraUsage: Bool, + copilotBudgetExtrasEnabled: Bool = false, sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, @@ -767,6 +769,7 @@ extension UsageMenuCardView.Model { self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage + self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 55d1144143..c2099a97b3 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -667,6 +667,7 @@ struct ProvidersPane: View { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, quotaWarningThresholds: [ diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 660a2d2303..19d246f51e 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -17,6 +17,9 @@ struct CopilotProviderImplementation: ProviderImplementation { func observeSettings(_ settings: SettingsStore) { _ = settings.copilotAPIToken _ = settings.copilotEnterpriseHost + _ = settings.copilotBudgetExtrasEnabled + _ = settings.copilotBudgetCookieSource + _ = settings.copilotBudgetCookieHeader } @MainActor @@ -31,9 +34,153 @@ struct CopilotProviderImplementation: ProviderImplementation { ("Add Account...", .addProviderAccount(.copilot)) } + @MainActor + func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { + let budgetExtrasBinding = Binding( + get: { context.settings.copilotBudgetExtrasEnabled }, + set: { enabled in + context.settings.copilotBudgetExtrasEnabled = enabled + }) + let budgetExtrasStatus: () -> String? = { + if context.store.snapshot(for: .copilot)?.extraRateWindows?.isEmpty == false { + return nil + } + if context.settings.copilotBudgetCookieSource == .manual, + context.settings.copilotBudgetCookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return [ + "Paste a github.com Cookie header, then refresh Copilot.", + "Copilot reauth does not provide the GitHub web cookie used for budgets.", + ].joined(separator: " ") + } + return [ + "Refresh Copilot to load budget bars.", + "Budget extras require a logged-in github.com browser session or a manual Cookie header.", + ].joined(separator: " ") + } + + return [ + ProviderSettingsToggleDescriptor( + id: "copilot-budget-extras", + title: "Budget extras", + subtitle: [ + "Optional.", + "Turn this on to fetch configured GitHub Copilot budget limits and show them as extra bars.", + ].joined(separator: " "), + binding: budgetExtrasBinding, + statusText: budgetExtrasStatus, + actions: [], + isVisible: nil, + onChange: { enabled in + if enabled { + await context.store.refreshProvider(.copilot, allowDisabled: true) + } else { + context.store.clearCopilotBudgetExtras() + } + }, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ] + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let extraWindows = context.store.snapshot(for: .copilot)?.extraRateWindows ?? [] + let cookieBinding = Binding( + get: { context.settings.copilotBudgetCookieSource.rawValue }, + set: { raw in + context.settings.copilotBudgetCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.copilotBudgetCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports browser cookies for github.com budget extras.", + manual: "Paste a Cookie header from github.com.", + off: "GitHub cookies are disabled.") + } + let options = [ + ProviderSettingsPickerOption( + id: CopilotIconSecondaryWindowSelection.chat, + title: "Chat"), + ] + extraWindows.map { window in + ProviderSettingsPickerOption(id: window.id, title: window.title) + } + + return [ + ProviderSettingsPickerDescriptor( + id: "copilot-icon-secondary-window", + title: "Menu bar secondary metric", + subtitle: "Choose the second meter shown in the menu bar icon.", + dynamicSubtitle: { + extraWindows.isEmpty + ? "Budget options appear after a refresh finds configured Copilot budgets." + : nil + }, + binding: Binding( + get: { + let selected = context.settings.copilotIconSecondaryWindowID + if selected == CopilotIconSecondaryWindowSelection.chat { + return selected + } + return extraWindows.contains(where: { $0.id == selected }) + ? selected + : CopilotIconSecondaryWindowSelection.chat + }, + set: { selection in + context.settings.copilotIconSecondaryWindowID = selection + }), + options: options, + isVisible: { context.settings.copilotBudgetExtrasEnabled }, + onChange: nil), + ProviderSettingsPickerDescriptor( + id: "copilot-budget-cookie-source", + title: "GitHub cookies", + subtitle: "Automatically imports browser cookies for budget extras.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: { context.settings.copilotBudgetExtrasEnabled }, + onChange: { _ in + await context.store.refreshProvider(.copilot, allowDisabled: true) + }, + trailingText: { + guard context.settings.copilotBudgetCookieSource != .manual else { return nil } + guard let entry = CookieHeaderCache.load(provider: .copilot) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ + ProviderSettingsFieldDescriptor( + id: "copilot-budget-cookie-header", + title: "Manual GitHub Cookie header", + subtitle: "Paste a github.com Cookie header. Treat this value like a password.", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.copilotBudgetCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "refresh-copilot-budget-cookie", + title: "Refresh budgets", + style: .bordered, + isVisible: nil, + perform: { + await context.store.refreshProvider(.copilot, allowDisabled: true) + }), + ], + isVisible: { + context.settings.copilotBudgetExtrasEnabled && + context.settings.copilotBudgetCookieSource == .manual + }, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "copilot-enterprise-host", title: "Enterprise host", diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index 38c875e8b9..4fdf677eef 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -1,6 +1,10 @@ import CodexBarCore import Foundation +enum CopilotIconSecondaryWindowSelection { + static let chat = "chat" +} + extension SettingsStore { var copilotAPIToken: String { get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedAPIKey ?? "" } @@ -21,7 +25,48 @@ extension SettingsStore { } } + var copilotBudgetCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .copilot) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .copilot, field: "cookieHeader", value: newValue) + } + } + + var copilotBudgetCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .copilot, fallback: .auto) } + set { + self.updateProviderConfig(provider: .copilot) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .copilot, field: "cookieSource", value: newValue.rawValue) + } + } + func ensureCopilotAPITokenLoaded() {} + + var copilotIconSecondaryWindowID: String { + get { + let raw = self.copilotIconSecondaryWindowIDRaw.trimmingCharacters(in: .whitespacesAndNewlines) + return raw.isEmpty ? CopilotIconSecondaryWindowSelection.chat : raw + } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + self.copilotIconSecondaryWindowIDRaw = trimmed.isEmpty + ? CopilotIconSecondaryWindowSelection.chat + : trimmed + } + } + + func copilotIconSecondaryWindowOverrideID(snapshot: UsageSnapshot?) -> String? { + guard self.copilotBudgetExtrasEnabled else { return nil } + let selected = self.copilotIconSecondaryWindowID + guard selected != CopilotIconSecondaryWindowSelection.chat else { return nil } + guard snapshot?.extraRateWindows?.contains(where: { $0.id == selected }) == true else { return nil } + return selected + } } extension SettingsStore { @@ -36,6 +81,10 @@ extension SettingsStore { let host = CopilotDeviceFlow.normalizedHost(self.copilotEnterpriseHost) return ProviderSettingsSnapshot.CopilotProviderSettings( apiToken: self.normalizedConfigValue(token), - enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host) + enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host, + selectedAccountExternalIdentifier: account?.externalIdentifier.flatMap(self.normalizedConfigValue), + budgetExtrasEnabled: self.copilotBudgetExtrasEnabled, + budgetCookieSource: self.copilotBudgetCookieSource, + manualBudgetCookieHeader: self.normalizedConfigValue(self.copilotBudgetCookieHeader)) } } diff --git a/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift new file mode 100644 index 0000000000..0d6dc4075c --- /dev/null +++ b/Sources/CodexBar/Providers/Copilot/UsageStore+CopilotBudgets.swift @@ -0,0 +1,19 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + func clearCopilotBudgetExtras() { + if let snapshot = self.snapshots[.copilot], + snapshot.extraRateWindows?.isEmpty == false + { + let updated = snapshot.with(extraRateWindows: nil) + self.snapshots[.copilot] = updated + self.lastKnownResetSnapshots[.copilot] = updated + } else if let resetSnapshot = self.lastKnownResetSnapshots[.copilot], + resetSnapshot.extraRateWindows?.isEmpty == false + { + self.lastKnownResetSnapshots[.copilot] = resetSnapshot.with(extraRateWindows: nil) + } + } +} diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index cc966c1463..c4632d2b65 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -288,6 +288,14 @@ extension SettingsStore { } } + var copilotIconSecondaryWindowIDRaw: String { + get { self.defaultsState.copilotIconSecondaryWindowIDRaw } + set { + self.defaultsState.copilotIconSecondaryWindowIDRaw = newValue + self.userDefaults.set(newValue, forKey: "copilotIconSecondaryWindowID") + } + } + var costUsageEnabled: Bool { get { self.defaultsState.costUsageEnabled } set { @@ -375,6 +383,17 @@ extension SettingsStore { set { self.claudeWebExtrasEnabledRaw = newValue } } + var copilotBudgetExtrasEnabled: Bool { + get { self.defaultsState.copilotBudgetExtrasEnabled } + set { + self.defaultsState.copilotBudgetExtrasEnabled = newValue + self.userDefaults.set(newValue, forKey: "copilotBudgetExtrasEnabled") + CodexBarLog.logger(LogCategories.settings).info( + "Copilot budget extras updated", + metadata: ["enabled": newValue ? "1" : "0"]) + } + } + private var claudeWebExtrasEnabledRaw: Bool { get { self.defaultsState.claudeWebExtrasEnabledRaw } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index f81c06fb43..ccff585ce6 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -30,6 +30,7 @@ extension SettingsStore { _ = self.historicalTrackingEnabled _ = self.multiAccountMenuLayout _ = self.menuBarMetricPreferencesRaw + _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled _ = self.costUsageHistoryDays _ = self.appLanguage @@ -39,6 +40,7 @@ extension SettingsStore { _ = self.claudeOAuthKeychainPromptMode _ = self.claudeOAuthKeychainReadStrategy _ = self.claudeWebExtrasEnabled + _ = self.copilotBudgetExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 7a05a305bc..20a004bb50 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -327,20 +327,7 @@ extension SettingsStore { } let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false let debugMenuEnabled = userDefaults.object(forKey: "debugMenuEnabled") as? Bool ?? false - let debugDisableKeychainAccess: Bool = { - if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool { - return stored - } - if Self.shouldBridgeSharedDefaults(for: userDefaults), - let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool - { - if Self.isRunningTests { - userDefaults.set(shared, forKey: "debugDisableKeychainAccess") - } - return shared - } - return false - }() + let debugDisableKeychainAccess = Self.loadDebugDisableKeychainAccess(userDefaults: userDefaults) let debugFileLoggingEnabled = userDefaults.object(forKey: "debugFileLoggingEnabled") as? Bool ?? false let debugLogLevelRaw = userDefaults.string(forKey: "debugLogLevel") ?? CodexBarLog.Level.verbose.rawValue if Self.isRunningTests, userDefaults.string(forKey: "debugLogLevel") == nil { @@ -372,11 +359,10 @@ extension SettingsStore { let kiroMenuBarDisplayModeRaw = userDefaults.string(forKey: "kiroMenuBarDisplayMode") ?? KiroMenuBarDisplayMode.automatic.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false - let multiAccountMenuLayoutRaw = userDefaults.string(forKey: "multiAccountMenuLayout") ?? { - let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false - return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue - }() + let multiAccountMenuLayoutRaw = Self.loadMultiAccountMenuLayoutRaw(userDefaults: userDefaults) let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults) + let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false + let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults) let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) @@ -447,6 +433,8 @@ extension SettingsStore { historicalTrackingEnabled: historicalTrackingEnabled, multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, + copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled, + copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, costUsageHistoryDays: costUsageHistoryDays, hidePersonalInfo: hidePersonalInfo, @@ -482,6 +470,33 @@ extension SettingsStore { return Dictionary(uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) } + private static func loadMultiAccountMenuLayoutRaw(userDefaults: UserDefaults) -> String { + if let layout = userDefaults.string(forKey: "multiAccountMenuLayout") { + return layout + } + let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false + return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue + } + + private static func loadCopilotIconSecondaryWindowIDRaw(userDefaults: UserDefaults) -> String { + userDefaults.string(forKey: "copilotIconSecondaryWindowID") ?? CopilotIconSecondaryWindowSelection.chat + } + + private static func loadDebugDisableKeychainAccess(userDefaults: UserDefaults) -> Bool { + if let stored = userDefaults.object(forKey: "debugDisableKeychainAccess") as? Bool { + return stored + } + if Self.shouldBridgeSharedDefaults(for: userDefaults), + let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool + { + if Self.isRunningTests { + userDefaults.set(shared, forKey: "debugDisableKeychainAccess") + } + return shared + } + return false + } + private struct LoadedQuotaWarningDefaults { var notificationsEnabled: Bool var thresholdsRaw: [Int] diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 9ed022a978..dfd384d0e4 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -29,6 +29,8 @@ struct SettingsDefaultsState { var historicalTrackingEnabled: Bool var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] + var copilotBudgetExtrasEnabled: Bool + var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool var costUsageHistoryDays: Int var hidePersonalInfo: Bool diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3b545e6dc4..b6dc3e09c4 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -253,7 +253,8 @@ extension StatusItemController { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, - showUsed: showUsed) + showUsed: showUsed, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) } var primary = resolved?.primary var weekly = resolved?.secondary @@ -479,7 +480,8 @@ extension StatusItemController { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, - showUsed: showUsed) + showUsed: showUsed, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) } var primary = resolved?.primary var weekly = resolved?.secondary diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index dbc61ab627..b7e22208dd 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1365,7 +1365,8 @@ extension StatusItemController { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, - showUsed: showUsed) + showUsed: showUsed, + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) } let primary = resolved?.primary var weekly = resolved?.secondary diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index eebc4b830f..ac26780e4d 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -109,6 +109,7 @@ extension StatusItemController { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift new file mode 100644 index 0000000000..1041659275 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotBudgetWebFetcher.swift @@ -0,0 +1,801 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +#if os(macOS) +import SweetCookieKit +#endif + +public struct CopilotBudgetWebFetcher: Sendable { + public enum Error: Swift.Error, LocalizedError, Equatable { + case noSessionCookie + case notLoggedIn + case accountMismatch(expected: String, actual: String?) + case badStatus(Int) + case invalidResponse + + public var errorDescription: String? { + switch self { + case .noSessionCookie: + "No GitHub browser session cookie found." + case .notLoggedIn: + "GitHub browser session is not logged in." + case let .accountMismatch(expected, actual): + "GitHub browser session belongs to \(actual ?? "an unknown account"), expected \(expected)." + case let .badStatus(status): + "GitHub budgets request failed with HTTP \(status)." + case .invalidResponse: + "GitHub budgets response could not be decoded." + } + } + } + + struct BudgetResponse: Decodable, Sendable { + let budgets: [Budget] + let hasNextPage: Bool? + + private enum CodingKeys: String, CodingKey { + case budgets + case payload + case hasNextPage + case hasNextPageSnake = "has_next_page" + } + + init(budgets: [Budget], hasNextPage: Bool? = nil) { + self.budgets = budgets + self.hasNextPage = hasNextPage + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let payload = try container.decodeIfPresent(BudgetResponse.self, forKey: .payload) { + self = payload + return + } + self.budgets = try container.decodeIfPresent([Budget].self, forKey: .budgets) ?? [] + self.hasNextPage = try container.decodeIfPresent(Bool.self, forKey: .hasNextPage) + ?? container.decodeIfPresent(Bool.self, forKey: .hasNextPageSnake) + } + } + + struct Budget: Decodable, Equatable, Sendable { + let id: String? + let name: String? + let budgetType: String? + let budgetProductSkus: [String] + let budgetScope: String? + let budgetEntityName: String? + let budgetAmount: Double + let currentAmount: Double + + init( + id: String? = nil, + name: String? = nil, + budgetType: String? = nil, + budgetProductSkus: [String] = [], + budgetScope: String? = nil, + budgetEntityName: String? = nil, + budgetAmount: Double, + currentAmount: Double = 0) + { + self.id = id + self.name = name + self.budgetType = budgetType + self.budgetProductSkus = budgetProductSkus + self.budgetScope = budgetScope + self.budgetEntityName = budgetEntityName + self.budgetAmount = budgetAmount + self.currentAmount = currentAmount + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.id = Self.decodeString(container: container, keys: ["id", "uuid", "budget_id", "budgetId"]) + self.name = Self.decodeString(container: container, keys: ["name", "display_name", "displayName", "title"]) + self.budgetType = Self.decodeString( + container: container, + keys: ["budget_type", "budgetType", "type", "pricing_target_type", "pricingTargetType"]) + self.budgetProductSkus = Self.decodeStringArray( + container: container, + keys: [ + "budget_product_skus", + "budgetProductSkus", + "budget_product_sku", + "budgetProductSku", + "product_skus", + "productSkus", + "skus", + "sku", + "product", + "product_name", + "productName", + "pricing_target_id", + "pricingTargetId", + ]) + self.budgetScope = Self.decodeString(container: container, keys: ["budget_scope", "budgetScope", "scope"]) + self.budgetEntityName = Self.decodeString( + container: container, + keys: [ + "budget_entity_name", + "budgetEntityName", + "entity_name", + "entityName", + "target_name", + "targetName", + ]) + self.budgetAmount = Self.decodeDouble( + container: container, + keys: [ + "budget_amount", + "budgetAmount", + "target_amount", + "targetAmount", + "spending_limit", + "spendingLimit", + "limit", + "amount", + "max", + ]) ?? 0 + self.currentAmount = Self.decodeDouble( + container: container, + keys: [ + "current_usage", + "currentUsage", + "current_amount", + "currentAmount", + "usage_amount", + "usageAmount", + "usage", + "spent", + "amount_used", + "amountUsed", + ]) ?? 0 + } + + var normalizedSelectors: Set { + let values = self.budgetProductSkus + [ + self.budgetType, + self.budgetEntityName, + self.name, + ].compactMap(\.self) + return Set(values.compactMap(CopilotBudgetWebFetcher.normalizedBillingIdentifier)) + } + + private static func decodeString( + container: KeyedDecodingContainer, + keys: [String]) -> String? + { + for key in keys { + guard let codingKey = DynamicCodingKey(key) else { continue } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), !value.isEmpty { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) { + return String(value) + } + } + return nil + } + + private static func decodeStringArray( + container: KeyedDecodingContainer, + keys: [String]) -> [String] + { + for key in keys { + guard let codingKey = DynamicCodingKey(key) else { continue } + if let values = try? container.decodeIfPresent([String].self, forKey: codingKey), !values.isEmpty { + return values + } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), !value.isEmpty { + return [value] + } + if let values = try? container.decodeIfPresent([ProductSKU].self, forKey: codingKey), + !values.isEmpty + { + return values.flatMap(\.selectors) + } + } + return [] + } + + private static func decodeDouble( + container: KeyedDecodingContainer, + keys: [String]) -> Double? + { + for key in keys { + guard let codingKey = DynamicCodingKey(key) else { continue } + if let value = try? container.decodeIfPresent(Double.self, forKey: codingKey) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), + let parsed = Self.parseAmount(value) + { + return parsed + } + if let value = try? container.decodeIfPresent(AmountValue.self, forKey: codingKey) { + return value.amount + } + } + return nil + } + + fileprivate static func parseAmount(_ value: String) -> Double? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let isNegative = trimmed.first == "-" + guard !trimmed.dropFirst(isNegative ? 1 : 0).contains("-") else { return nil } + let unsigned = trimmed.filter { $0.isNumber || $0 == "." } + guard !unsigned.isEmpty else { return nil } + return Double(isNegative ? "-\(unsigned)" : unsigned) + } + } + + struct GitHubWebIdentity: Equatable, Sendable { + let id: String? + let login: String? + + init(id: String?, login: String?) { + let trimmedID = id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedLogin = login?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.id = trimmedID.isEmpty ? nil : trimmedID + self.login = trimmedLogin.isEmpty ? nil : trimmedLogin + } + + var displayName: String? { + self.login ?? self.id.map { "github:user:\($0)" } + } + } + + private struct ProductSKU: Decodable, Sendable, Equatable { + let selectors: [String] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.selectors = [ + "sku", + "name", + "display_name", + "displayName", + "product", + "product_name", + "productName", + ].compactMap { key in + guard let codingKey = DynamicCodingKey(key) else { return nil } + return try? container.decodeIfPresent(String.self, forKey: codingKey) + } + } + } + + private struct AmountValue: Decodable, Sendable { + let amount: Double? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.amount = [ + "amount", + "value", + "total", + "cents", + "formatted", + ].lazy.compactMap { key -> Double? in + guard let codingKey = DynamicCodingKey(key) else { return nil } + if let value = try? container.decodeIfPresent(Double.self, forKey: codingKey) { + return key == "cents" ? value / 100 : value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: codingKey) { + return key == "cents" ? Double(value) / 100 : Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey) { + return Budget.parseAmount(value) + } + return nil + }.first + } + } + + private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(_ stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(stringValue: String) { + self.init(stringValue) + } + + init?(intValue: Int) { + self.stringValue = String(intValue) + self.intValue = intValue + } + } + + private static let copilotProductID = "copilot" + private static let copilotPremiumRequestSKU = "copilot_premium_request" + private static let copilotAgentPremiumRequestSKU = "copilot_agent_premium_request" + private static let sparkPremiumRequestSKU = "spark_premium_request" + private static let copilotBudgetSelectors: Set = [ + copilotProductID, + copilotPremiumRequestSKU, + copilotAgentPremiumRequestSKU, + sparkPremiumRequestSKU, + ] + + private let cookieHeaderOverride: String? + private let expectedGitHubAccountIdentifier: String? + private let browserDetection: BrowserDetection + private let transport: any ProviderHTTPTransport + private let now: @Sendable () -> Date + + public init( + cookieHeaderOverride: String? = nil, + expectedGitHubAccountIdentifier: String? = nil, + browserDetection: BrowserDetection = BrowserDetection(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: @escaping @Sendable () -> Date = { Date() }) + { + self.cookieHeaderOverride = CookieHeaderNormalizer.normalize(cookieHeaderOverride ?? "") + self.expectedGitHubAccountIdentifier = Self.normalizedExpectedAccountIdentifier( + expectedGitHubAccountIdentifier) + self.browserDetection = browserDetection + self.transport = transport + self.now = now + } + + public func fetchBudgetWindows() async throws -> [NamedRateWindow] { + if let cookieHeaderOverride, !cookieHeaderOverride.isEmpty { + return try await self.fetchBudgetWindows(cookieHeader: cookieHeaderOverride) + } + + if let cached = CookieHeaderCache.load(provider: .copilot), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + do { + return try await self.fetchBudgetWindows(cookieHeader: cached.cookieHeader) + } catch { + if case Error.notLoggedIn = error { + CookieHeaderCache.clear(provider: .copilot) + } else if case Error.accountMismatch = error { + CookieHeaderCache.clear(provider: .copilot) + } else { + throw error + } + } + } + + #if os(macOS) + for session in CopilotGitHubCookieImporter.importSessions(browserDetection: self.browserDetection) { + do { + let windows = try await self.fetchBudgetWindows(cookieHeader: session.cookieHeader) + CookieHeaderCache.store( + provider: .copilot, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return windows + } catch { + if case Error.notLoggedIn = error { + continue + } + if case Error.accountMismatch = error { + continue + } + throw error + } + } + #endif + + throw Error.noSessionCookie + } + + func fetchBudgetWindows(cookieHeader: String) async throws -> [NamedRateWindow] { + let nonce = try await self.boundFetchNonce(cookieHeader: cookieHeader) + var allBudgets: [Budget] = [] + var page = 1 + let maxPages = 20 + var shouldContinue = true + while shouldContinue, page <= maxPages { + let response = try await self.fetchBudgetPage( + cookieHeader: cookieHeader, + nonce: nonce, + page: page) + allBudgets.append(contentsOf: response.budgets) + shouldContinue = response.hasNextPage == true + page += 1 + } + if shouldContinue { + CodexBarLog.logger(LogCategories.providers).warning( + "Copilot budget pagination reached page cap", + metadata: ["pageCap": "\(maxPages)"]) + } + return Self.extraRateWindows(from: allBudgets, now: self.now()) + } + + private func boundFetchNonce(cookieHeader: String) async throws -> String? { + guard self.expectedGitHubAccountIdentifier != nil else { + return await self.bestEffortFetchNonce(cookieHeader: cookieHeader) + } + let metadata = try await self.fetchBudgetPageMetadata(cookieHeader: cookieHeader) + try self.verifyExpectedGitHubAccount(metadata.identity) + return metadata.nonce + } + + private func bestEffortFetchNonce(cookieHeader: String) async -> String? { + do { + return try await self.fetchBudgetPageMetadata(cookieHeader: cookieHeader).nonce + } catch { + // GitHub accepts some budget requests without a nonce. Keep auth failures and + // page-shape changes non-fatal so a valid Cookie header can still be tried. + return nil + } + } + + private struct BudgetPageMetadata: Sendable { + let nonce: String? + let identity: GitHubWebIdentity? + } + + private func fetchBudgetPageMetadata(cookieHeader: String) async throws -> BudgetPageMetadata { + guard let url = URL(string: "https://github.com/settings/billing/budgets") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.timeoutInterval = 15 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + + let response = try await self.transport.response(for: request) + switch response.statusCode { + case 200: + guard let html = String(data: response.data, encoding: .utf8) else { throw Error.invalidResponse } + return BudgetPageMetadata( + nonce: Self.extractFetchNonce(from: html), + identity: Self.extractGitHubWebIdentity(from: html)) + case 401, 403: + throw Error.notLoggedIn + default: + throw Error.badStatus(response.statusCode) + } + } + + private func verifyExpectedGitHubAccount(_ actual: GitHubWebIdentity?) throws { + guard let expected = self.expectedGitHubAccountIdentifier else { return } + guard let actual else { throw Error.accountMismatch(expected: expected, actual: nil) } + guard Self.webIdentity(actual, matches: expected) else { + throw Error.accountMismatch(expected: expected, actual: actual.displayName) + } + } + + private func fetchBudgetPage(cookieHeader: String, nonce: String?, page: Int) async throws -> BudgetResponse { + guard var components = URLComponents(string: "https://github.com/settings/billing/budgets") else { + throw URLError(.badURL) + } + components.queryItems = [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "page_size", value: "10"), + URLQueryItem(name: "scope", value: "customer"), + ] + guard let url = components.url else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.timeoutInterval = 15 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("https://github.com/settings/billing/budgets", forHTTPHeaderField: "Referer") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue("true", forHTTPHeaderField: "GitHub-Verified-Fetch") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + if let nonce, !nonce.isEmpty { + request.setValue(nonce, forHTTPHeaderField: "X-Fetch-Nonce") + } + + let response = try await self.transport.response(for: request) + switch response.statusCode { + case 200: + do { + return try JSONDecoder().decode(BudgetResponse.self, from: response.data) + } catch { + throw Error.invalidResponse + } + case 401, 403: + throw Error.notLoggedIn + default: + throw Error.badStatus(response.statusCode) + } + } + + static func extractFetchNonce(from html: String) -> String? { + let patterns = [ + #"x-fetch-nonce"\s+content="([^"]+)""#, + #"X-Fetch-Nonce"\s*:\s*"([^"]+)""#, + #"fetchNonce"\s*:\s*"([^"]+)""#, + #"data-fetch-nonce="([^"]+)""#, + ] + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + continue + } + let range = NSRange(html.startIndex.. GitHubWebIdentity? { + let id = self.extractMetaContent( + named: [ + "octolytics-actor-id", + "analytics-user-id", + "user-id", + ], + from: html) + let login = self.extractMetaContent( + named: [ + "user-login", + "octolytics-actor-login", + "analytics-user-login", + ], + from: html) + let identity = GitHubWebIdentity(id: id, login: login) + return identity.id == nil && identity.login == nil ? nil : identity + } + + private static let metaTagRegex = try? NSRegularExpression( + pattern: #"]*>"#, + options: [.caseInsensitive]) + + private static let metaAttributeRegex = try? NSRegularExpression( + pattern: #"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(['"])(.*?)\2"#, + options: [.caseInsensitive]) + + private static func extractMetaContent(named names: [String], from html: String) -> String? { + guard let metaTagRegex, let metaAttributeRegex else { return nil } + let expectedNames = Set(names.map { $0.lowercased() }) + var contentByName: [String: String] = [:] + let htmlRange = NSRange(html.startIndex.. Bool { + guard let expected = self.normalizedExpectedAccountIdentifier(expectedIdentifier), + let identity + else { return false } + if let expectedID = self.githubUserID(from: expected) { + return identity.id == expectedID + } + return identity.login?.lowercased() == expected + } + + static func normalizedGitHubAccountIdentifier(for identity: CopilotUsageFetcher.GitHubUserIdentity) -> String { + "github:user:\(identity.id)" + } + + private static func normalizedExpectedAccountIdentifier(_ identifier: String?) -> String? { + let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func githubUserID(from identifier: String) -> String? { + let prefix = "github:user:" + guard identifier.hasPrefix(prefix) else { return nil } + let suffix = String(identifier.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return suffix.isEmpty ? nil : suffix + } + + static func extraRateWindows(from budgets: [Budget], now: Date) -> [NamedRateWindow] { + var usedIDs = Set() + let resetDate = self.approximateNextMonthResetDate(now: now) + return budgets + .compactMap { budget in + let selectors = budget.normalizedSelectors + guard self.isCopilotBudget(budget, selectors: selectors) else { return nil } + let id = self.uniqueWindowID(for: budget, selectors: selectors, usedIDs: &usedIDs) + let usedPercent = budget.budgetAmount > 0 + ? min(999, max(0, budget.currentAmount / budget.budgetAmount * 100)) + : 0 + let window = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: resetDate.map { + UsageFormatter.resetDescription(from: $0, now: now) + }) + return NamedRateWindow( + id: id, + title: self.windowTitle(for: budget, selectors: selectors), + window: window) + } + } + + private static func isCopilotBudget(_ budget: Budget, selectors: Set) -> Bool { + guard budget.budgetAmount > 0 else { return false } + return !selectors.isDisjoint(with: self.copilotBudgetSelectors) + } + + static func normalizedBillingIdentifier(_ value: String?) -> String? { + guard let value else { return nil } + let slug = self.slug(value) + guard !slug.isEmpty else { return nil } + let underscored = slug.replacingOccurrences(of: "-", with: "_") + if underscored == self.copilotProductID { + return self.copilotProductID + } + if underscored == "premium_request" || underscored == "premium_requests" { + return self.copilotPremiumRequestSKU + } + if underscored == "coding_agent_premium_request" || underscored == "coding_agent_premium_requests" { + return self.copilotAgentPremiumRequestSKU + } + if underscored.contains("spark"), underscored.contains("premium"), underscored.contains("request") { + return self.sparkPremiumRequestSKU + } + if underscored.contains("cloud") || underscored.contains("coding"), + underscored.contains("agent"), + underscored.contains("premium"), + underscored.contains("request") + { + return self.copilotAgentPremiumRequestSKU + } + if underscored.contains("bundled"), underscored.contains("premium"), underscored.contains("request") { + return self.copilotPremiumRequestSKU + } + if underscored.contains("copilot"), + underscored.contains("agent"), + underscored.contains("premium"), + underscored.contains("request") + { + return self.copilotAgentPremiumRequestSKU + } + if underscored.contains("copilot"), underscored.contains("premium"), underscored.contains("request") { + return self.copilotPremiumRequestSKU + } + return underscored + } + + private static func windowTitle(for budget: Budget, selectors: Set) -> String { + let budgetType = if selectors == [self.copilotProductID] { + "Copilot" + } else if selectors.contains(self.copilotAgentPremiumRequestSKU) { + "Copilot Agent Premium Requests" + } else if selectors.contains(self.sparkPremiumRequestSKU) { + "Spark Premium Requests" + } else if selectors.contains(self.copilotPremiumRequestSKU) { + "All Premium Request SKUs" + } else if let name = budget.name?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + { + name + } else { + "Copilot Premium Requests" + } + return "Budget - \(budgetType)" + } + + private static func uniqueWindowID( + for budget: Budget, + selectors: Set, + usedIDs: inout Set) -> String + { + let source = budget.id ?? budget.budgetProductSkus.joined(separator: "-") + let slug = self.slug(source.isEmpty ? self.windowTitle(for: budget, selectors: selectors) : source) + let base = slug.isEmpty ? "copilot-budget" : "copilot-budget-\(slug)" + var candidate = base + var suffix = 2 + while !usedIDs.insert(candidate).inserted { + candidate = "\(base)-\(suffix)" + suffix += 1 + } + return candidate + } + + private static func approximateNextMonthResetDate(now: Date) -> Date? { + // GitHub's budget response does not expose a reset instant. Use the local + // start of next month as a display-only approximation. + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let components = calendar.dateComponents([.year, .month], from: now) + guard let monthStart = calendar.date(from: DateComponents( + year: components.year, + month: components.month, + day: 1)) + else { + return nil + } + return calendar.date(byAdding: .month, value: 1, to: monthStart) + } + + private static func slug(_ value: String) -> String { + var result = "" + var lastWasDash = false + for scalar in value.lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) { + result.unicodeScalars.append(scalar) + lastWasDash = false + } else if !lastWasDash { + result.append("-") + lastWasDash = true + } + } + return result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } +} + +#if os(macOS) +private enum CopilotGitHubCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.copilot]?.browserCookieOrder ?? [.chrome] + private static let sessionCookieNames: Set = [ + "user_session", + "__Host-user_session_same_site", + "_gh_sess", + "logged_in", + "dotcom_user", + ] + + struct SessionInfo: Sendable { + let cookies: [HTTPCookie] + let sourceLabel: String + + var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + static func importSessions(browserDetection: BrowserDetection) -> [SessionInfo] { + let installedBrowsers = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + return installedBrowsers.flatMap { browser -> [SessionInfo] in + do { + return try self.importSessions(from: browser) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + return [] + } + } + } + + private static func importSessions(from browser: Browser) throws -> [SessionInfo] { + let query = BrowserCookieQuery(domains: ["github.com", "www.github.com"]) + let sources = try self.cookieClient.codexBarRecords(matching: query, in: browser) + return sources.compactMap { source -> SessionInfo? in + guard !source.records.isEmpty else { return nil } + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard cookies.contains(where: { self.sessionCookieNames.contains($0.name) }) else { + return nil + } + return SessionInfo(cookies: cookies, sourceLabel: source.label) + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 3709cabed7..139e190d33 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -21,6 +21,7 @@ public enum CopilotProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.copilotCookieImportOrder, dashboardURL: "https://github.com/settings/copilot", statusPageURL: "https://www.githubstatus.com/"), branding: ProviderBranding( @@ -54,7 +55,8 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { let fetcher = CopilotUsageFetcher( token: token, enterpriseHost: context.settings?.copilot?.enterpriseHost) - let snap = try await fetcher.fetch() + let usage = try await fetcher.fetch() + let snap = await self.addBudgetWindowsIfNeeded(to: usage, token: token, context: context) return self.makeResult( usage: snap, sourceLabel: "api") @@ -70,4 +72,65 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { "COPILOT_API_TOKEN": context.settings?.copilot?.apiToken ?? "", ])?.token } + + private func addBudgetWindowsIfNeeded( + to usage: UsageSnapshot, + token: String, + context: ProviderFetchContext) async -> UsageSnapshot + { + guard let settings = context.settings?.copilot, + settings.budgetExtrasEnabled, + settings.budgetCookieSource != .off + else { return usage } + + let manualCookieHeader = Self.budgetCookieHeaderOverride(from: settings) + if settings.budgetCookieSource == .manual, manualCookieHeader == nil { + return usage + } + do { + let expectedAccountIdentifier = try await self.expectedBudgetAccountIdentifier( + token: token, + settings: settings) + let extraRateWindows = try await CopilotBudgetWebFetcher( + cookieHeaderOverride: manualCookieHeader, + expectedGitHubAccountIdentifier: expectedAccountIdentifier, + browserDetection: context.browserDetection) + .fetchBudgetWindows() + guard !extraRateWindows.isEmpty else { return usage } + return usage.with(extraRateWindows: extraRateWindows) + } catch { + CodexBarLog.logger(LogCategories.providers).warning( + "Copilot budget extras unavailable", + metadata: ["error": "\(error.localizedDescription)"]) + return usage + } + } + + static func budgetCookieHeaderOverride( + from settings: ProviderSettingsSnapshot.CopilotProviderSettings) -> String? + { + guard settings.budgetCookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(settings.manualBudgetCookieHeader) + } + + private func expectedBudgetAccountIdentifier( + token: String, + settings: ProviderSettingsSnapshot.CopilotProviderSettings) async throws -> String + { + let identity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) + let tokenIdentifier = CopilotBudgetWebFetcher.normalizedGitHubAccountIdentifier(for: identity) + if let selectedIdentifier = Self.normalizedBudgetAccountIdentifier(settings.selectedAccountExternalIdentifier), + selectedIdentifier != tokenIdentifier.lowercased(), + selectedIdentifier != identity.login.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + { + CodexBarLog.logger(LogCategories.providers).warning( + "Ignoring stale Copilot account identifier") + } + return tokenIdentifier + } + + private static func normalizedBudgetAccountIdentifier(_ identifier: String?) -> String? { + let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index af033f4b46..2ed15330b5 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -206,10 +206,25 @@ public struct ProviderSettingsSnapshot: Sendable { public struct CopilotProviderSettings: Sendable { public let apiToken: String? public let enterpriseHost: String? + public let selectedAccountExternalIdentifier: String? + public let budgetExtrasEnabled: Bool + public let budgetCookieSource: ProviderCookieSource + public let manualBudgetCookieHeader: String? - public init(apiToken: String? = nil, enterpriseHost: String? = nil) { + public init( + apiToken: String? = nil, + enterpriseHost: String? = nil, + selectedAccountExternalIdentifier: String? = nil, + budgetExtrasEnabled: Bool = false, + budgetCookieSource: ProviderCookieSource = .auto, + manualBudgetCookieHeader: String? = nil) + { self.apiToken = apiToken self.enterpriseHost = enterpriseHost + self.selectedAccountExternalIdentifier = selectedAccountExternalIdentifier + self.budgetExtrasEnabled = budgetExtrasEnabled + self.budgetCookieSource = budgetCookieSource + self.manualBudgetCookieHeader = manualBudgetCookieHeader } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 6678c6cc96..e44982276d 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -243,4 +243,13 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// Copilot budget imports should stay Chrome-only by default to avoid prompting unrelated browsers. + public static var copilotCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 5ec231fc28..2ff3f53993 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -168,6 +168,29 @@ public struct UsageSnapshot: Codable, Sendable { self.identity = identity } + public func with(extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot { + UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + tertiary: self.tertiary, + extraRateWindows: extraRateWindows, + kiroUsage: self.kiroUsage, + providerCost: self.providerCost, + zaiUsage: self.zaiUsage, + minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, + openRouterUsage: self.openRouterUsage, + openAIAPIUsage: self.openAIAPIUsage, + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, + cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + updatedAt: self.updatedAt, + identity: self.identity) + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) diff --git a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index d2bdd21d66..c8cf68d48a 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -64,5 +64,10 @@ struct BrowserCookieOrderStatusStringTests { #expect(!order.contains(.arc)) } + @Test + func `copilot cookie imports default to chrome only`() { + #expect(ProviderDefaults.metadata[.copilot]?.browserCookieOrder == [.chrome]) + #expect(ProviderBrowserCookieDefaults.copilotCookieImportOrder == [.chrome]) + } #endif } diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 4a907a53cc..7ea5cf4963 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -163,6 +163,62 @@ struct CodexBarTests { #expect(regionHasFill(xRange: 3...33, yRange: 19...31)) } + @Test + func `copilot icon can use selected budget as secondary lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .copilot, + secondaryOverrideWindowID: "copilot-budget-agent") + + #expect(remaining.primary == 80) + #expect(remaining.secondary == 35) + } + + @Test + func `copying extra rate windows preserves subscription dates`() { + let expiresAt = Date(timeIntervalSince1970: 1_810_656_000) + let renewsAt = Date(timeIntervalSince1970: 1_810_569_600) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + subscriptionExpiresAt: expiresAt, + subscriptionRenewsAt: renewsAt, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + + let copied = snapshot.with(extraRateWindows: []) + + #expect(copied.subscriptionExpiresAt == expiresAt) + #expect(copied.subscriptionRenewsAt == renewsAt) + } + + @Test + func `copilot icon falls back to chat lane when selected budget is unavailable`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: nil, + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .copilot, + secondaryOverrideWindowID: "copilot-budget-agent") + + #expect(remaining.primary == 80) + #expect(remaining.secondary == 70) + } + @Test func `codex icon promotes weekly only window into primary display lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift new file mode 100644 index 0000000000..1518c40bb1 --- /dev/null +++ b/Tests/CodexBarTests/CopilotBudgetCookieRoutingTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import CodexBarCore + +struct CopilotBudgetCookieRoutingTests { + @Test + func `auto budget cookies ignore stale manual header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .auto, + manualBudgetCookieHeader: "user_session=stale") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } + + @Test + func `manual budget cookies use trimmed manual header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: " user_session=manual ") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == "user_session=manual") + } + + @Test + func `manual budget cookies require non-empty header`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: " ") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } + + @Test + func `invalid manual budget cookies do not fall back to browser import`() { + let settings = ProviderSettingsSnapshot.CopilotProviderSettings( + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "Cookie:") + + #expect(CopilotAPIFetchStrategy.budgetCookieHeaderOverride(from: settings) == nil) + } +} diff --git a/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift new file mode 100644 index 0000000000..2bdba637a1 --- /dev/null +++ b/Tests/CodexBarTests/CopilotBudgetWebFetcherTests.swift @@ -0,0 +1,721 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CopilotBudgetWebFetcherTests { + @Test + func `maps positive copilot budgets to extra rate windows`() { + let budgets: [CopilotBudgetWebFetcher.Budget] = [ + .init( + id: "product-budget", + budgetProductSkus: ["copilot"], + budgetAmount: 100, + currentAmount: 15), + .init( + id: "agent-budget", + budgetProductSkus: ["copilot_agent_premium_request"], + budgetAmount: 20, + currentAmount: 5), + .init( + id: "zero-budget", + budgetProductSkus: ["spark_premium_request"], + budgetAmount: 0, + currentAmount: 0), + ] + + let windows = CopilotBudgetWebFetcher.extraRateWindows( + from: budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)) + + #expect(windows.map(\.id) == ["copilot-budget-product-budget", "copilot-budget-agent-budget"]) + #expect(windows.map(\.title) == ["Budget - Copilot", "Budget - Copilot Agent Premium Requests"]) + #expect(windows[0].window.usedPercent == 15) + #expect(windows[1].window.usedPercent == 25) + #expect(windows.allSatisfy { $0.window.resetsAt != nil }) + } + + @Test + func `decodes github web budget response shape`() throws { + let data = Data(""" + { + "payload": { + "budgets": [ + { + "uuid": "budget-1", + "targetName": "Example", + "pricingTargetType": "BundlePricing", + "pricingTargetId": "premium_requests", + "targetAmount": 30.0, + "currentAmount": 0.0 + } + ], + "has_next_page": false + } + } + """.utf8) + + let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data) + let budget = try #require(response.budgets.first) + #expect(response.hasNextPage == false) + #expect(budget.id == "budget-1") + #expect(budget.budgetEntityName == "Example") + #expect(budget.budgetAmount == 30) + #expect(budget.currentAmount == 0) + + let windows = CopilotBudgetWebFetcher.extraRateWindows( + from: response.budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)) + #expect(windows.map(\.title) == ["Budget - All Premium Request SKUs"]) + #expect(windows.first?.window.usedPercent == 0) + } + + @Test + func `ignores malformed embedded minus amounts`() throws { + let data = Data(""" + { + "budgets": [ + { + "uuid": "budget-1", + "pricingTargetId": "premium_requests", + "targetAmount": "1-5", + "currentAmount": "$5.00" + }, + { + "uuid": "budget-2", + "pricingTargetId": "premium_requests", + "targetAmount": "-$15.00", + "currentAmount": "$5.00" + } + ] + } + """.utf8) + + let response = try JSONDecoder().decode(CopilotBudgetWebFetcher.BudgetResponse.self, from: data) + + #expect(response.budgets.map(\.budgetAmount) == [0, -15]) + #expect(CopilotBudgetWebFetcher.extraRateWindows( + from: response.budgets, + now: Date(timeIntervalSince1970: 1_780_358_400)).isEmpty) + } + + @Test + func `normalizes documented copilot billing names`() { + #expect(CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot") == "copilot") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Premium Request") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot Agent Premium Request") == + "copilot_agent_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Spark Premium Request") == + "spark_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Premium requests") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Bundled premium request budget") == + "copilot_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("Copilot cloud agent premium requests") == + "copilot_agent_premium_request") + #expect( + CopilotBudgetWebFetcher.normalizedBillingIdentifier("coding_agent_premium_request") == + "copilot_agent_premium_request") + } + + @Test + func `extracts github fetch nonce from html`() { + let html = #""# + #expect(CopilotBudgetWebFetcher.extractFetchNonce(from: html) == "v2:abc-123") + } + + @Test + func `extracts github web identity from html`() throws { + let html = """ + + + """ + + let identity = try #require(CopilotBudgetWebFetcher.extractGitHubWebIdentity(from: html)) + + #expect(identity.id == "123") + #expect(identity.login == "octocat") + #expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:123")) + #expect(CopilotBudgetWebFetcher.webIdentity(identity, matches: "OctoCat")) + #expect(!CopilotBudgetWebFetcher.webIdentity(identity, matches: "github:user:456")) + } + + @Test + func `missing github web identity with expected account maps to unknown account mismatch`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Missing identity should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=missing-identity", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected account mismatch") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .accountMismatch(expected: "github:user:123", actual: nil)) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `invalid github budget page html encoding maps to invalid response`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Invalid HTML encoding should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data([0xC3, 0x28]), response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=invalid-html", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected invalid response") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .invalidResponse) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `manual budget cookie for different github account is ignored before budget request`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Mismatched cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=other", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected account mismatch") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .accountMismatch(expected: "github:user:123", actual: "otheruser")) + } + + #expect(await transport.requests().count == 1) + } + + @Test + func `manual budget cookie with matching github account appends budget windows`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return ( + Data(""" + { + "budgets": [ + { + "uuid": "budget-1", + "pricingTargetId": "premium_requests", + "targetAmount": 100.0, + "currentAmount": 40.0 + } + ], + "has_next_page": false + } + """.utf8), + response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + cookieHeaderOverride: "user_session=matching", + expectedGitHubAccountIdentifier: "github:user:123", + transport: transport, + now: { Date(timeIntervalSince1970: 1_780_358_400) }) + + let windows = try await fetcher.fetchBudgetWindows() + + #expect(windows.map(\.id) == ["copilot-budget-budget-1"]) + #expect(windows.first?.window.usedPercent == 40) + #expect(await transport.requests().count == 2) + } + + @Test + func `mismatched manual budget cookie leaves normal copilot usage unchanged`() async { + let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self) + } + CopilotBudgetBindingStubURLProtocol.reset() + } + CopilotBudgetBindingStubURLProtocol.reset() + CopilotBudgetBindingStubURLProtocol.handler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + if url.host == "api.github.com", url.path == "/copilot_internal/user" { + return Self.stubResponse( + url: url, + data: Data(""" + { + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium" + } + }, + "copilot_plan": "pro" + } + """.utf8)) + } + if url.host == "api.github.com", url.path == "/user" { + return Self.stubResponse( + url: url, + data: Data(#"{"id":123,"login":"expecteduser"}"#.utf8)) + } + if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil { + return Self.stubResponse( + url: url, + data: Data(""" + + + + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404) + } + let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot) + let settings = ProviderSettingsSnapshot.make(copilot: .init( + apiToken: "selected-token", + selectedAccountExternalIdentifier: "github:user:123", + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "user_session=other")) + let context = Self.makeFetchContext(settings: settings) + + let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot) + + guard case let .success(result) = outcome.result else { + Issue.record("Expected Copilot usage fetch to succeed") + return + } + #expect(result.usage.primary?.usedPercent == 20) + #expect(result.usage.extraRateWindows == nil) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.query?.contains("page=") == true + } == false) + } + + @Test + func `stale selected account identifier is ignored for budget cookie binding`() async { + let registered = URLProtocol.registerClass(CopilotBudgetBindingStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(CopilotBudgetBindingStubURLProtocol.self) + } + CopilotBudgetBindingStubURLProtocol.reset() + } + CopilotBudgetBindingStubURLProtocol.reset() + CopilotBudgetBindingStubURLProtocol.handler = { request in + guard let url = request.url else { + throw URLError(.badURL) + } + if url.host == "api.github.com", url.path == "/copilot_internal/user" { + return Self.stubResponse( + url: url, + data: Data(""" + { + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium" + } + }, + "copilot_plan": "pro" + } + """.utf8)) + } + if url.host == "api.github.com", url.path == "/user" { + return Self.stubResponse( + url: url, + data: Data(#"{"id":999,"login":"newuser"}"#.utf8)) + } + if url.host == "github.com", url.path == "/settings/billing/budgets", url.query == nil { + return Self.stubResponse( + url: url, + data: Data(""" + + + + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.stubResponse(url: url, data: Data("{}".utf8), statusCode: 404) + } + let descriptor = ProviderDescriptorRegistry.descriptor(for: .copilot) + let settings = ProviderSettingsSnapshot.make(copilot: .init( + apiToken: "new-selected-token", + selectedAccountExternalIdentifier: "github:user:123", + budgetExtrasEnabled: true, + budgetCookieSource: .manual, + manualBudgetCookieHeader: "user_session=old-browser-account")) + let context = Self.makeFetchContext(settings: settings) + + let outcome = await descriptor.fetchPlan.fetchOutcome(context: context, provider: .copilot) + + guard case let .success(result) = outcome.result else { + Issue.record("Expected Copilot usage fetch to succeed") + return + } + #expect(result.usage.primary?.usedPercent == 20) + #expect(result.usage.extraRateWindows == nil) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.path == "/user" + }) + #expect(CopilotBudgetBindingStubURLProtocol.requests().contains { + $0.url?.query?.contains("page=") == true + } == false) + } + + @Test + func `invalid github budget JSON maps to invalid response`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return (Data("{".utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + transport: transport, + now: { Date(timeIntervalSince1970: 1_780_358_400) }) + + do { + _ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc") + Issue.record("Expected invalidResponse") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .invalidResponse) + } + } + + @Test + func `cached cookie non auth errors do not fall back to browser import`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 500, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + return (Data("{}".utf8), response) + } + let fetcher = CopilotBudgetWebFetcher(transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected badStatus") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .badStatus(500)) + } + + #expect(await transport.requests().count == 2) + #expect(CookieHeaderCache.load(provider: .copilot)?.cookieHeader == "user_session=cached") + } + + @Test + func `cached cookie account mismatch clears cache before browser fallback`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Mismatched cached cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return ( + Data(""" + + + + """.utf8), + response) + } + let fetcher = CopilotBudgetWebFetcher( + expectedGitHubAccountIdentifier: "github:user:123", + browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0), + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected browser fallback to exhaust without a session") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .noSessionCookie) + } + + #expect(await transport.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .copilot) == nil) + } + + @Test + func `cached cookie missing identity clears cache before browser fallback`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store(provider: .copilot, cookieHeader: "user_session=cached", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .copilot) } + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + Issue.record("Unverifiable cached cookie should not reach budget JSON endpoint") + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher( + expectedGitHubAccountIdentifier: "github:user:123", + browserDetection: BrowserDetection(homeDirectory: temp.path, cacheTTL: 0), + transport: transport) + + do { + _ = try await fetcher.fetchBudgetWindows() + Issue.record("Expected browser fallback to exhaust without a session") + } catch let error as CopilotBudgetWebFetcher.Error { + #expect(error == .noSessionCookie) + } + + #expect(await transport.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .copilot) == nil) + } + + @Test + func `budget page request omits content type on get`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil) + else { + throw URLError(.badServerResponse) + } + if request.url?.query?.contains("page=") == true { + return (Data(#"{"budgets":[],"has_next_page":false}"#.utf8), response) + } + return (Data(#""#.utf8), response) + } + let fetcher = CopilotBudgetWebFetcher(transport: transport) + + _ = try await fetcher.fetchBudgetWindows(cookieHeader: "user_session=abc") + + let pageRequest = try #require(await transport.requests().first { $0.url?.query?.contains("page=") == true }) + #expect(pageRequest.value(forHTTPHeaderField: "Content-Type") == nil) + } + + private static func makeFetchContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + private static func stubResponse( + url: URL, + data: Data, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (data, response) + } +} + +final class CopilotBudgetBindingStubURLProtocol: URLProtocol { + private static let lock = NSLock() + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (Data, URLResponse))? + private nonisolated(unsafe) static var recordedRequests: [URLRequest] = [] + + static func reset() { + self.lock.lock() + defer { self.lock.unlock() } + self.handler = nil + self.recordedRequests = [] + } + + static func requests() -> [URLRequest] { + self.lock.lock() + defer { self.lock.unlock() } + return self.recordedRequests + } + + override static func canInit(with request: URLRequest) -> Bool { + guard self.hasHandler else { return false } + guard request.url?.scheme == "https" else { return false } + switch (request.url?.host, request.url?.path) { + case ("api.github.com", "/copilot_internal/user"), + ("api.github.com", "/user"), + ("github.com", "/settings/billing/budgets"): + return true + default: + return false + } + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.lock() + Self.recordedRequests.append(self.request) + let handler = Self.handler + Self.lock.unlock() + + guard let handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (data, response) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +extension CopilotBudgetBindingStubURLProtocol { + fileprivate static var hasHandler: Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.handler != nil + } +} diff --git a/Tests/CodexBarTests/CopilotMenuCardModelTests.swift b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift new file mode 100644 index 0000000000..5c1a238928 --- /dev/null +++ b/Tests/CodexBarTests/CopilotMenuCardModelTests.swift @@ -0,0 +1,44 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct CopilotMenuCardModelTests { + @Test + func `hides copilot budget bars when budget extras are disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow(usedPercent: 65, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + let model = UsageMenuCardView.Model.make(.init( + provider: .copilot, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Premium", "Chat"]) + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 29c9178ec8..09aa338170 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -153,6 +153,33 @@ struct ProviderSettingsDescriptorTests { #expect(fields.contains(where: { $0.id == "kilo-api-key" })) } + @Test + func `copilot budget secondary picker appears before cookie picker`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-pickers") + fixture.settings.copilotBudgetExtrasEnabled = true + let context = fixture.settingsContext(provider: .copilot) + + let pickers = CopilotProviderImplementation().settingsPickers(context: context) + + #expect(pickers.map(\.id) == ["copilot-icon-secondary-window", "copilot-budget-cookie-source"]) + #expect(pickers.first?.title == "Menu bar secondary metric") + } + + @Test + func `copilot manual cookie field is labelled and refreshable`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-field") + fixture.settings.copilotBudgetExtrasEnabled = true + fixture.settings.copilotBudgetCookieSource = .manual + let context = fixture.settingsContext(provider: .copilot) + + let fields = CopilotProviderImplementation().settingsFields(context: context) + let field = try #require(fields.first { $0.id == "copilot-budget-cookie-header" }) + + #expect(field.title == "Manual GitHub Cookie header") + #expect(field.subtitle.contains("Treat this value like a password")) + #expect(field.actions.map(\.id) == ["refresh-copilot-budget-cookie"]) + } + @Test func `deepgram exposes api key and project id fields`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 7c01c3e275..51a0cca053 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -51,6 +51,35 @@ struct ProvidersPaneCoverageTests { == [.deepseek]) } + @Test + func `copilot menu card preview follows budget extras setting`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-copilot-budget-preview") + let store = Self.makeUsageStore(settings: settings) + let budgetTitle = "Budget - Copilot Agent Premium Requests" + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: budgetTitle, + window: RateWindow( + usedPercent: 65, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()), + provider: .copilot) + let pane = ProvidersPane(settings: settings, store: store) + + #expect(!pane._test_menuCardModel(for: .copilot).metrics.map(\.title).contains(budgetTitle)) + + settings.copilotBudgetExtrasEnabled = true + #expect(pane._test_menuCardModel(for: .copilot).metrics.map(\.title).contains(budgetTitle)) + } + @Test func `open router menu bar metric picker shows only automatic and primary`() { Self.withEnglishLocalization { diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index fc68cba5b6..2e860c534f 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -99,6 +99,24 @@ struct SettingsStoreCoverageTests { #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=global") } + @Test + func `copilot budget extras default off and persist in provider snapshot`() throws { + let suite = "SettingsStoreCoverageTests-copilot-budget-extras" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.copilotBudgetExtrasEnabled == false) + #expect(initial.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled == false) + + initial.copilotBudgetExtrasEnabled = true + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.copilotBudgetExtrasEnabled) + #expect(reloaded.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled) + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout" @@ -195,6 +213,21 @@ struct SettingsStoreCoverageTests { #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) } + @Test + func `copilot settings snapshot carries selected account identifier`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "token-1", + externalIdentifier: "github:user:123") + + let snapshot = settings.copilotSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.apiToken == "token-1") + #expect(snapshot.selectedAccountExternalIdentifier == "github:user:123") + } + @Test func `copilot enterprise host persists in provider config`() throws { let suite = "SettingsStoreCoverageTests-copilot-enterprise-host" diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 3428b2d0d3..e8ea3d8076 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -127,6 +127,41 @@ struct UsageStoreCoverageTests { #expect(store.sourceLabel(for: .kilo) == "api") } + @Test + func `clearing copilot budget extras syncs reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: [Self.makeCopilotBudgetWindow()]) + let resetBaseline = Self.makeCopilotSnapshot(usedPercent: 10, extraRateWindows: nil) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 20) + } + + @Test + func `clearing copilot budget extras also clears stale reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-reset-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: nil) + let resetBaseline = Self.makeCopilotSnapshot( + usedPercent: 10, + extraRateWindows: [Self.makeCopilotBudgetWindow()]) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.snapshot(for: .copilot)?.primary?.usedPercent == 20) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 10) + } + @Test func `permission prompt errors are detected for notifications`() { let errors: [LocalizedTestError] = [ @@ -707,6 +742,24 @@ struct UsageStoreCoverageTests { .replacingOccurrences(of: "/", with: "_") } + private static func makeCopilotSnapshot( + usedPercent: Double, + extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + extraRateWindows: extraRateWindows, + updatedAt: Date(timeIntervalSince1970: 1_780_358_400)) + } + + private static func makeCopilotBudgetWindow() -> NamedRateWindow { + NamedRateWindow( + id: "copilot-budget-test", + title: "Budget - Copilot", + window: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + } + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { let metadata = ProviderRegistry.shared.metadata for provider in UsageProvider.allCases { diff --git a/docs/copilot.md b/docs/copilot.md index 23f19a016e..9643cc234f 100644 --- a/docs/copilot.md +++ b/docs/copilot.md @@ -1,5 +1,5 @@ --- -summary: "Copilot provider data sources: GitHub device flow + Copilot internal usage API." +summary: "Copilot provider data sources: GitHub device flow, Copilot internal usage API, and optional GitHub web budgets." read_when: - Debugging Copilot login or usage parsing - Updating GitHub OAuth device flow behavior @@ -7,7 +7,7 @@ read_when: # Copilot provider -Copilot uses GitHub OAuth device flow and the Copilot internal usage API. No browser cookies. +Copilot uses GitHub OAuth device flow and the Copilot internal usage API for primary usage. Optional budget extras use GitHub web cookies only when enabled. ## Data sources + fallback order @@ -23,7 +23,7 @@ Copilot uses GitHub OAuth device flow and the Copilot internal usage API. No bro - Scope: `read:user`. - Token stored in config: - `~/.codexbar/config.json` → `providers[].apiKey` for `copilot` - - token accounts use `providers[].tokenAccounts` + - token accounts use `providers[].tokenAccounts` 2) **Usage fetch** - `GET https://api.github.com/copilot_internal/user` @@ -36,9 +36,29 @@ Copilot uses GitHub OAuth device flow and the Copilot internal usage API. No bro - `User-Agent: GitHubCopilotChat/0.26.7` - `X-Github-Api-Version: 2025-04-01` +3) **Budget fetch** (optional GitHub web endpoint, best-effort) + - Disabled by default. The Copilot provider's "Budget extras" setting must be enabled before CodexBar imports + github.com cookies or renders budget bars. + - CodexBar asks the logged-in GitHub web endpoint for customer-scope budgets: + - `GET https://github.com/settings/billing/budgets?page=&page_size=10&scope=customer` + - Headers: + - `Cookie: ` + - `Accept: application/json` + - `X-Requested-With: XMLHttpRequest` + - `GitHub-Verified-Fetch: true` + - `X-Fetch-Nonce: ` + - CodexBar first tries to read a fresh nonce from `https://github.com/settings/billing/budgets`, then calls the JSON + endpoint. If GitHub rejects the web request, CodexBar keeps the normal Copilot quota bars and omits budget bars. + - This is intentionally not the public GitHub REST billing API. The REST API did not expose the personal budget list + for the tested individual account. + ## Snapshot mapping - Primary: `quotaSnapshots.premiumInteractions` percent remaining → used percent. - Secondary: `quotaSnapshots.chat` percent remaining → used percent. +- Extra: positive Copilot billing budgets from the GitHub web endpoint → `extraRateWindows`, only when "Budget extras" + is enabled. + - Product budget: `copilot` + - SKU budgets: `copilot_premium_request`, `copilot_agent_premium_request`, `spark_premium_request` - Reset dates are not provided by the API. - Plan label from `copilotPlan`.