Skip to content

feat(menubar): i18n support + Simplified Chinese localization for the macOS menubar - #1289

Closed
ozymandiashh wants to merge 9 commits into
getagentseal:mainfrom
ozymandiashh:feat/1219-menubar-zh-hans
Closed

ozymandiashh wants to merge 9 commits into
getagentseal:mainfrom
ozymandiashh:feat/1219-menubar-zh-hans

Conversation

@ozymandiashh

Copy link
Copy Markdown
Collaborator

Summary

Adds internationalization to the macOS menubar app and ships a complete Simplified Chinese
translation. This is the menubar half of #1219, the split @TheCrazyAnt proposed in the issue
thread; the CLI/Ink output and the web dashboard are a different stack and stay out of scope.

No third-party library. One Localizable.strings table per locale, shipped as SwiftPM target
resources for en and zh-Hans, with the language chosen by AppKit from the user's system
preferences — there is no picker inside CodeBurn, because macOS already owns that choice.
CFBundleLocalizations in both packaging scripts lists the app under System Settings >
General > Language & Region, so the language can be overridden for CodeBurn alone.

533 keys cover the popover, the Capacity Dock, the status-item menu, the update alerts and
all of Settings. The key is the English copy, so English stays the development language:
an untranslated string renders as correct English rather than a dotted identifier, and
en.lproj is an identity table kept so the bundle advertises en and so the two tables can
be diffed.

One design point worth a close look: every lookup names Bundle.module explicitly via
L(_:) rather than relying on SwiftUI's implicit LocalizedStringKey. SwiftPM emits target
resources into a sibling bundle that the packaging scripts copy into Contents/Resources, so
Bundle.main has no .lproj at all and Text("literal") would always miss. Naming the
module bundle is the one form that resolves identically under swift run, under swift test
and in the packaged .app.

Enum raw values that double as persistence or cache keys (Period, MenubarScope,
InsightMode, AccentPreset, ProviderFilter) keep their raw value and gained a separate
displayLabel, so nothing a user has saved changes meaning. Three display-only date
formatters that were pinned to en_US_POSIX with fixed patterns now follow the locale, and
the calendar's weekday row comes from the locale's own symbols, so a Chinese UI reads
2026年9月 and 一 二 三 instead of September 2026 and Mo Tu We. Numbers and currency
are untouched — they already went through the existing formatters, and L(_:_:) only
substitutes values those produced.

Left verbatim in every locale: provider, model and plan names; units; currency codes; shell
commands and paths; anything the codeburn CLI produces; and the QuotaSummary.Window
labels, because headlineWindow selects the Capacity Dock's glance window by
case-insensitive English substring match on week/month and translating them would
silently break that selection.

build-local.sh also now copies the SwiftPM resource bundle into the app, using the
named-bundle + [[ -d ]] pattern package-app.sh already has. The feature needs it to find
its strings at runtime, and that script's output already trapped at launch without it — the
resource-bundle half of #1262.

Three commits: mechanical extraction, then the translation, then tests and docs.

Closes #1219

Testing

  • cd mac && swift build — clean build from an empty .build, exit 0, no new warnings (the
    only warnings are pre-existing SecKeychainGetStatus deprecations in a file this PR does
    not touch).
  • New LocalizationCatalogTests (10 tests): key sets equal in both directions, no blank
    values, en is an identity table, argument specifiers identical in count and order, %%
    counts equal, no key is only specifiers, shipped localizations agree with
    L10n.supportedLocalizations, plus four cases resolving representative presentation
    strings in both locales and checking the English one against what the presentation struct
    returns.
  • Verified runtime resolution from the real resource bundle in both locales (3 sessions /
    3 个会话, Reconnect Claude / 重新连接 Claude), and from a hand-assembled .app
    laid out the way package-app.sh produces it (main bundle reports ["en", "zh-Hans"],
    both tables reachable through Contents/Resources).
  • Checked format-string arity at all 711 L(...) call sites — L(_:_:) is variadic
    CVarArg, so the compiler cannot catch a mismatch; 0 found.

swift test could not run on my machine: it is Command Line Tools only with no Xcode, so
swift-testing is unavailable — pristine origin/main fails there identically with 124 ×
no such module 'Testing'. Same reason package-app.sh could not run locally (its two-arch
build needs xcbuild). I ran the new tests' assertions through a Testing-free harness
derived from the test file instead (1621 assertions, 0 failures), but the macro expansions
themselves are only exercised by CI. No visual QA in a Chinese UI; text fit in the
fixed-width Capacity Dock and period-strip controls is unverified, though Chinese is
typically shorter than English.

Groundwork for getagentseal#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 getagentseal#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 getagentseal#1219
Two menubar features landed on main after this branch was cut: the optional
second status-item row (getagentseal#1252) and the Capacity Dock glance-window switch
(getagentseal#1243), plus the Copilot GitHub Enterprise host fix (getagentseal#1286). All three touch
files this branch rewrote to route every string through `L(...)`, so four
files conflicted.

Every conflict is resolved by keeping both sides: main's new behaviour lands
verbatim and the catalog routing is re-applied on top of it, never instead of
it.

- CodeBurnApp.swift: main hoisted the status button's tooltip out of
  `composeStatusTitle` (which now returns the title so the two-row path can
  append to it) into `refreshStatusButton`. Taking main's side wholesale would
  have silently dropped the `L("CodeBurn %@ · %lld of %lld devices reporting")`
  routing along with the moved lines, so it is re-applied at the tooltip's new
  home.
- CapacityDockView.swift: the gauge's accessibility label, value, hint and the
  new named switch action all come from main; each literal is routed through
  the catalog. The window label itself stays verbatim — it is provider data and
  doubles as the matching needle in CapacityDockGlanceWindow.
- SettingsView.swift: main's Second row toggle, metric picker and help text are
  kept and routed; the Copilot "How it works" paragraph takes main's longer
  wording (it gained the `.ghe.com` host rule) under a new key; the connection
  detail moves to main's `CopilotQuotaPresentation.connectedSettingsDetail`.
- CHANGELOG.md: all four entries kept, in main's sections.

Verified mechanically: no `L(...)` call site from this branch was lost except
the two keys main reworded, and outside mac/ the tree is identical to main.

The strings the two new features added are not in the catalog yet; that is the
next commit.
getagentseal#1252 and getagentseal#1308's second status-item row, getagentseal#1243's Capacity Dock glance switch
and getagentseal#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. getagentseal#1252 and getagentseal#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 getagentseal#1289
@ozymandiashh

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main and extended, because #1308 and #1309 landed new user-facing strings that bypassed the catalog entirely (zero L( calls in either diff, e.g. Toggle("Second row")). Those are now routed and translated: 18 new keys, 533 to 551, covering the second-row Settings section and picker, the dock window-switch accessibility action and value, and their help text.

Two things worth a reviewer's attention. The merge had a trap: main moved the status-button tooltip out of composeStatusTitle into refreshStatusButton, and taking main's side of that conflict compiles and passes every existing test while silently dropping the L(...) routing. Re-applied at the new location, and I verified mechanically that no other lookup was lost and that outside mac/ the tree is byte-identical to main.

Second, the parity test only proved the two catalogs agreed with each other, which passes even when a literal never reaches the catalog at all, which is exactly how this drift happened. There is now a guard with three passes (call sites, display-label properties, catalog round-trip) that fails CI when a user-facing literal is not routed. It immediately found pre-existing drift of its own in CodexUsage's credit-limit copy, which I localized rather than denylisted.

One gap left for a follow-up: LocalizedError.errorDescription is unlocalized across all ten *SubscriptionService.swift files. Main's two new Copilot error strings follow that same existing rule, so doing Copilot's alone would be worse drift.

swift build complete; catalog harness 53/53, en 551 / zh-Hans 551.

A day's worth of menubar work landed: getagentseal#1328 (Codex banked limit resets), getagentseal#1329
(early quota reset detection), 138ef34 folding the three notification switches
into one Settings section, cbcd984 rewording the usage-drop early reset, the
Copilot per-rung host work, and the dock's cache-read row and pace captions.

Four files conflicted, ten hunks. Every one resolved as a union: main's
behaviour lands verbatim and the catalog routing is re-applied on top.

- MenubarSecondRow.swift: main rebuilt `secondRow` around a `row` binding it
  clamps through `clampToRowBudget`, and rebuilt `quotaRow` so the provider
  label is abbreviated against the budget left over by the figures. The routing
  is reshaped to fit rather than reverting either: the figures go through
  `L("%lld%% left")` and the label joins with a plain space, which is the word
  order in both locales. The old `"%@ %lld%% left"` key cannot survive that
  restructure, and a `"%@ %@"` key to replace it would be nothing but
  specifiers, which LocalizationCatalogTests rejects on purpose.
- CapacityDockView.swift: main's cache-read line kept, and its new
  stale-but-idle distinction kept as two routed strings rather than one.
- HeatmapSection.swift: main moved the reset-credits label into the new
  `CodexBankedResetPresentation`, so the local helper it replaced is deleted
  rather than kept alongside it.
- SettingsView.swift: the Updates section becomes main's consolidated
  Notifications section; the Copilot help paragraph and token footer take
  main's longer wording under new keys; the pasted-token section gains main's
  host field; `.dormant` and the stale connection title route through main's
  new presentation helpers.

Verified mechanically: outside `mac/` the tree is byte-identical to main, and
every one of the seven `L(...)` keys that disappeared is accounted for — three
reworded by main, one dropped by the `quotaRow` restructure above, and three
whose code main moved into `CodexBankedResetPresentation` and
`CopilotQuotaPresentation.dormantSettingsDetail`. Those three, and the much
larger set of new strings the features shipped unrouted, are the next commit.
iamtoruk added a commit that referenced this pull request Sep 12, 2026
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.
getagentseal#1328's banked limit resets, getagentseal#1329's early quota reset detection, the
consolidated notifications section, the Copilot per-rung host work and the
dock's cache-read row all shipped user-facing copy with no `L(` calls. 67 new
keys, 5 retired by main's rewrites and restructures: 551 keys become 613.

Most of it was invisible to the guard test, which is the next commit's problem.
Both features put their wording in a new pure presentation type — exactly the
shape the views render as `Text(presentation.caption)` — so the call-site pass
had nothing to look at.

Routed here:

- Notifications: the section itself plus the banked-reset and early-reset
  toggles and their help text.
- Codex banked resets: the Plan-tab row label, the detail parts the hover card
  and Plan tab share, the notification title and body, and the compact-age
  vocabulary.
- Early quota resets: both notification shapes, the dock band, the hover help
  text and the three history-summary sentences, plus the Claude window names
  and the lead formatter.
- Quota pace: the captions, the projection sentences and the window-length and
  countdown labels. The countdown reuses the popover's own `%lldd %lldh` /
  `%lldh %lldm` / `%lldm` keys so the two can never disagree.
- Copilot: the host-aware dormant and rejection messages, the new host field.
- Capacity Dock: the cache-read row and the window/reset accessibility labels.

Two notes on judgement calls:

`CodexBankedResetPresentation.detail` and `compactAge` carry a documented
character-identical mirror in `src/quota/codex.ts`. Routing them keeps that
mirror exact — `en` is an identity table, so they emit byte-for-byte what they
did before — while letting a zh-Hans menubar say it in Chinese. The CLI stays
English-only, so the two diverge in a translated build exactly as every other
menubar string already does. The doc comments now say so.

`EarlyQuotaResetFormat.windowNoun` trims " limit" and `capitalizedFirst`
upper-cases: English morphology that degrades to the identity in Chinese. Left
that way deliberately — the trim exists to avoid "weekly limit resets" in
English, a repetition Chinese does not produce — rather than keying a second
catalog entry per window purely to shorten a noun. Both are commented.

Argument order is preserved in every multi-argument key, including the history
summary, where the natural Chinese phrasing wanted to reorder two counts:
`String(format:)` binds positionally, so the parity test would have caught it,
but the reordered sentence would have been wrong in a way no test can see.

Verified with the standalone swiftc harness (`swift test` cannot run here) and
`swift build`.
The previous commit had to add 67 keys, and the guard test caught almost none
of them. getagentseal#1328 and getagentseal#1329 each arrived as a new pure presentation type holding a
feature's whole vocabulary — `CodexBankedResetPresentation`,
`EarlyQuotaResetFormat`, `QuotaPacePresentation` — and the views only ever
render `Text(presentation.caption)`. There is no literal at a call site to see,
and these are not enum display labels either, so two of the three passes were
blind. Only the catalog round-trip noticed anything, and only for the four keys
whose code main happened to move.

So the display-label pass becomes a copy-bearing-declaration pass, anchored on
the convention this codebase already follows: every `String` returned by a type
whose name ends in `Presentation` or `Format` is copy, as is every
`displayName` / `settingsLabel` / `notificationTitle` / `notificationBody` /
`noticeText` / `noticeHelpText` / `caption` / `helpText` / `rowLabel` anywhere.

A return-type rule was tried first — any declaration returning String — and
rejected: it reports 378 findings on this tree, almost all enum raw values and
CLI argument strings. Anchoring on the naming convention gives 29, every one of
which is either already denylisted or a bug fixed below.

Three bugs the probe exposed:

- `->` drove the header parser's bracket depth negative, because `>` was
  treated as closing a generic. Every `func … -> String` was invisible while
  the `var` form was caught — so `QuotaPacePresentation.caption`, the single
  biggest source of new copy, would have sailed through.
- A `case "five_hour":` pattern read as produced copy rather than as a value
  being matched, so any string-keyed switch reported its own match keys.
- `enclosingTypeName` rescanned the file per member, which is quadratic; on
  AppStore.swift the pass took minutes. It now indexes each file once.

Denylist grows by two entries, both documented: `ExportFormat.cliName` and
`.suffix`, caught only because that enum's name ends in "Format" — it is an
export-format enum, and those return a CLI argument value and a file extension.

Mutation-checked: un-routing `EarlyQuotaResetEvent.notificationTitle` and
`QuotaPacePresentation.caption` both fail the new pass, and each of the three
bugs above has a test that fails without its fix.

Known imprecision, documented in the source: the owning type is the nearest
preceding declaration rather than a brace-scoped one, so a member after a
nested type's closing brace is attributed to the nested type. A finding prints
the qualified name it used and that is the string to paste into the denylist,
so the two cannot disagree — and it errs toward guarding more, never less.
@iamtoruk

Copy link
Copy Markdown
Member

Landed via #1330 (merge 5498818) with your five commits intact and credited. What changed on top of your branch: rebased onto main and added the 14 keys new strings needed, a Language picker in Settings (System / English / 中文) so a Chinese system can still run the app in English, weekday row now uses two-character short symbols so Chinese weekdays fit, the second menubar row budgets width per glyph instead of per character, and QuotaPacePresentation, EarlyQuotaReset and CodexBankedResets are localized with whole-sentence keys. English output is byte-identical to main on the 70 rendered strings I diffed, except four date formats that now come from the locale. Argument-order drift in the all-%@ keys is tracked in #1331. Thanks for the groundwork, the catalog and coverage tests carried the whole review.

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.

[Feature Request] Add i18n support / Chinese localization

2 participants