diff --git a/src/tray/windows-tray.ps1 b/src/tray/windows-tray.ps1 index f005c6ea931..3112ee6fdc9 100644 --- a/src/tray/windows-tray.ps1 +++ b/src/tray/windows-tray.ps1 @@ -1,4 +1,4 @@ -param( +param( [Parameter(Mandatory = $true)][string]$BunPath, [Parameter(Mandatory = $true)][string]$CliPath, [Parameter(Mandatory = $true)][string]$CodexHome, @@ -15,6 +15,19 @@ Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing try { [System.Windows.Forms.Application]::EnableVisualStyles() } catch { $null = $_ } +# Chinese UI cultures get Chinese tray text; every other locale keeps the English strings. The +# judgment is a named function so it can be exercised directly instead of only through a live +# desktop, where a selector that always answered English would still look correct. +function Test-TrayChineseCulture([string]$CultureName) { + return $CultureName -like "zh*" +} +$script:isZh = Test-TrayChineseCulture ([System.Globalization.CultureInfo]::CurrentUICulture.Name) +function Get-TrayText { + param([string]$En, [string]$Zh) + if ($script:isZh) { return $Zh } + return $En +} + # Normalize aliases before deriving singleton/event names. Without this, # C:\path and C:\path\. create separate tray instances for the same home. function Normalize-HomePath([string]$Value) { @@ -127,7 +140,7 @@ function Start-OcxCommand([string[]]$CommandArgs, [switch]$TrackExit) { return $true } catch { Write-ActionLog "launch failed: $($_.Exception.GetType().Name)" - $notify.ShowBalloonTip(5000, "opencodex action failed", "The action could not start. Open the logs folder or run ocx doctor.", [System.Windows.Forms.ToolTipIcon]::Error) + $notify.ShowBalloonTip(5000, (Get-TrayText "opencodex action failed" "opencodex 操作失败"), (Get-TrayText "The action could not start. Open the logs folder or run ocx doctor." "操作无法启动。打开日志文件夹或运行 ocx doctor。"), [System.Windows.Forms.ToolTipIcon]::Error) return $false } } @@ -396,19 +409,19 @@ $statusItem = New-Object System.Windows.Forms.ToolStripMenuItem $statusItem.Enabled = $false $safetyItem = New-Object System.Windows.Forms.ToolStripMenuItem $safetyItem.Enabled = $false -$openItem = $menu.Items.Add("Open Dashboard") -$updateItem = $menu.Items.Add("Update available") +$openItem = $menu.Items.Add((Get-TrayText "Open Dashboard" "打开面板")) +$updateItem = $menu.Items.Add((Get-TrayText "Update available" "有可用更新")) $updateItem.Visible = $false $updateItem.Enabled = $false -$startItem = $menu.Items.Add("Start Proxy") -$stopItem = $menu.Items.Add("Stop Proxy and Restore Native Routing") -$restartItem = $menu.Items.Add("Restart Proxy") +$startItem = $menu.Items.Add((Get-TrayText "Start Proxy" "启动代理")) +$stopItem = $menu.Items.Add((Get-TrayText "Stop Proxy and Restore Native Routing" "停止代理并还原原生路由")) +$restartItem = $menu.Items.Add((Get-TrayText "Restart Proxy" "重启代理")) [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) [void]$menu.Items.Add($statusItem) [void]$menu.Items.Add($safetyItem) -$logsItem = $menu.Items.Add("Open Logs Folder") +$logsItem = $menu.Items.Add((Get-TrayText "Open Logs Folder" "打开日志文件夹")) [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) -$exitItem = $menu.Items.Add("Exit Tray") +$exitItem = $menu.Items.Add((Get-TrayText "Exit Tray" "退出托盘")) $script:online = $false $script:port = 10100 @@ -463,6 +476,14 @@ function Set-PendingAction([string]$Action, [int]$TimeoutSeconds) { function Complete-PendingAction([bool]$Success) { if ($null -eq $script:pendingAction) { return } $action = $script:pendingAction + # The pending value stays English because it is compared against the labels the click + # handlers set; only the text a user reads is localized. + $displayAction = switch ($action) { + "Start Proxy" { Get-TrayText "Start Proxy" "启动代理" } + "Stop Proxy" { Get-TrayText "Stop Proxy" "停止代理" } + "Restart Proxy" { Get-TrayText "Restart Proxy" "重启代理" } + default { $action } + } $script:pendingAction = $null if ($null -ne $script:pendingProcess) { try { @@ -474,10 +495,10 @@ function Complete-PendingAction([bool]$Success) { } if ($Success) { Write-ActionLog "$action completed (port=$($script:port), pid=$($script:proxyPid))" - $notify.ShowBalloonTip(2500, "opencodex", "$action completed.", [System.Windows.Forms.ToolTipIcon]::Info) + $notify.ShowBalloonTip(2500, "opencodex", (Get-TrayText "$displayAction completed." "$displayAction 已完成。"), [System.Windows.Forms.ToolTipIcon]::Info) } else { Write-ActionLog "$action failed to reach the expected state" - $notify.ShowBalloonTip(5000, "opencodex action failed", "$action did not reach the expected state. Open the logs folder or run ocx doctor.", [System.Windows.Forms.ToolTipIcon]::Error) + $notify.ShowBalloonTip(5000, (Get-TrayText "opencodex action failed" "opencodex 操作失败"), (Get-TrayText "$displayAction did not reach the expected state. Open the logs folder or run ocx doctor." "$displayAction 未达到预期状态。打开日志文件夹或运行 ocx doctor。"), [System.Windows.Forms.ToolTipIcon]::Error) } } @@ -536,8 +557,8 @@ function Update-TrayState { Start-StartupHealthProbe } if ($script:online) { - $statusItem.Text = "Proxy: Online (port $($script:port))" - $notify.Text = "opencodex: Online" + $statusItem.Text = (Get-TrayText "Proxy: Online (port $($script:port))" "代理: 在线 (端口 $($script:port))") + $notify.Text = (Get-TrayText "opencodex: Online" "opencodex: 在线") $startItem.Enabled = $false $stopItem.Enabled = $true $restartItem.Enabled = $true @@ -547,21 +568,27 @@ function Update-TrayState { # diagnostic is cleaned up outside the online-only UI branch. $startup = $script:startupHealth if ($null -ne $startup) { - $label = if ($startup.status -eq "at-risk") { "At risk" } elseif ($startup.status -eq "protected") { "Protected" } else { "Native routing" } - $safetyItem.Text = "Restart safety: $label" + $label = if ($startup.status -eq "at-risk") { + Get-TrayText "At risk" "有风险" + } elseif ($startup.status -eq "protected") { + Get-TrayText "Protected" "已保护" + } else { + Get-TrayText "Native routing" "原生路由" + } + $safetyItem.Text = (Get-TrayText "Restart safety: $label" "重启保护: $label") $notify.Icon = if ($startup.status -eq "at-risk") { if ($script:updateAvailable) { $warningUpdateIcon } else { $warningIcon } } else { if ($script:updateAvailable) { $onlineUpdateIcon } else { $onlineIcon } } } else { - $safetyItem.Text = "Restart safety: unavailable" + $safetyItem.Text = (Get-TrayText "Restart safety: unavailable" "重启保护: 不可用") $notify.Icon = if ($script:updateAvailable) { $warningUpdateIcon } else { $warningIcon } } } else { - $statusItem.Text = "Proxy: Offline" - $safetyItem.Text = "Restart safety: start the proxy to inspect" - $notify.Text = "opencodex: Offline" + $statusItem.Text = (Get-TrayText "Proxy: Offline" "代理: 离线") + $safetyItem.Text = (Get-TrayText "Restart safety: start the proxy to inspect" "重启保护: 启动代理后查看") + $notify.Text = (Get-TrayText "opencodex: Offline" "opencodex: 离线") $notify.Icon = if ($script:updateAvailable) { $offlineUpdateIcon } else { $offlineIcon } $startItem.Enabled = $true $stopItem.Enabled = $false @@ -607,7 +634,7 @@ $openItem.add_Click({ Start-OcxCommand @("gui") }) $updateItem.add_Click({ Start-OcxCommand @("gui") }) $startItem.add_Click({ if (-not (Set-PendingAction "Start Proxy" 75)) { return } - $statusItem.Text = "Proxy: Starting..." + $statusItem.Text = (Get-TrayText "Proxy: Starting..." "代理: 启动中...") # service start can spend 20s and the CLI then observes health for another 40s. $startProcess = Start-OcxCommand @("__tray-start") -TrackExit if ($startProcess -is [System.Diagnostics.Process]) { @@ -618,7 +645,7 @@ $startItem.add_Click({ }) $stopItem.add_Click({ if (-not (Set-PendingAction "Stop Proxy" 15)) { return } - $statusItem.Text = "Proxy: Stopping..." + $statusItem.Text = (Get-TrayText "Proxy: Stopping..." "代理: 停止中...") $stopProcess = Start-OcxCommand @("stop") -TrackExit if ($stopProcess -is [System.Diagnostics.Process]) { $script:pendingProcess = $stopProcess @@ -628,7 +655,7 @@ $stopItem.add_Click({ }) $restartItem.add_Click({ if (-not (Set-PendingAction "Restart Proxy" 160)) { return } - $statusItem.Text = "Proxy: Restarting..." + $statusItem.Text = (Get-TrayText "Proxy: Restarting..." "代理: 重启中...") # /api/system/restart may drain active work for 60s and then spend up to 70s # handing off to an identity-verified replacement. The tray observes health/PID # rather than the detached CLI exit, so keep a watchdog margin around that shared @@ -665,7 +692,7 @@ $timer.add_Tick({ $notify.ContextMenuStrip = $menu $notify.Icon = $offlineIcon $notify.Visible = $true -$notify.Text = "opencodex: Checking..." +$notify.Text = (Get-TrayText "opencodex: Checking..." "opencodex: 检查中...") try { Initialize-UpdateBadgeReader diff --git a/tests/helpers/windows-tray-i18n-driver.ps1 b/tests/helpers/windows-tray-i18n-driver.ps1 new file mode 100644 index 00000000000..2c84bbe2c27 --- /dev/null +++ b/tests/helpers/windows-tray-i18n-driver.ps1 @@ -0,0 +1,98 @@ +# Behavioral driver for the tray's locale selection. +# +# Loads the REAL Test-TrayChineseCulture / Get-TrayText / Complete-PendingAction out of +# src/tray/windows-tray.ps1 via the PowerShell AST (function definitions only - the top-level tray +# UI never runs) and reports what each culture actually renders and notifies. A selector or a +# notification that always answered English would pass a source-text check and fail here. +param( + [Parameter(Mandatory = $true)][string]$TrayScriptPath, + [Parameter(Mandatory = $true)][string]$ResultPath +) +$ErrorActionPreference = "Stop" + +# Complete-PendingAction names ToolTipIcon in its balloon call. The tray script loads WinForms at +# its top level, which this driver never runs, so load it here for the enum to resolve. +Add-Type -AssemblyName System.Windows.Forms + +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($TrayScriptPath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw "tray script parse failed: $($parseErrors[0].Message)" } +$wanted = @( + "Test-TrayChineseCulture", + "Get-TrayText", + "Complete-PendingAction" +) +$definitions = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) +$loaded = @() +foreach ($fn in $definitions) { + if ($wanted -contains $fn.Name) { + # Dot-source the full definition extent so each function is defined exactly as the tray + # script declares it, params included. + . ([ScriptBlock]::Create($fn.Extent.Text)) + $loaded += $fn.Name + } +} +$missing = @($wanted | Where-Object { $loaded -notcontains $_ }) +if ($missing.Count -gt 0) { throw "tray script is missing functions: $($missing -join ', ')" } + +# The action log is a file append the driver has no use for; the notification is what it asserts. +function Write-ActionLog([string]$Message) { } + +$cultureDecisions = [ordered]@{} +foreach ($name in @("zh-CN", "zh-TW", "zh-Hans", "en-US", "ja-JP", "")) { + $cultureDecisions[$name] = [bool](Test-TrayChineseCulture $name) +} + +$rendered = [ordered]@{} +foreach ($isZh in @($false, $true)) { + $script:isZh = $isZh + $rendered[$(if ($isZh) { "zh" } else { "en" })] = [ordered]@{ + open = Get-TrayText "Open Dashboard" "打开面板" + start = Get-TrayText "Start Proxy" "启动代理" + restart = Get-TrayText "Restart Proxy" "重启代理" + exit = Get-TrayText "Exit Tray" "退出托盘" + status = Get-TrayText "opencodex: Online" "opencodex: 在线" + } +} + +# Capture what Complete-PendingAction actually shows. The WinForms NotifyIcon is replaced by a +# stub that records each balloon tip, so both completion branches are asserted as rendered text +# rather than as script source. +$global:trayNotifications = New-Object System.Collections.ArrayList +$notify = New-Object PSObject +$notify | Add-Member -MemberType ScriptMethod -Name ShowBalloonTip -Value { + param($timeout, $title, $text, $icon) + [void]$global:trayNotifications.Add([ordered]@{ title = $title; text = $text }) +} + +$notifications = [ordered]@{} +foreach ($isZh in @($false, $true)) { + $script:isZh = $isZh + $script:port = 10100 + $script:proxyPid = 4242 + $script:pendingProcess = $null + $perAction = [ordered]@{} + foreach ($action in @("Start Proxy", "Stop Proxy", "Restart Proxy")) { + $branch = [ordered]@{} + foreach ($success in @($true, $false)) { + $global:trayNotifications.Clear() + $script:pendingAction = $action + Complete-PendingAction $success + $tip = $global:trayNotifications[0] + $branch[$(if ($success) { "ok" } else { "fail" })] = [ordered]@{ + title = $tip.title + text = $tip.text + } + } + $perAction[$action] = $branch + } + $notifications[$(if ($isZh) { "zh" } else { "en" })] = $perAction +} + +$result = [ordered]@{ + cultureDecisions = $cultureDecisions + rendered = $rendered + notifications = $notifications +} +[System.IO.File]::WriteAllText($ResultPath, ($result | ConvertTo-Json -Depth 8 -Compress), (New-Object System.Text.UTF8Encoding($false))) diff --git a/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 index 15adf55ac1f..00ea31af25e 100644 --- a/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 +++ b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 @@ -36,6 +36,7 @@ $ast = [System.Management.Automation.Language.Parser]::ParseFile($TrayScriptPath if ($parseErrors.Count -gt 0) { throw "tray script parse failed: $($parseErrors[0].Message)" } $wanted = @( "Write-ActionLog", + "Get-TrayText", "Normalize-HomePath", "ConvertTo-NativeArgument", "Set-OcxChildEnvironment", diff --git a/tests/windows/windows-tray.test.ts b/tests/windows/windows-tray.test.ts index 44debc13a3f..e49581b8257 100644 --- a/tests/windows/windows-tray.test.ts +++ b/tests/windows/windows-tray.test.ts @@ -884,5 +884,85 @@ describe("Windows tray packaging and command safety", () => { const asUtf8 = Buffer.from(cp1252).toString("utf8"); expect(parseWindowsTrayRunValue(asUtf8, runValue)).not.toBe(command); }); + + // Behavioral proof for the locale selection: the driver loads the REAL + // Test-TrayChineseCulture / Get-TrayText / Complete-PendingAction out of windows-tray.ps1 (via + // the PowerShell AST, so comment and whitespace edits cannot fake it) and reports what each + // culture actually renders and notifies. A selector, or a notification that kept using the + // English pending value, would pass a source-text check and fail here. + test("tray text and completion notifications follow the UI culture", () => { + if (process.platform !== "win32") return; + const root = mkdtempSync(join(tmpdir(), "ocx-tray-i18n-")); + try { + const resultPath = join(root, "result.json"); + const run = Bun.spawnSync([ + windowsPowerShellPath(), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", helperPath("windows-tray-i18n-driver.ps1"), + "-TrayScriptPath", repoPath("src", "tray", "windows-tray.ps1"), + "-ResultPath", resultPath, + ], { stdout: "pipe", stderr: "pipe" }); + expect(run.exitCode, run.stderr.toString()).toBe(0); + const result = JSON.parse(readFileSync(resultPath, "utf8")) as { + cultureDecisions: Record; + rendered: Record>; + notifications: Record>; + }; + expect(result.cultureDecisions).toEqual({ + "zh-CN": true, "zh-TW": true, "zh-Hans": true, "en-US": false, "ja-JP": false, "": false, + }); + expect(result.rendered.zh).toEqual({ + open: "打开面板", + start: "启动代理", + restart: "重启代理", + exit: "退出托盘", + status: "opencodex: 在线", + }); + expect(result.rendered.en).toEqual({ + open: "Open Dashboard", + start: "Start Proxy", + restart: "Restart Proxy", + exit: "Exit Tray", + status: "opencodex: Online", + }); + + // The pending value stays English for state comparisons; the notification a user reads must + // not carry it, on either branch. + // The pending value stays English for state comparisons; the notification a user reads must + // not carry it, on either branch and for every action the tray can run. + const expected = { + zh: { + "Start Proxy": { ok: "启动代理 已完成。", fail: "启动代理 未达到预期状态。打开日志文件夹或运行 ocx doctor。" }, + "Stop Proxy": { ok: "停止代理 已完成。", fail: "停止代理 未达到预期状态。打开日志文件夹或运行 ocx doctor。" }, + "Restart Proxy": { ok: "重启代理 已完成。", fail: "重启代理 未达到预期状态。打开日志文件夹或运行 ocx doctor。" }, + }, + en: { + "Start Proxy": { ok: "Start Proxy completed.", fail: "Start Proxy did not reach the expected state. Open the logs folder or run ocx doctor." }, + "Stop Proxy": { ok: "Stop Proxy completed.", fail: "Stop Proxy did not reach the expected state. Open the logs folder or run ocx doctor." }, + "Restart Proxy": { ok: "Restart Proxy completed.", fail: "Restart Proxy did not reach the expected state. Open the logs folder or run ocx doctor." }, + }, + }; + for (const locale of ["zh", "en"] as const) { + expect(Object.keys(result.notifications[locale])).toEqual(["Start Proxy", "Stop Proxy", "Restart Proxy"]); + for (const [action, want] of Object.entries(expected[locale])) { + expect(result.notifications[locale][action]).toEqual({ + ok: { title: "opencodex", text: want.ok }, + fail: { + title: locale === "zh" ? "opencodex 操作失败" : "opencodex action failed", + text: want.fail, + }, + }); + if (locale === "zh") { + expect(result.notifications.zh[action].ok.text).not.toContain(action); + expect(result.notifications.zh[action].fail.text).not.toContain(action); + } + } + } + } finally { + removeTreeWithRetry(root); + } + }); }); import { ManagementRequest as Request } from "../helpers/management-auth";