191 lines
6.3 KiB
Swift
191 lines
6.3 KiB
Swift
import Combine
|
|
import Foundation
|
|
|
|
/// The single runtime locale source shared by the host and dynamic plugins.
|
|
/// Views observe `revision`; string lookup always reads the same snapshot.
|
|
public final class PluginRuntimeLocaleSource: ObservableObject, @unchecked Sendable {
|
|
public static let preferenceUserDefaultsKey = "app.languagePreference"
|
|
public static let shared = PluginRuntimeLocaleSource()
|
|
|
|
@Published public private(set) var revision = 0
|
|
|
|
private let lock = NSLock()
|
|
private let systemPreferredLanguages: () -> [String]
|
|
private let currentLocale: () -> Locale
|
|
private var storedPreference: String?
|
|
private var storedPreferredLanguages: [String]
|
|
private var storedLocale: Locale
|
|
private var localeChangeObserver: NSObjectProtocol?
|
|
|
|
public var preferredLanguages: [String] {
|
|
lock.withLock { storedPreferredLanguages }
|
|
}
|
|
|
|
public var locale: Locale {
|
|
lock.withLock { storedLocale }
|
|
}
|
|
|
|
init(
|
|
userDefaults: UserDefaults = .standard,
|
|
systemPreferredLanguages: @escaping () -> [String] = PluginRuntimeLocaleSource.systemPreferredLanguages,
|
|
currentLocale: @escaping () -> Locale = { .current }
|
|
) {
|
|
let initialSystemLanguages = systemPreferredLanguages()
|
|
let initialLocale = currentLocale()
|
|
self.systemPreferredLanguages = systemPreferredLanguages
|
|
self.currentLocale = currentLocale
|
|
let preference = userDefaults.string(forKey: Self.preferenceUserDefaultsKey)
|
|
self.storedPreference = preference
|
|
if let preference, preference != "system" {
|
|
self.storedPreferredLanguages = [preference]
|
|
self.storedLocale = LocalizedBundleResolver.locale(
|
|
for: preference,
|
|
currentLocale: initialLocale
|
|
)
|
|
} else {
|
|
self.storedPreferredLanguages = initialSystemLanguages
|
|
self.storedLocale = initialLocale
|
|
}
|
|
localeChangeObserver = NotificationCenter.default.addObserver(
|
|
forName: NSLocale.currentLocaleDidChangeNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.refreshSystemLocaleIfNeeded()
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let localeChangeObserver {
|
|
NotificationCenter.default.removeObserver(localeChangeObserver)
|
|
}
|
|
}
|
|
|
|
/// Updates the language snapshot and notifies all app and plugin surfaces.
|
|
/// Call this whenever the app-language preference is saved.
|
|
public func setPreference(_ preference: String?) {
|
|
let languages = preferredLanguages(for: preference)
|
|
let locale = locale(for: preference)
|
|
let changed = lock.withLock {
|
|
guard storedPreference != preference
|
|
|| storedPreferredLanguages != languages
|
|
|| storedLocale.identifier != locale.identifier
|
|
else {
|
|
return false
|
|
}
|
|
|
|
storedPreference = preference
|
|
storedPreferredLanguages = languages
|
|
storedLocale = locale
|
|
return true
|
|
}
|
|
|
|
guard changed else {
|
|
return
|
|
}
|
|
|
|
publishRevision()
|
|
}
|
|
|
|
private func refreshSystemLocaleIfNeeded() {
|
|
let snapshot = lock.withLock { () -> (shouldRefresh: Bool, preference: String?) in
|
|
(
|
|
storedPreference == nil || storedPreference == "system",
|
|
storedPreference
|
|
)
|
|
}
|
|
guard snapshot.shouldRefresh else {
|
|
return
|
|
}
|
|
|
|
setPreference(snapshot.preference)
|
|
}
|
|
|
|
private func preferredLanguages(for preference: String?) -> [String] {
|
|
guard let preference, preference != "system" else {
|
|
return systemPreferredLanguages()
|
|
}
|
|
|
|
return [preference]
|
|
}
|
|
|
|
private func locale(for preference: String?) -> Locale {
|
|
guard let preference, preference != "system" else {
|
|
return currentLocale()
|
|
}
|
|
|
|
return LocalizedBundleResolver.locale(for: preference, currentLocale: currentLocale())
|
|
}
|
|
|
|
private func publishRevision() {
|
|
if Thread.isMainThread {
|
|
revision &+= 1
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.revision &+= 1
|
|
}
|
|
}
|
|
|
|
private static func systemPreferredLanguages() -> [String] {
|
|
let globalDomain = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
|
|
if let languages = globalDomain?["AppleLanguages"] as? [String], !languages.isEmpty {
|
|
return languages
|
|
}
|
|
|
|
return Locale.preferredLanguages
|
|
}
|
|
}
|
|
|
|
/// Resolves localized resources against `PluginRuntimeLocaleSource.shared`.
|
|
/// `Bundle.localizedString` only consults the process language list, so every
|
|
/// lookup explicitly selects an `.lproj` resource bundle instead.
|
|
public enum PluginRuntimeLocalization {
|
|
public static let preferenceUserDefaultsKey = PluginRuntimeLocaleSource.preferenceUserDefaultsKey
|
|
public static let source = PluginRuntimeLocaleSource.shared
|
|
public static let baseLanguage = "en"
|
|
|
|
public static var preferredLanguages: [String] {
|
|
source.preferredLanguages
|
|
}
|
|
|
|
public static var locale: Locale {
|
|
source.locale
|
|
}
|
|
|
|
public static func string(
|
|
_ key: String,
|
|
defaultValue: String,
|
|
table: String? = nil,
|
|
bundle: Bundle
|
|
) -> String {
|
|
// The selected language falls back to English, then to the caller's
|
|
// source-language default when neither resource contains the key.
|
|
for localizedBundle in localizedBundles(in: bundle) {
|
|
let value = localizedBundle.localizedString(forKey: key, value: nil, table: table)
|
|
if value != key {
|
|
return value
|
|
}
|
|
}
|
|
|
|
return defaultValue
|
|
}
|
|
|
|
public static func localizedBundle(in bundle: Bundle) -> Bundle? {
|
|
localizedBundles(in: bundle).first
|
|
}
|
|
|
|
public static func localizedBundles(in bundle: Bundle) -> [Bundle] {
|
|
LocalizedBundleResolver.localizedBundles(
|
|
in: bundle,
|
|
preferredLanguages: preferredLanguages,
|
|
baseLanguage: baseLanguage
|
|
)
|
|
}
|
|
|
|
public static func candidateLanguageIdentifiers(for language: String) -> [String] {
|
|
LocalizedBundleResolver.candidateLanguageIdentifiers(for: language)
|
|
}
|
|
}
|