Files
keyboard/CustomKeyboard/View/KBFunctionView.m

912 lines
38 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 <MBProgressHUD.h>
#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 <MJExtension/MJExtension.h>
#import "KBBizCode.h"
#import "KBBackspaceLongPressHandler.h"
#import "KBBackspaceUndoManager.h"
@interface KBFunctionView () <KBFunctionBarViewDelegate, KBStreamOverlayViewDelegate, KBFunctionTagListViewDelegate>
// 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<NSString *> *itemsInternal;
@property (nonatomic, strong) NSMutableArray<KBTagItemModel *> *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<KBTagItemModel *> *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];
[self.barViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self);
make.top.equalTo(self.mas_top).offset(6);
make.height.mas_equalTo(52);
}];
// 右侧竖排按钮容器
[self addSubview:self.rightButtonContainer];
[self.rightButtonContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.mas_right).offset(-4);
make.top.equalTo(self.barViewInternal.mas_bottom).offset(0);
make.bottom.equalTo(self.mas_bottom).offset(-10);
make.width.mas_equalTo(60);
}];
// 右侧四个按钮
[self.rightButtonContainer addSubview:self.pasteButtonInternal];
[self.rightButtonContainer addSubview:self.deleteButtonInternal];
[self.rightButtonContainer addSubview:self.clearButtonInternal];
[self.rightButtonContainer addSubview:self.sendButtonInternal];
// 竖向排布:粘贴、删除、清空为等高;发送优先更高,但允许在空间不足时压缩
CGFloat smallH = 41;
CGFloat bigH = 56;
// 原 10 在键盘总高度 276 下容易超出容器,改为 8 以避免 AutoLayout 冲突
CGFloat vSpace = 4;
[self.pasteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.rightButtonContainer.mas_top);
make.left.right.equalTo(self.rightButtonContainer);
make.height.mas_equalTo(smallH);
}];
[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);
// 允许在空间不足时缩短到 smallH避免产生约束冲突
make.height.greaterThanOrEqualTo(@(smallH));
make.height.lessThanOrEqualTo(@(bigH));
make.bottom.lessThanOrEqualTo(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(-10);
}];
}
#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 = 4.0;
make.left.equalTo(self.pasteViewInternal);
make.right.equalTo(self).offset(-vSpace);
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(10);
make.bottom.equalTo(self.mas_bottom).offset(-10);
}];
// 仅隐藏删除/清空/发送按钮保留“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. 处理上一个包遗留的 <SPLIT> 前缀(比如 "<SP" + "LIT>"
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. 处理结尾可能是不完整的 "<SPLIT" 之类,先截掉,放到下一个包里拼
NSString *suffix = [self kb_pendingSplitSuffixForString:text];
if (suffix.length > 0) {
self.eventSourceSplitPrefix = suffix;
text = [text substringToIndex:text.length - suffix.length];
}
if (text.length == 0) { return @""; }
// 4. 处理完整的 <SPLIT>,变成段落分隔符 \t
text = [text stringByReplacingOccurrencesOfString:@"<SPLIT>" withString:@"\t"];
// 不再做其它替换,不合并 /t、不改行只把真正内容原样丢给 UI
return text;
}
- (NSString *)kb_formattedSearchResultString:(id)dataValue {
// data 不是数组就直接返回空串
if (![dataValue isKindOfClass:[NSArray class]]) { return @""; }
NSArray *list = (NSArray *)dataValue;
NSMutableArray<NSString *> *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 = @"<SPLIT>";
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 的分片:
/// - 已将 `<SPLIT>` 转换为 `\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<UITextDocumentProxy> 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<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
[proxy deleteBackward];
}
- (void)onTapClear {
NSLog(@"点击:清空");
[self.backspaceHandler performClearAction];
}
- (void)onTapSend {
NSLog(@"点击:发送");
[[KBBackspaceUndoManager shared] registerNonClearAction];
// 发送:插入换行。大多数聊天类 App 会把回车视为“发送”
UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> 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<NSString *> *)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