Skip to content

feat(menubar): localize the macOS menubar app, ship Simplified Chinese - #1330

Merged
iamtoruk merged 14 commits into
mainfrom
feat/menubar-i18n
Sep 12, 2026
Merged

iamtoruk merged 14 commits into
mainfrom
feat/menubar-i18n

Conversation

@iamtoruk

Copy link
Copy Markdown
Member

Supersedes #1289 by @ozymandiashh. His five commits are here unchanged, rebased onto current main; everything after them is the rebase resolution and the follow-ups asked for in review.

What it does

Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts, the notifications and all of Settings resolves through a Localizable.strings catalog shipped for en and zh-Hans. No third-party library. 628 keys, and the key is the English copy, so an untranslated string degrades to correct English instead of a visible identifier. en.lproj is a strict identity table.

Lookups go through L(_:) / L(_:_:), which name Bundle.module. SwiftPM emits target resources into a sibling bundle that the packaging scripts copy into Contents/Resources, so Bundle.main has no .lproj and Text("literal") would always miss.

Language picker

Settings > General > Language offers System, English and 简体中文. It writes AppleLanguages into the app's own preferences domain, which is the same key System Settings > General > Language & Region > Applications writes, so the two surfaces are one setting rather than two. The current value is read back from that domain via persistentDomain(forName:), never UserDefaults.standard.array(forKey:): that read falls through to the global domain and would report the system language as CodeBurn's own override, so System could never show as selected. That was the #1244 bug. Applying offers an inline relaunch, not a modal.

Verified end to end on a packaged bundle: with nothing written, Bundle.module.preferredLocalizations is ["en"] and every key returns English; after defaults write <bundle id> AppleLanguages -array zh-Hans it is ["zh-Hans"] and L("Refresh Now") returns 立即刷新.

English is unchanged

Three independent checks:

  • en.lproj is an identity table. 0 of 628 entries have a value different from the key, so L(k) returns k.
  • A script walks the extraction diff and matches each introduced L("…") key back to the literal it replaced in the same file: 589 distinct pairs, 544 matched automatically, the 45 residues checked by hand. They are enum raw values whose rawValue already equalled the new displayLabel (AccentPreset, MenubarScope, Period, InsightMode, ProviderFilter, QuotaSummary), and format-string reshapes that produce the same bytes.
  • 70 helper outputs rendered over a real codeburn status --format menubar-json payload, diffed against the same helpers built from main: byte-identical. That covers SessionCountLabel, the second-row formatter, every pace caption and help sentence, every early-reset title, body, band and history caption for four window keys and both signals, and the whole banked-reset surface including compactAge and resetTypeLabel.

The full suite passes with no expected-English string touched: 644 tests in 71 suites, plus 498 XCTest cases, 0 failures.

English output that does change

Four, all from moving display-only date formatters off en_US_POSIX fixed patterns onto the locale:

formatter before en_US after en_GB after
EEE MMM d (x2) Sat Sep 12 Sat, Sep 12 Sat 12 Sep
MMM d Sep 12 Sep 12 12 Sep
MMMM yyyy September 2026 September 2026 September 2026

The calendar weekday row is locale-driven too, but stays Mo Tu We Th Fr Sa Su in English: it uses shortWeekdaySymbols clipped to two units rather than veryShortWeekdaySymbols, which would give English M T W T F S S, two ambiguous pairs and two duplicate ForEach ids. zh-Hans reads 周一 周二, 19.9pt in a 30pt cell.

Rebase

Six files conflicted over 13 hunks against #1306, #1313, #1314, #1315, #1328 and #1329. Both intents kept in every case: main's second-row title composition, glance-window accessibility actions, cache-read line, GitHub host field and merged Notifications section keep their behaviour and now resolve through the catalog. 14 keys added for the copy main introduced, 4 dropped that no longer have a call site, and the two Copilot explanations main rewrote were retranslated.

One behaviour fix fell out of it: #1289 had reshaped secondRow to return early for the cost and token metrics, which bypassed the 24-unit clamp #1310 added.

Three fixes from review

