feat(menubar): localize the macOS menubar app, ship Simplified Chinese - #1330
Merged
Merged
Conversation
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
force-pushed
the
feat/menubar-i18n
branch
from
September 12, 2026 23:12
554ac34 to
f15bece
Compare
This was referenced Sep 12, 2026
Closed
This was referenced Sep 12, 2026
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.
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.stringscatalog shipped forenandzh-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.lprojis a strict identity table.Lookups go through
L(_:)/L(_:_:), which nameBundle.module. SwiftPM emits target resources into a sibling bundle that the packaging scripts copy intoContents/Resources, soBundle.mainhas no.lprojandText("literal")would always miss.Language picker
Settings > General > Language offers System, English and 简体中文. It writes
AppleLanguagesinto 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 viapersistentDomain(forName:), neverUserDefaults.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.preferredLocalizationsis["en"]and every key returns English; afterdefaults write <bundle id> AppleLanguages -array zh-Hansit is["zh-Hans"]andL("Refresh Now")returns 立即刷新.English is unchanged
Three independent checks:
en.lprojis an identity table. 0 of 628 entries have a value different from the key, soL(k)returnsk.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 whoserawValuealready equalled the newdisplayLabel(AccentPreset,MenubarScope,Period,InsightMode,ProviderFilter,QuotaSummary), and format-string reshapes that produce the same bytes.codeburn status --format menubar-jsonpayload, diffed against the same helpers built from main: byte-identical. That coversSessionCountLabel, 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 includingcompactAgeandresetTypeLabel.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_POSIXfixed patterns onto the locale:EEE MMM d(x2)Sat Sep 12Sat, Sep 12Sat 12 SepMMM dSep 12Sep 1212 SepMMMM yyyySeptember 2026September 2026September 2026The calendar weekday row is locale-driven too, but stays
Mo Tu We Th Fr Sa Suin English: it usesshortWeekdaySymbolsclipped to two units rather thanveryShortWeekdaySymbols, which would give EnglishM T W T F S S, two ambiguous pairs and two duplicateForEachids. 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
secondRowto 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.abbreviatenow weights East Asian wide and fullwidth scalars as two. All-Latin rows are unchanged.The presentation layer is translated.
QuotaPacePresentation,EarlyQuotaResetandCodexBankedResetscompose 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 rendered22% 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, andEarlyQuotaResetEvent.windowNamekeeps its English form because it is persisted with the event.The scanner sees two more surfaces.
.toolTip =andpost(title:)are both user-facing and were both invisible to it. Nothing on main is newly flagged.Left English on purpose
codeburnCLI produces.QuotaSummary.Windowlabels (Weekly,5-hour).headlineWindowselects the Capacity Dock's glance window by case-insensitive English substring match onweek/month, so translating them would silently break that selection. The visible cost is mixed text in a Chinese UI:配额窗口 Weekly,显示 Weekly 用量.reset_typevalues 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.EarlyQuotaResetFormattranslates the four Claude names it knows and carries anything else through, since guessing at a grammar for an unseen label is worse than English.compactAgekeeps its English form character-identical with the TypeScript side, which is what that contract was about. Other locales diverge fromcodeburn 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:.processinPackage.swift,CFBundleLocalizationsinpackage-app.shandbuild-local.sh, andL10n.supportedLocalizations.LocalizationCatalogTestsfails if they disagree, if a key is missing from either table, if a value is blank, or if the format specifiers differ.LocalizationCoverageTestsreadsmac/Sourcesitself and fails when a user-facing literal never reaches the catalog, or when a catalog entry has no call site.