Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions Sources/CodexBar/CostHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ struct CostHistoryChartMenuView: View {
let model = Self.makeModel(provider: self.provider, daily: self.daily)
VStack(alignment: .leading, spacing: 10) {
if model.points.isEmpty {
Text("No cost history data.")
Text(L("No cost history data."))
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityLabel("No cost history data available.")
.accessibilityLabel(L("No cost history data available."))
} else {
Chart {
ForEach(model.points) { point in
Expand Down Expand Up @@ -82,8 +82,10 @@ struct CostHistoryChartMenuView: View {
}
.chartLegend(.hidden)
.frame(height: 130)
.accessibilityLabel("Cost history chart")
.accessibilityValue(model.points.isEmpty ? "No data" : "\(model.points.count) days of cost data")
.accessibilityLabel(L("Cost history chart"))
.accessibilityValue(model.points.isEmpty ? L("No data") : String(
format: L("%@ days of cost data"),
"\(model.points.count)"))
.chartOverlay { proxy in
GeometryReader { geo in
ZStack(alignment: .topLeading) {
Expand Down Expand Up @@ -148,7 +150,7 @@ struct CostHistoryChartMenuView: View {
}

if let total = self.totalCostUSD {
Text("Est. total (30d): \(UsageFormatter.usdString(total))")
Text(String(format: L("Est. total (30d): %@"), UsageFormatter.usdString(total)))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Expand Down Expand Up @@ -354,13 +356,13 @@ struct CostHistoryChartMenuView: View {
let point = model.pointsByDateKey[key],
let date = Self.dateFromDayKey(key)
else {
return DetailContent(primary: "Hover a bar for details", rows: [])
return DetailContent(primary: L("Hover a bar for details"), rows: [])
}

let dayLabel = date.formatted(.dateTime.month(.abbreviated).day())
let cost = UsageFormatter.usdString(point.costUSD)
let primary = if let tokens = point.totalTokens {
"\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens"
String(format: L("%1$@: %2$@ · %3$@ tokens"), dayLabel, cost, UsageFormatter.tokenCountString(tokens))
} else {
"\(dayLabel): \(cost)"
}
Expand Down
22 changes: 13 additions & 9 deletions Sources/CodexBar/CreditsHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ struct CreditsHistoryChartMenuView: View {
let model = Self.makeModel(from: self.breakdown)
VStack(alignment: .leading, spacing: 10) {
if model.points.isEmpty {
Text("No credits history data.")
Text(L("No credits history data."))
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityLabel("No credits history data available.")
.accessibilityLabel(L("No credits history data available."))
} else {
Chart {
ForEach(model.points) { point in
Expand Down Expand Up @@ -62,8 +62,10 @@ struct CreditsHistoryChartMenuView: View {
}
.chartLegend(.hidden)
.frame(height: 130)
.accessibilityLabel("Credits history chart")
.accessibilityValue(model.points.isEmpty ? "No data" : "\(model.points.count) days of credits data")
.accessibilityLabel(L("Credits history chart"))
.accessibilityValue(model.points.isEmpty ? L("No data") : String(
format: L("%@ days of credits data"),
"\(model.points.count)"))
.chartOverlay { proxy in
GeometryReader { geo in
ZStack(alignment: .topLeading) {
Expand Down Expand Up @@ -101,7 +103,9 @@ struct CreditsHistoryChartMenuView: View {
}

if let total = model.totalCreditsUsed {
Text("Total (30d): \(total.formatted(.number.precision(.fractionLength(0...2)))) credits")
Text(String(
format: L("Total (30d): %@ credits"),
total.formatted(.number.precision(.fractionLength(0...2)))))
.font(.caption)
.foregroundStyle(.secondary)
}
Expand Down Expand Up @@ -302,17 +306,17 @@ struct CreditsHistoryChartMenuView: View {
let day = model.breakdownByDayKey[key],
let date = Self.dateFromDayKey(key)
else {
return ("Hover a bar for details", nil)
return (L("Hover a bar for details"), nil)
}

let dayLabel = date.formatted(.dateTime.month(.abbreviated).day())
let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2)))
if day.services.isEmpty {
return ("\(dayLabel): \(total) credits", nil)
return (String(format: L("%1$@: %2$@ credits"), dayLabel, total), nil)
}
if day.services.count <= 1, let first = day.services.first {
let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2)))
return ("\(dayLabel): \(used) credits", first.service)
return (String(format: L("%1$@: %2$@ credits"), dayLabel, used), first.service)
}

let services = day.services
Expand All @@ -324,6 +328,6 @@ struct CreditsHistoryChartMenuView: View {
.map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" }
.joined(separator: " · ")

return ("\(dayLabel): \(total) credits", services)
return (String(format: L("%1$@: %2$@ credits"), dayLabel, total), services)
}
}
11 changes: 7 additions & 4 deletions Sources/CodexBar/CursorLoginRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ final class CursorLoginRunner {
await self.resetSessionCache()

guard self.openURL(Self.authURL) else {
let message = "Could not open Cursor login in your browser."
let message = L("Could not open Cursor login in your browser.")
onPhaseChange(.failed(message))
self.logger.error("Cursor login browser launch failed")
return Result(outcome: .failed(message), email: nil)
Expand Down Expand Up @@ -103,10 +103,13 @@ final class CursorLoginRunner {
}

private static func timeoutMessage(lastError: Error?) -> String {
let hint = "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar."
let hint = L("Sign in to cursor.com in your browser, then refresh Cursor in CodexBar.")
guard let lastError else {
return "Timed out waiting for Cursor login. \(hint)"
return String(format: L("Timed out waiting for Cursor login. %@"), hint)
}
return "Timed out waiting for Cursor login. \(hint) Last error: \(lastError.localizedDescription)"
return String(
format: L("Timed out waiting for Cursor login. %@ Last error: %@"),
hint,
lastError.localizedDescription)
}
}
18 changes: 6 additions & 12 deletions Sources/CodexBar/Date+RelativeDescription.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import Foundation

enum RelativeTimeFormatters {
@MainActor
static let full: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.locale = Locale(identifier: "en_US")
formatter.unitsStyle = .full
return formatter
}()
}

extension Date {
@MainActor
func relativeDescription(now: Date = .now) -> String {
let seconds = abs(now.timeIntervalSince(self))
if seconds < 15 {
return "just now"
return L("just now")
}
return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now)

let formatter = RelativeDateTimeFormatter()
formatter.locale = codexBarLocalizationLocale()
formatter.unitsStyle = .full
return formatter.localizedString(for: self, relativeTo: now)
}
}
98 changes: 32 additions & 66 deletions Sources/CodexBar/KeychainPromptCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,96 +22,62 @@ enum KeychainPromptCoordinator {
}

private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) {
let title = "Keychain Access Required"
let message = [
"CodexBar will ask macOS Keychain for “\(context.label)” so it can decrypt browser cookies",
"and authenticate your account. Click OK to continue.",
].joined(separator: " ")
let title = L("Keychain Access Required")
let messageTemplate = L(
"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies " +
"and authenticate your account. Click OK to continue.")
let message = String(
format: messageTemplate,
context.label)
self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label])
self.presentAlert(title: title, message: message)
}

private static func keychainCopy(for context: KeychainPromptContext) -> (title: String, message: String) {
let title = "Keychain Access Required"
let title = L("Keychain Access Required")
switch context.kind {
case .claudeOAuth:
return (title, [
"CodexBar will ask macOS Keychain for the Claude Code OAuth token",
"so it can fetch your Claude usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "the Claude Code OAuth token", purpose: "your Claude usage"))
case .codexCookie:
return (title, [
"CodexBar will ask macOS Keychain for your OpenAI cookie header",
"so it can fetch Codex dashboard extras. Click OK to continue.",
].joined(separator: " "))
return (
title,
self.keychainFetchMessage(item: "your OpenAI cookie header", purpose: "Codex dashboard extras"))
case .claudeCookie:
return (title, [
"CodexBar will ask macOS Keychain for your Claude cookie header",
"so it can fetch Claude web usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Claude cookie header", purpose: "Claude web usage"))
case .cursorCookie:
return (title, [
"CodexBar will ask macOS Keychain for your Cursor cookie header",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Cursor cookie header"))
case .opencodeCookie:
return (title, [
"CodexBar will ask macOS Keychain for your OpenCode cookie header",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your OpenCode cookie header"))
case .factoryCookie:
return (title, [
"CodexBar will ask macOS Keychain for your Factory cookie header",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Factory cookie header"))
case .zaiToken:
return (title, [
"CodexBar will ask macOS Keychain for your z.ai API token",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your z.ai API token"))
case .syntheticToken:
return (title, [
"CodexBar will ask macOS Keychain for your Synthetic API key",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Synthetic API key"))
case .copilotToken:
return (title, [
"CodexBar will ask macOS Keychain for your GitHub Copilot token",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your GitHub Copilot token"))
case .kimiToken:
return (title, [
"CodexBar will ask macOS Keychain for your Kimi auth token",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Kimi auth token"))
case .kimiK2Token:
return (title, [
"CodexBar will ask macOS Keychain for your Kimi K2 API key",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Kimi K2 API key"))
case .minimaxCookie:
return (title, [
"CodexBar will ask macOS Keychain for your MiniMax cookie header",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your MiniMax cookie header"))
case .minimaxToken:
return (title, [
"CodexBar will ask macOS Keychain for your MiniMax API token",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your MiniMax API token"))
case .augmentCookie:
return (title, [
"CodexBar will ask macOS Keychain for your Augment cookie header",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Augment cookie header"))
case .ampCookie:
return (title, [
"CodexBar will ask macOS Keychain for your Amp cookie header",
"so it can fetch usage. Click OK to continue.",
].joined(separator: " "))
return (title, self.keychainFetchMessage(item: "your Amp cookie header"))
}
}

private static func keychainFetchMessage(item: String, purpose: String = "usage") -> String {
String(
format: L("CodexBar will ask macOS Keychain for %@ so it can fetch %@. Click OK to continue."),
L(item),
L(purpose))
}

private static func presentAlert(title: String, message: String) {
self.promptLock.lock()
defer { self.promptLock.unlock() }
Expand Down
28 changes: 26 additions & 2 deletions Sources/CodexBar/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ private func localizedBundle() -> Bundle {
return resourceBundle
}

func codexBarLocalizationLocale() -> Locale {
let language = appLanguageDefaults().string(forKey: "appLanguage") ?? ""
if !language.isEmpty {
return Locale(identifier: language)
}
return .autoupdatingCurrent
}

private func lprojBundle(named language: String, in resourceBundle: Bundle) -> Bundle? {
let candidates = [language, language.lowercased()]
for candidate in candidates where !candidate.isEmpty {
Expand All @@ -70,10 +78,26 @@ private func lprojBundle(named language: String, in resourceBundle: Bundle) -> B
return nil
}

private func localizedString(for key: String) -> String {
let bundle = localizedBundle()
let value = bundle.localizedString(forKey: key, value: nil, table: nil)
guard value == key else { return value }

let resourceBundle = codexBarLocalizationResourceBundle()
guard bundle.bundleURL.lastPathComponent != "en.lproj",
let englishBundle = lprojBundle(named: "en", in: resourceBundle)
else {
return value
}

let fallback = englishBundle.localizedString(forKey: key, value: nil, table: nil)
return fallback == key ? value : fallback
}

func L(_ key: String) -> String {
localizedBundle().localizedString(forKey: key, value: nil, table: nil)
localizedString(for: key)
}

func L(_ key: String, _ arguments: CVarArg...) -> String {
String(format: localizedBundle().localizedString(forKey: key, value: nil, table: nil), arguments: arguments)
String(format: localizedString(for: key), arguments: arguments)
}
Loading