1
This commit is contained in:
48
CustomKeyboard/Network/KBStreamFetcher.h
Normal file
48
CustomKeyboard/Network/KBStreamFetcher.h
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// KBStreamFetcher.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 轻量网络流拉取器:支持纯文本分块与 SSE(text/event-stream) 两种形式的“边下边显”。
|
||||
// - 增量解码:按 UTF-8 安全前缀逐步转成字符串,避免半个多字节字符导致阻塞/乱码
|
||||
// - SSE 解析:按 \n\n 切事件,合并 data: 行,移除前缀,仅回传正文
|
||||
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
||||
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef void (^KBStreamFetcherChunkHandler)(NSString *chunk);
|
||||
typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
|
||||
|
||||
@interface KBStreamFetcher : NSObject <NSURLSessionDataDelegate>
|
||||
|
||||
// 便利构造
|
||||
+ (instancetype)fetcherWithURL:(NSURL *)url;
|
||||
|
||||
// 必填:请求地址
|
||||
@property (nonatomic, strong) NSURL *url;
|
||||
|
||||
// 可选 Header
|
||||
@property (nonatomic, copy, nullable) NSDictionary<NSString *, NSString *> *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
|
||||
|
||||
247
CustomKeyboard/Network/KBStreamFetcher.m
Normal file
247
CustomKeyboard/Network/KBStreamFetcher.m
Normal file
@@ -0,0 +1,247 @@
|
||||
//
|
||||
// KBStreamFetcher.m
|
||||
//
|
||||
|
||||
#import "KBStreamFetcher.h"
|
||||
|
||||
@interface KBStreamFetcher () <NSURLSessionDataDelegate>
|
||||
@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<NSString *> *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<NSString *> *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
|
||||
|
||||
71
CustomKeyboard/Network/NetworkStreamHandler.h
Normal file
71
CustomKeyboard/Network/NetworkStreamHandler.h
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// NetworkStreamHandler.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Mac on 2025/11/12.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSUInteger, NetworkStreamState) {
|
||||
NetworkStreamStateIdle,
|
||||
NetworkStreamStateConnecting,
|
||||
NetworkStreamStateReceiving,
|
||||
NetworkStreamStateCompleted,
|
||||
NetworkStreamStateError
|
||||
};
|
||||
|
||||
@class NetworkStreamHandler;
|
||||
|
||||
@protocol NetworkStreamDelegate <NSObject>
|
||||
|
||||
@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 <NSURLSessionDataDelegate>
|
||||
|
||||
@property (nonatomic, weak) id<NetworkStreamDelegate> 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
|
||||
253
CustomKeyboard/Network/NetworkStreamHandler.m
Normal file
253
CustomKeyboard/Network/NetworkStreamHandler.m
Normal file
@@ -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
|
||||
@@ -17,10 +17,11 @@
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBURLOpenBridge.h" // 兜底从扩展侧直接尝试 openURL:
|
||||
#import "KBStreamTextView.h" // 流式文本视图
|
||||
#import "KBStreamFetcher.h" // 网络流封装
|
||||
|
||||
static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
|
||||
|
||||
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate, NSURLSessionDataDelegate>
|
||||
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate>
|
||||
// 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<NSString *> *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<NSString *> *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<NSString *> *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;两次都失败则提示开启完全访问。
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBStreamTextView.h; sourceTree = "<group>"; };
|
||||
049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBStreamTextView.m; sourceTree = "<group>"; };
|
||||
049FB2302EC45A0000FAB05D /* KBStreamFetcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBStreamFetcher.h; sourceTree = "<group>"; };
|
||||
049FB2312EC45A0000FAB05D /* KBStreamFetcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBStreamFetcher.m; sourceTree = "<group>"; };
|
||||
049FB2332EC45C6A00FAB05D /* NetworkStreamHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NetworkStreamHandler.h; sourceTree = "<group>"; };
|
||||
049FB2342EC45C6A00FAB05D /* NetworkStreamHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NetworkStreamHandler.m; sourceTree = "<group>"; };
|
||||
049FB31B2EC21BCD00FAB05D /* KBMyKeyboardCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyKeyboardCell.h; sourceTree = "<group>"; };
|
||||
049FB31C2EC21BCD00FAB05D /* KBMyKeyboardCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyKeyboardCell.m; sourceTree = "<group>"; };
|
||||
04A9A67D2EB9E1690023B8F4 /* KBResponderUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBResponderUtils.h; sourceTree = "<group>"; };
|
||||
@@ -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 = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
Reference in New Issue
Block a user