If you've ever localized an iOS or macOS app, you know the drill: you've got a .plist file full of UI strings sitting in Base.lproj, and now you need the same file in fr.lproj, th.lproj, de.lproj... and so on for every language you support. You can pay for a translation service, hand it to a freelancer, or sit there manually retyping strings into fifteen copies of the same file. I didn't want to do any of those things, so I spent a day building pListTranslatorApp — a small SwiftUI macOS app that opens an Xcode plist, finds the string values you want translated, batch-translates them using Apple's built-in Translation framework, and writes out a ready-to-use localized plist. It's free, it runs entirely on-device, and it's now sitting on GitHub for anyone who wants it. The constraint that shaped the whole thing I'm developing on a MacBook Air with a 256GB SSD and 8GB of RAM — not a lot of headroom. That ruled out a couple of obvious approaches: Cloud translation APIs (DeepL, Google Cloud Translate, etc.) work well, but even a generous free tier means an external dependency, an API key to manage, and a network round-trip for something that's ultimately just short UI strings. Downloading a pile of on-device language models "just in case" eats real disk space fast — Apple's Translation framework models are shared system-wide across apps, which helps, but they still add up if you're not paying attention. So the app leans entirely on Apple's Translation framework, downloading exactly one language pack at a time, on demand, and lets you delete it once you're done with that language. What it actually does Open a plist. Pick any Xcode .plist via a standard file importer. Choose which keys to translate. Originally I hardcoded it to look for a key called itemTitle, but that's obviously too narrow for anyone else's project — so it's now a comma-separated text field. Type itemTitle, title, label and it'll pick up all of them, anywhere in the plist's nested dictionaries and arrays. Pick a target language from a picker (Thai, French, German, Spanish, Japanese — easy to extend). Translate All. The app fires a batch of translation requests through TranslationSession. If the language pack isn't installed yet, macOS's own system prompt pops up to download it — no extra UI work needed on my end. Save. Writes a new plist alongside the original, via a proper save panel, ready to drop into the matching .lproj folder. There's also a small QA feature I added once I realized machine translation, however good, still benefits from a human sanity check: the app can optionally write the original English string alongside the translation under a key_en entry, so a native speaker can review both side by side in the same file without needing a diff tool. The gotchas that ate most of the day None of this was as simple as "call the Translation API and done." A few things tripped me up that I couldn't find clearly documented anywhere, so I'm sharing them here in case they save someone else the debugging time: The sandbox doesn't grant you a folder, only a file. startAccessingSecurityScopedResource() on the plist you opened only covers that one file. Trying to write a new file into the same directory afterward throws a permissions error, even though it looks like the same folder. The fix was routing the save through NSSavePanel, which grants scoped access to whatever the user confirms. There are two very different ways to start a TranslationSession, and only one can prompt for a download. TranslationSession(installedSource:target:) assumes the language pack already exists and just throws .notInstalled if it doesn't — it has no UI to fall back on. The .translationTask(configuration:) view modifier, on the other hand, can trigger the system's own "download this language" prompt automatically. If your translation silently fails with notInstalled, this is almost certainly why. .translationTask won't always re-fire on a second run. If you reuse the same source/target language for a second batch (a very normal thing to do — translating multiple plists into the same target language), SwiftUI can treat the new Configuration as unchanged and skip re-running the task entirely. Explicitly invalidating and nil-ing the old configuration before assigning a new one helps, but the reliable fix was attaching an .id() to the modifier, keyed on the language and item count, forcing SwiftUI to treat each run as a genuinely new task. Don't rely on dictionary iteration order to match strings back to their slots. My first pass used a simple iterator to walk extracted strings back into the plist tree in the same order they came out. That works right up until PropertyListSerialization parses the file a second time (on save) and iterates its dictionary keys in a different order than the first parse did — silently shifting every translation over by one slot. The fix was recording each string's exact path through the plist (which key, which array index, at every level) at extraction time, then writing translations back to that exact path rather than relying on sequence. Try it The whole thing is open source on GitHub: https://github.com/swainwri/PListTranslator It's a small, opinionated tool built for a specific workflow — Xcode plist localization — but if you're building something similar, the source might save you the same afternoon of TranslationError debugging it cost me. Issues and PRs welcome, especially if you want to extend it to .strings/.stringsdict files, which is the natural next step. If you use it and it's useful, or if your Thai/French/Italian friends find the output questionable, I'd love to hear about it.
I Got Tired of Hand-Translating Xcode Plists, So I Built a Tiny Free Tool to Do It On-Device
Full Article
📰 Original Source
Read full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.