A Swift SDK for over-the-air (OTA) localization with LingoHub. Update your app's translations without releasing a new app version.
Contents: How it works · Installation · Get your API key · Quick Start · Which lookup APIs are covered · Configuration · Advanced Usage · Error handling · Privacy · Sample app
- 🚀 Over-the-air localization updates via the LingoHub CDN
- 🛡 Transactional installs — a corrupt download, full disk, or crash mid-update never breaks active translations
- ⏱ Paces its own update checks and backs off when the CDN is unavailable
- 🔄 Runtime language switching, persisted across launches
- 🧩 SwiftUI and Swift lookups via
Bundle.lingohub:Text("key", bundle: .lingohub),String(localized:bundle:),LocalizedStringResource - 🛠
NSLocalizedString, storyboards, and XIBs via swizzling — keep your code as it is, from any thread - 📱
.strings,.stringsdict(plurals), and String Catalog (.xcstrings) projects — what decides coverage is the lookup API, not the file format - ⚡ Closure and async/await APIs
- 🔒 Descriptive error reporting with structured error codes
- 🕵️ Ships a privacy manifest (
PrivacyInfo.xcprivacy) - 📝 Optional debug logging
- Publish a release for a Distribution in LingoHub.
- The SDK asks the LingoHub CDN whether a release matching your app version is available. Releases can target app version ranges, with an optional fallback release for all other versions.
- If there is a new release, the SDK downloads it and serves the updated strings: to
NSLocalizedString, storyboards, and XIBs through swizzling, and to SwiftUIText,String(localized:), andLocalizedStringResourcethroughBundle.lingohub. - Downloaded translations are cached on disk and discarded automatically when your app version changes, so a fresh app release always starts from its bundled strings.
If nothing has been published yet for your app version and environment, the SDK simply reports that no update is available — that is a normal state, not an error.
- iOS 15.0+ / macOS 12.0+
- Swift 5.9+ / Xcode 15+
The SwiftUI snippets below use the two-parameter
onChange(of:), which requires iOS 17. On iOS 15–16, use the single-parameter variant as noted. TheLocalizedStringResourcehelpers (Text(lh:),String(lh:),resolve(_:)) require iOS 16 / macOS 13.
The SDK is available via Swift Package Manager. In Xcode: File → Add Package Dependencies… and enter:
https://github.com/lingohub/ios-cdn-sdk.git
Or add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/lingohub/ios-cdn-sdk.git", from: "2.1.0")
]Using SwiftUI or
String(localized:)? Before 2.1 these lookups never showed downloaded translations — swizzling cannot reach them, although earlier versions of this README suggested otherwise. Passbundle: .lingohub; see Which lookup APIs are covered.
Upgrading from 1.x? Most integrations that use the documented API (
configure,swizzleMainBundle,update/updateAsync,setLanguage) compile unchanged. What changed in 2.0:
- Platforms — the minimum is now iOS 15 / macOS 12.
- 1.0.0 type names removed — the deprecated aliases
LingohubSDK,LingohubSDKError, and.LingohubDidUpdateLocalizationare gone; use the LingoHub spelling (LingoHubSDK,LingoHubSDKError,.LingoHubDidUpdateLocalization). The module name is unchanged (import Lingohub).- Internalized symbols —
checkForUpdate(result:),downloadUpdate(...),useUpdatedBundle(...),updateBundleExists,BundleInfo, andHTTPMethodwere implementation details and are no longer public.update(result:)/updateAsync()cover the complete cycle.languagesetter — assigning the property now behaves exactly likesetLanguage(_:)/setSystemLanguage(): the override is persisted,nilremoves it.- Coming from 1.0.0: also see the 1.1.0 notes in the changelog (
.apiErrorgained anerrorCodesassociated value).Downloaded translations and settings migrate automatically; no user-visible state is lost.
- In LingoHub, open your project and create a Distribution (type: Mobile SDK iOS).
- Publish a release for the environment you want to use (or mark one release as the fallback).
- Copy the distribution's CDN API key — it starts with
lh-cdn_.
See the LingoHub CDN documentation for details.
import SwiftUI
import Lingohub
@main
struct YourApp: App {
@SwiftUI.Environment(\.scenePhase) private var scenePhase
init() {
// Configure the SDK with your CDN API key
LingoHubSDK.shared.configure(withApiKey: "lh-cdn_...")
// Only needed if parts of your app use NSLocalizedString, storyboards, or XIBs
LingoHubSDK.shared.swizzleMainBundle()
}
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
// Check for updates when the app becomes active
LingoHubSDK.shared.update()
}
}
}
}
struct ContentView: View {
@State private var refreshID = UUID()
@State private var unreadCount = 3
var body: some View {
VStack {
// Look strings up in the LingoHub bundle
Text("welcome_message", bundle: .lingohub)
Text("\(unreadCount) unread messages", bundle: .lingohub)
}
.id(refreshID)
// Re-render when new translations are active
.onReceive(NotificationCenter.default.publisher(for: .LingoHubDidUpdateLocalization)) { _ in
refreshID = UUID()
}
}
}SwiftUI's Text("key"), String(localized:), and LocalizedStringResource look strings up without calling Bundle.localizedString(forKey:value:table:), the one method swizzleMainBundle() intercepts — without bundle: .lingohub they always show the strings bundled with your app. Bundle.lingohub serves the downloaded release and falls back to your bundled strings for keys the release doesn't contain. Text(NSLocalizedString("welcome_message", comment: "")) works too (it goes through the swizzled method, which is what SDK versions before 2.1 required), but renders the string verbatim, without markdown, and needs String(format:) for arguments. More in Which lookup APIs are covered.
Note: the SDK exports its own
Environmenttype, so the SwiftUI property wrapper needs to be written as@SwiftUI.Environment.On iOS 15–16, use the single-parameter
onChange:.onChange(of: scenePhase) { newPhase in ... }
import Lingohub
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Configure the SDK with your CDN API key
LingoHubSDK.shared.configure(withApiKey: "lh-cdn_...")
// Enable method swizzling for automatic localization
LingoHubSDK.shared.swizzleMainBundle()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Check for updates when the app becomes active
LingoHubSDK.shared.update()
}The SDK serves downloaded translations along two paths. Which one a string takes depends on the API you look it up with, not on the file format:
| Lookup | Shows downloaded translations | How |
|---|---|---|
NSLocalizedString (Swift and Objective-C), Bundle.localizedString(forKey:value:table:) |
✅ | swizzleMainBundle() |
Storyboards and XIBs localized with .strings files (Base Internationalization) |
✅ | swizzleMainBundle() |
SwiftUI Text("key", bundle: .lingohub) |
✅ | Bundle.lingohub |
String(localized: "key", bundle: .lingohub), AttributedString(localized: "key", bundle: .lingohub) |
✅ | Bundle.lingohub |
LocalizedStringResource, through Text(lh:), String(lh:), LingoHubSDK.shared.resolve(_:), or bundle: .atURL(Bundle.lingohub.bundleURL) |
✅ | Bundle.lingohub (iOS 16+) |
String(localized:), AttributedString(localized:), LocalizedStringResource, SwiftUI Text("key") — without the LingoHub bundle |
❌ bundled strings only | — |
SwiftUI initializers that take a LocalizedStringKey but no bundle: Button("key"), Label("key", …), Toggle("key", …), .navigationTitle("key"), … |
❌ bundled strings only | pass a Text(…, bundle: .lingohub) or LingoHubSDK.shared.resolve("key") instead |
The ❌ rows look strings up without ever calling Bundle.localizedString(forKey:value:table:), and Foundation offers no public way to intercept them. Hooking the private methods they use instead would put your app's review at risk and break with OS updates, so the SDK does not — use Bundle.lingohub.
With swizzling enabled, keep using NSLocalizedString — the SDK serves the updated translation when one is available and falls back to your bundled strings otherwise:
NSLocalizedString("welcome_message", comment: "Welcome message")Swizzling is what routes these lookups through LingoHub. Without swizzleMainBundle(), they keep showing the strings packaged in the app bundle — downloaded translations are never applied (unless you use the manual API). Once enabled, swizzling stays active for the lifetime of the process; there is no API to disable it at runtime. While no downloaded release is active, swizzled lookups take the original code path unchanged.
Pass Bundle.lingohub wherever a Swift API takes a bundle:
Text("welcome_message", bundle: .lingohub)
Text("\(count) new messages", bundle: .lingohub) // .stringsdict plurals
String(localized: "welcome_message", bundle: .lingohub)
AttributedString(localized: "terms_markdown", bundle: .lingohub)LocalizedStringResource has no Bundle parameter; use the helpers (iOS 16+):
Text(lh: "welcome_message")
let status = String(lh: "\(count) new messages")
Button(LingoHubSDK.shared.resolve("save_button")) { save() }
LocalizedStringResource("welcome_message", bundle: .atURL(Bundle.lingohub.bundleURL))All of these keep Xcode extracting your strings into the String Catalog. How Bundle.lingohub behaves:
- When a release is activated, the SDK writes a merged bundle next to it: every string table of your app bundle with the release's entries laid over it. Keys the release doesn't contain resolve to your bundled strings,
.stringsdictplurals included. Each language resolves your tables the way Foundation does: nonlocalized tables, the language, the localizations Foundation falls back to for it (deforde-AT,zh-Hantforzh-Hant-TW),Base, and, for a table the language has no file of, your development language. A release's update of a language also reaches the languages that fall back to it. - A language set with
setLanguage(_:)is served whatever locale a lookup carries. Languages that only exist in the release fall back the same way, to your closest localization or your development language, for keys the release lacks. - Without an active release — before the first download, or after an app update discarded it —
Bundle.lingohubisBundle.main. - It covers your app bundle. Strings of frameworks or Swift packages (
bundle: .module) keep reading their own bundle. - Read it where you look a string up (for example in
body) instead of storing it: it changes when a release is activated or the language switches. Refresh visible views on.LingoHubDidUpdateLocalization.
Text(lh:), String(lh:), and resolve(_:) keep a resource's key, table, default value, locale, and interpolation arguments. Because LocalizedStringResource.bundle is read-only, they rebuild the resource through its Codable representation — Apple's format, not a documented contract. That makes them best effort: should an OS release change the format, the resource is returned unchanged and shows your bundled strings. Text("key", bundle: .lingohub) and String(localized:bundle:) don't depend on it.
.stringsand.stringsdict(plurals, device variations) — served on both paths.- String Catalogs (
.xcstrings) — Xcode compiles them into.strings/.stringsdictat build time, and LingoHub delivers releases in the same compiled formats, so the file format needs no extra support. Whether a string shows downloaded translations depends on the lookup API — see the table above. String Catalog projects typically use SwiftUI andString(localized:), which needBundle.lingohub.
LingoHubSDK.shared.configure(
withApiKey: "lh-cdn_...",
environment: .production, // optional, defaults to .production
logLevel: .full // optional, defaults to .none
)| Parameter | Values | Default |
|---|---|---|
appVersion |
Any version string, used for release targeting | CFBundleShortVersionString from your Info.plist |
environment |
.production, .staging, .development, .test |
.production |
logLevel |
.none, .full |
.none |
The environment must match the environment of the release you published. The appVersion matters because releases in LingoHub can target app version ranges. Enable .full logging only in debug builds:
#if DEBUG
let logLevel: LogLevel = .full
#else
let logLevel: LogLevel = .none
#endif// Override with an ISO 639-1 language code
LingoHubSDK.shared.setLanguage("de")
// Back to the system language
LingoHubSDK.shared.setSystemLanguage()The override is persisted and restored on the next launch. It applies to LingoHub-served strings, so it takes effect once a release is active: Bundle.lingohub then serves the override language entirely (your bundled strings in that language for keys the release lacks), while swizzled lookups serve the release's strings in that language and fall back to the system language for keys it lacks. Already-rendered views don't re-render themselves — drive a refresh from your UI, for example:
struct ContentView: View {
@State private var refreshTrigger = false
var body: some View {
VStack {
Text("welcome_message", bundle: .lingohub)
Button {
LingoHubSDK.shared.setLanguage("de")
refreshTrigger.toggle()
} label: {
Text("switch_to_german", bundle: .lingohub)
}
}
.id(refreshTrigger) // re-render on language change
}
}LingoHubSDK.shared.currentLanguageCode returns the language currently being served (the override, or the system language when none is set) — use it to initialize that state. LingoHubSDK.shared.language is the override only and is nil when the SDK follows the system language; assigning it behaves exactly like calling setLanguage(_:) (persisted) or, with nil, setSystemLanguage().
.stringsdict patterns select their plural category at format time, from the formatting locale (a Foundation behavior, independent of this SDK). Without an override that matches automatically — the device language and the served strings agree. If you override to a language whose plural rules differ from the device language (say, setLanguage("ru") on an English device), format with the override's locale:
let locale = Locale(identifier: LingoHubSDK.shared.currentLanguageCode ?? "en")
// NSLocalizedString: instead of String.localizedStringWithFormat
let pattern = NSLocalizedString("apples_count", comment: "")
let text = String(format: pattern, locale: locale, count)
// Swift
let label = String(localized: "\(count) apples", bundle: .lingohub, locale: locale)
// SwiftUI
Text("\(count) apples", bundle: .lingohub)
.environment(\.locale, locale)Languages sharing the simple one/other rule (English, German, Spanish, …) are unaffected either way.
If you prefer not to use method swizzling:
func getLocalizedString(for key: String, tableName: String? = nil) -> String {
if let localizedString = LingoHubSDK.shared.localizedString(forKey: key, tableName: tableName) {
return localizedString
}
return NSLocalizedString(key, tableName: tableName, comment: "")
}The manual API reads .strings files only; for .stringsdict plurals use the swizzled path or Bundle.lingohub.
Via NotificationCenter — posted after a new translation bundle has been downloaded and is fully active. Strings read from an observer (even one running synchronously) already resolve against the new release, through swizzling and Bundle.lingohub alike. It is also posted when translations downloaded earlier become available to Bundle.lingohub shortly after launch (the first launch with SDK 2.1, or of a new app build whose bundled strings changed):
NotificationCenter.default.addObserver(
forName: .LingoHubDidUpdateLocalization,
object: nil,
queue: .main
) { [weak self] _ in
self?.updateUI()
}Via callback — true means new translations were downloaded and are active, false means there was nothing new:
LingoHubSDK.shared.update { result in
switch result {
case .success(let updated):
if updated {
// new translations are active, refresh your UI if needed
}
case .failure(let error):
print("LingoHub update failed: \(error.localizedDescription)")
}
}Callbacks are delivered on the main queue. Concurrent update()/updateAsync() calls (for example, a manual refresh racing a foreground-transition check) share a single update cycle — one network request, one install — and every caller receives the same result.
Cancelling a task that awaits updateAsync() never aborts the shared cycle (other callers may be waiting on it); the cancelled caller throws CancellationError no later than when the cycle finishes.
Or with async/await:
do {
let updated = try await LingoHubSDK.shared.updateAsync()
if updated {
// new translations are active, refresh your UI if needed
}
} catch {
print("LingoHub update failed: \(error.localizedDescription)")
}CDN usage counts towards your plan, so the SDK paces its checks itself: after a successful update check, update() reports false without contacting the CDN for the next 15 minutes. Call it whenever your app becomes active, as in the Quick Start, and leave the pacing to the SDK. Debug builds check on every call, so you see new releases right away while you develop.
To check less often, raise the interval (in seconds):
LingoHubSDK.shared.minimumCheckInterval = 24 * 60 * 60 // at most once a dayA failed check doesn't start the interval: the next update() call tries again, unless the SDK paused checks after server errors or a 429 (see Failures and retries).
Two situations are not errors and are reported as .success(false) — "no new content":
- Already up to date — the CDN answered that you have the latest release.
- Nothing published yet — no release exists for your environment and app version (
DISTRIBUTION_NOT_FOUND). Publish a release in your Distribution to resolve this.
Real failures are delivered as LingoHubSDKError. The .apiError case carries the HTTP status and the server's error codes as structured fields — statusCode: Int and errorCodes: [String] — so you can react without parsing the message:
| Status | Error codes | Meaning and what to do |
|---|---|---|
| 401 | CDN_KEY_NOT_FOUND, CDN_KEY_EXPIRED, TOKEN_EXPIRED, JWT_INVALID |
The API key is missing, invalid, revoked, or rotated — check the key you pass to configure |
| 429 | USAGE_LIMIT_EXCEEDED |
Your CDN usage budget is exhausted; the SDK pauses its checks for an hour (see Failures and retries) |
| 5xx | — | The CDN is temporarily unavailable; the SDK retries once, then pauses its checks (see Failures and retries) |
| 400 | — | Malformed request — usually an SDK/backend version mismatch, please report it |
| other | — | Network errors and unexpected responses |
LingoHubSDK.shared.update { result in
switch result {
case .success(let updated):
print(updated ? "Translations updated" : "Already up to date")
case .failure(let error):
switch error {
case .apiError(429, _, _):
break // Usage budget exhausted: the SDK pauses its checks on its own
case .apiError(_, _, let errorCodes) where errorCodes.contains("CDN_KEY_EXPIRED"):
alertKeyRotationNeeded()
case .invalidApiKey:
print("API key is missing — call configure first")
default:
print("LingoHub update failed: \(error.localizedDescription)")
}
}
}statusCode is 0 for local and network errors (no response was received). .invalidApiKey, .invalidAppVersion, and .invalidSdkVersion mean configure was not called or was called with incomplete data. The CDN may introduce additional error codes over time — treat the table above as non-exhaustive.
The SDK paces update checks and handles failed ones on its own, with the same policy on iOS and Android:
| Failure | Behavior |
|---|---|
| Transport error (offline, DNS, timeout) | No automatic retry. The next update() call, for example on the next app foreground, is the retry. |
5xx from the CDN |
One retry after the delay the response's Retry-After asks for, or after a random 2–5 seconds when it has none; a Retry-After over 10 seconds skips the retry. If the check still gets a 5xx, update checks pause for 5 minutes, doubling with each further failed update in a row up to 1 hour, and never shorter than Retry-After. |
429 (USAGE_LIMIT_EXCEEDED) |
Update checks pause for 1 hour, or for as long as Retry-After asks when that is longer. |
Other 4xx (400, 401, a 404 without DISTRIBUTION_NOT_FOUND) |
No retry. Logged once per app process and reported to your app. |
404 with DISTRIBUTION_NOT_FOUND |
Not an error: nothing is published for your app version yet. The normal minimum interval applies. |
Download failure (expired download URL, 5xx from storage) |
One fresh check to get a new download URL, then no further attempt until the next update() call. |
| Minimum interval between checks | After a successful check, update() doesn't contact the CDN for 15 minutes (no interval in debug builds). Configurable, see Reduce network requests. |
Pauses and the minimum interval are stored on the device and survive app restarts. A new app version, another environment, or another CDN key starts without them. No pause lasts longer than 24 hours, whatever Retry-After asks for. An update check that is running when you change the environment or CDN key stops at its next step without changes: it sends no further request, installs nothing and records nothing. The update then checks with the new configuration, and update() reports the result of that check. "Logged once" refers to the SDK's error message: with full logging enabled, the request trace still lists every request.
While update checks are paused, update() reports the failure that caused the pause, as .apiError with its status code and error codes, without contacting the CDN. Within the minimum interval, it reports false.
updatereportsfalseright away, without a network request — the last successful check was less thanminimumCheckIntervalago (15 minutes in release builds). See Reduce network requests.updatekeeps reportingfalseand nothing changes — most likely no release is published yet for your app version and environment. Publish a release in your Distribution (or mark one as the fallback), and double-check that theenvironmentyou configure matches the release's environment. EnablelogLevel: .fullin a debug build to see what the SDK is doing.- SwiftUI
Text("key")orString(localized:)never shows downloaded translations — these lookups never reach swizzling. Passbundle: .lingohub, or useText(lh:)/String(lh:); see Which lookup APIs are covered. Button("key"),Label, or.navigationTitle("key")don't change — these SwiftUI initializers take no bundle. Use aText("key", bundle: .lingohub)label, or passLingoHubSDK.shared.resolve("key").NSLocalizedStringstrings never change, not even after an app restart — swizzling is not enabled. CallLingoHubSDK.shared.swizzleMainBundle()right afterconfigure; see Quick Start.- Strings change only after navigating away and back — lookups are served, but visible views are not re-rendered when the update arrives. Observe
.LingoHubDidUpdateLocalizationand refresh your UI. - Error 401 — the CDN key is missing, invalid, or was revoked.
errorCodescontains the reason (for exampleCDN_KEY_NOT_FOUND). - Error 429 — your CDN usage budget is exhausted. The SDK pauses its checks for an hour, or longer when the CDN asks for it, and reports the 429 without contacting the CDN until then.
The SDK includes a PrivacyInfo.xcprivacy manifest, which Xcode merges into your app's privacy report automatically — relevant for your App Store privacy declarations. What the SDK touches:
UserDefaults— stores the installed release ID, the app version it was downloaded for, the name of the folder the release is stored in, the language override, and the update check schedule: when the last check succeeded and, after server errors or a 429 response, until when checks are paused.- Downloaded translation bundles, and the merged copy of your app's string tables that
Bundle.lingohubserves — stored in the app's Application Support directory, excluded from device backups (they are re-downloadable or rebuilt). - Each update check sends to the LingoHub CDN over HTTPS: your CDN API key (Authorization header), the configured environment and distribution type, your app's version, the currently served language, the currently installed release ID, the SDK version, and a random installation identifier (a UUID generated by the SDK and stored in the keychain) as the client identifier for usage metering. The identifier is not linked to user identity and not used for tracking, and it is declared in the bundled privacy manifest.
The DemoAppLingoHub project in this repository shows a complete SwiftUI integration with Bundle.lingohub and Text(lh:), including runtime language switching and update notifications. Open it in Xcode, insert your CDN API key in LingohubApp.swift, and run.
For bug reports and feature requests, please open an issue on GitHub.
Apache License Version 2.0, January 2004. More info in the LICENSE file.