diff --git a/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift b/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift new file mode 100644 index 0000000000..b5f3e9b333 --- /dev/null +++ b/Sources/CodexBar/CodexResetCreditExpiryNotifier.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation + +@MainActor +struct CodexResetCreditExpiryNotifier { + static let expiryWindow: TimeInterval = 3 * 24 * 60 * 60 + + var userDefaults: UserDefaults = .standard + var notificationPoster: (String, String, String) -> Void = { id, title, body in + AppNotifications.shared.post(idPrefix: id, title: title, body: body) + } + + func postExpiringCreditsIfNeeded(snapshot: CodexRateLimitResetCreditsSnapshot, now: Date = Date()) { + let key = "codexResetCreditExpiryNotificationsPosted" + var posted = Set(self.userDefaults.stringArray(forKey: key) ?? []) + var changed = false + + for credit in snapshot.credits { + guard credit.status == .available, + let expiresAt = credit.expiresAt, + expiresAt > now, + expiresAt.timeIntervalSince(now) <= Self.expiryWindow, + !posted.contains(credit.id) + else { + continue + } + + posted.insert(credit.id) + changed = true + self.notificationPoster( + "codex-reset-credit-expiring-\(credit.id)", + L("Codex reset expires soon"), + String( + format: L("A Codex reset credit expires %@."), + UsageFormatter.resetDescription(from: expiresAt, now: now))) + } + + if changed { + self.userDefaults.set(Array(posted).sorted(), forKey: key) + } + } +} diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 9e1962b85c..e9c1a29d74 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -19,13 +19,12 @@ extension UsageMenuCardView.Model { "creditsRemaining=\(self.creditsRemaining.map(String.init(describing:)) ?? "nil")", MenuCardHeightFingerprint.field("creditsHint", self.creditsHintText), MenuCardHeightFingerprint.field("creditsCopy", self.creditsHintCopyText), - MenuCardHeightFingerprint.field("codexResetCredits", self.codexResetCreditsText), - MenuCardHeightFingerprint.field("codexResetCreditsDetail", self.codexResetCreditsDetailText), "metrics=\(MenuCardHeightFingerprint.join(self.metrics.map(\.heightFingerprint)))", "notes=\(notesFingerprint)", "dashboard=\(self.inlineUsageDashboard?.heightFingerprint ?? "")", "providerCost=\(self.providerCost?.heightFingerprint ?? "")", "tokenUsage=\(self.tokenUsage?.heightFingerprint ?? "")", + "codexResetCredits=\(self.codexResetCredits?.heightFingerprint ?? "")", "openaiAPI=\(self.openAIAPIUsage == nil ? "0" : "1")", ] + additional) } @@ -119,6 +118,17 @@ extension UsageMenuCardView.Model.TokenUsageSection { } } +extension CodexResetCreditsPresentation { + fileprivate var heightFingerprint: String { + MenuCardHeightFingerprint.join([ + MenuCardHeightFingerprint.field("text", self.text), + MenuCardHeightFingerprint.field("detail", self.detailText), + MenuCardHeightFingerprint.field("help", self.helpText), + self.creditToConsume == nil ? "consume=0" : "consume=1", + ]) + } +} + extension InlineUsageDashboardModel { fileprivate var heightFingerprint: String { MenuCardHeightFingerprint.join([ diff --git a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift index e7e4d69ac6..1638293709 100644 --- a/Sources/CodexBar/MenuCardView+CodexResetCredits.swift +++ b/Sources/CodexBar/MenuCardView+CodexResetCredits.swift @@ -1,10 +1,18 @@ import CodexBarCore import SwiftUI -struct CodexResetCreditsContent: View { +struct CodexResetCreditsPresentation: Equatable { let text: String let detailText: String? + let helpText: String? + let creditToConsume: CodexRateLimitResetCredit? +} + +struct CodexResetCreditsContent: View { + let model: CodexResetCreditsPresentation + @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.codexResetCreditConsumer) private var consumeCredit var body: some View { VStack(alignment: .leading, spacing: 4) { @@ -12,52 +20,68 @@ struct CodexResetCreditsContent: View { .font(.body) .fontWeight(.medium) .lineLimit(1) - HStack(alignment: .firstTextBaseline) { - Text(self.text) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.model.text) .font(.footnote.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) .lineLimit(1) .layoutPriority(1) Spacer() - if let detailText, !detailText.isEmpty { + if let detailText = self.model.detailText, !detailText.isEmpty { Text(detailText) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } + if let creditToConsume = self.model.creditToConsume, let consumeCredit { + Button(L("Use Reset")) { + consumeCredit(creditToConsume) + } + .buttonStyle(.borderless) + .controlSize(.small) + .help(L("Use the next expiring Codex reset credit")) + } } } .frame(maxWidth: .infinity, alignment: .leading) + .help(self.model.helpText ?? self.model.text) .accessibilityElement(children: .combine) .accessibilityLabel([ L("Limit Reset Credits"), - self.text, - self.detailText, + self.model.text, + self.model.detailText, ].compactMap(\.self).joined(separator: ", ")) } } extension UsageMenuCardView.Model { + static func codexResetCredits(input: Input) -> CodexResetCreditsPresentation? { + guard input.showOptionalCreditsAndExtraUsage else { return nil } + guard let text = codexResetCreditsText(input: input) else { return nil } + return CodexResetCreditsPresentation( + text: text, + detailText: Self.codexResetCreditsDetailText(input: input), + helpText: Self.codexResetCreditsHelpText(input: input), + creditToConsume: Self.codexResetCreditToConsume(input: input)) + } + static func codexResetCreditsText(input: Input) -> String? { - guard input.provider == .codex, - input.showOptionalCreditsAndExtraUsage, - let resetCredits = input.snapshot?.codexResetCredits, - resetCredits.availableCount > 0 - else { - return nil - } - let count = resetCredits.availableCount + guard let count = codexCurrentResetCreditCount(input: input), count > 0 else { return nil } if count == 1 { return L("1 available") } return String(format: L("%d available"), count) } + static func codexResetCreditToConsume(input: Input) -> CodexRateLimitResetCredit? { + guard input.provider == .codex else { return nil } + return input.snapshot?.codexResetCredits?.nextExpiringAvailableCredit(at: input.now) + } + static func codexResetCreditsDetailText(input: Input) -> String? { guard input.provider == .codex, - input.showOptionalCreditsAndExtraUsage, let resetCredits = input.snapshot?.codexResetCredits, - let expiresAt = resetCredits.nextExpiringAvailableCredit?.expiresAt + let expiresAt = resetCredits.nextExpiringAvailableCredit(at: input.now)?.expiresAt else { return nil } @@ -71,4 +95,39 @@ extension UsageMenuCardView.Model { } return String(format: L("Next expires %@"), timeText) } + + static func codexResetCreditsHelpText(input: Input) -> String? { + guard input.provider == .codex, + let resetCredits = input.snapshot?.codexResetCredits + else { + return nil + } + let lines = resetCredits.credits.map { credit in + let expires = Self.codexResetCreditExpiryText(credit, now: input.now) + return "\(credit.status.rawValue), \(expires)" + } + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + + private static func codexCurrentResetCreditCount(input: Input) -> Int? { + guard input.provider == .codex, + let resetCredits = input.snapshot?.codexResetCredits + else { + return nil + } + guard !resetCredits.credits.isEmpty else { return resetCredits.availableCount } + return resetCredits.availableCredits(at: input.now).count + } + + private static func codexResetCreditExpiryText( + _ credit: CodexRateLimitResetCredit, + now: Date) + -> String + { + guard let expiresAt = credit.expiresAt else { return L("No expiry") } + let absolute = UsageFormatter.resetDescription(from: expiresAt, now: now) + guard credit.status == .available, expiresAt > now else { return absolute } + let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now) + return "\(countdown) (\(absolute))" + } } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index c251ce0396..d26d3e3e94 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -65,14 +65,14 @@ extension UsageMenuCardView.Model { !self.usageNotes.isEmpty || self.openAIAPIUsage != nil || self.inlineUsageDashboard != nil || - self.codexResetCreditsText != nil || + self.codexResetCredits != nil || self.placeholder != nil } var usesStackedDetailLayout: Bool { !self.metrics.isEmpty || self.creditsText != nil || - self.codexResetCreditsText != nil || + self.codexResetCredits != nil || self.providerCost != nil || self.tokenUsage != nil } @@ -107,8 +107,7 @@ extension UsageMenuCardView.Model { candidateText: candidate.creditsText, candidateRemaining: candidate.creditsRemaining), self.creditsHintText == candidate.creditsHintText, - self.codexResetCreditsText == candidate.codexResetCreditsText, - self.codexResetCreditsDetailText == candidate.codexResetCreditsDetailText, + self.codexResetCredits == candidate.codexResetCredits, self.placeholder == candidate.placeholder, Self.hasCompatibleDashboardLayout(self.inlineUsageDashboard, candidate.inlineUsageDashboard), Self.hasCompatibleProviderCostLayout(self.providerCost, candidate.providerCost), diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 532b508f16..8892917fa9 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -112,8 +112,7 @@ struct UsageMenuCardView: View { var creditsProgressPercent: Double?, creditsScaleText: String? let creditsHintText: String? let creditsHintCopyText: String? - var codexResetCreditsText: String? - var codexResetCreditsDetailText: String? + var codexResetCredits: CodexResetCreditsPresentation? let providerCost: ProviderCostSection? let tokenUsage: TokenUsageSection? let placeholder: String? @@ -549,20 +548,18 @@ private struct UsageMenuCardUsageContentView: View { title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } - if let resetCredits = self.model.codexResetCreditsText { + if let resetCredits = self.model.codexResetCredits { if !self.model.metrics.isEmpty { Divider() } - CodexResetCreditsContent( - text: resetCredits, - detailText: self.model.codexResetCreditsDetailText) + CodexResetCreditsContent(model: resetCredits) } if let dashboard = self.model.inlineUsageDashboard { InlineUsageDashboardContent(model: dashboard) } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder, self.model.metrics.isEmpty, - self.model.codexResetCreditsText == nil + self.model.codexResetCredits == nil { Text(placeholder) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) @@ -859,8 +856,7 @@ extension UsageMenuCardView.Model { creditsScaleText: creditsScaleText, creditsHintText: codexCreditLimitDetail ?? redacted.creditsHintText, creditsHintCopyText: codexCreditLimitDetail ?? redacted.creditsHintCopyText, - codexResetCreditsText: Self.codexResetCreditsText(input: input), - codexResetCreditsDetailText: Self.codexResetCreditsDetailText(input: input), + codexResetCredits: Self.codexResetCredits(input: input), providerCost: providerCost, tokenUsage: tokenUsage, placeholder: placeholder, diff --git a/Sources/CodexBar/MenuHighlightStyle.swift b/Sources/CodexBar/MenuHighlightStyle.swift index bb493b5026..38a84c8cd7 100644 --- a/Sources/CodexBar/MenuHighlightStyle.swift +++ b/Sources/CodexBar/MenuHighlightStyle.swift @@ -1,3 +1,4 @@ +import CodexBarCore import SwiftUI extension EnvironmentValues { @@ -6,6 +7,7 @@ extension EnvironmentValues { /// subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu /// stays open, without rebuilding the menu during AppKit tracking. @Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor? + @Entry var codexResetCreditConsumer: ((CodexRateLimitResetCredit) -> Void)? } enum MenuHighlightStyle { diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index b9754ca3f1..9873a7b056 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -23,6 +23,7 @@ struct ProviderDetailView: View { @Binding var isErrorExpanded: Bool let onCopyError: (String) -> Void let onRefresh: () -> Void + let onConsumeCodexResetCredit: (CodexRateLimitResetCredit) -> Void let supplementarySettingsContent: SupplementaryContent let showsSupplementarySettingsContent: Bool @@ -42,6 +43,7 @@ struct ProviderDetailView: View { isErrorExpanded: Binding, onCopyError: @escaping (String) -> Void, onRefresh: @escaping () -> Void, + onConsumeCodexResetCredit: @escaping (CodexRateLimitResetCredit) -> Void = { _ in }, showsSupplementarySettingsContent: Bool = false, @ViewBuilder supplementarySettingsContent: () -> SupplementaryContent) { @@ -60,6 +62,7 @@ struct ProviderDetailView: View { self._isErrorExpanded = isErrorExpanded self.onCopyError = onCopyError self.onRefresh = onRefresh + self.onConsumeCodexResetCredit = onConsumeCodexResetCredit self.showsSupplementarySettingsContent = showsSupplementarySettingsContent self.supplementarySettingsContent = supplementarySettingsContent() } @@ -118,7 +121,8 @@ struct ProviderDetailView: View { provider: self.provider, model: self.model, isEnabled: self.isEnabled, - labelWidth: labelWidth) + labelWidth: labelWidth, + onConsumeCodexResetCredit: self.onConsumeCodexResetCredit) if let errorDisplay { ProviderErrorView( @@ -389,6 +393,7 @@ struct ProviderMetricsInlineView: View { let model: UsageMenuCardView.Model let isEnabled: Bool let labelWidth: CGFloat + let onConsumeCodexResetCredit: (CodexRateLimitResetCredit) -> Void var body: some View { let hasMetrics = !self.model.metrics.isEmpty @@ -396,13 +401,14 @@ struct ProviderMetricsInlineView: View { let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil + let hasResetCredits = self.model.codexResetCredits != nil ProviderSettingsSection( title: L("Usage"), spacing: 8, verticalPadding: 6, horizontalPadding: 0) { - if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { + if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage, !hasResetCredits { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -429,6 +435,13 @@ struct ProviderMetricsInlineView: View { labelWidth: self.labelWidth) } + if let resetCredits = self.model.codexResetCredits { + ProviderCodexResetCreditsInlineRow( + presentation: resetCredits, + labelWidth: self.labelWidth, + onConsume: self.onConsumeCodexResetCredit) + } + if let providerCost = self.model.providerCost { ProviderMetricInlineCostRow( section: providerCost, @@ -562,6 +575,56 @@ private struct ProviderUsageNotesInlineView: View { } } +private struct ProviderCodexResetCreditsInlineRow: View { + let presentation: CodexResetCreditsPresentation + let labelWidth: CGFloat + let onConsume: (CodexRateLimitResetCredit) -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(L("Reset bank")) + .font(.subheadline.weight(.semibold)) + .frame(width: self.labelWidth, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.presentation.text) + .font(.footnote) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + if let credit = self.presentation.creditToConsume { + Button(L("Use Reset")) { + self.onConsume(credit) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + if let detailText = self.presentation.detailText, !detailText.isEmpty { + Text(detailText) + .font(.footnote) + .foregroundStyle(.tertiary) + } + ForEach(self.detailLines, id: \.self) { line in + Text(line) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 2) + .help(self.presentation.helpText ?? self.presentation.text) + } + + private var detailLines: [String] { + guard let helpText = self.presentation.helpText else { return [] } + return helpText + .split(separator: "\n") + .map(String.init) + } +} + private struct ProviderMetricInlineTextRow: View { let title: String let value: String diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7a7ce13420..cf7d73cb26 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -91,6 +91,9 @@ struct ProvidersPane: View { onRefresh: { self.triggerRefresh(for: provider) }, + onConsumeCodexResetCredit: { credit in + self.requestCodexResetCreditConsumption(credit) + }, showsSupplementarySettingsContent: self.codexAccountsSectionState(for: provider) != nil, supplementarySettingsContent: { if let state = self.codexAccountsSectionState(for: provider) { @@ -387,6 +390,26 @@ struct ProvidersPane: View { }) } + func requestCodexResetCreditConsumption(_ credit: CodexRateLimitResetCredit) { + self.activeConfirmation = ProviderSettingsConfirmationState( + title: L("Use Codex reset?"), + message: L("This spends one banked Codex reset credit now."), + confirmTitle: L("Use Reset"), + onConfirm: { + Task { @MainActor in + await self.consumeCodexResetCreditFromSettings(credit) + } + }) + } + + private func consumeCodexResetCreditFromSettings(_ credit: CodexRateLimitResetCredit) async { + do { + _ = try await self.store.consumeCodexResetCredit(credit) + } catch { + self.presentLoginAlert(title: L("Codex reset failed"), message: error.localizedDescription) + } + } + func providerErrorDisplay(_ provider: UsageProvider) -> ProviderErrorDisplay? { guard let full = self.store.error(for: provider), !full.isEmpty else { return nil } let preview = self.store.userFacingError(for: provider) ?? full diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index bce54e5b77..252407219e 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1114,7 +1114,21 @@ "Limit Reset Credits" = "أرصدة إعادة تعيين الحد"; "1 available" = "1 متاح"; "%d available" = "%d متاح"; +"1 manual reset available" = "تتوفر إعادة تعيين يدوية واحدة"; +"%d manual resets available" = "تتوفر %d عمليات إعادة تعيين يدوية"; +"Reset bank" = "رصيد إعادة التعيين"; "Next expires %@" = "تنتهي صلاحية التالية %@"; +"Use Reset" = "استخدم إعادة التعيين"; +"Use the next expiring Codex reset credit" = "استخدم رصيد إعادة تعيين Codex التالي انتهاءً"; +"No expiry" = "لا انتهاء صلاحية"; +"Codex reset expires soon" = "ستنتهي صلاحية إعادة تعيين Codex قريبًا"; +"A Codex reset credit expires %@." = "ينتهي رصيد إعادة تعيين Codex %@."; +"Use Codex reset?" = "استخدام إعادة تعيين Codex؟"; +"This spends one banked Codex reset credit now." = "سيستخدم هذا رصيد إعادة تعيين Codex محفوظًا الآن."; +"Codex reset used" = "تم استخدام إعادة تعيين Codex"; +"%d window reset." = "تمت إعادة تعيين %d نافذة."; +"Codex reset failed" = "فشلت إعادة تعيين Codex"; +"Available reset" = "Available reset"; "byte_unit_byte" = "بايت"; "byte_unit_bytes" = "بايتات"; "byte_unit_kilobyte" = "كيلوبايت"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index a0ca85435c..5973625588 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -969,6 +969,18 @@ "Other (%d items)" = "Altres (%d elements)"; "Expand" = "Amplia"; "Collapse" = "Redueix"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "bytes"; "byte_unit_kilobyte" = "quilobyte"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index ea2cfc1164..1e847dde94 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1112,6 +1112,18 @@ "Other (%d items)" = "Andere (%d Elemente)"; "Expand" = "Aufklappen"; "Collapse" = "Zuklappen"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "Byte"; "byte_unit_bytes" = "Byte"; "byte_unit_kilobyte" = "Kilobyte"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 32142e332d..a9b2e6623e 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1114,7 +1114,21 @@ "Limit Reset Credits" = "Limit Reset Credits"; "1 available" = "1 available"; "%d available" = "%d available"; +"1 manual reset available" = "1 reset available"; +"%d manual resets available" = "%d resets available"; +"Reset bank" = "Reset bank"; "Next expires %@" = "Next expires %@"; +"Use Reset" = "Use Reset"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "bytes"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index e30bfb810e..0d9e10d966 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -969,6 +969,18 @@ "Other (%d items)" = "Otros (%d elementos)"; "Expand" = "Expandir"; "Collapse" = "Contraer"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "bytes"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 31fe59ee28..290a1317ff 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1114,7 +1114,21 @@ "Limit Reset Credits" = "اعتبارهای بازنشانی محدودیت"; "1 available" = "۱ مورد موجود"; "%d available" = "%d مورد موجود"; +"1 manual reset available" = "۱ بازنشانی دستی موجود است"; +"%d manual resets available" = "%d بازنشانی دستی موجود است"; +"Reset bank" = "بانک بازنشانی"; "Next expires %@" = "مورد بعدی در %@ منقضی می‌شود"; +"Use Reset" = "استفاده از بازنشانی"; +"Use the next expiring Codex reset credit" = "از اعتبار بازنشانی Codex که زودتر منقضی می‌شود استفاده کن"; +"No expiry" = "بدون انقضا"; +"Codex reset expires soon" = "بازنشانی Codex به‌زودی منقضی می‌شود"; +"A Codex reset credit expires %@." = "یک اعتبار بازنشانی Codex در %@ منقضی می‌شود."; +"Use Codex reset?" = "از بازنشانی Codex استفاده شود؟"; +"This spends one banked Codex reset credit now." = "این کار اکنون یک اعتبار بازنشانی ذخیره‌شده Codex را مصرف می‌کند."; +"Codex reset used" = "بازنشانی Codex استفاده شد"; +"%d window reset." = "%d پنجره بازنشانی شد."; +"Codex reset failed" = "بازنشانی Codex ناموفق بود"; +"Available reset" = "Available reset"; "byte_unit_byte" = "بایت"; "byte_unit_bytes" = "بایت"; "byte_unit_kilobyte" = "کیلوبایت"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index f0de7de2e6..00d0652b73 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1110,6 +1110,18 @@ "Other (%d items)" = "Autres (%d éléments)"; "Expand" = "Développer"; "Collapse" = "Réduire"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "octet"; "byte_unit_bytes" = "octets"; "byte_unit_kilobyte" = "kilooctet"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 611fade335..1e2a43d041 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1113,7 +1113,11 @@ "Limit Reset Credits" = "Kredit pengaturan ulang batas"; "1 available" = "1 tersedia"; "%d available" = "%d tersedia"; +"1 manual reset available" = "1 pengaturan ulang manual tersedia"; +"%d manual resets available" = "%d pengaturan ulang manual tersedia"; +"Reset bank" = "Bank reset"; "Next expires %@" = "Berikutnya kedaluwarsa %@"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "byte"; "byte_unit_kilobyte" = "kilobyte"; @@ -1122,3 +1126,14 @@ "byte_unit_megabytes" = "megabyte"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabyte"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta macOS Keychain untuk “%@” agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; +"Use Reset" = "Gunakan Reset"; +"Use the next expiring Codex reset credit" = "Gunakan kredit reset Codex yang paling dekat kedaluwarsa"; +"No expiry" = "Tidak ada kedaluwarsa"; +"Codex reset expires soon" = "Reset Codex segera kedaluwarsa"; +"A Codex reset credit expires %@." = "Kredit reset Codex kedaluwarsa %@."; +"Use Codex reset?" = "Gunakan reset Codex?"; +"This spends one banked Codex reset credit now." = "Ini akan memakai satu kredit reset Codex yang tersimpan sekarang."; +"Codex reset used" = "Reset Codex digunakan"; +"%d window reset." = "%d jendela direset."; +"Codex reset failed" = "Reset Codex gagal"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 3cf3387e1d..eaa76b045d 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1113,7 +1113,11 @@ "Limit Reset Credits" = "Crediti di reimpostazione limite"; "1 available" = "1 disponibile"; "%d available" = "%d disponibili"; +"1 manual reset available" = "1 reimpostazione manuale disponibile"; +"%d manual resets available" = "%d reimpostazioni manuali disponibili"; +"Reset bank" = "Banca reset"; "Next expires %@" = "La prossima scade %@"; +"Available reset" = "Reimpostazione disponibile"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "byte"; "byte_unit_kilobyte" = "kilobyte"; @@ -1122,3 +1126,13 @@ "byte_unit_megabytes" = "megabyte"; "byte_unit_gigabyte" = "gigabyte"; "byte_unit_gigabytes" = "gigabyte"; +"Use Reset" = "Usa reset"; +"Use the next expiring Codex reset credit" = "Usa il prossimo credito reset Codex in scadenza"; +"No expiry" = "Nessuna scadenza"; +"Codex reset expires soon" = "Reset Codex in scadenza"; +"A Codex reset credit expires %@." = "Un credito reset Codex scade %@."; +"Use Codex reset?" = "Usare il reset Codex?"; +"This spends one banked Codex reset credit now." = "Questo usa ora un credito reset Codex accumulato."; +"Codex reset used" = "Reset Codex usato"; +"%d window reset." = "%d finestra reimpostata."; +"Codex reset failed" = "Reset Codex non riuscito"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 52a10af8d2..ca40785444 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1111,6 +1111,18 @@ "Other (%d items)" = "その他(%d項目)"; "Expand" = "展開"; "Collapse" = "折りたたむ"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "バイト"; "byte_unit_bytes" = "バイト"; "byte_unit_kilobyte" = "キロバイト"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 356a5d73d6..d7c67c6039 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1081,6 +1081,18 @@ "Other (%d items)" = "기타(%d개 항목)"; "Expand" = "펼치기"; "Collapse" = "접기"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "바이트"; "byte_unit_bytes" = "바이트"; "byte_unit_kilobyte" = "킬로바이트"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 5b8c38fda5..745dec476e 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1110,6 +1110,18 @@ "Other (%d items)" = "Overig (%d onderdelen)"; "Expand" = "Uitvouwen"; "Collapse" = "Invouwen"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "bytes"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 397c781098..92a237276b 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1113,7 +1113,11 @@ "Limit Reset Credits" = "Kredyty resetowania limitu"; "1 available" = "1 dostępny"; "%d available" = "%d dostępne"; +"1 manual reset available" = "Dostępny 1 ręczny reset"; +"%d manual resets available" = "Dostępne ręczne resety: %d"; +"Reset bank" = "Bank resetów"; "Next expires %@" = "Następny wygasa %@"; +"Available reset" = "Available reset"; "byte_unit_byte" = "bajt"; "byte_unit_bytes" = "bajty"; "byte_unit_kilobyte" = "kilobajt"; @@ -1122,3 +1126,13 @@ "byte_unit_megabytes" = "megabajty"; "byte_unit_gigabyte" = "gigabajt"; "byte_unit_gigabytes" = "gigabajty"; +"Use Reset" = "Użyj resetu"; +"Use the next expiring Codex reset credit" = "Użyj najbliżej wygasającego kredytu resetu Codex"; +"No expiry" = "Brak terminu ważności"; +"Codex reset expires soon" = "Reset Codex wkrótce wygaśnie"; +"A Codex reset credit expires %@." = "Kredyt resetu Codex wygasa %@."; +"Use Codex reset?" = "Użyć resetu Codex?"; +"This spends one banked Codex reset credit now." = "To zużyje teraz jeden zachowany kredyt resetu Codex."; +"Codex reset used" = "Reset Codex użyty"; +"%d window reset." = "Zresetowano %d okno."; +"Codex reset failed" = "Reset Codex nie powiódł się"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 6aaba0ad88..0bee6909fb 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1111,6 +1111,18 @@ "Other (%d items)" = "Outros (%d itens)"; "Expand" = "Expandir"; "Collapse" = "Recolher"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "bytes"; "byte_unit_kilobyte" = "quilobyte"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index a0da03b065..b2ce02e205 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1109,6 +1109,18 @@ "Other (%d items)" = "Övrigt (%d objekt)"; "Expand" = "Expandera"; "Collapse" = "Fäll ihop"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "byte"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index bddab10f87..b734341342 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1114,7 +1114,21 @@ "Limit Reset Credits" = "เครดิตรีเซ็ตขีดจำกัด"; "1 available" = "1 รายการ"; "%d available" = "%d รายการ"; +"1 manual reset available" = "มีการรีเซ็ตด้วยตนเอง 1 ครั้ง"; +"%d manual resets available" = "มีการรีเซ็ตด้วยตนเอง %d ครั้ง"; +"Reset bank" = "คลังรีเซ็ต"; "Next expires %@" = "รายการถัดไปหมดอายุ %@"; +"Use Reset" = "ใช้การรีเซ็ต"; +"Use the next expiring Codex reset credit" = "ใช้เครดิตรีเซ็ต Codex ที่หมดอายุเร็วที่สุด"; +"No expiry" = "ไม่มีวันหมดอายุ"; +"Codex reset expires soon" = "การรีเซ็ต Codex จะหมดอายุเร็ว ๆ นี้"; +"A Codex reset credit expires %@." = "เครดิตรีเซ็ต Codex จะหมดอายุ %@."; +"Use Codex reset?" = "ใช้การรีเซ็ต Codex?"; +"This spends one banked Codex reset credit now." = "การดำเนินการนี้จะใช้เครดิตรีเซ็ต Codex ที่เก็บไว้หนึ่งรายการตอนนี้"; +"Codex reset used" = "ใช้การรีเซ็ต Codex แล้ว"; +"%d window reset." = "รีเซ็ต %d หน้าต่างแล้ว"; +"Codex reset failed" = "รีเซ็ต Codex ไม่สำเร็จ"; +"Available reset" = "Available reset"; "byte_unit_byte" = "ไบต์"; "byte_unit_bytes" = "ไบต์"; "byte_unit_kilobyte" = "กิโลไบต์"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 602073b9c9..3385e5647b 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1111,7 +1111,11 @@ "Limit Reset Credits" = "Limit Sıfırlama Kredileri"; "1 available" = "1 kullanılabilir"; "%d available" = "%d kullanılabilir"; +"1 manual reset available" = "1 manuel sıfırlama kullanılabilir"; +"%d manual resets available" = "%d manuel sıfırlama kullanılabilir"; +"Reset bank" = "Sıfırlama bankası"; "Next expires %@" = "Sonraki sona erme %@"; +"Available reset" = "Available reset"; "byte_unit_byte" = "bayt"; "byte_unit_bytes" = "bayt"; "byte_unit_kilobyte" = "kilobayt"; @@ -1120,3 +1124,13 @@ "byte_unit_megabytes" = "megabayt"; "byte_unit_gigabyte" = "gigabayt"; "byte_unit_gigabytes" = "gigabayt"; +"Use Reset" = "Sıfırlamayı kullan"; +"Use the next expiring Codex reset credit" = "En yakında süresi dolacak Codex sıfırlama kredisini kullan"; +"No expiry" = "Son kullanma yok"; +"Codex reset expires soon" = "Codex sıfırlaması yakında sona eriyor"; +"A Codex reset credit expires %@." = "Bir Codex sıfırlama kredisi %@ sona eriyor."; +"Use Codex reset?" = "Codex sıfırlaması kullanılsın mı?"; +"This spends one banked Codex reset credit now." = "Bu, saklanan bir Codex sıfırlama kredisini şimdi harcar."; +"Codex reset used" = "Codex sıfırlaması kullanıldı"; +"%d window reset." = "%d pencere sıfırlandı."; +"Codex reset failed" = "Codex sıfırlaması başarısız"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 23a067dab2..06e52ce252 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1110,6 +1110,18 @@ "Other (%d items)" = "Інше (%d елементів)"; "Expand" = "Розгорнути"; "Collapse" = "Згорнути"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "байт"; "byte_unit_bytes" = "байти"; "byte_unit_kilobyte" = "кілобайт"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index a4bbbd86b4..849f49eb3a 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1111,6 +1111,18 @@ "Other (%d items)" = "Khác (%d mục)"; "Expand" = "Mở rộng"; "Collapse" = "Thu gọn"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "byte"; "byte_unit_bytes" = "byte"; "byte_unit_kilobyte" = "kilobyte"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index b11bba9ed2..e39c4d4d1d 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1087,6 +1087,18 @@ "Other (%d items)" = "其他(%d 项)"; "Expand" = "展开"; "Collapse" = "收起"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "字节"; "byte_unit_bytes" = "字节"; "byte_unit_kilobyte" = "千字节"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 3c6a0aca3f..06e39a700f 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -983,6 +983,18 @@ "Other (%d items)" = "其他(%d 個項目)"; "Expand" = "展開"; "Collapse" = "收合"; +"Reset bank" = "Reset bank"; +"Use Reset" = "Use Reset"; +"No expiry" = "No expiry"; +"Codex reset expires soon" = "Codex reset expires soon"; +"A Codex reset credit expires %@." = "A Codex reset credit expires %@."; +"Use Codex reset?" = "Use Codex reset?"; +"This spends one banked Codex reset credit now." = "This spends one banked Codex reset credit now."; +"Codex reset used" = "Codex reset used"; +"%d window reset." = "%d window reset."; +"Codex reset failed" = "Codex reset failed"; +"Use the next expiring Codex reset credit" = "Use the next expiring Codex reset credit"; +"Available reset" = "Available reset"; "byte_unit_byte" = "位元組"; "byte_unit_bytes" = "位元組"; "byte_unit_kilobyte" = "千位元組"; diff --git a/Sources/CodexBar/StatusItemController+CodexResetCredits.swift b/Sources/CodexBar/StatusItemController+CodexResetCredits.swift new file mode 100644 index 0000000000..36703cf72c --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CodexResetCredits.swift @@ -0,0 +1,178 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + nonisolated static func splitMenuUsageSectionModels( + model: UsageMenuCardView.Model, + layoutModel: UsageMenuCardView.Model, + hasNativeResetCreditsItem: Bool) + -> (model: UsageMenuCardView.Model, layoutModel: UsageMenuCardView.Model) + { + guard hasNativeResetCreditsItem else { + return (model, layoutModel) + } + var usageModel = model + var usageLayoutModel = layoutModel + usageModel.codexResetCredits = nil + usageLayoutModel.codexResetCredits = nil + return (usageModel, usageLayoutModel) + } + + @discardableResult + func addCodexResetCreditsMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .codex, + self.settings.showOptionalCreditsAndExtraUsage, + let resetCredits = self.store.snapshot(for: .codex)?.codexResetCredits + else { + return false + } + let now = Date() + let availableCredits = resetCredits.availableCredits(at: now) + guard !availableCredits.isEmpty else { return false } + + let title = Self.codexResetCreditsTitle(availableCredits.count) + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = true + item.representedObject = "codexResetCredits" + if let subtitle = Self.codexResetCreditsNextExpiryText( + resetCredits, + resetStyle: self.settings.resetTimeDisplayStyle, + now: now) + { + self.applySubtitle(subtitle, to: item, title: title) + } + item.submenu = self.makeCodexResetCreditsSubmenu(resetCredits, now: now) + menu.addItem(item) + return true + } + + private func makeCodexResetCreditsSubmenu( + _ resetCredits: CodexRateLimitResetCreditsSnapshot, + now: Date) -> NSMenu + { + let availableCredits = resetCredits.availableCredits(at: now) + let nextCredit = resetCredits.nextExpiringAvailableCredit(at: now) + let submenu = NSMenu(title: Self.codexResetCreditsTitle(availableCredits.count)) + submenu.autoenablesItems = false + + for credit in resetCredits.credits { + let item = NSMenuItem(title: Self.codexResetCreditLine(credit, now: now), action: nil, keyEquivalent: "") + item.isEnabled = false + item.image = Self.codexResetCreditIcon(for: credit, now: now) + submenu.addItem(item) + } + + if nextCredit != nil { + submenu.addItem(.separator()) + } + + let useItem = NSMenuItem( + title: L("Use Reset"), + action: #selector(self.consumeCodexResetCreditFromMenu(_:)), + keyEquivalent: "") + useItem.target = self + useItem.representedObject = nextCredit?.id + useItem.isEnabled = nextCredit != nil + submenu.addItem(useItem) + return submenu + } + + private static func codexResetCreditsTitle(_ availableCount: Int) -> String { + if availableCount == 1 { + return L("1 manual reset available") + } + return String(format: L("%d manual resets available"), availableCount) + } + + private static func codexResetCreditsNextExpiryText( + _ resetCredits: CodexRateLimitResetCreditsSnapshot, + resetStyle: ResetTimeDisplayStyle, + now: Date) + -> String? + { + guard let expiresAt = resetCredits.nextExpiringAvailableCredit(at: now)?.expiresAt else { + return nil + } + + let timeText: String + switch resetStyle { + case .absolute: + timeText = UsageFormatter.resetDescription(from: expiresAt, now: now) + case .countdown: + let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now) + timeText = countdown == "now" ? L("now") : countdown + } + + return String(format: L("Next expires %@"), timeText) + } + + private static func codexResetCreditLine(_ credit: CodexRateLimitResetCredit, now: Date) -> String { + let expires = Self.codexResetCreditExpiryText(credit, now: now) + return "\(credit.status.rawValue), \(expires)" + } + + private static func codexResetCreditExpiryText(_ credit: CodexRateLimitResetCredit, now: Date) -> String { + guard let expiresAt = credit.expiresAt else { return L("No expiry") } + let absolute = UsageFormatter.resetDescription(from: expiresAt, now: now) + guard credit.status == .available, expiresAt > now else { return absolute } + let countdown = UsageFormatter.resetCountdownDescription(from: expiresAt, now: now) + return "\(countdown) (\(absolute))" + } + + private static func codexResetCreditIcon(for credit: CodexRateLimitResetCredit, now: Date) -> NSImage? { + guard credit.status == .available, + (credit.expiresAt ?? .distantPast) > now, + let image = NSImage( + systemSymbolName: "arrow.trianglehead.2.counterclockwise.rotate.90", + accessibilityDescription: L("Available reset")) + else { + return nil + } + + image.isTemplate = true + image.size = NSSize(width: 16, height: 16) + return image + } + + @objc func consumeCodexResetCreditFromMenu(_ sender: NSMenuItem) { + guard let creditID = sender.representedObject as? String, + let credit = self.store.snapshot(for: .codex)?.codexResetCredits? + .availableCredits(at: Date()) + .first(where: { $0.id == creditID }) + else { + return + } + self.consumeCodexResetCredit(credit) + } + + func consumeCodexResetCredit( + _ credit: CodexRateLimitResetCredit, + codexActiveSourceOverride: CodexActiveSource? = nil) + { + let alert = NSAlert() + alert.messageText = L("Use Codex reset?") + alert.informativeText = L("This spends one banked Codex reset credit now.") + alert.addButton(withTitle: L("Use Reset")) + alert.addButton(withTitle: L("Cancel")) + guard alert.runModal() == .alertFirstButtonReturn else { return } + + Task { @MainActor [weak self] in + guard let self else { return } + do { + let result = try await self.store.consumeCodexResetCredit( + credit, + codexActiveSourceOverride: codexActiveSourceOverride) + AppNotifications.shared.post( + idPrefix: "codex-reset-credit-used-\(credit.id)", + title: L("Codex reset used"), + body: String(format: L("%d window reset."), result.windowsReset)) + self.invalidateMenus() + } catch { + AppNotifications.shared.post( + idPrefix: "codex-reset-credit-failed-\(credit.id)", + title: L("Codex reset failed"), + body: error.localizedDescription) + } + } + } +} diff --git a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift index 257d48ea1f..aebd81ef9d 100644 --- a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift +++ b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore extension StatusItemController { func addStackedCodexMenuCards( @@ -35,7 +36,10 @@ extension StatusItemController { width: context.menuWidth, heightCacheScope: account.id, heightCacheFingerprint: model.heightFingerprint(section: "card"), - containsInteractiveControls: true)) + containsInteractiveControls: true, + resetCreditConsumer: { [weak self, source = account.selectionSource] credit in + self?.consumeCodexResetCredit(credit, codexActiveSourceOverride: source) + })) cardIndex += 1 if account.id != section.accounts.last?.id { menu.addItem(.separator()) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 086615c44e..649da2103d 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -674,7 +674,10 @@ extension StatusItemController { if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, layoutModel: renderedModel, width: context.menuWidth), + UsageMenuCardView( + model: model, + layoutModel: renderedModel, + width: context.menuWidth), id: "menuCard", width: context.menuWidth, heightCacheScope: context.currentProvider.rawValue, @@ -1215,11 +1218,17 @@ extension StatusItemController { webItems: OpenAIWebMenuItems) { let provider = layoutModel.provider - let hasUsageBlock = layoutModel.hasUsageContent let hasCredits = layoutModel.creditsText != nil let hasExtraUsage = layoutModel.providerCost != nil let hasCost = layoutModel.tokenUsage != nil let hasStorage = self.store.storageFootprintText(for: provider) != nil + let hasResetCredits = provider == .codex && + (self.store.snapshot(for: .codex)?.codexResetCredits?.availableCount ?? 0) > 0 + let usageSectionModels = Self.splitMenuUsageSectionModels( + model: model, + layoutModel: layoutModel, + hasNativeResetCreditsItem: hasResetCredits) + let hasUsageBlock = usageSectionModels.layoutModel.hasUsageContent let bottomPadding = CGFloat(hasCredits ? 4 : 6) let sectionSpacing = CGFloat(6) let usageBottomPadding = bottomPadding @@ -1231,8 +1240,8 @@ extension StatusItemController { if hasUsageBlock { let usageView = UsageMenuCardHeaderAndUsageSectionView( - model: model, - layoutModel: layoutModel, + model: usageSectionModels.model, + layoutModel: usageSectionModels.layoutModel, bottomPadding: usageBottomPadding, width: width) let usageSubmenu = self.makeUsageSubmenu( @@ -1245,7 +1254,7 @@ extension StatusItemController { id: "menuCardUsage", width: width, heightCacheScope: provider.rawValue, - heightCacheFingerprint: layoutModel.heightFingerprint(section: "usage"), + heightCacheFingerprint: usageSectionModels.layoutModel.heightFingerprint(section: "usage"), submenu: usageSubmenu, containsInteractiveControls: true)) } else { @@ -1262,7 +1271,13 @@ extension StatusItemController { containsInteractiveControls: true)) } - if hasStorage || hasCredits || hasExtraUsage || hasCost { + if hasResetCredits || hasStorage || hasCredits || hasExtraUsage || hasCost { + addSectionSeparator() + } + + if self.addCodexResetCreditsMenuItemIfNeeded(to: menu, provider: provider), + hasStorage || hasCredits || hasExtraUsage || hasCost + { addSectionSeparator() } diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 860d2c8875..e8713b2a67 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore import SwiftUI extension StatusItemController { @@ -34,9 +35,13 @@ extension StatusItemController { submenuIndicatorTopPadding: CGFloat = 8, containsInteractiveControls: Bool = false, usesGPUSelection: Bool = false, + resetCreditConsumer: ((CodexRateLimitResetCredit) -> Void)? = nil, onClick: (() -> Void)? = nil) -> NSMenuItem { let allowsMenuHighlight = submenu != nil || onClick != nil + let resetCreditConsumer = resetCreditConsumer ?? { [weak self] credit in + self?.consumeCodexResetCredit(credit) + } if !self.menuCardRenderingEnabledForController { let item = NSMenuItem() item.isEnabled = allowsMenuHighlight @@ -58,7 +63,8 @@ extension StatusItemController { showsSubmenuIndicator: submenu != nil, submenuIndicatorAlignment: submenuIndicatorAlignment, submenuIndicatorTopPadding: submenuIndicatorTopPadding, - refreshMonitor: self.menuCardRefreshMonitor) + refreshMonitor: self.menuCardRefreshMonitor, + resetCreditConsumer: resetCreditConsumer) { view } @@ -92,7 +98,8 @@ extension StatusItemController { showsSubmenuIndicator: submenu != nil, submenuIndicatorAlignment: submenuIndicatorAlignment, submenuIndicatorTopPadding: submenuIndicatorTopPadding, - refreshMonitor: self.menuCardRefreshMonitor) + refreshMonitor: self.menuCardRefreshMonitor, + resetCreditConsumer: resetCreditConsumer) { view } @@ -108,7 +115,8 @@ extension StatusItemController { showsSubmenuIndicator: submenu != nil, submenuIndicatorAlignment: submenuIndicatorAlignment, submenuIndicatorTopPadding: submenuIndicatorTopPadding, - refreshMonitor: self.menuCardRefreshMonitor) + refreshMonitor: self.menuCardRefreshMonitor, + resetCreditConsumer: resetCreditConsumer) { view } diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 70dba06660..4d9a97792a 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -474,12 +474,14 @@ struct MenuCardSectionContainerView: View { let submenuIndicatorAlignment: Alignment let submenuIndicatorTopPadding: CGFloat var refreshMonitor: MenuCardRefreshMonitor? + var resetCreditConsumer: ((CodexRateLimitResetCredit) -> Void)? @ViewBuilder let content: () -> Content var body: some View { self.content() .environment(\.menuItemHighlighted, self.highlightState.isHighlighted) .environment(\.menuCardRefreshMonitor, self.refreshMonitor) + .environment(\.codexResetCreditConsumer, self.resetCreditConsumer) .foregroundStyle(MenuHighlightStyle.primary(self.highlightState.isHighlighted)) .background(alignment: .topLeading) { if self.highlightState.isHighlighted { diff --git a/Sources/CodexBar/UsageStore+CodexResetCredits.swift b/Sources/CodexBar/UsageStore+CodexResetCredits.swift new file mode 100644 index 0000000000..b674629d13 --- /dev/null +++ b/Sources/CodexBar/UsageStore+CodexResetCredits.swift @@ -0,0 +1,64 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + func handleCodexResetCreditNotifications(snapshot: UsageSnapshot) { + guard self.settings.showOptionalCreditsAndExtraUsage, + let resetCredits = snapshot.codexResetCredits + else { return } + CodexResetCreditExpiryNotifier().postExpiringCreditsIfNeeded(snapshot: resetCredits) + } + + func codexResetCreditEnvironment(codexActiveSourceOverride: CodexActiveSource? = nil) -> [String: String] { + self.makeFetchContext( + provider: .codex, + override: nil, + codexActiveSourceOverride: codexActiveSourceOverride).env + } + + func fetchCodexResetCreditsIfAvailable(env: [String: String]) async -> CodexRateLimitResetCreditsSnapshot? { + guard self.settings.showOptionalCreditsAndExtraUsage else { return nil } + if let override = self._test_codexResetCreditsFetcherOverride { + return await override(env) + } + return await Self.fetchCodexResetCredits(env: env) + } + + nonisolated static func fetchCodexResetCredits( + env: [String: String]) async -> CodexRateLimitResetCreditsSnapshot? + { + do { + var credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: env) + if credentials.needsRefresh, !credentials.refreshToken.isEmpty { + credentials = try await CodexTokenRefresher.refresh(credentials) + try CodexOAuthCredentialsStore.save(credentials, env: env) + } + return try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits( + accessToken: credentials.accessToken, + accountId: credentials.accountId, + env: env) + } catch { + return nil + } + } + + func consumeCodexResetCredit( + _ credit: CodexRateLimitResetCredit, + codexActiveSourceOverride: CodexActiveSource? = nil) async throws + -> CodexRateLimitResetCreditConsumption { + let env = self.codexResetCreditEnvironment(codexActiveSourceOverride: codexActiveSourceOverride) + var credentials = try CodexOAuthCredentialsStore.loadOAuthTokens(env: env) + if credentials.needsRefresh, !credentials.refreshToken.isEmpty { + credentials = try await CodexTokenRefresher.refresh(credentials) + try CodexOAuthCredentialsStore.save(credentials, env: env) + } + let result = try await CodexOAuthUsageFetcher.consumeRateLimitResetCredit( + id: credit.id, + accessToken: credentials.accessToken, + accountId: credentials.accountId, + env: env) + await self.refreshProvider(.codex) + return result + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index aa86a8ae96..aa821a9fa7 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -5,6 +5,7 @@ extension UsageStore { private struct ProviderRefreshOutcomeContext { let generation: UInt64 let codexExpectedGuard: CodexAccountScopedRefreshGuard? + let codexResetCreditEnvironment: [String: String]? let claudeCredentialsChanged: Bool let shouldConsumeClaudeKeychainFingerprint: Bool let claudeOAuthHistoryPersistentRefHash: String? @@ -217,6 +218,7 @@ extension UsageStore { context: ProviderRefreshOutcomeContext( generation: generation, codexExpectedGuard: codexExpectedGuard, + codexResetCreditEnvironment: provider == .codex ? fetchContext.env : nil, claudeCredentialsChanged: claudeCredentialsChanged, shouldConsumeClaudeKeychainFingerprint: shouldConsumeClaudeKeychainFingerprint, claudeOAuthHistoryPersistentRefHash: claudeOAuthHistoryPersistentRefHash)) @@ -240,6 +242,7 @@ extension UsageStore { { return } + let codexResetCredits = await self.fetchCodexResetCreditsIfAvailable(context: context) let backfilled = await MainActor.run { () -> UsageSnapshot? in guard self.isCurrentProviderRefreshGeneration(provider, generation: context.generation) else { return nil @@ -253,9 +256,18 @@ extension UsageStore { let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( current: scoped, previous: self.snapshots[provider]) - let backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource) + var backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource) + if provider == .codex { + let resetCredits = self.settings.showOptionalCreditsAndExtraUsage + ? codexResetCredits ?? scoped.codexResetCredits ?? self.snapshots[provider]?.codexResetCredits + : nil + backfilled = backfilled.withCodexResetCredits(resetCredits) + } self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) + if provider == .codex { + self.handleCodexResetCreditNotifications(snapshot: backfilled) + } self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { @@ -328,6 +340,13 @@ extension UsageStore { } } + private func fetchCodexResetCreditsIfAvailable( + context: ProviderRefreshOutcomeContext) async -> CodexRateLimitResetCreditsSnapshot? + { + guard let env = context.codexResetCreditEnvironment else { return nil } + return await self.fetchCodexResetCreditsIfAvailable(env: env) + } + private func clearDisabledProviderRefreshState(_ provider: UsageProvider) async { self.refreshingProviders.remove(provider) await MainActor.run { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 3d84aa2835..4afa570cff 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -69,6 +69,7 @@ private struct CodexAccountFetchResult { let index: Int let account: CodexVisibleAccount let outcome: ProviderFetchOutcome + let resetCredits: CodexRateLimitResetCreditsSnapshot? } private struct CodexManagedVisibleAccountRuntimeState { @@ -140,6 +141,7 @@ extension UsageStore { outcome, account: account, priorSnapshot: priorByAccountID[account.id], + supplementalResetCredits: result.resetCredits, resetBackfillSnapshots: self.codexResetBackfillSnapshots( for: account, priorSnapshot: priorByAccountID[account.id], @@ -611,6 +613,8 @@ extension UsageStore { private func fetchCodexVisibleAccountOutcomes(_ accounts: [CodexVisibleAccount]) async -> [CodexAccountFetchResult] { + let shouldFetchResetCredits = self.settings.showOptionalCreditsAndExtraUsage + let resetCreditsFetcherOverride = self._test_codexResetCreditsFetcherOverride let requests: [( index: Int, account: CodexVisibleAccount, @@ -633,10 +637,28 @@ extension UsageStore { for request in requests { group.addTask { let outcome = await request.descriptor.fetchOutcome(context: request.context) + let embeddedResetCredits: CodexRateLimitResetCreditsSnapshot? = switch outcome.result { + case let .success(result): + result.usage.codexResetCredits + case .failure: + nil + } + let resetCredits: CodexRateLimitResetCreditsSnapshot? = if let embeddedResetCredits { + embeddedResetCredits + } else if shouldFetchResetCredits { + if let resetCreditsFetcherOverride { + await resetCreditsFetcherOverride(request.context.env) + } else { + await UsageStore.fetchCodexResetCredits(env: request.context.env) + } + } else { + nil + } return CodexAccountFetchResult( index: request.index, account: request.account, - outcome: outcome) + outcome: outcome, + resetCredits: resetCredits) } } @@ -1106,12 +1128,17 @@ extension UsageStore { _ outcome: ProviderFetchOutcome, account: CodexVisibleAccount, priorSnapshot: CodexAccountUsageSnapshot? = nil, + supplementalResetCredits: CodexRateLimitResetCreditsSnapshot? = nil, resetBackfillSnapshots: [UsageSnapshot] = []) -> ResolvedCodexAccountOutcome { switch outcome.result { case let .success(result): let scoped = result.usage.scoped(to: .codex) - let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) + let resetCredits = supplementalResetCredits ?? scoped.codexResetCredits ?? + priorSnapshot?.snapshot?.codexResetCredits + let labeled = self.applyCodexVisibleAccountLabel( + scoped.withCodexResetCredits(resetCredits), + account: account) let backfilled = Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots) .map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled let snapshot = CodexAccountUsageSnapshot( @@ -1184,7 +1211,11 @@ extension UsageStore { self.lastFetchAttempts[.codex] = outcome.attempts switch outcome.result { case .success: - guard let snapshot else { return } + guard var snapshot else { return } + if !self.settings.showOptionalCreditsAndExtraUsage { + snapshot = snapshot.withCodexResetCredits(nil) + } + self.handleCodexResetCreditNotifications(snapshot: snapshot) self.handleSessionQuotaTransition(provider: .codex, snapshot: snapshot) self.lastKnownResetSnapshots[.codex] = snapshot self.lastCodexAccountScopedRefreshGuard = Self.codexScopedRefreshGuard(for: account) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e02a2443a5..e6b7999d97 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -205,6 +205,8 @@ final class UsageStore { @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? @ObservationIgnored var _test_providerStatusFetchOverride: (@MainActor ( UsageProvider) async throws -> ProviderStatus)? + @ObservationIgnored var _test_codexResetCreditsFetcherOverride: (@Sendable ( + [String: String]) async -> CodexRateLimitResetCreditsSnapshot?)? @ObservationIgnored var _test_startupConnectivityRetryScheduled: (@MainActor (Int, TimeInterval) -> Void)? @ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor ( TimeInterval) async throws -> Void)? diff --git a/Sources/CodexBarCore/CreditsModels.swift b/Sources/CodexBarCore/CreditsModels.swift index cab9dc55d2..6311751fce 100644 --- a/Sources/CodexBarCore/CreditsModels.swift +++ b/Sources/CodexBarCore/CreditsModels.swift @@ -101,10 +101,17 @@ public struct CodexRateLimitResetCreditsSnapshot: Equatable, Codable, Sendable { } public var nextExpiringAvailableCredit: CodexRateLimitResetCredit? { - self.credits - .filter { credit in - credit.status == .available && (credit.expiresAt ?? .distantPast) > self.updatedAt - } + self.nextExpiringAvailableCredit(at: self.updatedAt) + } + + public func availableCredits(at date: Date) -> [CodexRateLimitResetCredit] { + self.credits.filter { credit in + credit.status == .available && (credit.expiresAt ?? .distantPast) > date + } + } + + public func nextExpiringAvailableCredit(at date: Date) -> CodexRateLimitResetCredit? { + self.availableCredits(at: date) .min { lhs, rhs in guard let lhsExpiresAt = lhs.expiresAt else { return false } guard let rhsExpiresAt = rhs.expiresAt else { return true } @@ -113,6 +120,24 @@ public struct CodexRateLimitResetCreditsSnapshot: Equatable, Codable, Sendable { } } +public struct CodexRateLimitResetCreditConsumption: Equatable, Codable, Sendable { + public let code: String + public let credit: CodexRateLimitResetCredit? + public let windowsReset: Int + + public init(code: String, credit: CodexRateLimitResetCredit?, windowsReset: Int) { + self.code = code + self.credit = credit + self.windowsReset = windowsReset + } + + private enum CodingKeys: String, CodingKey { + case code + case credit + case windowsReset = "windows_reset" + } +} + public struct CodexRateLimitResetCredit: Equatable, Codable, Sendable, Identifiable { public let id: String public let resetType: String diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index bb0902d2dd..1721fb0ba9 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -74,39 +74,59 @@ public enum CodexOAuthCredentialsStore { return try self.parse(data: data) } + public static func loadOAuthTokens(env: [String: String] = ProcessInfo.processInfo + .environment) throws -> CodexOAuthCredentials + { + let url = self.authFilePath(env: env) + guard FileManager.default.fileExists(atPath: url.path) else { + throw CodexOAuthCredentialsError.notFound + } + + let data = try Data(contentsOf: url) + guard let credentials = try self.tokenCredentials(data: data) else { + throw CodexOAuthCredentialsError.missingTokens + } + return credentials + } + public static func parse(data: Data) throws -> CodexOAuthCredentials { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") } - if let apiKey = json["OPENAI_API_KEY"] as? String, - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return CodexOAuthCredentials( - accessToken: apiKey, - refreshToken: "", - idToken: nil, - accountId: nil, - lastRefresh: nil) + if let apiKeyCredentials = Self.apiKeyCredentials(in: json) { + return apiKeyCredentials } - guard let tokens = json["tokens"] as? [String: Any] else { - throw CodexOAuthCredentialsError.missingTokens + if let tokenCredentials = Self.tokenCredentials(in: json) { + return tokenCredentials } - guard let accessToken = Self.stringValue(in: tokens, snakeCaseKey: "access_token", camelCaseKey: "accessToken"), - let refreshToken = Self.stringValue( + + throw CodexOAuthCredentialsError.missingTokens + } + + private static func tokenCredentials(data: Data) throws -> CodexOAuthCredentials? { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") + } + return self.tokenCredentials(in: json) + } + + private static func tokenCredentials(in json: [String: Any]) -> CodexOAuthCredentials? { + guard let tokens = json["tokens"] as? [String: Any], + let accessToken = stringValue(in: tokens, snakeCaseKey: "access_token", camelCaseKey: "accessToken"), + let refreshToken = stringValue( in: tokens, snakeCaseKey: "refresh_token", camelCaseKey: "refreshToken"), !accessToken.isEmpty else { - throw CodexOAuthCredentialsError.missingTokens + return nil } let idToken = Self.stringValue(in: tokens, snakeCaseKey: "id_token", camelCaseKey: "idToken") let accountId = Self.stringValue(in: tokens, snakeCaseKey: "account_id", camelCaseKey: "accountId") let lastRefresh = Self.parseLastRefresh(from: json["last_refresh"]) - return CodexOAuthCredentials( accessToken: accessToken, refreshToken: refreshToken, @@ -115,6 +135,20 @@ public enum CodexOAuthCredentialsStore { lastRefresh: lastRefresh) } + private static func apiKeyCredentials(in json: [String: Any]) -> CodexOAuthCredentials? { + guard let apiKey = json["OPENAI_API_KEY"] as? String, + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + return CodexOAuthCredentials( + accessToken: apiKey, + refreshToken: "", + idToken: nil, + accountId: nil, + lastRefresh: nil) + } + public static func save( _ credentials: CodexOAuthCredentials, env: [String: String] = ProcessInfo.processInfo.environment) throws diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 128a32d8fe..7da52b0b5b 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -439,6 +439,59 @@ public enum CodexOAuthUsageFetcher { } } + public static func consumeRateLimitResetCredit( + id creditID: String, + accessToken: String, + accountId: String?, + redeemRequestID: String = UUID().uuidString, + env: [String: String] = ProcessInfo.processInfo.environment, + timeout: TimeInterval = 10, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws + -> CodexRateLimitResetCreditConsumption + { + var request = URLRequest(url: Self.resolveRateLimitResetCreditsConsumeURL(env: env), timeoutInterval: timeout) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("codex-1", forHTTPHeaderField: "OpenAI-Beta") + request.setValue("Codex Desktop", forHTTPHeaderField: "originator") + + if let accountId, !accountId.isEmpty { + request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-ID") + } + + request.httpBody = try JSONEncoder().encode(RateLimitResetCreditConsumeRequest( + creditID: creditID, + redeemRequestID: redeemRequestID)) + + do { + let response = try await transport.response(for: request) + let data = response.data + + switch response.statusCode { + case 200...299: + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom(Self.decodeISO8601Date) + return try decoder.decode(CodexRateLimitResetCreditConsumption.self, from: data) + } catch { + throw CodexOAuthFetchError.invalidResponse + } + case 401, 403: + throw CodexOAuthFetchError.unauthorized + default: + let body = String(data: data, encoding: .utf8) + throw CodexOAuthFetchError.serverError(response.statusCode, body) + } + } catch let error as CodexOAuthFetchError { + throw error + } catch { + throw CodexOAuthFetchError.networkError(error) + } + } + private static func resolveUsageURL(env: [String: String]) -> URL { self.resolveUsageURL(env: env, configContents: nil) } @@ -462,6 +515,15 @@ public enum CodexOAuthUsageFetcher { return URL(string: full) ?? URL(string: Self.defaultChatGPTBaseURL + Self.rateLimitResetCreditsPath)! } + private static func resolveRateLimitResetCreditsConsumeURL(env: [String: String]) -> URL { + self.resolveRateLimitResetCreditsConsumeURL(env: env, configContents: nil) + } + + private static func resolveRateLimitResetCreditsConsumeURL(env: [String: String], configContents: String?) -> URL { + self.resolveRateLimitResetCreditsURL(env: env, configContents: configContents) + .appendingPathComponent("consume") + } + private static func resolveChatGPTBaseURL(env: [String: String], configContents: String?) -> String { if let configContents, let parsed = self.parseChatGPTBaseURL(from: configContents) { return parsed @@ -527,6 +589,16 @@ public enum CodexOAuthUsageFetcher { } } + private struct RateLimitResetCreditConsumeRequest: Encodable { + let creditID: String + let redeemRequestID: String + + private enum CodingKeys: String, CodingKey { + case creditID = "credit_id" + case redeemRequestID = "redeem_request_id" + } + } + private static func decodeISO8601Date(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) @@ -560,14 +632,24 @@ extension CodexOAuthUsageFetcher { self.resolveRateLimitResetCreditsURL(env: env, configContents: configContents) } - static func _decodeRateLimitResetCreditsForTesting(_ data: Data) throws -> CodexRateLimitResetCreditsSnapshot { + static func _resolveRateLimitResetCreditsConsumeURLForTesting( + env: [String: String] = [:], + configContents: String? = nil) -> URL + { + self.resolveRateLimitResetCreditsConsumeURL(env: env, configContents: configContents) + } + + static func _decodeRateLimitResetCreditsForTesting( + _ data: Data, + now: Date = Date()) throws -> CodexRateLimitResetCreditsSnapshot + { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom(Self.decodeISO8601Date) let payload = try decoder.decode(RateLimitResetCreditsResponse.self, from: data) return CodexRateLimitResetCreditsSnapshot( credits: payload.credits, availableCount: payload.availableCount, - updatedAt: Date()) + updatedAt: now) } } #endif diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshCreditsTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshCreditsTests.swift index 97a8aaad57..13bb8f7a98 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshCreditsTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshCreditsTests.swift @@ -4,6 +4,108 @@ import Testing @testable import CodexBar extension CodexAccountScopedRefreshTests { + @Test + func `stacked codex account refresh keeps reset credits scoped to each account`() async throws { + let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-reset-credits") + settings.refreshFrequency = .manual + settings.multiAccountMenuLayout = .stacked + settings.showOptionalCreditsAndExtraUsage = true + + let liveHome = FileManager.default.temporaryDirectory + .appendingPathComponent("live-\(UUID().uuidString)", isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("managed-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: liveHome, + email: "live@example.com", + plan: "pro", + accountId: "acct-live") + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "pro", + accountId: "acct-managed") + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: liveHome) + try? FileManager.default.removeItem(at: managedHome) + try? FileManager.default.removeItem(at: managedStoreURL) + } + + settings._test_managedCodexAccountStoreURL = managedStoreURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: liveHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-live")) + settings.codexActiveSource = .liveSystem + + let store = self.makeUsageStore(settings: settings) + self.installContextualCodexProvider(on: store) { context in + let email = context.env["CODEX_HOME"] == liveHome.path + ? "live@example.com" + : "managed@example.com" + return UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Pro")) + } + store._test_codexResetCreditsFetcherOverride = { env in + guard let home = env["CODEX_HOME"] else { return nil } + let now = Date() + return CodexRateLimitResetCreditsSnapshot( + credits: [ + CodexRateLimitResetCredit( + id: URL(fileURLWithPath: home).lastPathComponent, + resetType: "codex_rate_limits", + status: .available, + grantedAt: now, + expiresAt: now.addingTimeInterval(86400), + redeemStartedAt: nil, + redeemedAt: nil, + title: "One free rate limit reset", + description: nil), + ], + availableCount: 1, + updatedAt: now) + } + defer { store._test_codexResetCreditsFetcherOverride = nil } + + await store.refreshCodexVisibleAccountsForMenu() + + #expect(store.codexAccountSnapshots.count == 2) + for accountSnapshot in store.codexAccountSnapshots { + let expectedCreditID = switch accountSnapshot.account.selectionSource { + case .liveSystem: + liveHome.lastPathComponent + case .managedAccount: + managedHome.lastPathComponent + case .profileHome: + "unexpected-profile" + } + #expect(accountSnapshot.snapshot?.codexResetCredits?.credits.first?.id == expectedCreditID) + } + } + @Test func `credits refresh honors explicit codex oauth source without raw CLI fallback`() async throws { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-oauth-credits-source") diff --git a/Tests/CodexBarTests/CodexManagedRoutingTests.swift b/Tests/CodexBarTests/CodexManagedRoutingTests.swift index 2a2e2fcec3..18dac1303f 100644 --- a/Tests/CodexBarTests/CodexManagedRoutingTests.swift +++ b/Tests/CodexBarTests/CodexManagedRoutingTests.swift @@ -615,6 +615,43 @@ struct CodexManagedRoutingTests { let account = context.fetcher.loadAccountInfo() #expect(account.email == "override@example.com") #expect(account.plan == "pro") + let resetCreditEnv = store.codexResetCreditEnvironment( + codexActiveSourceOverride: .managedAccount(id: managedAccount.id)) + #expect(resetCreditEnv["CODEX_HOME"] == managedHome.path) + #expect(settings.codexActiveSource == .liveSystem) + } + + @Test + func `usage store builds reset credit consume environment with source override`() throws { + let settings = self.makeSettingsStore(suite: "CodexManagedRoutingTests-reset-consume-env") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/codex-reset-consume-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-reset-consume-\(UUID().uuidString).json") + let managedStore = FileManagedCodexAccountStore(fileURL: storeURL) + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [managedAccount])) + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: ["CODEX_HOME": "/tmp/live-codex-home"]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + let env = store.codexResetCreditEnvironment(codexActiveSourceOverride: .managedAccount(id: managedAccount.id)) + + #expect(env["CODEX_HOME"] == managedAccount.managedHomePath) #expect(settings.codexActiveSource == .liveSystem) } diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 291c4cb10a..eeb1a228c0 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +// swiftlint:disable:next type_body_length struct CodexOAuthTests { private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) @@ -41,8 +42,68 @@ struct CodexOAuthTests { #expect(creds.lastRefresh != nil) } - @Test - func `parses legacy camel case O auth credentials`() throws { + @Test func `prefers API key over O auth tokens by default`() throws { + let json = """ + { + "OPENAI_API_KEY": "sk-test", + "tokens": { + "access_token": "access-token", + "refresh_token": "refresh-token", + "account_id": "account-123" + }, + "last_refresh": "2025-12-20T12:34:56Z" + } + """ + + let creds = try CodexOAuthCredentialsStore.parse(data: Data(json.utf8)) + + #expect(creds.accessToken == "sk-test") + #expect(creds.refreshToken.isEmpty) + #expect(creds.accountId == nil) + } + + @Test func `loads O auth tokens for reset credits when API key also exists`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let credentials = CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: nil, + accountId: "account-123", + lastRefresh: Date()) + try CodexOAuthCredentialsStore.save(credentials, env: ["CODEX_HOME": home.path]) + let url = CodexOAuthCredentialsStore._authFileURLForTesting(env: ["CODEX_HOME": home.path]) + var json = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any] + json?["OPENAI_API_KEY"] = "sk-test" + let data = try JSONSerialization.data(withJSONObject: json ?? [:]) + try data.write(to: url, options: .atomic) + + let creds = try CodexOAuthCredentialsStore.loadOAuthTokens(env: ["CODEX_HOME": home.path]) + + #expect(creds.accessToken == "access-token") + #expect(creds.refreshToken == "refresh-token") + #expect(creds.accountId == "account-123") + } + + @Test func `falls back to API key when O auth tokens are incomplete`() throws { + let json = """ + { + "OPENAI_API_KEY": "sk-test", + "tokens": { + "access_token": "stale-access-token" + } + } + """ + + let creds = try CodexOAuthCredentialsStore.parse(data: Data(json.utf8)) + + #expect(creds.accessToken == "sk-test") + #expect(creds.refreshToken.isEmpty) + #expect(creds.accountId == nil) + } + + @Test func `parses legacy camel case O auth credentials`() throws { let json = """ { "OPENAI_API_KEY": null, diff --git a/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift index cade97d1a5..881bf2f940 100644 --- a/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift +++ b/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift @@ -120,12 +120,68 @@ struct CodexRateLimitResetCreditsTests { } """ - let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting(Data(json.utf8)) + let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting( + Data(json.utf8), + now: #require(ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z"))) #expect(snapshot.availableCount == 2) #expect(snapshot.credits.count == 4) #expect(snapshot.credits[0].resetType == "codex_rate_limits") #expect(snapshot.credits[3].status == .unknown("future_status")) #expect(snapshot.nextExpiringAvailableCredit?.id == "RateLimitResetCredit_earlier") + let later = try #require(ISO8601DateFormatter().date(from: "2026-07-19T00:00:01Z")) + #expect(snapshot.availableCredits(at: later).isEmpty) + #expect(snapshot.nextExpiringAvailableCredit(at: later) == nil) + } + + @Test + func `consume request scopes auth account and body`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url? + .absoluteString == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume") + #expect(request.httpMethod == "POST") + #expect(request.timeoutInterval == 10) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-ID") == "account-123") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try #require(request.httpBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["credit_id"] == "reset-123") + #expect(json["redeem_request_id"] == "request-123") + + let payload = """ + { + "code": "reset", + "windows_reset": 1, + "credit": { + "id": "reset-123", + "reset_type": "codex_rate_limits", + "status": "redeemed", + "granted_at": "2026-06-12T04:03:43Z", + "expires_at": "2026-07-12T04:03:43Z", + "redeem_started_at": "2026-06-20T04:03:43Z", + "redeemed_at": "2026-06-20T04:03:44Z", + "title": "One free rate limit reset", + "description": null + } + } + """ + return (Data(payload.utf8), HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)!) + } + + let result = try await CodexOAuthUsageFetcher.consumeRateLimitResetCredit( + id: "reset-123", + accessToken: "test-token", + accountId: "account-123", + redeemRequestID: "request-123", + session: transport) + + #expect(result.code == "reset") + #expect(result.windowsReset == 1) + #expect(result.credit?.status == .redeemed) } } diff --git a/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift new file mode 100644 index 0000000000..4eeb7ed80a --- /dev/null +++ b/Tests/CodexBarTests/CodexResetCreditExpiryNotifierTests.swift @@ -0,0 +1,49 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CodexResetCreditExpiryNotifierTests { + @Test + func `posts once for available credits expiring within three days`() throws { + let defaults = try #require(UserDefaults(suiteName: "CodexResetCreditExpiryNotifierTests")) + defaults.removeObject(forKey: "codexResetCreditExpiryNotificationsPosted") + let now = Date(timeIntervalSince1970: 1_781_726_400) + var posted: [(String, String, String)] = [] + let notifier = CodexResetCreditExpiryNotifier(userDefaults: defaults) { id, title, body in + posted.append((id, title, body)) + } + let snapshot = CodexRateLimitResetCreditsSnapshot( + credits: [ + Self.credit(id: "soon", status: .available, expiresAt: now.addingTimeInterval(2 * 24 * 60 * 60)), + Self.credit(id: "later", status: .available, expiresAt: now.addingTimeInterval(4 * 24 * 60 * 60)), + Self.credit(id: "used", status: .redeemed, expiresAt: now.addingTimeInterval(1 * 24 * 60 * 60)), + ], + availableCount: 2, + updatedAt: now) + + notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, now: now) + notifier.postExpiringCreditsIfNeeded(snapshot: snapshot, now: now) + + #expect(posted.map(\.0) == ["codex-reset-credit-expiring-soon"]) + #expect(defaults.stringArray(forKey: "codexResetCreditExpiryNotificationsPosted") == ["soon"]) + } + + private static func credit( + id: String, + status: CodexRateLimitResetCreditStatus, + expiresAt: Date) -> CodexRateLimitResetCredit + { + CodexRateLimitResetCredit( + id: id, + resetType: "codex_rate_limits", + status: status, + grantedAt: expiresAt.addingTimeInterval(-7 * 24 * 60 * 60), + expiresAt: expiresAt, + redeemStartedAt: nil, + redeemedAt: nil, + title: "Reset", + description: nil) + } +} diff --git a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift index 66297aef30..f5d91f630c 100644 --- a/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift +++ b/Tests/CodexBarTests/CodexResetCreditsMenuCardTests.swift @@ -39,8 +39,10 @@ struct CodexResetCreditsMenuCardTests { showOptionalUsage: true, now: now)) - #expect(model.codexResetCreditsText == "1 available") - #expect(model.codexResetCreditsDetailText == "Next expires in 1d") + #expect(model.codexResetCredits?.text == "1 available") + #expect(model.codexResetCredits?.detailText == "Next expires in 1d") + #expect(model.codexResetCredits?.helpText?.contains("available, in 1d (") == true) + #expect(model.codexResetCredits?.creditToConsume?.id == "reset-1") } @Test @@ -88,7 +90,57 @@ struct CodexResetCreditsMenuCardTests { showOptionalUsage: true, now: now)) - #expect(model.codexResetCreditsText == "2 available") + #expect(model.codexResetCredits?.text == "2 available") + } + + @Test + func `reset credits exclude expired cached entries from count and action`() throws { + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let now = Date(timeIntervalSince1970: 1_781_726_400) + let usage = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + codexResetCredits: CodexRateLimitResetCreditsSnapshot( + credits: [ + CodexRateLimitResetCredit( + id: "expired-reset", + resetType: "codex_rate_limits", + status: .available, + grantedAt: now.addingTimeInterval(-172_800), + expiresAt: now.addingTimeInterval(-60), + redeemStartedAt: nil, + redeemedAt: nil, + title: "One free rate limit reset", + description: nil), + CodexRateLimitResetCredit( + id: "current-reset", + resetType: "codex_rate_limits", + status: .available, + grantedAt: now.addingTimeInterval(-86400), + expiresAt: now.addingTimeInterval(86400), + redeemStartedAt: nil, + redeemedAt: nil, + title: "One free rate limit reset", + description: nil), + ], + availableCount: 2, + updatedAt: now.addingTimeInterval(-120)), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro")) + + let model = UsageMenuCardView.Model.make(Self.input( + metadata: metadata, + snapshot: usage, + showOptionalUsage: true, + now: now)) + + #expect(model.codexResetCredits?.text == "1 available") + #expect(model.codexResetCredits?.detailText == "Next expires in 1d") + #expect(model.codexResetCredits?.creditToConsume?.id == "current-reset") } @Test @@ -110,8 +162,39 @@ struct CodexResetCreditsMenuCardTests { showOptionalUsage: false, now: now)) - #expect(model.codexResetCreditsText == nil) - #expect(model.codexResetCreditsDetailText == nil) + #expect(model.codexResetCredits == nil) + } + + @Test + func `split menu usage card omits reset credits when native reset item exists`() { + let model = Self.modelWithResetCredits() + + let split = StatusItemController.splitMenuUsageSectionModels( + model: model, + layoutModel: model, + hasNativeResetCreditsItem: true) + #expect(split.model.codexResetCredits == nil) + #expect(split.layoutModel.codexResetCredits == nil) + + let unsplit = StatusItemController.splitMenuUsageSectionModels( + model: model, + layoutModel: model, + hasNativeResetCreditsItem: false) + #expect(unsplit.model.codexResetCredits?.text == "1 available") + #expect(unsplit.layoutModel.codexResetCredits?.text == "1 available") + } + + @Test + func `split reset credit only card has no usage section content`() { + let model = Self.modelWithResetCredits(includeMetric: false) + + let split = StatusItemController.splitMenuUsageSectionModels( + model: model, + layoutModel: model, + hasNativeResetCreditsItem: true) + + #expect(model.hasUsageContent) + #expect(split.layoutModel.hasUsageContent == false) } private static func input( @@ -140,4 +223,46 @@ struct CodexResetCreditsMenuCardTests { hidePersonalInfo: false, now: now) } + + private static func modelWithResetCredits(includeMetric: Bool = true) -> UsageMenuCardView.Model { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "", + subtitleText: "Signed in", + subtitleStyle: .info, + planText: nil, + metrics: includeMetric ? [ + .init( + id: "primary", + title: "5-hour limit", + percent: 25, + percentStyle: .left, + statusText: "25%", + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true), + ] : [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsProgressPercent: nil, + creditsScaleText: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + codexResetCredits: CodexResetCreditsPresentation( + text: "1 available", + detailText: "Next expires in 1d", + helpText: nil, + creditToConsume: nil), + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .blue) + } } diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift index b54e852e7f..4ea0be8629 100644 --- a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -1,3 +1,5 @@ +import CodexBarCore +import Foundation import SwiftUI import Testing @testable import CodexBar @@ -33,9 +35,34 @@ struct MenuCardHeightFingerprintTests { #expect(left != changedPercent) } + @Test + func `height fingerprint tracks codex reset credit presentation shape`() { + let base = Self.model().heightFingerprint(section: "card") + let withButton = Self.model(resetCredits: .init( + text: "1 manual reset available", + detailText: "Next expires in 1d", + helpText: "available, in 1d", + creditToConsume: Self.resetCredit())).heightFingerprint(section: "card") + let withoutButton = Self.model(resetCredits: .init( + text: "1 manual reset available", + detailText: "Next expires in 1d", + helpText: "available, in 1d", + creditToConsume: nil)).heightFingerprint(section: "card") + let changedDetail = Self.model(resetCredits: .init( + text: "2 manual resets available", + detailText: "Next expires in 2d", + helpText: "available, in 2d", + creditToConsume: Self.resetCredit())).heightFingerprint(section: "card") + + #expect(base != withButton) + #expect(withButton != withoutButton) + #expect(withButton != changedDetail) + } + private static func model( percent: Double = 42, - percentStyle: UsageMenuCardView.Model.PercentStyle = .left) -> UsageMenuCardView.Model + percentStyle: UsageMenuCardView.Model.PercentStyle = .left, + resetCredits: CodexResetCreditsPresentation? = nil) -> UsageMenuCardView.Model { UsageMenuCardView.Model( provider: .codex, @@ -67,9 +94,23 @@ struct MenuCardHeightFingerprintTests { creditsScaleText: nil, creditsHintText: nil, creditsHintCopyText: nil, + codexResetCredits: resetCredits, providerCost: nil, tokenUsage: nil, placeholder: nil, progressColor: .blue) } + + private static func resetCredit() -> CodexRateLimitResetCredit { + CodexRateLimitResetCredit( + id: "reset-1", + resetType: "codex_rate_limits", + status: .available, + grantedAt: Date(timeIntervalSince1970: 1), + expiresAt: Date(timeIntervalSince1970: 2), + redeemStartedAt: nil, + redeemedAt: nil, + title: "Reset", + description: nil) + } }