From a1f058a7238b888d7df4069d5e27945d68eb1c85 Mon Sep 17 00:00:00 2001 From: Zachary Elbode Date: Sat, 18 Apr 2026 10:40:39 -0700 Subject: [PATCH 1/6] Add Claude Design and Routines usage bars --- Sources/CodexBar/MenuCardView.swift | 21 +++ .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 91 +++++++++++-- .../Claude/ClaudeProviderDescriptor.swift | 1 + .../Providers/Claude/ClaudeUsageFetcher.swift | 63 ++++++++- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 127 +++++++++++++++++- Sources/CodexBarCore/UsageFetcher.swift | 19 +++ Tests/CodexBarTests/ClaudeOAuthTests.swift | 45 +++++++ Tests/CodexBarTests/ClaudeUsageTests.swift | 50 ++++++- Tests/CodexBarTests/MenuCardModelTests.swift | 65 +++++++++ 9 files changed, 463 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 6356c51a6c..9b6b7ced3d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -965,6 +965,27 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, zaiTimeDetail: zaiTimeDetail)) } + if let extraRateWindows = snapshot.extraRateWindows { + metrics.append(contentsOf: extraRateWindows.map { namedWindow in + Metric( + id: namedWindow.id, + title: namedWindow.title, + percent: Self.clamped( + input.usageBarsShowUsed + ? namedWindow.window.usedPercent + : namedWindow.window.remainingPercent), + percentStyle: percentStyle, + resetText: Self.resetText( + for: namedWindow.window, + style: input.resetTimeDisplayStyle, + now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + }) + } if input.provider == .kilo, metrics.contains(where: { $0.id == "primary" }), metrics.contains(where: { $0.id == "secondary" }) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 22003fb237..b3c44ec21e 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -114,17 +114,92 @@ struct OAuthUsageResponse: Decodable { let sevenDayOAuthApps: OAuthUsageWindow? let sevenDayOpus: OAuthUsageWindow? let sevenDaySonnet: OAuthUsageWindow? + let sevenDayDesign: OAuthUsageWindow? + let sevenDayRoutines: OAuthUsageWindow? + let sevenDayDesignSourceKey: String? + let sevenDayRoutinesSourceKey: String? let iguanaNecktie: OAuthUsageWindow? let extraUsage: OAuthExtraUsage? - enum CodingKeys: String, CodingKey { - case fiveHour = "five_hour" - case sevenDay = "seven_day" - case sevenDayOAuthApps = "seven_day_oauth_apps" - case sevenDayOpus = "seven_day_opus" - case sevenDaySonnet = "seven_day_sonnet" - case iguanaNecktie = "iguana_necktie" - case extraUsage = "extra_usage" + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.fiveHour = Self.decodeWindow(in: container, keys: ["five_hour"]) + self.sevenDay = Self.decodeWindow(in: container, keys: ["seven_day"]) + self.sevenDayOAuthApps = Self.decodeWindow(in: container, keys: ["seven_day_oauth_apps"]) + self.sevenDayOpus = Self.decodeWindow(in: container, keys: ["seven_day_opus"]) + self.sevenDaySonnet = Self.decodeWindow(in: container, keys: ["seven_day_sonnet"]) + let design = Self.decodeWindowWithSource(in: container, keys: [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ]) + self.sevenDayDesign = design.window + self.sevenDayDesignSourceKey = design.sourceKey + let routines = Self.decodeWindowWithSource(in: container, keys: [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ]) + self.sevenDayRoutines = routines.window + self.sevenDayRoutinesSourceKey = routines.sourceKey + self.iguanaNecktie = Self.decodeWindow(in: container, keys: ["iguana_necktie"]) + self.extraUsage = Self.decodeValue(in: container, keys: ["extra_usage"]) + } + + private static func decodeWindow( + in container: KeyedDecodingContainer, + keys: [String]) -> OAuthUsageWindow? + { + Self.decodeValue(in: container, keys: keys) + } + + private static func decodeWindowWithSource( + in container: KeyedDecodingContainer, + keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?) + { + for keyName in keys { + guard let key = DynamicCodingKey(stringValue: keyName) else { continue } + if container.contains(key) { + let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) + return (value ?? nil, keyName) + } + } + return (nil, nil) + } + + private static func decodeValue( + in container: KeyedDecodingContainer, + keys: [String]) -> T? + { + for keyName in keys { + guard let key = DynamicCodingKey(stringValue: keyName) else { continue } + if let value = try? container.decodeIfPresent(T.self, forKey: key) { + return value + } + } + return nil + } +} + +private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + return nil } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 50a54a301b..20db613124 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -303,6 +303,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { primary: usage.primary, secondary: usage.secondary, tertiary: usage.opus, + extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows, providerCost: usage.providerCost, updatedAt: usage.updatedAt, identity: identity) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index dd4848a537..5f4690e41a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -10,6 +10,7 @@ public struct ClaudeUsageSnapshot: Sendable { public let primary: RateWindow public let secondary: RateWindow? public let opus: RateWindow? + public let extraRateWindows: [NamedRateWindow] public let providerCost: ProviderCostSnapshot? public let updatedAt: Date public let accountEmail: String? @@ -21,6 +22,7 @@ public struct ClaudeUsageSnapshot: Sendable { primary: RateWindow, secondary: RateWindow?, opus: RateWindow?, + extraRateWindows: [NamedRateWindow] = [], providerCost: ProviderCostSnapshot? = nil, updatedAt: Date, accountEmail: String?, @@ -31,6 +33,7 @@ public struct ClaudeUsageSnapshot: Sendable { self.primary = primary self.secondary = secondary self.opus = opus + self.extraRateWindows = extraRateWindows self.providerCost = providerCost self.updatedAt = updatedAt self.accountEmail = accountEmail @@ -841,6 +844,7 @@ extension ClaudeUsageFetcher { let modelSpecific = makeWindow( usage.sevenDaySonnet ?? usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) + let extraRateWindows = Self.oauthExtraRateWindows(from: usage) let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier) let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod) @@ -849,6 +853,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: weekly, opus: modelSpecific, + extraRateWindows: extraRateWindows, providerCost: providerCost, updatedAt: Date(), accountEmail: nil, @@ -887,6 +892,52 @@ extension ClaudeUsageFetcher { (used: used / 100.0, limit: limit / 100.0) } + private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { + let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [ + ( + id: "claude-design", + title: "Claude Design", + window: usage.sevenDayDesign, + sourceKey: usage.sevenDayDesignSourceKey + ), + ( + id: "claude-routines", + title: "Claude Routines", + window: usage.sevenDayRoutines, + sourceKey: usage.sevenDayRoutinesSourceKey + ), + ] + if let designKey = usage.sevenDayDesignSourceKey { + Self.log.debug("Claude OAuth extra usage key matched: design=\(designKey)") + } + if let routinesKey = usage.sevenDayRoutinesSourceKey { + Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)") + } + return definitions.compactMap { definition in + let utilization: Double + let resetDate: Date? + if let window = definition.window, let parsedUtilization = window.utilization { + utilization = parsedUtilization + resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt) + } else if definition.sourceKey != nil { + // Keep product bars visible when the API returns a known key with null payload. + utilization = 0 + resetDate = nil + } else { + return nil + } + let resetDescription = resetDate.map(Self.formatResetDate) + return NamedRateWindow( + id: definition.id, + title: definition.title, + window: RateWindow( + usedPercent: utilization, + windowMinutes: Self.weeklyWindowMinutes, + resetsAt: resetDate, + resetDescription: resetDescription)) + } + } + // MARK: - Web API path (uses browser cookies) private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot { @@ -927,6 +978,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: secondary, opus: opus, + extraRateWindows: webData.extraRateWindows, providerCost: webData.extraUsageCost, updatedAt: Date(), accountEmail: webData.accountEmail, @@ -986,6 +1038,7 @@ extension ClaudeUsageFetcher { primary: primary, secondary: weekly, opus: opus, + extraRateWindows: [], providerCost: nil, updatedAt: Date(), accountEmail: snap.accountEmail, @@ -1009,13 +1062,17 @@ extension ClaudeUsageFetcher { Self.log.debug(msg) } } - // Only merge cost extras; keep identity fields from the primary data source. - if snapshot.providerCost == nil, let extra = webData.extraUsageCost { + // Only merge usage/cost extras; keep identity fields from the primary data source. + let mergedExtraRateWindows = snapshot.extraRateWindows.isEmpty ? webData.extraRateWindows : snapshot + .extraRateWindows + let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost + if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return ClaudeUsageSnapshot( primary: snapshot.primary, secondary: snapshot.secondary, opus: snapshot.opus, - providerCost: extra, + extraRateWindows: mergedExtraRateWindows, + providerCost: mergedProviderCost, updatedAt: snapshot.updatedAt, accountEmail: snapshot.accountEmail, accountOrganization: snapshot.accountOrganization, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index c350d30c49..11b1961f93 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -84,6 +84,7 @@ public enum ClaudeWebAPIFetcher { public let weeklyPercentUsed: Double? public let weeklyResetsAt: Date? public let opusPercentUsed: Double? + public let extraRateWindows: [NamedRateWindow] public let extraUsageCost: ProviderCostSnapshot? public let accountOrganization: String? public let accountEmail: String? @@ -95,6 +96,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: Double?, weeklyResetsAt: Date?, opusPercentUsed: Double?, + extraRateWindows: [NamedRateWindow], extraUsageCost: ProviderCostSnapshot?, accountOrganization: String?, accountEmail: String?, @@ -105,6 +107,7 @@ public enum ClaudeWebAPIFetcher { self.weeklyPercentUsed = weeklyPercentUsed self.weeklyResetsAt = weeklyResetsAt self.opusPercentUsed = opusPercentUsed + self.extraRateWindows = extraRateWindows self.extraUsageCost = extraUsageCost self.accountOrganization = accountOrganization self.accountEmail = accountEmail @@ -195,6 +198,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: extra, accountOrganization: usage.accountOrganization, accountEmail: usage.accountEmail, @@ -207,6 +211,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: usage.extraUsageCost, accountOrganization: usage.accountOrganization, accountEmail: account.email, @@ -219,6 +224,7 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, opusPercentUsed: usage.opusPercentUsed, + extraRateWindows: usage.extraRateWindows, extraUsageCost: usage.extraUsageCost, accountOrganization: name, accountEmail: usage.accountEmail, @@ -439,7 +445,7 @@ public enum ClaudeWebAPIFetcher { switch httpResponse.statusCode { case 200: - return try self.parseUsageResponse(data) + return try self.parseUsageResponse(data, logger: logger) case 401, 403: throw FetchError.unauthorized default: @@ -447,7 +453,7 @@ public enum ClaudeWebAPIFetcher { } } - private static func parseUsageResponse(_ data: Data) throws -> WebUsageData { + private static func parseUsageResponse(_ data: Data, logger: ((String) -> Void)? = nil) throws -> WebUsageData { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw FetchError.invalidResponse } @@ -480,12 +486,19 @@ public enum ClaudeWebAPIFetcher { } } - // Parse seven_day_opus (Opus-specific weekly) usage + // Parse seven_day_sonnet (preferred) / seven_day_opus usage var opusPercent: Double? - if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] { - if let utilization = sevenDayOpus["utilization"] as? Int { - opusPercent = Double(utilization) - } + if let sevenDaySonnet = json["seven_day_sonnet"] as? [String: Any] { + opusPercent = Self.percentValue(from: sevenDaySonnet["utilization"]) + } else if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] { + opusPercent = Self.percentValue(from: sevenDayOpus["utilization"]) + } + let extraRateParse = Self.parseExtraRateWindows(from: json) + if let sourceKey = extraRateParse.sourceKeys["claude-design"] { + logger?("Usage API extra window key matched: design=\(sourceKey)") + } + if let sourceKey = extraRateParse.sourceKeys["claude-routines"] { + logger?("Usage API extra window key matched: routines=\(sourceKey)") } return WebUsageData( @@ -494,12 +507,112 @@ public enum ClaudeWebAPIFetcher { weeklyPercentUsed: weeklyPercent, weeklyResetsAt: weeklyResets, opusPercentUsed: opusPercent, + extraRateWindows: extraRateParse.windows, extraUsageCost: nil, accountOrganization: nil, accountEmail: nil, loginMethod: nil) } + private static func parseExtraRateWindows( + from json: [String: Any]) -> (windows: [NamedRateWindow], sourceKeys: [String: String]) + { + let definitions: [(id: String, title: String, keys: [String])] = [ + ( + id: "claude-design", + title: "Claude Design", + keys: [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ] + ), + ( + id: "claude-routines", + title: "Claude Routines", + keys: [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ] + ), + ] + + var windows: [NamedRateWindow] = [] + var sourceKeys: [String: String] = [:] + windows.reserveCapacity(definitions.count) + + for definition in definitions { + if let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) { + let rawWindow = foundWindow.window + guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue } + let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date) + windows.append(NamedRateWindow( + id: definition.id, + title: definition.title, + window: RateWindow( + usedPercent: utilization, + windowMinutes: 7 * 24 * 60, + resetsAt: resetsAt, + resetDescription: nil))) + sourceKeys[definition.id] = foundWindow.sourceKey + continue + } + + // Some accounts expose the key with null payloads (for example `seven_day_cowork: null`). + // Preserve the bar in that case with a 0% window so the product section remains visible. + if let key = Self.firstUsageKey(in: json, keys: definition.keys) { + windows.append(NamedRateWindow( + id: definition.id, + title: definition.title, + window: RateWindow( + usedPercent: 0, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil))) + sourceKeys[definition.id] = key + } + } + return (windows, sourceKeys) + } + + private static func firstUsageWindow( + in json: [String: Any], + keys: [String]) -> (window: [String: Any], sourceKey: String)? + { + for key in keys { + if let window = json[key] as? [String: Any] { + return (window, key) + } + } + return nil + } + + private static func firstUsageKey(in json: [String: Any], keys: [String]) -> String? { + for key in keys where json.keys.contains(key) { + return key + } + return nil + } + + private static func percentValue(from value: Any?) -> Double? { + if let intValue = value as? Int { + return Double(intValue) + } + if let doubleValue = value as? Double { + return doubleValue + } + return nil + } + // MARK: - Extra usage cost (Claude "Extra") private struct OverageSpendLimitResponse: Decodable { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 24d413717c..22ebd19c70 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -28,6 +28,18 @@ public struct RateWindow: Codable, Equatable, Sendable { } } +public struct NamedRateWindow: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let window: RateWindow + + public init(id: String, title: String, window: RateWindow) { + self.id = id + self.title = title + self.window = window + } +} + public struct ProviderIdentitySnapshot: Codable, Sendable { public let providerID: UsageProvider? public let accountEmail: String? @@ -60,6 +72,7 @@ public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? @@ -72,6 +85,7 @@ public struct UsageSnapshot: Codable, Sendable { case primary case secondary case tertiary + case extraRateWindows case providerCost case openRouterUsage case updatedAt @@ -85,6 +99,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + extraRateWindows: [NamedRateWindow]? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, @@ -96,6 +111,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.extraRateWindows = extraRateWindows self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage @@ -110,6 +126,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) + self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time @@ -140,6 +157,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) + try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encode(self.updatedAt, forKey: .updatedAt) @@ -224,6 +242,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, + extraRateWindows: self.extraRateWindows, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index cb3541bc15..815f0ae206 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -81,6 +81,51 @@ struct ClaudeOAuthTests { #expect(snap.loginMethod == "Claude Pro") } + @Test + func `maps O auth design and routines usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_design": { "utilization": 44, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": { "utilization": 18, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.count == 2) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.title == "Claude Design") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 44) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Claude Routines") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18) + } + + @Test + func `maps O auth omelette and cowork usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_cowork": { "utilization": 9, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.count == 2) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 29) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 9) + } + + @Test + func `maps O auth null cowork as zero routines window`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_cowork": null + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + } + @Test func `maps O auth extra usage`() throws { // OAuth API returns values in cents (minor units), same as Web API. diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 6e400fd6b3..702ba424ce 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -680,7 +680,9 @@ struct ClaudeUsageTests { { "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, "seven_day": { "utilization": 4, "resets_at": "2025-12-29T23:00:00.000Z" }, - "seven_day_opus": { "utilization": 1 } + "seven_day_opus": { "utilization": 1 }, + "seven_day_design": { "utilization": 31, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_routines": { "utilization": 7, "resets_at": "2025-12-31T23:00:00.000Z" } } """ let data = Data(json.utf8) @@ -688,10 +690,56 @@ struct ClaudeUsageTests { #expect(parsed.sessionPercentUsed == 9) #expect(parsed.weeklyPercentUsed == 4) #expect(parsed.opusPercentUsed == 1) + #expect(parsed.extraRateWindows.count == 2) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 31) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 7) #expect(parsed.sessionResetsAt != nil) #expect(parsed.weeklyResetsAt != nil) } + @Test + func `parses claude web API sonnet usage response`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.opusPercentUsed == 6) + } + + @Test + func `parses claude web API omelette and cowork usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.count == 2) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 26) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) + } + + @Test + func `parses claude web API cowork null as zero routines window`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": null + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + } + @Test func `parses claude web API usage response when weekly missing`() throws { let json = """ diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index b4e3b7522c..e747e14068 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -124,6 +124,71 @@ struct MenuCardModelTests { #expect(model.planText == "Max") } + @Test + func `claude model includes design and routines bars when present`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 8, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "claude-design", + title: "Claude Design", + window: RateWindow( + usedPercent: 31, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(8200), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Claude Routines", + window: RateWindow( + usedPercent: 7, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9200), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "plus"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title).contains("Claude Design")) + #expect(model.metrics.map(\.title).contains("Claude Routines")) + } + @Test func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) From acc2ce2f54cfa82e81555eb19a55d70bfd96ee44 Mon Sep 17 00:00:00 2001 From: Zachary Elbode Date: Sat, 18 Apr 2026 12:38:21 -0700 Subject: [PATCH 2/6] Polish Claude labels and OpenAI cookie access errors --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 51 ++++++++++++++++--- .../Providers/Claude/ClaudeUsageFetcher.swift | 4 +- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 4 +- Tests/CodexBarTests/ClaudeOAuthTests.swift | 4 +- Tests/CodexBarTests/MenuCardModelTests.swift | 8 +-- 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 53b6e20160..c22d90865c 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -918,12 +918,29 @@ extension UsageStore { cacheScope: cacheScope, logger: log) case .auto: - result = try await importer.importBestCookies( - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - preferCachedCookieHeader: !force, - cacheScope: cacheScope, - logger: log) + do { + result = try await importer.importBestCookies( + intoAccountEmail: normalizedTarget, + allowAnyAccount: allowAnyAccount, + preferCachedCookieHeader: !force, + cacheScope: cacheScope, + logger: log) + } catch let error as OpenAIDashboardBrowserCookieImporter.ImportError { + let manualHeader = self.settings.codexCookieHeader + if case .browserAccessDenied = error, + let normalizedManual = CookieHeaderNormalizer.normalize(manualHeader) + { + log("Auto browser import blocked; retrying with manual cookie header fallback.") + result = try await importer.importManualCookies( + cookieHeader: normalizedManual, + intoAccountEmail: normalizedTarget, + allowAnyAccount: allowAnyAccount, + cacheScope: cacheScope, + logger: log) + } else { + throw error + } + } case .off: result = OpenAIDashboardBrowserCookieImporter.ImportResult( sourceLabel: "Off", @@ -992,8 +1009,14 @@ extension UsageStore { targetEmail: normalizedTarget) self.failClosedOpenAIDashboardSnapshot() } + case let .browserAccessDenied(details): + self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)") + await MainActor.run { + self.openAIDashboardCookieImportStatus = Self.conciseOpenAICookieAccessDeniedStatus( + details: details) + self.openAIDashboardRequiresLogin = true + } case .noCookiesFound, - .browserAccessDenied, .dashboardStillRequiresLogin, .manualCookieHeaderInvalid: self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)") @@ -1228,4 +1251,18 @@ extension UsageStore { } return "OpenAI cookies are for \(foundLabel), not \(targetLabel)." } + + private static func conciseOpenAICookieAccessDeniedStatus(details: String) -> String { + let lower = details.lowercased() + if lower.contains("safari") { + return [ + "OpenAI cookie import is blocked by macOS privacy for Safari.", + "Enable Full Disk Access for CodexBar, or switch Codex cookie source to Manual.", + ].joined(separator: " ") + } + return [ + "OpenAI cookie import is blocked by browser privacy permissions.", + "Enable cookie/keychain access for CodexBar, or switch Codex cookie source to Manual.", + ].joined(separator: " ") + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 5f4690e41a..9c0460e1c9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -896,13 +896,13 @@ extension ClaudeUsageFetcher { let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [ ( id: "claude-design", - title: "Claude Design", + title: "Designs", window: usage.sevenDayDesign, sourceKey: usage.sevenDayDesignSourceKey ), ( id: "claude-routines", - title: "Claude Routines", + title: "Daily Routines", window: usage.sevenDayRoutines, sourceKey: usage.sevenDayRoutinesSourceKey ), diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 11b1961f93..7524854b10 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -520,7 +520,7 @@ public enum ClaudeWebAPIFetcher { let definitions: [(id: String, title: String, keys: [String])] = [ ( id: "claude-design", - title: "Claude Design", + title: "Designs", keys: [ "seven_day_design", "seven_day_claude_design", @@ -533,7 +533,7 @@ public enum ClaudeWebAPIFetcher { ), ( id: "claude-routines", - title: "Claude Routines", + title: "Daily Routines", keys: [ "seven_day_routines", "seven_day_claude_routines", diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index 815f0ae206..77865c4595 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -92,9 +92,9 @@ struct ClaudeOAuthTests { """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) #expect(snap.extraRateWindows.count == 2) - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.title == "Claude Design") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.title == "Designs") #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 44) - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Claude Routines") + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Daily Routines") #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18) } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index e747e14068..ac7124ec72 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -147,7 +147,7 @@ struct MenuCardModelTests { extraRateWindows: [ NamedRateWindow( id: "claude-design", - title: "Claude Design", + title: "Designs", window: RateWindow( usedPercent: 31, windowMinutes: 10080, @@ -155,7 +155,7 @@ struct MenuCardModelTests { resetDescription: nil)), NamedRateWindow( id: "claude-routines", - title: "Claude Routines", + title: "Daily Routines", window: RateWindow( usedPercent: 7, windowMinutes: 10080, @@ -185,8 +185,8 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.map(\.title).contains("Claude Design")) - #expect(model.metrics.map(\.title).contains("Claude Routines")) + #expect(model.metrics.map(\.title).contains("Designs")) + #expect(model.metrics.map(\.title).contains("Daily Routines")) } @Test From bb10d4ff8a20cbd27ea0dcaca06d659fe18b3382 Mon Sep 17 00:00:00 2001 From: Zachary Elbode Date: Sun, 19 Apr 2026 12:06:59 -0700 Subject: [PATCH 3/6] Prefer populated alias over null in OAuth extra usage decoding Per Codex review on PR #1: decodeWindowWithSource returned on the first matching key in the payload even when its value was null, so a response with seven_day_design: null followed by seven_day_omelette: {...} picked the null alias and rendered a 0% bar. Scan all aliases, prefer a populated one, and fall back to the first null-valued key only to keep the bar visible when the API returns a known key with null payload. --- .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 14 ++++++++++---- Tests/CodexBarTests/ClaudeOAuthTests.swift | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index b3c44ec21e..a594d16a9a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -165,14 +165,20 @@ struct OAuthUsageResponse: Decodable { in container: KeyedDecodingContainer, keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?) { + var firstNullKey: String? for keyName in keys { guard let key = DynamicCodingKey(stringValue: keyName) else { continue } - if container.contains(key) { - let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) - return (value ?? nil, keyName) + guard container.contains(key) else { continue } + if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key), + value != nil + { + return (value, keyName) + } + if firstNullKey == nil { + firstNullKey = keyName } } - return (nil, nil) + return (nil, firstNullKey) } private static func decodeValue( diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index 77865c4595..d62d742075 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -126,6 +126,22 @@ struct ClaudeOAuthTests { #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) } + @Test + func `prefers populated alias over null alias in mixed payload`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, + "seven_day_design": null, + "seven_day_omelette": { "utilization": 37, "resets_at": "2025-12-31T00:00:00.000Z" }, + "seven_day_routines": null, + "seven_day_cowork": { "utilization": 14, "resets_at": "2026-01-01T00:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 37) + #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 14) + } + @Test func `maps O auth extra usage`() throws { // OAuth API returns values in cents (minor units), same as Web API. From 54735936d5d79cbfc31f45d41cb21afa236b99a9 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 23 Apr 2026 01:28:58 +0530 Subject: [PATCH 4/6] Fix Claude usage lint cleanup --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 157 ++++++++++++------ .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 8 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 6 +- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 91 +--------- .../ClaudeWebExtraRateWindowParser.swift | 118 +++++++++++++ Tests/CodexBarTests/ClaudeUsageTests.swift | 50 +----- .../ClaudeWebUsageExtraWindowTests.swift | 48 ++++++ 7 files changed, 277 insertions(+), 201 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift create mode 100644 Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index c22d90865c..7793c4fe7d 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -855,6 +855,101 @@ extension UsageStore { return false } + private struct OpenAIDashboardCookieImportRequest { + let normalizedTarget: String? + let allowAnyAccount: Bool + let cookieSource: ProviderCookieSource + let cacheScope: CookieHeaderCache.Scope? + let force: Bool + } + + private func importOpenAIDashboardCookies( + request: OpenAIDashboardCookieImportRequest, + logger log: @escaping (String) -> Void) async throws + -> OpenAIDashboardBrowserCookieImporter.ImportResult + { + if let override = self._test_openAIDashboardCookieImportOverride { + return try await override( + request.normalizedTarget, + request.allowAnyAccount, + request.cookieSource, + request.cacheScope, + log) + } + + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) + switch request.cookieSource { + case .manual: + return try await self.importManualOpenAIDashboardCookies( + importer: importer, + request: request, + logger: log) + case .auto: + return try await self.importAutoOpenAIDashboardCookies( + importer: importer, + request: request, + logger: log) + case .off: + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Off", + cookieCount: 0, + signedInEmail: request.normalizedTarget, + matchesCodexEmail: true) + } + } + + private func importManualOpenAIDashboardCookies( + importer: OpenAIDashboardBrowserCookieImporter, + request: OpenAIDashboardCookieImportRequest, + logger log: @escaping (String) -> Void) async throws + -> OpenAIDashboardBrowserCookieImporter.ImportResult + { + self.settings.ensureCodexCookieLoaded() + // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are + // isolated per managed account, but a manual header is an explicit override owned by settings, + // so switching managed accounts does not currently swap it underneath the user. + let manualHeader = self.settings.codexCookieHeader + guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { + throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid + } + return try await importer.importManualCookies( + cookieHeader: manualHeader, + intoAccountEmail: request.normalizedTarget, + allowAnyAccount: request.allowAnyAccount, + cacheScope: request.cacheScope, + logger: log) + } + + private func importAutoOpenAIDashboardCookies( + importer: OpenAIDashboardBrowserCookieImporter, + request: OpenAIDashboardCookieImportRequest, + logger log: @escaping (String) -> Void) async throws + -> OpenAIDashboardBrowserCookieImporter.ImportResult + { + do { + return try await importer.importBestCookies( + intoAccountEmail: request.normalizedTarget, + allowAnyAccount: request.allowAnyAccount, + preferCachedCookieHeader: !request.force, + cacheScope: request.cacheScope, + logger: log) + } catch let error as OpenAIDashboardBrowserCookieImporter.ImportError { + let manualHeader = self.settings.codexCookieHeader + if case .browserAccessDenied = error, + let normalizedManual = CookieHeaderNormalizer.normalize(manualHeader) + { + log("Auto browser import blocked; retrying with manual cookie header fallback.") + return try await importer.importManualCookies( + cookieHeader: normalizedManual, + intoAccountEmail: request.normalizedTarget, + allowAnyAccount: request.allowAnyAccount, + cacheScope: request.cacheScope, + logger: log) + } + throw error + } + } + func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { if await self.openAIWebCookieImportShouldFailClosed() { return nil @@ -896,59 +991,15 @@ extension UsageStore { self.logOpenAIWeb(message) } - let result: OpenAIDashboardBrowserCookieImporter.ImportResult - if let override = self._test_openAIDashboardCookieImportOverride { - result = try await override(normalizedTarget, allowAnyAccount, cookieSource, cacheScope, log) - } else { - let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) - switch cookieSource { - case .manual: - self.settings.ensureCodexCookieLoaded() - // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are - // isolated per managed account, but a manual header is an explicit override owned by settings, - // so switching managed accounts does not currently swap it underneath the user. - let manualHeader = self.settings.codexCookieHeader - guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { - throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid - } - result = try await importer.importManualCookies( - cookieHeader: manualHeader, - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - cacheScope: cacheScope, - logger: log) - case .auto: - do { - result = try await importer.importBestCookies( - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - preferCachedCookieHeader: !force, - cacheScope: cacheScope, - logger: log) - } catch let error as OpenAIDashboardBrowserCookieImporter.ImportError { - let manualHeader = self.settings.codexCookieHeader - if case .browserAccessDenied = error, - let normalizedManual = CookieHeaderNormalizer.normalize(manualHeader) - { - log("Auto browser import blocked; retrying with manual cookie header fallback.") - result = try await importer.importManualCookies( - cookieHeader: normalizedManual, - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - cacheScope: cacheScope, - logger: log) - } else { - throw error - } - } - case .off: - result = OpenAIDashboardBrowserCookieImporter.ImportResult( - sourceLabel: "Off", - cookieCount: 0, - signedInEmail: normalizedTarget, - matchesCodexEmail: true) - } - } + let request = OpenAIDashboardCookieImportRequest( + normalizedTarget: normalizedTarget, + allowAnyAccount: allowAnyAccount, + cookieSource: cookieSource, + cacheScope: cacheScope, + force: force) + let result = try await self.importOpenAIDashboardCookies( + request: request, + logger: log) let effectiveEmail = result.signedInEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty == false diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index a594d16a9a..33e8677e7d 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -158,7 +158,7 @@ struct OAuthUsageResponse: Decodable { in container: KeyedDecodingContainer, keys: [String]) -> OAuthUsageWindow? { - Self.decodeValue(in: container, keys: keys) + self.decodeValue(in: container, keys: keys) } private static func decodeWindowWithSource( @@ -169,9 +169,7 @@ struct OAuthUsageResponse: Decodable { for keyName in keys { guard let key = DynamicCodingKey(stringValue: keyName) else { continue } guard container.contains(key) else { continue } - if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key), - value != nil - { + if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) { return (value, keyName) } if firstNullKey == nil { @@ -205,7 +203,7 @@ private struct DynamicCodingKey: CodingKey { } init?(intValue: Int) { - return nil + nil } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 9c0460e1c9..82d9198659 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -898,14 +898,12 @@ extension ClaudeUsageFetcher { id: "claude-design", title: "Designs", window: usage.sevenDayDesign, - sourceKey: usage.sevenDayDesignSourceKey - ), + sourceKey: usage.sevenDayDesignSourceKey), ( id: "claude-routines", title: "Daily Routines", window: usage.sevenDayRoutines, - sourceKey: usage.sevenDayRoutinesSourceKey - ), + sourceKey: usage.sevenDayRoutinesSourceKey), ] if let designKey = usage.sevenDayDesignSourceKey { Self.log.debug("Claude OAuth extra usage key matched: design=\(designKey)") diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 7524854b10..6fb3f41eaa 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -493,7 +493,7 @@ public enum ClaudeWebAPIFetcher { } else if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] { opusPercent = Self.percentValue(from: sevenDayOpus["utilization"]) } - let extraRateParse = Self.parseExtraRateWindows(from: json) + let extraRateParse = ClaudeWebExtraRateWindowParser.parse(from: json) if let sourceKey = extraRateParse.sourceKeys["claude-design"] { logger?("Usage API extra window key matched: design=\(sourceKey)") } @@ -514,95 +514,6 @@ public enum ClaudeWebAPIFetcher { loginMethod: nil) } - private static func parseExtraRateWindows( - from json: [String: Any]) -> (windows: [NamedRateWindow], sourceKeys: [String: String]) - { - let definitions: [(id: String, title: String, keys: [String])] = [ - ( - id: "claude-design", - title: "Designs", - keys: [ - "seven_day_design", - "seven_day_claude_design", - "claude_design", - "design", - "seven_day_omelette", - "omelette", - "omelette_promotional", - ] - ), - ( - id: "claude-routines", - title: "Daily Routines", - keys: [ - "seven_day_routines", - "seven_day_claude_routines", - "claude_routines", - "routines", - "routine", - "seven_day_cowork", - "cowork", - ] - ), - ] - - var windows: [NamedRateWindow] = [] - var sourceKeys: [String: String] = [:] - windows.reserveCapacity(definitions.count) - - for definition in definitions { - if let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) { - let rawWindow = foundWindow.window - guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue } - let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date) - windows.append(NamedRateWindow( - id: definition.id, - title: definition.title, - window: RateWindow( - usedPercent: utilization, - windowMinutes: 7 * 24 * 60, - resetsAt: resetsAt, - resetDescription: nil))) - sourceKeys[definition.id] = foundWindow.sourceKey - continue - } - - // Some accounts expose the key with null payloads (for example `seven_day_cowork: null`). - // Preserve the bar in that case with a 0% window so the product section remains visible. - if let key = Self.firstUsageKey(in: json, keys: definition.keys) { - windows.append(NamedRateWindow( - id: definition.id, - title: definition.title, - window: RateWindow( - usedPercent: 0, - windowMinutes: 7 * 24 * 60, - resetsAt: nil, - resetDescription: nil))) - sourceKeys[definition.id] = key - } - } - return (windows, sourceKeys) - } - - private static func firstUsageWindow( - in json: [String: Any], - keys: [String]) -> (window: [String: Any], sourceKey: String)? - { - for key in keys { - if let window = json[key] as? [String: Any] { - return (window, key) - } - } - return nil - } - - private static func firstUsageKey(in json: [String: Any], keys: [String]) -> String? { - for key in keys where json.keys.contains(key) { - return key - } - return nil - } - private static func percentValue(from value: Any?) -> Double? { if let intValue = value as? Int { return Double(intValue) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift new file mode 100644 index 0000000000..b1df6343cf --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift @@ -0,0 +1,118 @@ +import Foundation + +enum ClaudeWebExtraRateWindowParser { + private static let definitions: [(id: String, title: String, keys: [String])] = [ + ( + id: "claude-design", + title: "Designs", + keys: [ + "seven_day_design", + "seven_day_claude_design", + "claude_design", + "design", + "seven_day_omelette", + "omelette", + "omelette_promotional", + ]), + ( + id: "claude-routines", + title: "Daily Routines", + keys: [ + "seven_day_routines", + "seven_day_claude_routines", + "claude_routines", + "routines", + "routine", + "seven_day_cowork", + "cowork", + ]), + ] + + static func parse(from json: [String: Any]) -> (windows: [NamedRateWindow], sourceKeys: [String: String]) { + var windows: [NamedRateWindow] = [] + var sourceKeys: [String: String] = [:] + windows.reserveCapacity(Self.definitions.count) + + for definition in Self.definitions { + if let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) { + let rawWindow = foundWindow.window + guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue } + let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date) + windows.append(Self.namedWindow( + id: definition.id, + title: definition.title, + usedPercent: utilization, + resetsAt: resetsAt)) + sourceKeys[definition.id] = foundWindow.sourceKey + continue + } + + // Some accounts expose the key with null payloads (for example `seven_day_cowork: null`). + // Preserve the bar in that case with a 0% window so the product section remains visible. + if let key = Self.firstUsageKey(in: json, keys: definition.keys) { + windows.append(Self.namedWindow( + id: definition.id, + title: definition.title, + usedPercent: 0, + resetsAt: nil)) + sourceKeys[definition.id] = key + } + } + return (windows, sourceKeys) + } + + private static func namedWindow( + id: String, + title: String, + usedPercent: Double, + resetsAt: Date?) -> NamedRateWindow + { + NamedRateWindow( + id: id, + title: title, + window: RateWindow( + usedPercent: usedPercent, + windowMinutes: 7 * 24 * 60, + resetsAt: resetsAt, + resetDescription: nil)) + } + + private static func firstUsageWindow( + in json: [String: Any], + keys: [String]) -> (window: [String: Any], sourceKey: String)? + { + for key in keys { + if let window = json[key] as? [String: Any] { + return (window, key) + } + } + return nil + } + + private static func firstUsageKey(in json: [String: Any], keys: [String]) -> String? { + for key in keys where json.keys.contains(key) { + return key + } + return nil + } + + private static func percentValue(from value: Any?) -> Double? { + if let intValue = value as? Int { + return Double(intValue) + } + if let doubleValue = value as? Double { + return doubleValue + } + return nil + } + + private static func parseISO8601Date(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 702ba424ce..6e400fd6b3 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -680,9 +680,7 @@ struct ClaudeUsageTests { { "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, "seven_day": { "utilization": 4, "resets_at": "2025-12-29T23:00:00.000Z" }, - "seven_day_opus": { "utilization": 1 }, - "seven_day_design": { "utilization": 31, "resets_at": "2025-12-30T23:00:00.000Z" }, - "seven_day_routines": { "utilization": 7, "resets_at": "2025-12-31T23:00:00.000Z" } + "seven_day_opus": { "utilization": 1 } } """ let data = Data(json.utf8) @@ -690,56 +688,10 @@ struct ClaudeUsageTests { #expect(parsed.sessionPercentUsed == 9) #expect(parsed.weeklyPercentUsed == 4) #expect(parsed.opusPercentUsed == 1) - #expect(parsed.extraRateWindows.count == 2) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 31) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 7) #expect(parsed.sessionResetsAt != nil) #expect(parsed.weeklyResetsAt != nil) } - @Test - func `parses claude web API sonnet usage response`() throws { - let json = """ - { - "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" } - } - """ - let data = Data(json.utf8) - let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.opusPercentUsed == 6) - } - - @Test - func `parses claude web API omelette and cowork usage windows`() throws { - let json = """ - { - "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, - "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" } - } - """ - let data = Data(json.utf8) - let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.extraRateWindows.count == 2) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 26) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) - } - - @Test - func `parses claude web API cowork null as zero routines window`() throws { - let json = """ - { - "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, - "seven_day_cowork": null - } - """ - let data = Data(json.utf8) - let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) - } - @Test func `parses claude web API usage response when weekly missing`() throws { let json = """ diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift new file mode 100644 index 0000000000..324332297b --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeWebUsageExtraWindowTests { + @Test + func `parses claude web API sonnet usage response`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.opusPercentUsed == 6) + } + + @Test + func `parses claude web API omelette and cowork usage windows`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.count == 2) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 26) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) + } + + @Test + func `parses claude web API cowork null as zero routines window`() throws { + let json = """ + { + "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" }, + "seven_day_cowork": null + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + } +} From 0c628a32e194c251d893f349c87c585ffc032f54 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 23 Apr 2026 01:59:31 +0530 Subject: [PATCH 5/6] Drop OpenAI cookie import changes --- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 162 +++++--------------- 1 file changed, 37 insertions(+), 125 deletions(-) diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 7793c4fe7d..53b6e20160 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -855,101 +855,6 @@ extension UsageStore { return false } - private struct OpenAIDashboardCookieImportRequest { - let normalizedTarget: String? - let allowAnyAccount: Bool - let cookieSource: ProviderCookieSource - let cacheScope: CookieHeaderCache.Scope? - let force: Bool - } - - private func importOpenAIDashboardCookies( - request: OpenAIDashboardCookieImportRequest, - logger log: @escaping (String) -> Void) async throws - -> OpenAIDashboardBrowserCookieImporter.ImportResult - { - if let override = self._test_openAIDashboardCookieImportOverride { - return try await override( - request.normalizedTarget, - request.allowAnyAccount, - request.cookieSource, - request.cacheScope, - log) - } - - let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) - switch request.cookieSource { - case .manual: - return try await self.importManualOpenAIDashboardCookies( - importer: importer, - request: request, - logger: log) - case .auto: - return try await self.importAutoOpenAIDashboardCookies( - importer: importer, - request: request, - logger: log) - case .off: - return OpenAIDashboardBrowserCookieImporter.ImportResult( - sourceLabel: "Off", - cookieCount: 0, - signedInEmail: request.normalizedTarget, - matchesCodexEmail: true) - } - } - - private func importManualOpenAIDashboardCookies( - importer: OpenAIDashboardBrowserCookieImporter, - request: OpenAIDashboardCookieImportRequest, - logger log: @escaping (String) -> Void) async throws - -> OpenAIDashboardBrowserCookieImporter.ImportResult - { - self.settings.ensureCodexCookieLoaded() - // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are - // isolated per managed account, but a manual header is an explicit override owned by settings, - // so switching managed accounts does not currently swap it underneath the user. - let manualHeader = self.settings.codexCookieHeader - guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { - throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid - } - return try await importer.importManualCookies( - cookieHeader: manualHeader, - intoAccountEmail: request.normalizedTarget, - allowAnyAccount: request.allowAnyAccount, - cacheScope: request.cacheScope, - logger: log) - } - - private func importAutoOpenAIDashboardCookies( - importer: OpenAIDashboardBrowserCookieImporter, - request: OpenAIDashboardCookieImportRequest, - logger log: @escaping (String) -> Void) async throws - -> OpenAIDashboardBrowserCookieImporter.ImportResult - { - do { - return try await importer.importBestCookies( - intoAccountEmail: request.normalizedTarget, - allowAnyAccount: request.allowAnyAccount, - preferCachedCookieHeader: !request.force, - cacheScope: request.cacheScope, - logger: log) - } catch let error as OpenAIDashboardBrowserCookieImporter.ImportError { - let manualHeader = self.settings.codexCookieHeader - if case .browserAccessDenied = error, - let normalizedManual = CookieHeaderNormalizer.normalize(manualHeader) - { - log("Auto browser import blocked; retrying with manual cookie header fallback.") - return try await importer.importManualCookies( - cookieHeader: normalizedManual, - intoAccountEmail: request.normalizedTarget, - allowAnyAccount: request.allowAnyAccount, - cacheScope: request.cacheScope, - logger: log) - } - throw error - } - } - func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { if await self.openAIWebCookieImportShouldFailClosed() { return nil @@ -991,15 +896,42 @@ extension UsageStore { self.logOpenAIWeb(message) } - let request = OpenAIDashboardCookieImportRequest( - normalizedTarget: normalizedTarget, - allowAnyAccount: allowAnyAccount, - cookieSource: cookieSource, - cacheScope: cacheScope, - force: force) - let result = try await self.importOpenAIDashboardCookies( - request: request, - logger: log) + let result: OpenAIDashboardBrowserCookieImporter.ImportResult + if let override = self._test_openAIDashboardCookieImportOverride { + result = try await override(normalizedTarget, allowAnyAccount, cookieSource, cacheScope, log) + } else { + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) + switch cookieSource { + case .manual: + self.settings.ensureCodexCookieLoaded() + // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are + // isolated per managed account, but a manual header is an explicit override owned by settings, + // so switching managed accounts does not currently swap it underneath the user. + let manualHeader = self.settings.codexCookieHeader + guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { + throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid + } + result = try await importer.importManualCookies( + cookieHeader: manualHeader, + intoAccountEmail: normalizedTarget, + allowAnyAccount: allowAnyAccount, + cacheScope: cacheScope, + logger: log) + case .auto: + result = try await importer.importBestCookies( + intoAccountEmail: normalizedTarget, + allowAnyAccount: allowAnyAccount, + preferCachedCookieHeader: !force, + cacheScope: cacheScope, + logger: log) + case .off: + result = OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Off", + cookieCount: 0, + signedInEmail: normalizedTarget, + matchesCodexEmail: true) + } + } let effectiveEmail = result.signedInEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty == false @@ -1060,14 +992,8 @@ extension UsageStore { targetEmail: normalizedTarget) self.failClosedOpenAIDashboardSnapshot() } - case let .browserAccessDenied(details): - self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)") - await MainActor.run { - self.openAIDashboardCookieImportStatus = Self.conciseOpenAICookieAccessDeniedStatus( - details: details) - self.openAIDashboardRequiresLogin = true - } case .noCookiesFound, + .browserAccessDenied, .dashboardStillRequiresLogin, .manualCookieHeaderInvalid: self.logOpenAIWeb("[\(stamp)] import failed: \(err.localizedDescription)") @@ -1302,18 +1228,4 @@ extension UsageStore { } return "OpenAI cookies are for \(foundLabel), not \(targetLabel)." } - - private static func conciseOpenAICookieAccessDeniedStatus(details: String) -> String { - let lower = details.lowercased() - if lower.contains("safari") { - return [ - "OpenAI cookie import is blocked by macOS privacy for Safari.", - "Enable Full Disk Access for CodexBar, or switch Codex cookie source to Manual.", - ].joined(separator: " ") - } - return [ - "OpenAI cookie import is blocked by browser privacy permissions.", - "Enable cookie/keychain access for CodexBar, or switch Codex cookie source to Manual.", - ].joined(separator: " ") - } } From ea63c78b6c2f335751d7f11b06c841d3c4bc7d5e Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 23 Apr 2026 02:24:00 +0530 Subject: [PATCH 6/6] Place Claude Sonnet bar before extras --- Sources/CodexBar/MenuCardView.swift | 54 ++++++++++---------- Tests/CodexBarTests/MenuCardModelTests.swift | 9 ++-- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 9b6b7ced3d..18b77df05d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -965,6 +965,33 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, zaiTimeDetail: zaiTimeDetail)) } + if input.metadata.supportsOpus, let opus = snapshot.tertiary { + var tertiaryDetailText: String? + if input.provider == .alibaba, + let detail = opus.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + tertiaryDetailText = detail + } + if input.provider == .zai, let detail = zaiSessionDetail { + tertiaryDetailText = detail + } + // Perplexity purchased credits don't reset; show balance without "Resets" prefix. + let opusResetText: String? = input.provider == .perplexity + ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) + : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now) + metrics.append(Metric( + id: "tertiary", + title: input.metadata.opusLabel ?? "Sonnet", + percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), + percentStyle: percentStyle, + resetText: opusResetText, + detailText: tertiaryDetailText, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) + } if let extraRateWindows = snapshot.extraRateWindows { metrics.append(contentsOf: extraRateWindows.map { namedWindow in Metric( @@ -998,33 +1025,6 @@ extension UsageMenuCardView.Model { return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max) } } - if input.metadata.supportsOpus, let opus = snapshot.tertiary { - var tertiaryDetailText: String? - if input.provider == .alibaba, - let detail = opus.resetDescription, - !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - tertiaryDetailText = detail - } - if input.provider == .zai, let detail = zaiSessionDetail { - tertiaryDetailText = detail - } - // Perplexity purchased credits don't reset; show balance without "Resets" prefix. - let opusResetText: String? = input.provider == .perplexity - ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) - : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now) - metrics.append(Metric( - id: "tertiary", - title: input.metadata.opusLabel ?? "Sonnet", - percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), - percentStyle: percentStyle, - resetText: opusResetText, - detailText: tertiaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true)) - } if let codexProjection = input.codexProjection, codexProjection.supplementalMetrics.contains(.codeReview), diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index ac7124ec72..ee8fce91ae 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -143,7 +143,11 @@ struct MenuCardModelTests { windowMinutes: 10080, resetsAt: now.addingTimeInterval(7200), resetDescription: nil), - tertiary: nil, + tertiary: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7800), + resetDescription: nil), extraRateWindows: [ NamedRateWindow( id: "claude-design", @@ -185,8 +189,7 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.map(\.title).contains("Designs")) - #expect(model.metrics.map(\.title).contains("Daily Routines")) + #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Designs", "Daily Routines"]) } @Test