Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ __pycache__/
.playwright-cli/
.sparkle-dist/
windows/dist/
windows/build/
windows/build-*/
windows/*.spec
windows/dist-*/
windows/.DS_Store
site/assets/codexcontrol-site-preview.png
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ python -m unittest discover -s windows/tests -v
./Scripts/deploy_site.sh
```

### Localization

The desktop apps support a local language preference with a system-language
fallback. When adding or changing user-facing text, update the localization
catalogues for both macOS and Windows and add coverage for the fallback and
Spanish translation where applicable.

### Release Artifacts

```bash
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexControl/App/CodexControlApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import SwiftUI
@main
struct CodexControlApp: App {
@StateObject private var model = AppModel()
@StateObject private var localization = Localization()

var body: some Scene {
MenuBarExtra {
RootView(model: self.model)
.environmentObject(self.localization)
} label: {
HStack(spacing: 0) {
Image(systemName: self.model.menuBarSymbol)
Expand Down
113 changes: 113 additions & 0 deletions Sources/CodexControl/Support/Localization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import SwiftUI

enum AppLanguage: String, CaseIterable, Identifiable {
case system
case english = "en"
case spanish = "es"

var id: String { self.rawValue }
}

@MainActor
final class Localization: ObservableObject {
@Published var language: AppLanguage {
didSet {
UserDefaults.standard.set(self.language.rawValue, forKey: Self.languageKey)
}
}

private static let languageKey = "CodexControl.language"

init() {
let stored = UserDefaults.standard.string(forKey: Self.languageKey)
self.language = AppLanguage(rawValue: stored ?? "system") ?? .system
}

var effectiveLanguage: AppLanguage {
guard self.language == .system else { return self.language }
let languageCode = Locale.current.language.languageCode?.identifier
return languageCode == "es" ? .spanish : .english
}

func text(_ key: String) -> String {
guard self.effectiveLanguage == .spanish else { return key }
return Self.spanish[key] ?? key
}

func text(_ key: String, _ values: CVarArg...) -> String {
String(format: self.text(key), arguments: values)
}

func label(for language: AppLanguage) -> String {
switch (self.effectiveLanguage, language) {
case (.spanish, .system): return "Sistema"
case (.spanish, .english): return "Inglés"
case (.spanish, .spanish): return "Español"
case (_, .system): return "System"
case (_, .english): return "English"
case (_, .spanish): return "Español"
}
}

private static let spanish: [String: String] = [
"Add account": "Añadir cuenta",
"Cancel account setup": "Cancelar configuración",
"Refresh all accounts": "Actualizar todas las cuentas",
"Cancel account setup": "Cancelar configuración",
"Refresh all accounts": "Actualizar todas las cuentas",
"Search accounts": "Buscar cuentas",
"No Accounts": "Sin cuentas",
"Add a Codex account to start tracking quota.": "Añade una cuenta de Codex para empezar a consultar la cuota.",
"Remove Account": "Eliminar cuenta",
"%@ will be removed from CodexControl.": "%@ se eliminará de CodexControl.",
"Cancel": "Cancelar",
"Remove": "Eliminar",
"Refresh": "Actualizar",
"Switch active account": "Cambiar cuenta activa",
"Reauthenticate": "Volver a autenticar",
"Open folder": "Abrir carpeta",
"Label": "Etiqueta",
"Save": "Guardar",
"Active": "Activa",
"None": "Ninguna",
"Last Error": "Último error",
"Account Details": "Detalles de la cuenta",
"Plan details pending": "Detalles del plan pendientes",
"Open Folder": "Abrir carpeta",
"Last updated: %@": "Última actualización: %@",
"Resets: %@": "Se restablece: %@",
"No data": "Sin datos",
"5 Hours": "5 horas",
"7 Days": "7 días",
"Primary Quota": "Cuota principal",
"Secondary Quota": "Cuota secundaria",
"Credits": "Créditos",
"This account appears to have unlimited credits.": "Esta cuenta parece tener créditos ilimitados.",
"Credit balance available.": "Hay saldo de créditos disponible.",
"No extra credits detected.": "No se detectaron créditos adicionales.",
"Switch": "Cambiar",
"Quota reached": "Cuota alcanzada",
"No quota data": "Sin datos de cuota",
"Waiting for data": "Esperando datos",
"Source": "Origen",
"Email": "Correo electrónico",
"Provider ID": "ID del proveedor",
"Unknown": "Desconocido",
"No `auth.json` was found for this account.": "No se encontró `auth.json` para esta cuenta.",
"The required token fields are missing from `auth.json`.": "Faltan campos de token obligatorios en `auth.json`.",
"The Codex usage API request returned unauthorized.": "La API de uso de Codex rechazó la solicitud.",
"The Codex API response was not in the expected format.": "La respuesta de la API de Codex no tenía el formato esperado.",
"Live API responses were inconsistent. The data could not be verified.": "Las respuestas de la API en directo fueron inconsistentes. No se pudieron verificar los datos.",
"The refresh token has expired. Sign in again for this account.": "El token de renovación caducó. Vuelve a iniciar sesión en esta cuenta.",
"The refresh token was revoked. Sign in again for this account.": "El token de renovación fue revocado. Vuelve a iniciar sesión en esta cuenta.",
"The refresh token can no longer be reused. Sign in again for this account.": "El token de renovación ya no se puede reutilizar. Vuelve a iniciar sesión en esta cuenta.",
"Network error: %@": "Error de red: %@",
"Account setup cancelled.": "Se canceló la configuración de la cuenta.",
"The `codex` command could not be found.": "No se encontró el comando `codex`.",
"The Codex sign-in flow timed out.": "El inicio de sesión de Codex agotó el tiempo de espera.",
"Sign-in completed, but the account identity could not be read.": "El inicio de sesión terminó, pero no se pudo leer la identidad de la cuenta.",
"Language": "Idioma",
"%d accounts": "%d cuentas",
"%d accounts, %d critical": "%d cuentas, %d críticas",
]
}
49 changes: 26 additions & 23 deletions Sources/CodexControl/Views/AccountDetailView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI

struct AccountDetailView: View {
@EnvironmentObject private var localization: Localization
let account: StoredAccount
let state: AccountRuntimeState
let isReauthenticating: Bool
Expand All @@ -21,9 +22,9 @@ struct AccountDetailView: View {
if let errorMessage = self.state.errorMessage {
DetailCard {
VStack(alignment: .leading, spacing: 8) {
Text("Last Error")
Text(self.localization.text("Last Error"))
.font(.headline)
Text(errorMessage)
Text(self.localization.text(errorMessage))
.font(.body)
.foregroundStyle(.secondary)
.textSelection(.enabled)
Expand All @@ -33,12 +34,12 @@ struct AccountDetailView: View {

DetailCard {
VStack(alignment: .leading, spacing: 12) {
Text("Account Details")
Text(self.localization.text("Account Details"))
.font(.headline)

InfoRow(title: "Source", value: self.account.source.displayName)
InfoRow(title: "Email", value: self.state.snapshot?.email ?? self.account.emailHint ?? "Unknown")
InfoRow(title: "Provider ID", value: self.state.snapshot?.providerAccountID ?? self.account.providerAccountID ?? "Unknown")
InfoRow(title: self.localization.text("Source"), value: self.account.source.displayName)
InfoRow(title: self.localization.text("Email"), value: self.state.snapshot?.email ?? self.account.emailHint ?? self.localization.text("Unknown"))
InfoRow(title: self.localization.text("Provider ID"), value: self.state.snapshot?.providerAccountID ?? self.account.providerAccountID ?? self.localization.text("Unknown"))
InfoRow(title: "CODEX_HOME", value: self.account.codexHomePath)
}
}
Expand All @@ -61,7 +62,7 @@ struct AccountDetailView: View {
VStack(alignment: .leading, spacing: 6) {
Text(self.account.displayName)
.font(.title2.weight(.semibold))
Text(self.state.snapshot?.planDisplayName ?? "Plan details pending")
Text(self.state.snapshot?.planDisplayName ?? self.localization.text("Plan details pending"))
.foregroundStyle(.secondary)
}
Spacer()
Expand All @@ -71,26 +72,26 @@ struct AccountDetailView: View {
}

HStack(spacing: 8) {
TextField("Label", text: self.$draftNickname)
TextField(self.localization.text("Label"), text: self.$draftNickname)
.textFieldStyle(.roundedBorder)
Button("Save") {
Button(self.localization.text("Save")) {
self.onSaveNickname(self.draftNickname)
}
}

HStack(spacing: 8) {
Button("Refresh", action: self.onRefresh)
Button(self.localization.text("Refresh"), action: self.onRefresh)
.buttonStyle(.borderedProminent)
Button("Reauthenticate", action: self.onReauthenticate)
Button(self.localization.text("Reauthenticate"), action: self.onReauthenticate)
.buttonStyle(.bordered)
Button("Open Folder", action: self.onOpenFolder)
Button(self.localization.text("Open Folder"), action: self.onOpenFolder)
.buttonStyle(.bordered)
Button("Remove", role: .destructive, action: self.onRemove)
Button(self.localization.text("Remove"), role: .destructive, action: self.onRemove)
.buttonStyle(.bordered)
}

if let updatedAt = self.state.snapshot?.updatedAt {
Text("Last updated: \(updatedAt.formatted(date: .abbreviated, time: .shortened))")
Text(self.localization.text("Last updated: %@", updatedAt.formatted(date: .abbreviated, time: .shortened)))
.font(.footnote)
.foregroundStyle(.secondary)
}
Expand All @@ -108,11 +109,11 @@ struct AccountDetailView: View {
spacing: 12)
{
QuotaCard(
title: self.state.snapshot?.primaryWindow?.displayName ?? "Primary Quota",
title: self.localization.text(self.state.snapshot?.primaryWindow?.displayName ?? "Primary Quota"),
accent: self.accent(for: self.state.snapshot?.primaryWindow),
window: self.state.snapshot?.primaryWindow)
QuotaCard(
title: self.state.snapshot?.secondaryWindow?.displayName ?? "Secondary Quota",
title: self.localization.text(self.state.snapshot?.secondaryWindow?.displayName ?? "Secondary Quota"),
accent: self.accent(for: self.state.snapshot?.secondaryWindow),
window: self.state.snapshot?.secondaryWindow)
CreditsCard(snapshot: self.state.snapshot?.credits)
Expand Down Expand Up @@ -148,6 +149,7 @@ private struct DetailCard<Content: View>: View {
}

private struct QuotaCard: View {
@EnvironmentObject private var localization: Localization
let title: String
let accent: Color
let window: UsageWindowSnapshot?
Expand All @@ -165,12 +167,12 @@ private struct QuotaCard: View {
ProgressView(value: window.remainingPercent, total: 100)
.tint(self.accent)
if let resetAt = window.resetAtDisplay {
Text("Resets: \(resetAt)")
Text(self.localization.text("Resets: %@", resetAt))
.font(.footnote)
.foregroundStyle(.secondary)
}
} else {
Text("No data")
Text(self.localization.text("No data"))
.font(.title3.weight(.semibold))
.foregroundStyle(.secondary)
}
Expand All @@ -180,33 +182,34 @@ private struct QuotaCard: View {
}

private struct CreditsCard: View {
@EnvironmentObject private var localization: Localization
let snapshot: CreditsBalanceSnapshot?

var body: some View {
DetailCard {
VStack(alignment: .leading, spacing: 12) {
Text("Credits")
Text(self.localization.text("Credits"))
.font(.headline)

if let snapshot {
Text(snapshot.displayValue)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(Color.accentColor)
if snapshot.unlimited {
Text("This account appears to have unlimited credits.")
Text(self.localization.text("This account appears to have unlimited credits."))
.font(.footnote)
.foregroundStyle(.secondary)
} else if snapshot.hasCredits {
Text("Credit balance available.")
Text(self.localization.text("Credit balance available."))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("No extra credits detected.")
Text(self.localization.text("No extra credits detected."))
.font(.footnote)
.foregroundStyle(.secondary)
}
} else {
Text("No data")
Text(self.localization.text("No data"))
.font(.title3.weight(.semibold))
.foregroundStyle(.secondary)
}
Expand Down
Loading