diff --git a/locales/en-US/native.json b/locales/en-US/native.json new file mode 100644 index 00000000..775633b9 --- /dev/null +++ b/locales/en-US/native.json @@ -0,0 +1,36 @@ +{ + "tray": "Tray", + "trayIconAndSession": "Icon + Session", + "trayHourlyAndWeekly": "Hourly + Weekly", + "hidden": "Hidden", + "dockIcon": "Dock Icon", + "showInDock": "Show in Dock", + "menuBarOnly": "Menu Bar Only", + "settings": "Settings", + "language": "Language", + "systemLanguage": "System Default", + "english": "English", + "simplifiedChinese": "Simplified Chinese", + "window": "Window", + "help": "Help", + "file": "File", + "edit": "Edit", + "view": "View", + "noAccounts": "No accounts configured", + "openApp": "Open Codex Switcher", + "quit": "Quit", + "about": "About Codex Switcher", + "services": "Services", + "hide": "Hide Codex Switcher", + "hideOthers": "Hide Others", + "minimize": "Minimize", + "maximize": "Maximize", + "closeWindow": "Close Window", + "undo": "Undo", + "redo": "Redo", + "cut": "Cut", + "copy": "Copy", + "paste": "Paste", + "selectAll": "Select All", + "fullscreen": "Enter Full Screen" +} diff --git a/locales/en-US/ui.json b/locales/en-US/ui.json new file mode 100644 index 00000000..7013b522 --- /dev/null +++ b/locales/en-US/ui.json @@ -0,0 +1,297 @@ +{ + "common": { + "add": "Add", + "cancel": "Cancel", + "close": "Close", + "copy": "Copy", + "copied": "Copied", + "dismiss": "Dismiss", + "import": "Import", + "later": "Later", + "open": "Open", + "refresh": "Refresh", + "restart": "Restart", + "unknown": "Unknown", + "unknownError": "Unknown error" + }, + "language": { + "label": "Language", + "system": "System Default", + "changeFailed": "Could not change language: {{error}}" + }, + "window": { + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close" + }, + "header": { + "codexRunning": "{{count}} Codex running", + "forceClose": "Force close", + "forceCloseTitle": "Force close running Codex processes", + "openCodex": "Open Codex", + "opening": "Opening...", + "showAll": "Show all account names and emails", + "hideAll": "Hide all account names and emails", + "refreshingAll": "Refreshing all usage", + "refreshAll": "Refresh all usage", + "warmupAll": "Send minimal traffic using all accounts", + "disableAutoAll": "Disable auto warm-up for all accounts", + "enableAutoAll": "Enable auto warm-up for all accounts", + "timedTitle": "Schedule warm-up at specific times of day for all accounts", + "timedWarmup": "Timed warm-up", + "noTimes": "No times added yet.", + "removeTime": "Remove {{time}}", + "lightMode": "Switch to light mode", + "darkMode": "Switch to dark mode", + "accountMenu": "Account", + "addAccount": "+ Add Account" + }, + "warmup": { + "warming": "Warming...", + "autoOn": "Auto: on", + "autoOff": "Auto: off", + "autoSession": "Auto: 5h", + "autoWeekly": "Auto: weekly", + "autoWarming": "Auto warming...", + "waitingWeekly": "Waiting weekly reset", + "timedWarming": "Timed warming...", + "timedOff": "Timed: off", + "timedNext": "Timed: {{time}}", + "switchedFromTray": "Switched account from tray.", + "switchFailed": "Switch failed: {{error}}", + "switchBlocked": "Account switch was blocked.", + "closeFailed": "Close failed: {{error}}", + "switchedAfterClose": "Switched account after force closing Codex.", + "switchAfterCloseFailed": "Switch failed after force close: {{error}}", + "sentFor": "Warm-up sent for {{name}}", + "failedFor": "Warm-up failed for {{name}}: {{error}}", + "noneAvailable": "No accounts available for warm-up", + "sentForAll": "Warm-up sent for all {{count}} account", + "sentForAll_other": "Warm-up sent for all {{count}} accounts", + "summary": "Warmed {{warmed}}/{{total}}. Failed: {{failed}}", + "allFailed": "Warm-up all failed: {{error}}", + "autoSessionSentFor": "Auto 5h warm-up sent for {{name}}", + "autoWeeklySentFor": "Auto weekly warm-up sent for {{name}}", + "autoFailedFor": "Auto warm-up failed for {{name}}: {{error}}", + "timedSent": "Timed warm-up sent for {{count}} account", + "timedSent_other": "Timed warm-up sent for {{count}} accounts", + "timedSummary": "Timed warm-up: {{warmed}} succeeded, {{failed}} failed" + }, + "backup": { + "exportSlim": "Export Slim Text", + "importSlim": "Import Slim Text", + "exportFull": "Export Full Encrypted File", + "importFull": "Import Full Encrypted File", + "exporting": "Exporting...", + "importing": "Importing...", + "slimExported": "Slim text exported ({{count}} accounts).", + "slimExportFailed": "Slim export failed", + "pasteFirst": "Please paste the slim text string first.", + "importSummary": "Imported {{imported}}, skipped {{skipped}} (total {{total}})", + "slimImportFailed": "Slim import failed", + "fullExported": "Full encrypted file exported.", + "fullExportFailed": "Full export failed", + "fullImportFailed": "Full import failed", + "keepExisting": "Existing accounts are kept. Only missing accounts are imported.", + "secretWarning": "This slim string contains account secrets. Keep it private.", + "generating": "Generating...", + "exportPlaceholder": "Export string will appear here", + "importPlaceholder": "Paste config string here", + "clipboardUnavailable": "Clipboard unavailable. Please copy manually.", + "copyString": "Copy String", + "importMissing": "Import Missing Accounts" + }, + "codex": { + "opened": "Codex app opened.", + "openFailed": "Open Codex failed: {{error}}" + }, + "accounts": { + "loading": "Loading accounts...", + "loadFailed": "Failed to load accounts", + "emptyTitle": "No accounts yet", + "emptyBody": "Add your first Codex account to get started", + "add": "Add Account", + "activeHeading": "Active Account", + "otherHeading": "Other Accounts ({{count}})", + "searchPlaceholder": "Search accounts by name or email", + "searchLabel": "Search accounts", + "clearSearch": "Clear account search", + "noMatchingTitle": "No matching accounts", + "noMatchingBody": "Try a different account name or email address.", + "sort": "Sort", + "sortResetAsc": "Reset: earliest to latest", + "sortResetDesc": "Reset: latest to earliest", + "sortRemainingDesc": "% remaining: highest to lowest", + "sortRemainingAsc": "% remaining: lowest to highest", + "sortExpiryAsc": "Expiry: earliest to latest", + "sortExpiryDesc": "Expiry: latest to earliest", + "refreshSuccess": "Usage refreshed successfully", + "deleteConfirm": "Click delete again to confirm removal" + }, + "forceClose": { + "switch": "Force close and switch account", + "processes": "Force close running Codex processes", + "title": "Force close running Codex processes?", + "body": "This will force close {{count}} Codex process that currently blocks account switching.", + "body_other": "This will force close {{count}} Codex processes that currently block account switching.", + "thenSwitch": "After closing Codex, Codex Switcher will switch to {{name}}.", + "warning": "Unsaved Codex work may be lost.", + "closing": "Force closing...", + "noneFound": "No running Codex processes found.", + "closed": "Force closed {{count}} Codex session.", + "closed_other": "Force closed {{count}} Codex sessions.", + "partial": "Force closed {{closed}}/{{total}} Codex sessions. {{remaining}} still running.", + "couldNotClose": "Could not force close {{count}} Codex session.", + "couldNotClose_other": "Could not force close {{count}} Codex sessions.", + "failed": "Force close failed: {{error}}" + }, + "closeBehavior": { + "title": "Keep Codex Switcher in the Dock?", + "body": "When the window is closed, Codex Switcher can stay in the Dock or live only in the menu bar.", + "changeLater": "You can always change this later from the tray popup.", + "dontAsk": "Don't ask again", + "keepDock": "Keep in Dock", + "menuBarOnly": "Menu Bar Only" + }, + "addAccount": { + "title": "Add Account", + "chatgptLogin": "ChatGPT Login", + "importFile": "Import File", + "name": "Account Name (optional)", + "namePlaceholder": "Leave blank to use email", + "nameRequired": "Please enter an account name", + "fileRequired": "Please select an auth.json file", + "waiting": "Waiting for browser login...", + "openLink": "Please open the following link in your browser to proceed:", + "copyLinkManually": "Clipboard unavailable. Copy the link manually.", + "copied": "Copied!", + "remoteOauthWarning": "OAuth login must finish on the same host machine because the callback redirects to localhost.", + "loginHelp": "Click the button below to generate a login link. You will need to open it in your browser to authenticate.", + "selectFile": "Select auth.json file", + "browse": "Browse...", + "importHelp": "Import credentials from an existing Codex auth.json file", + "adding": "Adding...", + "generateLink": "Generate Login Link" + }, + "accountCard": { + "never": "Never", + "justNow": "Just now", + "secondsAgo": "{{count}}s ago", + "minutesAgo": "{{count}}m ago", + "hoursAgo": "{{count}}h ago", + "expiryUnavailable": "Expiry unavailable", + "expired": "Expired {{date}}", + "until": "Until {{date}}", + "resetCount": "{{count}} reset", + "resetCount_other": "{{count}} resets", + "closest": "closest {{date}}", + "closestExpires": "closest expires {{date}}", + "apiKey": "API Key", + "rename": "Click to rename", + "showInfo": "Show info", + "hideInfo": "Hide info", + "lastUpdated": "Last updated: {{time}}", + "active": "Active", + "closeProcesses": "Close all Codex processes first", + "switching": "Switching...", + "codexRunning": "Codex Running", + "switch": "Switch", + "warmupSending": "Sending warm-up request...", + "warmupSend": "Send minimal warm-up request", + "autoAll": "Auto warm-up is enabled for all accounts", + "autoDisable": "Disable auto warm-up for this account", + "autoEnable": "Enable auto warm-up for this account", + "refreshUsage": "Refresh usage", + "remove": "Remove account" + }, + "usage": { + "now": "now", + "seconds": "{{count}}s", + "minutes": "{{count}}m", + "hours": "{{count}}h", + "hoursMinutes": "{{hours}}h {{minutes}}m", + "days": "{{count}}d", + "left": "{{percent}}% left", + "resets": "resets {{time}}", + "fetching": "Fetching usage...", + "noData": "No rate limit data", + "fiveHour": "5h Limit", + "weekly": "Weekly Limit", + "credits": "Credits: {{balance}}" + }, + "updates": { + "available": "Update available: v{{version}}", + "update": "Update", + "downloading": "Downloading update...", + "ready": "Update ready. Restart to apply.", + "failed": "Update failed: {{error}}" + }, + "tray": { + "loading": "Loading...", + "noAccounts": "No accounts configured", + "session": "Session", + "weekly": "Weekly", + "resetsNow": "Resets now", + "resetsIn": "Resets in {{time}}", + "usageUnavailable": "Usage unavailable", + "today": "today", + "lastSevenDays": "last 7 days", + "dock": "Dock", + "show": "Show", + "menuBar": "Menu Bar", + "openApp": "Open Codex Switcher", + "quit": "Quit" + }, + "stats": { + "source": "Codex usage stats via ChatGPT backend", + "chatgptOnly": "Usage stats are available for ChatGPT accounts only.", + "unavailable": "Usage stats unavailable.", + "title": "Usage Stats", + "updated": "updated {{time}}", + "asOf": "Stats as of {{date}}", + "backend": "ChatGPT backend", + "refresh": "Refresh usage stats", + "lifetime": "Lifetime", + "today": "Today", + "lastSevenDays": "Last 7 days", + "lastThirtyDays": "Last 30 days", + "currentStreak": "Current streak", + "peakDay": "Peak day", + "tokens": "tokens", + "reported": "reported", + "days": "{{count, number}} day", + "days_other": "{{count, number}} days", + "justNow": "just now", + "minutesAgo": "{{count}}m ago", + "hoursAgo": "{{count}}h ago", + "rangeThirty": "Last 30 days", + "rangeThreeMonths": "Last 3 months", + "rangeSixMonths": "Last 6 months", + "rangeAll": "All reported", + "optionThirty": "30d", + "optionThreeMonths": "3 mo", + "optionSixMonths": "6 mo", + "optionAll": "All", + "dailyUnavailable": "Daily activity unavailable", + "tokenActivity": "Token activity", + "tokenActivityRange": "Token activity range", + "moreDetails": "More usage details", + "longestTask": "Longest task", + "longestStreak": "Longest streak", + "activityInsights": "Activity insights", + "fastMode": "Fast mode", + "reasoning": "Reasoning", + "skillsExplored": "Skills explored", + "totalThreads": "Total threads", + "mostUsedPlugins": "Most used plugins", + "runs": "{{count, number}} run", + "runs_other": "{{count, number}} runs" + }, + "fileDialog": { + "selectAuth": "Select auth.json file", + "exportFull": "Export Full Encrypted Account Config", + "importFull": "Import Full Encrypted Account Config", + "noFile": "No file selected" + } +} diff --git a/locales/manifest.json b/locales/manifest.json new file mode 100644 index 00000000..1a6bd49c --- /dev/null +++ b/locales/manifest.json @@ -0,0 +1,4 @@ +[ + { "code": "en-US", "label": "English" }, + { "code": "zh-CN", "label": "简体中文" } +] diff --git a/locales/zh-CN/native.json b/locales/zh-CN/native.json new file mode 100644 index 00000000..e3d02468 --- /dev/null +++ b/locales/zh-CN/native.json @@ -0,0 +1,36 @@ +{ + "tray": "托盘", + "trayIconAndSession": "图标 + 会话", + "trayHourlyAndWeekly": "小时 + 每周", + "hidden": "隐藏", + "dockIcon": "程序坞图标", + "showInDock": "在程序坞中显示", + "menuBarOnly": "仅菜单栏", + "settings": "设置", + "language": "语言", + "systemLanguage": "跟随系统", + "english": "English", + "simplifiedChinese": "简体中文", + "window": "窗口", + "help": "帮助", + "file": "文件", + "edit": "编辑", + "view": "视图", + "noAccounts": "尚未配置账户", + "openApp": "打开 Codex Switcher", + "quit": "退出", + "about": "关于 Codex Switcher", + "services": "服务", + "hide": "隐藏 Codex Switcher", + "hideOthers": "隐藏其他应用", + "minimize": "最小化", + "maximize": "最大化", + "closeWindow": "关闭窗口", + "undo": "撤销", + "redo": "重做", + "cut": "剪切", + "copy": "复制", + "paste": "粘贴", + "selectAll": "全选", + "fullscreen": "进入全屏" +} diff --git a/locales/zh-CN/ui.json b/locales/zh-CN/ui.json new file mode 100644 index 00000000..a1be5cff --- /dev/null +++ b/locales/zh-CN/ui.json @@ -0,0 +1,131 @@ +{ + "common": { + "add": "添加", + "cancel": "取消", + "close": "关闭", + "copy": "复制", + "copied": "已复制", + "dismiss": "关闭", + "import": "导入", + "later": "稍后", + "open": "打开", + "refresh": "刷新", + "restart": "重启", + "unknown": "未知", + "unknownError": "未知错误" + }, + "language": { "label": "语言", "system": "跟随系统", "changeFailed": "语言切换失败:{{error}}" }, + "window": { "minimize": "最小化", "maximize": "最大化", "restore": "还原", "close": "关闭" }, + "header": { + "codexRunning": "{{count}} 个 Codex 正在运行", + "forceClose": "强制关闭", + "forceCloseTitle": "强制关闭正在运行的 Codex 进程", + "openCodex": "打开 Codex", + "opening": "正在打开...", + "showAll": "显示所有账户名称和邮箱", + "hideAll": "隐藏所有账户名称和邮箱", + "refreshingAll": "正在刷新全部用量", + "refreshAll": "刷新全部用量", + "warmupAll": "使用所有账户发送最小流量", + "disableAutoAll": "关闭所有账户的自动预热", + "enableAutoAll": "开启所有账户的自动预热", + "timedTitle": "设置每天为所有账户执行预热的时间", + "timedWarmup": "定时预热", + "noTimes": "尚未添加时间。", + "removeTime": "移除 {{time}}", + "lightMode": "切换到浅色模式", + "darkMode": "切换到深色模式", + "accountMenu": "账户", + "addAccount": "+ 添加账户" + }, + "warmup": { + "warming": "预热中...", "autoOn": "自动:开", "autoOff": "自动:关", "autoSession": "自动:5 小时", "autoWeekly": "自动:每周", "autoWarming": "自动预热中...", + "waitingWeekly": "等待每周额度重置", "timedWarming": "定时预热中...", "timedOff": "定时:关", "timedNext": "定时:{{time}}", + "switchedFromTray": "已从托盘切换账户。", "switchFailed": "切换失败:{{error}}", "switchBlocked": "账户切换被阻止。", + "closeFailed": "关闭失败:{{error}}", "switchedAfterClose": "强制关闭 Codex 后已切换账户。", "switchAfterCloseFailed": "强制关闭后切换失败:{{error}}", + "sentFor": "已为 {{name}} 发送预热请求", "failedFor": "{{name}} 预热失败:{{error}}", "noneAvailable": "没有可预热的账户", + "sentForAll": "已为全部 {{count}} 个账户发送预热请求", "sentForAll_other": "已为全部 {{count}} 个账户发送预热请求", + "summary": "已预热 {{warmed}}/{{total}},失败:{{failed}}", "allFailed": "全部预热失败:{{error}}", + "autoSessionSentFor": "已为 {{name}} 执行 5 小时额度窗口自动预热", "autoWeeklySentFor": "已为 {{name}} 执行每周额度窗口自动预热", "autoFailedFor": "{{name}} 自动预热失败:{{error}}", + "timedSent": "已定时预热 {{count}} 个账户", "timedSent_other": "已定时预热 {{count}} 个账户", + "timedSummary": "定时预热:成功 {{warmed}} 个,失败 {{failed}} 个" + }, + "backup": { + "exportSlim": "导出精简文本", "importSlim": "导入精简文本", "exportFull": "导出完整加密文件", "importFull": "导入完整加密文件", + "exporting": "正在导出...", "importing": "正在导入...", "slimExported": "已导出精简文本({{count}} 个账户)。", "slimExportFailed": "精简文本导出失败", + "pasteFirst": "请先粘贴精简文本。", "importSummary": "已导入 {{imported}},跳过 {{skipped}}(共 {{total}})", "slimImportFailed": "精简文本导入失败", + "fullExported": "完整加密文件已导出。", "fullExportFailed": "完整文件导出失败", "fullImportFailed": "完整文件导入失败", + "keepExisting": "现有账户会被保留,只导入缺少的账户。", "secretWarning": "精简文本包含账户密钥,请妥善保管。", + "generating": "正在生成...", "exportPlaceholder": "导出的文本将显示在这里", "importPlaceholder": "在此粘贴配置文本", + "clipboardUnavailable": "剪贴板不可用,请手动复制。", "copyString": "复制文本", "importMissing": "导入缺少的账户" + }, + "codex": { "opened": "Codex 已打开。", "openFailed": "打开 Codex 失败:{{error}}" }, + "accounts": { + "loading": "正在加载账户...", "loadFailed": "账户加载失败", "emptyTitle": "还没有账户", "emptyBody": "添加第一个 Codex 账户以开始使用", + "add": "添加账户", "activeHeading": "当前账户", "otherHeading": "其他账户({{count}})", + "searchPlaceholder": "按名称或邮箱搜索账户", "searchLabel": "搜索账户", "clearSearch": "清除账户搜索", + "noMatchingTitle": "没有匹配的账户", "noMatchingBody": "请尝试其他账户名称或邮箱地址。", "sort": "排序", + "sortResetAsc": "重置时间:从早到晚", "sortResetDesc": "重置时间:从晚到早", "sortRemainingDesc": "剩余额度:从高到低", + "sortRemainingAsc": "剩余额度:从低到高", "sortExpiryAsc": "到期时间:从早到晚", "sortExpiryDesc": "到期时间:从晚到早", + "refreshSuccess": "用量刷新成功", "deleteConfirm": "再次点击删除以确认移除" + }, + "forceClose": { + "switch": "强制关闭并切换账户", "processes": "强制关闭正在运行的 Codex 进程", "title": "强制关闭正在运行的 Codex 进程?", + "body": "这将强制关闭当前阻止账户切换的 {{count}} 个 Codex 进程。", "body_other": "这将强制关闭当前阻止账户切换的 {{count}} 个 Codex 进程。", + "thenSwitch": "关闭 Codex 后,Codex Switcher 将切换到 {{name}}。", "warning": "Codex 中未保存的工作可能会丢失。", "closing": "正在强制关闭...", + "noneFound": "未发现正在运行的 Codex 进程。", "closed": "已强制关闭 {{count}} 个 Codex 会话。", "closed_other": "已强制关闭 {{count}} 个 Codex 会话。", + "partial": "已强制关闭 {{closed}}/{{total}} 个 Codex 会话,仍有 {{remaining}} 个正在运行。", "couldNotClose": "无法强制关闭 {{count}} 个 Codex 会话。", + "couldNotClose_other": "无法强制关闭 {{count}} 个 Codex 会话。", "failed": "强制关闭失败:{{error}}" + }, + "closeBehavior": { + "title": "在程序坞中保留 Codex Switcher?", "body": "窗口关闭后,Codex Switcher 可以保留在程序坞中,也可以仅显示在菜单栏。", + "changeLater": "之后可随时在托盘弹窗中更改。", "dontAsk": "不再询问", "keepDock": "保留在程序坞", "menuBarOnly": "仅菜单栏" + }, + "addAccount": { + "title": "添加账户", "chatgptLogin": "ChatGPT 登录", "importFile": "导入文件", "name": "账户名称(可选)", "namePlaceholder": "留空则使用邮箱", + "nameRequired": "请输入账户名称", "fileRequired": "请选择 auth.json 文件", "waiting": "正在等待浏览器登录...", + "openLink": "请在浏览器中打开以下链接继续:", "copyLinkManually": "剪贴板不可用,请手动复制链接。", "copied": "已复制!", + "remoteOauthWarning": "OAuth 登录必须在运行本程序的同一台主机上完成,因为回调会跳转到 localhost。", + "loginHelp": "点击下方按钮生成登录链接,然后在浏览器中打开并完成认证。", "selectFile": "选择 auth.json 文件", "browse": "浏览...", + "importHelp": "从已有的 Codex auth.json 文件导入凭据", "adding": "正在添加...", "generateLink": "生成登录链接" + }, + "accountCard": { + "never": "从未", "justNow": "刚刚", "secondsAgo": "{{count}} 秒前", "minutesAgo": "{{count}} 分钟前", "hoursAgo": "{{count}} 小时前", + "expiryUnavailable": "到期时间不可用", "expired": "已于 {{date}} 到期", "until": "有效期至 {{date}}", "resetCount": "{{count}} 次重置", + "resetCount_other": "{{count}} 次重置", "closest": "最近 {{date}}", "closestExpires": "最近一批于 {{date}} 到期", "apiKey": "API 密钥", + "rename": "点击重命名", "showInfo": "显示信息", "hideInfo": "隐藏信息", "lastUpdated": "最后更新:{{time}}", "active": "当前使用", + "closeProcesses": "请先关闭所有 Codex 进程", "switching": "切换中...", "codexRunning": "Codex 正在运行", "switch": "切换", + "warmupSending": "正在发送预热请求...", "warmupSend": "发送最小预热请求", "autoAll": "已为所有账户开启自动预热", + "autoDisable": "关闭此账户的自动预热", "autoEnable": "开启此账户的自动预热", "refreshUsage": "刷新用量", "remove": "移除账户" + }, + "usage": { + "now": "现在", "seconds": "{{count}} 秒", "minutes": "{{count}} 分钟", "hours": "{{count}} 小时", "hoursMinutes": "{{hours}} 小时 {{minutes}} 分钟", "days": "{{count}} 天", + "left": "剩余 {{percent}}%", "resets": "{{time}} 后重置", "fetching": "正在获取用量...", "noData": "没有速率限制数据", + "fiveHour": "5 小时限制", "weekly": "每周限制", "credits": "余额:{{balance}}" + }, + "updates": { + "available": "发现新版本:v{{version}}", "update": "更新", "downloading": "正在下载更新...", "ready": "更新已就绪,重启后生效。", "failed": "更新失败:{{error}}" + }, + "tray": { + "loading": "正在加载...", "noAccounts": "尚未配置账户", "session": "会话", "weekly": "每周", + "resetsNow": "现在重置", "resetsIn": "{{time}} 后重置", "usageUnavailable": "用量不可用", + "today": "今天", "lastSevenDays": "最近 7 天", "dock": "程序坞", "show": "显示", "menuBar": "菜单栏", + "openApp": "打开 Codex Switcher", "quit": "退出" + }, + "stats": { + "source": "通过 ChatGPT 后端获取的 Codex 用量统计", "chatgptOnly": "用量统计仅适用于 ChatGPT 账户。", "unavailable": "用量统计不可用。", + "title": "用量统计", "updated": "更新于 {{time}}", "asOf": "统计截至 {{date}}", "backend": "ChatGPT 后端", "refresh": "刷新用量统计", + "lifetime": "累计", "today": "今天", "lastSevenDays": "最近 7 天", "lastThirtyDays": "最近 30 天", "currentStreak": "当前连续天数", + "peakDay": "峰值日", "tokens": "tokens", "reported": "已统计", "days": "{{count, number}} 天", "days_other": "{{count, number}} 天", + "justNow": "刚刚", "minutesAgo": "{{count}} 分钟前", "hoursAgo": "{{count}} 小时前", + "rangeThirty": "最近 30 天", "rangeThreeMonths": "最近 3 个月", "rangeSixMonths": "最近 6 个月", "rangeAll": "全部统计", + "optionThirty": "30 天", "optionThreeMonths": "3 个月", "optionSixMonths": "6 个月", "optionAll": "全部", + "dailyUnavailable": "每日活动数据不可用", "tokenActivity": "Token 活动", "tokenActivityRange": "Token 活动范围", + "moreDetails": "更多用量详情", "longestTask": "最长任务", "longestStreak": "最长连续天数", "activityInsights": "活动分析", + "fastMode": "快速模式", "reasoning": "推理", "skillsExplored": "使用过的技能", "totalThreads": "总线程数", + "mostUsedPlugins": "最常用插件", "runs": "运行 {{count, number}} 次", "runs_other": "运行 {{count, number}} 次" + }, + "fileDialog": { + "selectAuth": "选择 auth.json 文件", "exportFull": "导出完整加密账户配置", "importFull": "导入完整加密账户配置", "noFile": "未选择文件" + } +} diff --git a/package.json b/package.json index 6afe7984..99395eed 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "pnpm check:i18n && tsc && vite build", + "check:i18n": "node scripts/check-i18n.mjs", + "check:i18n:strict": "node scripts/check-i18n.mjs --strict", "preview": "vite preview", "tauri": "sh ./scripts/tauri.sh", "tauri:win": "tauri", @@ -26,8 +28,10 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-updater": "^2.10.0", + "i18next": "^26.3.4", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-i18next": "^17.0.8" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5830e87a..f37723a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,12 +23,18 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.10.0 version: 2.10.0 + i18next: + specifier: ^26.3.4 + version: 26.3.4(typescript@5.8.3) react: specifier: ^19.1.0 version: 19.2.3 react-dom: specifier: ^19.1.0 version: 19.2.3(react@19.2.3) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.4(typescript@5.8.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.8.3) devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 @@ -128,6 +134,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -735,6 +745,17 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@26.3.4: + resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -859,6 +880,22 @@ packages: peerDependencies: react: ^19.2.3 + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -905,6 +942,11 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -945,6 +987,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1039,6 +1085,8 @@ snapshots: '@babel/core': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -1483,6 +1531,14 @@ snapshots: graceful-fs@4.2.11: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@26.3.4(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -1569,6 +1625,17 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-i18next@17.0.8(i18next@26.3.4(typescript@5.8.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.4(typescript@5.8.3) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + typescript: 5.8.3 + react-refresh@0.17.0: {} react@19.2.3: {} @@ -1627,6 +1694,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 @@ -1640,4 +1711,6 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + void-elements@3.1.0: {} + yallist@3.1.1: {} diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 00000000..302c148f --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,119 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const strict = process.argv.includes("--strict"); +const manifest = JSON.parse(readFileSync(join(root, "locales", "manifest.json"), "utf8")); +const localeCodes = manifest.map(({ code }) => code); +if (!localeCodes.includes("en-US")) throw new Error("manifest must include the en-US fallback locale"); +if (new Set(localeCodes).size !== localeCodes.length) throw new Error("manifest locale codes must be unique"); + +function flatten(value, prefix = "") { + return Object.entries(value).flatMap(([key, child]) => { + const path = prefix ? `${prefix}.${key}` : key; + return child && typeof child === "object" && !Array.isArray(child) + ? flatten(child, path) + : [path]; + }); +} + +const referenceKeys = new Map(); +const catalogWarnings = []; + +for (const catalog of ["ui.json", "native.json"]) { + const reference = new Set( + flatten(JSON.parse(readFileSync(join(root, "locales", "en-US", catalog), "utf8"))) + ); + referenceKeys.set(catalog, reference); + + for (const { code } of manifest) { + const keys = new Set( + flatten(JSON.parse(readFileSync(join(root, "locales", code, catalog), "utf8"))) + ); + const missing = [...reference].filter((key) => !keys.has(key)); + const extra = [...keys].filter((key) => !reference.has(key)); + if (code !== "en-US" && (missing.length || extra.length)) { + catalogWarnings.push({ code, catalog, missing, extra }); + } + } +} + +if (strict && catalogWarnings.length) { + throw new Error( + catalogWarnings + .map(({ code, catalog, missing, extra }) => + `${code}/${catalog} key mismatch\nMissing: ${missing.join(", ") || "none"}\nExtra: ${extra.join(", ") || "none"}` + ) + .join("\n\n") + ); +} + +if (!strict) { + for (const { code, catalog, missing, extra } of catalogWarnings) { + console.warn( + `\x1b[33m⚠ ${code}/${catalog} is incomplete; missing text falls back to en-US` + + ` / 翻译尚未完整,缺失文案将回退英文` + + `\n Missing / 缺少: ${missing.join(", ") || "none"}` + + `\n Extra / 多余: ${extra.join(", ") || "none"}\x1b[0m` + ); + } +} + +function sourceFiles(directory, extensions) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path, extensions); + return extensions.some((extension) => entry.name.endsWith(extension)) ? [path] : []; + }); +} + +function assertUsedKeysExist(files, catalog) { + const known = referenceKeys.get(catalog); + const missing = []; + const keyPattern = /(?:\bt|\bi18n\.t|\bappI18n\.t)\(\s*["']([^"']+)["']/g; + + for (const file of files) { + const contents = readFileSync(file, "utf8"); + for (const match of contents.matchAll(keyPattern)) { + if (!known.has(match[1])) missing.push(`${file.slice(root.length + 1)}: ${match[1]}`); + } + } + + if (missing.length) { + throw new Error(`Unknown keys used with ${catalog}:\n${missing.join("\n")}`); + } +} + +assertUsedKeysExist(sourceFiles(join(root, "src"), [".ts", ".tsx"]), "ui.json"); +assertUsedKeysExist( + [join(root, "src-tauri", "src", "app_menu.rs"), join(root, "src-tauri", "src", "tray.rs")], + "native.json" +); + +const hardcodedToastFiles = []; +for (const file of sourceFiles(join(root, "src"), [".ts", ".tsx"])) { + const contents = readFileSync(file, "utf8"); + if (/showWarmupToast\(\s*[`"']/.test(contents)) { + hardcodedToastFiles.push(file.slice(root.length + 1)); + } +} + +if (strict && hardcodedToastFiles.length) { + throw new Error( + `Non-localized toast literals found:\n${hardcodedToastFiles.join("\n")}` + ); +} + +if (!strict && hardcodedToastFiles.length) { + console.warn( + `\x1b[33m⚠ Non-localized toast literals found; build will continue` + + ` / 发现未国际化的 Toast,构建将继续` + + `\n${hardcodedToastFiles.map((file) => ` ${file}`).join("\n")}\x1b[0m` + ); +} + +console.log( + `\x1b[32m✓ i18n ${strict ? "strict " : ""}check passed for ${manifest.length} locales` + + ` / 国际化资源${strict ? "严格" : "常规"}检查通过,共 ${manifest.length} 种语言\x1b[0m` +); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e6fc2360..1241b60d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -535,6 +535,7 @@ dependencies = [ "dirs", "flate2", "futures", + "include_dir", "pbkdf2", "plist", "rand 0.9.2", @@ -542,6 +543,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sys-locale", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -1970,6 +1972,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -4219,6 +4240,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.7.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b491ae8f..6f8b1109 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,8 @@ thiserror = "2" anyhow = "1" urlencoding = "2" futures = "0.3" +include_dir = "0.7" +sys-locale = "0.3" url = "2" flate2 = "1" chacha20poly1305 = "0.10" diff --git a/src-tauri/src/app_menu.rs b/src-tauri/src/app_menu.rs index 04c515ef..07edc72c 100644 --- a/src-tauri/src/app_menu.rs +++ b/src-tauri/src/app_menu.rs @@ -9,12 +9,13 @@ use tauri::{ pub(crate) use crate::types::DockDisplayMode; use crate::{ auth::{load_app_settings, save_app_settings}, - types::{AppSettings, TrayDisplayMode}, + types::{AppLanguage, AppSettings, TrayDisplayMode}, }; const TRAY_ICON_AND_SESSION_ID: &str = "tray-display-icon-and-session"; const TRAY_ACTIVE_USAGE_TEXT_ID: &str = "tray-display-active-usage-text"; const TRAY_HIDDEN_ID: &str = "tray-display-hidden"; +const LANGUAGE_ITEM_PREFIX: &str = "language:"; #[cfg(target_os = "macos")] pub(crate) const DOCK_SHOW_IN_DOCK_ID: &str = "dock-display-show-in-dock"; #[cfg(target_os = "macos")] @@ -43,6 +44,14 @@ fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { return; } + if let Some(code) = item_id.as_ref().strip_prefix(LANGUAGE_ITEM_PREFIX) { + let language = AppLanguage::new(code); + if let Err(error) = crate::commands::settings::set_app_language(app.clone(), language) { + eprintln!("Failed to update app language: {error}"); + } + return; + } + #[cfg(target_os = "macos")] if let Some(mode) = dock_display_mode_for_item(item_id.as_ref()) { update_dock_display_mode(app, mode); @@ -176,6 +185,8 @@ fn apply_dock_display_mode(app: &AppHandle, mode: DockDisplayMode } fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri::Result> { + let resolved_code = crate::i18n::resolved_code(&settings.language); + let t = |key| crate::i18n::text_for_code(resolved_code, key); let pkg_info = app.package_info(); let config = app.config(); let about_metadata = AboutMetadata { @@ -192,13 +203,13 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: let tray_settings = Submenu::with_items( app, - "Tray", + t("tray"), true, &[ &CheckMenuItem::with_id( app, TRAY_ICON_AND_SESSION_ID, - "Icon + Session", + t("trayIconAndSession"), true, settings.tray_display_mode == TrayDisplayMode::IconAndSession, None::<&str>, @@ -206,7 +217,7 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: &CheckMenuItem::with_id( app, TRAY_ACTIVE_USAGE_TEXT_ID, - "Hourly + Weekly", + t("trayHourlyAndWeekly"), true, settings.tray_display_mode == TrayDisplayMode::ActiveUsageText, None::<&str>, @@ -214,7 +225,7 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: &CheckMenuItem::with_id( app, TRAY_HIDDEN_ID, - "Hidden", + t("hidden"), true, settings.tray_display_mode == TrayDisplayMode::Hidden, None::<&str>, @@ -225,13 +236,13 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: #[cfg(target_os = "macos")] let dock_settings = Submenu::with_items( app, - "Dock Icon", + t("dockIcon"), true, &[ &CheckMenuItem::with_id( app, DOCK_SHOW_IN_DOCK_ID, - "Show in Dock", + t("showInDock"), true, settings.dock_display_mode == DockDisplayMode::ShowInDock, None::<&str>, @@ -239,7 +250,7 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: &CheckMenuItem::with_id( app, DOCK_MENU_BAR_ONLY_ID, - "Menu Bar Only", + t("menuBarOnly"), true, settings.dock_display_mode == DockDisplayMode::MenuBarOnly, None::<&str>, @@ -247,27 +258,58 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: ], )?; + let language_settings = Submenu::new(app, t("language"), true)?; + let system_language = CheckMenuItem::with_id( + app, + format!("{LANGUAGE_ITEM_PREFIX}{}", AppLanguage::SYSTEM_CODE), + t("systemLanguage"), + true, + settings.language.as_str() == AppLanguage::SYSTEM_CODE, + None::<&str>, + )?; + language_settings.append(&system_language)?; + for locale in crate::i18n::supported_languages() { + let item = CheckMenuItem::with_id( + app, + format!("{LANGUAGE_ITEM_PREFIX}{}", locale.code), + &locale.label, + true, + settings.language.as_str() == locale.code, + None::<&str>, + )?; + language_settings.append(&item)?; + } + #[cfg(target_os = "macos")] - let settings_menu = - Submenu::with_items(app, "Settings", true, &[&tray_settings, &dock_settings])?; + let settings_menu = Submenu::with_items( + app, + t("settings"), + true, + &[&tray_settings, &dock_settings, &language_settings], + )?; #[cfg(not(target_os = "macos"))] - let settings_menu = Submenu::with_items(app, "Settings", true, &[&tray_settings])?; + let settings_menu = Submenu::with_items( + app, + t("settings"), + true, + &[&tray_settings, &language_settings], + )?; let window_menu = Submenu::with_items( app, - "Window", + t("window"), true, &[ - &PredefinedMenuItem::minimize(app, None)?, - &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::minimize(app, Some(t("minimize")))?, + &PredefinedMenuItem::maximize(app, Some(t("maximize")))?, #[cfg(target_os = "macos")] &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::close_window(app, None)?, + &PredefinedMenuItem::close_window(app, Some(t("closeWindow")))?, ], )?; - let help_menu = Submenu::with_items(app, "Help", true, &[])?; + let help_menu = Submenu::with_items(app, t("help"), true, &[])?; Menu::with_items( app, @@ -278,16 +320,16 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: pkg_info.name.clone(), true, &[ - &PredefinedMenuItem::about(app, None, Some(about_metadata))?, + &PredefinedMenuItem::about(app, Some(t("about")), Some(about_metadata))?, &PredefinedMenuItem::separator(app)?, &settings_menu, &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::services(app, Some(t("services")))?, &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::hide(app, None)?, - &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::hide(app, Some(t("hide")))?, + &PredefinedMenuItem::hide_others(app, Some(t("hideOthers")))?, &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::quit(app, None)?, + &PredefinedMenuItem::quit(app, Some(t("quit")))?, ], )?, #[cfg(not(any( @@ -299,34 +341,36 @@ fn build_menu(app: &AppHandle, settings: &AppSettings) -> tauri:: )))] &Submenu::with_items( app, - "File", + t("file"), true, &[ - &PredefinedMenuItem::close_window(app, None)?, + &PredefinedMenuItem::close_window(app, Some(t("closeWindow")))?, #[cfg(not(target_os = "macos"))] - &PredefinedMenuItem::quit(app, None)?, + &PredefinedMenuItem::quit(app, Some(t("quit")))?, ], )?, + #[cfg(not(target_os = "macos"))] + &settings_menu, &Submenu::with_items( app, - "Edit", + t("edit"), true, &[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::undo(app, Some(t("undo")))?, + &PredefinedMenuItem::redo(app, Some(t("redo")))?, &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, + &PredefinedMenuItem::cut(app, Some(t("cut")))?, + &PredefinedMenuItem::copy(app, Some(t("copy")))?, + &PredefinedMenuItem::paste(app, Some(t("paste")))?, + &PredefinedMenuItem::select_all(app, Some(t("selectAll")))?, ], )?, #[cfg(target_os = "macos")] &Submenu::with_items( app, - "View", + t("view"), true, - &[&PredefinedMenuItem::fullscreen(app, None)?], + &[&PredefinedMenuItem::fullscreen(app, Some(t("fullscreen")))?], )?, &window_menu, &help_menu, diff --git a/src-tauri/src/commands/account_stats.rs b/src-tauri/src/commands/account_stats.rs index 0bb1273b..c54896f4 100644 --- a/src-tauri/src/commands/account_stats.rs +++ b/src-tauri/src/commands/account_stats.rs @@ -14,6 +14,7 @@ const CHATGPT_PROFILE_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/pr const CHATGPT_RESET_CREDITS_URL: &str = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; const CODEX_USER_AGENT: &str = "codex-cli/1.0.0"; +const ACCOUNT_USAGE_SOURCE_CHATGPT_BACKEND: &str = "chatgpt_backend"; #[derive(Debug, Clone, Serialize)] pub struct AccountUsageStats { @@ -289,7 +290,7 @@ fn map_profile_usage(account_id: &str, payload: ProfileUsageResponse) -> Account AccountUsageStats { account_id: account_id.to_string(), available, - source: "Codex usage stats via ChatGPT backend".to_string(), + source: ACCOUNT_USAGE_SOURCE_CHATGPT_BACKEND.to_string(), generated_at: payload.metadata.generated_at, stats_as_of: payload.metadata.stats_as_of, summary: AccountUsageSummary { @@ -388,7 +389,7 @@ fn unavailable_stats(account_id: String, message: &str) -> AccountUsageStats { AccountUsageStats { account_id, available: false, - source: "Codex usage stats via ChatGPT backend".to_string(), + source: ACCOUNT_USAGE_SOURCE_CHATGPT_BACKEND.to_string(), generated_at: None, stats_as_of: None, summary: AccountUsageSummary::default(), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0d8a128a..5074e1e6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod account; pub mod account_stats; pub mod oauth; pub mod process; +pub mod settings; pub mod usage; pub mod window; @@ -11,5 +12,6 @@ pub use account::*; pub use account_stats::*; pub use oauth::*; pub use process::*; +pub use settings::*; pub use usage::*; pub use window::*; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs new file mode 100644 index 00000000..e008cccf --- /dev/null +++ b/src-tauri/src/commands/settings.rs @@ -0,0 +1,74 @@ +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +use crate::{ + auth::{load_app_settings, save_app_settings}, + types::AppLanguage, +}; + +pub const LANGUAGE_CHANGED_EVENT: &str = "language-changed"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppLanguageState { + pub preference: AppLanguage, + pub resolved_code: String, +} + +fn language_state(preference: AppLanguage) -> AppLanguageState { + let resolved_code = crate::i18n::resolved_code(&preference).to_string(); + AppLanguageState { + preference, + resolved_code, + } +} + +#[tauri::command] +pub fn get_app_language() -> AppLanguageState { + language_state(load_app_settings().unwrap_or_default().language) +} + +#[tauri::command] +pub fn set_app_language(app: AppHandle, language: AppLanguage) -> Result { + let state = save_language(language)?; + + #[cfg(desktop)] + { + if let Err(error) = crate::app_menu::refresh(&app) { + eprintln!("Failed to refresh app menu after language change: {error}"); + } + crate::tray::refresh(&app); + } + + let _ = app.emit(LANGUAGE_CHANGED_EVENT, state.clone()); + Ok(state) +} + +pub fn save_language(language: AppLanguage) -> Result { + if !crate::i18n::is_supported(&language) { + return Err(format!("Unsupported app language: {}", language.as_str())); + } + + let mut settings = load_app_settings().unwrap_or_default(); + if settings.language != language { + settings.language = language.clone(); + save_app_settings(&settings).map_err(|error| error.to_string())?; + } + Ok(language_state(language)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_language_state_keeps_preference_and_resolved_code() { + let state = language_state(AppLanguage::new("zh-CN")); + assert_eq!(state.preference.as_str(), "zh-CN"); + assert_eq!(state.resolved_code, "zh-CN"); + + let serialized = serde_json::to_value(state).unwrap(); + assert_eq!(serialized["preference"], "zh-CN"); + assert_eq!(serialized["resolvedCode"], "zh-CN"); + } +} diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs new file mode 100644 index 00000000..be9d2a1a --- /dev/null +++ b/src-tauri/src/i18n.rs @@ -0,0 +1,160 @@ +use std::{collections::HashMap, sync::LazyLock}; + +use include_dir::{include_dir, Dir}; +use serde::Deserialize; + +use crate::types::AppLanguage; + +static LOCALES_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../locales"); + +#[derive(Debug, Deserialize)] +pub struct LocaleDefinition { + pub code: String, + pub label: String, +} + +static MANIFEST: LazyLock> = LazyLock::new(|| { + let contents = LOCALES_DIR + .get_file("manifest.json") + .expect("locales/manifest.json must be embedded") + .contents_utf8() + .expect("locales/manifest.json must be UTF-8"); + serde_json::from_str(contents).expect("locales/manifest.json must be valid JSON") +}); + +static NATIVE_TRANSLATIONS: LazyLock>> = + LazyLock::new(|| { + MANIFEST + .iter() + .map(|locale| { + let path = format!("{}/native.json", locale.code); + let contents = LOCALES_DIR + .get_file(&path) + .unwrap_or_else(|| panic!("{path} must be embedded")) + .contents_utf8() + .unwrap_or_else(|| panic!("{path} must be UTF-8")); + let translations = serde_json::from_str(contents) + .unwrap_or_else(|error| panic!("{path} must be valid JSON: {error}")); + (locale.code.clone(), translations) + }) + .collect() + }); + +pub fn supported_languages() -> &'static [LocaleDefinition] { + &MANIFEST +} + +pub fn is_supported(language: &AppLanguage) -> bool { + if language.as_str() == AppLanguage::SYSTEM_CODE { + return true; + } + MANIFEST + .iter() + .any(|locale| locale.code == language.as_str()) +} + +fn match_locale_code(locale: Option<&str>) -> &'static str { + let Some(locale) = locale else { + return AppLanguage::DEFAULT_CODE; + }; + let normalized = locale.replace('_', "-").to_ascii_lowercase(); + + if let Some(exact) = MANIFEST + .iter() + .find(|item| item.code.to_ascii_lowercase() == normalized) + { + return exact.code.as_str(); + } + + // Do not map Traditional Chinese locales to Simplified Chinese. + if normalized.starts_with("zh-") + && !normalized.starts_with("zh-cn") + && !normalized.starts_with("zh-sg") + && !normalized.starts_with("zh-hans") + { + return AppLanguage::DEFAULT_CODE; + } + + let primary = normalized.split('-').next().unwrap_or_default(); + MANIFEST + .iter() + .find(|item| { + item.code + .split('-') + .next() + .is_some_and(|code| code.eq_ignore_ascii_case(primary)) + }) + .map(|item| item.code.as_str()) + .unwrap_or(AppLanguage::DEFAULT_CODE) +} + +pub fn resolved_code(language: &AppLanguage) -> &'static str { + if language.as_str() == AppLanguage::SYSTEM_CODE { + return match_locale_code(sys_locale::get_locale().as_deref()); + } + MANIFEST + .iter() + .find(|item| item.code == language.as_str()) + .map(|item| item.code.as_str()) + .unwrap_or(AppLanguage::DEFAULT_CODE) +} + +pub fn text_for_code<'a>(language_code: &str, key: &'a str) -> &'a str { + NATIVE_TRANSLATIONS + .get(language_code) + .and_then(|translations| translations.get(key)) + .or_else(|| { + NATIVE_TRANSLATIONS + .get(AppLanguage::DEFAULT_CODE) + .and_then(|translations| translations.get(key)) + }) + .map(String::as_str) + .unwrap_or(key) +} + +pub fn text<'a>(language: &AppLanguage, key: &'a str) -> &'a str { + text_for_code(resolved_code(language), key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_manifest_native_catalogs_load() { + assert!(!NATIVE_TRANSLATIONS[AppLanguage::DEFAULT_CODE].is_empty()); + for locale in supported_languages() { + assert!(NATIVE_TRANSLATIONS.contains_key(locale.code.as_str())); + } + } + + #[test] + fn manifest_languages_are_available() { + assert!(is_supported(&AppLanguage::new("system"))); + assert!(is_supported(&AppLanguage::new("en-US"))); + assert!(is_supported(&AppLanguage::new("zh-CN"))); + assert!(!is_supported(&AppLanguage::new("missing"))); + } + + #[test] + fn system_locale_falls_back_to_english() { + assert_eq!(match_locale_code(Some("xx-ZZ")), "en-US"); + assert_eq!(match_locale_code(None), "en-US"); + } + + #[test] + fn system_locale_matches_supported_variants() { + assert_eq!(match_locale_code(Some("zh_CN")), "zh-CN"); + assert_eq!(match_locale_code(Some("zh-Hans-CN")), "zh-CN"); + assert_eq!(match_locale_code(Some("zh-TW")), "en-US"); + assert_eq!(match_locale_code(Some("en-GB")), "en-US"); + } + + #[test] + fn unknown_keys_fall_back_to_the_key() { + assert_eq!( + text(&AppLanguage::new("zh-CN"), "missing.key"), + "missing.key" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72956a7c..17b2826c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ pub mod api; pub mod app_menu; pub mod auth; pub mod commands; +pub mod i18n; #[cfg(desktop)] pub mod tray; pub mod types; @@ -13,12 +14,13 @@ pub mod web; use commands::{ ack_close_behavior_prompt, add_account_from_file, cancel_login, check_codex_processes, complete_close_behavior, complete_login, delete_account, export_accounts_full_encrypted_file, - export_accounts_slim_text, get_account_usage_stats, get_active_account_info, + export_accounts_slim_text, get_account_usage_stats, get_active_account_info, get_app_language, get_dock_display_mode, get_masked_account_ids, get_usage, hide_tray_window, import_accounts_full_encrypted_file, import_accounts_slim_text, kill_codex_processes, list_accounts, open_main_window, quit_app, refresh_account_metadata, - refresh_all_accounts_usage, rename_account, report_usage, set_dock_display_mode, - set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts, + refresh_all_accounts_usage, rename_account, report_usage, set_app_language, + set_dock_display_mode, set_masked_account_ids, start_login, switch_account, warmup_account, + warmup_all_accounts, }; use tauri::Emitter; @@ -98,6 +100,8 @@ pub fn run() { set_dock_display_mode, complete_close_behavior, ack_close_behavior_prompt, + get_app_language, + set_app_language, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 7f6760e7..18f9ab19 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -16,7 +16,7 @@ use crate::{ is_codex_running_switch_block, restore_main_window, switch_account_by_id, window::TRAY_WINDOW, }, - types::{AccountsStore, TrayDisplayMode, UsageInfo}, + types::{AccountsStore, AppSettings, TrayDisplayMode, UsageInfo}, }; static TRAY_USAGE: LazyLock>> = @@ -44,7 +44,9 @@ pub fn setup(app: &AppHandle) -> tauri::Result<()> { #[cfg(not(target_os = "linux"))] create_tray_window(app)?; - let menu = build_menu(app, &load_accounts().unwrap_or_default())?; + let store = load_accounts().unwrap_or_default(); + let settings = load_app_settings().unwrap_or_default(); + let menu = build_menu(app, &store, &settings)?; #[cfg(target_os = "linux")] let icon = app @@ -180,12 +182,18 @@ fn position_near_cursor( // Native menu (the only tray interaction on Linux; right-click on macOS/Windows) // ============================================================================ -fn build_menu(app: &AppHandle, store: &AccountsStore) -> tauri::Result> { +fn build_menu( + app: &AppHandle, + store: &AccountsStore, + settings: &AppSettings, +) -> tauri::Result> { let menu = Menu::new(app)?; + let resolved_code = crate::i18n::resolved_code(&settings.language); + let t = |key| crate::i18n::text_for_code(resolved_code, key); if store.accounts.is_empty() { menu.append( - &MenuItemBuilder::with_id("empty", "No accounts configured") + &MenuItemBuilder::with_id("empty", t("noAccounts")) .enabled(false) .build(app)?, )?; @@ -202,30 +210,36 @@ fn build_menu(app: &AppHandle, store: &AccountsStore) -> tauri::R menu.append(&PredefinedMenuItem::separator(app)?)?; #[cfg(target_os = "macos")] - append_dock_settings_menu(app, &menu)?; + append_dock_settings_menu(app, &menu, settings, resolved_code)?; #[cfg(target_os = "macos")] menu.append(&PredefinedMenuItem::separator(app)?)?; - menu.append(&MenuItemBuilder::with_id(OPEN_ITEM_ID, "Open Codex Switcher").build(app)?)?; - menu.append(&MenuItemBuilder::with_id(QUIT_ITEM_ID, "Quit").build(app)?)?; + menu.append(&MenuItemBuilder::with_id(OPEN_ITEM_ID, t("openApp")).build(app)?)?; + menu.append(&MenuItemBuilder::with_id(QUIT_ITEM_ID, t("quit")).build(app)?)?; Ok(menu) } #[cfg(target_os = "macos")] -fn append_dock_settings_menu(app: &AppHandle, menu: &Menu) -> tauri::Result<()> { - let settings = load_app_settings().unwrap_or_default(); +fn append_dock_settings_menu( + app: &AppHandle, + menu: &Menu, + settings: &AppSettings, + resolved_code: &str, +) -> tauri::Result<()> { + let t = |key| crate::i18n::text_for_code(resolved_code, key); let dock_settings = Submenu::with_items( app, - "Dock Icon", + t("dockIcon"), true, &[ - &CheckMenuItemBuilder::with_id(crate::app_menu::DOCK_SHOW_IN_DOCK_ID, "Show in Dock") + &CheckMenuItemBuilder::with_id(crate::app_menu::DOCK_SHOW_IN_DOCK_ID, t("showInDock")) .checked(settings.dock_display_mode == crate::app_menu::DockDisplayMode::ShowInDock) .build(app)?, - &CheckMenuItemBuilder::with_id(crate::app_menu::DOCK_MENU_BAR_ONLY_ID, "Menu Bar Only") - .checked( - settings.dock_display_mode == crate::app_menu::DockDisplayMode::MenuBarOnly, - ) - .build(app)?, + &CheckMenuItemBuilder::with_id( + crate::app_menu::DOCK_MENU_BAR_ONLY_ID, + t("menuBarOnly"), + ) + .checked(settings.dock_display_mode == crate::app_menu::DockDisplayMode::MenuBarOnly) + .build(app)?, ], )?; menu.append(&dock_settings)?; @@ -303,7 +317,7 @@ fn refresh_menu_on_main_thread(app: &AppHandle) { store.active_account_id.as_deref(), settings.tray_display_mode, ); - let menu = build_menu(app, &store).map_err(|error| error.to_string())?; + let menu = build_menu(app, &store, &settings).map_err(|error| error.to_string())?; Ok((menu, title, settings.tray_display_mode)) }) { Ok((menu, title, mode)) => { diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index c23d96d3..4c472c6e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -36,6 +36,29 @@ pub enum DockDisplayMode { MenuBarOnly, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AppLanguage(String); + +impl AppLanguage { + pub const DEFAULT_CODE: &'static str = "en-US"; + pub const SYSTEM_CODE: &'static str = "system"; + + pub fn new(code: impl Into) -> Self { + Self(code.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for AppLanguage { + fn default() -> Self { + Self::new(Self::SYSTEM_CODE) + } +} + fn default_close_behavior_prompt_enabled() -> bool { true } @@ -45,6 +68,7 @@ fn default_close_behavior_prompt_enabled() -> bool { pub struct AppSettings { pub tray_display_mode: TrayDisplayMode, pub dock_display_mode: DockDisplayMode, + pub language: AppLanguage, #[serde(default = "default_close_behavior_prompt_enabled")] pub close_behavior_prompt_enabled: bool, } @@ -54,6 +78,7 @@ impl Default for AppSettings { Self { tray_display_mode: TrayDisplayMode::default(), dock_display_mode: DockDisplayMode::default(), + language: AppLanguage::default(), close_behavior_prompt_enabled: true, } } @@ -500,7 +525,9 @@ pub struct CreditStatusDetails { #[cfg(test)] mod tests { - use super::{parse_chatgpt_id_token_claims, AppSettings, DockDisplayMode, TrayDisplayMode}; + use super::{ + parse_chatgpt_id_token_claims, AppLanguage, AppSettings, DockDisplayMode, TrayDisplayMode, + }; use base64::Engine; #[test] @@ -529,6 +556,7 @@ mod tests { assert_eq!(settings.tray_display_mode, TrayDisplayMode::ActiveUsageText); assert_eq!(settings.dock_display_mode, DockDisplayMode::ShowInDock); + assert_eq!(settings.language, AppLanguage::default()); assert!(settings.close_behavior_prompt_enabled); } } diff --git a/src-tauri/src/web.rs b/src-tauri/src/web.rs index 1d91290e..95a06e1c 100644 --- a/src-tauri/src/web.rs +++ b/src-tauri/src/web.rs @@ -13,11 +13,12 @@ use crate::commands::{ add_account_from_auth_json_text, add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account, export_accounts_full_encrypted_bytes, export_accounts_slim_text, fetch_usage, get_account_usage_stats, get_active_account_info, - get_masked_account_ids, import_accounts_full_encrypted_bytes, import_accounts_slim_text, - kill_codex_processes, list_accounts, refresh_account_metadata, refresh_all_accounts_usage, - rename_account, set_masked_account_ids, start_login, switch_account, warmup_account, - warmup_all_accounts, + get_app_language, get_masked_account_ids, import_accounts_full_encrypted_bytes, + import_accounts_slim_text, kill_codex_processes, list_accounts, refresh_account_metadata, + refresh_all_accounts_usage, rename_account, set_masked_account_ids, start_login, + switch_account, warmup_account, warmup_all_accounts, }; +use crate::types::AppLanguage; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -71,6 +72,11 @@ struct FileImportArgs { name: String, } +#[derive(Debug, Deserialize)] +struct LanguageArgs { + language: AppLanguage, +} + pub fn run_lan_server(host: &str, port: u16) -> anyhow::Result<()> { let address = format!("{host}:{port}"); let server = Server::http(&address) @@ -195,6 +201,11 @@ async fn invoke_web_command(command: &str, payload: Value) -> Result to_json(get_app_language()), + "set_app_language" => { + let args: LanguageArgs = parse_args(payload)?; + to_json(crate::commands::settings::save_language(args.language)?) + } "check_codex_processes" => to_json(check_codex_processes().await?), "kill_codex_processes" => to_json(kill_codex_processes().await?), _ => Err(format!("Unsupported web command: {command}")), diff --git a/src/App.tsx b/src/App.tsx index 716ad2f6..b64839be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useAccounts } from "./hooks/useAccounts"; import { useForceCloseCodexProcesses } from "./hooks/useForceCloseCodexProcesses"; @@ -38,6 +39,14 @@ import { type AutoWarmupWindowKind, } from "./lib/autoWarmupPolicy"; import "./App.css"; +import { + changeAppLanguage, + getLanguagePreference, + subscribeLanguagePreference, + supportedLanguages, + SYSTEM_LANGUAGE, + type AppLanguage, +} from "./i18n"; const AUTO_WARMUP_CHECK_INTERVAL_MS = 30 * 1000; const AUTO_WARMUP_RETRY_BACKOFF_MS = 60 * 1000; @@ -161,6 +170,7 @@ function matchesAccountSearch( } function App() { + const { t } = useTranslation(); const { accounts, loading, @@ -243,6 +253,9 @@ function App() { const actionsMenuRef = useRef(null); const timedWarmupRef = useRef(null); const [themeMode, setThemeMode] = useState(readStoredTheme); + const [languagePreference, setLanguagePreference] = useState( + getLanguagePreference + ); const [isWindowMaximized, setIsWindowMaximized] = useState(false); const [closeBehaviorPromptOpen, setCloseBehaviorPromptOpen] = useState(false); const [closeBehaviorDontAskAgain, setCloseBehaviorDontAskAgain] = useState(false); @@ -476,6 +489,11 @@ function App() { } }, [themeMode]); + useEffect( + () => subscribeLanguagePreference(setLanguagePreference), + [] + ); + useEffect(() => { if (!isTauriRuntime() || isMacOs) return; @@ -556,16 +574,25 @@ function App() { setTimeout(() => setWarmupToast(null), 2500); }, []); + const handleLanguageChange = useCallback(async (language: AppLanguage) => { + try { + await changeAppLanguage(language); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showWarmupToast(t("language.changeFailed", { error: message }), true); + } + }, [showWarmupToast, t]); + const formatWarmupError = useCallback((err: unknown) => { - if (!err) return "Unknown error"; + if (!err) return t("common.unknownError"); if (err instanceof Error && err.message) return err.message; if (typeof err === "string") return err; try { return JSON.stringify(err); } catch { - return "Unknown error"; + return t("common.unknownError"); } - }, []); + }, [t]); const markSuccessfulWarmup = useCallback( (accountId: string, timestamp = Date.now(), window?: AutoWarmupWindow) => { @@ -623,10 +650,10 @@ function App() { setSwitchingId(accountId); await switchAccount(accountId); setPendingTraySwitchAccountId(null); - showWarmupToast("Switched account from tray."); + showWarmupToast(t("warmup.switchedFromTray")); } catch (err) { console.error("Failed to retry tray account switch:", err); - showWarmupToast(`Switch failed: ${formatWarmupError(err)}`, true); + showWarmupToast(t("warmup.switchFailed", { error: formatWarmupError(err) }), true); } finally { setSwitchingId(null); } @@ -634,7 +661,7 @@ function App() { } showWarmupToast( - event.payload?.error || "Account switch was blocked.", + event.payload?.error || t("warmup.switchBlocked"), true ); } @@ -665,7 +692,7 @@ function App() { unlistenAutoWarmup?.(); unlistenCloseBehavior?.(); }; - }, [checkProcesses, formatWarmupError, setForceCloseConfirmOpen, showWarmupToast, switchAccount]); + }, [checkProcesses, formatWarmupError, setForceCloseConfirmOpen, showWarmupToast, switchAccount, t]); const handleCloseBehaviorChoice = useCallback( async (mode: DockDisplayMode) => { @@ -678,12 +705,12 @@ function App() { setCloseBehaviorPromptOpen(false); } catch (err) { console.error("Failed to complete close behavior:", err); - showWarmupToast(`Close failed: ${formatWarmupError(err)}`, true); + showWarmupToast(t("warmup.closeFailed", { error: formatWarmupError(err) }), true); } finally { setIsCompletingCloseBehavior(false); } }, - [closeBehaviorDontAskAgain, formatWarmupError, showWarmupToast] + [closeBehaviorDontAskAgain, formatWarmupError, showWarmupToast, t] ); const handleForceCloseConfirm = useCallback(async () => { @@ -703,12 +730,12 @@ function App() { setSwitchingId(accountId); await switchAccount(accountId); setPendingTraySwitchAccountId(null); - showWarmupToast("Switched account after force closing Codex."); + showWarmupToast(t("warmup.switchedAfterClose")); } catch (err) { console.error("Failed to switch account after force close:", err); setPendingTraySwitchAccountId(null); showWarmupToast( - `Switch failed after force close: ${formatWarmupError(err)}`, + t("warmup.switchAfterCloseFailed", { error: formatWarmupError(err) }), true ); } finally { @@ -720,6 +747,7 @@ function App() { pendingTraySwitchAccountId, showWarmupToast, switchAccount, + t, ]); const handleWarmupAccount = async (accountId: string, accountName: string) => { @@ -727,11 +755,11 @@ function App() { setWarmingUpId(accountId); await warmupAccount(accountId); markSuccessfulWarmup(accountId); - showWarmupToast(`Warm-up sent for ${accountName}`); + showWarmupToast(t("warmup.sentFor", { name: accountName })); } catch (err) { console.error("Failed to warm up account:", err); showWarmupToast( - `Warm-up failed for ${accountName}: ${formatWarmupError(err)}`, + t("warmup.failedFor", { name: accountName, error: formatWarmupError(err) }), true ); } finally { @@ -744,7 +772,7 @@ function App() { setIsWarmingAll(true); const summary = await warmupAllAccounts(); if (summary.total_accounts === 0) { - showWarmupToast("No accounts available for warm-up", true); + showWarmupToast(t("warmup.noneAvailable"), true); return; } @@ -758,19 +786,21 @@ function App() { if (summary.failed_account_ids.length === 0) { showWarmupToast( - `Warm-up sent for all ${summary.warmed_accounts} account${ - summary.warmed_accounts === 1 ? "" : "s" - }` + t("warmup.sentForAll", { count: summary.warmed_accounts }) ); } else { showWarmupToast( - `Warmed ${summary.warmed_accounts}/${summary.total_accounts}. Failed: ${summary.failed_account_ids.length}`, + t("warmup.summary", { + warmed: summary.warmed_accounts, + total: summary.total_accounts, + failed: summary.failed_account_ids.length, + }), true ); } } catch (err) { console.error("Failed to warm up all accounts:", err); - showWarmupToast(`Warm-up all failed: ${formatWarmupError(err)}`, true); + showWarmupToast(t("warmup.allFailed", { error: formatWarmupError(err) }), true); } finally { setIsWarmingAll(false); } @@ -801,28 +831,28 @@ function App() { isEnabled: boolean, isRunning: boolean ) => { - if (isRunning) return "Warming..."; - if (!isEnabled) return "Auto: off"; - if (!usage || usage.error) return "Auto: on"; + if (isRunning) return t("warmup.warming"); + if (!isEnabled) return t("warmup.autoOff"); + if (!usage || usage.error) return t("warmup.autoOn"); const windowKind = getAutoWarmupWindowKind(usage); if (windowKind === "session" && isLimitFull(usage.secondary_used_percent)) { - return "Waiting weekly reset"; + return t("warmup.waitingWeekly"); } - if (windowKind === "session") return "Auto: 5h"; - if (windowKind === "weekly") return "Auto: weekly"; + if (windowKind === "session") return t("warmup.autoSession"); + if (windowKind === "weekly") return t("warmup.autoWeekly"); - return "Auto: on"; + return t("warmup.autoOn"); }, - [] + [t] ); const headerAutoWarmupLabel = useMemo(() => { - if (autoWarmupRunningIds.size > 0) return "Auto warming..."; + if (autoWarmupRunningIds.size > 0) return t("warmup.autoWarming"); return autoWarmupAllEnabled || autoWarmupAccountIds.size > 0 - ? "Auto: on" - : "Auto: off"; - }, [autoWarmupAccountIds.size, autoWarmupAllEnabled, autoWarmupRunningIds]); + ? t("warmup.autoOn") + : t("warmup.autoOff"); + }, [autoWarmupAccountIds.size, autoWarmupAllEnabled, autoWarmupRunningIds, t]); const timedWarmupTargetsReady = useMemo( () => @@ -860,13 +890,22 @@ function App() { await warmupAccount(accountId); markSuccessfulWarmup(accountId, Date.now(), window); - const modeLabel = window.kind === "session" ? "5h" : "weekly"; - showWarmupToast(`Auto ${modeLabel} warm-up sent for ${accountName}`); + showWarmupToast( + t( + window.kind === "session" + ? "warmup.autoSessionSentFor" + : "warmup.autoWeeklySentFor", + { name: accountName } + ) + ); } catch (err) { console.error("Auto warm-up failed:", err); backOffAutoWarmupRetry(accountId); showWarmupToast( - `Auto warm-up failed for ${accountName}: ${formatWarmupError(err)}`, + t("warmup.autoFailedFor", { + name: accountName, + error: formatWarmupError(err), + }), true ); } finally { @@ -884,6 +923,7 @@ function App() { markSuccessfulWarmup, refreshSingleUsage, showWarmupToast, + t, warmupAccount, ] ); @@ -941,16 +981,14 @@ function App() { } if (failed === 0) { - showWarmupToast( - `Timed warm-up sent for ${warmed} account${warmed === 1 ? "" : "s"}` - ); + showWarmupToast(t("warmup.timedSent", { count: warmed })); } else { - showWarmupToast(`Timed warm-up: ${warmed} ok, ${failed} failed`, true); + showWarmupToast(t("warmup.timedSummary", { warmed, failed }), true); } } finally { setTimedWarmupRunning(false); } - }, [markSuccessfulWarmup, showWarmupToast, warmupAccount]); + }, [markSuccessfulWarmup, showWarmupToast, t, warmupAccount]); useEffect(() => { if (!timedWarmupEnabled || timedWarmupTimes.length === 0) return; @@ -1012,8 +1050,8 @@ function App() { }, []); const timedWarmupLabel = useMemo(() => { - if (timedWarmupRunning) return "Timed warming..."; - if (!timedWarmupEnabled || timedWarmupTimes.length === 0) return "Timed: off"; + if (timedWarmupRunning) return t("warmup.timedWarming"); + if (!timedWarmupEnabled || timedWarmupTimes.length === 0) return t("warmup.timedOff"); const now = new Date(); const nowMinutes = now.getHours() * 60 + now.getMinutes(); @@ -1021,8 +1059,8 @@ function App() { const [hours, minutes] = time.split(":").map(Number); return hours * 60 + minutes > nowMinutes; }); - return `Timed: ${upcoming ?? timedWarmupTimes[0]}`; - }, [timedWarmupEnabled, timedWarmupRunning, timedWarmupTimes]); + return t("warmup.timedNext", { time: upcoming ?? timedWarmupTimes[0] }); + }, [timedWarmupEnabled, timedWarmupRunning, timedWarmupTimes, t]); const handleExportSlimText = async () => { setConfigModalMode("slim_export"); @@ -1035,12 +1073,12 @@ function App() { setIsExportingSlim(true); const payload = await exportAccountsSlimText(); setConfigPayload(payload); - showWarmupToast(`Slim text exported (${accounts.length} accounts).`); + showWarmupToast(t("backup.slimExported", { count: accounts.length })); } catch (err) { console.error("Failed to export slim text:", err); const message = err instanceof Error ? err.message : String(err); setConfigModalError(message); - showWarmupToast("Slim export failed", true); + showWarmupToast(t("backup.slimExportFailed"), true); } finally { setIsExportingSlim(false); } @@ -1056,7 +1094,7 @@ function App() { const handleImportSlimText = async () => { if (!configPayload.trim()) { - setConfigModalError("Please paste the slim text string first."); + setConfigModalError(t("backup.pasteFirst")); return; } @@ -1067,13 +1105,17 @@ function App() { setMaskedAccounts(new Set()); setIsConfigModalOpen(false); showWarmupToast( - `Imported ${summary.imported_count}, skipped ${summary.skipped_count} (total ${summary.total_in_payload})` + t("backup.importSummary", { + imported: summary.imported_count, + skipped: summary.skipped_count, + total: summary.total_in_payload, + }) ); } catch (err) { console.error("Failed to import slim text:", err); const message = err instanceof Error ? err.message : String(err); setConfigModalError(message); - showWarmupToast("Slim import failed", true); + showWarmupToast(t("backup.slimImportFailed"), true); } finally { setIsImportingSlim(false); } @@ -1082,12 +1124,12 @@ function App() { const handleExportFullFile = async () => { try { setIsExportingFull(true); - const exported = await exportFullBackupFile(); + const exported = await exportFullBackupFile(t("fileDialog.exportFull")); if (!exported) return; - showWarmupToast("Full encrypted file exported."); + showWarmupToast(t("backup.fullExported")); } catch (err) { console.error("Failed to export full encrypted file:", err); - showWarmupToast("Full export failed", true); + showWarmupToast(t("backup.fullExportFailed"), true); } finally { setIsExportingFull(false); } @@ -1096,18 +1138,22 @@ function App() { const handleImportFullFile = async () => { try { setIsImportingFull(true); - const summary = await importFullBackupFile(); + const summary = await importFullBackupFile(t("fileDialog.importFull")); if (!summary) return; const accountList = await loadAccounts(); await refreshUsage(accountList); const maskedIds = await loadMaskedAccountIds(); setMaskedAccounts(new Set(maskedIds)); showWarmupToast( - `Imported ${summary.imported_count}, skipped ${summary.skipped_count} (total ${summary.total_in_payload})` + t("backup.importSummary", { + imported: summary.imported_count, + skipped: summary.skipped_count, + total: summary.total_in_payload, + }) ); } catch (err) { console.error("Failed to import full encrypted file:", err); - showWarmupToast("Full import failed", true); + showWarmupToast(t("backup.fullImportFailed"), true); } finally { setIsImportingFull(false); } @@ -1117,13 +1163,13 @@ function App() { try { setIsOpeningCodex(true); await invokeBackend("open_codex_app"); - showWarmupToast("Codex app opened."); + showWarmupToast(t("codex.opened")); setTimeout(() => { void checkProcesses(); }, 1500); } catch (err) { console.error("Failed to open Codex app:", err); - showWarmupToast(`Open Codex failed: ${formatWarmupError(err)}`, true); + showWarmupToast(t("codex.openFailed", { error: formatWarmupError(err) }), true); } finally { setIsOpeningCodex(false); } @@ -1137,8 +1183,8 @@ function App() { [accounts, pendingTraySwitchAccountId] ); const forceCloseConfirmLabel = pendingTraySwitchAccount - ? "Force close and switch account" - : "Force close running Codex processes"; + ? t("forceClose.switch") + : t("forceClose.processes"); const sortedOtherAccounts = useMemo(() => { const getResetDeadline = (resetAt: number | null | undefined) => @@ -1258,7 +1304,7 @@ function App() { void appWindow.minimize(); }} className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100" - title="Minimize" + title={t("window.minimize")} > @@ -1269,7 +1315,7 @@ function App() { void appWindow.toggleMaximize(); }} className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100" - title={isWindowMaximized ? "Restore" : "Maximize"} + title={isWindowMaximized ? t("window.restore") : t("window.maximize")} > {isWindowMaximized ? ( @@ -1287,7 +1333,7 @@ function App() { void appWindow.close(); }} className="flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-red-500 hover:text-white dark:text-gray-400 dark:hover:bg-red-500 dark:hover:text-white" - title="Close" + title={t("window.close")} > @@ -1319,8 +1365,8 @@ function App() { > {hasRunningProcesses - ? `${processInfo.count} Codex running` - : "0 Codex running"} + ? t("header.codexRunning", { count: processInfo.count }) + : t("header.codexRunning", { count: 0 })} {hasRunningProcesses && ( @@ -1331,9 +1377,9 @@ function App() { }} disabled={isForceClosingCodex} className="inline-flex items-center rounded-md border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 disabled:opacity-50 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30" - title="Force close running Codex processes" + title={t("header.forceCloseTitle")} > - Force close + {t("header.forceClose")} )} @@ -1343,9 +1389,9 @@ function App() { onClick={handleOpenCodexApp} disabled={isOpeningCodex} className="inline-flex items-center rounded-md border border-green-200 bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 transition-colors hover:bg-green-100 disabled:opacity-50 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300 dark:hover:bg-green-900/30" - title="Open Codex app" + title={t("header.openCodex")} > - {isOpeningCodex ? "Opening..." : "Open Codex"} + {isOpeningCodex ? t("header.opening") : t("header.openCodex")} )} @@ -1356,7 +1402,7 @@ function App() { @@ -1386,7 +1432,7 @@ function App() { onClick={handleWarmupAll} disabled={isWarmingAll || accounts.length === 0} className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-700 transition-colors hover:bg-gray-200 disabled:opacity-50 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 shrink-0" - title="Send minimal traffic using all accounts" + title={t("header.warmupAll")} > @@ -1400,8 +1446,8 @@ function App() { }`} title={ autoWarmupAllEnabled - ? "Disable auto warm-up for all accounts" - : "Enable auto warm-up for all accounts" + ? t("header.disableAutoAll") + : t("header.enableAutoAll") } > {headerAutoWarmupLabel} @@ -1414,14 +1460,14 @@ function App() { ? "bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/30" : "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700" }`} - title="Schedule warm-up at specific times of day for all accounts" + title={t("header.timedTitle")} > {timedWarmupLabel} ▾ {isTimedWarmupOpen && (
@@ -1479,7 +1525,7 @@ function App() { @@ -1489,7 +1535,7 @@ function App() { onClick={() => setIsActionsMenuOpen((prev) => !prev)} className="h-10 px-4 py-2 text-sm font-medium rounded-lg bg-gray-900 text-white transition-colors hover:bg-gray-800 dark:bg-black dark:hover:bg-neutral-900 shrink-0 whitespace-nowrap" > - Account ▾ + {t("header.accountMenu")} ▾ {isActionsMenuOpen && (
@@ -1500,7 +1546,7 @@ function App() { }} className="w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:text-white dark:hover:bg-neutral-900" > - + Add Account + {t("header.addAccount")} +
+
)}
@@ -1555,11 +1617,11 @@ function App() { {loading && accounts.length === 0 ? (
-

Loading accounts...

+

{t("accounts.loading")}

) : error ? (
-
Failed to load accounts
+
{t("accounts.loadFailed")}

{error}

) : accounts.length === 0 ? ( @@ -1568,16 +1630,16 @@ function App() { 👤

- No accounts yet + {t("accounts.emptyTitle")}

- Add your first Codex account to get started + {t("accounts.emptyBody")}

) : ( @@ -1601,15 +1663,15 @@ function App() { type="search" value={accountSearchQuery} onChange={(event) => setAccountSearchQuery(event.target.value)} - placeholder="Search accounts by name or email" - aria-label="Search accounts" + placeholder={t("accounts.searchPlaceholder")} + aria-label={t("accounts.searchLabel")} className="w-full rounded-xl border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm text-gray-900 shadow-sm transition-colors placeholder:text-gray-400 focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:border-gray-600 dark:focus:ring-gray-800" /> {accountSearchQuery.length > 0 && ( @@ -1866,15 +1922,15 @@ function App() {

- Keep Codex Switcher in the Dock? + {t("closeBehavior.title")}

- When the window is closed, Codex Switcher can stay in the Dock or live only in the menu bar. + {t("closeBehavior.body")}

- You can always change this later from the tray popup. + {t("closeBehavior.changeLater")}

@@ -1892,21 +1948,21 @@ function App() { disabled={isCompletingCloseBehavior} className="px-4 py-2.5 text-sm font-medium rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 transition-colors disabled:opacity-50" > - Cancel + {t("common.cancel")}
@@ -1929,7 +1985,7 @@ function App() {

- {configModalMode === "slim_export" ? "Export Slim Text" : "Import Slim Text"} + {configModalMode === "slim_export" ? t("backup.exportSlim") : t("backup.importSlim")}