diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index 353a9ae..69e0602 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -16,6 +16,7 @@ #import "KBKeyBoardMainView.h" #import "KBKeyboardSubscriptionView.h" #import "KBLocalizationManager.h" +#import "KBSettingView.h" #import "KBSkinManager.h" #import "KBSkinInstallBridge.h" #import "KBSuggestionEngine.h" @@ -96,6 +97,19 @@ static NSString *KBFormatMB(uint64_t bytes) { } [self kb_applyTheme]; }]; + + // 语言变化时,重建键盘 UI(保证“App 语言=键盘语言”,并支持 App 内切换语言后键盘即时刷新) + self.kb_localizationObserverToken = [[NSNotificationCenter defaultCenter] + addObserverForName:KBLocalizationDidChangeNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(__unused NSNotification *_Nonnull note) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + [self kb_reloadUIForLocalizationChange]; + }]; [self kb_applyTheme]; [self kb_registerDarwinSkinInstallObserver]; [self kb_consumePendingShopSkin]; @@ -239,6 +253,11 @@ static NSString *KBFormatMB(uint64_t bytes) { removeObserver:self.kb_skinObserverToken]; self.kb_skinObserverToken = nil; } + if (self.kb_localizationObserverToken) { + [[NSNotificationCenter defaultCenter] + removeObserver:self.kb_localizationObserverToken]; + self.kb_localizationObserverToken = nil; + } [self kb_stopObservingAppGroupChanges]; [self kb_unregisterDarwinSkinInstallObserver]; #if DEBUG @@ -250,6 +269,57 @@ static NSString *KBFormatMB(uint64_t bytes) { #endif } +#pragma mark - Localization + +- (void)kb_reloadUIForLocalizationChange { + if (![NSThread isMainThread]) { + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf kb_reloadUIForLocalizationChange]; + }); + return; + } + + // 记录当前面板状态,重建后尽量恢复。 + KBKeyboardPanelMode targetMode = self.kb_panelMode; + // 强制下次布局刷新:即使 profileId 未变,也需要让新建的主视图应用一次当前 profile。 + _kb_lastLoadedProfileId = nil; + + // 主键盘/面板里有大量静态文案(init 时设置),语言变化后需要重建才能刷新。 + if (_keyBoardMainView) { + [_keyBoardMainView removeFromSuperview]; + _keyBoardMainView = nil; + } + self.keyBoardMainHeightConstraint = nil; + + if (_functionView) { + [_functionView removeFromSuperview]; + _functionView = nil; + } + if (_settingView) { + [_settingView removeFromSuperview]; + _settingView = nil; + } + if (_subscriptionView) { + [_subscriptionView removeFromSuperview]; + _subscriptionView = nil; + } + if (_chatPanelView) { + [_chatPanelView removeFromSuperview]; + _chatPanelView = nil; + } + self.chatPanelVisible = NO; + self.chatPanelHeightConstraint = nil; + + // 强制触发面板刷新:先回到 Main,再切回目标面板(避免 kb_setPanelMode 直接 return)。 + self.kb_panelMode = KBKeyboardPanelModeMain; + [self kb_setPanelMode:targetMode animated:NO]; + // 语言变化后,键盘布局/profile 也可能需要同步更新(未手动选择键盘配置时会随 App 语言变化) + [self kb_checkAndApplyLayoutIfNeeded]; + [KBHUD setContainerView:self.view]; + [self kb_applyTheme]; +} + #pragma mark - Layout Switching - (void)kb_checkAndApplyLayoutIfNeeded { diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h index 2efebb2..30f388a 100644 --- a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h @@ -68,6 +68,7 @@ typedef NS_ENUM(NSInteger, KBKeyboardPanelMode) { NSString *_chatPanelBaselineText; id _kb_fullAccessObserverToken; id _kb_skinObserverToken; + id _kb_localizationObserverToken; KBKeyboardPanelMode _kb_panelMode; } @@ -104,6 +105,7 @@ typedef NS_ENUM(NSInteger, KBKeyboardPanelMode) { @property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本 @property(nonatomic, strong, nullable) id kb_fullAccessObserverToken; @property(nonatomic, strong, nullable) id kb_skinObserverToken; +@property(nonatomic, strong, nullable) id kb_localizationObserverToken; @property(nonatomic, assign) KBKeyboardPanelMode kb_panelMode; @property(nonatomic, strong, nullable) id kb_appGroupObserverToken; diff --git a/CustomKeyboard/Manager/KBKeyboardLayoutResolver.m b/CustomKeyboard/Manager/KBKeyboardLayoutResolver.m index 581c5fd..d082f44 100644 --- a/CustomKeyboard/Manager/KBKeyboardLayoutResolver.m +++ b/CustomKeyboard/Manager/KBKeyboardLayoutResolver.m @@ -6,6 +6,7 @@ #import "KBKeyboardLayoutResolver.h" #import "KBInputProfileManager.h" #import "KBConfig.h" +#import "KBLocalizationManager.h" @implementation KBKeyboardLayoutResolver @@ -18,6 +19,30 @@ return instance; } +/// 未手动选择键盘输入配置时,根据当前 App 语言推导默认键盘语言码(对应 kb_input_profiles.json 的 code)。 +- (NSString *)kb_defaultKeyboardLanguageCodeForAppLanguageCode:(NSString *)appLanguageCode { + NSString *lc = (appLanguageCode ?: @"").lowercaseString; + if ([lc hasPrefix:@"es"]) { return @"es"; } + if ([lc hasPrefix:@"pt"]) { return @"pt"; } + if ([lc hasPrefix:@"id"]) { return @"id"; } + if ([lc hasPrefix:@"zh-hant"]) { return @"zh-Hant-Pinyin"; } + return @"en"; +} + +- (BOOL)kb_didUserSelectKeyboardProfileInAppGroup:(NSUserDefaults *)appGroup { + return [appGroup boolForKey:AppGroup_DidUserSelectKeyboardProfile]; +} + +- (nullable KBInputProfileLayout *)kb_defaultLayoutForCurrentAppLanguage { + NSString *appLang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish; + NSString *kbLang = [self kb_defaultKeyboardLanguageCodeForAppLanguageCode:appLang]; + KBInputProfile *profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:kbLang]; + if (!profile) { + profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:@"en"]; + } + return profile.layouts.firstObject; +} + - (NSString *)layoutJsonIdForProfileId:(NSString *)profileId { if (profileId.length == 0) { return @"letters"; @@ -51,19 +76,31 @@ - (nullable NSString *)currentProfileId { NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; NSString *profileId = [appGroup stringForKey:AppGroup_SelectedKeyboardProfileId]; - return profileId; + if ([self kb_didUserSelectKeyboardProfileInAppGroup:appGroup]) { + return profileId; + } + KBInputProfileLayout *layout = [self kb_defaultLayoutForCurrentAppLanguage]; + return layout.profileId.length > 0 ? layout.profileId : profileId; } - (nullable NSString *)currentLanguageCode { NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; NSString *languageCode = [appGroup stringForKey:AppGroup_SelectedKeyboardLanguageCode]; - return languageCode; + if ([self kb_didUserSelectKeyboardProfileInAppGroup:appGroup]) { + return languageCode; + } + NSString *appLang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish; + return [self kb_defaultKeyboardLanguageCodeForAppLanguageCode:appLang]; } - (nullable NSString *)currentLayoutVariant { NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; NSString *layoutVariant = [appGroup stringForKey:AppGroup_SelectedKeyboardLayoutVariant]; - return layoutVariant; + if ([self kb_didUserSelectKeyboardProfileInAppGroup:appGroup]) { + return layoutVariant; + } + KBInputProfileLayout *layout = [self kb_defaultLayoutForCurrentAppLanguage]; + return layout.variant.length > 0 ? layout.variant : layoutVariant; } @end diff --git a/CustomKeyboard/Resource/kb_keyboard_layout_config.json b/CustomKeyboard/Resource/kb_keyboard_layout_config.json index 34a5be2..dd957f8 100644 --- a/CustomKeyboard/Resource/kb_keyboard_layout_config.json +++ b/CustomKeyboard/Resource/kb_keyboard_layout_config.json @@ -843,7 +843,7 @@ "align": "left", "insetLeft": 4, "insetRight": 4, - "gap": 5, + "gap": 6, "items": [ "letter:ㄅ", "letter:ㄉ", "letter:ˇ", "letter:ˋ", "letter:ㄓ", "letter:ˊ", "letter:˙", "letter:ㄚ", "letter:ㄞ", "letter:ㄢ", "letter:ㄦ" diff --git a/Shared/KBConfig.h b/Shared/KBConfig.h index 1364bf4..4541a94 100644 --- a/Shared/KBConfig.h +++ b/Shared/KBConfig.h @@ -38,6 +38,11 @@ #define AppGroup_SelectedKeyboardLanguageCode @"AppGroup_SelectedKeyboardLanguageCode" #define AppGroup_SelectedKeyboardLayoutVariant @"AppGroup_SelectedKeyboardLayoutVariant" +/// 是否用户手动选择过键盘输入配置(语言/布局/profile)。 +/// YES:主 App 不再自动把键盘配置跟随 App 语言变更(避免覆盖用户手选)。 +/// NO :主 App 会在“App 语言变化且未手动选择键盘配置”时同步更新键盘配置,使键盘语言/布局与 App 一致。 +#define AppGroup_DidUserSelectKeyboardProfile @"AppGroup_DidUserSelectKeyboardProfile" + /// Darwin 跨进程通知:键盘扩展发送聊天消息后通知主 App 刷新 #define kKBDarwinChatUpdated @"com.loveKey.nyx.chat.updated" diff --git a/Shared/KBLocalizationManager.m b/Shared/KBLocalizationManager.m index 92e8115..07be3a9 100644 --- a/Shared/KBLocalizationManager.m +++ b/Shared/KBLocalizationManager.m @@ -7,6 +7,9 @@ #import #import "KBConfig.h" +// Darwin 跨进程通知:语言变更(App <-> 扩展) +static NSString * const kKBDarwinLocalizationChanged = @"com.loveKey.nyx.loc.changed"; + /// 语言常量定义 KBLanguageCode const KBLanguageCodeEnglish = @"en"; KBLanguageCode const KBLanguageCodeTraditionalChinese = @"zh-Hant"; @@ -37,9 +40,20 @@ NSString * const KBLocalizationDidChangeNotification = @"KBLocalizationDidChange 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 初始化阶段递归触发自身: @@ -54,19 +68,141 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { 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(); - // 启动读取:先取共享钥匙串,再按系统偏好回退 - NSString *saved = [[self class] kc_read]; - if (saved.length == 0) { - saved = [m bestSupportedLanguageForPreferred:[NSLocale preferredLanguages]] ?: KBLanguageCodeEnglish; + + BOOL isAppExtension = [[self class] kb_isRunningInAppExtension]; + BOOL wasAppBootstrapped = [[self class] kb_isAppBootstrapped]; + BOOL isAppBootstrapped = wasAppBootstrapped; + if (!isAppExtension) { + // 仅主 App 写入该标记;扩展只读取 + [[self class] kb_markAppBootstrappedIfNeeded]; + isAppBootstrapped = YES; } - [m applyLanguage:saved]; + // 新安装判定(优先使用 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; } @@ -90,15 +226,37 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { - (void)setCurrentLanguageCode:(NSString *)code persist:(BOOL)persist { if (code.length == 0) return; // 忽略空值 - if ([code isEqualToString:self.currentLanguageCode]) return; // 无变更 - [self applyLanguage:code]; - if (persist) { [[self class] kc_write:self.currentLanguageCode]; } // 需同步到 App/扩展 + // 仅允许切到“受支持语言”;不支持(如 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; - [self setCurrentLanguageCode:best persist:NO]; + 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 { @@ -116,15 +274,25 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { - (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 preferred) { + 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 preferred) { + for (NSString *p in primaryOnly) { NSString *pLC = p.lowercaseString; for (NSString *s in self.supportedLanguageCodes) { NSString *sLC = s.lowercaseString; @@ -138,7 +306,7 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { } } // 2.5) 特殊处理葡语:pt-BR / pt-PT / pt -> pt-PT(若受支持) - for (NSString *p in preferred) { + 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) { @@ -147,7 +315,7 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { } } // 3) 特殊处理中文:将 zh-Hant/zh-TW/zh-HK 映射到 zh-Hant(若受支持) - for (NSString *p in preferred) { + 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) { @@ -155,7 +323,10 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { } } } - // 4) 兜底:取第一个受支持语言 + // 4) 兜底:若英文受支持则固定回退英文,否则取第一个 + for (NSString *s in self.supportedLanguageCodes) { + if ([s.lowercaseString isEqualToString:@"en"]) { return s; } + } return self.supportedLanguageCodes.firstObject ?: KBLanguageCodeEnglish; } @@ -165,7 +336,8 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { #pragma mark - 内部实现 -- (void)applyLanguage:(NSString *)code { +- (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_"]) { @@ -175,6 +347,22 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { 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"]; @@ -194,6 +382,16 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { #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); @@ -217,14 +415,60 @@ static inline NSMutableDictionary *KBLocBaseKCQuery(void) { return lang; } -- (void)reloadFromSharedStorageIfNeeded { - NSString *saved = [[self class] kc_read]; - if (saved.length == 0) return; - if ([saved isEqualToString:self.currentLanguageCode]) return; +- (void)kb_onSystemLocaleMaybeChanged:(NSNotification *)note { + // 只有未手动选择语言时才需要跟随系统变化;手动选择情况下 reload 会优先读取保存值,不会被系统变化影响。 + [self reloadFromSharedStorageIfNeeded]; +} - [self applyLanguage:saved]; - [[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification - object:nil]; +- (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 diff --git a/Shared/KBSkinInstallBridge.m b/Shared/KBSkinInstallBridge.m index 780ed0b..4f10f28 100644 --- a/Shared/KBSkinInstallBridge.m +++ b/Shared/KBSkinInstallBridge.m @@ -1021,6 +1021,18 @@ static NSString * const kKBSkinMetadataThemeKey = @"theme_json"; targetSkinId, themeOK, (unsigned long)iconPathMap.count); return themeOK; } + + // 目标默认皮肤不存在:如果当前仍是“默认类皮肤”,为避免 key_icons 仍是旧语言导致看起来“还是繁体”,回退到无图标的默认主题。 + // 等主 App 后续安装/应用对应语言的默认皮肤后,会通过 KBSkinManager 的 Darwin 通知同步到扩展。 + if (!hasTargetSkin && isDefaultLike && + currentSkinId.length > 0 && + ![currentSkinId isEqualToString:@"default"] && + [currentSkinId hasPrefix:@"bundle_skin_default_"] && + ![currentSkinId isEqualToString:targetSkinId]) { + NSLog(@"[SkinBridge] reloadCurrentSkinIconMap: missing target skin, fallback to default theme (clear old icon map)"); + [[KBSkinManager shared] resetToDefault]; + return YES; + } // 如果目标皮肤不存在,尝试更新当前皮肤的图标映射 if (currentSkinId.length == 0 || [currentSkinId isEqualToString:@"default"]) { diff --git a/keyBoard/AppDelegate.m b/keyBoard/AppDelegate.m index 418e0c0..5201f08 100644 --- a/keyBoard/AppDelegate.m +++ b/keyBoard/AppDelegate.m @@ -25,8 +25,11 @@ #import "KBUserSessionManager.h" #import "KBLoginVC.h" #import "KBConfig.h" +#import "KBLocalizationManager.h" #import "KBSkinInstallBridge.h" +#import "KBSkinManager.h" #import "KBAppUpdateView.h" +#import "KBInputProfileManager.h" static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; @@ -46,6 +49,10 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { /// 1:配置国际化(统一使用集中管理的语言列表) [KBLocalizationManager shared].supportedLanguageCodes = KBDefaultSupportedLanguageCodes(); + /// 1.1:首次启动为键盘写入默认输入配置(语言/布局/profile),保证“App 语言=键盘语言” + [self kb_bootstrapDefaultKeyboardProfileIfNeeded]; + /// 1.2:未手动选择键盘输入配置时,让键盘配置随 App 语言变化 + [self kb_syncKeyboardProfileToCurrentAppLanguageIfNeeded]; /// 2 : 处理token问题(包括卸载重装场景下的 token 清理) [[KBUserSessionManager shared] bootstrapIfNeeded]; @@ -73,6 +80,12 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; // 安装默认皮肤(首次安装时) [self kb_installDefaultSkinIfNeeded]; + + // App 语言变化(系统跟随或手动切换)时,同步键盘配置并让键盘即时刷新 + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(kb_onLocalizationChanged:) + name:KBLocalizationDidChangeNotification + object:nil]; [self kb_requestAppUpdateAndPresentIfNeeded]; @@ -87,6 +100,119 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; return YES; } +- (NSString *)kb_defaultKeyboardLanguageCodeForAppLanguageCode:(NSString *)appLanguageCode { + NSString *lc = appLanguageCode.lowercaseString ?: @""; + if ([lc hasPrefix:@"es"]) { return @"es"; } + if ([lc hasPrefix:@"pt"]) { return @"pt"; } + if ([lc hasPrefix:@"id"]) { return @"id"; } + if ([lc hasPrefix:@"zh-hant"]) { return @"zh-Hant-Pinyin"; } + return @"en"; +} + +- (void)kb_bootstrapDefaultKeyboardProfileIfNeeded { + NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; + NSString *savedProfileId = [shared stringForKey:AppGroup_SelectedKeyboardProfileId]; + if (savedProfileId.length > 0) { + return; + } + + NSString *appLanguageCode = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish; + NSString *kbLanguageCode = [self kb_defaultKeyboardLanguageCodeForAppLanguageCode:appLanguageCode]; + KBInputProfile *profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:kbLanguageCode]; + if (!profile) { + profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:@"en"]; + } + KBInputProfileLayout *layout = profile.layouts.firstObject; + if (!layout) { + return; + } + + [shared setObject:(profile.code ?: @"en") forKey:AppGroup_SelectedKeyboardLanguageCode]; + [shared setObject:(layout.variant ?: @"qwerty") forKey:AppGroup_SelectedKeyboardLayoutVariant]; + [shared setObject:(layout.profileId ?: @"en_US_qwerty") forKey:AppGroup_SelectedKeyboardProfileId]; + [shared synchronize]; + + NSLog(@"[AppDelegate] Bootstrap keyboard profile: appLang=%@ kbLang=%@ profileId=%@ variant=%@", + appLanguageCode, profile.code, layout.profileId, layout.variant); +} + +- (void)kb_syncKeyboardProfileToCurrentAppLanguageIfNeeded { + NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; + if ([shared boolForKey:AppGroup_DidUserSelectKeyboardProfile]) { + return; + } + NSString *appLanguageCode = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish; + NSString *kbLanguageCode = [self kb_defaultKeyboardLanguageCodeForAppLanguageCode:appLanguageCode]; + + KBInputProfile *profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:kbLanguageCode]; + if (!profile) { + profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:@"en"]; + } + KBInputProfileLayout *layout = profile.layouts.firstObject; + if (!layout) { + return; + } + + NSString *curProfileId = [shared stringForKey:AppGroup_SelectedKeyboardProfileId] ?: @""; + NSString *curLang = [shared stringForKey:AppGroup_SelectedKeyboardLanguageCode] ?: @""; + NSString *curVariant = [shared stringForKey:AppGroup_SelectedKeyboardLayoutVariant] ?: @""; + if ([curProfileId isEqualToString:layout.profileId ?: @""] && + [curLang isEqualToString:profile.code ?: @""] && + [curVariant isEqualToString:layout.variant ?: @""]) { + return; + } + + [shared setObject:(profile.code ?: @"en") forKey:AppGroup_SelectedKeyboardLanguageCode]; + [shared setObject:(layout.variant ?: @"qwerty") forKey:AppGroup_SelectedKeyboardLayoutVariant]; + [shared setObject:(layout.profileId ?: @"en_US_qwerty") forKey:AppGroup_SelectedKeyboardProfileId]; + [shared synchronize]; + + NSLog(@"[AppDelegate] Sync keyboard profile to appLang=%@ kbLang=%@ profileId=%@ variant=%@", + appLanguageCode, profile.code, layout.profileId, layout.variant); + + // 同步完键盘语言后,如当前是默认皮肤,则让皮肤也跟随(避免 key_icons 仍是旧语言导致看起来“还是繁体”) + [self kb_applyDefaultSkinForKeyboardLanguageIfNeeded:profile.code ?: @"en"]; +} + +- (void)kb_onLocalizationChanged:(NSNotification *)note { + [self kb_syncKeyboardProfileToCurrentAppLanguageIfNeeded]; +} + +- (void)kb_applyDefaultSkinForKeyboardLanguageIfNeeded:(NSString *)languageCode { + NSString *lc = languageCode.length > 0 ? languageCode : @"en"; + NSString *targetSkinId = [NSString stringWithFormat:@"bundle_skin_default_%@", lc]; + NSString *currentSkinId = [KBSkinManager shared].current.skinId ?: @""; + BOOL isDefaultLike = (currentSkinId.length == 0 || + [currentSkinId isEqualToString:@"default"] || + [currentSkinId hasPrefix:@"bundle_skin_default_"]); + if (!isDefaultLike) { + return; + } + if ([currentSkinId isEqualToString:targetSkinId] && [KBSkinManager kb_hasAssetsForSkinId:targetSkinId]) { + return; + } + + KBInputProfile *profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:lc]; + NSString *zipName = profile.defaultSkinZip.length > 0 ? profile.defaultSkinZip : @"normal_them.zip"; + NSDictionary *iconShortNames = [KBSkinInstallBridge iconShortNamesForLanguageCode:lc]; + + NSLog(@"[AppDelegate] Applying default skin for keyboard language=%@ currentSkin=%@ targetSkin=%@ zip=%@", + lc, currentSkinId, targetSkinId, zipName); + + [KBSkinInstallBridge publishBundleSkinRequestWithId:targetSkinId + name:targetSkinId + zipName:zipName + iconShortNames:iconShortNames]; + [KBSkinInstallBridge consumePendingRequestFromBundle:[NSBundle mainBundle] + completion:^(BOOL success, NSError * _Nullable error) { + if (!success) { + NSLog(@"[AppDelegate] Apply default skin failed: %@", error); + } else { + NSLog(@"[AppDelegate] Apply default skin success: %@", targetSkinId); + } + }]; +} + - (void)kb_requestAppUpdateAndPresentIfNeeded { __weak typeof(self) weakSelf = self; [[KBLoginVM shared] checkAppUpdateWithCompletion:^(KBAppUpdateInfo * _Nullable info, NSError * _Nullable error) { @@ -545,36 +671,14 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; } - (void)kb_installDefaultSkinIfNeeded { - static NSString *const kKBDefaultSkinInstalledKey = @"KBDefaultSkinInstalled"; - NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; - - if ([ud boolForKey:kKBDefaultSkinInstalledKey]) { - NSLog(@"[AppDelegate] Default skin already installed, skip"); - return; + NSLog(@"[AppDelegate] Installing/applying default skin if needed..."); + NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; + NSString *languageCode = [shared stringForKey:AppGroup_SelectedKeyboardLanguageCode]; + if (languageCode.length == 0) { + NSString *appLanguageCode = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish; + languageCode = [self kb_defaultKeyboardLanguageCodeForAppLanguageCode:appLanguageCode]; } - - NSLog(@"[AppDelegate] Installing default skin for first launch..."); - - NSString *skinId = @"bundle_skin_default_en"; - NSString *zipName = @"normal_them.zip"; - - NSDictionary *iconShortNames = [KBSkinInstallBridge iconShortNamesForLanguageCode:@"en"]; - - [KBSkinInstallBridge publishBundleSkinRequestWithId:skinId - name:skinId - zipName:zipName - iconShortNames:iconShortNames]; - - [KBSkinInstallBridge consumePendingRequestFromBundle:[NSBundle mainBundle] - completion:^(BOOL success, NSError * _Nullable error) { - if (success) { - NSLog(@"[AppDelegate] Default skin installed successfully"); - [ud setBool:YES forKey:kKBDefaultSkinInstalledKey]; - [ud synchronize]; - } else { - NSLog(@"[AppDelegate] Default skin install failed: %@", error); - } - }]; + [self kb_applyDefaultSkinForKeyboardLanguageIfNeeded:languageCode ?: @"en"]; } @end diff --git a/keyBoard/Class/Me/VC/KBPersonInfoVC.m b/keyBoard/Class/Me/VC/KBPersonInfoVC.m index 58cb61f..45d8e73 100644 --- a/keyBoard/Class/Me/VC/KBPersonInfoVC.m +++ b/keyBoard/Class/Me/VC/KBPersonInfoVC.m @@ -630,6 +630,7 @@ typedef void(^KBInputProfileSelectHandler)(NSString *languageCode, NSString *lay [shared setObject:languageCode forKey:AppGroup_SelectedKeyboardLanguageCode]; [shared setObject:layoutVariant forKey:AppGroup_SelectedKeyboardLayoutVariant]; [shared setObject:profileId forKey:AppGroup_SelectedKeyboardProfileId]; + [shared setBool:YES forKey:AppGroup_DidUserSelectKeyboardProfile]; [shared synchronize]; [[KBLocalizationManager shared] setCurrentLanguageCode:languageCode persist:YES];