The second row is budgeted by the width it draws. The 24-unit budget was a Character count, so GitHub Copilot 剩余 12% · 6 小时 2 分 fitted it at 24 Characters while drawing 31 cells, a third wider than the first row it is not allowed to exceed. abbreviate now weights East Asian wide and fullwidth scalars as two. All-Latin rows are unchanged.

The presentation layer is translated. QuotaPacePresentation, EarlyQuotaReset and CodexBankedResets compose their copy in a presentation type, not at a view call site, so neither a catalog diff nor a source scan could see them and a zh-Hans build rendered 22% in deficit, Runs out in 18h 0m, Claude's weekly limit reset 18h early. and the whole banked-reset notification in English. Each sentence is now one key with placeholders. Window labels are translated through a lookup on the English name rather than by stripping " limit" off the end, which is a rule only English obeys, and EarlyQuotaResetEvent.windowName keeps its English form because it is persisted with the event.

The scanner sees two more surfaces. .toolTip = and post(title:) are both user-facing and were both invisible to it. Nothing on main is newly flagged.

Left English on purpose

  • Provider, model and plan names, units, currency codes, shell commands, paths, and anything the codeburn CLI produces.
  • QuotaSummary.Window labels (Weekly, 5-hour). headlineWindow selects the Capacity Dock's glance window by case-insensitive English substring match on week/month, so translating them would silently break that selection. The visible cost is mixed text in a Chinese UI: 配额窗口 Weekly, 显示 Weekly 用量.
  • reset_type values outside the four cadence words OpenAI has been seen to send. It is vendor data with no fixed vocabulary, so anything else rides through verbatim inside a translated frame.
  • Window labels for a provider wired up later. EarlyQuotaResetFormat translates the four Claude names it knows and carries anything else through, since guessing at a grammar for an unseen label is worse than English.
  • Each language names itself in the picker. Translating an endonym defeats a picker someone opens because the UI is in a language they cannot read.
  • compactAge keeps its English form character-identical with the TypeScript side, which is what that contract was about. Other locales diverge from codeburn quota, which is not localized.

Adding a language

Copy en.lproj, translate the values, then add the locale in four places that must stay in step: .process in Package.swift, CFBundleLocalizations in package-app.sh and build-local.sh, and L10n.supportedLocalizations. LocalizationCatalogTests fails if they disagree, if a key is missing from either table, if a value is blank, or if the format specifiers differ. LocalizationCoverageTests reads mac/Sources itself and fails when a user-facing literal never reaches the catalog, or when a catalog entry has no call site.

ozymandiashh and others added 14 commits September 12, 2026 15:21
Groundwork for #1219. The menubar app hardcoded ~530 English strings across
Views/, the presentation structs, the status-item menu and the update alerts,
so there was nothing for a translator to translate.

Every user-facing literal now goes through `L(_:)` / `L(_:_:)`, which look the
string up in `Localizable.strings` in the SwiftPM target resource bundle. The
key *is* the English copy, so English stays the development language and a
missing translation degrades to correct English instead of a dotted
identifier. `en.lproj` is therefore an identity table; it exists so the bundle
advertises `en` and so a translation can be diffed against it.

Why the lookups are explicit rather than relying on SwiftUI's implicit
`LocalizedStringKey`: SwiftPM emits target resources into a sibling bundle
(`CodeBurnMenubar_CodeBurnMenubar.bundle`) that the packaging scripts copy into
`Contents/Resources`. `Bundle.main` has no `.lproj` at all, so `Text("literal")`
would always miss. Naming `Bundle.module` is the one form that resolves the
same way in `swift run`, in `swift test` and in the packaged `.app`.

