// // KBFunctionView.m // CustomKeyboard // // Created by Mac on 2025/10/28. // #import "KBFunctionView.h" #import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具 #import "KBFunctionBarView.h" #import "KBFunctionPasteView.h" #import "KBFunctionTagCell.h" #import "Masonry.h" #import #import "KBFullAccessGuideView.h" #import "KBFullAccessManager.h" #import "KBSkinManager.h" #import "KBAuthManager.h" // 登录态判断(共享钥匙串) #import "KBULBridgeNotification.h" // Darwin 通知常量(UL 已处理) #import "KBHostAppLauncher.h" #import "KBStreamTextView.h" // 流式文本视图 #import "KBStreamOverlayView.h" // 带关闭按钮的流式层 #import "KBFunctionTagListView.h" #import "WJXEventSource.h" #import "KBTagItemModel.h" #import #import "KBBizCode.h" #import "KBBackspaceLongPressHandler.h" #import "KBBackspaceUndoManager.h" @interface KBFunctionView () // UI @property (nonatomic, strong) KBFunctionBarView *barViewInternal; @property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal; @property (nonatomic, strong) KBFunctionTagListView *tagListView; @property (nonatomic, strong) UIView *rightButtonContainer; // 右侧竖排按钮容器 @property (nonatomic, strong) UIButton *pasteButtonInternal; @property (nonatomic, strong) UIButton *deleteButtonInternal; @property (nonatomic, strong) UIButton *clearButtonInternal; @property (nonatomic, strong) UIButton *sendButtonInternal; // 叠层:流式文本视图 + 关闭按钮 @property (nonatomic, strong, nullable) KBStreamOverlayView *streamOverlay; // 网络流式(封装) @property (nonatomic, strong, nullable) WJXEventSource *eventSource; @property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用) @property (nonatomic, strong, nullable) NSNumber *loadingTagIndex; // 当前显示loading的标签index @property (nonatomic, copy, nullable) NSString *loadingTagTitle; @property (nonatomic, assign) BOOL eventSourceDidReceiveDone; @property (nonatomic, copy, nullable) NSString *eventSourceSplitPrefix; // Data //@property (nonatomic, strong) NSArray *itemsInternal; @property (nonatomic, strong) NSMutableArray *modelArray; // 剪贴板自动检测 @property (nonatomic, strong) NSTimer *pasteboardTimer; // 轮询定时器(轻量、主线程) @property (nonatomic, assign) NSInteger lastHandledPBCount; // 上次处理过的 changeCount,避免重复弹窗 // UL 双路兜底 @property (nonatomic, assign) NSUInteger kb_ulSeq; // 当前 UL 发起序号 @property (nonatomic, assign) BOOL kb_ulHandledFlag; // 主 App 已确认处理 UL @property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler; @end @implementation KBFunctionView - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { // 背景使用当前主题强调色 [self kb_applyTheme]; self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self]; [self setupUI]; // [self reloadDemoData]; [self kb_reloadTagsFromSharedDefaults]; // 初始化剪贴板监控状态 _lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount; // 监听“完全访问”状态变化,动态启停剪贴板监控,避免在未开完全访问时触发 TCC/XPC 错误日志 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_fullAccessChanged) name:KBFullAccessChangedNotification object:nil]; // 监听主 App 的 Darwin 确认(UL 已处理) CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), KBULDarwinCallback, (__bridge CFStringRef)KBDarwinULHandled, NULL, CFNotificationSuspensionBehaviorDeliverImmediately); } return self; } #pragma mark - Data /// 从 App Group 的 NSUserDefaults 中读取真实 JSON,解析为 model + 标签文案 - (void)kb_reloadTagsFromSharedDefaults { NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroup]; NSDictionary *jsonDict = [sharedDefaults objectForKey:AppGroup_MyKbJson]; if (jsonDict != nil) { id dataObj = jsonDict[@"data"]; NSArray *modelList = [KBTagItemModel mj_objectArrayWithKeyValuesArray:(NSArray *)dataObj]; if (modelList.count > 0) { self.modelArray = [NSMutableArray array]; [self.modelArray addObjectsFromArray:modelList]; // [self.collectionView reloadData]; [self.tagListView setItems:self.modelArray]; } }else{ NSLog(@"json❎"); } } #pragma mark - Theme - (void)kb_applyTheme { // KBSkinManager *mgr = [KBSkinManager shared]; // UIColor *accent = mgr.current.accentColor ?: [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0]; // BOOL hasImg = ([mgr currentBackgroundImage] != nil); self.backgroundColor = [UIColor colorWithHex:0xD0D3DA]; } - (void)dealloc { [self stopPasteboardMonitor]; [self kb_stopNetworkStreaming]; [[NSNotificationCenter defaultCenter] removeObserver:self]; CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinULHandled, NULL); } #pragma mark - UI - (void)setupUI { // 1. 顶部 Bar [self addSubview:self.barViewInternal]; CGFloat barTopInset = KBFit(6.0f); CGFloat barHeight = KBFit(52.0f); [self.barViewInternal mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self); make.top.equalTo(self.mas_top).offset(barTopInset); make.height.mas_equalTo(barHeight); }]; // 右侧竖排按钮容器 [self addSubview:self.rightButtonContainer]; CGFloat rightInset = KBFit(4.0f); CGFloat containerBottomInset = KBFit(10.0f); CGFloat containerWidth = KBFit(60.0f); [self.rightButtonContainer mas_makeConstraints:^(MASConstraintMaker *make) { make.right.equalTo(self.mas_right).offset(-rightInset); make.top.equalTo(self.barViewInternal.mas_bottom).offset(0); make.bottom.equalTo(self.mas_bottom).offset(0); make.width.mas_equalTo(containerWidth); }]; // 右侧四个按钮 [self.rightButtonContainer addSubview:self.pasteButtonInternal]; [self.rightButtonContainer addSubview:self.deleteButtonInternal]; [self.rightButtonContainer addSubview:self.clearButtonInternal]; [self.rightButtonContainer addSubview:self.sendButtonInternal]; // 竖向排布:容器内四个按钮等高分配,间距为 8px(按设计稿等比缩放) CGFloat smallH = KBFit(41.0f); CGFloat vSpace = KBFit(8.0f); [self.pasteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.rightButtonContainer.mas_top); make.left.right.equalTo(self.rightButtonContainer); }]; [self.deleteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.pasteButtonInternal.mas_bottom).offset(vSpace); make.left.right.equalTo(self.rightButtonContainer); make.height.equalTo(self.pasteButtonInternal); }]; [self.clearButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.deleteButtonInternal.mas_bottom).offset(vSpace); make.left.right.equalTo(self.rightButtonContainer); make.height.equalTo(self.pasteButtonInternal); }]; [self.sendButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.clearButtonInternal.mas_bottom).offset(vSpace); make.left.right.equalTo(self.rightButtonContainer); make.height.equalTo(self.pasteButtonInternal); make.bottom.equalTo(self.rightButtonContainer.mas_bottom); }]; // 2. 粘贴区(位于右侧按钮左侧) [self addSubview:self.pasteViewInternal]; [self.pasteViewInternal mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.mas_left).offset(vSpace); make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace); make.top.equalTo(self.barViewInternal.mas_bottom).offset(0); make.height.mas_equalTo(smallH); }]; // 点击整个粘贴卡片按钮,行为与右侧「Paste」按钮保持一致 [self.pasteViewInternal.pasBtn addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside]; // 3. Tag List View [self addSubview:self.tagListView]; [self.tagListView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.pasteViewInternal); make.right.equalTo(self.rightButtonContainer.mas_left).offset(-vSpace); make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(vSpace); make.bottom.equalTo(self.mas_bottom).offset(0); }]; } #pragma mark - Data //- (void)reloadDemoData { // // 演示数据(可由外部替换) // self.itemsInternal = @[KBLocalized(@"Warm hearted man"), // KBLocalized(@"Warm2 hearted man"), // KBLocalized(@"Warm3 hearted man"), // KBLocalized(@"撩女生啊u发顺丰大师傅"), // KBLocalized(@"Warm = man"), // KBLocalized(@"Warm hearted man"), // KBLocalized(@"一枚暖男发放"), // KBLocalized(@"聊天搭子"), // KBLocalized(@"表达爱意"), // KBLocalized(@"更多话术")]; // [self.tagListView setItems:self.itemsInternal]; //} // UICollectionView 逻辑已下沉至 KBFunctionTagListView - (void)kb_showStreamTextViewIfNeededWithTitle:(NSString *)title { // 已有则不重复创建 if (self.streamOverlay.superview) { return; } // 隐藏标签列表,使用同一区域展示流式文本 self.tagListView.hidden = YES; KBStreamOverlayView *overlay = [[KBStreamOverlayView alloc] init]; overlay.delegate = (id)self; [self addSubview:overlay]; [overlay mas_makeConstraints:^(MASConstraintMaker *make) { // 在原标签区域内展示流式文本,右侧继续保留竖排按钮栏 CGFloat vSpace = KBFit(4.0f); CGFloat overlayTopInset = KBFit(10.0f); CGFloat overlayBottomInset = KBFit(10.0f); make.left.equalTo(self.pasteViewInternal); make.right.equalTo(self).offset(-vSpace); make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(overlayTopInset); make.bottom.equalTo(self.mas_bottom).offset(-overlayBottomInset); }]; // 仅隐藏删除/清空/发送按钮,保留“Paste”按钮可用 self.pasteButtonInternal.hidden = NO; self.deleteButtonInternal.hidden = YES; self.clearButtonInternal.hidden = YES; self.sendButtonInternal.hidden = YES; // 适当缩小内部左右留白,进一步提升可用宽度 overlay.textView.contentHorizontalPadding = 8.0; self.streamOverlay = overlay; // 只创建 UI;网络在点击 cell 时启动,避免重复 start 导致首包重复 } - (void)kb_onTapStreamDelete { // 关闭并销毁流式视图,恢复标签列表 [self kb_stopNetworkStreaming]; [self.streamOverlay removeFromSuperview]; self.streamOverlay = nil; self.tagListView.hidden = NO; // 恢复右侧按钮栏的全部按钮 self.pasteButtonInternal.hidden = NO; self.deleteButtonInternal.hidden = NO; self.clearButtonInternal.hidden = NO; self.sendButtonInternal.hidden = NO; } // 叠层关闭回调 - (void)streamOverlayDidTapClose:(KBStreamOverlayView *)overlay { [self kb_onTapStreamDelete]; } #pragma mark - Network Streaming (WJXEventSource) - (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle { [self kb_stopNetworkStreaming]; if (![[KBFullAccessManager shared] hasFullAccess]) { return; } NSString *apiUrl = [NSString stringWithFormat:@"%@%@", KB_BASE_URL, API_AI_TALK]; NSURL *url = [NSURL URLWithString:apiUrl]; if (!url) { return; } NSInteger characterId = 0; if (self.loadingTagIndex != nil) { NSInteger idx = self.loadingTagIndex.integerValue; if (idx >= 0 && idx < self.modelArray.count) { KBTagItemModel *model = self.modelArray[idx]; characterId = model.characterId; } } NSInteger resolvedCharacterId = (characterId > 0) ? characterId : 75; NSString *message = seedTitle.length > 0 ? seedTitle : @"aliqua non cupidatat"; // message = [NSString stringWithFormat:@"%@%d",message,arc4random() % 10000]; NSDictionary *payload = @{ @"characterId": @(resolvedCharacterId), @"message": message }; NSLog(@"[KBFunction] request payload: %@", payload); NSError *bodyError = nil; NSData *bodyData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&bodyError]; if (bodyError || bodyData.length == 0) { NSLog(@"[KBFunction] build body failed: %@", bodyError); return; } NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:60]; request.HTTPMethod = @"POST"; [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; NSString *token = KBAuthManager.shared.current.accessToken ?: @""; if (token.length > 0) { [request setValue:token forHTTPHeaderField:@"auth-token"]; } request.HTTPBody = bodyData; self.streamHasOutput = NO; self.eventSourceSplitPrefix = nil; self.eventSourceDidReceiveDone = NO; __weak typeof(self) weakSelf = self; WJXEventSource *source = [[WJXEventSource alloc] initWithRquest:request]; source.ignoreRetryAction = YES; [source addListener:^(WJXEvent * _Nonnull event) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; [self kb_handleEventSourceMessage:event]; } forEvent:WJXEventNameMessage queue:NSOperationQueue.mainQueue]; [source addListener:^(WJXEvent * _Nonnull event) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; [self kb_handleEventSourceError:event.error]; } forEvent:WJXEventNameError queue:NSOperationQueue.mainQueue]; self.eventSource = source; [self.eventSource open]; } - (void)kb_stopNetworkStreaming { [self.eventSource close]; self.eventSource = nil; self.eventSourceSplitPrefix = nil; self.eventSourceDidReceiveDone = NO; self.streamHasOutput = NO; } - (void)kb_handleEventSourceMessage:(WJXEvent *)event { if (event.data.length == 0) { return; } NSLog(@"[KBStream] SSE raw payload: %@", event.data); NSData *jsonData = [event.data dataUsingEncoding:NSUTF8StringEncoding]; if (!jsonData) { return; } NSError *error = nil; NSDictionary *payload = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error]; if (error || ![payload isKindOfClass:[NSDictionary class]]) { return; } if ([self kb_handleBizErrorIfNeeded:payload]) { return; } NSString *type = payload[@"type"]; if (![type isKindOfClass:[NSString class]]) { return; } if ([type isEqualToString:@"llm_chunk"]) { NSString *chunk = [self kb_normalizedLLMChunkString:payload[@"data"]]; if (chunk.length > 0) { [self kb_appendChunkToStreamView:chunk]; } return; } if ([type isEqualToString:@"search_result"]) { NSString *text = [self kb_formattedSearchResultString:payload[@"data"]]; if (text.length > 0) { [self kb_appendChunkToStreamView:text]; } return; } if ([type isEqualToString:@"done"]) { self.eventSourceDidReceiveDone = YES; [self kb_finishEventSourceWithError:nil]; return; } } - (void)kb_handleEventSourceError:(NSError * _Nullable)error { if (self.eventSourceDidReceiveDone) { return; } [self kb_finishEventSourceWithError:error]; } - (void)kb_finishEventSourceWithError:(NSError * _Nullable)error { [self.eventSource close]; self.eventSource = nil; if (!self.streamHasOutput && self.loadingTagIndex) { [self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue]; self.loadingTagIndex = nil; self.loadingTagTitle = nil; } BOOL shouldShowError = (error != nil); if (shouldShowError) { [KBHUD showInfo:error.localizedDescription ?: KBLocalized(@"拉取失败")]; } if (self.streamOverlay) { [self.streamOverlay finish]; } self.eventSourceSplitPrefix = nil; self.eventSourceDidReceiveDone = NO; } - (BOOL)kb_handleBizErrorIfNeeded:(NSDictionary *)payload { NSInteger code = KBBizCodeFromJSONObject(payload); if (code == NSNotFound || code == KBBizCodeSuccess) { return NO; } BOOL needSubscriptionGuide = (code == KBBizCodeQuotaExhausted); NSString *msg = KBBizMessageFromJSONObject(payload); if (msg.length == 0) { msg = KBLocalized(@"拉取失败"); } NSError *bizError = [NSError errorWithDomain:@"KBStreamBizError" code:code userInfo:@{NSLocalizedDescriptionKey: msg}]; [self kb_finishEventSourceWithError:bizError]; if (needSubscriptionGuide) { [self kb_requestSubscriptionGuide]; } return YES; } - (void)kb_requestSubscriptionGuide { if ([self.delegate respondsToSelector:@selector(functionViewDidRequestSubscription:)]) { [self.delegate functionViewDidRequestSubscription:self]; } } #pragma mark - Event Parsing - (NSString *)kb_normalizedLLMChunkString:(id)dataValue { if (![dataValue isKindOfClass:[NSString class]]) { return @""; } NSString *text = (NSString *)dataValue; // 1. 处理上一个包遗留的 前缀(比如 "") if (self.eventSourceSplitPrefix.length > 0) { text = [self.eventSourceSplitPrefix stringByAppendingString:text ?: @""]; self.eventSourceSplitPrefix = nil; } if (text.length == 0) { return @""; } // 2. 去掉开头多余换行(避免一开始就空一大块) while (text.length > 0) { unichar c0 = [text characterAtIndex:0]; if (c0 == '\n' || c0 == '\r') { text = [text substringFromIndex:1]; continue; } break; } if (text.length == 0) { return @""; } // 3. 处理结尾可能是不完整的 " 0) { self.eventSourceSplitPrefix = suffix; text = [text substringToIndex:text.length - suffix.length]; } if (text.length == 0) { return @""; } // 4. 处理完整的 ,变成段落分隔符 \t text = [text stringByReplacingOccurrencesOfString:@"" withString:@"\t"]; // 不再做其它替换,不合并 /t、不改行,只把真正内容原样丢给 UI return text; } - (NSString *)kb_formattedSearchResultString:(id)dataValue { // data 不是数组就直接返回空串 if (![dataValue isKindOfClass:[NSArray class]]) { return @""; } NSArray *list = (NSArray *)dataValue; NSMutableArray *segments = [NSMutableArray array]; [list enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { NSString *payload = nil; if ([obj isKindOfClass:[NSDictionary class]]) { id val = obj[@"payload"]; if ([val isKindOfClass:[NSString class]]) { payload = (NSString *)val; } } else if ([obj isKindOfClass:[NSString class]]) { // 兼容后端直接给字符串数组的情况 payload = (NSString *)obj; } payload = [payload stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; if (payload.length > 0) { // 每一个 payload 就是一段 [segments addObject:payload]; } }]; if (segments.count == 0) { return @""; } // 用 \t 拼起来,KBStreamTextView 会按 \t 拆成多个 label NSMutableString *result = [NSMutableString string]; [segments enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { // 每段前面加一个 \t,保证是新的一段 [result appendFormat:@"\t%@", obj]; }]; return result; } - (NSString *)kb_pendingSplitSuffixForString:(NSString *)text { static NSString * const token = @""; if (text.length == 0) { return @""; } NSUInteger tokenLen = token.length; if (tokenLen <= 1) { return @""; } NSUInteger maxLen = MIN(tokenLen - 1, text.length); for (NSUInteger len = maxLen; len > 0; len--) { NSString *suffix = [text substringFromIndex:text.length - len]; NSString *prefix = [token substringToIndex:len]; if ([suffix isEqualToString:prefix]) { return suffix; } } return @""; } #pragma mark - Helpers /// 统一处理需要输出到 KBStreamTextView 的分片: /// - 已将 `` 转换为 `\t` 并去掉多余换行 /// - 这里仅负责附加到视图与标记首段状态,避免 UI 抖动 - (void)kb_appendChunkToStreamView:(NSString *)chunk { if (chunk.length == 0) return; // 第一次有数据才创建 overlay,并取消 cell 上的小菊花 if (!self.streamOverlay) { [self kb_showStreamTextViewIfNeededWithTitle:self.loadingTagTitle ?: @""]; if (self.loadingTagIndex) { [self.tagListView setLoading:NO atIndex:self.loadingTagIndex.integerValue]; self.loadingTagIndex = nil; self.loadingTagTitle = nil; } } if (!self.streamOverlay) return; [self.streamOverlay appendChunk:chunk]; self.streamHasOutput = YES; } /// 统一更新左侧粘贴按钮的展示: /// - 有粘贴文本:只显示文字,不再展示左侧图标; /// - 无粘贴文本:恢复原始图标 + 占位文案。 - (void)kb_updatePasteButtonWithDisplayText:(NSString * _Nullable)text { if (text.length > 0) { NSString *displayText = text; if (displayText.length > 30) { displayText = [[displayText substringToIndex:30] stringByAppendingString:@"…"]; } [self.pasteView.pasBtn setImage:nil forState:UIControlStateNormal]; [self.pasteView.pasBtn setTitle:displayText forState:UIControlStateNormal]; } else { UIImage *img = [UIImage imageNamed:@"kb_zt_icon"]; [self.pasteView.pasBtn setImage:img forState:UIControlStateNormal]; [self.pasteView.pasBtn setTitle:KBLocalized(@" Paste Ta's Words") forState:UIControlStateNormal]; } } #pragma mark - KBFunctionTagListViewDelegate - (void)tagListView:(KBFunctionTagListView *)view didSelectIndex:(NSInteger)index title:(NSString *)title { // 1) 先判断权限:未开启“完全访问”则走引导逻辑 if (![[KBFullAccessManager shared] hasFullAccess]) { // 未开启完全访问:保持原有引导路径 [KBHUD showInfo:KBLocalized(@"处理中…")]; [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self]; return; } // 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录 if (!KBAuthManager.shared.isLoggedIn) { UIInputViewController *ivc = KBFindInputViewController(self); NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]; NSURL *scheme = [NSURL URLWithString:schemeStr]; // 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:ivc.view]; return; // if (!ivc) return; // NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @""; // NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)index, encodedTitle]]; // if (!ul) return; // // 发起 UL,不依赖 ok 结果 // dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ // [ivc.extensionContext openURL:ul completionHandler:^(__unused BOOL ok) {}]; // }); // // 双路兜底:500ms 内未收到主 App 确认,则回退到自定义 Scheme(通过宿主 UIApplication 打开) // self.kb_ulHandledFlag = NO; // NSUInteger token = ++self.kb_ulSeq; // dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ // if (token != self.kb_ulSeq) return; // 已有新请求覆盖 // if (self.kb_ulHandledFlag) return; // 主 App 已确认处理 // NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@://login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)index, encodedTitle]]; // if (!scheme) return; // UIResponder *start = ivc.view ?: (UIResponder *)self; // // 让键盘失去焦点 // [ivc dismissKeyboard]; // BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start]; // if (!ok) { // [KBHUD showInfo:KBLocalized(@"请切换到主App完成登录")]; // }else{ // // } // }); // return; } BOOL hasPasteText = ![self.pasteView.pasBtn.currentTitle isEqualToString:KBLocalized(@" Paste Ta's Words")]; // BOOL hasPasteText = (self.pasteView.pasBtn.imageView.image == nil); if (!hasPasteText) { [KBHUD showInfo:KBLocalized(@"Please copy the text first")]; return; } NSString *copyTitle = self.pasteView.pasBtn.currentTitle; // 3) 已登录:开始业务逻辑(展示加载并拉取流式内容) [self.tagListView setLoading:YES atIndex:index]; self.loadingTagIndex = @(index); self.loadingTagTitle = title ?: @""; [self kb_startNetworkStreamingWithSeed:copyTitle]; return; } // Darwin 回调:主 App 已处理 UL static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { KBFunctionView *self_ = (__bridge KBFunctionView *)observer; if (!self_) return; dispatch_async(dispatch_get_main_queue(), ^{ self_.kb_ulHandledFlag = YES; }); } // 用户点击功能标签:优先 UL 拉起主App,失败再 Scheme;两次都失败则提示开启完全访问。 // 若已开启“完全访问”,则直接在键盘侧创建 KBStreamTextView,并在其右上角提供删除按钮关闭。 - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { // 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。 if ([[KBFullAccessManager shared] hasFullAccess]) { KBTagItemModel *selModel = self.modelArray[indexPath.item]; [self kb_showStreamTextViewIfNeededWithTitle:selModel.characterName]; return; } [KBHUD showInfo:KBLocalized(@"处理中…")]; UIInputViewController *ivc = KBFindInputViewController(self); if (!ivc) return; NSString *title = self.modelArray[indexPath.item].characterName; NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @""; NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)indexPath.item, encodedTitle]]; if (!ul) return; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ // 先尝试通过 extensionContext 打开 UL [ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) { if (ok) { return; } // UL 失败时,再通过宿主 UIApplication + 自定义 Scheme 兜底 NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)indexPath.item, encodedTitle]]; UIResponder *start = ivc.view ?: (UIResponder *)self; BOOL ok2 = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start]; if (!ok2) { // 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager 引导。 dispatch_async(dispatch_get_main_queue(), ^{ [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self]; }); } }]; }); } #pragma mark - Button Actions - (void)onTapPaste { // 用户点击“粘贴”时才读取剪贴板: // - iOS16+ 会在跨 App 首次读取时自动弹出系统权限弹窗; // - iOS15 及以下不会弹窗,直接返回内容; // 注意:不要在非用户触发的时机主动读取(如 viewDidLoad),否则会造成“立刻弹窗”的体验。 // 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。 if (![[KBFullAccessManager shared] hasFullAccess]) { // 未开启完全访问:保持原有引导路径 [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self]; return; } UIPasteboard *pb = [UIPasteboard generalPasteboard]; NSString *text = pb.string; // 读取纯文本(可能触发系统粘贴权限弹窗) if (text.length <= 0) { // 无可用文本或用户拒绝了粘贴权限;保持占位文案不变 NSLog(@"粘贴板无可用文本或未授权粘贴"); [KBHUD showInfo:KBLocalized(@"Clipboard is empty")]; return; } // 1)把内容真正「粘贴」到当前输入框 // UIInputViewController *ivc = KBFindInputViewController(self); // if (ivc) { // id proxy = ivc.textDocumentProxy; // [proxy insertText:text]; // } // 2)顺便把最新的剪贴板内容展示在左侧粘贴区按钮上,便于用户确认 [self kb_updatePasteButtonWithDisplayText:text]; } #pragma mark - 自动监控剪贴板(复制即弹窗) // 说明: // - 仅在视图可见时开启轮询,避免不必要的读取与打扰; // - 当检测到 changeCount 变化,立即读 pasteboard.string: // * iOS16+:此处会触发系统“是否允许粘贴”弹窗; // * iOS15:不会弹窗,直接得到文本; // - 无论允许/拒绝,都把本次 changeCount 记为已处理,避免一直重复询问。 - (void)startPasteboardMonitor { // 未开启“完全访问”时不做自动读取,避免宿主/系统拒绝并刷错误日志 if (![[KBFullAccessManager shared] hasFullAccess]) return; if (self.pasteboardTimer) return; KBWeakSelf self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; UIPasteboard *pb = [UIPasteboard generalPasteboard]; NSInteger cc = pb.changeCount; if (cc <= self.lastHandledPBCount) return; // 没有新复制 self.lastHandledPBCount = cc; // 标记已处理,避免重复 // 实际读取触发系统弹窗(iOS16+) NSString *text = pb.string; // 有文字 -> 仅展示文字;无文字/非文本 -> 恢复图标 + 原占位文案 [self kb_updatePasteButtonWithDisplayText:text]; }]; } - (void)stopPasteboardMonitor { [self.pasteboardTimer invalidate]; self.pasteboardTimer = nil; } - (void)didMoveToWindow { [super didMoveToWindow]; [self kb_refreshPasteboardMonitor]; } - (void)setHidden:(BOOL)hidden { BOOL wasHidden = self.isHidden; [super setHidden:hidden]; if (wasHidden != hidden) { [self kb_refreshPasteboardMonitor]; } } // 根据窗口可见性与完全访问状态,统一启停粘贴板监控 - (void)kb_refreshPasteboardMonitor { BOOL visible = (self.window && !self.isHidden); if (visible && [[KBFullAccessManager shared] hasFullAccess]) { [self startPasteboardMonitor]; } else { [self stopPasteboardMonitor]; } } - (void)kb_fullAccessChanged { dispatch_async(dispatch_get_main_queue(), ^{ [self kb_refreshPasteboardMonitor]; }); } - (void)onTapDelete { NSLog(@"点击:删除"); [[KBBackspaceUndoManager shared] registerNonClearAction]; UIInputViewController *ivc = KBFindInputViewController(self); id proxy = ivc.textDocumentProxy; [proxy deleteBackward]; } - (void)onTapClear { NSLog(@"点击:清空"); [self.backspaceHandler performClearAction]; } - (void)onTapSend { NSLog(@"点击:发送"); [[KBBackspaceUndoManager shared] registerNonClearAction]; // 发送:插入换行。大多数聊天类 App 会把回车视为“发送” UIInputViewController *ivc = KBFindInputViewController(self); id proxy = ivc.textDocumentProxy; [proxy insertText:@"\n"]; } #pragma mark - Lazy - (KBFunctionBarView *)barViewInternal { if (!_barViewInternal) { _barViewInternal = [[KBFunctionBarView alloc] init]; _barViewInternal.delegate = self; // 顶部功能Bar事件下发到本View } return _barViewInternal; } #pragma mark - KBFunctionBarViewDelegate - (void)functionBarView:(KBFunctionBarView *)bar didTapLeftAtIndex:(NSInteger)index { // 将事件继续透传给上层(如键盘控制器),用于切换界面或其它业务 if ([self.delegate respondsToSelector:@selector(functionView:didTapToolActionAtIndex:)]) { [self.delegate functionView:self didTapToolActionAtIndex:index]; } } - (void)functionBarView:(KBFunctionBarView *)bar didTapRightAtIndex:(NSInteger)index { // 右侧按钮点击,如收藏/宫格等,按需继续向外抛出(这里暂不定义单独协议方法,可在此内部处理或扩展) if ([self.delegate respondsToSelector:@selector(functionView:didRightTapToolActionAtIndex:)]) { [self.delegate functionView:self didRightTapToolActionAtIndex:index]; } } - (KBFunctionPasteView *)pasteViewInternal { if (!_pasteViewInternal) { _pasteViewInternal = [[KBFunctionPasteView alloc] init]; } return _pasteViewInternal; } - (KBFunctionTagListView *)tagListView { if (!_tagListView) { _tagListView = [[KBFunctionTagListView alloc] init]; _tagListView.delegate = (id)self; } return _tagListView; } - (UIView *)rightButtonContainer { if (!_rightButtonContainer) { _rightButtonContainer = [[UIView alloc] init]; _rightButtonContainer.backgroundColor = [UIColor clearColor]; } return _rightButtonContainer; } - (UIButton *)buildRightButtonWithTitle:(NSString *)title color:(UIColor *)color { UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; btn.backgroundColor = color; btn.layer.cornerRadius = 8.0; btn.layer.masksToBounds = YES; btn.titleLabel.font = [KBFont medium:13]; [btn setTitle:title forState:UIControlStateNormal]; [btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; return btn; } - (UIButton *)pasteButtonInternal { if (!_pasteButtonInternal) { _pasteButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Paste") color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]]; [_pasteButtonInternal addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside]; } return _pasteButtonInternal; } - (UIButton *)deleteButtonInternal { if (!_deleteButtonInternal) { _deleteButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom]; _deleteButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8]; _deleteButtonInternal.layer.cornerRadius = 8.0; _deleteButtonInternal.layer.masksToBounds = YES; [_deleteButtonInternal setImage:[UIImage imageNamed:@"kb_del_icon"] forState:UIControlStateNormal]; [_deleteButtonInternal addTarget:self action:@selector(onTapDelete) forControlEvents:UIControlEventTouchUpInside]; [self.backspaceHandler bindDeleteButton:_deleteButtonInternal showClearLabel:NO]; } return _deleteButtonInternal; } - (UIButton *)clearButtonInternal { if (!_clearButtonInternal) { _clearButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom]; _clearButtonInternal.backgroundColor = [UIColor colorWithHex:0xB9BDC8]; _clearButtonInternal.layer.cornerRadius = 8.0; _clearButtonInternal.layer.masksToBounds = YES; _clearButtonInternal.titleLabel.font = [KBFont medium:13]; [_clearButtonInternal setTitle:KBLocalized(@"Clear") forState:UIControlStateNormal]; [_clearButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal]; [_clearButtonInternal addTarget:self action:@selector(onTapClear) forControlEvents:UIControlEventTouchUpInside]; } return _clearButtonInternal; } - (UIButton *)sendButtonInternal { if (!_sendButtonInternal) { _sendButtonInternal = [self buildRightButtonWithTitle:KBLocalized(@"Send") color:[UIColor colorWithHex:0x02BEAC]]; [_sendButtonInternal addTarget:self action:@selector(onTapSend) forControlEvents:UIControlEventTouchUpInside]; } return _sendButtonInternal; } #pragma mark - Expose - (UICollectionView *)collectionView { return self.tagListView.collectionView; } //- (NSArray *)items { return self.itemsInternal; } - (KBFunctionBarView *)barView { return self.barViewInternal; } - (KBFunctionPasteView *)pasteView { return self.pasteViewInternal; } - (UIButton *)pasteButton { return self.pasteButtonInternal; } - (UIButton *)deleteButton { return self.deleteButtonInternal; } - (UIButton *)clearButton { return self.clearButtonInternal; } - (UIButton *)sendButton { return self.sendButtonInternal; } #pragma mark - Find Owner Controller // 工具方法已提取到 KBResponderUtils.h @end