Skip to content

feat(i18n): localize UI strings via String Catalog (en/ja/zh-Hans/zh-Hant/ko/vi) - #132

Merged
kazukinakai merged 2 commits into
mainfrom
feat/i18n-string-catalog
Aug 9, 2026
Merged

feat(i18n): localize UI strings via String Catalog (en/ja/zh-Hans/zh-Hant/ko/vi)#132
kazukinakai merged 2 commits into
mainfrom
feat/i18n-string-catalog

Conversation

@kazukinakai

Copy link
Copy Markdown
Member

Summary

Localizes every user-facing string (menu bar items, Settings tabs, alerts, update notification, NSOpenPanel) into a String Catalog (Localizable.xcstrings, 77 keys) covering en (base) / ja / zh-Hans / zh-Hant / ko / vi.

Design

  • All call sites go through a new L(_:) helper (Sources/CmdIMESwift/Localization.swift) instead of Text("...")/NSLocalizedString directly. SPM never merges a target's resources into Bundle.main, so a bundle-less lookup would silently return the raw key in a real .app. L(_:) resolves against Bundle.module and returns a plain String; SwiftUI call sites use the StringProtocol overloads of Text/Toggle/Button/Picker/Label/Section/Link (not the LocalizedStringKey ones) so the already-resolved string displays verbatim instead of being re-resolved a second time.
  • Translations aim for current macOS System Settings terminology where an equivalent exists (e.g. "Launch at Login" → ja "ログイン時に開く", matching System Settings › General › Login Items — not a literal translation of the English label).
  • 英数/かな key-name presets are intentionally identical across all 6 locales — they name a physical/system key label, not prose.

Build-pipeline gotcha found and fixed (documented in CLAUDE.md)

swift build/swift test do not compile .xcstrings into usable localization data — that's an Xcode-only build phase. Confirmed empirically: a raw .xcstrings copied into the resource bundle resolved to the literal key on every locale, in every test run.

Fix: Localizable.xcstrings is pre-compiled with xcstringstool compile into committed Resources/<locale>.lproj/Localizable.strings, which SPM's .process() resource rule and Foundation's Bundle localization both understand natively under swift build. Confirmed working end-to-end, including inside a real signed .app built via scripts/package.sh (verified Contents/Resources/CmdIMESwift_CmdIMESwift.bundle/ja.lproj/Localizable.strings resolves general.launchAtLoginログイン時に開く).

scripts/package.sh also never copied SPM's <Package>_<Target>.bundle into Contents/Resources/ — harmless before this PR (no resources: existed yet), but would have silently shipped zero translations in production while swift test stayed green. Fixed by adding a ditto step.

Tests (LocalizationCatalogTests.swift)

  • Every catalog key resolves to a non-empty value in the compiled table, for every locale.
  • No compiled table has stale keys beyond the source catalog.
  • Committed compiled tables match a fresh xcstringstool compile of the source (drift detection; skips gracefully if xcstringstool is unavailable in the runner).
  • L(_:) resolves a known key to a real translated value, not the raw key.

Acceptance

$ swift build   # Build complete
$ swift test    # 55 tests, 1 skip (pre-existing diagnostic dump test), 0 failures

Also built and code-signed a real .app locally via CMDIME_BUILD_MODE=local ./scripts/package.sh and confirmed the resource bundle + all 6 .lproj tables land inside Contents/Resources/.

Not yet done

  • Visual QA of each screen in ja/zh-Hans/zh-Hant/ko/vi inside the actual running menu-bar app (accessibility-permission flow, live menu rendering) is a manual step for whoever runs the build locally — not automatable from this environment.
  • manifest.toml version intentionally not bumped (release timing is an owner decision).

Scope note for the companion PR

feat/multilang-input-source (input-source generalization, same base) also touches Settings/ShortcutsSettingsView.swift. Both PRs are based on main independently, so merging both will need a small textual conflict resolved in that file — the string keys added here for the preset/action labels and the new "Switch to Input Source" submenu strings from that PR don't collide in meaning, just in diff position.

Adds Localizable.xcstrings (source of truth, 77 keys) covering every
menu item, settings tab, alert, notification, and NSOpenPanel string
across en (base) / ja / zh-Hans / zh-Hant / ko / vi, translated to
match current macOS System Settings terminology where an equivalent
exists (e.g. "Launch at Login" -> ja "ログイン時に開く", matching
System Settings > General > Login Items, not a literal translation).

Every call site goes through a new L(_:) helper (Localization.swift)
instead of Text("...")/NSLocalizedString directly, because SPM never
merges a target's resources into Bundle.main — a bundle-less lookup
would silently return the raw key in a real .app. L(_:) resolves
against Bundle.module and returns a plain String, so SwiftUI displays
it verbatim via the StringProtocol overloads of Text/Toggle/Button/
Picker/Label/Section/Link instead of re-resolving a LocalizedStringKey
against Bundle.main a second time.

Build-time gotcha found and worked around (documented in CLAUDE.md):
swift build/swift test do NOT compile .xcstrings into usable
localization data — only Xcode's build system does that. Confirmed
empirically: a raw .xcstrings copied into the resource bundle resolved
to the key on every locale. Localizable.xcstrings is now pre-compiled
with xcstringstool compile into committed Resources/<locale>.lproj/
Localizable.strings, which SPM's resource processing and Foundation's
Bundle localization both understand natively under swift build —
confirmed working end to end, including inside a real signed .app
produced by scripts/package.sh.

scripts/package.sh also never copied SPM's <Package>_<Target>.bundle
into Contents/Resources/ (harmless before this PR, since there were no
declared resources yet) — fixed, or every localized string would have
silently fallen back to its raw key in production while still passing
swift test.

New tests (LocalizationCatalogTests): every catalog key resolves to a
non-empty value in the compiled table for every locale; no compiled
table has stale keys beyond the source catalog; committed compiled
tables match a fresh xcstringstool compile of the source (drift
detection, skips gracefully if xcstringstool is unavailable); L(_:)
resolves a known key to a real value (not the raw key).
Ported from fix/smappservice-test-isolation (PR #134): this branch's
AppSettingsTests.swift still had the pre-fix version, which calls the
real SMAppService.mainApp.register() and re-registered the xctest
test-runner as a login item after this branch's own test runs.
See PR #134 for the full incident writeup.
@kazukinakai

Copy link
Copy Markdown
Member Author

追加commit(6cb5c94)について: このbranchが元々main由来で持っていたテストの既存バグ(実SMAppService.register()呼び出しがxctestプロセス自体をログイン項目として登録してしまう)を修正するため、別PR #134 で作った修正を安全のためこのbranchにもportしました。PR #134の内容と同一・本PRの本題(i18n)とは無関係です。詳細はPR #134参照。

@kazukinakai
kazukinakai merged commit 5ed7f71 into main Aug 9, 2026
35 of 38 checks passed
@kazukinakai
kazukinakai deleted the feat/i18n-string-catalog branch August 9, 2026 05:44
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.

1 participant