Enum raw values that double as identity (`Period`, `MenubarScope`,
`InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gain
a `displayLabel`, so persistence and cache keys are untouched by translation.

Three display-only date formatters that were pinned to `en_US_POSIX` with fixed
patterns now use `setLocalizedDateFormatFromTemplate`, and the calendar
popover's weekday row comes from the locale's own symbols. The `yyyy-MM-dd`
formatter stays POSIX: it parses and builds data keys, not display text.

Not extracted, deliberately: provider, model and plan names; units; currency
codes; shell commands and paths; quota window labels that policy code matches
on by English substring (`QuotaSummary.headlineWindow`); and anything the
`codeburn` CLI produces.

Also copies the SwiftPM resource bundle into the app in build-local.sh, using
the named-bundle + `[[ -d ]]` pattern package-app.sh already uses. Without it
the assembled app finds no strings table (and already trapped on first icon
load, the resource-bundle half of #1262).
All 533 catalog keys translated into zh-Hans, so the menubar app now follows
the system language with no setting to find and no third-party library. AppKit
picks the table: `CFBundleLocalizations` in both packaging scripts advertises
`en` and `zh-Hans`, which is also what puts the app under System Settings >
Language & Region so a user can override the language for CodeBurn alone.

Wording follows macOS system apps rather than a literal gloss: 立即刷新,
断开连接, 存储并连接, 载入配额, 再试一次. Product nouns stay as users
already know them: provider, model and plan names (Claude, Codex, Gemini,
Opus, Sonnet, Antigravity), Token, Dock, API, OAuth, shell commands and file
paths, currency codes.

Format specifiers are identical to the English entry in count and argument
order. `%%` is a literal percent sign and not an argument, so it may move where
Chinese word order demands it ("%@ · %@ 达到 100%%"); the argument-consuming
specifiers may not, because String(format:) binds them positionally. The next
commit's test enforces exactly that distinction.

Numbers, dates and currency are untouched: they already went through
NumberFormatter / DateFormatter / asCurrency(), and `L(_:_:)` only substitutes
values those produced.
A translation catalog rots in ways the compiler cannot see: a key added to a
view but not to zh-Hans shows English in a Chinese UI, a dropped key shows a
raw identifier, and a format specifier that disagrees between the tables is a
wrong number or a crash inside String(format:).

LocalizationCatalogTests reads both tables out of the resource bundle, the same
files L(_:) resolves at runtime, and asserts: the key sets are equal in both
directions; no value is blank; en repeats its key verbatim, which is what makes
the English fallback correct; argument specifiers match in count and order,
because String(format:) binds them positionally; literal `%%` counts match,
while allowing `%%` to move where Chinese word order demands it; no key is only
specifiers, which would leave a translator nothing to work with; and the shipped
localizations agree with L10n.supportedLocalizations, so Package.swift, the two
packaging scripts' CFBundleLocalizations and the Swift constant cannot drift.

Four cases then resolve representative presentation strings in both locales and
check that the English one equals what the presentation struct returns, which is
the link proving the struct reads the catalog rather than a stale hardcoded
string, and that a substituted product name survives translation.

The specifier test caught one bad key while being written: the Capacity Dock
connect button's accessibility label was `L("%@ %@", title, providerName)`,
which has no translatable content at all. It is plain interpolation now, and
both halves were already localized on their own.

CHANGELOG and mac/README document the user-visible part: the language follows
the system, overridable per app in System Settings, plus what adding a third
language requires.

Closes #1219
and #1286's Copilot host fix all shipped user-facing copy that bypasses the
catalog — neither of the first two PRs' diffs contains a single `L(` call. In a
zh-Hans build those strings render in English.

Sweeping mac/Sources the way the original extraction did brings 18 new keys,
and retires 2 that main reworded (the Copilot help paragraph gained the
GitHub Enterprise Cloud host rule; the connection detail now names the host
that answered rather than hardcoding api.github.com). 533 keys become 551.

Routed here:

- Second row: the Settings toggle, the metric picker and its four option
  names, the help paragraph, and the row text itself — today's cost, today's
  tokens, and the quota row with and without a provider label. The reset
  countdown reuses the popover's own `%lldd %lldh` / `%lldh %lldm` / `%lldm`
  keys rather than minting a second set, so the two countdowns cannot
  disagree in a translated build.
- Capacity Dock glance: the switch hint and the named VoiceOver action.
- Copilot: the reworded help paragraph and the host-aware connection detail.

Kept verbatim, per the rules in the catalog header: provider, model and plan
names; `tok`; the `%.2f` amount and its currency symbol; and the window label
the gauge reports, which is provider data and doubles as the matching needle
in CapacityDockGlanceWindow — translating the generic fallback alone would
make one VoiceOver sentence half-Chinese depending on the provider selected.

Also localizes CodexUsage's credit-limit labels. Those are not new, they were
missed by the original extraction and the guard test in the next commit finds
them; leaving them would mean shipping a test that fails on main.

Verified with a standalone swiftc harness over both tables (`swift test`
cannot run on this host): key parity, no blank values, en identity, and
argument/literal-percent specifier parity, plus the new rows formatting
correctly in both locales.
LocalizationCatalogTests diffs en against zh-Hans. That catches a key
translated in one locale and not the other, but it is blind to the failure
that actually happens: a feature ships a bare `Text("Second row")`, the
literal never becomes a key, both tables stay in perfect agreement, and a
zh-Hans build renders English. #1252 and #1243 landed exactly that way, which
is why the previous commit exists at all.

So this suite reads mac/Sources instead of the tables, in three passes that
cover each other:

1. Call sites. The SwiftUI and AppKit surfaces that put a string on screen —
   Text/Button/Toggle/Picker/…, the accessibility modifiers, NSMenuItem,
   NSAlert and window titles — must be handed `L(…)`, not a literal.
2. Display-label properties. `displayName`/`displayLabel`/`settingsLabel` are
   how this codebase names an enum for a picker, and they are not call sites,
   so a new metric case with a bare literal would slip past pass 1.
3. Catalog round-trip. Every key a view asks for has an entry in both
   locales, and every entry has a call site — so a key added to the code but
   not the table, or left behind after a rewording, fails too.

Scoped to avoid false positives rather than by suppressing findings:

- A literal is exempt when, with `\(…)` segments removed, nothing is left but
  figures, symbols, or words in a three-entry vocabulary (`CodeBurn`, `tok`,
  `USD`). That covers `Text("$25")`, `Text("\(count)")` and `Text("— ")`
  without naming a single file, so the exemption cannot go stale.
- Pass 2 only looks at bracket-depth zero, which is what separates the
  property's result (`case .pro: "Pro"`) from machinery it calls
  (`Locale(identifier: "en_US")`).
- The two denylists are documented with reasons and keyed to the declaring
  type, so `PlanType.displayName` being exempt does not exempt every
  `displayName` in the app.

Comments are stripped first, or Localization.swift's own documentation of
`Text("literal")` would read as a violation; strings win over comment markers
so a URL in a literal does not swallow the rest of the file.

The scanner lives in its own file with no `import Testing` so the standalone
swiftc harness can exercise the shipped code rather than a copy of it —
`swift test` cannot run on this host, and a reimplementation would drift.

Mutation-checked: un-routing `Toggle(L("Second row"))` fails pass 1,
un-routing `MenubarSecondRowMetric.settingsLabel` fails pass 2, and dropping
`L("Show %@ usage")` fails pass 3. Writing it also turned up two real bugs in
the scan (`Label(` matching inside `.accessibilityLabel(`, and a nested
`enum PlanType` reporting its outer type), both now covered by a test.

Closes #1289
Rebase resolution for #1289 onto main (#1306, #1313, #1314, #1315, #1328,
#1329). Conflicts kept both intents: main's second-row title composition,
glance-window accessibility actions, cache-read line, host field and merged
Notifications section keep their behaviour and now resolve through L(…).

Adds 14 keys to both tables, drops 4 that no longer have a call site, and
retranslates the two Copilot explanations main rewrote.
veryShortWeekdaySymbols gives en "M T W T F S S": two ambiguous pairs, and
two duplicate ForEach ids. shortWeekdaySymbols clipped to two units keeps
"Mo Tu We" in English and reads 周一 周二 in zh-Hans, 19.9pt in a 30pt cell.
The 24-unit budget was a Character count, so "GitHub Copilot 剩余 12% · 6 小时
2 分" fitted it at 24 Characters while drawing 31 cells: a third wider than
the first row it is not allowed to exceed. abbreviate now measures East Asian
wide and fullwidth scalars as two. All-Latin rows are unchanged.
Settings > General > Language offers System, English and 简体中文. It writes
AppleLanguages into the app's own preferences domain, the same key System
Settings > Language & Region > Applications writes, so the two surfaces are one
setting. The current value is read back from that domain rather than through
UserDefaults.standard, which falls through to the global domain and would
report the system language as CodeBurn's own override: System could then never
show as selected. An inline row offers the relaunch that applies it.
.toolTip = and post(title:) are both user-facing and both were invisible to
the scan. Nothing on main is newly flagged.
Every caption and help sentence #1267 added rendered English in a zh-Hans
build: they are composed in a presentation type, not at a view call site, so
neither the catalog diff nor the source scan could see them. Each sentence is
now one key with placeholders. The two stage captions reuse the Plan tab's
existing keys, and the countdown reuses the popover's. English output is
unchanged: the 329 existing pace expectations pass untouched.
Every sentence #725 added is now one key with placeholders. The window label
keeps its English form in the persisted event and is translated at render
through a lookup on that form, because "weekly limit" -> "weekly" is a suffix
rule only English obeys; a label outside the known set reads through as before.
The 697 existing early-reset expectations pass untouched.
…ad tooltip

The notification, the Plan-tab detail and the hover-card line #725 added were
composed in a presentation type and shipped English to a zh-Hans user. Each is
one key now. reset_type is vendor data with no fixed vocabulary, so the four
cadence words OpenAI sends are translated and anything else rides through
verbatim inside a translated frame. compactAge stays character-identical with
the TypeScript side in English, which is what the contract was about: en is an
identity table. The dock's cache-read tooltip was bound to a let before its
call site, which is why the scan never saw it.
@iamtoruk
iamtoruk merged commit 5498818 into main Sep 12, 2026
17 of 20 checks passed
ozymandiashh added a commit to ozymandiashh/codeburn that referenced this pull request Sep 12, 2026
getagentseal#1330, the menubar i18n work, landed: `defaultLocalization`, en and
zh-Hans catalogs, a language picker, and a scanner test that reads the
source and fails when a user-facing literal never reaches the catalog.

One conflict, `mac/Package.swift`, resolved as a union: the dataset's
`.copy("Resources/CodexResetHistory")` and the two `.process` entries for
the `.lproj` bundles all stay, and `defaultLocalization: "en"` is kept.
The two declarations are independent - one carries data the model reads,
the others carry strings NSBundle resolves per localization.

What the merge actually implied was the rest of this commit. Main's
scanner found seven user-facing literals in the forecast's Settings
section; the forecast sentences, the notification copy and the dataset
line were not literals at a call site, so the scanner did not see them,
but they are just as visible to a zh-Hans user. All of it now goes
through `L(...)`, with 29 keys added to both catalogs.

Points worth reviewing rather than skimming:

- **The English output is byte-identical**, which is why the
  character-for-character parity checks against `src/reset-forecast.ts`
  still pass. In `en` the key IS the copy, so `L()` is the identity
  there. Parity is an `en` property and is documented as one: the CLI is
  English-only by the catalog's own stated policy.
- **No `yes`/`no` key.** The working-hours tail would have made one, and
  a translator handed "yes" has no sentence to work with. There are two
  whole-sentence keys instead, differing only in that word.
- **No pluralising suffix.** "wait"/"waits" was built with a `\(plural)`
  hole; that is an English rule. Two keys now.
- **The full stop rides the last clause** (`low confidence.`), so no bare
  "." key exists for anyone to guess at.
- **The notification body is one literal, not a `+` chain.** The scanner
  reads source rather than running it, so a concatenated key is only
  half-visible to the tooling meant to prove every key is translated -
  it reported exactly that, and it was right.
- **Specifier order is preserved in Chinese.** The first draft read more
  naturally by putting the elapsed time after the reset it counts from,
  which silently swaps two `%@` and would have substituted the wrong way
  round. The zh strings are phrased to keep English argument order.

Verified against main's own tooling: `LocalizationSourceScanner` reports
zero unrouted literals across all of `mac/Sources`, every key the sources
request resolves in the catalog, and the catalog rules the suite checks -
matching key sets, English identity, argument-specifier order, literal
`%%` counts, no empty values, no key that is only specifiers - all hold.

The Settings picker did not move the sections; "Reset Forecast Data"
still sits between Notifications and Terminal.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants