2

In previous iOS versions, it was possible to access the localization of system resources on device by using the Bundle class.

For example, translating Done into German was possible using the following code:

let bundle = Bundle(url: Bundle(for: UINavigationController.self).url(forResource: "de", withExtension: "lproj")!)!
print(" \(bundle.bundleURL)")
for file in try! FileManager.default.contentsOfDirectory(at: bundle.bundleURL, includingPropertiesForKeys: []) {
    print("   \(file.lastPathComponent)")
}
let done = bundle.localizedString(forKey: "Done", value: "_fallback_", table: "Localizable")
print("Done in German: \(done)")

It was printing the following, just like expected:

 file:///Applications/Xcode-14.3.1.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/de.lproj/
   Localizable.strings
   Localizable.stringsdict
   UITableViewLocalizedSectionIndex.plist
Done in German: Fertig

Note that this technique is still working on the simulators (for example iPhone 14 Pro running iOS 16.4) but is not working on actual devices.

When running this same code on an iPhone 11 running iOS 16.5.1 I get the following output:

 file:///System/Library/PrivateFrameworks/UIKitCore.framework/de.lproj/
   UITableViewLocalizedSectionIndex.plist
Done in German: _fallback_

We can see that the translation fails because the Localizable.strings and Localizable.stringsdict have disappeared.

What happened to those files in recent iOS releases? Can we still access them somehow?

0xced
  • 25,219
  • 10
  • 103
  • 255

2 Answers2

2

The simple answer is: "they moved".

These days, UIKit is technically multiple frameworks that look like a single framework. You can check this out yourself.

First, we need to find the "root" of the iOS simulator runtime, which we can do using the find command:

% cd /Applications/Xcode14_3_1.app
% find . -name 'RuntimeRoot'
./Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot

This is the iOS runtime's equivalent of / for the simulator. You'll see the familiar-looking System, Library, Developer, Applications, etc folders.

Inside System/Library/Frameworks, you'll see the list of public frameworks. Inside UIKit.framework, you'll see the almost-entirely-empty UIKit contents. So where are they?

If you open the UIKit binary in a tool like Hex Fiend, you can view the structure of the file's Mach-O configuration:

UIKit Mach-O structure

See how there are a few LC_REEXPORT_DYLIB commands? That's a command that dyld uses to "pretend" that all the symbols coming from the specified framework (UIKitCore.framework, in the screenshot) should be treated as if they're coming from this framework.

In other words, the symbols all live in the private UIKitCore.framework, but your app thinks they're coming from UIKit.framework.

Because of this, when you ask the runtime for the Bundle that contains the UINavigationController class, it will report that it's coming from UIKit.framework, and not the actual UIKitCore.framework.

Since we know where the runtime root of the system is, we can manually check to see what's in that framework:

enter image description here

And what do you know… there are the localization files!

(insert standard caveat here about how relying on private stuff is not a good idea and can be fragile, as you've discovered)


TL;DR: The localization files are in the private UIKitCore.framework.

Dave DeLong
  • 242,470
  • 58
  • 448
  • 498
  • Thanks for the explanation but unfortunately this is not what is happening. I have edited my question to display the full URLs of the bundles and we see that `Bundle(for: UINavigationController.self)` returns the `UIKitCore.framework` bundle where the `Localizable`files are missing (but on devices only). – 0xced Aug 09 '23 at 05:42
0

Alexandre Colucci informed me on Mastodon about the existence of a new Localizable.loctable file where all the localizations can be found.

With this knowledge I was able to write an extension method on the Bundle class to access localized strings from any bundle.

There are two implementations to get the dictionary of all localized strings. One that does not use a private API but uses the undocumented .loctable file format and one that uses the private localizedStringsForTable:localization: method.

import Foundation

extension Bundle {
    public func localizedString(forKey key: String, localization: String, value: String? = nil, table tableName: String? = nil) -> String {
        if let localizedStrings = localizedStrings(forTable: tableName, localization: localization), let localizedString = localizedStrings[key] as? String {
            return localizedString
        } else if let url = url(forResource: localization, withExtension: "lproj"), let localizationBundle = Bundle(url: url) {
            return localizationBundle.localizedString(forKey: key, value: value, table: tableName)
        }

        if let value, !value.isEmpty {
            return value
        }

        return key
    }

    private func localizedStrings(forTable tableName: String?, localization: String) -> [String: Any]? {
#if DISABLE_PRIVATE_API
        // This is not technically using a private API but it is using an undocument file and format
        let loctableURL = url(forResource: tableName ?? "Localizable", withExtension: "loctable")
        if let loctableURL, let data = try? Data(contentsOf: loctableURL) {
            let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: [String: Any]]
            if let localizedStrings = plist?[localization] as? [String: Any] {
                return localizedStrings
            }
        }
#else
        // This is using a private API but one which is designed for this purpose
        let localizedStringsForTable = Selector(("localizedStringsForTable:localization:"))
        if responds(to: localizedStringsForTable), let localizedStrings = perform(localizedStringsForTable, with: tableName, with: localization)?.takeUnretainedValue() as? [String: Any] {
            return localizedStrings
        }
#endif
        return nil
    }
}

Usage is straightforward and works on both simulators and devices for all versions of iOS.

let done = Bundle(for: UIApplication.self).localizedString(forKey: "Done", localization: "de")
print("Done in German: \(done)")

This prints Done in German: Fertig as expected.

0xced
  • 25,219
  • 10
  • 103
  • 255
  • Note that this technique along with some swizzling can be used to change the app language dynamically for both your main bundle but also for system bundles. See also https://stackoverflow.com/questions/9416923/ios-how-to-change-app-language-programmatically-without-restarting-the-app/ – 0xced Aug 22 '23 at 12:28