From 6370dfb81d39d70d7a8b74742dec231a40bc6710 Mon Sep 17 00:00:00 2001 From: Fabian Mendoza <229246299+BlazzzPlay@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:55:26 -0400 Subject: [PATCH 1/2] Add Spanish localization support --- .gitignore | 4 + CONTRIBUTING.md | 7 + .../CodexControl/App/CodexControlApp.swift | 2 + .../CodexControl/Support/Localization.swift | 113 +++++++++ .../Views/AccountDetailView.swift | 49 ++-- .../CodexControl/Views/AccountRowView.swift | 36 +-- Sources/CodexControl/Views/RootView.swift | 33 ++- windows/codexcontrol_windows/app.py | 216 ++++++++++++------ .../codexcontrol_windows/file_locations.py | 1 + windows/codexcontrol_windows/localization.py | 147 ++++++++++++ windows/tests/test_localization.py | 49 ++++ 11 files changed, 530 insertions(+), 127 deletions(-) create mode 100644 Sources/CodexControl/Support/Localization.swift create mode 100644 windows/codexcontrol_windows/localization.py create mode 100644 windows/tests/test_localization.py diff --git a/.gitignore b/.gitignore index e876d15..f205bd6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ceb7de..1720fd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Sources/CodexControl/App/CodexControlApp.swift b/Sources/CodexControl/App/CodexControlApp.swift index 910f319..824e37d 100644 --- a/Sources/CodexControl/App/CodexControlApp.swift +++ b/Sources/CodexControl/App/CodexControlApp.swift @@ -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) diff --git a/Sources/CodexControl/Support/Localization.swift b/Sources/CodexControl/Support/Localization.swift new file mode 100644 index 0000000..adacdb5 --- /dev/null +++ b/Sources/CodexControl/Support/Localization.swift @@ -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", + ] +} diff --git a/Sources/CodexControl/Views/AccountDetailView.swift b/Sources/CodexControl/Views/AccountDetailView.swift index 86800eb..40512d2 100644 --- a/Sources/CodexControl/Views/AccountDetailView.swift +++ b/Sources/CodexControl/Views/AccountDetailView.swift @@ -1,6 +1,7 @@ import SwiftUI struct AccountDetailView: View { + @EnvironmentObject private var localization: Localization let account: StoredAccount let state: AccountRuntimeState let isReauthenticating: Bool @@ -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) @@ -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) } } @@ -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() @@ -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) } @@ -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) @@ -148,6 +149,7 @@ private struct DetailCard: View { } private struct QuotaCard: View { + @EnvironmentObject private var localization: Localization let title: String let accent: Color let window: UsageWindowSnapshot? @@ -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) } @@ -180,12 +182,13 @@ 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 { @@ -193,20 +196,20 @@ private struct CreditsCard: View { .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) } diff --git a/Sources/CodexControl/Views/AccountRowView.swift b/Sources/CodexControl/Views/AccountRowView.swift index 1afbfa5..c853ab2 100644 --- a/Sources/CodexControl/Views/AccountRowView.swift +++ b/Sources/CodexControl/Views/AccountRowView.swift @@ -1,6 +1,7 @@ import SwiftUI struct AccountRowView: View { + @EnvironmentObject private var localization: Localization let account: StoredAccount let state: AccountRuntimeState let isActive: Bool @@ -39,17 +40,17 @@ struct AccountRowView: View { HStack(spacing: 6) { RowActionButton(systemName: "arrow.clockwise", action: self.onRefresh) - .help("Refresh") + .help(self.localization.text("Refresh")) if self.canSwitch { RowActionButton(systemName: "arrow.left.arrow.right.circle", action: self.onSwitch) - .help("Switch active account") + .help(self.localization.text("Switch active account")) } RowActionButton(systemName: "person.crop.circle.badge.checkmark", action: self.onReauthenticate) - .help("Reauthenticate") + .help(self.localization.text("Reauthenticate")) RowActionButton(systemName: "folder", action: self.onOpenFolder) - .help("Open folder") + .help(self.localization.text("Open folder")) RowActionButton(systemName: "trash", role: .destructive, action: self.onRemove) - .help("Remove") + .help(self.localization.text("Remove")) Spacer() @@ -61,7 +62,7 @@ struct AccountRowView: View { } HStack(spacing: 8) { - TextField("Label", text: self.$draftNickname) + TextField(self.localization.text("Label"), text: self.$draftNickname) .textFieldStyle(.plain) .font(.caption) .padding(.horizontal, 8) @@ -73,7 +74,7 @@ struct AccountRowView: View { self.onSaveNickname(self.draftNickname) } - Button("Save") { + Button(self.localization.text("Save")) { self.onSaveNickname(self.draftNickname) } .buttonStyle(.plain) @@ -123,7 +124,7 @@ struct AccountRowView: View { HStack(spacing: 6) { if self.isActive { - Text("Active") + Text(self.localization.text("Active")) .font(.system(size: 10, weight: .semibold, design: .rounded)) .padding(.horizontal, 7) .padding(.vertical, 4) @@ -134,10 +135,10 @@ struct AccountRowView: View { if self.canSwitch && !self.isSelected { HeaderActionPill( - title: "Switch", + title: self.localization.text("Switch"), systemName: "arrow.left.arrow.right", action: self.onSwitch) - .help("Switch active account") + .help(self.localization.text("Switch active account")) } if let snapshot = self.state.snapshot { @@ -168,37 +169,37 @@ struct AccountRowView: View { HStack(alignment: .top, spacing: 7) { if let primaryWindow = snapshot.primaryWindow { CompactQuotaPill( - title: primaryWindow.shortLabel, + title: self.localization.text(primaryWindow.displayName), window: primaryWindow, tint: self.tint(for: primaryWindow)) } if let secondaryWindow = snapshot.secondaryWindow { CompactQuotaPill( - title: secondaryWindow.shortLabel, + title: self.localization.text(secondaryWindow.displayName), window: secondaryWindow, tint: self.tint(for: secondaryWindow)) } } } else if snapshot.isQuotaBlocked { InlineStatePill( - text: "Quota reached", + text: self.localization.text("Quota reached"), systemName: "exclamationmark.circle.fill", tint: .red) } else { InlineStatePill( - text: "No quota data", + text: self.localization.text("No quota data"), systemName: "chart.bar.xaxis", tint: .secondary) } } else if let errorMessage = self.state.errorMessage { InlineStatePill( - text: errorMessage, + text: self.localization.text(errorMessage), systemName: "exclamationmark.triangle.fill", tint: .orange, lineLimit: self.isSelected ? 4 : 2) } else { InlineStatePill( - text: "Waiting for data", + text: self.localization.text("Waiting for data"), systemName: "clock", tint: .secondary) } @@ -267,6 +268,7 @@ private struct CompactQuotaPill: View { } private struct CompactQuotaLine: View { + @EnvironmentObject private var localization: Localization let title: String let window: UsageWindowSnapshot? let tint: Color @@ -283,7 +285,7 @@ private struct CompactQuotaLine: View { .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } else { - Text("None") + Text(self.localization.text("None")) .font(.caption2) .foregroundStyle(.secondary) } diff --git a/Sources/CodexControl/Views/RootView.swift b/Sources/CodexControl/Views/RootView.swift index be9f343..495aa52 100644 --- a/Sources/CodexControl/Views/RootView.swift +++ b/Sources/CodexControl/Views/RootView.swift @@ -2,6 +2,7 @@ import SwiftUI struct RootView: View { @ObservedObject var model: AppModel + @EnvironmentObject private var localization: Localization var body: some View { ZStack { @@ -41,13 +42,21 @@ struct RootView: View { alternateSystemName: "xmark", isActive: self.model.isAddingAccount, activeAction: { self.model.cancelAddAccount() }) - .help(self.model.isAddingAccount ? "Cancel account setup" : "Add account") + .help(self.localization.text(self.model.isAddingAccount ? "Cancel account setup" : "Add account")) HeaderIconButton( systemName: "arrow.clockwise", action: { Task { await self.model.refreshAll() } }) - .help("Refresh all accounts") + .help(self.localization.text("Refresh all accounts")) .disabled(self.model.isRefreshingAll) + + Picker(self.localization.text("Language"), selection: self.$localization.language) { + ForEach(AppLanguage.allCases) { language in + Text(self.localization.label(for: language)).tag(language) + } + } + .labelsHidden() + .frame(width: 90) } } .padding(.horizontal, 2) @@ -58,7 +67,7 @@ struct RootView: View { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) - TextField("Search accounts", text: self.$model.searchText) + TextField(self.localization.text("Search accounts"), text: self.$model.searchText) .textFieldStyle(.plain) if !self.model.searchText.isEmpty { @@ -82,9 +91,9 @@ struct RootView: View { private var accountList: some View { if self.model.filteredAccounts.isEmpty { ContentUnavailableView( - "No Accounts", + self.localization.text("No Accounts"), systemImage: "tray", - description: Text("Add a Codex account to start tracking quota.")) + description: Text(self.localization.text("Add a Codex account to start tracking quota."))) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { @@ -118,14 +127,14 @@ struct RootView: View { private var headerStatusText: String { if let statusMessage = self.model.statusMessage, !statusMessage.isEmpty { - return statusMessage + return self.localization.text(statusMessage) } let lowQuotaCount = self.model.lowQuotaCount if lowQuotaCount > 0 { - return "\(self.model.accountCount) accounts, \(lowQuotaCount) critical" + return self.localization.text("%d accounts, %d critical", self.model.accountCount, lowQuotaCount) } - return "\(self.model.accountCount) accounts" + return self.localization.text("%d accounts", self.model.accountCount) } private func removalOverlay(for account: StoredAccount) -> some View { @@ -137,17 +146,17 @@ struct RootView: View { } VStack(alignment: .leading, spacing: 12) { - Text("Remove Account") + Text(self.localization.text("Remove Account")) .font(.system(size: 14, weight: .semibold)) - Text("\(account.displayName) will be removed from CodexControl.") + Text(self.localization.text("%@ will be removed from CodexControl.", account.displayName)) .font(.system(size: 12)) .foregroundStyle(.secondary) HStack(spacing: 8) { Spacer() - Button("Cancel") { + Button(self.localization.text("Cancel")) { self.model.cancelPendingRemoval() } .buttonStyle(.plain) @@ -157,7 +166,7 @@ struct RootView: View { Capsule(style: .continuous) .fill(Color(NSColor.controlBackgroundColor))) - Button("Remove") { + Button(self.localization.text("Remove")) { self.model.confirmPendingRemoval() } .buttonStyle(.plain) diff --git a/windows/codexcontrol_windows/app.py b/windows/codexcontrol_windows/app.py index ff1dfd9..9f2cedd 100644 --- a/windows/codexcontrol_windows/app.py +++ b/windows/codexcontrol_windows/app.py @@ -22,6 +22,7 @@ from .codex_api import fetch_snapshot from .codex_desktop import CodexDesktopControlError, restart_codex_desktop from .models import AccountRuntimeState, AccountUsageSnapshot, RemovedAccountIdentity, StoredAccount, StoredAccountSource, utc_now +from .localization import Language, language_label, load_language, save_language, tr from .presentation_logic import account_sort_key, is_active_account from .stores import AccountStore, SnapshotStore @@ -334,6 +335,7 @@ def __init__(self, start_hidden: bool = False) -> None: self.selected_account_id: UUID | None = None self.active_identity: AuthBackedIdentity | None = None self.status_message: str | None = None + self.language = load_language() self.is_refreshing_all = False self.is_adding_account = False self.reauthenticating_account_id: UUID | None = None @@ -361,6 +363,7 @@ def __init__(self, start_hidden: bool = False) -> None: self._font_object_cache: dict[tuple[Any, ...], tkfont.Font] = {} self._ellipsize_cache: dict[tuple[str, tuple[Any, ...], int], str] = {} self._metric_value_labels: dict[str, tk.Label] = {} + self._metric_label_widgets: dict[str, tk.Label] = {} self.start_hidden = start_hidden self.palette = { @@ -416,6 +419,12 @@ def __init__(self, start_hidden: bool = False) -> None: def run(self) -> None: self.root.mainloop() + def _t(self, key: str, **values: object) -> str: + return tr(key, self.language, **values) + + def _error_text(self, error: Exception) -> str: + return self._t(str(error)) + def quit(self) -> None: self._quitting = True for job_name in ( @@ -511,7 +520,7 @@ def start_add_account(self) -> None: return self.is_adding_account = True - self.status_message = "Complete or cancel the Codex sign-in flow in your browser." + self.status_message = self._t("Complete or cancel the Codex sign-in flow in your browser.") self._add_handle = ManagedLoginProcess() self._submit_future( self.executor.submit(self.account_manager.add_managed_account, self._add_handle), @@ -523,7 +532,7 @@ def cancel_add_account(self) -> None: if not self.is_adding_account or self._add_handle is None: return - self.status_message = "Cancelling account setup." + self.status_message = self._t("Cancelling account setup.") self._add_handle.cancel() self._render() @@ -532,7 +541,7 @@ def reauthenticate(self, account: StoredAccount) -> None: return self.reauthenticating_account_id = account.id - self.status_message = f"Waiting for {account.display_name} to sign in again." + self.status_message = self._t("Waiting for {account} to sign in again.", account=account.display_name) self._reauth_handle = ManagedLoginProcess() self._submit_future( self.executor.submit(self.account_manager.reauthenticate, account, self._reauth_handle), @@ -557,7 +566,7 @@ def update_nickname(self, account_id: UUID) -> None: def remove_account(self, account: StoredAccount) -> None: confirmed = messagebox.askyesno( "Remove Account", - f"{account.display_name} will be removed from CodexControl.", + self._t("{account} will be removed from CodexControl.", account=account.display_name), parent=self.root, ) if not confirmed: @@ -580,14 +589,14 @@ def remove_account(self, account: StoredAccount) -> None: self.account_manager.remove_managed_files_if_owned(account) self.account_store.save_accounts(self.accounts, self.removed_accounts) self._ensure_selection() - self.status_message = f"{account.display_name} removed." + self.status_message = self._t("{account} removed.", account=account.display_name) except CodexAccountManagerError as error: - self.status_message = str(error) + self.status_message = self._error_text(error) self._render() def switch_account(self, account: StoredAccount) -> None: if self._is_active_account(account): - self.status_message = f"{account.display_name} is already the active Codex account." + self.status_message = self._t("{account} is already the active Codex account.", account=account.display_name) self._render() return @@ -597,9 +606,9 @@ def switch_account(self, account: StoredAccount) -> None: self._replace_or_append_account(result.materialized_account) self._refresh_active_identity() self._load_initial_state() - self.status_message = ( - f"Active account switched to {account.display_name}. " - "Restarting Codex Desktop to apply the new session." + self.status_message = self._t( + "Active account switched to {account}. Restarting Codex Desktop to apply the new session.", + account=account.display_name, ) self._render() if self._restart_desktop_job is not None: @@ -612,7 +621,7 @@ def switch_account(self, account: StoredAccount) -> None: lambda result=result: self._restart_codex_desktop(result), ) except CodexAccountManagerError as error: - self.status_message = str(error) + self.status_message = self._error_text(error) self._render() def _restart_codex_desktop(self, result: Any = None) -> None: @@ -627,7 +636,7 @@ def _restart_codex_desktop(self, result: Any = None) -> None: else None, ) except CodexDesktopControlError as error: - self.status_message = str(error) + self.status_message = self._error_text(error) self._render() def open_folder(self, account: StoredAccount) -> None: @@ -717,6 +726,7 @@ def pick(candidates: list[str], fallback: str) -> str: self.icons["metric_accounts"] = "\u25a6" self.icons["metric_live"] = "\u25c9" self.icons["metric_critical"] = "\u26a0" + self.icons["language"] = "\ue774" if self.font_family_icon != self.font_family_text else "\u2299" def _build_ui(self) -> None: outer = tk.Frame(self.root, bg=self.palette["bg"]) @@ -768,9 +778,18 @@ def _build_ui(self) -> None: controls = tk.Frame(header_top, bg=self.palette["shell"]) controls.pack(side="right") + self.language_button = self._make_button( + controls, + text=language_label(self.language), + command=self._show_language_menu, + kind="surface", + icon=self.icons["language"], + ) + self.language_button.pack(side="left", padx=(0, 6)) + self.add_button = self._make_button( controls, - text="Add", + text=self._t("Add"), command=self.start_or_cancel_add_account, kind="surface", icon=self.icons["add"], @@ -779,7 +798,7 @@ def _build_ui(self) -> None: self.refresh_button = self._make_button( controls, - text="Refresh", + text=self._t("Refresh"), command=self.refresh_all, kind="surface", icon=self.icons["refresh"], @@ -857,17 +876,45 @@ def _build_ui(self) -> None: self.canvas.bind("", self._on_canvas_configure) self.canvas.bind_all("", self._on_mousewheel) + def _show_language_menu(self) -> None: + menu = tk.Menu( + self.root, + tearoff=False, + bg=self.palette["panel"], + fg=self.palette["text"], + activebackground=self.palette["accent_soft"], + activeforeground=self.palette["text"], + bd=0, + ) + for selected_language in Language: + menu.add_command( + label=language_label(selected_language, self.language), + command=lambda language=selected_language: self._on_language_selected(language), + ) + + x = self.language_button.winfo_rootx() + y = self.language_button.winfo_rooty() + self.language_button.winfo_height() + try: + menu.tk_popup(x, y) + finally: + menu.grab_release() + + def _on_language_selected(self, selected_language: Language) -> None: + self.language = selected_language + save_language(self.language) + self.language_button.set_text(language_label(self.language)) + if self.tray_icon is not None: + self.tray_icon.menu = self._build_tray_menu() + self.status_message = tr( + "language.changed", + self.language, + language=language_label(self.language, self.language), + ) + self._render() + def _setup_tray_icon(self) -> None: self.tray_icon: pystray.Icon | None = None - menu = pystray.Menu( - pystray.MenuItem( - lambda _: "Hide Window" if self.root.state() != "withdrawn" else "Show Window", - lambda icon, item: self.root.after(0, self._toggle_window), - ), - pystray.MenuItem("Refresh All", lambda icon, item: self.root.after(0, self.refresh_all)), - pystray.MenuItem("Add Account", lambda icon, item: self.root.after(0, self.start_add_account)), - pystray.MenuItem("Quit", lambda icon, item: self.root.after(0, self.quit)), - ) + menu = self._build_tray_menu() self.tray_icon = pystray.Icon( "CodexControl", self._create_icon_image("neutral", 64), @@ -876,6 +923,17 @@ def _setup_tray_icon(self) -> None: ) self.tray_icon.run_detached() + def _build_tray_menu(self) -> pystray.Menu: + return pystray.Menu( + pystray.MenuItem( + lambda _: self._t("Hide Window") if self.root.state() != "withdrawn" else self._t("Show Window"), + lambda icon, item: self.root.after(0, self._toggle_window), + ), + pystray.MenuItem(self._t("Refresh All"), lambda icon, item: self.root.after(0, self.refresh_all)), + pystray.MenuItem(self._t("Add Account"), lambda icon, item: self.root.after(0, self.start_add_account)), + pystray.MenuItem(self._t("Quit"), lambda icon, item: self.root.after(0, self.quit)), + ) + def _set_window_icon(self) -> None: self.window_icon_images = [ ImageTk.PhotoImage(self._create_icon_image("neutral", size)) @@ -905,7 +963,7 @@ def _load_initial_state(self) -> None: if self.accounts != loaded_accounts: self.account_store.save_accounts(self.accounts, self.removed_accounts) except Exception as error: - self.status_message = str(error) + self.status_message = self._error_text(error) self.accounts = [] try: @@ -917,7 +975,7 @@ def _load_initial_state(self) -> None: if account_id in valid_ids } except Exception as error: - self.status_message = str(error) + self.status_message = self._error_text(error) self.runtime_states = {} for account in self.accounts: @@ -979,7 +1037,7 @@ def _apply_refresh_result( self._update_account_metadata(account_id, snapshot) else: state.snapshot = None - state.error_message = str(error) if error else "Unknown refresh error." + state.error_message = self._error_text(error) if error else self._t("Unknown refresh error.") self.runtime_states[account_id] = state self._mark_runtime_dirty() @@ -1008,10 +1066,10 @@ def _apply_add_account_result(self, account: StoredAccount | None, error: Except matched = next((candidate for candidate in self.accounts if candidate.matches(account)), account) self.selected_account_id = matched.id self.nickname_drafts[matched.id] = matched.nickname or "" - self.status_message = f"{matched.display_name} added." + self.status_message = self._t("{account} added.", account=matched.display_name) self.refresh_account(matched) else: - self.status_message = str(error) if error else "Account setup failed." + self.status_message = self._error_text(error) if error else self._t("Account setup failed.") self._render() def _apply_reauth_result( @@ -1028,7 +1086,7 @@ def _apply_reauth_result( self.accounts = self.account_store.merge(self.accounts, [account]) self.account_store.save_accounts(self.accounts, self.removed_accounts) self._mark_accounts_dirty() - self.status_message = f"{account.display_name} reauthenticated." + self.status_message = self._t("{account} reauthenticated.", account=account.display_name) refreshed = next((candidate for candidate in self.accounts if candidate.id == original_account_id), None) if refreshed is not None: self.refresh_account(refreshed) @@ -1036,7 +1094,7 @@ def _apply_reauth_result( self._render() return - self.status_message = str(error) if error else "Reauthentication failed." + self.status_message = self._error_text(error) if error else self._t("Reauthentication failed.") self._render() def _update_account_metadata(self, account_id: UUID, snapshot: AccountUsageSnapshot) -> None: @@ -1062,7 +1120,7 @@ def _persist_accounts_silently(self) -> None: try: self.account_store.save_accounts(self.accounts, self.removed_accounts) except Exception as error: - self.status_message = str(error) + self.status_message = self._error_text(error) def _persist_snapshots_silently(self) -> None: snapshots = { @@ -1073,7 +1131,7 @@ def _persist_snapshots_silently(self) -> None: try: self.snapshot_store.save(snapshots) except Exception as error: - self.status_message = str(error) + self.status_message = self._error_text(error) def _schedule_group_refresh_flush(self) -> None: if self._group_refresh_flush_job is not None: @@ -1187,9 +1245,11 @@ def _render_now(self) -> None: text=self._header_status_text(presentation), wraplength=self._header_wrap_width(), ) - self.add_button.set_text("Cancel" if self.is_adding_account else "Add") + self.add_button.set_text(self._t("Cancel" if self.is_adding_account else "Add")) self.add_button.set_icon(self.icons["trash"] if self.is_adding_account else self.icons["add"]) self.add_button.set_theme(self._button_theme("accent" if self.is_adding_account else "surface")) + self.language_button.set_text(language_label(self.language)) + self.refresh_button.set_text(self._t("Refresh")) self.refresh_button.set_enabled(not self.is_refreshing_all) self._render_metrics(presentation) self._render_cards(presentation) @@ -1201,14 +1261,19 @@ def _render_metrics(self, presentation: PresentationState) -> None: self.metrics_frame.grid_columnconfigure(column, weight=1, uniform="metrics") metrics = [ - ("accounts", "Accounts", self.icons["metric_accounts"], self.palette["neutral"]), - ("live", "Live", self.icons["metric_live"], self.palette["success"]), - ("critical", "Critical", self.icons["metric_critical"], self.palette["warning"]), + ("accounts", self._t("Accounts"), self.icons["metric_accounts"], self.palette["neutral"]), + ("live", self._t("Live"), self.icons["metric_live"], self.palette["success"]), + ("critical", self._t("Critical"), self.icons["metric_critical"], self.palette["warning"]), ] for index, (key, label, icon, tone) in enumerate(metrics): - tile, value_label = self._build_metric_tile(self.metrics_frame, label, "0", icon, tone) + tile, value_label, label_widget = self._build_metric_tile(self.metrics_frame, label, "0", icon, tone) tile.grid(row=0, column=index, sticky="nsew", padx=(0, 6 if index < len(metrics) - 1 else 0)) self._metric_value_labels[key] = value_label + self._metric_label_widgets[key] = label_widget + + self._metric_label_widgets["accounts"].configure(text=self._t("Accounts")) + self._metric_label_widgets["live"].configure(text=self._t("Live")) + self._metric_label_widgets["critical"].configure(text=self._t("Critical")) self._metric_value_labels["accounts"].configure(text=str(presentation.account_count)) self._metric_value_labels["live"].configure(text=str(presentation.usable_quota_count)) @@ -1221,7 +1286,7 @@ def _build_metric_tile( value: str, icon: str, tone: str, - ) -> tuple[tk.Frame, tk.Label]: + ) -> tuple[tk.Frame, tk.Label, tk.Label]: tile = tk.Frame( parent, bg=self.palette["panel"], @@ -1253,13 +1318,14 @@ def _build_metric_tile( font=self.fonts["label"], ).pack(expand=True) - tk.Label( + label_widget = tk.Label( top, text=label, bg=self.palette["panel"], fg=self.palette["muted"], font=self.fonts["caption"], - ).pack(side="left", padx=(8, 0)) + ) + label_widget.pack(side="left", padx=(8, 0)) value_label = tk.Label( tile, @@ -1269,7 +1335,7 @@ def _build_metric_tile( font=self.fonts["metric"], ) value_label.pack(anchor="w", pady=(8, 0)) - return tile, value_label + return tile, value_label, label_widget def _render_cards(self, presentation: PresentationState) -> None: self._cancel_cards_render_job() @@ -1381,11 +1447,11 @@ def _render_empty_state(self, search_query: str) -> None: ) panel.pack(fill="x") - title = "No Accounts" if not search_query else "No Matches" + title = self._t("No Accounts" if not search_query else "No Matches") message = ( - "Add a Codex account to start tracking quota." + self._t("Add a Codex account to start tracking quota.") if not self.accounts - else "No accounts match your current search." + else self._t("No accounts match your current search.") ) tk.Label( panel, @@ -1448,7 +1514,7 @@ def _build_account_card(self, parent: tk.Widget, account: StoredAccount, width_h if is_active: active_chip = self._make_inline_chip( title_row, - "Active", + self._t("Active"), self.palette["accent_soft"], self.palette["accent"], ) @@ -1458,7 +1524,7 @@ def _build_account_card(self, parent: tk.Widget, account: StoredAccount, width_h if account.source is StoredAccountSource.AMBIENT: system_chip = self._make_inline_chip( title_row, - "System", + self._t("System"), self.palette["panel_alt"], self.palette["muted"], ) @@ -1510,7 +1576,7 @@ def _build_account_card(self, parent: tk.Widget, account: StoredAccount, width_h quick_actions.pack(anchor="e", pady=(8, 0)) self._make_button( quick_actions, - "Switch", + self._t("Switch"), lambda: self.switch_account(account), kind="surface_tiny", icon=self.icons["add"], @@ -1570,23 +1636,23 @@ def _build_account_card(self, parent: tk.Widget, account: StoredAccount, width_h actions.pack(fill="x") button_specs: list[tuple[str, Callable[[], None], str, str | None]] = [ - ("Refresh", lambda: self.refresh_account(account), "surface_small", self.icons["refresh"]), + (self._t("Refresh"), lambda: self.refresh_account(account), "surface_small", self.icons["refresh"]), ] if is_active: - button_specs.append(("Active", lambda: None, "surface_small", self.icons["spark"])) + button_specs.append((self._t("Active"), lambda: None, "surface_small", self.icons["spark"])) elif self._can_switch_account(account): - button_specs.append(("Switch", lambda: self.switch_account(account), "surface_small", self.icons["add"])) - button_specs.append(("Reauth", lambda: self.reauthenticate(account), "surface_small", self.icons["spark"])) - button_specs.append(("Folder", lambda: self.open_folder(account), "surface_small", self.icons["folder"])) + button_specs.append((self._t("Switch"), lambda: self.switch_account(account), "surface_small", self.icons["add"])) + button_specs.append((self._t("Reauth"), lambda: self.reauthenticate(account), "surface_small", self.icons["spark"])) + button_specs.append((self._t("Folder"), lambda: self.open_folder(account), "surface_small", self.icons["folder"])) if account.source.owns_files: - button_specs.append(("Remove", lambda: self.remove_account(account), "danger_small", self.icons["trash"])) + button_specs.append((self._t("Remove"), lambda: self.remove_account(account), "danger_small", self.icons["trash"])) self._render_action_buttons(actions, button_specs, width_hint) if not account.source.owns_files: tk.Label( actions, - text="System account", + text=self._t("System account"), bg=card_bg, fg=self.palette["muted"], font=self.fonts["body_small"], @@ -1597,7 +1663,7 @@ def _build_account_card(self, parent: tk.Widget, account: StoredAccount, width_h actions_meta.pack(fill="x", pady=(8, 0)) tk.Label( actions_meta, - text=state.snapshot.updated_at.astimezone().strftime("Updated %H:%M"), + text=self._t("Updated {time}", time=state.snapshot.updated_at.astimezone().strftime("%H:%M")), bg=card_bg, fg=self.palette["muted"], font=self.fonts["caption"], @@ -1639,7 +1705,7 @@ def sync_draft(*_: Any) -> None: save_button = self._make_button( label_row, - "Save", + self._t("Save"), lambda: self.update_nickname(account.id), kind="surface_small", icon=self.icons["save"], @@ -1663,7 +1729,7 @@ def sync_draft(*_: Any) -> None: if state.snapshot is not None and state.snapshot.next_reset_at is not None: tk.Label( footer, - text=f"Next reset {state.snapshot.next_reset_at.astimezone().strftime('%b %d %H:%M')}", + text=self._t("Next reset {date}", date=state.snapshot.next_reset_at.astimezone().strftime("%b %d %H:%M")), bg=card_bg, fg=self.palette["muted"], font=self.fonts["caption"], @@ -1702,7 +1768,7 @@ def _build_window_strip(self, parent: tk.Widget, window: Any, width_hint: int) - ).pack(side="left") tk.Label( header, - text=window.display_name, + text=self._t(window.display_name), bg=self.palette["panel_alt"], fg=self.palette["muted"], font=self.fonts["caption"], @@ -1726,14 +1792,14 @@ def _build_window_strip(self, parent: tk.Widget, window: Any, width_hint: int) - footer.pack(fill="x") tk.Label( footer, - text=f"Used {window.used_percent:.0f}%", + text=self._t("Used {percent}%", percent=f"{window.used_percent:.0f}"), bg=self.palette["panel_alt"], fg=self.palette["muted"], font=self.fonts["caption"], ).pack(side="left") tk.Label( footer, - text=window.compact_reset_at_display or "Reset unknown", + text=window.compact_reset_at_display or self._t("Reset unknown"), bg=self.palette["panel_alt"], fg=self.palette["muted"], font=self.fonts["caption"], @@ -1761,7 +1827,7 @@ def _build_window_tile(self, parent: tk.Widget, card_bg: str, window: Any) -> tk ).pack(side="left") tk.Label( header, - text=window.display_name, + text=self._t(window.display_name), bg=self.palette["panel_alt"], fg=self.palette["muted"], font=self.fonts["caption"], @@ -1785,12 +1851,12 @@ def _build_window_tile(self, parent: tk.Widget, card_bg: str, window: Any) -> tk footer.pack(fill="x") tk.Label( footer, - text=f"Used {window.used_percent:.0f}%", + text=self._t("Used {percent}%", percent=f"{window.used_percent:.0f}"), bg=self.palette["panel_alt"], fg=self.palette["muted"], font=self.fonts["caption"], ).pack(side="left") - reset_text = window.compact_reset_at_display or "Reset unknown" + reset_text = window.compact_reset_at_display or self._t("Reset unknown") tk.Label( footer, text=reset_text, @@ -1808,21 +1874,21 @@ def _header_status_text(self, presentation: PresentationState) -> str: if self.status_message: return self.status_message if presentation.low_quota_count > 0: - return f"{presentation.account_count} accounts, {presentation.low_quota_count} critical" - return f"{presentation.account_count} accounts" + return f"{presentation.account_count} {self._t('Accounts').lower()}, {presentation.low_quota_count} {self._t('Critical').lower()}" + return f"{presentation.account_count} {self._t('Accounts').lower()}" def _status_text(self, state: AccountRuntimeState) -> str: if state.is_loading: - return "Syncing" + return self._t("Syncing") if state.error_message: - return "Attention" + return self._t("Attention") if state.snapshot is None: - return "Pending" + return self._t("Pending") if state.snapshot.has_usable_quota_now: - return "Available" + return self._t("Available") if state.snapshot.is_quota_blocked: - return "Blocked" - return "Limited" + return self._t("Blocked") + return self._t("Limited") def _status_value_text(self, state: AccountRuntimeState) -> str: if state.snapshot is not None: @@ -1833,14 +1899,14 @@ def _status_value_text(self, state: AccountRuntimeState) -> str: def _inline_message(self, state: AccountRuntimeState) -> str: if state.is_loading: - return "Refreshing live quota data..." + return self._t("Refreshing live quota data...") if state.error_message: return state.error_message if state.snapshot is None: - return "Waiting for data." + return self._t("Waiting for data.") if state.snapshot.is_quota_blocked: - return "Quota reached." - return "No quota data." + return self._t("Quota reached.") + return self._t("No quota data.") def _status_color(self, state: AccountRuntimeState) -> str: if state.error_message: diff --git a/windows/codexcontrol_windows/file_locations.py b/windows/codexcontrol_windows/file_locations.py index 4d28ed1..5cd208d 100644 --- a/windows/codexcontrol_windows/file_locations.py +++ b/windows/codexcontrol_windows/file_locations.py @@ -26,6 +26,7 @@ def localappdata_directory() -> Path: ] ACCOUNTS_FILE = APP_SUPPORT_DIRECTORY / "accounts.json" SNAPSHOTS_FILE = APP_SUPPORT_DIRECTORY / "snapshots.json" +LANGUAGE_FILE = APP_SUPPORT_DIRECTORY / "language.json" MANAGED_HOMES_DIRECTORY = APP_SUPPORT_DIRECTORY / "managed-homes" AUTH_BACKUPS_DIRECTORY = APP_SUPPORT_DIRECTORY / "auth-backups" AMBIENT_CODEX_HOME = Path.home() / ".codex" diff --git a/windows/codexcontrol_windows/localization.py b/windows/codexcontrol_windows/localization.py new file mode 100644 index 0000000..7e99c8c --- /dev/null +++ b/windows/codexcontrol_windows/localization.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import locale +from enum import StrEnum + +from .file_locations import LANGUAGE_FILE, ensure_directories + + +class Language(StrEnum): + SYSTEM = "system" + ENGLISH = "en" + SPANISH = "es" + + +LANGUAGE_LABELS = { + Language.SYSTEM: "System", + Language.ENGLISH: "English", + Language.SPANISH: "Español", +} + +_TRANSLATIONS = { + "en": { + "language.changed": "Language changed to {language}.", + }, + "es": { + "Add": "Añadir", + "Cancel": "Cancelar", + "Refresh": "Actualizar", + "Switch": "Cambiar", + "Reauth": "Reautenticar", + "Folder": "Carpeta", + "Refresh All": "Actualizar todo", + "Accounts": "Cuentas", + "Live": "Disponibles", + "Critical": "Críticas", + "Active": "Activa", + "System": "Sistema", + "Save": "Guardar", + "Remove": "Eliminar", + "No Accounts": "Sin cuentas", + "No Matches": "Sin coincidencias", + "Add a Codex account to start tracking quota.": "Añade una cuenta de Codex para empezar a consultar la cuota.", + "No accounts match your current search.": "Ninguna cuenta coincide con la búsqueda actual.", + "Hide Window": "Ocultar ventana", + "Show Window": "Mostrar ventana", + "Add Account": "Añadir cuenta", + "Quit": "Salir", + "Syncing": "Sincronizando", + "Attention": "Atención", + "Pending": "Pendiente", + "Available": "Disponible", + "Blocked": "Bloqueada", + "Limited": "Limitada", + "Refreshing live quota data...": "Actualizando los datos de cuota...", + "Waiting for data.": "Esperando datos.", + "Quota reached.": "Cuota alcanzada.", + "No quota data.": "Sin datos de cuota.", + "Used {percent}%": "Usado: {percent}%", + "Reset unknown": "Restablecimiento desconocido", + "Complete or cancel the Codex sign-in flow in your browser.": "Completa o cancela el inicio de sesión de Codex en el navegador.", + "Cancelling account setup.": "Cancelando la configuración de la cuenta.", + "Waiting for {account} to sign in again.": "Esperando a que {account} vuelva a iniciar sesión.", + "{account} removed.": "{account} eliminada.", + "{account} is already the active Codex account.": "{account} ya es la cuenta activa de Codex.", + "Active account switched to {account}. Restarting Codex Desktop to apply the new session.": "La cuenta activa se cambió a {account}. Reiniciando Codex Desktop para aplicar la nueva sesión.", + "Restarting Codex Desktop to apply the new session.": "Reiniciando Codex Desktop para aplicar la nueva sesión.", + "{account} added.": "{account} añadida.", + "Account setup failed.": "La configuración de la cuenta falló.", + "{account} reauthenticated.": "{account} volvió a autenticarse.", + "Reauthentication failed.": "La reautenticación falló.", + "Unknown refresh error.": "Error desconocido al actualizar.", + "System account": "Cuenta del sistema", + "Updated {time}": "Actualizada a las {time}", + "Next reset {date}": "Próximo restablecimiento: {date}", + "No data": "Sin datos", + "5 Hours": "5 horas", + "7 Days": "7 días", + "The Codex usage API request returned unauthorized.": "La API de uso de Codex rechazó la solicitud.", + "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.", + "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 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.", + "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 has expired. Sign in again for this account.": "El token de renovación caducó. Vuelve a iniciar sesión en esta cuenta.", + "The Codex API response was not in the expected format.": "La respuesta de la API de Codex no tenía el formato esperado.", + "This path is not an app-managed home directory.": "Esta ruta no es un directorio de cuenta administrado por la aplicación.", + "The selected account does not contain `auth.json`.": "La cuenta seleccionada no contiene `auth.json`.", + "The current active account does not contain `auth.json`.": "La cuenta activa actual no contiene `auth.json`.", + "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", + "System": "Sistema", + "language.system": "Sistema", + "language.english": "Inglés", + "language.spanish": "Español", + "language.changed": "Idioma cambiado a {language}.", + } +} + + +def _system_language() -> Language: + language, _ = locale.getlocale() + if language and language.lower().startswith(("es", "ca", "gl", "eu")): + return Language.SPANISH + return Language.ENGLISH + + +def effective_language(language: Language) -> Language: + return _system_language() if language is Language.SYSTEM else language + + +def load_language() -> Language: + if not LANGUAGE_FILE.exists(): + return Language.SYSTEM + + try: + payload = json.loads(LANGUAGE_FILE.read_text(encoding="utf-8")) + return Language(payload.get("language", Language.SYSTEM)) + except (OSError, ValueError, TypeError): + return Language.SYSTEM + + +def save_language(language: Language) -> None: + ensure_directories() + LANGUAGE_FILE.write_text( + json.dumps({"language": language.value}, indent=2) + "\n", + encoding="utf-8", + ) + + +def language_label(language: Language, current: Language | None = None) -> str: + if current is None or effective_language(current) is Language.ENGLISH: + return LANGUAGE_LABELS[language] + return { + Language.SYSTEM: "Sistema", + Language.ENGLISH: "Inglés", + Language.SPANISH: "Español", + }[language] + + +def tr(key: str, selected: Language, **values: object) -> str: + effective = effective_language(selected) + text = _TRANSLATIONS.get(effective.value, {}).get(key, key) + return text.format(**values) diff --git a/windows/tests/test_localization.py b/windows/tests/test_localization.py new file mode 100644 index 0000000..bcbed9a --- /dev/null +++ b/windows/tests/test_localization.py @@ -0,0 +1,49 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from codexcontrol_windows.localization import ( + Language, + effective_language, + load_language, + save_language, + tr, +) + + +class LocalizationTests(unittest.TestCase): + def test_defaults_to_system_language(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "language.json" + with patch("codexcontrol_windows.localization.LANGUAGE_FILE", path): + self.assertIs(load_language(), Language.SYSTEM) + + def test_language_round_trips(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "language.json" + with patch("codexcontrol_windows.localization.LANGUAGE_FILE", path): + with patch("codexcontrol_windows.localization.ensure_directories"): + save_language(Language.SPANISH) + self.assertEqual(load_language(), Language.SPANISH) + self.assertEqual(json.loads(path.read_text())["language"], "es") + + def test_system_language_falls_back_to_english(self) -> None: + with patch("codexcontrol_windows.localization.locale.getlocale", return_value=("en_US", "UTF-8")): + self.assertIs(effective_language(Language.SYSTEM), Language.ENGLISH) + + def test_spanish_translation_supports_interpolation(self) -> None: + self.assertEqual( + tr("language.changed", Language.SPANISH, language="Español"), + "Idioma cambiado a Español.", + ) + + def test_english_translation_supports_interpolation(self) -> None: + self.assertEqual( + tr("language.changed", Language.ENGLISH, language="English"), + "Language changed to English.", + ) + + def test_unknown_key_falls_back_to_key(self) -> None: + self.assertEqual(tr("missing.key", Language.SPANISH), "missing.key") From acfb527e9635aca421f03dc00b182a904bda7e40 Mon Sep 17 00:00:00 2001 From: Fabian Mendoza <229246299+BlazzzPlay@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:55:29 -0400 Subject: [PATCH 2/2] Keep distinct accounts separate by identity --- windows/codexcontrol_windows/models.py | 8 ++++++++ windows/tests/test_models.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/windows/codexcontrol_windows/models.py b/windows/codexcontrol_windows/models.py index 1640aa4..6bd63e4 100644 --- a/windows/codexcontrol_windows/models.py +++ b/windows/codexcontrol_windows/models.py @@ -122,6 +122,10 @@ def matches(self, other: "StoredAccount") -> bool: if self.standardized_home_path == other.standardized_home_path: return True if self.normalized_provider_account_id and self.normalized_provider_account_id == other.normalized_provider_account_id: + if self.normalized_auth_subject and other.normalized_auth_subject: + return self.normalized_auth_subject == other.normalized_auth_subject + if self.normalized_email_hint and other.normalized_email_hint: + return self.normalized_email_hint == other.normalized_email_hint return True if self.normalized_provider_account_id or other.normalized_provider_account_id: return False @@ -233,6 +237,10 @@ def matches(self, account: StoredAccount) -> bool: if self.standardized_home_path == account.standardized_home_path: return True if self.normalized_provider_account_id and self.normalized_provider_account_id == account.normalized_provider_account_id: + if self.normalized_auth_subject and account.normalized_auth_subject: + return self.normalized_auth_subject == account.normalized_auth_subject + if self.normalized_email_hint and account.normalized_email_hint: + return self.normalized_email_hint == account.normalized_email_hint return True if self.normalized_provider_account_id or account.normalized_provider_account_id: return False diff --git a/windows/tests/test_models.py b/windows/tests/test_models.py index 5097a08..e84c927 100644 --- a/windows/tests/test_models.py +++ b/windows/tests/test_models.py @@ -74,6 +74,21 @@ def test_matches_keeps_different_provider_accounts_separate(self) -> None: self.assertFalse(first.matches(second)) + def test_matches_keeps_different_users_separate_with_shared_provider_id(self) -> None: + created_at = datetime(2026, 4, 18, tzinfo=timezone.utc) + first = StoredAccount( + id=uuid4(), nickname=None, email_hint="first@example.com", auth_subject="auth0|first", + provider_account_id="shared-team", codex_home_path="C:/temp/a", + source=StoredAccountSource.MANAGED_BY_APP, created_at=created_at, updated_at=created_at, + ) + second = StoredAccount( + id=uuid4(), nickname=None, email_hint="second@example.com", auth_subject="auth0|second", + provider_account_id="shared-team", codex_home_path="C:/temp/b", + source=StoredAccountSource.MANAGED_BY_APP, created_at=created_at, updated_at=created_at, + ) + + self.assertFalse(first.matches(second)) + def test_removed_identity_matches_provider_not_shared_email(self) -> None: created_at = datetime(2026, 4, 18, tzinfo=timezone.utc) removed_account = StoredAccount(