Eleven Languages, One Toggle
Apple assumes one language per session. The Bundle API disagrees.
Every localized Mac app I have used requires a restart to switch languages. Apple’s framework assumes it: the system picks a language at launch, loads the matching bundle, and that is the language until the next launch.
Renala switches instantly. Pick a language in preferences, the UI updates in place. That is not Apple’s default behavior. It is a deliberate architectural choice, built on an extension point the framework provides but does not advertise.
I front-loaded that choice. Internationalization (i18n) and localization (l10n) were design constraints from day one, the same way accessibility was. Adding localization after the fact means doing the work twice: finding the strings, extracting them, verifying nothing broke. Doing it from the start costs discipline. Retrofitting it costs more.
The string system
The mechanism behind the instant switch: Swift’s localized-string APIs let you control where the lookup happens. Pass String(localized:bundle:) a custom Bundle (Apple’s resource package for a specific language) loaded for the target language, and the string comes from that bundle’s localization table instead of the app’s default. Same idea as swapping the active translation file in a web app, except the framework resolves strings through the bundle at call time.
Apple’s standard localization picks the language at launch and does not change it. The Bundle-swap approach overrides that by loading a different resource package at runtime.
Formatted values are a separate concern: the app also keeps SwiftUI’s locale environment in sync with the selected language, so date formats, number separators, and currency symbols follow the same choice.
LocalizationManager
The live switching mechanism lives in LocalizationManager, an @Observable class isolated to @MainActor. When the user changes language, it loads a new Bundle and updates observable state. Views that read that state are invalidated through SwiftUI’s observation tracking. No restart, no window refresh, no manual invalidation.
The pattern in views: read the manager from the environment, call String(localized:bundle:) with the manager’s active bundle, and let the app’s locale and layout-direction environments move with the same selection. Observation handles the rest.
Non-view code uses LocalizationManager.shared, the singleton on @MainActor. Menu items, accessibility strings, anything outside the view hierarchy but still tied to the UI. VoiceOver reads accessibility labels aloud, so “Scan complete, 3.2 GB analyzed” needs to be correct in Arabic, not just English.
xcstrings
Apple introduced .xcstrings as the modern authoring format for localized strings. The format is JSON-based. One file holds all languages for a given string table. That is a significant improvement over juggling separate .strings files by hand, even though runtime lookup still happens through the usual bundle localization machinery.
With .xcstrings, adding a new string means adding one entry to one file. Xcode’s editor shows all translations side by side. Missing translations are flagged. The diffs are cleaner than the old split-file approach.
The editor is convenient for a handful of strings. At Renala’s scale, with 11 languages and hundreds of keys, the .xcstrings files are not edited directly.
The translation pipeline
They are generated.
Scripts/Localization/generate_translations.py is the source of truth. It holds every string and its translation in all 11 languages. Running it regenerates the .xcstrings files. This keeps the translation data in a format that is easy to read, diff, and edit without Xcode. It also means the translation process can be scripted: update the Python file, run the generator, commit the result.
flowchart LR
accTitle: Translation generation and validation pipeline
accDescr: A Python script generates .xcstrings files for the app bundle. Three validation tools run against the script output: a translation lint for quality rules, a pseudo-localization check for placeholder integrity, and a completeness check for coverage per language.
PY["generate_translations.py<br/>(source of truth)"] -->|"python3"| XC[".xcstrings files<br/>(generated output)"]
XC --> APP["App bundle<br/>localization tables"]
PY -->|"lint"| LINT["lint_translations.py<br/>quality rules"]
PY -->|"pseudo"| PSEUDO["pseudo_localization_check.py<br/>placeholder integrity"]
PY -->|"completeness"| COMP["check_completeness.py<br/>coverage per language"]
Adding a new user-facing string means adding it to the Python script first. Modifying a string means updating the script and all affected translations. The .xcstrings files are outputs, not inputs. This means Xcode’s translation memory, xcloc export (Xcode’s localization interchange format), and “mark as reviewed” workflow are all bypassed, a tradeoff for diffability and scripted control.
The pipeline handles the text. It does not handle layout.
RTL and Arabic
Arabic is a right-to-left language. When the active language is Arabic, SwiftUI derives the layoutDirection from the locale. Navigation flips, chevrons point the opposite way, and the parts of the interface that follow layout direction mirror with it.
SwiftUI handles most of this automatically through the layoutDirection environment. The part that will bite you is icon direction. chevron.left for back-navigation? Wrong in Arabic. The reading direction is reversed, so “back” points right.
The fix: chevron.forward and chevron.backward. Semantic, not directional. SF Symbols resolves them to the correct glyph based on layout direction. Get this right and Arabic users see correctly-pointing navigation arrows. Get it wrong and your app is subtly broken for 400 million speakers, none of whom will file a bug report in a language you can read.
The bundle-swap interactive above demonstrates this live: switch to Arabic and watch the chevron flip from ← to →. That is chevron.forward and chevron.backward in action, resolved by SF Symbols based on layout direction.
Arabic plural forms
English has two plural categories: one item, and everything else. Arabic has six: zero, one, two, few, many, other. These are the CLDR (Common Locale Data Repository) categories. CLDR is the Unicode consortium’s shared database of locale rules: the upstream source that Apple, Android, and every major browser use to decide how numbers, dates, and plurals behave in each language. The categories are not decorative.
“1 file” and “2 files” are different words in Arabic. So are “11 files” and “100 files.” The rules are specific, internally consistent, and will make an English speaker’s head spin.
xcstrings supports CLDR plural categories. For Arabic strings that genuinely depend on the count, you need access to all six. Collapse them into one/other and the Arabic is wrong for a large range of numbers. This is the kind of mistake that is invisible to non-Arabic speakers in review and immediately obvious to Arabic speakers.
There is a subtler trap. If you format a number into a display string first (“3.2 GB”) and then pass that string to the localizer, the plural machinery never sees the underlying number. In English this is harmless: “1 file” and “42 files” only need one/other. In Arabic, the grammar is wrong for most values. The fix: pass the numeric value itself into the localized string, using String(localized:) with interpolation so the plural machinery can dispatch on the number directly.
The app currently ships in 11 languages, each with its own CLDR plural category set:1
Arabic’s six categories, zero, one, two, few, many, other, are why it requires the most attention in the translation pipeline. Every pluralized count key that changes grammar with the number needs those categories available, not just one/other.
CI checks
Code review cannot catch localization bugs. The reviewer reads English. The bug is in Arabic. Three automated checks fill that gap, running on every push and pull request.
The translation lint enforces a small set of quality rules that caught real regressions while I was building the catalog: deprecated keys, required replacements, glossary coverage, and a few language-specific wording mistakes that are easy to miss in review.
The pseudo-localization check (substituting fake translations to stress-test the UI) verifies placeholder preservation. A string like “%lld files” has a format placeholder. The translated version has to preserve it. Drop or scramble that placeholder and the result ranges from obviously wrong output to a broken formatted string at the call site.
The completeness check counts coverage: what percentage of keys have translations in each language. A language that falls below a threshold blocks the build.
flowchart TD
accTitle: CI checks for translation quality
accDescr: On every git push, GitHub Actions runs three checks in parallel: translation lint for quality rules, pseudo-localization for missing placeholders, and completeness against a coverage threshold. Any failure blocks the build.
COMMIT["git push"] --> CI["GitHub Actions"]
CI --> LINT["Translation lint<br/>quality rules"]
CI --> PSEUDO["Pseudo-localization<br/>placeholder check"]
CI --> COMP["Completeness check<br/>coverage threshold"]
LINT -->|"rule violated?"| FAIL["Build fails"]
PSEUDO -->|"missing placeholder?"| FAIL
COMP -->|"< threshold?"| FAIL
LINT & PSEUDO & COMP -->|"all pass"| PASS["Build succeeds"]
What it costs
Every user-facing string gets written twice: once in English in the code, once in the translation script with all 11 translations. The discipline is real. There is no shortcut where you write English and add translations later. When a string changes, all 11 translations need updating in the same commit. The Python script makes it visible, not painless.
The payoff is visible in the app today: Arabic and Japanese work alongside the rest of the interface, including RTL layout and the plural logic Arabic needs. No string hunting, no untranslated fallback text, no post-launch localization sprint.
The infrastructure is the real investment: the pipeline, the CI checks, the generator script. But once that infrastructure exists, the marginal cost collapses. A new string is a minutes-scale task. A new language is an hours-scale task. The best localization work is the work you front-load.
If you are building a Mac or iOS app and you think localization can wait: the longer you wait, the more strings you bury in views, the harder the migration becomes.
References
- Apple: Localizing and varying text with a String Catalog: xcstrings format and Xcode editor documentation.
- WWDC 2023: Discover String Catalogs: Introduction to the xcstrings format and migration from .strings files.
- Unicode CLDR plural rules: Specification for all CLDR plural categories, including Arabic’s six forms.
- Apple HIG: Right to left: Human Interface Guidelines on RTL layout and localization.
- LayoutDirection (SwiftUI): SwiftUI’s layout direction enum and RTL support.
Footnotes
-
CLDR plural category counts per the Unicode CLDR v48 specification. French, Spanish, Italian, and Portuguese show 3 categories because CLDR added a “many” form for compact decimal formatting (e.g., “1 million”). For regular integer counts like file totals, those languages effectively use 2 categories (one/other). Russian’s 4 categories (one, few, many, other) apply to all integers. ↩
