Internationalization (i18n)

April 22, 2026 · View on GitHub

mux0 supports English and Simplified Chinese UI. Language preference lives in UserDefaults (key mux0.language, values system|zh|en) and is exposed in Settings → Appearance → Language.

Architecture

  • mux0/Localization/LanguageStore.swift@Observable singleton. Holds the user's preference, persists to UserDefaults, publishes a monotonic tick counter, and exposes locale (for SwiftUI \.locale environment) and effectiveBundle (for AppKit callers).
  • mux0/Localization/L10n.swift — compile-time namespace of constants: L10n.Sidebar / Tab / Settings / App / Menu. Each constant is a LocalizedStringResource. For AppKit call sites there is a helper L10n.string("raw.key", args...) that resolves via LanguageStore.shared.effectiveBundle.localizedString(forKey:value:table:nil) and applies String(format:) to any variadic args.
  • mux0/Localization/Localizable.xcstrings — Xcode String Catalog. English is the source language; Simplified Chinese is maintained in parallel. Xcode compiles this into en.lproj/Localizable.strings and zh-Hans.lproj/Localizable.strings inside the built bundle.

SwiftUI path

mux0App.swift injects both languageStore and \.locale = languageStore.locale into the environment at the scene root. SwiftUI views use Text(L10n.Namespace.key)Text(LocalizedStringResource) honors the injected \.locale, so any body-rebuild produced by an observable property change picks up the right language.

For views that need a String (e.g. IconButton.help: String?), read @Environment(\.locale) private var locale and wrap:

String(localized: L10n.Namespace.key.withLocale(locale))

The withLocale(_:) helper is defined in L10n.swift. Do NOT use String(localized: res) without .withLocale(locale) — that reads Locale.current (the process-level system locale) and ignores the user's mux0 language choice.

AppKit path

AppKit subclasses can't read SwiftUI environment. Two patterns:

  1. Ephemeral strings (rebuilt on each use — right-click menus, alert dialogs): call L10n.string("raw.key") directly. It reads LanguageStore.shared.effectiveBundle at call time, so it always reflects the current preference.
  2. Persistent labels (stringValue, toolTip on long-lived NSViews): the owning *Bridge: NSViewRepresentable takes a languageTick: Int parameter wired from languageStore.tick, reads it in updateNSView (forcing re-evaluation when preference changes), and calls a refreshLocalizedStrings() method on its NSView subclass that re-assigns every tracked label.

SidebarListBridge, TabBridge, and TabContentView/TabBarView already follow this pattern — use them as references.

Commands menu (mux0App)

SwiftUI Commands live outside the view tree, so @Environment(\.locale) isn't available there. We reference LanguageStore.shared.locale directly and include _ = languageStore.tick at the top of var body: some Scene to force Commands to re-build when the user changes language. Menu items may take a moment (until the next scene pass) to update after a switch — this is a known SwiftUI Commands limitation; accept it.

Adding a new string

  1. Open mux0/Localization/Localizable.xcstrings in Xcode and add the key. Xcode provides a table editor for the catalog. Fill both en and zh-Hans.
  2. Add a constant to the appropriate L10n.* namespace in mux0/Localization/L10n.swift.
  3. Reference it at the call site. Use Text(...) in SwiftUI, or L10n.string(...) / String(localized: ....withLocale(locale)) in AppKit / SwiftUI-view contexts that need a raw String.
  4. Add the raw key to mux0Tests/L10nSmokeTests.swift's allKeys array so the smoke test catches missing translations.

Adding a new language

  1. Open the Catalog in Xcode and add a new localization column (e.g. ja).
  2. Extend LanguageStore.Preference with a new case (e.g. .ja).
  3. Extend LanguageStore.locale and LanguageStore.effectiveBundle to handle the new case.
  4. Add a Text(verbatim: "日本語").tag(LanguageStore.Preference.ja) option to the Language picker in AppearanceSectionView. Keep the display name in its native form — users switching accidentally need to recognize their language to switch back.