This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user