Skip to content
Development

Retrofitting Deep Cut Atlas to Xcode String Catalogs (and the CLI gotcha nobody warns you about)

By Victor Da Luz
swiftiosi18ndev-logdeep-cut-atlas

I’m getting Deep Cut Atlas ready to ship with Spanish and Brazilian Portuguese support, which means the first real step isn’t translating anything. It’s giving Xcode a place to put the translations. That’s what a String Catalog (Localizable.xcstrings) is: a single JSON file that holds every translatable string in the app, keyed by its English source text, with a column per locale.

The problem

Before this, the app had zero localization infrastructure. No .xcstrings, no .strings files, no .lproj folders, just ~150 hardcoded English literals scattered across Text(), Button(), toast messages, and error copy in about 20 of the app’s 71 Swift files.

Xcode’s pitch for String Catalogs is that most of this is free: add the catalog, build once, and Xcode scans your SwiftUI source for Text("..."), Button("..."), .navigationTitle("...") and pulls every literal argument into the catalog automatically. No code changes needed for the view layer.

That part’s true. What isn’t obvious is where the automatic part stops.

What doesn’t get picked up automatically

Xcode’s scanner only sees string literals sitting directly in a SwiftUI call site. It can’t see a string that’s built somewhere else and handed to Text() later as a variable. That’s most of this app’s toast and error messages, since they’re assembled in view models and services, not typed inline in a view.

Concretely, that meant hunting down and manually wrapping: every LocalizedError.errorDescription implementation across the music-library, player, discover, and store services (including the four purchase/restore errors); toast strings built in view models, things like "Added to \(playlistDisplayName)"; the displayName computed properties that feed multiple Text() call sites from a single source of truth; accessibility labels with connector words (“by X”, “from X”, “now playing”) glued together across five view files; and the MusicKit privacy-acknowledgement copy with its error.localizedDescription fallback.

Each of those got wrapped in String(localized:) so it enters the catalog as a real, translatable key instead of just being invisible to the tooling.

I deliberately left some things alone: album/artist/track/playlist names (real Apple Music data, not app copy), the StoreKit product ID, UserDefaults storage keys. None of that is translatable. It’s just data that happens to be a string.

The gotcha: xcodebuild will lie to you

Here’s the part that cost the most time. I did all of the above on the command line, ran xcodebuild, and the build succeeded, but the catalog still had zero keys. Not “some keys.” Zero.

Turns out populating a String Catalog from the scanned literals is an Xcode-IDE-only step. xcodebuild compiles .stringsdata extraction files per source file (you can see it happening, SWIFT_EMIT_LOC_STRINGS is doing real work), but it never runs the merge step that actually writes those extracted keys into Localizable.xcstrings. The build reports success because, from xcodebuild’s point of view, it is a success. It just never touches the catalog.

The fix is almost anticlimactic: open the project in Xcode itself and build with Cmd-B. That single build runs the merge, and the catalog goes from 0 keys to 153.

This app already had one Xcode-IDE-only gotcha (StoreKit configuration files only load through the IDE, never xcodebuild), so finding a second one in the same category wasn’t a total surprise. But it’s a good reminder that “build succeeded” and “the thing you actually wanted to happen, happened” are two different claims. CI and automation built entirely around xcodebuild can quietly miss steps that only exist inside Xcode’s own build pipeline.

Verifying it actually worked

Once the catalog was populated, I ran the full test suite (198/198 passing) and then walked through the simulator checking specific strings that got wrapped: an “Album added to playlist name” toast, the “by artist, from album” accessibility labels, the Album/Single release-type labels. Everything rendered identically to the pre-wrap literals. Same text, same interpolation, nothing truncated or garbled.

The one thing I didn’t get to this round was the equivalent check on a physical device via mirroring. It wasn’t available this session, so that’s a known gap rather than a skipped step. Everything else is done: the English-only catalog is complete, which was the actual goal here. Per-locale translations (es, pt-BR) are separate follow-on work now that there’s somewhere to put them.

Related reading