|
|
@@ -6,8 +6,11 @@ extension Notification.Name {
|
|
|
}
|
|
|
|
|
|
enum AppLanguageManager {
|
|
|
- private static let defaultsKey = "settings.appLanguageCode"
|
|
|
+ private static let legacyDefaultsKey = "settings.appLanguageCode"
|
|
|
private static var didApplyLaunchLanguage = false
|
|
|
+ private static var cachedSystemLanguageCode: String?
|
|
|
+ /// In-session override from Settings → Language; cleared on every launch so the app follows macOS.
|
|
|
+ private static var sessionLanguageOverride: String?
|
|
|
|
|
|
private static let launchLanguageBootstrap: Void = {
|
|
|
applyLanguageOnLaunch()
|
|
|
@@ -17,23 +20,35 @@ enum AppLanguageManager {
|
|
|
_ = launchLanguageBootstrap
|
|
|
}
|
|
|
|
|
|
+ /// Effective macOS language preferences for this app (bundle localization, per-app language, then system).
|
|
|
+ private static var systemPreferredLanguageIdentifiers: [String] {
|
|
|
+ var identifiers: [String] = []
|
|
|
+ identifiers.append(contentsOf: Bundle.main.preferredLocalizations)
|
|
|
+ identifiers.append(contentsOf: Locale.preferredLanguages)
|
|
|
+ if let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain),
|
|
|
+ let appleLanguages = global["AppleLanguages"] as? [String] {
|
|
|
+ for language in appleLanguages where identifiers.contains(language) == false {
|
|
|
+ identifiers.append(language)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let current = Locale.current.identifier
|
|
|
+ if identifiers.contains(current) == false {
|
|
|
+ identifiers.append(current)
|
|
|
+ }
|
|
|
+ return identifiers
|
|
|
+ }
|
|
|
+
|
|
|
/// UI languages shown in Settings → Language (order preserved).
|
|
|
private static let supportedLanguageCodes = ["en", "fr", "fr-CA", "es-ES", "es-MX", "de", "it", "nl", "pl", "sv", "nb", "fi", "el", "he", "ar", "ko", "pt-BR", "ru", "uk", "vi", "zh-Hans", "zh-Hant"]
|
|
|
|
|
|
- /// `nil` follows the system language; otherwise uses the selected `.lproj` code.
|
|
|
+ /// `nil` follows the macOS system language; otherwise uses the in-session Settings → Language choice.
|
|
|
static var selectedLanguageCode: String? {
|
|
|
- get {
|
|
|
- guard UserDefaults.standard.object(forKey: defaultsKey) != nil else { return nil }
|
|
|
- let value = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
|
|
|
- return value.isEmpty ? nil : value
|
|
|
- }
|
|
|
- set {
|
|
|
- if let code = newValue, code.isEmpty == false {
|
|
|
- UserDefaults.standard.set(code, forKey: defaultsKey)
|
|
|
- } else {
|
|
|
- UserDefaults.standard.removeObject(forKey: defaultsKey)
|
|
|
- }
|
|
|
- }
|
|
|
+ sessionLanguageOverride
|
|
|
+ }
|
|
|
+
|
|
|
+ /// True when the app is using the macOS system language (no in-session override).
|
|
|
+ static var isFollowingSystemLanguage: Bool {
|
|
|
+ sessionLanguageOverride == nil
|
|
|
}
|
|
|
|
|
|
static var availableLanguageCodes: [String] {
|
|
|
@@ -42,15 +57,18 @@ enum AppLanguageManager {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// Resolved language code: stored preference, or best match for the system locale.
|
|
|
+ /// Resolved language code: in-session picker choice, or best match for the system locale.
|
|
|
static var effectiveLanguageCode: String {
|
|
|
- selectedLanguageCode
|
|
|
+ if let code = sessionLanguageOverride {
|
|
|
+ return resolvedBundleLanguageCode(for: code)
|
|
|
+ }
|
|
|
+ return cachedSystemLanguageCode
|
|
|
?? bestMatchingAvailableLanguageCode()
|
|
|
?? availableLanguageCodes.first
|
|
|
?? "en"
|
|
|
}
|
|
|
|
|
|
- /// Language code shown in Settings → Language.
|
|
|
+ /// Language code pre-selected in Settings → Language (session choice or best system match).
|
|
|
static var pickerSelectedLanguageCode: String {
|
|
|
effectiveLanguageCode
|
|
|
}
|
|
|
@@ -59,30 +77,45 @@ enum AppLanguageManager {
|
|
|
let available = availableLanguageCodes
|
|
|
guard available.isEmpty == false else { return nil }
|
|
|
|
|
|
- func match(_ identifier: String) -> String? {
|
|
|
- let normalized = identifier.replacingOccurrences(of: "_", with: "-")
|
|
|
- if available.contains(normalized) { return normalized }
|
|
|
- let base = normalized.split(separator: "-").first.map(String.init) ?? normalized
|
|
|
- if available.contains(base) { return base }
|
|
|
- return available.first { code in
|
|
|
- code.split(separator: "-").first.map(String.init) == base
|
|
|
- }
|
|
|
+ for preferred in systemPreferredLanguageIdentifiers {
|
|
|
+ if let code = matchLanguageCode(preferred, in: available) { return code }
|
|
|
+ }
|
|
|
+ return matchLanguageCode(Locale.current.identifier, in: available)
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func matchLanguageCode(_ identifier: String, in available: [String]) -> String? {
|
|
|
+ let normalized = identifier.replacingOccurrences(of: "_", with: "-")
|
|
|
+ if available.contains(normalized) { return normalized }
|
|
|
+ let base = normalized.split(separator: "-").first.map(String.init) ?? normalized
|
|
|
+ if available.contains(base) { return base }
|
|
|
+ return available.first { code in
|
|
|
+ code.split(separator: "-").first.map(String.init) == base
|
|
|
}
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func resolvedBundleLanguageCode(for code: String) -> String {
|
|
|
+ let available = availableLanguageCodes
|
|
|
+ guard available.isEmpty == false else { return "en" }
|
|
|
+ return matchLanguageCode(code, in: available) ?? available.first(where: { $0 == "en" }) ?? available[0]
|
|
|
+ }
|
|
|
|
|
|
- for preferred in Locale.preferredLanguages {
|
|
|
- if let code = match(preferred) { return code }
|
|
|
+ private static func bundle(forLanguageCode code: String) -> Bundle {
|
|
|
+ let resolvedCode = resolvedBundleLanguageCode(for: code)
|
|
|
+ if let path = Bundle.main.path(forResource: resolvedCode, ofType: "lproj"),
|
|
|
+ let bundle = Bundle(path: path) {
|
|
|
+ return bundle
|
|
|
}
|
|
|
- return match(Locale.current.identifier)
|
|
|
+ // Never fall back to `.main` — it localizes using the macOS system locale, not our resolved language.
|
|
|
+ if let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
|
|
|
+ let bundle = Bundle(path: path) {
|
|
|
+ return bundle
|
|
|
+ }
|
|
|
+ return .main
|
|
|
}
|
|
|
|
|
|
static var localizationBundle: Bundle {
|
|
|
bootstrapLaunchLanguageIfNeeded()
|
|
|
- let code = effectiveLanguageCode
|
|
|
- guard let path = Bundle.main.path(forResource: code, ofType: "lproj"),
|
|
|
- let bundle = Bundle(path: path) else {
|
|
|
- return .main
|
|
|
- }
|
|
|
- return bundle
|
|
|
+ return bundle(forLanguageCode: effectiveLanguageCode)
|
|
|
}
|
|
|
|
|
|
static var formattingLocale: Locale {
|
|
|
@@ -179,26 +212,42 @@ enum AppLanguageManager {
|
|
|
/// Ensures launch-time language resolution runs before UI strings are read.
|
|
|
static func syncWithSystemLanguageOnLaunch() {
|
|
|
bootstrapLaunchLanguageIfNeeded()
|
|
|
+ resetToSystemLanguageForLaunch()
|
|
|
applyGlobalLayoutDirection()
|
|
|
}
|
|
|
|
|
|
/// Re-evaluates system language matching after launch; macOS locale preferences can settle after the first UI pass.
|
|
|
- static func reconcileLanguageAfterLaunch() {
|
|
|
- guard selectedLanguageCode == nil else {
|
|
|
- applyGlobalLayoutDirection()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
+ @discardableResult
|
|
|
+ static func reconcileLanguageAfterLaunch() -> Bool {
|
|
|
let previous = effectiveLanguageCode
|
|
|
+ if isFollowingSystemLanguage {
|
|
|
+ refreshCachedSystemLanguageCode()
|
|
|
+ }
|
|
|
applyGlobalLayoutDirection()
|
|
|
- if effectiveLanguageCode != previous {
|
|
|
+ let didChange = effectiveLanguageCode != previous
|
|
|
+ if didChange {
|
|
|
NotificationCenter.default.post(name: .appLanguageDidChange, object: nil)
|
|
|
}
|
|
|
+ return didChange
|
|
|
}
|
|
|
|
|
|
private static func applyLanguageOnLaunch() {
|
|
|
guard didApplyLaunchLanguage == false else { return }
|
|
|
didApplyLaunchLanguage = true
|
|
|
+ resetToSystemLanguageForLaunch()
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func resetToSystemLanguageForLaunch() {
|
|
|
+ sessionLanguageOverride = nil
|
|
|
+ // Older builds persisted the picker choice; always follow macOS on a fresh launch.
|
|
|
+ UserDefaults.standard.removeObject(forKey: legacyDefaultsKey)
|
|
|
+ refreshCachedSystemLanguageCode()
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func refreshCachedSystemLanguageCode() {
|
|
|
+ cachedSystemLanguageCode = bestMatchingAvailableLanguageCode()
|
|
|
+ ?? availableLanguageCodes.first
|
|
|
+ ?? "en"
|
|
|
}
|
|
|
|
|
|
static var isRightToLeft: Bool {
|
|
|
@@ -262,8 +311,9 @@ enum AppLanguageManager {
|
|
|
static func setLanguage(code: String?) {
|
|
|
let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
let cleaned = (normalized?.isEmpty == false) ? normalized : nil
|
|
|
- guard cleaned != selectedLanguageCode else { return }
|
|
|
- selectedLanguageCode = cleaned
|
|
|
+ let resolved = cleaned.map { resolvedBundleLanguageCode(for: $0) }
|
|
|
+ guard resolved != sessionLanguageOverride else { return }
|
|
|
+ sessionLanguageOverride = resolved
|
|
|
applyGlobalLayoutDirection()
|
|
|
NotificationCenter.default.post(name: .appLanguageDidChange, object: nil)
|
|
|
}
|