diff --git a/CustomKeyboard/Network/KBStreamFetcher.h b/CustomKeyboard/Network/KBStreamFetcher.h new file mode 100644 index 0000000..f077e8c --- /dev/null +++ b/CustomKeyboard/Network/KBStreamFetcher.h @@ -0,0 +1,48 @@ +// +// KBStreamFetcher.h +// CustomKeyboard +// +// 轻量网络流拉取器:支持纯文本分块与 SSE(text/event-stream) 两种形式的“边下边显”。 +// - 增量解码:按 UTF-8 安全前缀逐步转成字符串,避免半个多字节字符导致阻塞/乱码 +// - SSE 解析:按 \n\n 切事件,合并 data: 行,移除前缀,仅回传正文 +// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t” +// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^KBStreamFetcherChunkHandler)(NSString *chunk); +typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error); + +@interface KBStreamFetcher : NSObject + +// 便利构造 ++ (instancetype)fetcherWithURL:(NSURL *)url; + +// 必填:请求地址 +@property (nonatomic, strong) NSURL *url; + +// 可选 Header +@property (nonatomic, copy, nullable) NSDictionary *extraHeaders; + +// 配置项(默认值见注释) +@property (nonatomic, assign) BOOL acceptEventStream; // 默认 NO;置 YES 时发送 Accept: text/event-stream +@property (nonatomic, assign) BOOL disableCompression; // 默认 YES;发送 Accept-Encoding: identity +@property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES;将“/t”替换为“\t” +@property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES;首次正文起始的“\t”删一个(忽略前导空白) +@property (nonatomic, assign) NSTimeInterval requestTimeout; // 默认 30s + +// 回调(统一在主线程触发) +@property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk; +@property (nonatomic, copy, nullable) KBStreamFetcherFinishHandler onFinish; + +// 控制 +- (void)start; +- (void)cancel; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/CustomKeyboard/Network/KBStreamFetcher.m b/CustomKeyboard/Network/KBStreamFetcher.m new file mode 100644 index 0000000..32338c9 --- /dev/null +++ b/CustomKeyboard/Network/KBStreamFetcher.m @@ -0,0 +1,247 @@ +// +// KBStreamFetcher.m +// + +#import "KBStreamFetcher.h" + +@interface KBStreamFetcher () +@property (nonatomic, strong) NSURLSession *session; +@property (nonatomic, strong) NSURLSessionDataTask *task; +@property (nonatomic, strong) NSMutableData *buffer; // 网络原始字节累加 +@property (nonatomic, assign) NSStringEncoding textEncoding; // 推断得到的文本编码(默认 UTF-8) +@property (nonatomic, assign) BOOL isSSE; // 是否为 SSE 响应 +@property (nonatomic, strong) NSMutableString *sseTextBuffer; // SSE 文本缓冲(已解码) +@property (nonatomic, assign) NSInteger decodedPrefixBytes; // 已解码并写入 sseTextBuffer 的字节数(SSE) +@property (nonatomic, assign) NSInteger deliveredCharCount; // 已回传的字符数(非 SSE,用于做增量) +@property (nonatomic, assign) BOOL hasEmitted; // 是否已经输出过正文(用于“首段删 1 个 \t”) +@end + +// 计算数据中以 UTF-8 编码可完整解码的“前缀字节长度”,避免切断多字节字符 +static NSUInteger kb_validUTF8PrefixLen(NSData *data) { + const unsigned char *bytes = (const unsigned char *)data.bytes; + NSUInteger n = data.length; + if (n == 0) return 0; + NSInteger i = (NSInteger)n - 1; + while (i >= 0 && (bytes[i] & 0xC0) == 0x80) { i--; } // 10xxxxxx 续字节 + if (i < 0) return 0; // 全是续字节,等下次 + unsigned char b = bytes[i]; + NSUInteger expected = 1; + if ((b & 0x80) == 0x00) expected = 1; // 0xxxxxxx + else if ((b & 0xE0) == 0xC0) expected = 2; // 110xxxxx + else if ((b & 0xF0) == 0xE0) expected = 3; // 1110xxxx + else if ((b & 0xF8) == 0xF0) expected = 4; // 11110xxx + else return (NSUInteger)i; // 非法起始,截到 i 之前 + NSUInteger remain = n - (NSUInteger)i; + return (remain >= expected) ? n : (NSUInteger)i; +} + +@implementation KBStreamFetcher + ++ (instancetype)fetcherWithURL:(NSURL *)url { + KBStreamFetcher *f = [[self alloc] init]; + f.url = url; + return f; +} + +- (instancetype)init { + if (self = [super init]) { + _acceptEventStream = NO; + _disableCompression = YES; + _treatSlashTAsTab = YES; + _trimLeadingTabOnce = YES; + _requestTimeout = 30.0; + _textEncoding = NSUTF8StringEncoding; + _buffer = [NSMutableData data]; + _sseTextBuffer = [NSMutableString string]; + } + return self; +} + +- (void)start { + if (!self.url) return; + [self cancel]; + + NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration]; + cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + cfg.timeoutIntervalForRequest = self.requestTimeout; + cfg.timeoutIntervalForResource = MAX(self.requestTimeout, 60.0); + + self.session = [NSURLSession sessionWithConfiguration:cfg delegate:self delegateQueue:[NSOperationQueue mainQueue]]; + + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:self.url]; + req.HTTPMethod = @"GET"; + if (self.disableCompression) { [req setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; } + if (self.acceptEventStream) { [req setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"]; } + [req setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"]; + [req setValue:@"keep-alive" forHTTPHeaderField:@"Connection"]; + [self.extraHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *k, NSString *v, BOOL *stop){ [req setValue:v forHTTPHeaderField:k]; }]; + + // 状态复位 + [self.buffer setLength:0]; + [self.sseTextBuffer setString:@""]; + self.isSSE = NO; + self.textEncoding = NSUTF8StringEncoding; + self.decodedPrefixBytes = 0; + self.deliveredCharCount = 0; + self.hasEmitted = NO; + + self.task = [self.session dataTaskWithRequest:req]; + [self.task resume]; +} + +- (void)cancel { + [self.task cancel]; + self.task = nil; + [self.session invalidateAndCancel]; + self.session = nil; + [self.buffer setLength:0]; + [self.sseTextBuffer setString:@""]; + self.decodedPrefixBytes = 0; + self.deliveredCharCount = 0; + self.hasEmitted = NO; +} + +#pragma mark - NSURLSessionDataDelegate + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler { + self.isSSE = NO; + self.textEncoding = NSUTF8StringEncoding; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *r = (NSHTTPURLResponse *)response; + NSString *ct = r.allHeaderFields[@"Content-Type"] ?: r.allHeaderFields[@"content-type"]; + if ([ct isKindOfClass:[NSString class]]) { + NSString *lower = [ct lowercaseString]; + if ([lower containsString:@"text/event-stream"]) self.isSSE = YES; + NSRange pos = [lower rangeOfString:@"charset="]; + if (pos.location != NSNotFound) { + NSString *charset = [[lower substringFromIndex:pos.location + pos.length] componentsSeparatedByString:@";"][0]; + if ([charset containsString:@"utf-8"] || [charset containsString:@"utf8"]) { + self.textEncoding = NSUTF8StringEncoding; + } else if ([charset containsString:@"iso-8859-1"] || [charset containsString:@"latin1"]) { + self.textEncoding = NSISOLatin1StringEncoding; + } + } + } + } + [self.sseTextBuffer setString:@""]; + self.decodedPrefixBytes = 0; + if (completionHandler) completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data { + if (data.length == 0) return; + [self.buffer appendData:data]; + + NSUInteger validLen = (self.textEncoding == NSUTF8StringEncoding) + ? kb_validUTF8PrefixLen(self.buffer) + : self.buffer.length; + if (validLen == 0) return; // 末尾可能卡着半个字符 + + if (self.isSSE) { + if ((NSUInteger)self.decodedPrefixBytes < validLen) { + NSRange rng = NSMakeRange((NSUInteger)self.decodedPrefixBytes, validLen - (NSUInteger)self.decodedPrefixBytes); + NSString *piece = [[NSString alloc] initWithBytes:(const char *)self.buffer.bytes + rng.location + length:rng.length + encoding:self.textEncoding]; + if (piece.length > 0) { + [self.sseTextBuffer appendString:piece]; + self.decodedPrefixBytes = (NSInteger)validLen; + } + } + // 统一换行并按 SSE 事件 \n\n 切开 + if (self.sseTextBuffer.length > 0) { + NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]; + [self.sseTextBuffer setString:normalized]; + while (1) { + NSRange sep = [self.sseTextBuffer rangeOfString:@"\n\n"]; // 完整事件 + if (sep.location == NSNotFound) break; + NSString *event = [self.sseTextBuffer substringToIndex:sep.location]; + [self.sseTextBuffer deleteCharactersInRange:NSMakeRange(0, sep.location + sep.length)]; + + // 合并 data: 行为正文 + NSArray *lines = [event componentsSeparatedByString:@"\n"]; + NSMutableString *payload = [NSMutableString string]; + for (NSString *ln in lines) { + if ([ln hasPrefix:@"data:"]) { + NSString *v = [ln substringFromIndex:5]; + if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; + [payload appendString:v ?: @""]; + [payload appendString:@"\n"]; // 多 data 行合并 + } + } + if (payload.length > 0 && [payload hasSuffix:@"\n"]) { + [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; + } + if (payload.length > 0) { + [self emitChunk:payload]; + } + } + } + return; + } + + // 非 SSE:直接对“可解码前缀”做增量输出 + NSString *prefix = [[NSString alloc] initWithBytes:self.buffer.bytes length:validLen encoding:self.textEncoding]; + if (!prefix) return; + if (self.deliveredCharCount < (NSInteger)prefix.length) { + NSString *delta = [prefix substringFromIndex:self.deliveredCharCount]; + self.deliveredCharCount = prefix.length; + [self emitChunk:delta]; + } +} + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { + if (!error && self.isSSE && self.sseTextBuffer.length > 0) { + // 处理最后一条未以 \n\n 结束的事件 + NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]; + NSArray *lines = [normalized componentsSeparatedByString:@"\n"]; + NSMutableString *payload = [NSMutableString string]; + for (NSString *ln in lines) { + if ([ln hasPrefix:@"data:"]) { + NSString *v = [ln substringFromIndex:5]; + if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; + [payload appendString:v ?: @""]; + [payload appendString:@"\n"]; + } + } + if (payload.length > 0 && [payload hasSuffix:@"\n"]) { + [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; + } + if (payload.length > 0) { + [self emitChunk:payload]; + } + } + + if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); }); + [self cancel]; +} + +#pragma mark - Helpers + +- (void)emitChunk:(NSString *)rawText { + if (rawText.length == 0) return; + NSString *text = rawText; + if (self.treatSlashTAsTab) { + text = [text stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; + } + if (!self.hasEmitted && self.trimLeadingTabOnce) { + // 跳过前导空白,只删除“一个”起始的 \t + NSUInteger i = 0; + while (i < text.length) { + unichar c = [text characterAtIndex:i]; + if (c == ' ' || c == '\r' || c == '\n') { i++; continue; } + break; + } + if (i < text.length && [text characterAtIndex:i] == '\t') { + NSMutableString *m = [text mutableCopy]; + [m deleteCharactersInRange:NSMakeRange(i, 1)]; + text = m; + } + } + if (text.length == 0) return; + if (self.onChunk) dispatch_async(dispatch_get_main_queue(), ^{ self.onChunk(text); }); + self.hasEmitted = YES; +} + +@end + diff --git a/CustomKeyboard/Network/NetworkStreamHandler.h b/CustomKeyboard/Network/NetworkStreamHandler.h new file mode 100644 index 0000000..4c1c74b --- /dev/null +++ b/CustomKeyboard/Network/NetworkStreamHandler.h @@ -0,0 +1,71 @@ +// +// NetworkStreamHandler.h +// CustomKeyboard +// +// Created by Mac on 2025/11/12. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSUInteger, NetworkStreamState) { + NetworkStreamStateIdle, + NetworkStreamStateConnecting, + NetworkStreamStateReceiving, + NetworkStreamStateCompleted, + NetworkStreamStateError +}; + +@class NetworkStreamHandler; + +@protocol NetworkStreamDelegate + +@optional +// 接收到数据块 +- (void)networkStream:(NetworkStreamHandler *)stream didReceiveData:(NSData *)data; +// 接收到文本数据(如果是文本内容) +- (void)networkStream:(NetworkStreamHandler *)stream didReceiveText:(NSString *)text; +// 进度更新 +- (void)networkStream:(NetworkStreamHandler *)stream downloadProgress:(float)progress; +// 状态改变 +- (void)networkStream:(NetworkStreamHandler *)stream stateChanged:(NetworkStreamState)state; +// 请求完成 +- (void)networkStream:(NetworkStreamHandler *)stream didCompleteWithError:(NSError * _Nullable)error; + +@end + +typedef void (^NetworkStreamProgressBlock)(float progress); +typedef void (^NetworkStreamDataBlock)(NSData *data); +typedef void (^NetworkStreamTextBlock)(NSString *text); +typedef void (^NetworkStreamCompletionBlock)(NSError * _Nullable error); + +@interface NetworkStreamHandler : NSObject + +@property (nonatomic, weak) id delegate; +@property (nonatomic, assign, readonly) NetworkStreamState state; +@property (nonatomic, strong, readonly) NSURLResponse *response; +@property (nonatomic, assign, readonly) long long totalBytesReceived; + +// 初始化方法 +- (instancetype)initWithURL:(NSURL *)url; +- (instancetype)initWithRequest:(NSURLRequest *)request; + +// 开始请求(使用代理回调) +- (void)startRequest; + +// 开始请求(使用 Block 回调) +- (void)startRequestWithProgress:(NetworkStreamProgressBlock _Nullable)progress + onData:(NetworkStreamDataBlock _Nullable)dataBlock + onText:(NetworkStreamTextBlock _Nullable)textBlock + completion:(NetworkStreamCompletionBlock _Nullable)completion; + +// 取消请求 +- (void)cancelRequest; + +// 构建默认请求(包含常见的请求头) ++ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/Network/NetworkStreamHandler.m b/CustomKeyboard/Network/NetworkStreamHandler.m new file mode 100644 index 0000000..f225470 --- /dev/null +++ b/CustomKeyboard/Network/NetworkStreamHandler.m @@ -0,0 +1,253 @@ +// +// NetworkStreamHandler.m +// CustomKeyboard +// +// Created by Mac on 2025/11/12. +// + +#import "NetworkStreamHandler.h" + +@interface NetworkStreamHandler () + +@property (nonatomic, strong) NSURLSession *session; +@property (nonatomic, strong) NSURLSessionDataTask *dataTask; +@property (nonatomic, strong) NSURLRequest *request; +@property (nonatomic, strong) NSMutableData *receivedData; +@property (nonatomic, assign) long long expectedContentLength; +@property (nonatomic, assign) NetworkStreamState state; +@property (nonatomic, strong) NSURLResponse *response; + +// Block 回调 +@property (nonatomic, copy) NetworkStreamProgressBlock progressBlock; +@property (nonatomic, copy) NetworkStreamDataBlock dataBlock; +@property (nonatomic, copy) NetworkStreamTextBlock textBlock; +@property (nonatomic, copy) NetworkStreamCompletionBlock completionBlock; + +@end + +@implementation NetworkStreamHandler + +- (instancetype)initWithURL:(NSURL *)url { + NSURLRequest *request = [NetworkStreamHandler createDefaultRequestWithURL:url method:@"GET"]; + return [self initWithRequest:request]; +} + +- (instancetype)initWithRequest:(NSURLRequest *)request { + self = [super init]; + if (self) { + _request = request; + _receivedData = [NSMutableData data]; + _state = NetworkStreamStateIdle; + _totalBytesReceived = 0; + + // 创建 URLSession 配置 + NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; + config.timeoutIntervalForRequest = 30.0; + config.timeoutIntervalForResource = 300.0; + config.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + + // 创建 URLSession + _session = [NSURLSession sessionWithConfiguration:config + delegate:self + delegateQueue:[NSOperationQueue mainQueue]]; + } + return self; +} + +- (void)dealloc { + [self cancelRequest]; +} + +#pragma mark - Public Methods + +- (void)startRequest { + if (self.state != NetworkStreamStateIdle) { + NSLog(@"Request already in progress"); + return; + } + + [self updateState:NetworkStreamStateConnecting]; + self.dataTask = [self.session dataTaskWithRequest:self.request]; + [self.dataTask resume]; +} + +- (void)startRequestWithProgress:(NetworkStreamProgressBlock)progress + onData:(NetworkStreamDataBlock)dataBlock + onText:(NetworkStreamTextBlock)textBlock + completion:(NetworkStreamCompletionBlock)completion { + + self.progressBlock = progress; + self.dataBlock = dataBlock; + self.textBlock = textBlock; + self.completionBlock = completion; + + [self startRequest]; +} + +- (void)cancelRequest { + if (self.dataTask) { + [self.dataTask cancel]; + self.dataTask = nil; + } + [self updateState:NetworkStreamStateIdle]; +} + ++ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method { + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = method; + request.timeoutInterval = 30.0; + + // 设置常见的请求头(根据您的截图) + [request setValue:@"text/html, application/xhtml+xml, application/xml; q=0.9, image/avif, image/webp, image/apng, */*; q=0.8, application/signed-exchange; v=b3; q=0.7" forHTTPHeaderField:@"Accept"]; + [request setValue:@"gzip, deflate" forHTTPHeaderField:@"Accept-Encoding"]; + [request setValue:@"zh-CN, zh; q=0.9, ko; q=0.8, ja; q=0.7" forHTTPHeaderField:@"Accept-Language"]; + [request setValue:@"keep-alive" forHTTPHeaderField:@"Connection"]; + [request setValue:@"1" forHTTPHeaderField:@"Upgrade-Insecure-Requests"]; + + // 用户代理(可选) + NSString *userAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1"; + [request setValue:userAgent forHTTPHeaderField:@"User-Agent"]; + + return [request copy]; +} + +#pragma mark - Private Methods + +- (void)updateState:(NetworkStreamState)newState { + if (_state != newState) { + _state = newState; + + // 通知代理状态改变 + if ([self.delegate respondsToSelector:@selector(networkStream:stateChanged:)]) { + [self.delegate networkStream:self stateChanged:newState]; + } + } +} + +- (void)notifyProgress:(float)progress { + if (self.progressBlock) { + self.progressBlock(progress); + } + + if ([self.delegate respondsToSelector:@selector(networkStream:downloadProgress:)]) { + [self.delegate networkStream:self downloadProgress:progress]; + } +} + +- (void)notifyReceivedData:(NSData *)data { + if (self.dataBlock) { + self.dataBlock(data); + } + + if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveData:)]) { + [self.delegate networkStream:self didReceiveData:data]; + } + + // 如果是文本数据,尝试转换为字符串 + if (self.textBlock || [self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) { + NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (text) { + if (self.textBlock) { + self.textBlock(text); + } + if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) { + [self.delegate networkStream:self didReceiveText:text]; + } + } + } +} + +- (void)notifyCompletionWithError:(NSError * _Nullable)error { + if (self.completionBlock) { + self.completionBlock(error); + } + + if ([self.delegate respondsToSelector:@selector(networkStream:didCompleteWithError:)]) { + [self.delegate networkStream:self didCompleteWithError:error]; + } +} + +#pragma mark - NSURLSessionDataDelegate + +- (void)URLSession:(NSURLSession *)session + dataTask:(NSURLSessionDataTask *)dataTask +didReceiveResponse:(NSURLResponse *)response + completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler { + + self.response = response; + self.expectedContentLength = response.expectedContentLength; + _totalBytesReceived = 0; + [self.receivedData setLength:0]; + + [self updateState:NetworkStreamStateReceiving]; + + // 检查响应头,处理 CORS 等 + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + NSLog(@"Response headers: %@", httpResponse.allHeaderFields); + + // 可以在这里检查 CORS 头等信息 + NSString *allowOrigin = httpResponse.allHeaderFields[@"Access-Control-Allow-Origin"]; + if (allowOrigin) { + NSLog(@"CORS Allow Origin: %@", allowOrigin); + } + } + + completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession *)session + dataTask:(NSURLSessionDataTask *)dataTask + didReceiveData:(NSData *)data { + + _totalBytesReceived += data.length; + [self.receivedData appendData:data]; + + // 通知接收到数据块 + [self notifyReceivedData:data]; + + // 计算并通知进度 + if (self.expectedContentLength != NSURLResponseUnknownLength) { + float progress = (float)self.totalBytesReceived / (float)self.expectedContentLength; + [self notifyProgress:progress]; + } else { + // 对于 chunked 传输,可能没有确切的内容长度 + [self notifyProgress:-1]; // 使用 -1 表示未知进度 + } +} + +- (void)URLSession:(NSURLSession *)session + task:(NSURLSessionTask *)task +didCompleteWithError:(NSError *)error { + + if (error) { + [self updateState:NetworkStreamStateError]; + NSLog(@"Request failed with error: %@", error); + } else { + [self updateState:NetworkStreamStateCompleted]; + NSLog(@"Request completed successfully. Total bytes: %lld", self.totalBytesReceived); + } + + [self notifyCompletionWithError:error]; + + // 清理 + [self.session finishTasksAndInvalidate]; + self.dataTask = nil; +} + +#pragma mark - URL Session Delegate (处理 SSL/认证) + +- (void)URLSession:(NSURLSession *)session +didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge + completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler { + + // 处理 SSL 认证挑战 + if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) { + NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust]; + completionHandler(NSURLSessionAuthChallengeUseCredential, credential); + } else { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); + } +} + +@end diff --git a/CustomKeyboard/View/KBFunctionView.m b/CustomKeyboard/View/KBFunctionView.m index 2bb0cb9..43dc252 100644 --- a/CustomKeyboard/View/KBFunctionView.m +++ b/CustomKeyboard/View/KBFunctionView.m @@ -17,10 +17,11 @@ #import "KBSkinManager.h" #import "KBURLOpenBridge.h" // 兜底从扩展侧直接尝试 openURL: #import "KBStreamTextView.h" // 流式文本视图 +#import "KBStreamFetcher.h" // 网络流封装 static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; -@interface KBFunctionView () +@interface KBFunctionView () // UI @property (nonatomic, strong) KBFunctionBarView *barViewInternal; @property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal; @@ -41,15 +42,9 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; @property (nonatomic, copy, nullable) NSString *streamMockSource; // 连续文本源(包含 \t 作为分段) @property (nonatomic, assign) NSInteger streamMockCursor; // 当前光标位置 -// 网络流式:使用 NSURLSessionDataDelegate 逐块接收 -@property (nonatomic, strong, nullable) NSURLSession *streamSession; -@property (nonatomic, strong, nullable) NSURLSessionDataTask *streamTask; -@property (nonatomic, strong, nullable) NSMutableData *streamDataBuffer; -@property (nonatomic, assign) NSInteger streamDeliveredCharCount; // 已成功解码并输出的字符数 -@property (nonatomic, assign) NSStringEncoding streamTextEncoding; // 从响应头推断;默认 UTF-8 -@property (nonatomic, assign) BOOL streamIsSSE; // 是否为 SSE -@property (nonatomic, strong, nullable) NSMutableString *sseTextBuffer; // SSE 文本缓冲 -@property (nonatomic, assign) NSInteger streamDecodedByteCount; // 已从 buffer 解码到 sseTextBuffer 的字节数 +// 网络流式(封装) +@property (nonatomic, strong, nullable) KBStreamFetcher *streamFetcher; +@property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用) // Data @property (nonatomic, strong) NSArray *itemsInternal; @@ -322,190 +317,62 @@ static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/ NSURL *url = [NSURL URLWithString:kKBStreamDemoURL]; if (!url) { if (fallback) [self kb_startMockStreamingWithSeed:seedTitle]; return; } - NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration]; - cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData; - cfg.timeoutIntervalForRequest = 30; - cfg.timeoutIntervalForResource = 60; - self.streamDataBuffer = [NSMutableData data]; - self.streamDeliveredCharCount = 0; - self.streamTextEncoding = NSUTF8StringEncoding; // 默认 UTF-8 - - // 主队列回调,便于直接驱动 UI - self.streamSession = [NSURLSession sessionWithConfiguration:cfg delegate:self delegateQueue:[NSOperationQueue mainQueue]]; - NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url]; - req.HTTPMethod = @"GET"; - // 重要:禁用压缩,声明 SSE/流偏好,避免中间层缓存/缓冲 - [req setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; // 禁用 gzip,利于分块直达 - [req setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"]; // 若服务端支持 SSE,可避免代理缓冲 - [req setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"]; - [req setValue:@"keep-alive" forHTTPHeaderField:@"Connection"]; - - [KBHUD showInfo:@"拉取中…"]; - self.streamTask = [self.streamSession dataTaskWithRequest:req]; - [self.streamTask resume]; + self.streamHasOutput = NO; // 重置首段处理标记 + __weak typeof(self) weakSelf = self; + KBStreamFetcher *fetcher = [KBStreamFetcher fetcherWithURL:url]; + // 由本类统一做 /t->\t 与首段去 \t,fetcher 只负责增量与协议解析 + fetcher.disableCompression = YES; + fetcher.acceptEventStream = NO; // 响应头若是 SSE 仍会自动解析 + fetcher.treatSlashTAsTab = NO; + fetcher.trimLeadingTabOnce = NO; + fetcher.onChunk = ^(NSString *chunk) { + __strong typeof(weakSelf) self = weakSelf; if (!self) return; + [self kb_appendChunkToStreamView:chunk]; + }; + fetcher.onFinish = ^(NSError * _Nullable error) { + __strong typeof(weakSelf) self = weakSelf; if (!self) return; + if (error && fallback && !self.streamHasOutput) { + [KBHUD showInfo:@"拉取失败,使用本地演示"]; // 降级 + [self kb_startMockStreamingWithSeed:nil]; + } else { + [self.streamTextView finishStreaming]; + } + }; + self.streamFetcher = fetcher; + [self.streamFetcher start]; } - (void)kb_stopNetworkStreaming { - [self.streamTask cancel]; - self.streamTask = nil; - [self.streamSession invalidateAndCancel]; - self.streamSession = nil; - self.streamDataBuffer = nil; - self.streamDeliveredCharCount = 0; + [self.streamFetcher cancel]; + self.streamFetcher = nil; + self.streamHasOutput = NO; } -// 计算数据中以 UTF-8 编码可完整解码的前缀字节长度,避免切断多字节字符 -static NSUInteger kb_validUTF8PrefixLen(NSData *data) { - const unsigned char *bytes = (const unsigned char *)data.bytes; - NSUInteger n = data.length; - if (n == 0) return 0; - // 从末尾回溯,找到最后一个潜在的字符起始位置 - NSInteger i = (NSInteger)n - 1; - while (i >= 0 && (bytes[i] & 0xC0) == 0x80) { // 10xxxxxx 续字节 - i--; - } - if (i < 0) return 0; // 全是续字节,等下次 - // 根据起始字节计算应有的长度 - unsigned char b = bytes[i]; - NSUInteger expected = 1; - if ((b & 0x80) == 0x00) expected = 1; // 0xxxxxxx - else if ((b & 0xE0) == 0xC0) expected = 2; // 110xxxxx - else if ((b & 0xF0) == 0xE0) expected = 3; // 1110xxxx - else if ((b & 0xF8) == 0xF0) expected = 4; // 11110xxx - else return (NSUInteger)i; // 非法起始,截到 i 之前 - NSUInteger remain = n - (NSUInteger)i; - if (remain >= expected) return n; // 末尾完整 - return (NSUInteger)i; // 末尾不完整,截到起始位之前 -} +#pragma mark - Helpers -#pragma mark - NSURLSessionDataDelegate - -- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler { - // 初始化解析状态:识别 SSE 与编码 - self.streamIsSSE = NO; - self.streamTextEncoding = NSUTF8StringEncoding; - if ([response isKindOfClass:[NSHTTPURLResponse class]]) { - NSHTTPURLResponse *r = (NSHTTPURLResponse *)response; - NSString *ct = r.allHeaderFields[@"Content-Type"] ?: r.allHeaderFields[@"content-type"]; - if ([ct isKindOfClass:[NSString class]]) { - NSString *lower = [ct lowercaseString]; - if ([lower containsString:@"text/event-stream"]) self.streamIsSSE = YES; - NSRange pos = [lower rangeOfString:@"charset="]; - if (pos.location != NSNotFound) { - NSString *charset = [[lower substringFromIndex:pos.location + pos.length] componentsSeparatedByString:@";"][0]; - if ([charset containsString:@"utf-8"] || [charset containsString:@"utf8"]) { - self.streamTextEncoding = NSUTF8StringEncoding; - } else if ([charset containsString:@"iso-8859-1"] || [charset containsString:@"latin1"]) { - self.streamTextEncoding = NSISOLatin1StringEncoding; - } - } +/// 统一处理需要输出到 KBStreamTextView 的分片: +/// - 将 "/t" 转为真正的制表符 "\t"; +/// - 若这是首段输出且文本起始(允许有前导空白)紧跟一个 "\t",只删除这一个; +/// - 非空才追加到视图,并标记已输出。 +- (void)kb_appendChunkToStreamView:(NSString *)chunk { + if (chunk.length == 0 || !self.streamTextView) return; + NSString *text = [chunk stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; + if (!self.streamHasOutput) { + NSUInteger i = 0; // 跳过前导空白 + while (i < text.length) { + unichar c = [text characterAtIndex:i]; + if (c == ' ' || c == '\r' || c == '\n') { i++; continue; } + break; + } + if (i < text.length && [text characterAtIndex:i] == '\t') { + NSMutableString *m = [text mutableCopy]; + [m deleteCharactersInRange:NSMakeRange(i, 1)]; + text = m; } } - if (self.streamIsSSE) { - self.sseTextBuffer = [NSMutableString string]; - self.streamDecodedByteCount = 0; - } - if (completionHandler) completionHandler(NSURLSessionResponseAllow); -} - -- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data { - if (!self.streamTextView) return; - if (data.length == 0) return; - - if (!self.streamDataBuffer) { self.streamDataBuffer = [NSMutableData data]; } - [self.streamDataBuffer appendData:data]; - - // 可解码前缀长度:UTF-8 需处理半字符;其它编码直接尝试全部 - NSUInteger validLen = (self.streamTextEncoding == NSUTF8StringEncoding) - ? kb_validUTF8PrefixLen(self.streamDataBuffer) - : self.streamDataBuffer.length; - if (validLen == 0) return; - - if (self.streamIsSSE) { - // 仅解码新增字节到文本缓冲 - if (validLen > (NSUInteger)self.streamDecodedByteCount) { - NSRange rng = NSMakeRange((NSUInteger)self.streamDecodedByteCount, validLen - (NSUInteger)self.streamDecodedByteCount); - NSString *piece = [[NSString alloc] initWithBytes:(const char *)self.streamDataBuffer.bytes + rng.location length:rng.length encoding:self.streamTextEncoding]; - if (piece.length > 0) { - [self.sseTextBuffer appendString:piece]; - self.streamDecodedByteCount = (NSInteger)validLen; - } - } - // 规范换行,按 SSE 事件分隔符 \n\n 切分 - if (self.sseTextBuffer.length > 0) { - NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]; - [self.sseTextBuffer setString:normalized]; - while (1) { - NSRange sep = [self.sseTextBuffer rangeOfString:@"\n\n"]; // 完整事件 - if (sep.location == NSNotFound) break; - NSString *event = [self.sseTextBuffer substringToIndex:sep.location]; - [self.sseTextBuffer deleteCharactersInRange:NSMakeRange(0, sep.location + sep.length)]; - // 提取 data: 行组成 payload - NSArray *lines = [event componentsSeparatedByString:@"\n"]; - NSMutableString *payload = [NSMutableString string]; - for (NSString *ln in lines) { - if ([ln hasPrefix:@"data:"]) { - NSString *v = [ln substringFromIndex:5]; - if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; - [payload appendString:v ?: @""]; - [payload appendString:@"\n"]; // 多 data 行合并 - } - } - if (payload.length > 0 && [payload hasSuffix:@"\n"]) { - [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; - } - if (payload.length > 0) { - NSString *clean = [[payload copy] stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; - [self.streamTextView appendStreamText:clean]; - } - } - } - return; - } - - // 非 SSE:直接以字符串增量输出 - NSString *prefix = [[NSString alloc] initWithBytes:self.streamDataBuffer.bytes length:validLen encoding:self.streamTextEncoding]; - if (!prefix) return; - if (self.streamDeliveredCharCount < (NSInteger)prefix.length) { - NSString *delta = [prefix substringFromIndex:self.streamDeliveredCharCount]; - self.streamDeliveredCharCount = prefix.length; - delta = [delta stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; // 支持后端 /t 作为分段 - [self.streamTextView appendStreamText:delta]; - } -} - -- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { - if (error) { - NSLog(@"[Stream] 网络失败: %@", error); - if (self.streamTextView) { - [KBHUD showInfo:@"拉取失败,使用本地演示"]; // 降级提示 - [self kb_startMockStreamingWithSeed:nil]; - } - } else { - // 结束:若为 SSE,尝试处理最后一个不以 \n\n 结尾的事件 - if (self.streamIsSSE && self.sseTextBuffer.length > 0) { - NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"]; - NSArray *lines = [normalized componentsSeparatedByString:@"\n"]; - NSMutableString *payload = [NSMutableString string]; - for (NSString *ln in lines) { - if ([ln hasPrefix:@"data:"]) { - NSString *v = [ln substringFromIndex:5]; - if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1]; - [payload appendString:v ?: @""]; - [payload appendString:@"\n"]; - } - } - if (payload.length > 0 && [payload hasSuffix:@"\n"]) { - [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; - } - if (payload.length > 0) { - NSString *clean = [[payload copy] stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"]; - [self.streamTextView appendStreamText:clean]; - } - } - [self.streamTextView finishStreaming]; - } - [self kb_stopNetworkStreaming]; + if (text.length == 0) return; + [self.streamTextView appendStreamText:text]; + self.streamHasOutput = YES; } // 用户点击功能标签:优先 UL 拉起主App,失败再 Scheme;两次都失败则提示开启完全访问。 diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index c7779fb..42ec51d 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -87,6 +87,8 @@ 049FB2292EC31BB000FAB05D /* KBChangeNicknamePopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB2282EC31BB000FAB05D /* KBChangeNicknamePopView.m */; }; 049FB22C2EC31F8800FAB05D /* KBGenderPickerPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB22B2EC31F8800FAB05D /* KBGenderPickerPopView.m */; }; 049FB22F2EC34EB900FAB05D /* KBStreamTextView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */; }; + 049FB2322EC45A0000FAB05D /* KBStreamFetcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB2312EC45A0000FAB05D /* KBStreamFetcher.m */; }; + 049FB2352EC45C6A00FAB05D /* NetworkStreamHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB2342EC45C6A00FAB05D /* NetworkStreamHandler.m */; }; 049FB31D2EC21BCD00FAB05D /* KBMyKeyboardCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB31C2EC21BCD00FAB05D /* KBMyKeyboardCell.m */; }; 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC97082EB31B14007BD342 /* KBHUD.m */; }; 04A9FE132EB4D0D20020DB6D /* KBFullAccessManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04A9FE112EB4D0D20020DB6D /* KBFullAccessManager.m */; }; @@ -302,6 +304,10 @@ 049FB22B2EC31F8800FAB05D /* KBGenderPickerPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBGenderPickerPopView.m; sourceTree = ""; }; 049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBStreamTextView.h; sourceTree = ""; }; 049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBStreamTextView.m; sourceTree = ""; }; + 049FB2302EC45A0000FAB05D /* KBStreamFetcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBStreamFetcher.h; sourceTree = ""; }; + 049FB2312EC45A0000FAB05D /* KBStreamFetcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBStreamFetcher.m; sourceTree = ""; }; + 049FB2332EC45C6A00FAB05D /* NetworkStreamHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NetworkStreamHandler.h; sourceTree = ""; }; + 049FB2342EC45C6A00FAB05D /* NetworkStreamHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NetworkStreamHandler.m; sourceTree = ""; }; 049FB31B2EC21BCD00FAB05D /* KBMyKeyboardCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyKeyboardCell.h; sourceTree = ""; }; 049FB31C2EC21BCD00FAB05D /* KBMyKeyboardCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyKeyboardCell.m; sourceTree = ""; }; 04A9A67D2EB9E1690023B8F4 /* KBResponderUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBResponderUtils.h; sourceTree = ""; }; @@ -1180,6 +1186,10 @@ children = ( A1B2C3E02EB0C0A100000001 /* KBNetworkManager.h */, A1B2C3E12EB0C0A100000001 /* KBNetworkManager.m */, + 049FB2302EC45A0000FAB05D /* KBStreamFetcher.h */, + 049FB2312EC45A0000FAB05D /* KBStreamFetcher.m */, + 049FB2332EC45C6A00FAB05D /* NetworkStreamHandler.h */, + 049FB2342EC45C6A00FAB05D /* NetworkStreamHandler.m */, ); path = Network; sourceTree = ""; @@ -1385,6 +1395,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 049FB2322EC45A0000FAB05D /* KBStreamFetcher.m in Sources */, 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */, 04C6EADD2EAF8CEB0089C901 /* KBToolBar.m in Sources */, 04FC95792EB09BC8007BD342 /* KBKeyBoardMainView.m in Sources */, @@ -1395,6 +1406,7 @@ A1B2C3D42EB0A0A100000001 /* KBFunctionTagCell.m in Sources */, 04A9FE1A2EB892460020DB6D /* KBLocalizationManager.m in Sources */, A1B2C3E22EB0C0A100000001 /* KBNetworkManager.m in Sources */, + 049FB2352EC45C6A00FAB05D /* NetworkStreamHandler.m in Sources */, 04FC956A2EB05497007BD342 /* KBKeyButton.m in Sources */, 04FC95B22EB0B2CC007BD342 /* KBSettingView.m in Sources */, 049FB22F2EC34EB900FAB05D /* KBStreamTextView.m in Sources */,