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—@Observablesingleton. Holds the user's preference, persists to UserDefaults, publishes a monotonictickcounter, and exposeslocale(for SwiftUI\.localeenvironment) andeffectiveBundle(for AppKit callers).mux0/Localization/L10n.swift— compile-time namespace of constants:L10n.Sidebar / Tab / Settings / App / Menu. Each constant is aLocalizedStringResource. For AppKit call sites there is a helperL10n.string("raw.key", args...)that resolves viaLanguageStore.shared.effectiveBundle.localizedString(forKey:value:table:nil)and appliesString(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 intoen.lproj/Localizable.stringsandzh-Hans.lproj/Localizable.stringsinside 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:
- Ephemeral strings (rebuilt on each use — right-click menus, alert
dialogs): call
L10n.string("raw.key")directly. It readsLanguageStore.shared.effectiveBundleat call time, so it always reflects the current preference. - Persistent labels (
stringValue,toolTipon long-lived NSViews): the owning*Bridge: NSViewRepresentabletakes alanguageTick: Intparameter wired fromlanguageStore.tick, reads it inupdateNSView(forcing re-evaluation when preference changes), and calls arefreshLocalizedStrings()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
- Open
mux0/Localization/Localizable.xcstringsin Xcode and add the key. Xcode provides a table editor for the catalog. Fill bothenandzh-Hans. - Add a constant to the appropriate
L10n.*namespace inmux0/Localization/L10n.swift. - Reference it at the call site. Use
Text(...)in SwiftUI, orL10n.string(...)/String(localized: ....withLocale(locale))in AppKit / SwiftUI-view contexts that need a rawString. - Add the raw key to
mux0Tests/L10nSmokeTests.swift'sallKeysarray so the smoke test catches missing translations.
Adding a new language
- Open the Catalog in Xcode and add a new localization column (e.g.
ja). - Extend
LanguageStore.Preferencewith a new case (e.g..ja). - Extend
LanguageStore.localeandLanguageStore.effectiveBundleto handle the new case. - Add a
Text(verbatim: "日本語").tag(LanguageStore.Preference.ja)option to the Language picker inAppearanceSectionView. Keep the display name in its native form — users switching accidentally need to recognize their language to switch back.