From 0ac47925fdad286bfcb52ac57e5fe7c0992ac8e8 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Tue, 24 Feb 2026 13:38:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=88=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CustomKeyboard/KeyboardViewController.m | 2041 +-------------- .../KeyboardViewController+Chat.m | 681 +++++ .../KeyboardViewController+Layout.m | 96 + .../KeyboardViewController+Legacy.m | 2238 +++++++++++++++++ .../KeyboardViewController+Panels.m | 623 +++++ .../KeyboardViewController+Private.h | 154 ++ .../KeyboardViewController+Subscription.m | 117 + .../KeyboardViewController+Suggestions.m | 178 ++ .../KeyboardViewController+Theme.m | 376 +++ .../KeyboardViewController+UI.m | 151 ++ keyBoard.xcodeproj/project.pbxproj | 48 +- 11 files changed, 4676 insertions(+), 2027 deletions(-) create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Chat.m create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Layout.m create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Panels.m create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Suggestions.m create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Theme.m create mode 100644 CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+UI.m diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index 9576f35..d3e776a 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -5,108 +5,25 @@ // Created by Mac on 2025/10/27. // -#import "KeyboardViewController.h" -#import "KBKeyBoardMainView.h" +#import "KeyboardViewController+Private.h" -#import "KBAuthManager.h" #import "KBBackspaceUndoManager.h" +#import "KBChatLimitPopView.h" +#import "KBChatPanelView.h" #import "KBFullAccessManager.h" #import "KBFunctionView.h" -#import "KBHostAppLauncher.h" #import "KBInputBufferManager.h" -#import "KBKey.h" -#import "KBKeyboardSubscriptionProduct.h" +#import "KBKeyBoardMainView.h" #import "KBKeyboardSubscriptionView.h" -#import "KBSettingView.h" -#import "KBChatMessage.h" -#import "KBChatPanelView.h" -#import "KBChatLimitPopView.h" -#import "KBSkinInstallBridge.h" +#import "KBLocalizationManager.h" #import "KBSkinManager.h" #import "KBSuggestionEngine.h" -#import "KBNetworkManager.h" -#import "KBVM.h" -#import "Masonry.h" -#import "UIImage+KBColor.h" -#import #import + #if DEBUG #import #endif -// #import "KBLog.h" - -// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin -// 方法。 -@interface KeyboardViewController (KBSkinShopBridge) -- (void)kb_consumePendingShopSkin; -@end - -// 以 375 宽设计稿为基准的键盘总高度 -static const CGFloat kKBKeyboardBaseHeight = 250.0f; -static const CGFloat kKBChatPanelHeight = 180; -static const NSUInteger kKBChatMessageLimit = 6; -static NSString *const kKBDefaultSkinIdLight = @"normal_them"; -static NSString *const kKBDefaultSkinZipNameLight = @"normal_them"; -static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them"; -static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them"; - -static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, - void *observer, CFStringRef name, - const void *object, - CFDictionaryRef userInfo) { - KeyboardViewController *strongSelf = - (__bridge KeyboardViewController *)observer; - if (!strongSelf) { - return; - } - dispatch_async(dispatch_get_main_queue(), ^{ - if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) { - [strongSelf kb_consumePendingShopSkin]; - } - }); -} - -@interface KeyboardViewController () -@property(nonatomic, strong) - UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选) -@property(nonatomic, strong) UIView *contentView; -@property(nonatomic, strong) KBKeyBoardMainView - *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示) -@property(nonatomic, strong) - KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示) -@property(nonatomic, strong) KBSettingView *settingView; // 设置页 -@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层) -@property(nonatomic, strong) KBChatPanelView *chatPanelView; -@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView; -@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine; -@property(nonatomic, copy) NSString *currentWord; -@property(nonatomic, assign) BOOL suppressSuggestions; -@property(nonatomic, strong) UIControl *chatLimitMaskView; -@property(nonatomic, strong) MASConstraint *contentWidthConstraint; -@property(nonatomic, strong) MASConstraint *contentHeightConstraint; -@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint; -@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint; -@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint; -@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint; -@property(nonatomic, assign) CGFloat kb_lastPortraitWidth; -@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight; -@property(nonatomic, strong) UIImage *kb_cachedGradientImage; -@property(nonatomic, assign) CGSize kb_cachedGradientSize; -@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer; -@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey; -@property(nonatomic, strong) NSMutableArray *chatMessages; -@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; -@property(nonatomic, assign) BOOL chatPanelVisible; -@property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本 -@property(nonatomic, strong, nullable) id kb_fullAccessObserverToken; -@property(nonatomic, strong, nullable) id kb_skinObserverToken; -@end - #if DEBUG static NSInteger sKBKeyboardVCAliveCount = 0; @@ -177,11 +94,7 @@ static NSString *KBFormatMB(uint64_t bytes) { [self kb_applyTheme]; }]; [self kb_applyTheme]; - CFNotificationCenterAddObserver( - CFNotificationCenterGetDarwinNotifyCenter(), - (__bridge const void *)(self), KBSkinInstallNotificationCallback, - (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL, - CFNotificationSuspensionBehaviorDeliverImmediately); + [self kb_registerDarwinSkinInstallObserver]; [self kb_consumePendingShopSkin]; [self kb_applyDefaultSkinIfNeeded]; } @@ -263,1506 +176,6 @@ static NSString *KBFormatMB(uint64_t bytes) { .documentContextAfterInput]; } -- (void)setupUI { - self.view.translatesAutoresizingMaskIntoConstraints = NO; - - // 按“短边”宽度等比缩放,横屏保持竖屏布局比例 - CGFloat portraitWidth = [self kb_portraitWidth]; - CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; - CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; - CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds); - - // FIX: iOS 26 键盘闪烁问题 - // iOS 26 在键盘滑入动画开始前,会对 self.view 做一次离屏预渲染快照(非实时 view), - // 该快照会短暂显示在屏幕中间。如果此时 view 已有完整高度和内容,用户就会看到 - // 键盘 UI 在屏幕中间闪现一帧,然后键盘才从底部正常滑入。 - // 解决方案:初始高度设为 0,让系统快照时无内容可渲染; - // 在 viewWillAppear: 中恢复正确高度,此时系统已准备好滑入动画。 - // (iOS 18 及更早版本无此预渲染机制,不受影响) - NSLayoutConstraint *h = - [self.view.heightAnchor constraintEqualToConstant:0]; - NSLayoutConstraint *w = - [self.view.widthAnchor constraintEqualToConstant:screenWidth]; - self.kb_heightConstraint = h; - self.kb_widthConstraint = w; - - h.priority = UILayoutPriorityRequired; - w.priority = UILayoutPriorityRequired; - [NSLayoutConstraint activateConstraints:@[ h, w ]]; - // 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突) - if ([self.view isKindOfClass:[UIInputView class]]) { - UIInputView *iv = (UIInputView *)self.view; - if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) { - iv.allowsSelfSizing = NO; - } - } - // 内容容器:横屏时保持竖屏宽度,居中显示 - [self.view addSubview:self.contentView]; - [self.contentView mas_makeConstraints:^(MASConstraintMaker *make) { - make.centerX.equalTo(self.view); - make.bottom.equalTo(self.view); - self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth); - self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight); - }]; - - // 背景图铺底(仅在内容容器内) - [self.contentView addSubview:self.bgImageView]; - [self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.contentView); - }]; - - [self.contentView addSubview:self.keyBoardMainView]; - [self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) { - make.left.right.equalTo(self.contentView); - make.bottom.equalTo(self.contentView); - self.keyBoardMainHeightConstraint = - make.height.mas_equalTo(keyboardBaseHeight); - }]; - - // 初始隐藏,避免布局完成前闪烁 - self.contentView.hidden = YES; -} - -#pragma mark - Private - -// MARK: - Suggestions - -- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text { - if (text.length == 0) { - return; - } - if ([self kb_isAlphabeticString:text]) { - NSString *current = self.currentWord ?: @""; - self.currentWord = [current stringByAppendingString:text]; - self.suppressSuggestions = NO; - [self kb_updateSuggestionsForCurrentWord]; - } else { - [self kb_clearCurrentWord]; - } -} - -- (void)kb_clearCurrentWord { - self.currentWord = @""; - [self.keyBoardMainView kb_setSuggestions:@[]]; - self.suppressSuggestions = NO; -} - -- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression { - dispatch_async(dispatch_get_main_queue(), ^{ - [self kb_refreshCurrentWordFromDocumentContextResetSuppression: - resetSuppression]; - }); -} - -- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression: - (BOOL)resetSuppression { - NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @""; - NSString *word = [self kb_extractTrailingWordFromContext:context]; - self.currentWord = word ?: @""; - if (resetSuppression) { - self.suppressSuggestions = NO; - } - [self kb_updateSuggestionsForCurrentWord]; -} - -- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context { - if (context.length == 0) { - return @""; - } - static NSCharacterSet *letters = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - letters = [NSCharacterSet - characterSetWithCharactersInString: - @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; - }); - - NSInteger idx = (NSInteger)context.length - 1; - while (idx >= 0) { - unichar ch = [context characterAtIndex:(NSUInteger)idx]; - if (![letters characterIsMember:ch]) { - break; - } - idx -= 1; - } - NSUInteger start = (NSUInteger)(idx + 1); - if (start >= context.length) { - return @""; - } - return [context substringFromIndex:start]; -} - -- (BOOL)kb_isAlphabeticString:(NSString *)text { - if (text.length == 0) { - return NO; - } - static NSCharacterSet *letters = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - letters = [NSCharacterSet - characterSetWithCharactersInString: - @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; - }); - for (NSUInteger i = 0; i < text.length; i++) { - if (![letters characterIsMember:[text characterAtIndex:i]]) { - return NO; - } - } - return YES; -} - -- (void)kb_updateSuggestionsForCurrentWord { - NSString *prefix = self.currentWord ?: @""; - if (prefix.length == 0) { - [self.keyBoardMainView kb_setSuggestions:@[]]; - return; - } - if (self.suppressSuggestions) { - [self.keyBoardMainView kb_setSuggestions:@[]]; - return; - } - NSArray *items = - [self.suggestionEngine suggestionsForPrefix:prefix limit:5]; - NSArray *cased = [self kb_applyCaseToSuggestions:items - prefix:prefix]; - [self.keyBoardMainView kb_setSuggestions:cased]; -} - -- (NSArray *)kb_applyCaseToSuggestions:(NSArray *)items - prefix:(NSString *)prefix { - if (items.count == 0 || prefix.length == 0) { - return items; - } - BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString]; - BOOL firstUpper = [[prefix substringToIndex:1] - isEqualToString:[[prefix substringToIndex:1] uppercaseString]]; - - if (!allUpper && !firstUpper) { - return items; - } - - NSMutableArray *result = - [NSMutableArray arrayWithCapacity:items.count]; - for (NSString *word in items) { - if (allUpper) { - [result addObject:word.uppercaseString]; - } else { - NSString *first = [[word substringToIndex:1] uppercaseString]; - NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @""; - [result addObject:[first stringByAppendingString:rest]]; - } - } - return result.copy; -} - -/// 切换显示功能面板/键盘主视图 -- (void)showFunctionPanel:(BOOL)show { - // 简单显隐切换,复用相同的布局区域 - if (show) { - [self showChatPanel:NO]; - [self kb_ensureFunctionViewIfNeeded]; - } - if (_functionView) { - _functionView.hidden = !show; - } else if (show) { - // ensure 后按理已存在;这里兜底一次,避免异常情况下状态不一致 - self.functionView.hidden = NO; - } - self.keyBoardMainView.hidden = show; - - if (show) { - [[KBMaiPointReporter sharedReporter] - reportPageExposureWithEventName:@"enter_keyboard_function_panel" - pageId:@"keyboard_function_panel" - extra:nil - completion:nil]; - [self hideSubscriptionPanel]; - } else { - [[KBMaiPointReporter sharedReporter] - reportPageExposureWithEventName:@"enter_keyboard_main_panel" - pageId:@"keyboard_main_panel" - extra:nil - completion:nil]; - } - - // 可选:把当前显示的视图置顶,避免层级遮挡 - if (show) { - if (_functionView) { - [self.contentView bringSubviewToFront:_functionView]; - } - } else { - [self.contentView bringSubviewToFront:self.keyBoardMainView]; - } -} - -/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出 -- (void)showSettingView:(BOOL)show { - if (show) { - [self showChatPanel:NO]; - [[KBMaiPointReporter sharedReporter] - reportPageExposureWithEventName:@"enter_keyboard_settings" - pageId:@"keyboard_settings" - extra:nil - completion:nil]; - KBSettingView *settingView = self.settingView; - if (!settingView.superview) { - settingView.hidden = YES; - [self.contentView addSubview:settingView]; - [settingView mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.contentView); - }]; - [settingView.backButton addTarget:self - action:@selector(onTapSettingsBack) - forControlEvents:UIControlEventTouchUpInside]; - } - [self.contentView bringSubviewToFront:settingView]; - // 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算 - [self.contentView layoutIfNeeded]; - CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); - if (w <= 0) { - w = CGRectGetWidth(self.contentView.bounds); - } - if (w <= 0) { - w = [self kb_portraitWidth]; - } - settingView.transform = CGAffineTransformMakeTranslation(w, 0); - settingView.hidden = NO; - [UIView animateWithDuration:0.25 - delay:0 - options:UIViewAnimationOptionCurveEaseOut - animations:^{ - settingView.transform = CGAffineTransformIdentity; - } - completion:nil]; - } else { - KBSettingView *settingView = self.settingView; - if (!settingView.superview || settingView.hidden) - return; - CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); - if (w <= 0) { - w = CGRectGetWidth(self.contentView.bounds); - } - if (w <= 0) { - w = [self kb_portraitWidth]; - } - [UIView animateWithDuration:0.22 - delay:0 - options:UIViewAnimationOptionCurveEaseIn - animations:^{ - settingView.transform = CGAffineTransformMakeTranslation(w, 0); - } - completion:^(BOOL finished) { - settingView.hidden = YES; - }]; - } -} - -/// 显示/隐藏聊天面板(覆盖整个键盘区域) -- (void)showChatPanel:(BOOL)show { - if (show == self.chatPanelVisible) { - return; - } - self.chatPanelVisible = show; - if (show) { - // 记录打开聊天面板时宿主输入框已有的文本,发送时只取新增部分 - [[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy]; - self.chatPanelBaselineText = [KBInputBufferManager shared].liveText ?: @""; - [self kb_ensureChatPanelViewIfNeeded]; - self.chatPanelView.hidden = NO; - self.chatPanelView.alpha = 0.0; - [self.contentView bringSubviewToFront:self.chatPanelView]; - if (_functionView) { - _functionView.hidden = YES; - } - [self hideSubscriptionPanel]; - [self showSettingView:NO]; - [UIView animateWithDuration:0.2 - delay:0 - options:UIViewAnimationOptionCurveEaseOut - animations:^{ - self.chatPanelView.alpha = 1.0; - } - completion:nil]; - } else { - // 从未创建过聊天面板时,直接返回,避免 show/hide 触发额外内存分配 - if (!_chatPanelView) { - [self kb_updateKeyboardLayoutIfNeeded]; - return; - } - [UIView animateWithDuration:0.18 - delay:0 - options:UIViewAnimationOptionCurveEaseIn - animations:^{ - self.chatPanelView.alpha = 0.0; - } - completion:^(BOOL finished) { - self.chatPanelView.hidden = YES; - }]; - } - [self kb_updateKeyboardLayoutIfNeeded]; -} - -// 延迟创建:仅在用户真正打开功能面板时才创建/布局,降低默认内存占用。 -- (void)kb_ensureFunctionViewIfNeeded { - if (_functionView && _functionView.superview) { - return; - } - KBFunctionView *v = self.functionView; - if (!v.superview) { - v.hidden = YES; - [self.contentView addSubview:v]; - [v mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.contentView); - }]; - } -} - -// 延迟创建:仅在用户打开聊天面板时才创建/布局。 -- (void)kb_ensureChatPanelViewIfNeeded { - if (_chatPanelView && _chatPanelView.superview) { - return; - } - CGFloat portraitWidth = [self kb_portraitWidth]; - CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; - KBChatPanelView *v = self.chatPanelView; - if (!v.superview) { - [self.contentView addSubview:v]; - [v mas_makeConstraints:^(MASConstraintMaker *make) { - make.left.right.equalTo(self.contentView); - make.bottom.equalTo(self.keyBoardMainView.mas_top); - self.chatPanelHeightConstraint = - make.height.mas_equalTo(chatPanelHeight); - }]; - v.hidden = YES; - } -} - -// 延迟创建:键盘主面板(按键区)在隐藏时会被释放;再次显示时需要重建。 -- (void)kb_ensureKeyBoardMainViewIfNeeded { - if (_keyBoardMainView && _keyBoardMainView.superview) { - return; - } - CGFloat portraitWidth = [self kb_portraitWidth]; - CGFloat keyboardBaseHeight = - [self kb_keyboardBaseHeightForWidth:portraitWidth]; - KBKeyBoardMainView *v = self.keyBoardMainView; - if (!v.superview) { - [self.contentView addSubview:v]; - [v mas_makeConstraints:^(MASConstraintMaker *make) { - make.left.right.equalTo(self.contentView); - make.bottom.equalTo(self.contentView); - self.keyBoardMainHeightConstraint = - make.height.mas_equalTo(keyboardBaseHeight); - }]; - } - [self.contentView bringSubviewToFront:v]; -} - -// 键盘隐藏时释放可重建资源(背景图/缓存/非必需面板),降低扩展内存峰值。 -- (void)kb_releaseMemoryWhenKeyboardHidden { - [KBHUD setContainerView:nil]; - self.bgImageView.image = nil; - self.kb_cachedGradientImage = nil; - [self.kb_defaultGradientLayer removeFromSuperlayer]; - self.kb_defaultGradientLayer = nil; - [[SDImageCache sharedImageCache] clearMemory]; - - // 聊天相关可能持有音频数据/临时文件,键盘隐藏时直接清空,避免累计占用。 - if (self.chatAudioPlayer) { - [self.chatAudioPlayer stop]; - self.chatAudioPlayer = nil; - } - if (_chatMessages.count > 0) { - NSString *tmpRoot = NSTemporaryDirectory(); - for (KBChatMessage *msg in _chatMessages.copy) { - if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 && - [msg.audioFilePath hasPrefix:tmpRoot]) { - [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath - error:nil]; - } - } - [_chatMessages removeAllObjects]; - } - - if (_keyBoardMainView) { - [_keyBoardMainView removeFromSuperview]; - _keyBoardMainView = nil; - } - self.keyBoardMainHeightConstraint = nil; - - if (_functionView) { - [_functionView removeFromSuperview]; - _functionView = nil; - } - if (_chatPanelView) { - [_chatPanelView removeFromSuperview]; - _chatPanelView = nil; - } - self.chatPanelVisible = NO; - - if (_subscriptionView) { - [_subscriptionView removeFromSuperview]; - _subscriptionView = nil; - } - if (_settingView) { - [_settingView removeFromSuperview]; - _settingView = nil; - } -} - -- (void)showSubscriptionPanel { - // 1) 先判断权限:未开启“完全访问”则走引导逻辑 - if (![[KBFullAccessManager shared] hasFullAccess]) { - // 未开启完全访问:保持原有引导路径 - // [KBHUD showInfo:KBLocalized(@"处理中…")]; - [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]; - return; - } - // 点击充值要先判断是否登录 - // 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App - // 负责完成登录 - if (!KBAuthManager.shared.isLoggedIn) { - NSString *schemeStr = - [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; - NSURL *scheme = [NSURL URLWithString:schemeStr]; - // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App - BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; - return; - } - [[KBMaiPointReporter sharedReporter] - reportPageExposureWithEventName:@"enter_keyboard_subscription_panel" - pageId:@"keyboard_subscription_panel" - extra:nil - completion:nil]; - [self showFunctionPanel:NO]; - KBKeyboardSubscriptionView *panel = self.subscriptionView; - if (!panel.superview) { - panel.hidden = YES; - [self.contentView addSubview:panel]; - [panel mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.contentView); - }]; - } - [self.contentView bringSubviewToFront:panel]; - panel.hidden = NO; - panel.alpha = 0.0; - CGFloat height = CGRectGetHeight(self.contentView.bounds); - if (height <= 0) { - height = 260; - } - panel.transform = CGAffineTransformMakeTranslation(0, height); - [panel refreshProductsIfNeeded]; - [UIView animateWithDuration:0.25 - delay:0 - options:UIViewAnimationOptionCurveEaseOut - animations:^{ - panel.alpha = 1.0; - panel.transform = CGAffineTransformIdentity; - } - completion:nil]; -} - -- (void)hideSubscriptionPanel { - if (!self.subscriptionView || self.subscriptionView.hidden) { - return; - } - CGFloat height = CGRectGetHeight(self.subscriptionView.bounds); - if (height <= 0) { - height = CGRectGetHeight(self.contentView.bounds); - } - KBKeyboardSubscriptionView *panel = self.subscriptionView; - [UIView animateWithDuration:0.22 - delay:0 - options:UIViewAnimationOptionCurveEaseIn - animations:^{ - panel.alpha = 0.0; - panel.transform = CGAffineTransformMakeTranslation(0, height); - } - completion:^(BOOL finished) { - panel.hidden = YES; - panel.alpha = 1.0; - panel.transform = CGAffineTransformIdentity; - }]; -} - -// MARK: - KBKeyBoardMainViewDelegate -- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView - didTapKey:(KBKey *)key { - switch (key.type) { - case KBKeyTypeCharacter: { - [[KBBackspaceUndoManager shared] registerNonClearAction]; - NSString *text = key.output ?: key.title ?: @""; - [self.textDocumentProxy insertText:text]; - [self kb_updateCurrentWordWithInsertedText:text]; - [[KBInputBufferManager shared] appendText:text]; - } break; - case KBKeyTypeBackspace: - [[KBInputBufferManager shared] - refreshFromProxyIfPossible:self.textDocumentProxy]; - [[KBInputBufferManager shared] - prepareSnapshotForDeleteWithContextBefore: - self.textDocumentProxy.documentContextBeforeInput - after: - self.textDocumentProxy - .documentContextAfterInput]; - [[KBBackspaceUndoManager shared] - captureAndDeleteBackwardFromProxy:self.textDocumentProxy - count:1]; - [self kb_scheduleContextRefreshResetSuppression:NO]; - [[KBInputBufferManager shared] applyHoldDeleteCount:1]; - break; - case KBKeyTypeSpace: - [[KBBackspaceUndoManager shared] registerNonClearAction]; - [self.textDocumentProxy insertText:@" "]; - [self kb_clearCurrentWord]; - [[KBInputBufferManager shared] appendText:@" "]; - break; - case KBKeyTypeReturn: - if (self.chatPanelVisible) { - [self kb_handleChatSendAction]; - break; - } - [[KBBackspaceUndoManager shared] registerNonClearAction]; - [self.textDocumentProxy insertText:@"\n"]; - [self kb_clearCurrentWord]; - [[KBInputBufferManager shared] appendText:@"\n"]; - break; - case KBKeyTypeGlobe: - [self advanceToNextInputMode]; - break; - case KBKeyTypeCustom: - [[KBBackspaceUndoManager shared] registerNonClearAction]; - // 点击自定义键切换到功能面板 - [self showFunctionPanel:YES]; - [self kb_clearCurrentWord]; - break; - case KBKeyTypeModeChange: - case KBKeyTypeShift: - // 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理 - break; - } -} - -- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView - didTapToolActionAtIndex:(NSInteger)index { - NSDictionary *extra = @{@"index" : @(index)}; - [[KBMaiPointReporter sharedReporter] - reportClickWithEventName:@"click_keyboard_toolbar_action" - pageId:@"keyboard_main_panel" - elementId:@"toolbar_action" - extra:extra - completion:nil]; - if (index == 0) { - [self showChatPanel:NO]; - [self showFunctionPanel:YES]; - [self kb_clearCurrentWord]; - return; - } - if (index == 1) { - [self showFunctionPanel:NO]; - [self showChatPanel:YES]; - return; - } - [self showFunctionPanel:NO]; - [self showChatPanel:NO]; -} - -- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView { - [[KBMaiPointReporter sharedReporter] - reportClickWithEventName:@"click_keyboard_settings_btn" - pageId:@"keyboard_main_panel" - elementId:@"settings_btn" - extra:nil - completion:nil]; - [self showSettingView:YES]; -} - -- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView - didSelectEmoji:(NSString *)emoji { - if (emoji.length == 0) { - return; - } - [[KBBackspaceUndoManager shared] registerNonClearAction]; - [self.textDocumentProxy insertText:emoji]; - [self kb_clearCurrentWord]; - [[KBInputBufferManager shared] appendText:emoji]; -} - -- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView { - [[KBMaiPointReporter sharedReporter] - reportClickWithEventName:@"click_keyboard_undo_btn" - pageId:@"keyboard_main_panel" - elementId:@"undo_btn" - extra:nil - completion:nil]; - [[KBBackspaceUndoManager shared] performUndoFromResponder:self.view]; - [self kb_scheduleContextRefreshResetSuppression:YES]; -} - -- (void)keyBoardMainViewDidTapEmojiSearch: - (KBKeyBoardMainView *)keyBoardMainView { -// [[KBMaiPointReporter sharedReporter] -// reportClickWithEventName:@"click_keyboard_emoji_search_btn" -// pageId:@"keyboard_main_panel" -// elementId:@"emoji_search_btn" -// extra:nil -// completion:nil]; - [KBHUD showInfo:KBLocalized(@"Search coming soon")]; -} - -- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView - didSelectSuggestion:(NSString *)suggestion { - if (suggestion.length == 0) { - return; - } - NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)}; -// [[KBMaiPointReporter sharedReporter] -// reportClickWithEventName:@"click_keyboard_suggestion_item" -// pageId:@"keyboard_main_panel" -// elementId:@"suggestion_item" -// extra:extra -// completion:nil]; - [[KBBackspaceUndoManager shared] registerNonClearAction]; - NSString *current = self.currentWord ?: @""; - if (current.length > 0) { - for (NSUInteger i = 0; i < current.length; i++) { - [self.textDocumentProxy deleteBackward]; - } - } - [self.textDocumentProxy insertText:suggestion]; - self.currentWord = suggestion; - [self.suggestionEngine recordSelection:suggestion]; - self.suppressSuggestions = YES; - [self.keyBoardMainView kb_setSuggestions:@[]]; - [[KBInputBufferManager shared] replaceTailWithText:suggestion - deleteCount:current.length]; -} - -// MARK: - KBFunctionViewDelegate -- (void)functionView:(KBFunctionView *)functionView - didTapToolActionAtIndex:(NSInteger)index { - // 需求:当 index == 0 时,切回键盘主视图 - if (index == 0) { - [self showFunctionPanel:NO]; - } -} -- (void)functionView:(KBFunctionView *_Nullable)functionView - didRightTapToolActionAtIndex:(NSInteger)index { - [[KBMaiPointReporter sharedReporter] - reportClickWithEventName:@"click_keyboard_function_right_action" - pageId:@"keyboard_function_panel" - elementId:@"right_action" - extra:@{@"action" : @"login_or_recharge"} - completion:nil]; - if (!KBAuthManager.shared.isLoggedIn) { - NSString *schemeStr = - [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; - NSURL *scheme = [NSURL URLWithString:schemeStr]; - // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App - BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; - return; - } - NSString *schemeStr = - [NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME]; - NSURL *scheme = [NSURL URLWithString:schemeStr]; - // - // if (!ul && !scheme) { return; } - // - // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App - BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; - - if (!ok) { - // 失败兜底:给个文案提示 - // 比如:请回到桌面手动打开 XXX App 进行设置/充值 - [KBHUD showInfo:@"请回到桌面手动打开App进行充值"]; - } -} - -- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView { - [self showSubscriptionPanel]; -} - -#pragma mark - KBChatPanelViewDelegate - -- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text { - NSString *trim = - [text stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (trim.length == 0) { - return; - } - [self kb_sendChatText:trim]; -} - -- (void)chatPanelView:(KBChatPanelView *)view - didTapMessage:(KBChatMessage *)message { - if (message.audioFilePath.length == 0) { - return; - } - [self kb_playChatAudioAtPath:message.audioFilePath]; -} - -- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message { - if (!message) return; - - // 如果有 audioData,直接播放 - if (message.audioData && message.audioData.length > 0) { - [self kb_playChatAudioData:message.audioData]; - return; - } - - // 如果有 audioFilePath,播放文件 - if (message.audioFilePath.length > 0) { - [self kb_playChatAudioAtPath:message.audioFilePath]; - return; - } - - NSLog(@"[Keyboard] 没有音频数据可播放"); -} - -- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view { - // 清空 chatPanelView 内部的消息 - [view kb_reloadWithMessages:@[]]; - if (self.chatAudioPlayer.isPlaying) { - [self.chatAudioPlayer stop]; - } - self.chatAudioPlayer = nil; - [self showChatPanel:NO]; -} - -#pragma mark - Chat Helpers - -- (void)kb_handleChatSendAction { - if (!self.chatPanelVisible) { - return; - } - [[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy]; - NSString *fullText = [KBInputBufferManager shared].liveText ?: @""; - - // 去掉打开聊天面板前宿主输入框里已有的基线文本,只取新增部分 - NSString *baseline = self.chatPanelBaselineText ?: @""; - NSString *rawText = fullText; - if (baseline.length > 0 && [fullText hasPrefix:baseline]) { - rawText = [fullText substringFromIndex:baseline.length]; - } - - NSString *trim = - [rawText stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (trim.length == 0) { - [KBHUD showInfo:KBLocalized(@"请输入内容")]; - return; - } - [self kb_sendChatText:trim]; - // 只清除新增的文本,保留基线文本 - [self kb_clearHostInputForText:rawText]; -} - -- (void)kb_sendChatText:(NSString *)text { - if (text.length == 0) { - return; - } - NSLog(@"[KB] 发送消息: %@", text); - - KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text]; - outgoing.avatarURL = [self kb_sharedUserAvatarURL]; - [self.chatPanelView kb_addUserMessage:text]; - [self kb_prefetchAvatarForMessage:outgoing]; - - if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) { - [KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")]; - return; - } - - // 添加 loading 消息 - [self.chatPanelView kb_addLoadingAssistantMessage]; - - // 调用新的聊天接口 - [self kb_requestChatMessageWithContent:text]; -} - -#pragma mark - Chat Limit Pop - -- (void)kb_showChatLimitPopWithMessage:(NSString *)message { - [self kb_dismissChatLimitPop]; - - UIControl *mask = [[UIControl alloc] init]; - mask.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4]; - mask.alpha = 0.0; - [mask addTarget:self - action:@selector(kb_dismissChatLimitPop) - forControlEvents:UIControlEventTouchUpInside]; - [self.contentView addSubview:mask]; - [mask mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.contentView); - }]; - - CGFloat width = 252.0; - CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0; - KBChatLimitPopView *content = - [[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)]; - content.message = message ?: @""; - content.delegate = self; - [mask addSubview:content]; - [content mas_makeConstraints:^(MASConstraintMaker *make) { - make.center.equalTo(mask); - make.width.mas_equalTo(width); - make.height.mas_equalTo(height); - }]; - - self.chatLimitMaskView = mask; - [self.contentView bringSubviewToFront:mask]; - [UIView animateWithDuration:0.18 - animations:^{ - mask.alpha = 1.0; - }]; -} - -- (void)kb_dismissChatLimitPop { - if (!self.chatLimitMaskView) { - return; - } - UIControl *mask = self.chatLimitMaskView; - self.chatLimitMaskView = nil; - [UIView animateWithDuration:0.15 - animations:^{ - mask.alpha = 0.0; - } - completion:^(__unused BOOL finished) { - [mask removeFromSuperview]; - }]; -} - -- (void)kb_clearHostInputForText:(NSString *)text { - if (text.length == 0) { - return; - } - NSUInteger count = [self kb_composedCharacterCountForString:text]; - for (NSUInteger i = 0; i < count; i++) { - [self.textDocumentProxy deleteBackward]; - } - [[KBInputBufferManager shared] clearAllLiveText]; - [self kb_clearCurrentWord]; -} - -- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text { - if (text.length == 0) { - return 0; - } - __block NSUInteger count = 0; - [text enumerateSubstringsInRange:NSMakeRange(0, text.length) - options:NSStringEnumerationByComposedCharacterSequences - usingBlock:^(__unused NSString *substring, - __unused NSRange substringRange, - __unused NSRange enclosingRange, - __unused BOOL *stop) { - count += 1; - }]; - return count; -} - -- (NSString *)kb_sharedUserAvatarURL { - NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; - NSString *url = [ud stringForKey:AppGroup_UserAvatarURL]; - return url ?: @""; -} - -- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message { - if (!message || message.avatarImage) { - return; - } - NSString *urlString = message.avatarURL ?: @""; - if (urlString.length == 0) { - return; - } - if (![[KBFullAccessManager shared] hasFullAccess]) { - return; - } - __weak typeof(self) weakSelf = self; - [[KBVM shared] downloadAvatarFromURL:urlString completion:^(UIImage *image, NSError *error) { - __strong typeof(weakSelf) self = weakSelf; - if (!self || !image) return; - - message.avatarImage = image; - [self kb_reloadChatRowForMessage:message]; - }]; -} - -- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message { - // 头像预加载完成后不需要刷新表格 - // 因为键盘扩展的聊天面板不显示头像,所以这里直接返回 - // 如果将来需要显示头像,可以只刷新特定行而不是整个表格 -} - -- (void)kb_requestChatAudioForText:(NSString *)text { - NSString *mockPath = [self kb_mockChatAudioPath]; - if (mockPath.length > 0) { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - NSString *displayText = KBLocalized(@"语音回复"); - KBChatMessage *incoming = - [KBChatMessage messageWithText:displayText - outgoing:NO - audioFilePath:mockPath]; - incoming.displayName = KBLocalized(@"AI助手"); - [self kb_appendChatMessage:incoming]; - [self kb_playChatAudioAtPath:mockPath]; - }); - return; - } - NSDictionary *payload = @{@"message" : text ?: @""}; - __weak typeof(self) weakSelf = self; - [[KBNetworkManager shared] POST:API_AI_TALK - jsonBody:payload - headers:nil - completion:^(NSDictionary *json, NSURLResponse *response, - NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - __strong typeof(weakSelf) self = weakSelf; - if (!self) { - return; - } - if (error) { - NSString *tip = error.localizedDescription - ?: KBLocalized(@"请求失败"); - [KBHUD showInfo:tip]; - return; - } - NSString *displayText = - [self kb_chatTextFromJSON:json]; - NSString *audioURL = - [self kb_chatAudioURLFromJSON:json]; - NSString *audioBase64 = - [self kb_chatAudioBase64FromJSON:json]; - if (audioURL.length > 0) { - [self kb_downloadChatAudioFromURL:audioURL - displayText:displayText]; - return; - } - if (audioBase64.length > 0) { - NSData *data = [[NSData alloc] - initWithBase64EncodedString:audioBase64 - options:0]; - if (data.length == 0) { - [KBHUD showInfo:KBLocalized(@"音频数据解析失败")]; - return; - } - [self kb_handleChatAudioData:data - fileExtension:@"m4a" - displayText:displayText]; - return; - } - [KBHUD showInfo:KBLocalized(@"未获取到音频文件")]; - }); - }]; -} - -#pragma mark - New Chat API (with typewriter effect and audio preload) - -/// 调用新的聊天接口(返回文本和 audioId) -- (void)kb_requestChatMessageWithContent:(NSString *)content { - if (content.length == 0) { - [self.chatPanelView kb_removeLoadingAssistantMessage]; - return; - } - - NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup]; - NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId); - - __weak typeof(self) weakSelf = self; - [[KBVM shared] sendChatMessageWithContent:content - companionId:companionId - completion:^(KBChatResponse *response) { - __strong typeof(weakSelf) self = weakSelf; - if (!self) return; - - if (response.code != 0) { - if (response.code == 50030) { - NSLog(@"[KB] ⚠️ 次数用尽: %@", response.message); - [self.chatPanelView kb_removeLoadingAssistantMessage]; - [self kb_showChatLimitPopWithMessage:response.message]; - return; - } - NSLog(@"[KB] ❌ 请求失败: %@", response.message); - [self.chatPanelView kb_removeLoadingAssistantMessage]; - [KBHUD showInfo:response.message ?: KBLocalized(@"请求失败")]; - return; - } - - NSLog(@"[KB] ✅ 收到回复: %@", response.data.aiResponse); - - if (response.data.aiResponse.length == 0) { - [self.chatPanelView kb_removeLoadingAssistantMessage]; - [KBHUD showInfo:KBLocalized(@"未获取到回复内容")]; - return; - } - - // 添加 AI 消息(带打字机效果) - NSLog(@"[KB] 准备添加 AI 消息"); - [self.chatPanelView kb_addAssistantMessage:response.data.aiResponse audioId:response.data.audioId]; - NSLog(@"[KB] AI 消息添加完成"); - - // 如果有 audioId,开始预加载音频 - if (response.data.audioId.length > 0) { - [self kb_preloadAudioWithAudioId:response.data.audioId]; - } - }]; -} - -/// 从 AppGroup 获取选中的 persona companionId -- (NSInteger)kb_selectedCompanionId { - return [[KBVM shared] selectedCompanionIdFromAppGroup]; -} - -#pragma mark - Audio Preload - -/// 预加载音频(轮询获取 audioURL) -- (void)kb_preloadAudioWithAudioId:(NSString *)audioId { - if (audioId.length == 0) return; - - NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId); - - __weak typeof(self) weakSelf = self; - [[KBVM shared] pollAudioURLWithAudioId:audioId - maxRetries:10 - interval:1.0 - completion:^(KBAudioResponse *response) { - __strong typeof(weakSelf) self = weakSelf; - if (!self) return; - - if (!response.success || response.audioURL.length == 0) { - NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@", response.errorMessage); - return; - } - - NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功"); - - // 下载音频 - [[KBVM shared] downloadAudioFromURL:response.audioURL - completion:^(KBAudioResponse *audioResponse) { - if (!audioResponse.success) { - NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@", audioResponse.errorMessage); - return; - } - - // 更新最后一条 AI 消息的音频数据 - [self.chatPanelView kb_updateLastAssistantMessageWithAudioData:audioResponse.audioData - duration:audioResponse.duration]; - NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒", audioResponse.duration); - }]; - }]; -} - -- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL - displayText:(NSString *)displayText { - __weak typeof(self) weakSelf = self; - [[KBVM shared] downloadAudioFromURL:audioURL completion:^(KBAudioResponse *response) { - __strong typeof(weakSelf) self = weakSelf; - if (!self) return; - - if (!response.success) { - [KBHUD showInfo:response.errorMessage ?: KBLocalized(@"下载失败")]; - return; - } - - if (!response.audioData || response.audioData.length == 0) { - [KBHUD showInfo:KBLocalized(@"未获取到音频数据")]; - return; - } - - NSString *ext = @"m4a"; - NSURL *url = [NSURL URLWithString:audioURL]; - if (url.pathExtension.length > 0) { - ext = url.pathExtension; - } - [self kb_handleChatAudioData:response.audioData - fileExtension:ext - displayText:displayText]; - }]; -} - -- (void)kb_handleChatAudioData:(NSData *)data - fileExtension:(NSString *)extension - displayText:(NSString *)displayText { - if (data.length == 0) { - [KBHUD showInfo:KBLocalized(@"音频数据为空")]; - return; - } - NSString *ext = extension.length > 0 ? extension : @"m4a"; - NSString *fileName = [NSString - stringWithFormat:@"kb_chat_%@.%@", - @((long long)([NSDate date].timeIntervalSince1970 * - 1000)), - ext]; - NSString *filePath = - [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; - if (![data writeToFile:filePath atomically:YES]) { - [KBHUD showInfo:KBLocalized(@"音频保存失败")]; - return; - } - NSString *text = displayText.length > 0 ? displayText : KBLocalized(@"语音消息"); - KBChatMessage *incoming = - [KBChatMessage messageWithText:text - outgoing:NO - audioFilePath:filePath]; - incoming.displayName = KBLocalized(@"AI助手"); - [self kb_appendChatMessage:incoming]; -} - -- (void)kb_appendChatMessage:(KBChatMessage *)message { - if (!message) { - return; - } - [self.chatMessages addObject:message]; - if (self.chatMessages.count > kKBChatMessageLimit) { - NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit; - NSArray *removed = - [self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)]; - [self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)]; - for (KBChatMessage *msg in removed) { - if (msg.audioFilePath.length > 0) { - NSString *tmpRoot = NSTemporaryDirectory(); - if (tmpRoot.length > 0 && - [msg.audioFilePath hasPrefix:tmpRoot]) { - [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath - error:nil]; - } - } - } - } - [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; -} - -- (NSString *)kb_mockChatAudioPath { - NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test" - ofType:@"m4a"]; - return path ?: @""; -} - -- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json { - NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; - NSString *text = - [self kb_stringValueInDict:data - keys:@[ @"text", @"message", @"content" ]]; - if (text.length == 0) { - text = [self kb_stringValueInDict:json - keys:@[ @"text", @"message", @"content" ]]; - } - return text ?: @""; -} - -- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json { - NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; - NSArray *keys = - @[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl", - @"file_url", @"audioFileUrl", @"audio_file_url" ]; - NSString *url = [self kb_stringValueInDict:data keys:keys]; - if (url.length == 0) { - url = [self kb_stringValueInDict:json keys:keys]; - } - return url ?: @""; -} - -- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json { - NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; - NSArray *keys = - @[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data", - @"base64" ]; - NSString *b64 = [self kb_stringValueInDict:data keys:keys]; - if (b64.length == 0) { - b64 = [self kb_stringValueInDict:json keys:keys]; - } - return b64 ?: @""; -} - -- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json { - if (![json isKindOfClass:[NSDictionary class]]) { - return @{}; - } - id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"]; - if ([dataObj isKindOfClass:[NSDictionary class]]) { - return (NSDictionary *)dataObj; - } - return @{}; -} - -- (NSString *)kb_stringValueInDict:(NSDictionary *)dict - keys:(NSArray *)keys { - if (![dict isKindOfClass:[NSDictionary class]]) { - return @""; - } - for (NSString *key in keys) { - id value = dict[key]; - if ([value isKindOfClass:[NSString class]] && - ((NSString *)value).length > 0) { - return (NSString *)value; - } - } - return @""; -} - -- (void)kb_playChatAudioAtPath:(NSString *)path { - if (path.length == 0) { - return; - } - NSURL *url = [NSURL fileURLWithPath:path]; - if (![NSFileManager.defaultManager fileExistsAtPath:path]) { - [KBHUD showInfo:KBLocalized(@"音频文件不存在")]; - return; - } - - if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { - NSURL *currentURL = self.chatAudioPlayer.url; - if ([currentURL isEqual:url]) { - [self.chatAudioPlayer stop]; - self.chatAudioPlayer = nil; - return; - } - [self.chatAudioPlayer stop]; - self.chatAudioPlayer = nil; - } - - NSError *sessionError = nil; - AVAudioSession *session = [AVAudioSession sharedInstance]; - if ([session respondsToSelector:@selector(setCategory:options:error:)]) { - [session setCategory:AVAudioSessionCategoryPlayback - withOptions:AVAudioSessionCategoryOptionDuckOthers - error:&sessionError]; - } else { - [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; - } - [session setActive:YES error:nil]; - - NSError *playerError = nil; - AVAudioPlayer *player = - [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError]; - if (playerError || !player) { - [KBHUD showInfo:KBLocalized(@"音频播放失败")]; - return; - } - self.chatAudioPlayer = player; - [player prepareToPlay]; - [player play]; -} - -/// 播放音频数据 -- (void)kb_playChatAudioData:(NSData *)audioData { - if (!audioData || audioData.length == 0) { - NSLog(@"[Keyboard] 音频数据为空"); - return; - } - - // 如果正在播放,先停止 - if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { - [self.chatAudioPlayer stop]; - self.chatAudioPlayer = nil; - } - - // 配置音频会话 - NSError *sessionError = nil; - AVAudioSession *session = [AVAudioSession sharedInstance]; - if ([session respondsToSelector:@selector(setCategory:options:error:)]) { - [session setCategory:AVAudioSessionCategoryPlayback - withOptions:AVAudioSessionCategoryOptionDuckOthers - error:&sessionError]; - } else { - [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; - } - [session setActive:YES error:nil]; - - // 创建播放器 - NSError *playerError = nil; - AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&playerError]; - if (playerError || !player) { - NSLog(@"[Keyboard] 音频播放器初始化失败: %@", playerError.localizedDescription); - [KBHUD showInfo:KBLocalized(@"音频播放失败")]; - return; - } - - self.chatAudioPlayer = player; - player.volume = 1.0; - [player prepareToPlay]; - [player play]; - - NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration); -} - -#pragma mark - KBKeyboardSubscriptionViewDelegate - -- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view { - [[KBMaiPointReporter sharedReporter] - reportClickWithEventName:@"click_keyboard_subscription_close_btn" - pageId:@"keyboard_subscription_panel" - elementId:@"close_btn" - extra:nil - completion:nil]; - [self hideSubscriptionPanel]; -} - -- (void)subscriptionView:(KBKeyboardSubscriptionView *)view - didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product { - NSMutableDictionary *extra = [NSMutableDictionary dictionary]; - if ([product.productId isKindOfClass:NSString.class] && - product.productId.length > 0) { - extra[@"product_id"] = product.productId; - } - [[KBMaiPointReporter sharedReporter] - reportClickWithEventName:@"click_keyboard_subscription_product_btn" - pageId:@"keyboard_subscription_panel" - elementId:@"product_btn" - extra:extra.copy - completion:nil]; - [self hideSubscriptionPanel]; - [self kb_openRechargeForProduct:product]; -} - -#pragma mark - KBChatLimitPopViewDelegate - -- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view { - [self kb_dismissChatLimitPop]; -} - -- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view { - [self kb_dismissChatLimitPop]; - NSString *urlString = - [NSString stringWithFormat:@"%@://recharge?src=keyboard&vipType=svip", - KB_APP_SCHEME]; - NSURL *scheme = [NSURL URLWithString:urlString]; - BOOL success = [KBHostAppLauncher openHostAppURL:scheme - fromResponder:self.view]; - if (!success) { - [KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")]; - } -} - -#pragma mark - lazy -- (KBKeyBoardMainView *)keyBoardMainView { - if (!_keyBoardMainView) { - _keyBoardMainView = [[KBKeyBoardMainView alloc] init]; - _keyBoardMainView.delegate = self; - } - return _keyBoardMainView; -} - -- (KBFunctionView *)functionView { - if (!_functionView) { - _functionView = [[KBFunctionView alloc] init]; - _functionView.delegate = self; // 监听功能面板顶部Bar点击 - } - return _functionView; -} - -- (KBSettingView *)settingView { - if (!_settingView) { - _settingView = [[KBSettingView alloc] init]; - } - return _settingView; -} - -- (KBChatPanelView *)chatPanelView { - if (!_chatPanelView) { - NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!"); - _chatPanelView = [[KBChatPanelView alloc] init]; - _chatPanelView.delegate = self; - } - return _chatPanelView; -} - -- (NSMutableArray *)chatMessages { - if (!_chatMessages) { - _chatMessages = [NSMutableArray array]; - } - return _chatMessages; -} - -- (KBKeyboardSubscriptionView *)subscriptionView { - if (!_subscriptionView) { - _subscriptionView = [[KBKeyboardSubscriptionView alloc] init]; - _subscriptionView.delegate = self; - _subscriptionView.hidden = YES; - _subscriptionView.alpha = 0.0; - } - return _subscriptionView; -} - -#pragma mark - Actions - -- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product { - if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] || - product.productId.length == 0) { - [KBHUD showInfo:KBLocalized(@"Product unavailable")]; - return; - } - NSString *encodedId = [self.class kb_urlEncodedString:product.productId]; - NSString *title = [product displayTitle]; - NSString *encodedTitle = [self.class kb_urlEncodedString:title]; - NSMutableArray *params = - [NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil]; - if (encodedId.length) { - [params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]]; - } - if (encodedTitle.length) { - [params - addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]]; - } - NSString *query = [params componentsJoinedByString:@"&"]; - NSString *urlString = [NSString - stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query]; - NSURL *scheme = [NSURL URLWithString:urlString]; - BOOL success = [KBHostAppLauncher openHostAppURL:scheme - fromResponder:self.view]; - if (!success) { - [KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")]; - } -} - -+ (NSString *)kb_urlEncodedString:(NSString *)value { - if (value.length == 0) { - return @""; - } - NSString *reserved = @"!*'();:@&=+$,/?%#[]"; - NSMutableCharacterSet *allowed = - [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; - [allowed removeCharactersInString:reserved]; - return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed] - ?: @""; -} - -- (void)onTapSettingsBack { - [[KBMaiPointReporter sharedReporter] - reportClickWithEventName:@"click_keyboard_settings_back_btn" - pageId:@"keyboard_settings" - elementId:@"back_btn" - extra:nil - completion:nil]; - [self showSettingView:NO]; -} - -- (void)dealloc { - if (self.kb_fullAccessObserverToken) { - [[NSNotificationCenter defaultCenter] - removeObserver:self.kb_fullAccessObserverToken]; - self.kb_fullAccessObserverToken = nil; - } - if (self.kb_skinObserverToken) { - [[NSNotificationCenter defaultCenter] removeObserver:self.kb_skinObserverToken]; - self.kb_skinObserverToken = nil; - } - CFNotificationCenterRemoveObserver( - CFNotificationCenterGetDarwinNotifyCenter(), - (__bridge const void *)(self), - (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL); -#if DEBUG - if (_kb_debugDidCountAlive) { - sKBKeyboardVCAliveCount -= 1; - } - NSLog(@"[Keyboard] KeyboardViewController dealloc alive=%ld self=%p mem=%@", - (long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes())); -#endif -} - -// 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App -// 决定是否真的弹登录)。 - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // if (!_kb_didTriggerLoginDeepLinkOnce) { @@ -1776,7 +189,7 @@ static NSString *KBFormatMB(uint64_t bytes) { - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; -// [self kb_updateKeyboardLayoutIfNeeded]; + // [self kb_updateKeyboardLayoutIfNeeded]; // 首次布局完成后显示,避免闪烁 if (self.contentView.hidden) { @@ -1804,435 +217,25 @@ static NSString *KBFormatMB(uint64_t bytes) { }]; } -//- (void)kb_tryOpenContainerForLoginIfNeeded { -// // 使用与主 App 一致的自定义 Scheme -// NSURL *url = [NSURL URLWithString:[NSString -// stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]]; if (!url) -// return; KBWeakSelf [self.extensionContext openURL:url -// completionHandler:^(__unused BOOL success) { -// // 即使失败也不重复尝试;避免打扰。 -// __unused typeof(weakSelf) selfStrong = weakSelf; -// }]; -//} - -#pragma mark - Theme - -- (void)kb_applyTheme { - @autoreleasepool { - KBSkinTheme *t = [KBSkinManager shared].current; - UIImage *img = nil; - BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t]; - BOOL isDarkMode = [self kb_isDarkModeActive]; - - NSString *skinId = t.skinId ?: @""; - NSString *themeKey = - [NSString stringWithFormat:@"%@|default=%d|dark=%d", - skinId, isDefaultTheme, isDarkMode]; - BOOL themeChanged = - (self.kb_lastAppliedThemeKey.length == 0 || - ![self.kb_lastAppliedThemeKey isEqualToString:themeKey]); - if (themeChanged) { - self.kb_lastAppliedThemeKey = themeKey; - } - - CGSize size = self.bgImageView.bounds.size; - if (isDefaultTheme) { - if (isDarkMode) { - // 暗黑模式:直接使用背景色,不使用图片渲染 - // 这样可以避免图片渲染时的色彩空间转换导致颜色不一致 - img = nil; - self.bgImageView.image = nil; - [self.kb_defaultGradientLayer removeFromSuperlayer]; - self.kb_defaultGradientLayer = nil; - // 使用与系统键盘底部完全相同的颜色 - if (@available(iOS 13.0, *)) { - // iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E) - // 但为了完美匹配,我们使用动态颜色并直接设置为背景 - UIColor *kbBgColor = - [UIColor colorWithDynamicProvider:^UIColor *_Nonnull( - UITraitCollection *_Nonnull traitCollection) { - if (traitCollection.userInterfaceStyle == - UIUserInterfaceStyleDark) { - // 暗黑模式下系统键盘实际背景色 - return [UIColor colorWithRed:43.0 / 255.0 - green:43.0 / 255.0 - blue:43.0 / 255.0 - alpha:1.0]; - } else { - return [UIColor colorWithRed:209.0 / 255.0 - green:211.0 / 255.0 - blue:219.0 / 255.0 - alpha:1.0]; - } - }]; - self.contentView.backgroundColor = kbBgColor; - self.bgImageView.backgroundColor = kbBgColor; - } else { - UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0 - green:43.0 / 255.0 - blue:43.0 / 255.0 - alpha:1.0]; - self.contentView.backgroundColor = darkColor; - self.bgImageView.backgroundColor = darkColor; - } - } else { - // 浅色模式:使用渐变层(避免生成大位图导致内存上涨) - if (size.width <= 0 || size.height <= 0) { - [self.view layoutIfNeeded]; - size = self.bgImageView.bounds.size; - } - if (size.width <= 0 || size.height <= 0) { - size = self.view.bounds.size; - } - if (size.width <= 0 || size.height <= 0) { - size = [UIScreen mainScreen].bounds.size; - } - UIColor *topColor = [UIColor colorWithHex:0xDEDFE4]; - UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB]; - UIColor *resolvedTopColor = topColor; - UIColor *resolvedBottomColor = bottomColor; - if (@available(iOS 13.0, *)) { - resolvedTopColor = - [topColor resolvedColorWithTraitCollection:self.traitCollection]; - resolvedBottomColor = [bottomColor - resolvedColorWithTraitCollection:self.traitCollection]; - } - CAGradientLayer *layer = self.kb_defaultGradientLayer; - if (!layer) { - layer = [CAGradientLayer layer]; - layer.startPoint = CGPointMake(0.5, 0.0); - layer.endPoint = CGPointMake(0.5, 1.0); - [self.bgImageView.layer insertSublayer:layer atIndex:0]; - self.kb_defaultGradientLayer = layer; - } - layer.colors = @[ - (id)resolvedTopColor.CGColor, - (id)resolvedBottomColor.CGColor - ]; - layer.frame = (CGRect){CGPointZero, size}; - img = nil; - self.bgImageView.image = nil; - self.contentView.backgroundColor = [UIColor clearColor]; - self.bgImageView.backgroundColor = [UIColor clearColor]; - } - NSLog(@"==="); - } else { - // 自定义皮肤:清除背景色,使用皮肤图片 - self.contentView.backgroundColor = [UIColor clearColor]; - self.bgImageView.backgroundColor = [UIColor clearColor]; - [self.kb_defaultGradientLayer removeFromSuperlayer]; - self.kb_defaultGradientLayer = nil; - img = [[KBSkinManager shared] currentBackgroundImage]; - } - NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil)); - [self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img]; - self.bgImageView.image = img; - - // 触发键区按主题重绘 - if (themeChanged && - [self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) { -// method declared in KBKeyBoardMainView.h -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [self.keyBoardMainView performSelector:@selector(kb_applyTheme)]; -#pragma clang diagnostic pop - } - // 注意:这里不能直接访问 self.functionView,否则会导致功能面板提前创建,占用内存。 - if (themeChanged && _functionView && - [_functionView respondsToSelector:@selector(kb_applyTheme)]) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [_functionView performSelector:@selector(kb_applyTheme)]; -#pragma clang diagnostic pop - } +- (void)dealloc { + if (self.kb_fullAccessObserverToken) { + [[NSNotificationCenter defaultCenter] + removeObserver:self.kb_fullAccessObserverToken]; + self.kb_fullAccessObserverToken = nil; } -} - -- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme { - NSString *skinId = theme.skinId ?: @""; - if (skinId.length == 0 || [skinId isEqualToString:@"default"]) { - return YES; + if (self.kb_skinObserverToken) { + [[NSNotificationCenter defaultCenter] + removeObserver:self.kb_skinObserverToken]; + self.kb_skinObserverToken = nil; } - if ([skinId isEqualToString:kKBDefaultSkinIdLight]) { - return YES; - } - return [skinId isEqualToString:kKBDefaultSkinIdDark]; -} - -- (BOOL)kb_isDarkModeActive { - if (@available(iOS 13.0, *)) { - return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark; - } - return NO; -} - -- (NSString *)kb_defaultSkinIdForCurrentStyle { - return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark - : kKBDefaultSkinIdLight; -} - -- (NSString *)kb_defaultSkinZipNameForCurrentStyle { - return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark - : kKBDefaultSkinZipNameLight; -} - -- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size - topColor:(UIColor *)topColor - bottomColor:(UIColor *)bottomColor { - if (size.width <= 0 || size.height <= 0) { - return nil; - } - - // 尺寸未变则复用缓存,避免反复创建图片撑爆键盘扩展内存 - if (self.kb_cachedGradientImage && - CGSizeEqualToSize(self.kb_cachedGradientSize, size)) { - return self.kb_cachedGradientImage; - } - - UIColor *resolvedTopColor = topColor; - UIColor *resolvedBottomColor = bottomColor; - if (@available(iOS 13.0, *)) { - resolvedTopColor = - [topColor resolvedColorWithTraitCollection:self.traitCollection]; - resolvedBottomColor = - [bottomColor resolvedColorWithTraitCollection:self.traitCollection]; - } - - CAGradientLayer *layer = [CAGradientLayer layer]; - layer.frame = CGRectMake(0, 0, size.width, size.height); - layer.startPoint = CGPointMake(0.5, 0.0); - layer.endPoint = CGPointMake(0.5, 1.0); - layer.colors = - @[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ]; - - UIGraphicsBeginImageContextWithOptions(size, YES, 0); - [layer renderInContext:UIGraphicsGetCurrentContext()]; - UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - - self.kb_cachedGradientImage = image; - self.kb_cachedGradientSize = size; - return image; -} - -- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme - backgroundImage:(UIImage *)image { + [self kb_unregisterDarwinSkinInstallObserver]; #if DEBUG - NSString *skinId = theme.skinId ?: @""; - NSString *name = theme.name ?: @""; - NSMutableArray *roots = [NSMutableArray array]; - NSURL *containerURL = [[NSFileManager defaultManager] - containerURLForSecurityApplicationGroupIdentifier:AppGroup]; - if (containerURL.path.length > 0) { - [roots addObject:containerURL.path]; + if (_kb_debugDidCountAlive) { + sKBKeyboardVCAliveCount -= 1; } - NSString *cacheRoot = NSSearchPathForDirectoriesInDomains( - NSCachesDirectory, NSUserDomainMask, YES) - .firstObject; - if (cacheRoot.length > 0) { - [roots addObject:cacheRoot]; - } - - NSFileManager *fm = [NSFileManager defaultManager]; - NSMutableArray *lines = [NSMutableArray array]; - for (NSString *root in roots) { - NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"] - stringByAppendingPathComponent:skinId]; - iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"]; - BOOL isDir = NO; - BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir; - NSArray *contents = - exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil; - NSUInteger count = contents.count; - BOOL hasQ = - exists && - [fm fileExistsAtPath:[iconsDir - stringByAppendingPathComponent:@"key_q.png"]]; - BOOL hasQUp = - exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: - @"key_q_up.png"]]; - BOOL hasDel = - exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: - @"key_del.png"]]; - BOOL hasShift = - exists && - [fm fileExistsAtPath:[iconsDir - stringByAppendingPathComponent:@"key_up.png"]]; - BOOL hasShiftUpper = - exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: - @"key_up_upper.png"]]; - NSString *line = [NSString - stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d " - @"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d", - root, iconsDir, exists, count, hasQ, hasQUp, hasDel, - hasShift, hasShiftUpper]; - [lines addObject:line]; - } - - NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name, - (image != nil), [lines componentsJoinedByString:@"\n"]); + NSLog(@"[Keyboard] KeyboardViewController dealloc alive=%ld self=%p mem=%@", + (long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes())); #endif } -- (void)kb_consumePendingShopSkin { - KBWeakSelf [KBSkinInstallBridge - consumePendingRequestFromBundle:NSBundle.mainBundle - completion:^(BOOL success, - NSError *_Nullable error) { - if (!success) { - if (error) { - NSLog(@"[Keyboard] skin request failed: %@", - error); - [KBHUD - showInfo: - KBLocalized( - @"皮肤资源准备失败,请稍后再试")]; - } - return; - } - [weakSelf kb_applyTheme]; - [KBHUD showInfo:KBLocalized( - @"皮肤已更新,立即体验吧")]; - }]; -} - -#pragma mark - Default Skin - -- (void)kb_applyDefaultSkinIfNeeded { - NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload]; - if (pending.count > 0) { - return; - } - - NSString *currentId = [KBSkinManager shared].current.skinId ?: @""; - BOOL isDefault = - (currentId.length == 0 || [currentId isEqualToString:@"default"]); - BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight]; - BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark]; - if (!isDefault && !isLightDefault && !isDarkDefault) { - // 用户已应用自定义皮肤:不随深色模式切换默认皮肤 - return; - } - NSString *targetId = [self kb_defaultSkinIdForCurrentStyle]; - NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle]; - if (currentId.length > 0 && [currentId isEqualToString:targetId]) { - return; - } - - NSError *applyError = nil; - if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId - error:&applyError]) { - return; - } - - [KBSkinInstallBridge publishBundleSkinRequestWithId:targetId - name:targetId - zipName:targetZip - iconShortNames:nil]; - [KBSkinInstallBridge - consumePendingRequestFromBundle:NSBundle.mainBundle - completion:^(__unused BOOL success, - __unused NSError *_Nullable error){ - // 已通过通知触发主题刷新,这里无需额外处理 - }]; -} - -#pragma mark - Layout Helpers - -- (CGFloat)kb_portraitWidth { - CGSize s = [UIScreen mainScreen].bounds.size; - return MIN(s.width, s.height); -} - -- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width { - if (width <= 0) { - width = KB_DESIGN_WIDTH; - } - CGFloat scale = width / KB_DESIGN_WIDTH; - CGFloat baseHeight = kKBKeyboardBaseHeight * scale; - CGFloat chatHeight = kKBChatPanelHeight * scale; - if (self.chatPanelVisible) { - return baseHeight + chatHeight; - } - return baseHeight; -} - -- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width { - if (width <= 0) { - width = KB_DESIGN_WIDTH; - } - CGFloat scale = width / KB_DESIGN_WIDTH; - return kKBKeyboardBaseHeight * scale; -} - -- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width { - if (width <= 0) { - width = KB_DESIGN_WIDTH; - } - CGFloat scale = width / KB_DESIGN_WIDTH; - return kKBChatPanelHeight * scale; -} - -- (void)kb_updateKeyboardLayoutIfNeeded { - CGFloat portraitWidth = [self kb_portraitWidth]; - CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; - CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; - CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; - CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds); - if (containerWidth <= 0) { - containerWidth = CGRectGetWidth(self.view.window.bounds); - } - if (containerWidth <= 0) { - containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds); - } - - BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5); - BOOL heightChanged = - (fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5); - if (!widthChanged && !heightChanged && containerWidth > 0 && - self.kb_widthConstraint.constant == containerWidth) { - return; - } - self.kb_lastPortraitWidth = portraitWidth; - self.kb_lastKeyboardHeight = keyboardHeight; - - if (self.kb_heightConstraint) { - self.kb_heightConstraint.constant = keyboardHeight; - } - if (containerWidth > 0 && self.kb_widthConstraint) { - self.kb_widthConstraint.constant = containerWidth; - } - if (self.contentWidthConstraint) { - [self.contentWidthConstraint setOffset:portraitWidth]; - } - if (self.contentHeightConstraint) { - [self.contentHeightConstraint setOffset:keyboardHeight]; - } - if (self.keyBoardMainHeightConstraint) { - [self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight]; - } - if (self.chatPanelHeightConstraint) { - [self.chatPanelHeightConstraint setOffset:chatPanelHeight]; - } - [self.view layoutIfNeeded]; -} - -#pragma mark - Lazy - -- (UIView *)contentView { - if (!_contentView) { - _contentView = [[UIView alloc] init]; - _contentView.backgroundColor = [UIColor clearColor]; - } - return _contentView; -} - -- (UIImageView *)bgImageView { - if (!_bgImageView) { - _bgImageView = [[UIImageView alloc] init]; - _bgImageView.contentMode = UIViewContentModeScaleAspectFill; - _bgImageView.clipsToBounds = YES; - } - return _bgImageView; -} @end diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Chat.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Chat.m new file mode 100644 index 0000000..d2f9ced --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Chat.m @@ -0,0 +1,681 @@ +// +// KeyboardViewController+Chat.m +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController+Private.h" + +#import "KBChatLimitPopView.h" +#import "KBChatMessage.h" +#import "KBChatPanelView.h" +#import "KBFullAccessManager.h" +#import "KBHostAppLauncher.h" +#import "KBInputBufferManager.h" +#import "KBNetworkManager.h" +#import "KBVM.h" +#import "Masonry.h" + +#import + +static const NSUInteger kKBChatMessageLimit = 6; + +@implementation KeyboardViewController (Chat) + +#pragma mark - KBChatPanelViewDelegate + +- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text { + NSString *trim = + [text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trim.length == 0) { + return; + } + [self kb_sendChatText:trim]; +} + +- (void)chatPanelView:(KBChatPanelView *)view + didTapMessage:(KBChatMessage *)message { + if (message.audioFilePath.length == 0) { + return; + } + [self kb_playChatAudioAtPath:message.audioFilePath]; +} + +- (void)chatPanelView:(KBChatPanelView *)view + didTapVoiceButtonForMessage:(KBChatMessage *)message { + if (!message) + return; + + // 如果有 audioData,直接播放 + if (message.audioData && message.audioData.length > 0) { + [self kb_playChatAudioData:message.audioData]; + return; + } + + // 如果有 audioFilePath,播放文件 + if (message.audioFilePath.length > 0) { + [self kb_playChatAudioAtPath:message.audioFilePath]; + return; + } + + NSLog(@"[Keyboard] 没有音频数据可播放"); +} + +- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view { + // 清空 chatPanelView 内部的消息 + [view kb_reloadWithMessages:@[]]; + if (self.chatAudioPlayer.isPlaying) { + [self.chatAudioPlayer stop]; + } + self.chatAudioPlayer = nil; + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES]; +} + +#pragma mark - Chat Helpers + +- (void)kb_handleChatSendAction { + if (!self.chatPanelVisible) { + return; + } + [[KBInputBufferManager shared] + refreshFromProxyIfPossible:self.textDocumentProxy]; + NSString *fullText = [KBInputBufferManager shared].liveText ?: @""; + + // 去掉打开聊天面板前宿主输入框里已有的基线文本,只取新增部分 + NSString *baseline = self.chatPanelBaselineText ?: @""; + NSString *rawText = fullText; + if (baseline.length > 0 && [fullText hasPrefix:baseline]) { + rawText = [fullText substringFromIndex:baseline.length]; + } + + NSString *trim = + [rawText stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trim.length == 0) { + [KBHUD showInfo:KBLocalized(@"请输入内容")]; + return; + } + [self kb_sendChatText:trim]; + // 只清除新增的文本,保留基线文本 + [self kb_clearHostInputForText:rawText]; +} + +- (void)kb_sendChatText:(NSString *)text { + if (text.length == 0) { + return; + } + NSLog(@"[KB] 发送消息: %@", text); + + KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text]; + outgoing.avatarURL = [self kb_sharedUserAvatarURL]; + [self.chatPanelView kb_addUserMessage:text]; + [self kb_prefetchAvatarForMessage:outgoing]; + + if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) { + [KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")]; + return; + } + + // 添加 loading 消息 + [self.chatPanelView kb_addLoadingAssistantMessage]; + + // 调用新的聊天接口 + [self kb_requestChatMessageWithContent:text]; +} + +#pragma mark - Chat Limit Pop + +- (void)kb_showChatLimitPopWithMessage:(NSString *)message { + [self kb_dismissChatLimitPop]; + + UIControl *mask = [[UIControl alloc] init]; + mask.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4]; + mask.alpha = 0.0; + [mask addTarget:self + action:@selector(kb_dismissChatLimitPop) + forControlEvents:UIControlEventTouchUpInside]; + [self.contentView addSubview:mask]; + [mask mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + + CGFloat width = 252.0; + CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0; + KBChatLimitPopView *content = + [[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)]; + content.message = message ?: @""; + content.delegate = self; + [mask addSubview:content]; + [content mas_makeConstraints:^(MASConstraintMaker *make) { + make.center.equalTo(mask); + make.width.mas_equalTo(width); + make.height.mas_equalTo(height); + }]; + + self.chatLimitMaskView = mask; + [self.contentView bringSubviewToFront:mask]; + [UIView animateWithDuration:0.18 + animations:^{ + mask.alpha = 1.0; + }]; +} + +- (void)kb_dismissChatLimitPop { + if (!self.chatLimitMaskView) { + return; + } + UIControl *mask = self.chatLimitMaskView; + self.chatLimitMaskView = nil; + [UIView animateWithDuration:0.15 + animations:^{ + mask.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + [mask removeFromSuperview]; + }]; +} + +- (void)kb_clearHostInputForText:(NSString *)text { + if (text.length == 0) { + return; + } + NSUInteger count = [self kb_composedCharacterCountForString:text]; + for (NSUInteger i = 0; i < count; i++) { + [self.textDocumentProxy deleteBackward]; + } + [[KBInputBufferManager shared] clearAllLiveText]; + [self kb_clearCurrentWord]; +} + +- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text { + if (text.length == 0) { + return 0; + } + __block NSUInteger count = 0; + [text enumerateSubstringsInRange:NSMakeRange(0, text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(__unused NSString *substring, + __unused NSRange substringRange, + __unused NSRange enclosingRange, + __unused BOOL *stop) { + count += 1; + }]; + return count; +} + +- (NSString *)kb_sharedUserAvatarURL { + NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; + NSString *url = [ud stringForKey:AppGroup_UserAvatarURL]; + return url ?: @""; +} + +- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message { + if (!message || message.avatarImage) { + return; + } + NSString *urlString = message.avatarURL ?: @""; + if (urlString.length == 0) { + return; + } + if (![[KBFullAccessManager shared] hasFullAccess]) { + return; + } + __weak typeof(self) weakSelf = self; + [[KBVM shared] downloadAvatarFromURL:urlString + completion:^(UIImage *image, NSError *error) { + __strong typeof(weakSelf) self = weakSelf; + if (!self || !image) + return; + + message.avatarImage = image; + [self kb_reloadChatRowForMessage:message]; + }]; +} + +- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message { + // 头像预加载完成后不需要刷新表格 + // 因为键盘扩展的聊天面板不显示头像,所以这里直接返回 + // 如果将来需要显示头像,可以只刷新特定行而不是整个表格 +} + +- (void)kb_requestChatAudioForText:(NSString *)text { + NSString *mockPath = [self kb_mockChatAudioPath]; + if (mockPath.length > 0) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + NSString *displayText = KBLocalized(@"语音回复"); + KBChatMessage *incoming = + [KBChatMessage messageWithText:displayText + outgoing:NO + audioFilePath:mockPath]; + incoming.displayName = KBLocalized(@"AI助手"); + [self kb_appendChatMessage:incoming]; + [self kb_playChatAudioAtPath:mockPath]; + }); + return; + } + NSDictionary *payload = @{@"message" : text ?: @""}; + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] POST:API_AI_TALK + jsonBody:payload + headers:nil + completion:^(NSDictionary *json, NSURLResponse *response, + NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + if (error) { + NSString *tip = error.localizedDescription + ?: KBLocalized(@"请求失败"); + [KBHUD showInfo:tip]; + return; + } + NSString *displayText = + [self kb_chatTextFromJSON:json]; + NSString *audioURL = + [self kb_chatAudioURLFromJSON:json]; + NSString *audioBase64 = + [self kb_chatAudioBase64FromJSON:json]; + if (audioURL.length > 0) { + [self kb_downloadChatAudioFromURL:audioURL + displayText:displayText]; + return; + } + if (audioBase64.length > 0) { + NSData *data = [[NSData alloc] + initWithBase64EncodedString:audioBase64 + options:0]; + if (data.length == 0) { + [KBHUD showInfo:KBLocalized(@"音频数据解析失败")]; + return; + } + [self kb_handleChatAudioData:data + fileExtension:@"m4a" + displayText:displayText]; + return; + } + [KBHUD showInfo:KBLocalized(@"未获取到音频文件")]; + }); + }]; +} + +#pragma mark - New Chat API (with typewriter effect and audio preload) + +/// 调用新的聊天接口(返回文本和 audioId) +- (void)kb_requestChatMessageWithContent:(NSString *)content { + if (content.length == 0) { + [self.chatPanelView kb_removeLoadingAssistantMessage]; + return; + } + + NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup]; + NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId); + + __weak typeof(self) weakSelf = self; + [[KBVM shared] sendChatMessageWithContent:content + companionId:companionId + completion:^(KBChatResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) + return; + + if (response.code != 0) { + if (response.code == 50030) { + NSLog(@"[KB] ⚠️ 次数用尽: %@", + response.message); + [self.chatPanelView + kb_removeLoadingAssistantMessage]; + [self kb_showChatLimitPopWithMessage: + response.message]; + return; + } + NSLog(@"[KB] ❌ 请求失败: %@", + response.message); + [self.chatPanelView + kb_removeLoadingAssistantMessage]; + [KBHUD showInfo:response.message + ?: KBLocalized(@"请求失败")]; + return; + } + + NSLog(@"[KB] ✅ 收到回复: %@", + response.data.aiResponse); + + if (response.data.aiResponse.length == 0) { + [self.chatPanelView + kb_removeLoadingAssistantMessage]; + [KBHUD showInfo:KBLocalized(@"未获取到回复内容")]; + return; + } + + // 添加 AI 消息(带打字机效果) + NSLog(@"[KB] 准备添加 AI 消息"); + [self.chatPanelView + kb_addAssistantMessage:response.data.aiResponse + audioId:response.data.audioId]; + NSLog(@"[KB] AI 消息添加完成"); + + // 如果有 audioId,开始预加载音频 + if (response.data.audioId.length > 0) { + [self kb_preloadAudioWithAudioId: + response.data.audioId]; + } + }]; +} + +/// 从 AppGroup 获取选中的 persona companionId +- (NSInteger)kb_selectedCompanionId { + return [[KBVM shared] selectedCompanionIdFromAppGroup]; +} + +#pragma mark - Audio Preload + +/// 预加载音频(轮询获取 audioURL) +- (void)kb_preloadAudioWithAudioId:(NSString *)audioId { + if (audioId.length == 0) + return; + + NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId); + + __weak typeof(self) weakSelf = self; + [[KBVM shared] pollAudioURLWithAudioId:audioId + maxRetries:10 + interval:1.0 + completion:^(KBAudioResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) + return; + + if (!response.success || + response.audioURL.length == 0) { + NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@", + response.errorMessage); + return; + } + + NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功"); + + // 下载音频 + [[KBVM shared] + downloadAudioFromURL:response.audioURL + completion:^( + KBAudioResponse *audioResponse) { + if (!audioResponse.success) { + NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@", + audioResponse.errorMessage); + return; + } + + // 更新最后一条 AI 消息的音频数据 + [self.chatPanelView + kb_updateLastAssistantMessageWithAudioData: + audioResponse.audioData + duration: + audioResponse.duration]; + NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒", + audioResponse.duration); + }]; + }]; +} + +- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL + displayText:(NSString *)displayText { + __weak typeof(self) weakSelf = self; + [[KBVM shared] downloadAudioFromURL:audioURL + completion:^(KBAudioResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) + return; + + if (!response.success) { + [KBHUD showInfo:response.errorMessage + ?: KBLocalized(@"下载失败")]; + return; + } + + if (!response.audioData || + response.audioData.length == 0) { + [KBHUD showInfo:KBLocalized(@"未获取到音频数据")]; + return; + } + + NSString *ext = @"m4a"; + NSURL *url = [NSURL URLWithString:audioURL]; + if (url.pathExtension.length > 0) { + ext = url.pathExtension; + } + [self kb_handleChatAudioData:response.audioData + fileExtension:ext + displayText:displayText]; + }]; +} + +- (void)kb_handleChatAudioData:(NSData *)data + fileExtension:(NSString *)extension + displayText:(NSString *)displayText { + if (data.length == 0) { + [KBHUD showInfo:KBLocalized(@"音频数据为空")]; + return; + } + NSString *ext = extension.length > 0 ? extension : @"m4a"; + NSString *fileName = [NSString + stringWithFormat:@"kb_chat_%@.%@", + @((long long)([NSDate date].timeIntervalSince1970 * + 1000)), + ext]; + NSString *filePath = + [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; + if (![data writeToFile:filePath atomically:YES]) { + [KBHUD showInfo:KBLocalized(@"音频保存失败")]; + return; + } + NSString *text = + displayText.length > 0 ? displayText : KBLocalized(@"语音消息"); + KBChatMessage *incoming = + [KBChatMessage messageWithText:text outgoing:NO audioFilePath:filePath]; + incoming.displayName = KBLocalized(@"AI助手"); + [self kb_appendChatMessage:incoming]; +} + +- (void)kb_appendChatMessage:(KBChatMessage *)message { + if (!message) { + return; + } + [self.chatMessages addObject:message]; + if (self.chatMessages.count > kKBChatMessageLimit) { + NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit; + NSArray *removed = + [self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)]; + [self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)]; + for (KBChatMessage *msg in removed) { + if (msg.audioFilePath.length > 0) { + NSString *tmpRoot = NSTemporaryDirectory(); + if (tmpRoot.length > 0 && [msg.audioFilePath hasPrefix:tmpRoot]) { + [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath + error:nil]; + } + } + } + } + [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; +} + +- (NSString *)kb_mockChatAudioPath { + NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test" + ofType:@"m4a"]; + return path ?: @""; +} + +- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSString *text = + [self kb_stringValueInDict:data keys:@[ @"text", @"message", @"content" ]]; + if (text.length == 0) { + text = [self kb_stringValueInDict:json + keys:@[ @"text", @"message", @"content" ]]; + } + return text ?: @""; +} + +- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSArray *keys = + @[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl", + @"file_url", @"audioFileUrl", @"audio_file_url" ]; + NSString *url = [self kb_stringValueInDict:data keys:keys]; + if (url.length == 0) { + url = [self kb_stringValueInDict:json keys:keys]; + } + return url ?: @""; +} + +- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSArray *keys = + @[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data", + @"base64" ]; + NSString *b64 = [self kb_stringValueInDict:data keys:keys]; + if (b64.length == 0) { + b64 = [self kb_stringValueInDict:json keys:keys]; + } + return b64 ?: @""; +} + +- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json { + if (![json isKindOfClass:[NSDictionary class]]) { + return @{}; + } + id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"]; + if ([dataObj isKindOfClass:[NSDictionary class]]) { + return (NSDictionary *)dataObj; + } + return @{}; +} + +- (NSString *)kb_stringValueInDict:(NSDictionary *)dict + keys:(NSArray *)keys { + if (![dict isKindOfClass:[NSDictionary class]]) { + return @""; + } + for (NSString *key in keys) { + id value = dict[key]; + if ([value isKindOfClass:[NSString class]] && + ((NSString *)value).length > 0) { + return (NSString *)value; + } + } + return @""; +} + +- (void)kb_playChatAudioAtPath:(NSString *)path { + if (path.length == 0) { + return; + } + NSURL *url = [NSURL fileURLWithPath:path]; + if (![NSFileManager.defaultManager fileExistsAtPath:path]) { + [KBHUD showInfo:KBLocalized(@"音频文件不存在")]; + return; + } + + if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { + NSURL *currentURL = self.chatAudioPlayer.url; + if ([currentURL isEqual:url]) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + return; + } + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + + NSError *sessionError = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + if ([session respondsToSelector:@selector(setCategory:options:error:)]) { + [session setCategory:AVAudioSessionCategoryPlayback + withOptions:AVAudioSessionCategoryOptionDuckOthers + error:&sessionError]; + } else { + [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; + } + [session setActive:YES error:nil]; + + NSError *playerError = nil; + AVAudioPlayer *player = + [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError]; + if (playerError || !player) { + [KBHUD showInfo:KBLocalized(@"音频播放失败")]; + return; + } + self.chatAudioPlayer = player; + [player prepareToPlay]; + [player play]; +} + +/// 播放音频数据 +- (void)kb_playChatAudioData:(NSData *)audioData { + if (!audioData || audioData.length == 0) { + NSLog(@"[Keyboard] 音频数据为空"); + return; + } + + // 如果正在播放,先停止 + if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + + // 配置音频会话 + NSError *sessionError = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + if ([session respondsToSelector:@selector(setCategory:options:error:)]) { + [session setCategory:AVAudioSessionCategoryPlayback + withOptions:AVAudioSessionCategoryOptionDuckOthers + error:&sessionError]; + } else { + [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; + } + [session setActive:YES error:nil]; + + // 创建播放器 + NSError *playerError = nil; + AVAudioPlayer *player = + [[AVAudioPlayer alloc] initWithData:audioData error:&playerError]; + if (playerError || !player) { + NSLog(@"[Keyboard] 音频播放器初始化失败: %@", + playerError.localizedDescription); + [KBHUD showInfo:KBLocalized(@"音频播放失败")]; + return; + } + + self.chatAudioPlayer = player; + player.volume = 1.0; + [player prepareToPlay]; + [player play]; + + NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration); +} + +#pragma mark - KBChatLimitPopViewDelegate + +- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view { + [self kb_dismissChatLimitPop]; +} + +- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view { + [self kb_dismissChatLimitPop]; + NSString *urlString = + [NSString stringWithFormat:@"%@://recharge?src=keyboard&vipType=svip", + KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:urlString]; + BOOL success = [KBHostAppLauncher openHostAppURL:scheme + fromResponder:self.view]; + if (!success) { + [KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")]; + } +} + +@end diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Layout.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Layout.m new file mode 100644 index 0000000..295692c --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Layout.m @@ -0,0 +1,96 @@ +// +// KeyboardViewController+Layout.m +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController+Private.h" + +// 以 375 宽设计稿为基准的键盘总高度 +static const CGFloat kKBKeyboardBaseHeight = 250.0f; +static const CGFloat kKBChatPanelHeight = 180; + +@implementation KeyboardViewController (Layout) + +- (CGFloat)kb_portraitWidth { + CGSize s = [UIScreen mainScreen].bounds.size; + return MIN(s.width, s.height); +} + +- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + CGFloat baseHeight = kKBKeyboardBaseHeight * scale; + CGFloat chatHeight = kKBChatPanelHeight * scale; + if (self.chatPanelVisible) { + return baseHeight + chatHeight; + } + return baseHeight; +} + +- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + return kKBKeyboardBaseHeight * scale; +} + +- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + return kKBChatPanelHeight * scale; +} + +- (void)kb_updateKeyboardLayoutIfNeeded { + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; + CGFloat keyboardBaseHeight = + [self kb_keyboardBaseHeightForWidth:portraitWidth]; + CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; + CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds); + if (containerWidth <= 0) { + containerWidth = CGRectGetWidth(self.view.window.bounds); + } + if (containerWidth <= 0) { + containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds); + } + + BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5); + BOOL heightChanged = + (fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5); + if (!widthChanged && !heightChanged && containerWidth > 0 && + self.kb_widthConstraint.constant == containerWidth) { + return; + } + self.kb_lastPortraitWidth = portraitWidth; + self.kb_lastKeyboardHeight = keyboardHeight; + + if (self.kb_heightConstraint) { + self.kb_heightConstraint.constant = keyboardHeight; + } + if (containerWidth > 0 && self.kb_widthConstraint) { + self.kb_widthConstraint.constant = containerWidth; + } + if (self.contentWidthConstraint) { + [self.contentWidthConstraint setOffset:portraitWidth]; + } + if (self.contentHeightConstraint) { + [self.contentHeightConstraint setOffset:keyboardHeight]; + } + if (self.keyBoardMainHeightConstraint) { + [self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight]; + } + if (self.chatPanelHeightConstraint) { + [self.chatPanelHeightConstraint setOffset:chatPanelHeight]; + } + [self.view layoutIfNeeded]; +} + +@end + diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m new file mode 100644 index 0000000..2907883 --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m @@ -0,0 +1,2238 @@ +// +// KeyboardViewController+Legacy.m +// CustomKeyboard +// +// Created by Mac on 2025/10/27. +// + +#import "KeyboardViewController.h" +#import "KBKeyBoardMainView.h" + +#import "KBAuthManager.h" +#import "KBBackspaceUndoManager.h" +#import "KBFullAccessManager.h" +#import "KBFunctionView.h" +#import "KBHostAppLauncher.h" +#import "KBInputBufferManager.h" +#import "KBKey.h" +#import "KBKeyboardSubscriptionProduct.h" +#import "KBKeyboardSubscriptionView.h" +#import "KBSettingView.h" +#import "KBChatMessage.h" +#import "KBChatPanelView.h" +#import "KBChatLimitPopView.h" +#import "KBSkinInstallBridge.h" +#import "KBSkinManager.h" +#import "KBSuggestionEngine.h" +#import "KBNetworkManager.h" +#import "KBVM.h" +#import "Masonry.h" +#import "UIImage+KBColor.h" +#import +#import +#if DEBUG +#import +#endif + +// #import "KBLog.h" + +// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin +// 方法。 +@interface KeyboardViewController (KBSkinShopBridge) +- (void)kb_consumePendingShopSkin; +@end + +// 以 375 宽设计稿为基准的键盘总高度 +static const CGFloat kKBKeyboardBaseHeight = 250.0f; +static const CGFloat kKBChatPanelHeight = 180; +static const NSUInteger kKBChatMessageLimit = 6; +static NSString *const kKBDefaultSkinIdLight = @"normal_them"; +static NSString *const kKBDefaultSkinZipNameLight = @"normal_them"; +static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them"; +static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them"; + +static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, + void *observer, CFStringRef name, + const void *object, + CFDictionaryRef userInfo) { + KeyboardViewController *strongSelf = + (__bridge KeyboardViewController *)observer; + if (!strongSelf) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) { + [strongSelf kb_consumePendingShopSkin]; + } + }); +} + +@interface KeyboardViewController () +@property(nonatomic, strong) + UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选) +@property(nonatomic, strong) UIView *contentView; +@property(nonatomic, strong) KBKeyBoardMainView + *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示) +@property(nonatomic, strong) + KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示) +@property(nonatomic, strong) KBSettingView *settingView; // 设置页 +@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层) +@property(nonatomic, strong) KBChatPanelView *chatPanelView; +@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView; +@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine; +@property(nonatomic, copy) NSString *currentWord; +@property(nonatomic, assign) BOOL suppressSuggestions; +@property(nonatomic, strong) UIControl *chatLimitMaskView; +@property(nonatomic, strong) MASConstraint *contentWidthConstraint; +@property(nonatomic, strong) MASConstraint *contentHeightConstraint; +@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint; +@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint; +@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint; +@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint; +@property(nonatomic, assign) CGFloat kb_lastPortraitWidth; +@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight; +@property(nonatomic, strong) UIImage *kb_cachedGradientImage; +@property(nonatomic, assign) CGSize kb_cachedGradientSize; +@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer; +@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey; +@property(nonatomic, strong) NSMutableArray *chatMessages; +@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; +@property(nonatomic, assign) BOOL chatPanelVisible; +@property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本 +@property(nonatomic, strong, nullable) id kb_fullAccessObserverToken; +@property(nonatomic, strong, nullable) id kb_skinObserverToken; +@end + +#if DEBUG +static NSInteger sKBKeyboardVCAliveCount = 0; + +static uint64_t KBPhysFootprintBytes(void) { + task_vm_info_data_t vmInfo; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, + (task_info_t)&vmInfo, &count); + if (kr != KERN_SUCCESS) { + return 0; + } + return (uint64_t)vmInfo.phys_footprint; +} + +static NSString *KBFormatMB(uint64_t bytes) { + double mb = (double)bytes / 1024.0 / 1024.0; + return [NSString stringWithFormat:@"%.1fMB", mb]; +} +#endif + +@implementation KeyboardViewController + +{ + BOOL _kb_didTriggerLoginDeepLinkOnce; +#if DEBUG + BOOL _kb_debugDidCountAlive; +#endif +} + +- (void)viewDidLoad { + [super viewDidLoad]; +#if DEBUG + if (!_kb_debugDidCountAlive) { + _kb_debugDidCountAlive = YES; + sKBKeyboardVCAliveCount += 1; + } + NSLog(@"[Keyboard] KeyboardViewController viewDidLoad alive=%ld self=%p mem=%@", + (long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes())); +#endif + // 撤销删除是“上一段删除操作”的临时状态;键盘被系统回收/重建或跨页面回来时应当清空,避免误显示。 + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self setupUI]; + self.suggestionEngine = [KBSuggestionEngine shared]; + self.currentWord = @""; + // 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow) + [KBHUD setContainerView:self.view]; + // 绑定完全访问管理器,便于统一感知和联动网络开关 + [[KBFullAccessManager shared] bindInputController:self]; + self.kb_fullAccessObserverToken = [[NSNotificationCenter defaultCenter] + addObserverForName:KBFullAccessChangedNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(__unused NSNotification *_Nonnull note){ + // 如需,可在此刷新与完全访问相关的 UI + }]; + + // 皮肤变化时,立即应用 + __weak typeof(self) weakSelf = self; + self.kb_skinObserverToken = [[NSNotificationCenter defaultCenter] + addObserverForName:KBSkinDidChangeNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(__unused NSNotification *_Nonnull note) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + [self kb_applyTheme]; + }]; + [self kb_applyTheme]; + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), KBSkinInstallNotificationCallback, + (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); + [self kb_consumePendingShopSkin]; + [self kb_applyDefaultSkinIfNeeded]; +} + +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + // 扩展进程内存上限较小:在系统发出内存警告时主动清理可重建的缓存,降低被系统杀死概率。 + self.kb_cachedGradientImage = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + [[KBSkinManager shared] clearRuntimeImageCaches]; + [[SDImageCache sharedImageCache] clearMemory]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + // FIX: iOS 26 键盘闪烁问题 —— 恢复键盘正确高度 + // setupUI 中高度初始为 0(防止系统预渲染快照闪烁),此处恢复为实际键盘高度。 + // 此时系统已准备好键盘滑入动画,恢复高度后键盘将正常从底部滑入。 + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; + if (self.kb_heightConstraint) { + self.kb_heightConstraint.constant = keyboardHeight; + } + // 进入/重新进入输入界面时,清理上一次会话残留的撤销状态与缓存,避免显示“撤销删除”但实际上已不可撤销。 + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [[KBInputBufferManager shared] resetWithText:@""]; + [[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded]; + // 键盘再次出现时,恢复 HUD 容器与主题(viewDidDisappear 里可能已清理图片/缓存)。 + [KBHUD setContainerView:self.view]; + [self kb_ensureKeyBoardMainViewIfNeeded]; + [self kb_applyTheme]; +#if DEBUG + NSLog(@"[Keyboard] viewWillAppear self=%p mem=%@", + self, KBFormatMB(KBPhysFootprintBytes())); +#endif + // 注意:微信/QQ 等宿主的 documentContext 可能是“截断窗口”,这里只更新 + // liveText,不要把它当作全文 manualSnapshot。 + [[KBInputBufferManager shared] + updateFromExternalContextBefore:self.textDocumentProxy + .documentContextBeforeInput + after:self.textDocumentProxy + .documentContextAfterInput]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self kb_releaseMemoryWhenKeyboardHidden]; +#if DEBUG + NSLog(@"[Keyboard] viewWillDisappear self=%p mem=%@", + self, KBFormatMB(KBPhysFootprintBytes())); +#endif +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + // 再兜底一次,防止某些宿主只触发 willDisappear 而未触发 didDisappear。 + [self kb_releaseMemoryWhenKeyboardHidden]; +} + +- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + if (@available(iOS 13.0, *)) { + if (previousTraitCollection.userInterfaceStyle != + self.traitCollection.userInterfaceStyle) { + self.kb_cachedGradientImage = nil; + [self kb_applyDefaultSkinIfNeeded]; + } + } +} + +- (void)textDidChange:(id)textInput { + [super textDidChange:textInput]; + [[KBInputBufferManager shared] + updateFromExternalContextBefore:self.textDocumentProxy + .documentContextBeforeInput + after:self.textDocumentProxy + .documentContextAfterInput]; +} + +- (void)setupUI { + self.view.translatesAutoresizingMaskIntoConstraints = NO; + + // 按“短边”宽度等比缩放,横屏保持竖屏布局比例 + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; + CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; + CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds); + + // FIX: iOS 26 键盘闪烁问题 + // iOS 26 在键盘滑入动画开始前,会对 self.view 做一次离屏预渲染快照(非实时 view), + // 该快照会短暂显示在屏幕中间。如果此时 view 已有完整高度和内容,用户就会看到 + // 键盘 UI 在屏幕中间闪现一帧,然后键盘才从底部正常滑入。 + // 解决方案:初始高度设为 0,让系统快照时无内容可渲染; + // 在 viewWillAppear: 中恢复正确高度,此时系统已准备好滑入动画。 + // (iOS 18 及更早版本无此预渲染机制,不受影响) + NSLayoutConstraint *h = + [self.view.heightAnchor constraintEqualToConstant:0]; + NSLayoutConstraint *w = + [self.view.widthAnchor constraintEqualToConstant:screenWidth]; + self.kb_heightConstraint = h; + self.kb_widthConstraint = w; + + h.priority = UILayoutPriorityRequired; + w.priority = UILayoutPriorityRequired; + [NSLayoutConstraint activateConstraints:@[ h, w ]]; + // 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突) + if ([self.view isKindOfClass:[UIInputView class]]) { + UIInputView *iv = (UIInputView *)self.view; + if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) { + iv.allowsSelfSizing = NO; + } + } + // 内容容器:横屏时保持竖屏宽度,居中显示 + [self.view addSubview:self.contentView]; + [self.contentView mas_makeConstraints:^(MASConstraintMaker *make) { + make.centerX.equalTo(self.view); + make.bottom.equalTo(self.view); + self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth); + self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight); + }]; + + // 背景图铺底(仅在内容容器内) + [self.contentView addSubview:self.bgImageView]; + [self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + + [self.contentView addSubview:self.keyBoardMainView]; + [self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.contentView); + self.keyBoardMainHeightConstraint = + make.height.mas_equalTo(keyboardBaseHeight); + }]; + + // 初始隐藏,避免布局完成前闪烁 + self.contentView.hidden = YES; +} + +#pragma mark - Private + +// MARK: - Suggestions + +- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text { + if (text.length == 0) { + return; + } + if ([self kb_isAlphabeticString:text]) { + NSString *current = self.currentWord ?: @""; + self.currentWord = [current stringByAppendingString:text]; + self.suppressSuggestions = NO; + [self kb_updateSuggestionsForCurrentWord]; + } else { + [self kb_clearCurrentWord]; + } +} + +- (void)kb_clearCurrentWord { + self.currentWord = @""; + [self.keyBoardMainView kb_setSuggestions:@[]]; + self.suppressSuggestions = NO; +} + +- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression { + dispatch_async(dispatch_get_main_queue(), ^{ + [self kb_refreshCurrentWordFromDocumentContextResetSuppression: + resetSuppression]; + }); +} + +- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression: + (BOOL)resetSuppression { + NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @""; + NSString *word = [self kb_extractTrailingWordFromContext:context]; + self.currentWord = word ?: @""; + if (resetSuppression) { + self.suppressSuggestions = NO; + } + [self kb_updateSuggestionsForCurrentWord]; +} + +- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context { + if (context.length == 0) { + return @""; + } + static NSCharacterSet *letters = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + letters = [NSCharacterSet + characterSetWithCharactersInString: + @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; + }); + + NSInteger idx = (NSInteger)context.length - 1; + while (idx >= 0) { + unichar ch = [context characterAtIndex:(NSUInteger)idx]; + if (![letters characterIsMember:ch]) { + break; + } + idx -= 1; + } + NSUInteger start = (NSUInteger)(idx + 1); + if (start >= context.length) { + return @""; + } + return [context substringFromIndex:start]; +} + +- (BOOL)kb_isAlphabeticString:(NSString *)text { + if (text.length == 0) { + return NO; + } + static NSCharacterSet *letters = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + letters = [NSCharacterSet + characterSetWithCharactersInString: + @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; + }); + for (NSUInteger i = 0; i < text.length; i++) { + if (![letters characterIsMember:[text characterAtIndex:i]]) { + return NO; + } + } + return YES; +} + +- (void)kb_updateSuggestionsForCurrentWord { + NSString *prefix = self.currentWord ?: @""; + if (prefix.length == 0) { + [self.keyBoardMainView kb_setSuggestions:@[]]; + return; + } + if (self.suppressSuggestions) { + [self.keyBoardMainView kb_setSuggestions:@[]]; + return; + } + NSArray *items = + [self.suggestionEngine suggestionsForPrefix:prefix limit:5]; + NSArray *cased = [self kb_applyCaseToSuggestions:items + prefix:prefix]; + [self.keyBoardMainView kb_setSuggestions:cased]; +} + +- (NSArray *)kb_applyCaseToSuggestions:(NSArray *)items + prefix:(NSString *)prefix { + if (items.count == 0 || prefix.length == 0) { + return items; + } + BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString]; + BOOL firstUpper = [[prefix substringToIndex:1] + isEqualToString:[[prefix substringToIndex:1] uppercaseString]]; + + if (!allUpper && !firstUpper) { + return items; + } + + NSMutableArray *result = + [NSMutableArray arrayWithCapacity:items.count]; + for (NSString *word in items) { + if (allUpper) { + [result addObject:word.uppercaseString]; + } else { + NSString *first = [[word substringToIndex:1] uppercaseString]; + NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @""; + [result addObject:[first stringByAppendingString:rest]]; + } + } + return result.copy; +} + +/// 切换显示功能面板/键盘主视图 +- (void)showFunctionPanel:(BOOL)show { + // 简单显隐切换,复用相同的布局区域 + if (show) { + [self showChatPanel:NO]; + [self kb_ensureFunctionViewIfNeeded]; + } + if (_functionView) { + _functionView.hidden = !show; + } else if (show) { + // ensure 后按理已存在;这里兜底一次,避免异常情况下状态不一致 + self.functionView.hidden = NO; + } + self.keyBoardMainView.hidden = show; + + if (show) { + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_function_panel" + pageId:@"keyboard_function_panel" + extra:nil + completion:nil]; + [self hideSubscriptionPanel]; + } else { + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_main_panel" + pageId:@"keyboard_main_panel" + extra:nil + completion:nil]; + } + + // 可选:把当前显示的视图置顶,避免层级遮挡 + if (show) { + if (_functionView) { + [self.contentView bringSubviewToFront:_functionView]; + } + } else { + [self.contentView bringSubviewToFront:self.keyBoardMainView]; + } +} + +/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出 +- (void)showSettingView:(BOOL)show { + if (show) { + [self showChatPanel:NO]; + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_settings" + pageId:@"keyboard_settings" + extra:nil + completion:nil]; + KBSettingView *settingView = self.settingView; + if (!settingView.superview) { + settingView.hidden = YES; + [self.contentView addSubview:settingView]; + [settingView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + [settingView.backButton addTarget:self + action:@selector(onTapSettingsBack) + forControlEvents:UIControlEventTouchUpInside]; + } + [self.contentView bringSubviewToFront:settingView]; + // 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算 + [self.contentView layoutIfNeeded]; + CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); + if (w <= 0) { + w = CGRectGetWidth(self.contentView.bounds); + } + if (w <= 0) { + w = [self kb_portraitWidth]; + } + settingView.transform = CGAffineTransformMakeTranslation(w, 0); + settingView.hidden = NO; + [UIView animateWithDuration:0.25 + delay:0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + settingView.transform = CGAffineTransformIdentity; + } + completion:nil]; + } else { + KBSettingView *settingView = self.settingView; + if (!settingView.superview || settingView.hidden) + return; + CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); + if (w <= 0) { + w = CGRectGetWidth(self.contentView.bounds); + } + if (w <= 0) { + w = [self kb_portraitWidth]; + } + [UIView animateWithDuration:0.22 + delay:0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + settingView.transform = CGAffineTransformMakeTranslation(w, 0); + } + completion:^(BOOL finished) { + settingView.hidden = YES; + }]; + } +} + +/// 显示/隐藏聊天面板(覆盖整个键盘区域) +- (void)showChatPanel:(BOOL)show { + if (show == self.chatPanelVisible) { + return; + } + self.chatPanelVisible = show; + if (show) { + // 记录打开聊天面板时宿主输入框已有的文本,发送时只取新增部分 + [[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy]; + self.chatPanelBaselineText = [KBInputBufferManager shared].liveText ?: @""; + [self kb_ensureChatPanelViewIfNeeded]; + self.chatPanelView.hidden = NO; + self.chatPanelView.alpha = 0.0; + [self.contentView bringSubviewToFront:self.chatPanelView]; + if (_functionView) { + _functionView.hidden = YES; + } + [self hideSubscriptionPanel]; + [self showSettingView:NO]; + [UIView animateWithDuration:0.2 + delay:0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + self.chatPanelView.alpha = 1.0; + } + completion:nil]; + } else { + // 从未创建过聊天面板时,直接返回,避免 show/hide 触发额外内存分配 + if (!_chatPanelView) { + [self kb_updateKeyboardLayoutIfNeeded]; + return; + } + [UIView animateWithDuration:0.18 + delay:0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + self.chatPanelView.alpha = 0.0; + } + completion:^(BOOL finished) { + self.chatPanelView.hidden = YES; + }]; + } + [self kb_updateKeyboardLayoutIfNeeded]; +} + +// 延迟创建:仅在用户真正打开功能面板时才创建/布局,降低默认内存占用。 +- (void)kb_ensureFunctionViewIfNeeded { + if (_functionView && _functionView.superview) { + return; + } + KBFunctionView *v = self.functionView; + if (!v.superview) { + v.hidden = YES; + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + } +} + +// 延迟创建:仅在用户打开聊天面板时才创建/布局。 +- (void)kb_ensureChatPanelViewIfNeeded { + if (_chatPanelView && _chatPanelView.superview) { + return; + } + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; + KBChatPanelView *v = self.chatPanelView; + if (!v.superview) { + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.keyBoardMainView.mas_top); + self.chatPanelHeightConstraint = + make.height.mas_equalTo(chatPanelHeight); + }]; + v.hidden = YES; + } +} + +// 延迟创建:键盘主面板(按键区)在隐藏时会被释放;再次显示时需要重建。 +- (void)kb_ensureKeyBoardMainViewIfNeeded { + if (_keyBoardMainView && _keyBoardMainView.superview) { + return; + } + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardBaseHeight = + [self kb_keyboardBaseHeightForWidth:portraitWidth]; + KBKeyBoardMainView *v = self.keyBoardMainView; + if (!v.superview) { + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.contentView); + self.keyBoardMainHeightConstraint = + make.height.mas_equalTo(keyboardBaseHeight); + }]; + } + [self.contentView bringSubviewToFront:v]; +} + +// 键盘隐藏时释放可重建资源(背景图/缓存/非必需面板),降低扩展内存峰值。 +- (void)kb_releaseMemoryWhenKeyboardHidden { + [KBHUD setContainerView:nil]; + self.bgImageView.image = nil; + self.kb_cachedGradientImage = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + [[SDImageCache sharedImageCache] clearMemory]; + + // 聊天相关可能持有音频数据/临时文件,键盘隐藏时直接清空,避免累计占用。 + if (self.chatAudioPlayer) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + if (_chatMessages.count > 0) { + NSString *tmpRoot = NSTemporaryDirectory(); + for (KBChatMessage *msg in _chatMessages.copy) { + if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 && + [msg.audioFilePath hasPrefix:tmpRoot]) { + [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath + error:nil]; + } + } + [_chatMessages removeAllObjects]; + } + + if (_keyBoardMainView) { + [_keyBoardMainView removeFromSuperview]; + _keyBoardMainView = nil; + } + self.keyBoardMainHeightConstraint = nil; + + if (_functionView) { + [_functionView removeFromSuperview]; + _functionView = nil; + } + if (_chatPanelView) { + [_chatPanelView removeFromSuperview]; + _chatPanelView = nil; + } + self.chatPanelVisible = NO; + + if (_subscriptionView) { + [_subscriptionView removeFromSuperview]; + _subscriptionView = nil; + } + if (_settingView) { + [_settingView removeFromSuperview]; + _settingView = nil; + } +} + +- (void)showSubscriptionPanel { + // 1) 先判断权限:未开启“完全访问”则走引导逻辑 + if (![[KBFullAccessManager shared] hasFullAccess]) { + // 未开启完全访问:保持原有引导路径 + // [KBHUD showInfo:KBLocalized(@"处理中…")]; + [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]; + return; + } + // 点击充值要先判断是否登录 + // 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App + // 负责完成登录 + if (!KBAuthManager.shared.isLoggedIn) { + NSString *schemeStr = + [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:schemeStr]; + // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App + BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; + return; + } + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_subscription_panel" + pageId:@"keyboard_subscription_panel" + extra:nil + completion:nil]; + [self showFunctionPanel:NO]; + KBKeyboardSubscriptionView *panel = self.subscriptionView; + if (!panel.superview) { + panel.hidden = YES; + [self.contentView addSubview:panel]; + [panel mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + } + [self.contentView bringSubviewToFront:panel]; + panel.hidden = NO; + panel.alpha = 0.0; + CGFloat height = CGRectGetHeight(self.contentView.bounds); + if (height <= 0) { + height = 260; + } + panel.transform = CGAffineTransformMakeTranslation(0, height); + [panel refreshProductsIfNeeded]; + [UIView animateWithDuration:0.25 + delay:0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + panel.alpha = 1.0; + panel.transform = CGAffineTransformIdentity; + } + completion:nil]; +} + +- (void)hideSubscriptionPanel { + if (!self.subscriptionView || self.subscriptionView.hidden) { + return; + } + CGFloat height = CGRectGetHeight(self.subscriptionView.bounds); + if (height <= 0) { + height = CGRectGetHeight(self.contentView.bounds); + } + KBKeyboardSubscriptionView *panel = self.subscriptionView; + [UIView animateWithDuration:0.22 + delay:0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + panel.alpha = 0.0; + panel.transform = CGAffineTransformMakeTranslation(0, height); + } + completion:^(BOOL finished) { + panel.hidden = YES; + panel.alpha = 1.0; + panel.transform = CGAffineTransformIdentity; + }]; +} + +// MARK: - KBKeyBoardMainViewDelegate +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didTapKey:(KBKey *)key { + switch (key.type) { + case KBKeyTypeCharacter: { + [[KBBackspaceUndoManager shared] registerNonClearAction]; + NSString *text = key.output ?: key.title ?: @""; + [self.textDocumentProxy insertText:text]; + [self kb_updateCurrentWordWithInsertedText:text]; + [[KBInputBufferManager shared] appendText:text]; + } break; + case KBKeyTypeBackspace: + [[KBInputBufferManager shared] + refreshFromProxyIfPossible:self.textDocumentProxy]; + [[KBInputBufferManager shared] + prepareSnapshotForDeleteWithContextBefore: + self.textDocumentProxy.documentContextBeforeInput + after: + self.textDocumentProxy + .documentContextAfterInput]; + [[KBBackspaceUndoManager shared] + captureAndDeleteBackwardFromProxy:self.textDocumentProxy + count:1]; + [self kb_scheduleContextRefreshResetSuppression:NO]; + [[KBInputBufferManager shared] applyHoldDeleteCount:1]; + break; + case KBKeyTypeSpace: + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self.textDocumentProxy insertText:@" "]; + [self kb_clearCurrentWord]; + [[KBInputBufferManager shared] appendText:@" "]; + break; + case KBKeyTypeReturn: + if (self.chatPanelVisible) { + [self kb_handleChatSendAction]; + break; + } + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self.textDocumentProxy insertText:@"\n"]; + [self kb_clearCurrentWord]; + [[KBInputBufferManager shared] appendText:@"\n"]; + break; + case KBKeyTypeGlobe: + [self advanceToNextInputMode]; + break; + case KBKeyTypeCustom: + [[KBBackspaceUndoManager shared] registerNonClearAction]; + // 点击自定义键切换到功能面板 + [self showFunctionPanel:YES]; + [self kb_clearCurrentWord]; + break; + case KBKeyTypeModeChange: + case KBKeyTypeShift: + // 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理 + break; + } +} + +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didTapToolActionAtIndex:(NSInteger)index { + NSDictionary *extra = @{@"index" : @(index)}; + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_toolbar_action" + pageId:@"keyboard_main_panel" + elementId:@"toolbar_action" + extra:extra + completion:nil]; + if (index == 0) { + [self showChatPanel:NO]; + [self showFunctionPanel:YES]; + [self kb_clearCurrentWord]; + return; + } + if (index == 1) { + [self showFunctionPanel:NO]; + [self showChatPanel:YES]; + return; + } + [self showFunctionPanel:NO]; + [self showChatPanel:NO]; +} + +- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_settings_btn" + pageId:@"keyboard_main_panel" + elementId:@"settings_btn" + extra:nil + completion:nil]; + [self showSettingView:YES]; +} + +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didSelectEmoji:(NSString *)emoji { + if (emoji.length == 0) { + return; + } + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self.textDocumentProxy insertText:emoji]; + [self kb_clearCurrentWord]; + [[KBInputBufferManager shared] appendText:emoji]; +} + +- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_undo_btn" + pageId:@"keyboard_main_panel" + elementId:@"undo_btn" + extra:nil + completion:nil]; + [[KBBackspaceUndoManager shared] performUndoFromResponder:self.view]; + [self kb_scheduleContextRefreshResetSuppression:YES]; +} + +- (void)keyBoardMainViewDidTapEmojiSearch: + (KBKeyBoardMainView *)keyBoardMainView { +// [[KBMaiPointReporter sharedReporter] +// reportClickWithEventName:@"click_keyboard_emoji_search_btn" +// pageId:@"keyboard_main_panel" +// elementId:@"emoji_search_btn" +// extra:nil +// completion:nil]; + [KBHUD showInfo:KBLocalized(@"Search coming soon")]; +} + +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didSelectSuggestion:(NSString *)suggestion { + if (suggestion.length == 0) { + return; + } + NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)}; +// [[KBMaiPointReporter sharedReporter] +// reportClickWithEventName:@"click_keyboard_suggestion_item" +// pageId:@"keyboard_main_panel" +// elementId:@"suggestion_item" +// extra:extra +// completion:nil]; + [[KBBackspaceUndoManager shared] registerNonClearAction]; + NSString *current = self.currentWord ?: @""; + if (current.length > 0) { + for (NSUInteger i = 0; i < current.length; i++) { + [self.textDocumentProxy deleteBackward]; + } + } + [self.textDocumentProxy insertText:suggestion]; + self.currentWord = suggestion; + [self.suggestionEngine recordSelection:suggestion]; + self.suppressSuggestions = YES; + [self.keyBoardMainView kb_setSuggestions:@[]]; + [[KBInputBufferManager shared] replaceTailWithText:suggestion + deleteCount:current.length]; +} + +// MARK: - KBFunctionViewDelegate +- (void)functionView:(KBFunctionView *)functionView + didTapToolActionAtIndex:(NSInteger)index { + // 需求:当 index == 0 时,切回键盘主视图 + if (index == 0) { + [self showFunctionPanel:NO]; + } +} +- (void)functionView:(KBFunctionView *_Nullable)functionView + didRightTapToolActionAtIndex:(NSInteger)index { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_function_right_action" + pageId:@"keyboard_function_panel" + elementId:@"right_action" + extra:@{@"action" : @"login_or_recharge"} + completion:nil]; + if (!KBAuthManager.shared.isLoggedIn) { + NSString *schemeStr = + [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:schemeStr]; + // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App + BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; + return; + } + NSString *schemeStr = + [NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:schemeStr]; + // + // if (!ul && !scheme) { return; } + // + // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App + BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; + + if (!ok) { + // 失败兜底:给个文案提示 + // 比如:请回到桌面手动打开 XXX App 进行设置/充值 + [KBHUD showInfo:@"请回到桌面手动打开App进行充值"]; + } +} + +- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView { + [self showSubscriptionPanel]; +} + +#pragma mark - KBChatPanelViewDelegate + +- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text { + NSString *trim = + [text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trim.length == 0) { + return; + } + [self kb_sendChatText:trim]; +} + +- (void)chatPanelView:(KBChatPanelView *)view + didTapMessage:(KBChatMessage *)message { + if (message.audioFilePath.length == 0) { + return; + } + [self kb_playChatAudioAtPath:message.audioFilePath]; +} + +- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message { + if (!message) return; + + // 如果有 audioData,直接播放 + if (message.audioData && message.audioData.length > 0) { + [self kb_playChatAudioData:message.audioData]; + return; + } + + // 如果有 audioFilePath,播放文件 + if (message.audioFilePath.length > 0) { + [self kb_playChatAudioAtPath:message.audioFilePath]; + return; + } + + NSLog(@"[Keyboard] 没有音频数据可播放"); +} + +- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view { + // 清空 chatPanelView 内部的消息 + [view kb_reloadWithMessages:@[]]; + if (self.chatAudioPlayer.isPlaying) { + [self.chatAudioPlayer stop]; + } + self.chatAudioPlayer = nil; + [self showChatPanel:NO]; +} + +#pragma mark - Chat Helpers + +- (void)kb_handleChatSendAction { + if (!self.chatPanelVisible) { + return; + } + [[KBInputBufferManager shared] refreshFromProxyIfPossible:self.textDocumentProxy]; + NSString *fullText = [KBInputBufferManager shared].liveText ?: @""; + + // 去掉打开聊天面板前宿主输入框里已有的基线文本,只取新增部分 + NSString *baseline = self.chatPanelBaselineText ?: @""; + NSString *rawText = fullText; + if (baseline.length > 0 && [fullText hasPrefix:baseline]) { + rawText = [fullText substringFromIndex:baseline.length]; + } + + NSString *trim = + [rawText stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trim.length == 0) { + [KBHUD showInfo:KBLocalized(@"请输入内容")]; + return; + } + [self kb_sendChatText:trim]; + // 只清除新增的文本,保留基线文本 + [self kb_clearHostInputForText:rawText]; +} + +- (void)kb_sendChatText:(NSString *)text { + if (text.length == 0) { + return; + } + NSLog(@"[KB] 发送消息: %@", text); + + KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text]; + outgoing.avatarURL = [self kb_sharedUserAvatarURL]; + [self.chatPanelView kb_addUserMessage:text]; + [self kb_prefetchAvatarForMessage:outgoing]; + + if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) { + [KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")]; + return; + } + + // 添加 loading 消息 + [self.chatPanelView kb_addLoadingAssistantMessage]; + + // 调用新的聊天接口 + [self kb_requestChatMessageWithContent:text]; +} + +#pragma mark - Chat Limit Pop + +- (void)kb_showChatLimitPopWithMessage:(NSString *)message { + [self kb_dismissChatLimitPop]; + + UIControl *mask = [[UIControl alloc] init]; + mask.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4]; + mask.alpha = 0.0; + [mask addTarget:self + action:@selector(kb_dismissChatLimitPop) + forControlEvents:UIControlEventTouchUpInside]; + [self.contentView addSubview:mask]; + [mask mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + + CGFloat width = 252.0; + CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0; + KBChatLimitPopView *content = + [[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)]; + content.message = message ?: @""; + content.delegate = self; + [mask addSubview:content]; + [content mas_makeConstraints:^(MASConstraintMaker *make) { + make.center.equalTo(mask); + make.width.mas_equalTo(width); + make.height.mas_equalTo(height); + }]; + + self.chatLimitMaskView = mask; + [self.contentView bringSubviewToFront:mask]; + [UIView animateWithDuration:0.18 + animations:^{ + mask.alpha = 1.0; + }]; +} + +- (void)kb_dismissChatLimitPop { + if (!self.chatLimitMaskView) { + return; + } + UIControl *mask = self.chatLimitMaskView; + self.chatLimitMaskView = nil; + [UIView animateWithDuration:0.15 + animations:^{ + mask.alpha = 0.0; + } + completion:^(__unused BOOL finished) { + [mask removeFromSuperview]; + }]; +} + +- (void)kb_clearHostInputForText:(NSString *)text { + if (text.length == 0) { + return; + } + NSUInteger count = [self kb_composedCharacterCountForString:text]; + for (NSUInteger i = 0; i < count; i++) { + [self.textDocumentProxy deleteBackward]; + } + [[KBInputBufferManager shared] clearAllLiveText]; + [self kb_clearCurrentWord]; +} + +- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text { + if (text.length == 0) { + return 0; + } + __block NSUInteger count = 0; + [text enumerateSubstringsInRange:NSMakeRange(0, text.length) + options:NSStringEnumerationByComposedCharacterSequences + usingBlock:^(__unused NSString *substring, + __unused NSRange substringRange, + __unused NSRange enclosingRange, + __unused BOOL *stop) { + count += 1; + }]; + return count; +} + +- (NSString *)kb_sharedUserAvatarURL { + NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; + NSString *url = [ud stringForKey:AppGroup_UserAvatarURL]; + return url ?: @""; +} + +- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message { + if (!message || message.avatarImage) { + return; + } + NSString *urlString = message.avatarURL ?: @""; + if (urlString.length == 0) { + return; + } + if (![[KBFullAccessManager shared] hasFullAccess]) { + return; + } + __weak typeof(self) weakSelf = self; + [[KBVM shared] downloadAvatarFromURL:urlString completion:^(UIImage *image, NSError *error) { + __strong typeof(weakSelf) self = weakSelf; + if (!self || !image) return; + + message.avatarImage = image; + [self kb_reloadChatRowForMessage:message]; + }]; +} + +- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message { + // 头像预加载完成后不需要刷新表格 + // 因为键盘扩展的聊天面板不显示头像,所以这里直接返回 + // 如果将来需要显示头像,可以只刷新特定行而不是整个表格 +} + +- (void)kb_requestChatAudioForText:(NSString *)text { + NSString *mockPath = [self kb_mockChatAudioPath]; + if (mockPath.length > 0) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + NSString *displayText = KBLocalized(@"语音回复"); + KBChatMessage *incoming = + [KBChatMessage messageWithText:displayText + outgoing:NO + audioFilePath:mockPath]; + incoming.displayName = KBLocalized(@"AI助手"); + [self kb_appendChatMessage:incoming]; + [self kb_playChatAudioAtPath:mockPath]; + }); + return; + } + NSDictionary *payload = @{@"message" : text ?: @""}; + __weak typeof(self) weakSelf = self; + [[KBNetworkManager shared] POST:API_AI_TALK + jsonBody:payload + headers:nil + completion:^(NSDictionary *json, NSURLResponse *response, + NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + __strong typeof(weakSelf) self = weakSelf; + if (!self) { + return; + } + if (error) { + NSString *tip = error.localizedDescription + ?: KBLocalized(@"请求失败"); + [KBHUD showInfo:tip]; + return; + } + NSString *displayText = + [self kb_chatTextFromJSON:json]; + NSString *audioURL = + [self kb_chatAudioURLFromJSON:json]; + NSString *audioBase64 = + [self kb_chatAudioBase64FromJSON:json]; + if (audioURL.length > 0) { + [self kb_downloadChatAudioFromURL:audioURL + displayText:displayText]; + return; + } + if (audioBase64.length > 0) { + NSData *data = [[NSData alloc] + initWithBase64EncodedString:audioBase64 + options:0]; + if (data.length == 0) { + [KBHUD showInfo:KBLocalized(@"音频数据解析失败")]; + return; + } + [self kb_handleChatAudioData:data + fileExtension:@"m4a" + displayText:displayText]; + return; + } + [KBHUD showInfo:KBLocalized(@"未获取到音频文件")]; + }); + }]; +} + +#pragma mark - New Chat API (with typewriter effect and audio preload) + +/// 调用新的聊天接口(返回文本和 audioId) +- (void)kb_requestChatMessageWithContent:(NSString *)content { + if (content.length == 0) { + [self.chatPanelView kb_removeLoadingAssistantMessage]; + return; + } + + NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup]; + NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId); + + __weak typeof(self) weakSelf = self; + [[KBVM shared] sendChatMessageWithContent:content + companionId:companionId + completion:^(KBChatResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) return; + + if (response.code != 0) { + if (response.code == 50030) { + NSLog(@"[KB] ⚠️ 次数用尽: %@", response.message); + [self.chatPanelView kb_removeLoadingAssistantMessage]; + [self kb_showChatLimitPopWithMessage:response.message]; + return; + } + NSLog(@"[KB] ❌ 请求失败: %@", response.message); + [self.chatPanelView kb_removeLoadingAssistantMessage]; + [KBHUD showInfo:response.message ?: KBLocalized(@"请求失败")]; + return; + } + + NSLog(@"[KB] ✅ 收到回复: %@", response.data.aiResponse); + + if (response.data.aiResponse.length == 0) { + [self.chatPanelView kb_removeLoadingAssistantMessage]; + [KBHUD showInfo:KBLocalized(@"未获取到回复内容")]; + return; + } + + // 添加 AI 消息(带打字机效果) + NSLog(@"[KB] 准备添加 AI 消息"); + [self.chatPanelView kb_addAssistantMessage:response.data.aiResponse audioId:response.data.audioId]; + NSLog(@"[KB] AI 消息添加完成"); + + // 如果有 audioId,开始预加载音频 + if (response.data.audioId.length > 0) { + [self kb_preloadAudioWithAudioId:response.data.audioId]; + } + }]; +} + +/// 从 AppGroup 获取选中的 persona companionId +- (NSInteger)kb_selectedCompanionId { + return [[KBVM shared] selectedCompanionIdFromAppGroup]; +} + +#pragma mark - Audio Preload + +/// 预加载音频(轮询获取 audioURL) +- (void)kb_preloadAudioWithAudioId:(NSString *)audioId { + if (audioId.length == 0) return; + + NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId); + + __weak typeof(self) weakSelf = self; + [[KBVM shared] pollAudioURLWithAudioId:audioId + maxRetries:10 + interval:1.0 + completion:^(KBAudioResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) return; + + if (!response.success || response.audioURL.length == 0) { + NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@", response.errorMessage); + return; + } + + NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功"); + + // 下载音频 + [[KBVM shared] downloadAudioFromURL:response.audioURL + completion:^(KBAudioResponse *audioResponse) { + if (!audioResponse.success) { + NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@", audioResponse.errorMessage); + return; + } + + // 更新最后一条 AI 消息的音频数据 + [self.chatPanelView kb_updateLastAssistantMessageWithAudioData:audioResponse.audioData + duration:audioResponse.duration]; + NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒", audioResponse.duration); + }]; + }]; +} + +- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL + displayText:(NSString *)displayText { + __weak typeof(self) weakSelf = self; + [[KBVM shared] downloadAudioFromURL:audioURL completion:^(KBAudioResponse *response) { + __strong typeof(weakSelf) self = weakSelf; + if (!self) return; + + if (!response.success) { + [KBHUD showInfo:response.errorMessage ?: KBLocalized(@"下载失败")]; + return; + } + + if (!response.audioData || response.audioData.length == 0) { + [KBHUD showInfo:KBLocalized(@"未获取到音频数据")]; + return; + } + + NSString *ext = @"m4a"; + NSURL *url = [NSURL URLWithString:audioURL]; + if (url.pathExtension.length > 0) { + ext = url.pathExtension; + } + [self kb_handleChatAudioData:response.audioData + fileExtension:ext + displayText:displayText]; + }]; +} + +- (void)kb_handleChatAudioData:(NSData *)data + fileExtension:(NSString *)extension + displayText:(NSString *)displayText { + if (data.length == 0) { + [KBHUD showInfo:KBLocalized(@"音频数据为空")]; + return; + } + NSString *ext = extension.length > 0 ? extension : @"m4a"; + NSString *fileName = [NSString + stringWithFormat:@"kb_chat_%@.%@", + @((long long)([NSDate date].timeIntervalSince1970 * + 1000)), + ext]; + NSString *filePath = + [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; + if (![data writeToFile:filePath atomically:YES]) { + [KBHUD showInfo:KBLocalized(@"音频保存失败")]; + return; + } + NSString *text = displayText.length > 0 ? displayText : KBLocalized(@"语音消息"); + KBChatMessage *incoming = + [KBChatMessage messageWithText:text + outgoing:NO + audioFilePath:filePath]; + incoming.displayName = KBLocalized(@"AI助手"); + [self kb_appendChatMessage:incoming]; +} + +- (void)kb_appendChatMessage:(KBChatMessage *)message { + if (!message) { + return; + } + [self.chatMessages addObject:message]; + if (self.chatMessages.count > kKBChatMessageLimit) { + NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit; + NSArray *removed = + [self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)]; + [self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)]; + for (KBChatMessage *msg in removed) { + if (msg.audioFilePath.length > 0) { + NSString *tmpRoot = NSTemporaryDirectory(); + if (tmpRoot.length > 0 && + [msg.audioFilePath hasPrefix:tmpRoot]) { + [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath + error:nil]; + } + } + } + } + [self.chatPanelView kb_reloadWithMessages:self.chatMessages]; +} + +- (NSString *)kb_mockChatAudioPath { + NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test" + ofType:@"m4a"]; + return path ?: @""; +} + +- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSString *text = + [self kb_stringValueInDict:data + keys:@[ @"text", @"message", @"content" ]]; + if (text.length == 0) { + text = [self kb_stringValueInDict:json + keys:@[ @"text", @"message", @"content" ]]; + } + return text ?: @""; +} + +- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSArray *keys = + @[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl", + @"file_url", @"audioFileUrl", @"audio_file_url" ]; + NSString *url = [self kb_stringValueInDict:data keys:keys]; + if (url.length == 0) { + url = [self kb_stringValueInDict:json keys:keys]; + } + return url ?: @""; +} + +- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json { + NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json]; + NSArray *keys = + @[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data", + @"base64" ]; + NSString *b64 = [self kb_stringValueInDict:data keys:keys]; + if (b64.length == 0) { + b64 = [self kb_stringValueInDict:json keys:keys]; + } + return b64 ?: @""; +} + +- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json { + if (![json isKindOfClass:[NSDictionary class]]) { + return @{}; + } + id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"]; + if ([dataObj isKindOfClass:[NSDictionary class]]) { + return (NSDictionary *)dataObj; + } + return @{}; +} + +- (NSString *)kb_stringValueInDict:(NSDictionary *)dict + keys:(NSArray *)keys { + if (![dict isKindOfClass:[NSDictionary class]]) { + return @""; + } + for (NSString *key in keys) { + id value = dict[key]; + if ([value isKindOfClass:[NSString class]] && + ((NSString *)value).length > 0) { + return (NSString *)value; + } + } + return @""; +} + +- (void)kb_playChatAudioAtPath:(NSString *)path { + if (path.length == 0) { + return; + } + NSURL *url = [NSURL fileURLWithPath:path]; + if (![NSFileManager.defaultManager fileExistsAtPath:path]) { + [KBHUD showInfo:KBLocalized(@"音频文件不存在")]; + return; + } + + if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { + NSURL *currentURL = self.chatAudioPlayer.url; + if ([currentURL isEqual:url]) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + return; + } + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + + NSError *sessionError = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + if ([session respondsToSelector:@selector(setCategory:options:error:)]) { + [session setCategory:AVAudioSessionCategoryPlayback + withOptions:AVAudioSessionCategoryOptionDuckOthers + error:&sessionError]; + } else { + [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; + } + [session setActive:YES error:nil]; + + NSError *playerError = nil; + AVAudioPlayer *player = + [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError]; + if (playerError || !player) { + [KBHUD showInfo:KBLocalized(@"音频播放失败")]; + return; + } + self.chatAudioPlayer = player; + [player prepareToPlay]; + [player play]; +} + +/// 播放音频数据 +- (void)kb_playChatAudioData:(NSData *)audioData { + if (!audioData || audioData.length == 0) { + NSLog(@"[Keyboard] 音频数据为空"); + return; + } + + // 如果正在播放,先停止 + if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + + // 配置音频会话 + NSError *sessionError = nil; + AVAudioSession *session = [AVAudioSession sharedInstance]; + if ([session respondsToSelector:@selector(setCategory:options:error:)]) { + [session setCategory:AVAudioSessionCategoryPlayback + withOptions:AVAudioSessionCategoryOptionDuckOthers + error:&sessionError]; + } else { + [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; + } + [session setActive:YES error:nil]; + + // 创建播放器 + NSError *playerError = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:audioData error:&playerError]; + if (playerError || !player) { + NSLog(@"[Keyboard] 音频播放器初始化失败: %@", playerError.localizedDescription); + [KBHUD showInfo:KBLocalized(@"音频播放失败")]; + return; + } + + self.chatAudioPlayer = player; + player.volume = 1.0; + [player prepareToPlay]; + [player play]; + + NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration); +} + +#pragma mark - KBKeyboardSubscriptionViewDelegate + +- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_subscription_close_btn" + pageId:@"keyboard_subscription_panel" + elementId:@"close_btn" + extra:nil + completion:nil]; + [self hideSubscriptionPanel]; +} + +- (void)subscriptionView:(KBKeyboardSubscriptionView *)view + didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product { + NSMutableDictionary *extra = [NSMutableDictionary dictionary]; + if ([product.productId isKindOfClass:NSString.class] && + product.productId.length > 0) { + extra[@"product_id"] = product.productId; + } + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_subscription_product_btn" + pageId:@"keyboard_subscription_panel" + elementId:@"product_btn" + extra:extra.copy + completion:nil]; + [self hideSubscriptionPanel]; + [self kb_openRechargeForProduct:product]; +} + +#pragma mark - KBChatLimitPopViewDelegate + +- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view { + [self kb_dismissChatLimitPop]; +} + +- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view { + [self kb_dismissChatLimitPop]; + NSString *urlString = + [NSString stringWithFormat:@"%@://recharge?src=keyboard&vipType=svip", + KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:urlString]; + BOOL success = [KBHostAppLauncher openHostAppURL:scheme + fromResponder:self.view]; + if (!success) { + [KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")]; + } +} + +#pragma mark - lazy +- (KBKeyBoardMainView *)keyBoardMainView { + if (!_keyBoardMainView) { + _keyBoardMainView = [[KBKeyBoardMainView alloc] init]; + _keyBoardMainView.delegate = self; + } + return _keyBoardMainView; +} + +- (KBFunctionView *)functionView { + if (!_functionView) { + _functionView = [[KBFunctionView alloc] init]; + _functionView.delegate = self; // 监听功能面板顶部Bar点击 + } + return _functionView; +} + +- (KBSettingView *)settingView { + if (!_settingView) { + _settingView = [[KBSettingView alloc] init]; + } + return _settingView; +} + +- (KBChatPanelView *)chatPanelView { + if (!_chatPanelView) { + NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!"); + _chatPanelView = [[KBChatPanelView alloc] init]; + _chatPanelView.delegate = self; + } + return _chatPanelView; +} + +- (NSMutableArray *)chatMessages { + if (!_chatMessages) { + _chatMessages = [NSMutableArray array]; + } + return _chatMessages; +} + +- (KBKeyboardSubscriptionView *)subscriptionView { + if (!_subscriptionView) { + _subscriptionView = [[KBKeyboardSubscriptionView alloc] init]; + _subscriptionView.delegate = self; + _subscriptionView.hidden = YES; + _subscriptionView.alpha = 0.0; + } + return _subscriptionView; +} + +#pragma mark - Actions + +- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product { + if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] || + product.productId.length == 0) { + [KBHUD showInfo:KBLocalized(@"Product unavailable")]; + return; + } + NSString *encodedId = [self.class kb_urlEncodedString:product.productId]; + NSString *title = [product displayTitle]; + NSString *encodedTitle = [self.class kb_urlEncodedString:title]; + NSMutableArray *params = + [NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil]; + if (encodedId.length) { + [params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]]; + } + if (encodedTitle.length) { + [params + addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]]; + } + NSString *query = [params componentsJoinedByString:@"&"]; + NSString *urlString = [NSString + stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query]; + NSURL *scheme = [NSURL URLWithString:urlString]; + BOOL success = [KBHostAppLauncher openHostAppURL:scheme + fromResponder:self.view]; + if (!success) { + [KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")]; + } +} + ++ (NSString *)kb_urlEncodedString:(NSString *)value { + if (value.length == 0) { + return @""; + } + NSString *reserved = @"!*'();:@&=+$,/?%#[]"; + NSMutableCharacterSet *allowed = + [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; + [allowed removeCharactersInString:reserved]; + return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed] + ?: @""; +} + +- (void)onTapSettingsBack { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_settings_back_btn" + pageId:@"keyboard_settings" + elementId:@"back_btn" + extra:nil + completion:nil]; + [self showSettingView:NO]; +} + +- (void)dealloc { + if (self.kb_fullAccessObserverToken) { + [[NSNotificationCenter defaultCenter] + removeObserver:self.kb_fullAccessObserverToken]; + self.kb_fullAccessObserverToken = nil; + } + if (self.kb_skinObserverToken) { + [[NSNotificationCenter defaultCenter] removeObserver:self.kb_skinObserverToken]; + self.kb_skinObserverToken = nil; + } + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL); +#if DEBUG + if (_kb_debugDidCountAlive) { + sKBKeyboardVCAliveCount -= 1; + } + NSLog(@"[Keyboard] KeyboardViewController dealloc alive=%ld self=%p mem=%@", + (long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes())); +#endif +} + +// 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App +// 决定是否真的弹登录)。 +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + // if (!_kb_didTriggerLoginDeepLinkOnce) { + // _kb_didTriggerLoginDeepLinkOnce = YES; + // // 仅在未登录时尝试拉起主App登录 + // if (!KBAuthManager.shared.isLoggedIn) { + // [self kb_tryOpenContainerForLoginIfNeeded]; + // } + // } +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; +// [self kb_updateKeyboardLayoutIfNeeded]; + + // 首次布局完成后显示,避免闪烁 + if (self.contentView.hidden) { + self.contentView.hidden = NO; + } + if (self.kb_defaultGradientLayer) { + self.kb_defaultGradientLayer.frame = self.bgImageView.bounds; + } +} + +- (void)viewWillTransitionToSize:(CGSize)size + withTransitionCoordinator: + (id)coordinator { + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + __weak typeof(self) weakSelf = self; + [coordinator + animateAlongsideTransition:^( + id _Nonnull context) { + [weakSelf kb_updateKeyboardLayoutIfNeeded]; + } + completion:^( + __unused id< + UIViewControllerTransitionCoordinatorContext> _Nonnull context) { + [weakSelf kb_updateKeyboardLayoutIfNeeded]; + }]; +} + +//- (void)kb_tryOpenContainerForLoginIfNeeded { +// // 使用与主 App 一致的自定义 Scheme +// NSURL *url = [NSURL URLWithString:[NSString +// stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]]; if (!url) +// return; KBWeakSelf [self.extensionContext openURL:url +// completionHandler:^(__unused BOOL success) { +// // 即使失败也不重复尝试;避免打扰。 +// __unused typeof(weakSelf) selfStrong = weakSelf; +// }]; +//} + +#pragma mark - Theme + +- (void)kb_applyTheme { + @autoreleasepool { + KBSkinTheme *t = [KBSkinManager shared].current; + UIImage *img = nil; + BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t]; + BOOL isDarkMode = [self kb_isDarkModeActive]; + + NSString *skinId = t.skinId ?: @""; + NSString *themeKey = + [NSString stringWithFormat:@"%@|default=%d|dark=%d", + skinId, isDefaultTheme, isDarkMode]; + BOOL themeChanged = + (self.kb_lastAppliedThemeKey.length == 0 || + ![self.kb_lastAppliedThemeKey isEqualToString:themeKey]); + if (themeChanged) { + self.kb_lastAppliedThemeKey = themeKey; + } + + CGSize size = self.bgImageView.bounds.size; + if (isDefaultTheme) { + if (isDarkMode) { + // 暗黑模式:直接使用背景色,不使用图片渲染 + // 这样可以避免图片渲染时的色彩空间转换导致颜色不一致 + img = nil; + self.bgImageView.image = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + // 使用与系统键盘底部完全相同的颜色 + if (@available(iOS 13.0, *)) { + // iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E) + // 但为了完美匹配,我们使用动态颜色并直接设置为背景 + UIColor *kbBgColor = + [UIColor colorWithDynamicProvider:^UIColor *_Nonnull( + UITraitCollection *_Nonnull traitCollection) { + if (traitCollection.userInterfaceStyle == + UIUserInterfaceStyleDark) { + // 暗黑模式下系统键盘实际背景色 + return [UIColor colorWithRed:43.0 / 255.0 + green:43.0 / 255.0 + blue:43.0 / 255.0 + alpha:1.0]; + } else { + return [UIColor colorWithRed:209.0 / 255.0 + green:211.0 / 255.0 + blue:219.0 / 255.0 + alpha:1.0]; + } + }]; + self.contentView.backgroundColor = kbBgColor; + self.bgImageView.backgroundColor = kbBgColor; + } else { + UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0 + green:43.0 / 255.0 + blue:43.0 / 255.0 + alpha:1.0]; + self.contentView.backgroundColor = darkColor; + self.bgImageView.backgroundColor = darkColor; + } + } else { + // 浅色模式:使用渐变层(避免生成大位图导致内存上涨) + if (size.width <= 0 || size.height <= 0) { + [self.view layoutIfNeeded]; + size = self.bgImageView.bounds.size; + } + if (size.width <= 0 || size.height <= 0) { + size = self.view.bounds.size; + } + if (size.width <= 0 || size.height <= 0) { + size = [UIScreen mainScreen].bounds.size; + } + UIColor *topColor = [UIColor colorWithHex:0xDEDFE4]; + UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB]; + UIColor *resolvedTopColor = topColor; + UIColor *resolvedBottomColor = bottomColor; + if (@available(iOS 13.0, *)) { + resolvedTopColor = + [topColor resolvedColorWithTraitCollection:self.traitCollection]; + resolvedBottomColor = [bottomColor + resolvedColorWithTraitCollection:self.traitCollection]; + } + CAGradientLayer *layer = self.kb_defaultGradientLayer; + if (!layer) { + layer = [CAGradientLayer layer]; + layer.startPoint = CGPointMake(0.5, 0.0); + layer.endPoint = CGPointMake(0.5, 1.0); + [self.bgImageView.layer insertSublayer:layer atIndex:0]; + self.kb_defaultGradientLayer = layer; + } + layer.colors = @[ + (id)resolvedTopColor.CGColor, + (id)resolvedBottomColor.CGColor + ]; + layer.frame = (CGRect){CGPointZero, size}; + img = nil; + self.bgImageView.image = nil; + self.contentView.backgroundColor = [UIColor clearColor]; + self.bgImageView.backgroundColor = [UIColor clearColor]; + } + NSLog(@"==="); + } else { + // 自定义皮肤:清除背景色,使用皮肤图片 + self.contentView.backgroundColor = [UIColor clearColor]; + self.bgImageView.backgroundColor = [UIColor clearColor]; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + img = [[KBSkinManager shared] currentBackgroundImage]; + } + NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil)); + [self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img]; + self.bgImageView.image = img; + + // 触发键区按主题重绘 + if (themeChanged && + [self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) { +// method declared in KBKeyBoardMainView.h +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [self.keyBoardMainView performSelector:@selector(kb_applyTheme)]; +#pragma clang diagnostic pop + } + // 注意:这里不能直接访问 self.functionView,否则会导致功能面板提前创建,占用内存。 + if (themeChanged && _functionView && + [_functionView respondsToSelector:@selector(kb_applyTheme)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [_functionView performSelector:@selector(kb_applyTheme)]; +#pragma clang diagnostic pop + } + } +} + +- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme { + NSString *skinId = theme.skinId ?: @""; + if (skinId.length == 0 || [skinId isEqualToString:@"default"]) { + return YES; + } + if ([skinId isEqualToString:kKBDefaultSkinIdLight]) { + return YES; + } + return [skinId isEqualToString:kKBDefaultSkinIdDark]; +} + +- (BOOL)kb_isDarkModeActive { + if (@available(iOS 13.0, *)) { + return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark; + } + return NO; +} + +- (NSString *)kb_defaultSkinIdForCurrentStyle { + return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark + : kKBDefaultSkinIdLight; +} + +- (NSString *)kb_defaultSkinZipNameForCurrentStyle { + return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark + : kKBDefaultSkinZipNameLight; +} + +- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size + topColor:(UIColor *)topColor + bottomColor:(UIColor *)bottomColor { + if (size.width <= 0 || size.height <= 0) { + return nil; + } + + // 尺寸未变则复用缓存,避免反复创建图片撑爆键盘扩展内存 + if (self.kb_cachedGradientImage && + CGSizeEqualToSize(self.kb_cachedGradientSize, size)) { + return self.kb_cachedGradientImage; + } + + UIColor *resolvedTopColor = topColor; + UIColor *resolvedBottomColor = bottomColor; + if (@available(iOS 13.0, *)) { + resolvedTopColor = + [topColor resolvedColorWithTraitCollection:self.traitCollection]; + resolvedBottomColor = + [bottomColor resolvedColorWithTraitCollection:self.traitCollection]; + } + + CAGradientLayer *layer = [CAGradientLayer layer]; + layer.frame = CGRectMake(0, 0, size.width, size.height); + layer.startPoint = CGPointMake(0.5, 0.0); + layer.endPoint = CGPointMake(0.5, 1.0); + layer.colors = + @[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ]; + + UIGraphicsBeginImageContextWithOptions(size, YES, 0); + [layer renderInContext:UIGraphicsGetCurrentContext()]; + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + self.kb_cachedGradientImage = image; + self.kb_cachedGradientSize = size; + return image; +} + +- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme + backgroundImage:(UIImage *)image { +#if DEBUG + NSString *skinId = theme.skinId ?: @""; + NSString *name = theme.name ?: @""; + NSMutableArray *roots = [NSMutableArray array]; + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:AppGroup]; + if (containerURL.path.length > 0) { + [roots addObject:containerURL.path]; + } + NSString *cacheRoot = NSSearchPathForDirectoriesInDomains( + NSCachesDirectory, NSUserDomainMask, YES) + .firstObject; + if (cacheRoot.length > 0) { + [roots addObject:cacheRoot]; + } + + NSFileManager *fm = [NSFileManager defaultManager]; + NSMutableArray *lines = [NSMutableArray array]; + for (NSString *root in roots) { + NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"] + stringByAppendingPathComponent:skinId]; + iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"]; + BOOL isDir = NO; + BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir; + NSArray *contents = + exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil; + NSUInteger count = contents.count; + BOOL hasQ = + exists && + [fm fileExistsAtPath:[iconsDir + stringByAppendingPathComponent:@"key_q.png"]]; + BOOL hasQUp = + exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: + @"key_q_up.png"]]; + BOOL hasDel = + exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: + @"key_del.png"]]; + BOOL hasShift = + exists && + [fm fileExistsAtPath:[iconsDir + stringByAppendingPathComponent:@"key_up.png"]]; + BOOL hasShiftUpper = + exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: + @"key_up_upper.png"]]; + NSString *line = [NSString + stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d " + @"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d", + root, iconsDir, exists, count, hasQ, hasQUp, hasDel, + hasShift, hasShiftUpper]; + [lines addObject:line]; + } + + NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name, + (image != nil), [lines componentsJoinedByString:@"\n"]); +#endif +} + +- (void)kb_consumePendingShopSkin { + KBWeakSelf [KBSkinInstallBridge + consumePendingRequestFromBundle:NSBundle.mainBundle + completion:^(BOOL success, + NSError *_Nullable error) { + if (!success) { + if (error) { + NSLog(@"[Keyboard] skin request failed: %@", + error); + [KBHUD + showInfo: + KBLocalized( + @"皮肤资源准备失败,请稍后再试")]; + } + return; + } + [weakSelf kb_applyTheme]; + [KBHUD showInfo:KBLocalized( + @"皮肤已更新,立即体验吧")]; + }]; +} + +#pragma mark - Default Skin + +- (void)kb_applyDefaultSkinIfNeeded { + NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload]; + if (pending.count > 0) { + return; + } + + NSString *currentId = [KBSkinManager shared].current.skinId ?: @""; + BOOL isDefault = + (currentId.length == 0 || [currentId isEqualToString:@"default"]); + BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight]; + BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark]; + if (!isDefault && !isLightDefault && !isDarkDefault) { + // 用户已应用自定义皮肤:不随深色模式切换默认皮肤 + return; + } + NSString *targetId = [self kb_defaultSkinIdForCurrentStyle]; + NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle]; + if (currentId.length > 0 && [currentId isEqualToString:targetId]) { + return; + } + + NSError *applyError = nil; + if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId + error:&applyError]) { + return; + } + + [KBSkinInstallBridge publishBundleSkinRequestWithId:targetId + name:targetId + zipName:targetZip + iconShortNames:nil]; + [KBSkinInstallBridge + consumePendingRequestFromBundle:NSBundle.mainBundle + completion:^(__unused BOOL success, + __unused NSError *_Nullable error){ + // 已通过通知触发主题刷新,这里无需额外处理 + }]; +} + +#pragma mark - Layout Helpers + +- (CGFloat)kb_portraitWidth { + CGSize s = [UIScreen mainScreen].bounds.size; + return MIN(s.width, s.height); +} + +- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + CGFloat baseHeight = kKBKeyboardBaseHeight * scale; + CGFloat chatHeight = kKBChatPanelHeight * scale; + if (self.chatPanelVisible) { + return baseHeight + chatHeight; + } + return baseHeight; +} + +- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + return kKBKeyboardBaseHeight * scale; +} + +- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width { + if (width <= 0) { + width = KB_DESIGN_WIDTH; + } + CGFloat scale = width / KB_DESIGN_WIDTH; + return kKBChatPanelHeight * scale; +} + +- (void)kb_updateKeyboardLayoutIfNeeded { + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; + CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; + CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; + CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds); + if (containerWidth <= 0) { + containerWidth = CGRectGetWidth(self.view.window.bounds); + } + if (containerWidth <= 0) { + containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds); + } + + BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5); + BOOL heightChanged = + (fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5); + if (!widthChanged && !heightChanged && containerWidth > 0 && + self.kb_widthConstraint.constant == containerWidth) { + return; + } + self.kb_lastPortraitWidth = portraitWidth; + self.kb_lastKeyboardHeight = keyboardHeight; + + if (self.kb_heightConstraint) { + self.kb_heightConstraint.constant = keyboardHeight; + } + if (containerWidth > 0 && self.kb_widthConstraint) { + self.kb_widthConstraint.constant = containerWidth; + } + if (self.contentWidthConstraint) { + [self.contentWidthConstraint setOffset:portraitWidth]; + } + if (self.contentHeightConstraint) { + [self.contentHeightConstraint setOffset:keyboardHeight]; + } + if (self.keyBoardMainHeightConstraint) { + [self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight]; + } + if (self.chatPanelHeightConstraint) { + [self.chatPanelHeightConstraint setOffset:chatPanelHeight]; + } + [self.view layoutIfNeeded]; +} + +#pragma mark - Lazy + +- (UIView *)contentView { + if (!_contentView) { + _contentView = [[UIView alloc] init]; + _contentView.backgroundColor = [UIColor clearColor]; + } + return _contentView; +} + +- (UIImageView *)bgImageView { + if (!_bgImageView) { + _bgImageView = [[UIImageView alloc] init]; + _bgImageView.contentMode = UIViewContentModeScaleAspectFill; + _bgImageView.clipsToBounds = YES; + } + return _bgImageView; +} +@end diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Panels.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Panels.m new file mode 100644 index 0000000..d2c04ae --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Panels.m @@ -0,0 +1,623 @@ +// +// KeyboardViewController+Panels.m +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController+Private.h" + +#import "KBAuthManager.h" +#import "KBBackspaceUndoManager.h" +#import "KBChatMessage.h" +#import "KBChatPanelView.h" +#import "KBFunctionView.h" +#import "KBHostAppLauncher.h" +#import "KBInputBufferManager.h" +#import "KBKey.h" +#import "KBKeyBoardMainView.h" +#import "KBKeyboardSubscriptionView.h" +#import "KBSettingView.h" +#import "Masonry.h" +#import +#import + +@implementation KeyboardViewController (Panels) + +#pragma mark - Panel Mode + +- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated { + if (mode == self.kb_panelMode) { + return; + } + + KBKeyboardPanelMode fromMode = self.kb_panelMode; + self.kb_panelMode = mode; + + // 主键盘视图是基础承载:确保存在(键盘隐藏后会被释放) + [self kb_ensureKeyBoardMainViewIfNeeded]; + + // 1) 先收起所有面板(再展开目标面板),避免互相调用导致漏关/层级错乱 + [self kb_setSubscriptionPanelVisible:NO animated:animated]; + [self kb_setSettingViewVisible:NO animated:animated]; + [self kb_setChatPanelVisible:NO animated:animated]; + [self kb_setFunctionPanelVisible:NO]; + + // 2) 再展开目标面板 + switch (mode) { + case KBKeyboardPanelModeFunction: + [self kb_setFunctionPanelVisible:YES]; + break; + case KBKeyboardPanelModeChat: + [self kb_setChatPanelVisible:YES animated:animated]; + break; + case KBKeyboardPanelModeSettings: + [self kb_setSettingViewVisible:YES animated:animated]; + break; + case KBKeyboardPanelModeSubscription: + [self kb_setSubscriptionPanelVisible:YES animated:animated]; + break; + case KBKeyboardPanelModeMain: + default: + break; + } + + // 3) 事件埋点:保持原逻辑(仅功能面板/主面板会互相曝光) + if (mode == KBKeyboardPanelModeFunction) { + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_function_panel" + pageId:@"keyboard_function_panel" + extra:nil + completion:nil]; + } else if (mode == KBKeyboardPanelModeMain && + fromMode == KBKeyboardPanelModeFunction) { + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_main_panel" + pageId:@"keyboard_main_panel" + extra:nil + completion:nil]; + } else if (mode == KBKeyboardPanelModeSettings) { + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_settings" + pageId:@"keyboard_settings" + extra:nil + completion:nil]; + } else if (mode == KBKeyboardPanelModeSubscription) { + [[KBMaiPointReporter sharedReporter] + reportPageExposureWithEventName:@"enter_keyboard_subscription_panel" + pageId:@"keyboard_subscription_panel" + extra:nil + completion:nil]; + } + + // 4) 层级:保证当前面板在最上层 + if (mode == KBKeyboardPanelModeSubscription) { + [self.contentView bringSubviewToFront:self.subscriptionView]; + } else if (mode == KBKeyboardPanelModeSettings) { + [self.contentView bringSubviewToFront:self.settingView]; + } else if (mode == KBKeyboardPanelModeChat) { + [self.contentView bringSubviewToFront:self.chatPanelView]; + } else if (mode == KBKeyboardPanelModeFunction) { + [self.contentView bringSubviewToFront:self.functionView]; + } else { + [self.contentView bringSubviewToFront:self.keyBoardMainView]; + } +} + +/// 对外兼容:切换显示功能面板/键盘主视图 +- (void)showFunctionPanel:(BOOL)show { + if (show) { + [self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO]; + return; + } + if (self.kb_panelMode == KBKeyboardPanelModeFunction) { + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO]; + } +} + +/// 对外兼容:显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出 +- (void)showSettingView:(BOOL)show { + if (show) { + [self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES]; + return; + } + if (self.kb_panelMode == KBKeyboardPanelModeSettings) { + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES]; + } +} + +/// 对外兼容:显示/隐藏聊天面板(覆盖整个键盘区域) +- (void)showChatPanel:(BOOL)show { + if (show) { + [self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES]; + return; + } + if (self.kb_panelMode == KBKeyboardPanelModeChat) { + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES]; + } +} + +- (void)kb_setFunctionPanelVisible:(BOOL)visible { + if (visible) { + [self kb_ensureFunctionViewIfNeeded]; + } + if (_functionView) { + _functionView.hidden = !visible; + } else if (visible) { + // ensure 后按理已存在;这里兜底一次,避免异常情况下状态不一致 + self.functionView.hidden = NO; + } + self.keyBoardMainView.hidden = visible; +} + +- (void)kb_setChatPanelVisible:(BOOL)visible animated:(BOOL)animated { + if (visible == self.chatPanelVisible) { + return; + } + self.chatPanelVisible = visible; + + if (visible) { + // 记录打开聊天面板时宿主输入框已有的文本,发送时只取新增部分 + [[KBInputBufferManager shared] + refreshFromProxyIfPossible:self.textDocumentProxy]; + self.chatPanelBaselineText = [KBInputBufferManager shared].liveText ?: @""; + [self kb_ensureChatPanelViewIfNeeded]; + self.chatPanelView.hidden = NO; + self.chatPanelView.alpha = 0.0; + if (animated) { + [UIView animateWithDuration:0.2 + delay:0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + self.chatPanelView.alpha = 1.0; + } + completion:nil]; + } else { + self.chatPanelView.alpha = 1.0; + } + } else { + // 从未创建过聊天面板时,直接返回,避免 show/hide 触发额外内存分配 + if (!_chatPanelView) { + [self kb_updateKeyboardLayoutIfNeeded]; + return; + } + if (animated) { + [UIView animateWithDuration:0.18 + delay:0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + self.chatPanelView.alpha = 0.0; + } + completion:^(BOOL finished) { + self.chatPanelView.hidden = YES; + }]; + } else { + self.chatPanelView.alpha = 0.0; + self.chatPanelView.hidden = YES; + } + } + [self kb_updateKeyboardLayoutIfNeeded]; +} + +- (void)kb_setSettingViewVisible:(BOOL)visible animated:(BOOL)animated { + if (visible) { + KBSettingView *settingView = self.settingView; + if (!settingView.superview) { + settingView.hidden = YES; + [self.contentView addSubview:settingView]; + [settingView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + [settingView.backButton addTarget:self + action:@selector(onTapSettingsBack) + forControlEvents:UIControlEventTouchUpInside]; + } + [self.contentView bringSubviewToFront:settingView]; + // 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算 + [self.contentView layoutIfNeeded]; + CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); + if (w <= 0) { + w = CGRectGetWidth(self.contentView.bounds); + } + if (w <= 0) { + w = [self kb_portraitWidth]; + } + settingView.transform = CGAffineTransformMakeTranslation(w, 0); + settingView.hidden = NO; + if (animated) { + [UIView animateWithDuration:0.25 + delay:0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + settingView.transform = CGAffineTransformIdentity; + } + completion:nil]; + } else { + settingView.transform = CGAffineTransformIdentity; + } + } else { + KBSettingView *settingView = _settingView; + if (!settingView) { + return; + } + if (!settingView.superview || settingView.hidden) { + return; + } + CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds); + if (w <= 0) { + w = CGRectGetWidth(self.contentView.bounds); + } + if (w <= 0) { + w = [self kb_portraitWidth]; + } + if (animated) { + [UIView animateWithDuration:0.22 + delay:0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + settingView.transform = CGAffineTransformMakeTranslation(w, 0); + } + completion:^(BOOL finished) { + settingView.hidden = YES; + }]; + } else { + settingView.transform = CGAffineTransformMakeTranslation(w, 0); + settingView.hidden = YES; + } + } +} + +- (void)kb_setSubscriptionPanelVisible:(BOOL)visible animated:(BOOL)animated { + if (visible) { + KBKeyboardSubscriptionView *panel = self.subscriptionView; + if (!panel.superview) { + panel.hidden = YES; + [self.contentView addSubview:panel]; + [panel mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + } + [self.contentView bringSubviewToFront:panel]; + panel.hidden = NO; + panel.alpha = 0.0; + CGFloat height = CGRectGetHeight(self.contentView.bounds); + if (height <= 0) { + height = 260; + } + panel.transform = CGAffineTransformMakeTranslation(0, height); + [panel refreshProductsIfNeeded]; + if (animated) { + [UIView animateWithDuration:0.25 + delay:0 + options:UIViewAnimationOptionCurveEaseOut + animations:^{ + panel.alpha = 1.0; + panel.transform = CGAffineTransformIdentity; + } + completion:nil]; + } else { + panel.alpha = 1.0; + panel.transform = CGAffineTransformIdentity; + } + return; + } + + KBKeyboardSubscriptionView *panel = _subscriptionView; + if (!panel) { + return; + } + if (!panel.superview || panel.hidden) { + return; + } + CGFloat height = CGRectGetHeight(panel.bounds); + if (height <= 0) { + height = CGRectGetHeight(self.contentView.bounds); + } + if (animated) { + [UIView animateWithDuration:0.22 + delay:0 + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + panel.alpha = 0.0; + panel.transform = CGAffineTransformMakeTranslation(0, height); + } + completion:^(BOOL finished) { + panel.hidden = YES; + panel.alpha = 1.0; + panel.transform = CGAffineTransformIdentity; + }]; + } else { + panel.hidden = YES; + panel.alpha = 1.0; + panel.transform = CGAffineTransformIdentity; + } +} + +// 延迟创建:仅在用户真正打开功能面板时才创建/布局,降低默认内存占用。 +- (void)kb_ensureFunctionViewIfNeeded { + if (_functionView && _functionView.superview) { + return; + } + KBFunctionView *v = self.functionView; + if (!v.superview) { + v.hidden = YES; + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + } +} + +// 延迟创建:仅在用户打开聊天面板时才创建/布局。 +- (void)kb_ensureChatPanelViewIfNeeded { + if (_chatPanelView && _chatPanelView.superview) { + return; + } + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth]; + KBChatPanelView *v = self.chatPanelView; + if (!v.superview) { + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.keyBoardMainView.mas_top); + self.chatPanelHeightConstraint = + make.height.mas_equalTo(chatPanelHeight); + }]; + v.hidden = YES; + } +} + +// 延迟创建:键盘主面板(按键区)在隐藏时会被释放;再次显示时需要重建。 +- (void)kb_ensureKeyBoardMainViewIfNeeded { + if (_keyBoardMainView && _keyBoardMainView.superview) { + return; + } + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardBaseHeight = + [self kb_keyboardBaseHeightForWidth:portraitWidth]; + KBKeyBoardMainView *v = self.keyBoardMainView; + if (!v.superview) { + [self.contentView addSubview:v]; + [v mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.contentView); + self.keyBoardMainHeightConstraint = + make.height.mas_equalTo(keyboardBaseHeight); + }]; + } + [self.contentView bringSubviewToFront:v]; +} + +// 键盘隐藏时释放可重建资源(背景图/缓存/非必需面板),降低扩展内存峰值。 +- (void)kb_releaseMemoryWhenKeyboardHidden { + [KBHUD setContainerView:nil]; + self.bgImageView.image = nil; + self.kb_cachedGradientImage = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + [[SDImageCache sharedImageCache] clearMemory]; + + // 聊天相关可能持有音频数据/临时文件,键盘隐藏时直接清空,避免累计占用。 + if (self.chatAudioPlayer) { + [self.chatAudioPlayer stop]; + self.chatAudioPlayer = nil; + } + if (_chatMessages.count > 0) { + NSString *tmpRoot = NSTemporaryDirectory(); + for (KBChatMessage *msg in _chatMessages.copy) { + if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 && + [msg.audioFilePath hasPrefix:tmpRoot]) { + [[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath + error:nil]; + } + } + [_chatMessages removeAllObjects]; + } + + if (_keyBoardMainView) { + [_keyBoardMainView removeFromSuperview]; + _keyBoardMainView = nil; + } + self.keyBoardMainHeightConstraint = nil; + + if (_functionView) { + [_functionView removeFromSuperview]; + _functionView = nil; + } + if (_chatPanelView) { + [_chatPanelView removeFromSuperview]; + _chatPanelView = nil; + } + self.chatPanelVisible = NO; + self.kb_panelMode = KBKeyboardPanelModeMain; + + if (_subscriptionView) { + [_subscriptionView removeFromSuperview]; + _subscriptionView = nil; + } + if (_settingView) { + [_settingView removeFromSuperview]; + _settingView = nil; + } +} + +// MARK: - KBKeyBoardMainViewDelegate + +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didTapKey:(KBKey *)key { + switch (key.type) { + case KBKeyTypeCharacter: { + [[KBBackspaceUndoManager shared] registerNonClearAction]; + NSString *text = key.output ?: key.title ?: @""; + [self.textDocumentProxy insertText:text]; + [self kb_updateCurrentWordWithInsertedText:text]; + [[KBInputBufferManager shared] appendText:text]; + } break; + case KBKeyTypeBackspace: + [[KBInputBufferManager shared] + refreshFromProxyIfPossible:self.textDocumentProxy]; + [[KBInputBufferManager shared] + prepareSnapshotForDeleteWithContextBefore: + self.textDocumentProxy.documentContextBeforeInput + after: + self.textDocumentProxy + .documentContextAfterInput]; + [[KBBackspaceUndoManager shared] + captureAndDeleteBackwardFromProxy:self.textDocumentProxy + count:1]; + [self kb_scheduleContextRefreshResetSuppression:NO]; + [[KBInputBufferManager shared] applyHoldDeleteCount:1]; + break; + case KBKeyTypeSpace: + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self.textDocumentProxy insertText:@" "]; + [self kb_clearCurrentWord]; + [[KBInputBufferManager shared] appendText:@" "]; + break; + case KBKeyTypeReturn: + if (self.chatPanelVisible) { + [self kb_handleChatSendAction]; + break; + } + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self.textDocumentProxy insertText:@"\n"]; + [self kb_clearCurrentWord]; + [[KBInputBufferManager shared] appendText:@"\n"]; + break; + case KBKeyTypeGlobe: + [self advanceToNextInputMode]; + break; + case KBKeyTypeCustom: + [[KBBackspaceUndoManager shared] registerNonClearAction]; + // 点击自定义键切换到功能面板 + [self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO]; + [self kb_clearCurrentWord]; + break; + case KBKeyTypeModeChange: + case KBKeyTypeShift: + // 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理 + break; + } +} + +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didTapToolActionAtIndex:(NSInteger)index { + NSDictionary *extra = @{@"index" : @(index)}; + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_toolbar_action" + pageId:@"keyboard_main_panel" + elementId:@"toolbar_action" + extra:extra + completion:nil]; + if (index == 0) { + [self kb_setPanelMode:KBKeyboardPanelModeFunction animated:YES]; + [self kb_clearCurrentWord]; + return; + } + if (index == 1) { + [self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES]; + return; + } + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES]; +} + +- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_settings_btn" + pageId:@"keyboard_main_panel" + elementId:@"settings_btn" + extra:nil + completion:nil]; + [self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES]; +} + +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didSelectEmoji:(NSString *)emoji { + if (emoji.length == 0) { + return; + } + [[KBBackspaceUndoManager shared] registerNonClearAction]; + [self.textDocumentProxy insertText:emoji]; + [self kb_clearCurrentWord]; + [[KBInputBufferManager shared] appendText:emoji]; +} + +- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_undo_btn" + pageId:@"keyboard_main_panel" + elementId:@"undo_btn" + extra:nil + completion:nil]; + [[KBBackspaceUndoManager shared] performUndoFromResponder:self.view]; + [self kb_scheduleContextRefreshResetSuppression:YES]; +} + +- (void)keyBoardMainViewDidTapEmojiSearch: + (KBKeyBoardMainView *)keyBoardMainView { + // [[KBMaiPointReporter sharedReporter] + // reportClickWithEventName:@"click_keyboard_emoji_search_btn" + // pageId:@"keyboard_main_panel" + // elementId:@"emoji_search_btn" + // extra:nil + // completion:nil]; + [KBHUD showInfo:KBLocalized(@"Search coming soon")]; +} + +// MARK: - KBFunctionViewDelegate + +- (void)functionView:(KBFunctionView *)functionView + didTapToolActionAtIndex:(NSInteger)index { + // 需求:当 index == 0 时,切回键盘主视图 + if (index == 0) { + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO]; + } +} + +- (void)functionView:(KBFunctionView *_Nullable)functionView + didRightTapToolActionAtIndex:(NSInteger)index { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_function_right_action" + pageId:@"keyboard_function_panel" + elementId:@"right_action" + extra:@{@"action" : @"login_or_recharge"} + completion:nil]; + if (!KBAuthManager.shared.isLoggedIn) { + NSString *schemeStr = + [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:schemeStr]; + // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App + BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; + return; + } + NSString *schemeStr = + [NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:schemeStr]; + // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App + BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; + + if (!ok) { + // 失败兜底:给个文案提示 + // 比如:请回到桌面手动打开 XXX App 进行设置/充值 + [KBHUD showInfo:@"请回到桌面手动打开App进行充值"]; + } +} + +- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView { + [self showSubscriptionPanel]; +} + +#pragma mark - Actions + +- (void)onTapSettingsBack { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_settings_back_btn" + pageId:@"keyboard_settings" + elementId:@"back_btn" + extra:nil + completion:nil]; + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES]; +} + +@end diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h new file mode 100644 index 0000000..cf1ceba --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Private.h @@ -0,0 +1,154 @@ +// +// KeyboardViewController+Private.h +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController.h" +#import "Masonry.h" + +@class AVAudioPlayer; +@class CAGradientLayer; +@class KBChatMessage; +@class KBChatPanelView; +@class KBFunctionView; +@class KBKeyBoardMainView; +@class KBKeyboardSubscriptionView; +@class KBSettingView; +@class KBSuggestionEngine; + +@protocol KBChatLimitPopViewDelegate; +@protocol KBChatPanelViewDelegate; +@protocol KBFunctionViewDelegate; +@protocol KBKeyBoardMainViewDelegate; +@protocol KBKeyboardSubscriptionViewDelegate; + +typedef NS_ENUM(NSInteger, KBKeyboardPanelMode) { + KBKeyboardPanelModeMain = 0, + KBKeyboardPanelModeFunction, + KBKeyboardPanelModeChat, + KBKeyboardPanelModeSettings, + KBKeyboardPanelModeSubscription, +}; + +@interface KeyboardViewController () +{ + UIButton *_nextKeyboardButton; + UIView *_contentView; + KBKeyBoardMainView *_keyBoardMainView; + KBFunctionView *_functionView; + KBSettingView *_settingView; + UIImageView *_bgImageView; + KBChatPanelView *_chatPanelView; + KBKeyboardSubscriptionView *_subscriptionView; + KBSuggestionEngine *_suggestionEngine; + NSString *_currentWord; + UIControl *_chatLimitMaskView; + MASConstraint *_contentWidthConstraint; + MASConstraint *_contentHeightConstraint; + MASConstraint *_keyBoardMainHeightConstraint; + MASConstraint *_chatPanelHeightConstraint; + NSLayoutConstraint *_kb_heightConstraint; + NSLayoutConstraint *_kb_widthConstraint; + CGFloat _kb_lastPortraitWidth; + CGFloat _kb_lastKeyboardHeight; + UIImage *_kb_cachedGradientImage; + CGSize _kb_cachedGradientSize; + CAGradientLayer *_kb_defaultGradientLayer; + NSString *_kb_lastAppliedThemeKey; + NSMutableArray *_chatMessages; + AVAudioPlayer *_chatAudioPlayer; + BOOL _suppressSuggestions; + BOOL _chatPanelVisible; + NSString *_chatPanelBaselineText; + id _kb_fullAccessObserverToken; + id _kb_skinObserverToken; + KBKeyboardPanelMode _kb_panelMode; +} + +@property(nonatomic, strong) + UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选) +@property(nonatomic, strong) UIView *contentView; +@property(nonatomic, strong) KBKeyBoardMainView + *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示) +@property(nonatomic, strong) + KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示) +@property(nonatomic, strong) KBSettingView *settingView; // 设置页 +@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层) +@property(nonatomic, strong) KBChatPanelView *chatPanelView; +@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView; +@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine; +@property(nonatomic, copy) NSString *currentWord; +@property(nonatomic, assign) BOOL suppressSuggestions; +@property(nonatomic, strong) UIControl *chatLimitMaskView; +@property(nonatomic, strong) MASConstraint *contentWidthConstraint; +@property(nonatomic, strong) MASConstraint *contentHeightConstraint; +@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint; +@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint; +@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint; +@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint; +@property(nonatomic, assign) CGFloat kb_lastPortraitWidth; +@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight; +@property(nonatomic, strong) UIImage *kb_cachedGradientImage; +@property(nonatomic, assign) CGSize kb_cachedGradientSize; +@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer; +@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey; +@property(nonatomic, strong) NSMutableArray *chatMessages; +@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer; +@property(nonatomic, assign) BOOL chatPanelVisible; +@property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本 +@property(nonatomic, strong, nullable) id kb_fullAccessObserverToken; +@property(nonatomic, strong, nullable) id kb_skinObserverToken; +@property(nonatomic, assign) KBKeyboardPanelMode kb_panelMode; + +@end + +@interface KeyboardViewController (KBPrivate) + +// UI +- (void)setupUI; +- (nullable KBFunctionView *)kb_functionViewIfCreated; + +// Panels +- (void)showFunctionPanel:(BOOL)show; +- (void)showSettingView:(BOOL)show; +- (void)showChatPanel:(BOOL)show; +- (void)showSubscriptionPanel; +- (void)hideSubscriptionPanel; +- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated; +- (void)kb_ensureFunctionViewIfNeeded; +- (void)kb_ensureChatPanelViewIfNeeded; +- (void)kb_ensureKeyBoardMainViewIfNeeded; +- (void)kb_releaseMemoryWhenKeyboardHidden; + +// Suggestions +- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text; +- (void)kb_clearCurrentWord; +- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression; +- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression: + (BOOL)resetSuppression; +- (void)kb_updateSuggestionsForCurrentWord; + +// Chat +- (void)kb_handleChatSendAction; + +// Theme +- (void)kb_applyTheme; +- (void)kb_applyDefaultSkinIfNeeded; +- (void)kb_consumePendingShopSkin; +- (void)kb_registerDarwinSkinInstallObserver; +- (void)kb_unregisterDarwinSkinInstallObserver; + +// Layout +- (CGFloat)kb_portraitWidth; +- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width; +- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width; +- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width; +- (void)kb_updateKeyboardLayoutIfNeeded; + +@end diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m new file mode 100644 index 0000000..a4c6720 --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m @@ -0,0 +1,117 @@ +// +// KeyboardViewController+Subscription.m +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController+Private.h" + +#import "KBAuthManager.h" +#import "KBFullAccessManager.h" +#import "KBHostAppLauncher.h" +#import "KBKeyboardSubscriptionProduct.h" +#import "KBKeyboardSubscriptionView.h" + +@implementation KeyboardViewController (Subscription) + +- (void)showSubscriptionPanel { + // 1) 先判断权限:未开启“完全访问”则走引导逻辑 + if (![[KBFullAccessManager shared] hasFullAccess]) { + // 未开启完全访问:保持原有引导路径 + // [KBHUD showInfo:KBLocalized(@"处理中…")]; + [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]; + return; + } + // 点击充值要先判断是否登录 + // 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录 + if (!KBAuthManager.shared.isLoggedIn) { + NSString *schemeStr = + [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; + NSURL *scheme = [NSURL URLWithString:schemeStr]; + // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App + BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; + return; + } + [self kb_setPanelMode:KBKeyboardPanelModeSubscription animated:YES]; +} + +- (void)hideSubscriptionPanel { + if (self.kb_panelMode != KBKeyboardPanelModeSubscription) { + return; + } + [self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES]; +} + +#pragma mark - KBKeyboardSubscriptionViewDelegate + +- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view { + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_subscription_close_btn" + pageId:@"keyboard_subscription_panel" + elementId:@"close_btn" + extra:nil + completion:nil]; + [self hideSubscriptionPanel]; +} + +- (void)subscriptionView:(KBKeyboardSubscriptionView *)view + didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product { + NSMutableDictionary *extra = [NSMutableDictionary dictionary]; + if ([product.productId isKindOfClass:NSString.class] && + product.productId.length > 0) { + extra[@"product_id"] = product.productId; + } + [[KBMaiPointReporter sharedReporter] + reportClickWithEventName:@"click_keyboard_subscription_product_btn" + pageId:@"keyboard_subscription_panel" + elementId:@"product_btn" + extra:extra.copy + completion:nil]; + [self hideSubscriptionPanel]; + [self kb_openRechargeForProduct:product]; +} + +#pragma mark - Actions + +- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product { + if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] || + product.productId.length == 0) { + [KBHUD showInfo:KBLocalized(@"Product unavailable")]; + return; + } + NSString *encodedId = [self.class kb_urlEncodedString:product.productId]; + NSString *title = [product displayTitle]; + NSString *encodedTitle = [self.class kb_urlEncodedString:title]; + NSMutableArray *params = + [NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil]; + if (encodedId.length) { + [params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]]; + } + if (encodedTitle.length) { + [params + addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]]; + } + NSString *query = [params componentsJoinedByString:@"&"]; + NSString *urlString = [NSString + stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query]; + NSURL *scheme = [NSURL URLWithString:urlString]; + BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view]; + if (!success) { + [KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")]; + } +} + ++ (NSString *)kb_urlEncodedString:(NSString *)value { + if (value.length == 0) { + return @""; + } + NSString *reserved = @"!*'();:@&=+$,/?%#[]"; + NSMutableCharacterSet *allowed = + [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; + [allowed removeCharactersInString:reserved]; + return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed] + ?: @""; +} + +@end diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Suggestions.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Suggestions.m new file mode 100644 index 0000000..85c240d --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Suggestions.m @@ -0,0 +1,178 @@ +// +// KeyboardViewController+Suggestions.m +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController+Private.h" + +#import "KBBackspaceUndoManager.h" +#import "KBInputBufferManager.h" +#import "KBKeyBoardMainView.h" +#import "KBSuggestionEngine.h" + +@implementation KeyboardViewController (Suggestions) + +// MARK: - Suggestions + +- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text { + if (text.length == 0) { + return; + } + if ([self kb_isAlphabeticString:text]) { + NSString *current = self.currentWord ?: @""; + self.currentWord = [current stringByAppendingString:text]; + self.suppressSuggestions = NO; + [self kb_updateSuggestionsForCurrentWord]; + } else { + [self kb_clearCurrentWord]; + } +} + +- (void)kb_clearCurrentWord { + self.currentWord = @""; + [self.keyBoardMainView kb_setSuggestions:@[]]; + self.suppressSuggestions = NO; +} + +- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression { + dispatch_async(dispatch_get_main_queue(), ^{ + [self kb_refreshCurrentWordFromDocumentContextResetSuppression: + resetSuppression]; + }); +} + +- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression: + (BOOL)resetSuppression { + NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @""; + NSString *word = [self kb_extractTrailingWordFromContext:context]; + self.currentWord = word ?: @""; + if (resetSuppression) { + self.suppressSuggestions = NO; + } + [self kb_updateSuggestionsForCurrentWord]; +} + +- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context { + if (context.length == 0) { + return @""; + } + static NSCharacterSet *letters = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + letters = [NSCharacterSet + characterSetWithCharactersInString: + @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; + }); + + NSInteger idx = (NSInteger)context.length - 1; + while (idx >= 0) { + unichar ch = [context characterAtIndex:(NSUInteger)idx]; + if (![letters characterIsMember:ch]) { + break; + } + idx -= 1; + } + NSUInteger start = (NSUInteger)(idx + 1); + if (start >= context.length) { + return @""; + } + return [context substringFromIndex:start]; +} + +- (BOOL)kb_isAlphabeticString:(NSString *)text { + if (text.length == 0) { + return NO; + } + static NSCharacterSet *letters = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + letters = [NSCharacterSet + characterSetWithCharactersInString: + @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"]; + }); + for (NSUInteger i = 0; i < text.length; i++) { + if (![letters characterIsMember:[text characterAtIndex:i]]) { + return NO; + } + } + return YES; +} + +- (void)kb_updateSuggestionsForCurrentWord { + NSString *prefix = self.currentWord ?: @""; + if (prefix.length == 0) { + [self.keyBoardMainView kb_setSuggestions:@[]]; + return; + } + if (self.suppressSuggestions) { + [self.keyBoardMainView kb_setSuggestions:@[]]; + return; + } + NSArray *items = + [self.suggestionEngine suggestionsForPrefix:prefix limit:5]; + NSArray *cased = [self kb_applyCaseToSuggestions:items + prefix:prefix]; + [self.keyBoardMainView kb_setSuggestions:cased]; +} + +- (NSArray *)kb_applyCaseToSuggestions:(NSArray *)items + prefix:(NSString *)prefix { + if (items.count == 0 || prefix.length == 0) { + return items; + } + BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString]; + BOOL firstUpper = [[prefix substringToIndex:1] + isEqualToString:[[prefix substringToIndex:1] uppercaseString]]; + + if (!allUpper && !firstUpper) { + return items; + } + + NSMutableArray *result = + [NSMutableArray arrayWithCapacity:items.count]; + for (NSString *word in items) { + if (allUpper) { + [result addObject:word.uppercaseString]; + } else { + NSString *first = [[word substringToIndex:1] uppercaseString]; + NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @""; + [result addObject:[first stringByAppendingString:rest]]; + } + } + return result.copy; +} + +// MARK: - KBKeyBoardMainViewDelegate (Suggestion) + +- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView + didSelectSuggestion:(NSString *)suggestion { + if (suggestion.length == 0) { + return; + } + NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)}; + // [[KBMaiPointReporter sharedReporter] + // reportClickWithEventName:@"click_keyboard_suggestion_item" + // pageId:@"keyboard_main_panel" + // elementId:@"suggestion_item" + // extra:extra + // completion:nil]; + [[KBBackspaceUndoManager shared] registerNonClearAction]; + NSString *current = self.currentWord ?: @""; + if (current.length > 0) { + for (NSUInteger i = 0; i < current.length; i++) { + [self.textDocumentProxy deleteBackward]; + } + } + [self.textDocumentProxy insertText:suggestion]; + self.currentWord = suggestion; + [self.suggestionEngine recordSelection:suggestion]; + self.suppressSuggestions = YES; + [self.keyBoardMainView kb_setSuggestions:@[]]; + [[KBInputBufferManager shared] replaceTailWithText:suggestion + deleteCount:current.length]; +} + +@end + diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Theme.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Theme.m new file mode 100644 index 0000000..f4121a0 --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Theme.m @@ -0,0 +1,376 @@ +// +// KeyboardViewController+Theme.m +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController+Private.h" + +#import "KBFunctionView.h" +#import "KBKeyBoardMainView.h" +#import "KBSkinInstallBridge.h" +#import "KBSkinManager.h" +#import "UIImage+KBColor.h" +#import + +static NSString *const kKBDefaultSkinIdLight = @"normal_them"; +static NSString *const kKBDefaultSkinZipNameLight = @"normal_them"; +static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them"; +static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them"; + +// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。 +@interface KeyboardViewController (KBSkinShopBridge) +- (void)kb_consumePendingShopSkin; +@end + +static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center, + void *observer, CFStringRef name, + const void *object, + CFDictionaryRef userInfo) { + KeyboardViewController *strongSelf = + (__bridge KeyboardViewController *)observer; + if (!strongSelf) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) { + [strongSelf kb_consumePendingShopSkin]; + } + }); +} + +@implementation KeyboardViewController (Theme) + +- (void)kb_registerDarwinSkinInstallObserver { + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), KBSkinInstallNotificationCallback, + (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); +} + +- (void)kb_unregisterDarwinSkinInstallObserver { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + (__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL); +} + +- (void)kb_applyTheme { + @autoreleasepool { + KBSkinTheme *t = [KBSkinManager shared].current; + UIImage *img = nil; + BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t]; + BOOL isDarkMode = [self kb_isDarkModeActive]; + + NSString *skinId = t.skinId ?: @""; + NSString *themeKey = + [NSString stringWithFormat:@"%@|default=%d|dark=%d", skinId, + isDefaultTheme, isDarkMode]; + BOOL themeChanged = + (self.kb_lastAppliedThemeKey.length == 0 || + ![self.kb_lastAppliedThemeKey isEqualToString:themeKey]); + if (themeChanged) { + self.kb_lastAppliedThemeKey = themeKey; + } + + CGSize size = self.bgImageView.bounds.size; + if (isDefaultTheme) { + if (isDarkMode) { + // 暗黑模式:直接使用背景色,不使用图片渲染 + // 这样可以避免图片渲染时的色彩空间转换导致颜色不一致 + img = nil; + self.bgImageView.image = nil; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + // 使用与系统键盘底部完全相同的颜色 + if (@available(iOS 13.0, *)) { + // iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E) + // 但为了完美匹配,我们使用动态颜色并直接设置为背景 + UIColor *kbBgColor = + [UIColor colorWithDynamicProvider:^UIColor *_Nonnull( + UITraitCollection *_Nonnull traitCollection) { + if (traitCollection.userInterfaceStyle == + UIUserInterfaceStyleDark) { + // 暗黑模式下系统键盘实际背景色 + return [UIColor colorWithRed:43.0 / 255.0 + green:43.0 / 255.0 + blue:43.0 / 255.0 + alpha:1.0]; + } else { + return [UIColor colorWithRed:209.0 / 255.0 + green:211.0 / 255.0 + blue:219.0 / 255.0 + alpha:1.0]; + } + }]; + self.contentView.backgroundColor = kbBgColor; + self.bgImageView.backgroundColor = kbBgColor; + } else { + UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0 + green:43.0 / 255.0 + blue:43.0 / 255.0 + alpha:1.0]; + self.contentView.backgroundColor = darkColor; + self.bgImageView.backgroundColor = darkColor; + } + } else { + // 浅色模式:使用渐变层(避免生成大位图导致内存上涨) + if (size.width <= 0 || size.height <= 0) { + [self.view layoutIfNeeded]; + size = self.bgImageView.bounds.size; + } + if (size.width <= 0 || size.height <= 0) { + size = self.view.bounds.size; + } + if (size.width <= 0 || size.height <= 0) { + size = [UIScreen mainScreen].bounds.size; + } + UIColor *topColor = [UIColor colorWithHex:0xDEDFE4]; + UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB]; + UIColor *resolvedTopColor = topColor; + UIColor *resolvedBottomColor = bottomColor; + if (@available(iOS 13.0, *)) { + resolvedTopColor = + [topColor resolvedColorWithTraitCollection:self.traitCollection]; + resolvedBottomColor = + [bottomColor resolvedColorWithTraitCollection:self.traitCollection]; + } + CAGradientLayer *layer = self.kb_defaultGradientLayer; + if (!layer) { + layer = [CAGradientLayer layer]; + layer.startPoint = CGPointMake(0.5, 0.0); + layer.endPoint = CGPointMake(0.5, 1.0); + [self.bgImageView.layer insertSublayer:layer atIndex:0]; + self.kb_defaultGradientLayer = layer; + } + layer.colors = + @[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ]; + layer.frame = (CGRect){CGPointZero, size}; + img = nil; + self.bgImageView.image = nil; + self.contentView.backgroundColor = [UIColor clearColor]; + self.bgImageView.backgroundColor = [UIColor clearColor]; + } + NSLog(@"==="); + } else { + // 自定义皮肤:清除背景色,使用皮肤图片 + self.contentView.backgroundColor = [UIColor clearColor]; + self.bgImageView.backgroundColor = [UIColor clearColor]; + [self.kb_defaultGradientLayer removeFromSuperlayer]; + self.kb_defaultGradientLayer = nil; + img = [[KBSkinManager shared] currentBackgroundImage]; + } + NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil)); + [self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img]; + self.bgImageView.image = img; + + // 触发键区按主题重绘 + if (themeChanged && + [self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) { + // method declared in KBKeyBoardMainView.h +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [self.keyBoardMainView performSelector:@selector(kb_applyTheme)]; +#pragma clang diagnostic pop + } + // 注意:这里不能直接访问 self.functionView,否则会导致功能面板提前创建,占用内存。 + KBFunctionView *functionView = [self kb_functionViewIfCreated]; + if (themeChanged && functionView && + [functionView respondsToSelector:@selector(kb_applyTheme)]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [functionView performSelector:@selector(kb_applyTheme)]; +#pragma clang diagnostic pop + } + } + } + +- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme { + NSString *skinId = theme.skinId ?: @""; + if (skinId.length == 0 || [skinId isEqualToString:@"default"]) { + return YES; + } + if ([skinId isEqualToString:kKBDefaultSkinIdLight]) { + return YES; + } + return [skinId isEqualToString:kKBDefaultSkinIdDark]; +} + +- (BOOL)kb_isDarkModeActive { + if (@available(iOS 13.0, *)) { + return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark; + } + return NO; +} + +- (NSString *)kb_defaultSkinIdForCurrentStyle { + return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark + : kKBDefaultSkinIdLight; +} + +- (NSString *)kb_defaultSkinZipNameForCurrentStyle { + return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark + : kKBDefaultSkinZipNameLight; +} + +- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size + topColor:(UIColor *)topColor + bottomColor:(UIColor *)bottomColor { + if (size.width <= 0 || size.height <= 0) { + return nil; + } + + // 尺寸未变则复用缓存,避免反复创建图片撑爆键盘扩展内存 + if (self.kb_cachedGradientImage && + CGSizeEqualToSize(self.kb_cachedGradientSize, size)) { + return self.kb_cachedGradientImage; + } + + UIColor *resolvedTopColor = topColor; + UIColor *resolvedBottomColor = bottomColor; + if (@available(iOS 13.0, *)) { + resolvedTopColor = + [topColor resolvedColorWithTraitCollection:self.traitCollection]; + resolvedBottomColor = + [bottomColor resolvedColorWithTraitCollection:self.traitCollection]; + } + + CAGradientLayer *layer = [CAGradientLayer layer]; + layer.frame = CGRectMake(0, 0, size.width, size.height); + layer.startPoint = CGPointMake(0.5, 0.0); + layer.endPoint = CGPointMake(0.5, 1.0); + layer.colors = + @[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ]; + + UIGraphicsBeginImageContextWithOptions(size, YES, 0); + [layer renderInContext:UIGraphicsGetCurrentContext()]; + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + self.kb_cachedGradientImage = image; + self.kb_cachedGradientSize = size; + return image; +} + +- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme + backgroundImage:(UIImage *)image { +#if DEBUG + NSString *skinId = theme.skinId ?: @""; + NSString *name = theme.name ?: @""; + NSMutableArray *roots = [NSMutableArray array]; + NSURL *containerURL = [[NSFileManager defaultManager] + containerURLForSecurityApplicationGroupIdentifier:AppGroup]; + if (containerURL.path.length > 0) { + [roots addObject:containerURL.path]; + } + NSString *cacheRoot = NSSearchPathForDirectoriesInDomains( + NSCachesDirectory, NSUserDomainMask, YES) + .firstObject; + if (cacheRoot.length > 0) { + [roots addObject:cacheRoot]; + } + + NSFileManager *fm = [NSFileManager defaultManager]; + NSMutableArray *lines = [NSMutableArray array]; + for (NSString *root in roots) { + NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"] + stringByAppendingPathComponent:skinId]; + iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"]; + BOOL isDir = NO; + BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir; + NSArray *contents = + exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil; + NSUInteger count = contents.count; + BOOL hasQ = + exists && + [fm fileExistsAtPath:[iconsDir + stringByAppendingPathComponent:@"key_q.png"]]; + BOOL hasQUp = + exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: + @"key_q_up.png"]]; + BOOL hasDel = + exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: + @"key_del.png"]]; + BOOL hasShift = + exists && + [fm fileExistsAtPath:[iconsDir + stringByAppendingPathComponent:@"key_up.png"]]; + BOOL hasShiftUpper = + exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent: + @"key_up_upper.png"]]; + NSString *line = [NSString + stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d " + @"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d", + root, iconsDir, exists, count, hasQ, hasQUp, hasDel, + hasShift, hasShiftUpper]; + [lines addObject:line]; + } + + NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name, + (image != nil), [lines componentsJoinedByString:@"\n"]); +#endif +} + +- (void)kb_consumePendingShopSkin { + KBWeakSelf [KBSkinInstallBridge + consumePendingRequestFromBundle:NSBundle.mainBundle + completion:^(BOOL success, + NSError *_Nullable error) { + if (!success) { + if (error) { + NSLog(@"[Keyboard] skin request failed: %@", + error); + [KBHUD + showInfo: + KBLocalized( + @"皮肤资源准备失败,请稍后再试")]; + } + return; + } + [weakSelf kb_applyTheme]; + [KBHUD showInfo:KBLocalized( + @"皮肤已更新,立即体验吧")]; + }]; +} + +- (void)kb_applyDefaultSkinIfNeeded { + NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload]; + if (pending.count > 0) { + return; + } + + NSString *currentId = [KBSkinManager shared].current.skinId ?: @""; + BOOL isDefault = + (currentId.length == 0 || [currentId isEqualToString:@"default"]); + BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight]; + BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark]; + if (!isDefault && !isLightDefault && !isDarkDefault) { + // 用户已应用自定义皮肤:不随深色模式切换默认皮肤 + return; + } + NSString *targetId = [self kb_defaultSkinIdForCurrentStyle]; + NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle]; + if (currentId.length > 0 && [currentId isEqualToString:targetId]) { + return; + } + + NSError *applyError = nil; + if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId error:&applyError]) { + return; + } + + [KBSkinInstallBridge publishBundleSkinRequestWithId:targetId + name:targetId + zipName:targetZip + iconShortNames:nil]; + [KBSkinInstallBridge + consumePendingRequestFromBundle:NSBundle.mainBundle + completion:^(__unused BOOL success, + __unused NSError *_Nullable error) { + // 已通过通知触发主题刷新,这里无需额外处理 + }]; +} + +@end diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+UI.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+UI.m new file mode 100644 index 0000000..ba32bf6 --- /dev/null +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+UI.m @@ -0,0 +1,151 @@ +// +// KeyboardViewController+UI.m +// CustomKeyboard +// +// Created by Codex on 2026/02/22. +// + +#import "KeyboardViewController+Private.h" + +#import "KBChatMessage.h" +#import "KBChatPanelView.h" +#import "KBFunctionView.h" +#import "KBKeyBoardMainView.h" +#import "KBKeyboardSubscriptionView.h" +#import "KBSettingView.h" +#import "Masonry.h" + +@implementation KeyboardViewController (UI) + +- (void)setupUI { + self.view.translatesAutoresizingMaskIntoConstraints = NO; + + // 按“短边”宽度等比缩放,横屏保持竖屏布局比例 + CGFloat portraitWidth = [self kb_portraitWidth]; + CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth]; + CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth]; + CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds); + + // FIX: iOS 26 键盘闪烁问题 + // iOS 26 在键盘滑入动画开始前,会对 self.view 做一次离屏预渲染快照(非实时 view), + // 该快照会短暂显示在屏幕中间。如果此时 view 已有完整高度和内容,用户就会看到 + // 键盘 UI 在屏幕中间闪现一帧,然后键盘才从底部正常滑入。 + // 解决方案:初始高度设为 0,让系统快照时无内容可渲染; + // 在 viewWillAppear: 中恢复正确高度,此时系统已准备好滑入动画。 + // (iOS 18 及更早版本无此预渲染机制,不受影响) + NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:0]; + NSLayoutConstraint *w = + [self.view.widthAnchor constraintEqualToConstant:screenWidth]; + self.kb_heightConstraint = h; + self.kb_widthConstraint = w; + + h.priority = UILayoutPriorityRequired; + w.priority = UILayoutPriorityRequired; + [NSLayoutConstraint activateConstraints:@[ h, w ]]; + // 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突) + if ([self.view isKindOfClass:[UIInputView class]]) { + UIInputView *iv = (UIInputView *)self.view; + if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) { + iv.allowsSelfSizing = NO; + } + } + // 内容容器:横屏时保持竖屏宽度,居中显示 + [self.view addSubview:self.contentView]; + [self.contentView mas_makeConstraints:^(MASConstraintMaker *make) { + make.centerX.equalTo(self.view); + make.bottom.equalTo(self.view); + self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth); + self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight); + }]; + + // 背景图铺底(仅在内容容器内) + [self.contentView addSubview:self.bgImageView]; + [self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) { + make.edges.equalTo(self.contentView); + }]; + + [self.contentView addSubview:self.keyBoardMainView]; + [self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.right.equalTo(self.contentView); + make.bottom.equalTo(self.contentView); + self.keyBoardMainHeightConstraint = + make.height.mas_equalTo(keyboardBaseHeight); + }]; + + // 初始隐藏,避免布局完成前闪烁 + self.contentView.hidden = YES; +} + +#pragma mark - Lazy + +- (nullable KBFunctionView *)kb_functionViewIfCreated { + return _functionView; +} + +- (UIView *)contentView { + if (!_contentView) { + _contentView = [[UIView alloc] init]; + _contentView.backgroundColor = [UIColor clearColor]; + } + return _contentView; +} + +- (UIImageView *)bgImageView { + if (!_bgImageView) { + _bgImageView = [[UIImageView alloc] init]; + _bgImageView.contentMode = UIViewContentModeScaleAspectFill; + _bgImageView.clipsToBounds = YES; + } + return _bgImageView; +} + +- (KBKeyBoardMainView *)keyBoardMainView { + if (!_keyBoardMainView) { + _keyBoardMainView = [[KBKeyBoardMainView alloc] init]; + _keyBoardMainView.delegate = self; + } + return _keyBoardMainView; +} + +- (KBFunctionView *)functionView { + if (!_functionView) { + _functionView = [[KBFunctionView alloc] init]; + _functionView.delegate = self; // 监听功能面板顶部Bar点击 + } + return _functionView; +} + +- (KBSettingView *)settingView { + if (!_settingView) { + _settingView = [[KBSettingView alloc] init]; + } + return _settingView; +} + +- (KBChatPanelView *)chatPanelView { + if (!_chatPanelView) { + NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!"); + _chatPanelView = [[KBChatPanelView alloc] init]; + _chatPanelView.delegate = self; + } + return _chatPanelView; +} + +- (NSMutableArray *)chatMessages { + if (!_chatMessages) { + _chatMessages = [NSMutableArray array]; + } + return _chatMessages; +} + +- (KBKeyboardSubscriptionView *)subscriptionView { + if (!_subscriptionView) { + _subscriptionView = [[KBKeyboardSubscriptionView alloc] init]; + _subscriptionView.delegate = self; + _subscriptionView.hidden = YES; + _subscriptionView.alpha = 0.0; + } + return _subscriptionView; +} + +@end diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 643b5b7..81c3936 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -8,6 +8,13 @@ /* Begin PBXBuildFile section */ 04050ECB2F10FB8F008051EB /* UIImage+KBColor.m in Sources */ = {isa = PBXBuildFile; fileRef = 047C655D2EBCD5B20035E841 /* UIImage+KBColor.m */; }; + 040B620F2F4BF2560099DEAC /* KeyboardViewController+Theme.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620C2F4BF2560099DEAC /* KeyboardViewController+Theme.m */; }; + 040B62102F4BF2560099DEAC /* KeyboardViewController+Chat.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B62052F4BF2560099DEAC /* KeyboardViewController+Chat.m */; }; + 040B62112F4BF2560099DEAC /* KeyboardViewController+Panels.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B62082F4BF2560099DEAC /* KeyboardViewController+Panels.m */; }; + 040B62122F4BF2560099DEAC /* KeyboardViewController+Layout.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B62062F4BF2560099DEAC /* KeyboardViewController+Layout.m */; }; + 040B62132F4BF2560099DEAC /* KeyboardViewController+UI.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620D2F4BF2560099DEAC /* KeyboardViewController+UI.m */; }; + 040B62142F4BF2560099DEAC /* KeyboardViewController+Subscription.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620A2F4BF2560099DEAC /* KeyboardViewController+Subscription.m */; }; + 040B62162F4BF2560099DEAC /* KeyboardViewController+Suggestions.m in Sources */ = {isa = PBXBuildFile; fileRef = 040B620B2F4BF2560099DEAC /* KeyboardViewController+Suggestions.m */; }; 041007D22ECE012000D203BB /* KBSkinIconMap.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041007D12ECE012000D203BB /* KBSkinIconMap.strings */; }; 041007D42ECE012500D203BB /* 002.zip in Resources */ = {isa = PBXBuildFile; fileRef = 041007D32ECE012500D203BB /* 002.zip */; }; 04122F5D2EC5E5A900EF7AB3 /* KBLoginVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04122F5B2EC5E5A900EF7AB3 /* KBLoginVM.m */; }; @@ -331,6 +338,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 040B62052F4BF2560099DEAC /* KeyboardViewController+Chat.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Chat.m"; sourceTree = ""; }; + 040B62062F4BF2560099DEAC /* KeyboardViewController+Layout.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Layout.m"; sourceTree = ""; }; + 040B62072F4BF2560099DEAC /* KeyboardViewController+Legacy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Legacy.m"; sourceTree = ""; }; + 040B62082F4BF2560099DEAC /* KeyboardViewController+Panels.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Panels.m"; sourceTree = ""; }; + 040B62092F4BF2560099DEAC /* KeyboardViewController+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "KeyboardViewController+Private.h"; sourceTree = ""; }; + 040B620A2F4BF2560099DEAC /* KeyboardViewController+Subscription.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Subscription.m"; sourceTree = ""; }; + 040B620B2F4BF2560099DEAC /* KeyboardViewController+Suggestions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Suggestions.m"; sourceTree = ""; }; + 040B620C2F4BF2560099DEAC /* KeyboardViewController+Theme.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+Theme.m"; sourceTree = ""; }; + 040B620D2F4BF2560099DEAC /* KeyboardViewController+UI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "KeyboardViewController+UI.m"; sourceTree = ""; }; 041007D12ECE012000D203BB /* KBSkinIconMap.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = KBSkinIconMap.strings; sourceTree = ""; }; 041007D32ECE012500D203BB /* 002.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = 002.zip; sourceTree = ""; }; 04122F592EC5D40000EF7AB3 /* KBAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAPI.h; sourceTree = ""; }; @@ -877,6 +893,22 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 040B620E2F4BF2560099DEAC /* KeyboardViewControllerHelp */ = { + isa = PBXGroup; + children = ( + 040B62052F4BF2560099DEAC /* KeyboardViewController+Chat.m */, + 040B62062F4BF2560099DEAC /* KeyboardViewController+Layout.m */, + 040B62072F4BF2560099DEAC /* KeyboardViewController+Legacy.m */, + 040B62082F4BF2560099DEAC /* KeyboardViewController+Panels.m */, + 040B62092F4BF2560099DEAC /* KeyboardViewController+Private.h */, + 040B620A2F4BF2560099DEAC /* KeyboardViewController+Subscription.m */, + 040B620B2F4BF2560099DEAC /* KeyboardViewController+Suggestions.m */, + 040B620C2F4BF2560099DEAC /* KeyboardViewController+Theme.m */, + 040B620D2F4BF2560099DEAC /* KeyboardViewController+UI.m */, + ); + path = KeyboardViewControllerHelp; + sourceTree = ""; + }; 041007D02ECE010100D203BB /* Resource */ = { isa = PBXGroup; children = ( @@ -1536,13 +1568,6 @@ path = Localization; sourceTree = ""; }; - 04BBF8E52F3B50C000B1FBB2 /* KeyboardViewControllerHelp */ = { - isa = PBXGroup; - children = ( - ); - path = KeyboardViewControllerHelp; - sourceTree = ""; - }; 04C6EAB92EAF86530089C901 /* keyBoard */ = { isa = PBXGroup; children = ( @@ -1576,7 +1601,7 @@ 04C6EAD42EAF870B0089C901 /* Info.plist */, 04C6EAD52EAF870B0089C901 /* KeyboardViewController.h */, 04C6EAD62EAF870B0089C901 /* KeyboardViewController.m */, - 04BBF8E52F3B50C000B1FBB2 /* KeyboardViewControllerHelp */, + 040B620E2F4BF2560099DEAC /* KeyboardViewControllerHelp */, 04C6EADE2EAF8D680089C901 /* PrefixHeader.pch */, 04286A0A2ECD88B400CE730C /* KeyboardAssets.xcassets */, ); @@ -2344,6 +2369,13 @@ 0498BD862EE1BEC9006CC1D5 /* KBSignUtils.m in Sources */, 04791FFC2ED71D17004E8522 /* UIColor+Extension.m in Sources */, 0450AC4A2EF2C3ED00B6AF06 /* KBKeyboardSubscriptionOptionCell.m in Sources */, + 040B620F2F4BF2560099DEAC /* KeyboardViewController+Theme.m in Sources */, + 040B62102F4BF2560099DEAC /* KeyboardViewController+Chat.m in Sources */, + 040B62112F4BF2560099DEAC /* KeyboardViewController+Panels.m in Sources */, + 040B62122F4BF2560099DEAC /* KeyboardViewController+Layout.m in Sources */, + 040B62132F4BF2560099DEAC /* KeyboardViewController+UI.m in Sources */, + 040B62142F4BF2560099DEAC /* KeyboardViewController+Subscription.m in Sources */, + 040B62162F4BF2560099DEAC /* KeyboardViewController+Suggestions.m in Sources */, 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */, 048FFD562F2B9C3D005D62AE /* KBChatAssistantCell.m in Sources */, 048FFD572F2B9C3D005D62AE /* KBChatUserCell.m in Sources */,