Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

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)
}
}