// // KBLocalizationManager.m // 多语言管理实现 // #import "KBLocalizationManager.h" #import #import "KBConfig.h" // Darwin 跨进程通知:语言变更(App <-> 扩展) static NSString * const kKBDarwinLocalizationChanged = @"com.loveKey.nyx.loc.changed"; /// 语言常量定义 KBLanguageCode const KBLanguageCodeEnglish = @"en"; KBLanguageCode const KBLanguageCodeTraditionalChinese = @"zh-Hant"; KBLanguageCode const KBLanguageCodeSpanish = @"es"; KBLanguageCode const KBLanguageCodePortuguese = @"pt-PT"; KBLanguageCode const KBLanguageCodeIndonesian = @"id"; /// 默认支持语言列表(集中配置) NSArray *KBDefaultSupportedLanguageCodes(void) { static NSArray *codes; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ codes = @[ KBLanguageCodeEnglish, KBLanguageCodeSpanish, KBLanguageCodeIndonesian, KBLanguageCodePortuguese, KBLanguageCodeTraditionalChinese ]; }); return codes; } /// 语言变更通知名称 NSString * const KBLocalizationDidChangeNotification = @"KBLocalizationDidChangeNotification"; // 通过共享钥匙串跨 Target 持久化语言选择 static NSString * const kKBLocService = @"com.loveKey.nyx.loc"; static NSString * const kKBLocAccount = @"lang"; // 保存 UTF8 的语言代码 // 首次安装/首次启动标记(仅主 App 侧使用标准 UserDefaults;卸载后会被清除,用于抵消 Keychain 在卸载重装后仍保留的问题) static NSString * const kKBLocDidLaunchKey = @"com.loveKey.nyx.loc.did_launch"; // 主 App 启动标记(存 App Group,键盘扩展可读):用于“扩展先启动”场景下避免读取卸载重装遗留的 Keychain 语言。 static NSString * const kKBLocAppBootstrappedKey = @"com.loveKey.nyx.loc.app_bootstrapped"; // 用户是否在 App 内手动选择过语言(存 App Group;卸载重装会清理,满足“重装后跟随系统”) static NSString * const kKBLocUserSelectedLanguageKey = @"com.loveKey.nyx.loc.user_selected_language"; @interface KBLocalizationManager () @property (nonatomic, copy, readwrite) KBLanguageCode currentLanguageCode; // 当前语言代码 @property (nonatomic, strong) NSBundle *langBundle; // 对应语言的 .lproj 资源包 - (NSString *)normalizeLanguageCode:(NSString *)code; - (NSString *)supportedLanguageCodeForCandidate:(nullable NSString *)code; - (void)kb_onSystemLocaleMaybeChanged:(NSNotification *)note; @end // 避免 +shared 初始化阶段递归触发自身: // 这里提供一个 C 级别的工具函数,构建钥匙串查询,不依赖实例或 +shared。 static inline NSMutableDictionary *KBLocBaseKCQuery(void) { NSMutableDictionary *q = [@{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, (__bridge id)kSecAttrService: kKBLocService, (__bridge id)kSecAttrAccount: kKBLocAccount } mutableCopy]; if (KB_KEYCHAIN_ACCESS_GROUP.length > 0) { q[(__bridge id)kSecAttrAccessGroup] = KB_KEYCHAIN_ACCESS_GROUP; } return q; } static void KBLocDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { // 语言更新后,扩展侧需要主动 reload;App 侧收到也无害(会被 resolved==current short-circuit)。 dispatch_async(dispatch_get_main_queue(), ^{ KBLocalizationManager *m = [KBLocalizationManager shared]; [m reloadFromSharedStorageIfNeeded]; }); } @implementation KBLocalizationManager /// 是否运行在 App Extension(如键盘扩展)里。 /// 说明:扩展与主 App 共享 Keychain,但不应参与“首次启动”判定,避免扩展抢先写入标记导致主 App 不再清理历史 Keychain。 + (BOOL)kb_isRunningInAppExtension { return ([[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSExtension"] != nil); } + (NSUserDefaults *)kb_appGroupUserDefaults { NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; return ud ?: [NSUserDefaults standardUserDefaults]; } + (BOOL)kb_isAppBootstrapped { // App Group 在卸载重装后会被清理;扩展通过该标记判断“主 App 是否已在本次安装中启动过”。 return [[self kb_appGroupUserDefaults] boolForKey:kKBLocAppBootstrappedKey]; } + (void)kb_markAppBootstrappedIfNeeded { NSUserDefaults *ud = [self kb_appGroupUserDefaults]; if ([ud boolForKey:kKBLocAppBootstrappedKey]) { return; } [ud setBool:YES forKey:kKBLocAppBootstrappedKey]; [ud synchronize]; } + (BOOL)kb_userDidSelectLanguage { NSUserDefaults *ud = [self kb_appGroupUserDefaults]; return [ud boolForKey:kKBLocUserSelectedLanguageKey]; } + (void)kb_setUserDidSelectLanguage:(BOOL)selected { NSUserDefaults *ud = [self kb_appGroupUserDefaults]; [ud setBool:selected forKey:kKBLocUserSelectedLanguageKey]; [ud synchronize]; } + (instancetype)shared { static KBLocalizationManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBLocalizationManager new]; // 默认支持语言;可在启动时由外部重置 m.supportedLanguageCodes = KBDefaultSupportedLanguageCodes(); BOOL isAppExtension = [[self class] kb_isRunningInAppExtension]; BOOL wasAppBootstrapped = [[self class] kb_isAppBootstrapped]; BOOL isAppBootstrapped = wasAppBootstrapped; if (!isAppExtension) { // 仅主 App 写入该标记;扩展只读取 [[self class] kb_markAppBootstrappedIfNeeded]; isAppBootstrapped = YES; } // 新安装判定(优先使用 App Group 标记):卸载重装后 App Group 会被清理,但 Keychain 可能仍残留。 // 仅主 App 在检测到“本次安装从未启动过”时清理 Keychain,并写入系统择优结果,供扩展读取。 BOOL isFreshInstall = (!isAppExtension && !wasAppBootstrapped); if (isFreshInstall) { [[self class] kc_write:nil]; // 新安装:确保“未手动选择”状态(App Group 卸载重装会清理,这里再兜底一次) [[self class] kb_setUserDidSelectLanguage:NO]; } // 兼容旧逻辑:仍保留标准 UserDefaults 的首次启动标记(仅主 App),但不再作为关键判定条件。 BOOL isFirstLaunch = (!isAppExtension) ? [[self class] kb_markLaunchedAndReturnIsFirstLaunch] : NO; // 语言选择策略(满足产品规则): // 1) 用户在 App 内手动选过语言 -> 锁定(不跟随系统);通过 Keychain 跨 Target 同步 // 2) 用户未手动选择过 -> 跟随系统当前语言(首选列表第一个),不读取/不写入 Keychain BOOL userSelected = [[self class] kb_userDidSelectLanguage]; NSString *saved = nil; if (userSelected) { // 扩展在“主 App 尚未启动过”的场景下,忽略 Keychain(防止卸载重装遗留旧值在扩展先启动时生效) if (!(isAppExtension && !isAppBootstrapped)) { saved = (isFreshInstall ? nil : [[self class] kc_read]); } } else { // 未手动选择:清理可能遗留的 Keychain(例如旧版本曾写入“系统默认”导致后续不再跟随系统) if (!isAppExtension) { NSString *legacy = [[self class] kc_read]; if (legacy.length > 0) { [[self class] kc_write:nil]; } } } #if DEBUG NSLog(@"[KBLoc] init bundle=%@ isExt=%d appBoot=%d wasBoot=%d fresh=%d firstLaunch=%d userSelected=%d preferred=%@ keychainSaved=%@ supported=%@", NSBundle.mainBundle.bundleIdentifier ?: @"", isAppExtension, isAppBootstrapped, wasAppBootstrapped, isFreshInstall, isFirstLaunch, userSelected, [NSLocale preferredLanguages], saved ?: @"(nil)", m.supportedLanguageCodes ?: @[]); #endif // 计算最终语言:优先手动选择;否则跟随系统当前语言(不在 5 种内则英文) NSString *resolved = [m supportedLanguageCodeForCandidate:saved]; if (saved.length > 0 && resolved.length == 0) { // 若保存了不支持的语言码(历史遗留),视为“未手动选择”,清理并回退系统 [[self class] kc_write:nil]; [[self class] kb_setUserDidSelectLanguage:NO]; saved = nil; resolved = @""; } if (resolved.length == 0) { resolved = [m bestSupportedLanguageForPreferred:[NSLocale preferredLanguages]] ?: KBLanguageCodeEnglish; } [m applyLanguage:resolved]; #if DEBUG NSLog(@"[KBLoc] resolved=%@ current=%@ bundle=%@", resolved ?: @"", m.currentLanguageCode ?: @"", m.langBundle.bundlePath ?: @""); #endif // 监听 Darwin 跨进程通知(语言变更),用于“App 内切语言 -> 立即刷新键盘”。 CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(m), KBLocDarwinCallback, (__bridge CFStringRef)kKBDarwinLocalizationChanged, NULL, CFNotificationSuspensionBehaviorDeliverImmediately); // 系统语言/地区变化:如果未手动锁定语言,则跟随系统刷新 [[NSNotificationCenter defaultCenter] addObserver:m selector:@selector(kb_onSystemLocaleMaybeChanged:) name:NSCurrentLocaleDidChangeNotification object:nil]; }); return m; } #pragma mark - 对外 API - (void)setSupportedLanguageCodes:(NSArray *)supportedLanguageCodes { // 归一化:去重、去空 NSMutableOrderedSet *set = [NSMutableOrderedSet orderedSet]; for (NSString *c in supportedLanguageCodes) { if (c.length) { [set addObject:c]; } } _supportedLanguageCodes = set.array.count ? (NSArray *)set.array : @[ KBLanguageCodeEnglish ]; // 若当前语言不再受支持,则按最佳匹配切回(不持久化,仅内存),并广播变更 if (self.currentLanguageCode.length && ![set containsObject:self.currentLanguageCode]) { NSString *best = [self bestSupportedLanguageForPreferred:@[self.currentLanguageCode]]; [self applyLanguage:best ?: _supportedLanguageCodes.firstObject]; [[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil]; } } - (void)setCurrentLanguageCode:(NSString *)code persist:(BOOL)persist { if (code.length == 0) return; // 忽略空值 // 仅允许切到“受支持语言”;不支持(如 zh-Hans)时按最佳匹配回退(默认 en) NSString *resolved = [self supportedLanguageCodeForCandidate:code]; if (resolved.length == 0) { resolved = [self bestSupportedLanguageForPreferred:@[code]] ?: (self.supportedLanguageCodes.firstObject ?: KBLanguageCodeEnglish); } if ([resolved isEqualToString:self.currentLanguageCode]) return; // 无变更 [self applyLanguage:resolved]; if (persist) { [[self class] kb_setUserDidSelectLanguage:YES]; [[self class] kc_write:self.currentLanguageCode]; // 需同步到 App/扩展 // 通知对端进程刷新语言(特别是键盘扩展正在显示时) CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinLocalizationChanged, NULL, NULL, true); } [[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil]; } - (void)resetToSystemLanguage { [[self class] kb_setUserDidSelectLanguage:NO]; [[self class] kc_write:nil]; NSString *best = [self bestSupportedLanguageForPreferred:[NSLocale preferredLanguages]] ?: KBLanguageCodeEnglish; if ([best isEqualToString:self.currentLanguageCode]) { return; } [self applyLanguage:best]; // 通知对端进程刷新语言(特别是键盘扩展正在显示时) CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinLocalizationChanged, NULL, NULL, true); [[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil]; } - (NSString *)localizedStringForKey:(NSString *)key { return [self localizedStringForKey:key table:nil value:key]; } - (NSString *)localizedStringForKey:(NSString *)key table:(NSString *)table value:(NSString *)value { if (key.length == 0) return @""; NSBundle *bundle = self.langBundle ?: NSBundle.mainBundle; NSString *tbl = table ?: @"Localizable"; // 使用 bundle API,避免 NSLocalizedString 被系统语言钉死 NSString *str = [bundle localizedStringForKey:key value:value table:tbl]; return str ?: (value ?: key); } - (NSString *)bestSupportedLanguageForPreferred:(NSArray *)preferred { if (self.supportedLanguageCodes.count == 0) return KBLanguageCodeEnglish; // 需求:只使用“系统当前语言”(首选语言列表的第一个)。 // 若当前语言不在项目支持的 5 种语言内,则回退英文;不要从后续备选语言里再挑一个支持语言。 NSString *primary = preferred.firstObject ?: @""; if (primary.length == 0) { for (NSString *s in self.supportedLanguageCodes) { if ([s.lowercaseString isEqualToString:@"en"]) { return s; } } return self.supportedLanguageCodes.firstObject ?: KBLanguageCodeEnglish; } NSArray *primaryOnly = @[ primary ]; // 1) 完全匹配 for (NSString *p in primaryOnly) { NSString *pLC = p.lowercaseString; for (NSString *s in self.supportedLanguageCodes) { if ([pLC isEqualToString:s.lowercaseString]) { return s; } } } // 2) 前缀匹配:如 en-GB -> en, es-MX -> es for (NSString *p in primaryOnly) { NSString *pLC = p.lowercaseString; for (NSString *s in self.supportedLanguageCodes) { NSString *sLC = s.lowercaseString; if ([pLC hasPrefix:[sLC stringByAppendingString:@"-"]] || [pLC hasPrefix:[sLC stringByAppendingString:@"_"]]) { return s; } // also allow reverse: when supported is regional (rare) if ([sLC hasPrefix:[pLC stringByAppendingString:@"-"]] || [sLC hasPrefix:[pLC stringByAppendingString:@"_"]]) { return s; } } } // 2.5) 特殊处理葡语:pt-BR / pt-PT / pt -> pt-PT(若受支持) for (NSString *p in primaryOnly) { NSString *pLC = p.lowercaseString; if ([pLC isEqualToString:@"pt"] || [pLC hasPrefix:@"pt-"] || [pLC hasPrefix:@"pt_"]) { for (NSString *s in self.supportedLanguageCodes) { if ([s.lowercaseString isEqualToString:@"pt-pt"]) { return s; } } } } // 3) 特殊处理中文:将 zh-Hant/zh-TW/zh-HK 映射到 zh-Hant(若受支持) for (NSString *p in primaryOnly) { NSString *pLC = p.lowercaseString; if ([pLC hasPrefix:@"zh-hant"] || [pLC hasPrefix:@"zh-tw"] || [pLC hasPrefix:@"zh-hk"]) { for (NSString *s in self.supportedLanguageCodes) { if ([s.lowercaseString isEqualToString:@"zh-hant"]) { return s; } } } } // 4) 兜底:若英文受支持则固定回退英文,否则取第一个 for (NSString *s in self.supportedLanguageCodes) { if ([s.lowercaseString isEqualToString:@"en"]) { return s; } } return self.supportedLanguageCodes.firstObject ?: KBLanguageCodeEnglish; } - (NSString *)currentLanguageHeaderValue { return self.currentLanguageCode ?: KBLanguageCodeEnglish; } #pragma mark - 内部实现 - (NSString *)normalizeLanguageCode:(NSString *)code { if (code.length == 0) return @""; NSString *normalizedCode = code; NSString *lower = normalizedCode.lowercaseString; if ([lower isEqualToString:@"pt"] || [lower hasPrefix:@"pt-"] || [lower hasPrefix:@"pt_"]) { normalizedCode = @"pt-PT"; } lower = normalizedCode.lowercaseString; if ([lower hasPrefix:@"zh-hant"] || [lower hasPrefix:@"zh_hant"] || [lower hasPrefix:@"zh-tw"] || [lower hasPrefix:@"zh_hk"]) { normalizedCode = KBLanguageCodeTraditionalChinese; } return normalizedCode; } - (NSString *)supportedLanguageCodeForCandidate:(NSString *)code { NSString *normalized = [self normalizeLanguageCode:code]; if (normalized.length == 0) return @""; for (NSString *s in self.supportedLanguageCodes) { if ([s.lowercaseString isEqualToString:normalized.lowercaseString]) { return s; // 返回“工程内规范码”(保留大小写/连字符风格) } } return @""; } - (void)applyLanguage:(NSString *)code { NSString *normalizedCode = [self normalizeLanguageCode:code]; _currentLanguageCode = [normalizedCode copy]; // 基于当前 Target(App 或扩展)的主 bundle 加载 .lproj 资源 NSString *path = [NSBundle.mainBundle pathForResource:normalizedCode ofType:@"lproj"]; if (!path) { // 尝试去区域后缀:如 en-GB -> en NSString *shortCode = [[normalizedCode componentsSeparatedByCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"-_"]] firstObject]; if (shortCode.length > 0) { path = [NSBundle.mainBundle pathForResource:shortCode ofType:@"lproj"]; } } if (path) { self.langBundle = [NSBundle bundleWithPath:path]; } else { self.langBundle = NSBundle.mainBundle; // 兜底 } } #pragma mark - 钥匙串读写(App/扩展共享) + (BOOL)kb_markLaunchedAndReturnIsFirstLaunch { // 注意:这里只能用主 App 自己的标准 UserDefaults。 // 不要用 App Group,否则键盘扩展可能会抢先写入“已启动”标记,导致主 App 无法在首次启动时清理历史 Keychain。 NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; if ([ud boolForKey:kKBLocDidLaunchKey]) { return NO; } [ud setBool:YES forKey:kKBLocDidLaunchKey]; [ud synchronize]; return YES; } + (BOOL)kc_write:(NSString *)lang { NSMutableDictionary *q = KBLocBaseKCQuery(); SecItemDelete((__bridge CFDictionaryRef)q); if (lang.length == 0) return YES; // 等价于删除 NSData *data = [lang dataUsingEncoding:NSUTF8StringEncoding]; q[(__bridge id)kSecValueData] = data ?: [NSData data]; q[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; OSStatus st = SecItemAdd((__bridge CFDictionaryRef)q, NULL); return (st == errSecSuccess); } + (NSString *)kc_read { NSMutableDictionary *q = KBLocBaseKCQuery(); q[(__bridge id)kSecReturnData] = @YES; q[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne; CFTypeRef dataRef = NULL; OSStatus st = SecItemCopyMatching((__bridge CFDictionaryRef)q, &dataRef); if (st != errSecSuccess || !dataRef) return nil; NSData *data = (__bridge_transfer NSData *)dataRef; if (data.length == 0) return nil; NSString *lang = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; return lang; } - (void)kb_onSystemLocaleMaybeChanged:(NSNotification *)note { // 只有未手动选择语言时才需要跟随系统变化;手动选择情况下 reload 会优先读取保存值,不会被系统变化影响。 [self reloadFromSharedStorageIfNeeded]; } - (void)reloadFromSharedStorageIfNeeded { BOOL userSelected = [[self class] kb_userDidSelectLanguage]; BOOL isAppExtension = [[self class] kb_isRunningInAppExtension]; BOOL isAppBootstrapped = [[self class] kb_isAppBootstrapped]; NSString *target = @""; if (userSelected) { // 扩展在主 App 未启动前:不读取 Keychain(可能遗留旧值),保持当前按系统择优的结果。 if (isAppExtension && !isAppBootstrapped) { #if DEBUG NSLog(@"[KBLoc] reload skip (app not bootstrapped yet) bundle=%@ current=%@", NSBundle.mainBundle.bundleIdentifier ?: @"", self.currentLanguageCode ?: @""); #endif return; } NSString *saved = [[self class] kc_read]; if (saved.length == 0) { // 标记为手动选择但无保存值:降级为“跟随系统” [[self class] kb_setUserDidSelectLanguage:NO]; } else { NSString *resolved = [self supportedLanguageCodeForCandidate:saved]; if (resolved.length == 0) { // 保存了不支持的语言码(历史遗留):清理并降级为“跟随系统” [[self class] kc_write:nil]; [[self class] kb_setUserDidSelectLanguage:NO]; } else { target = resolved; } } } if (target.length == 0) { // 未手动选择:始终按系统当前语言(首选列表第一个)择优 target = [self bestSupportedLanguageForPreferred:[NSLocale preferredLanguages]] ?: KBLanguageCodeEnglish; } if ([target isEqualToString:self.currentLanguageCode]) { return; } [self applyLanguage:target]; // 系统跟随模式:主 App 语言变化时需要通知键盘扩展即时刷新(避免键盘仍停留旧语言)。 if (!userSelected && !isAppExtension) { CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinLocalizationChanged, NULL, NULL, true); } [[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil]; } @end