This commit is contained in:
2026-03-08 21:29:10 +08:00
parent 9fb2e2e694
commit c1ace5f53e
34 changed files with 870 additions and 1930 deletions

View File

@@ -175,16 +175,18 @@ self.voiceInputBar.enabled = YES;
---
## 🎯 完整示例(集成 Deepgram
## 🎯 完整示例(桥接录音事件
```objc
#import "YourViewController.h"
#import "KBVoiceInputBar.h"
#import "DeepgramStreamingManager.h"
#import "KBVoiceToTextManager.h"
#import "KBVoiceRecordManager.h"
@interface YourViewController () <KBVoiceInputBarDelegate, DeepgramStreamingManagerDelegate>
@interface YourViewController () <KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate>
@property (nonatomic, strong) KBVoiceInputBar *voiceInputBar;
@property (nonatomic, strong) DeepgramStreamingManager *deepgramManager;
@property (nonatomic, strong) KBVoiceToTextManager *voiceToTextManager;
@property (nonatomic, strong) KBVoiceRecordManager *voiceRecordManager;
@end
@implementation YourViewController
@@ -192,7 +194,7 @@ self.voiceInputBar.enabled = YES;
- (void)viewDidLoad {
[super viewDidLoad];
[self setupUI];
[self setupDeepgram];
[self setupVoiceManagers];
}
- (void)setupUI {
@@ -205,50 +207,44 @@ self.voiceInputBar.enabled = YES;
}];
}
- (void)setupDeepgram {
self.deepgramManager = [[DeepgramStreamingManager alloc] init];
self.deepgramManager.delegate = self;
self.deepgramManager.serverURL = @"wss://api.deepgram.com/v1/listen";
self.deepgramManager.apiKey = @"your_api_key";
[self.deepgramManager prepareConnection];
- (void)setupVoiceManagers {
self.voiceToTextManager = [[KBVoiceToTextManager alloc] initWithInputBar:self.voiceInputBar];
self.voiceToTextManager.delegate = self;
self.voiceRecordManager = [[KBVoiceRecordManager alloc] init];
self.voiceRecordManager.delegate = self;
}
#pragma mark - KBVoiceInputBarDelegate
#pragma mark - KBVoiceToTextManagerDelegate
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar {
inputBar.statusText = @"正在连接...";
[self.deepgramManager start];
- (void)voiceToTextManagerDidBeginRecording:(KBVoiceToTextManager *)manager {
[self.voiceRecordManager startRecording];
}
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar {
inputBar.statusText = @"正在识别...";
[self.deepgramManager stopAndFinalize];
- (void)voiceToTextManagerDidEndRecording:(KBVoiceToTextManager *)manager {
[self.voiceRecordManager stopRecording];
}
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar {
inputBar.statusText = @"已取消";
[self.deepgramManager cancel];
- (void)voiceToTextManagerDidCancelRecording:(KBVoiceToTextManager *)manager {
[self.voiceRecordManager cancelRecording];
}
#pragma mark - DeepgramStreamingManagerDelegate
#pragma mark - KBVoiceRecordManagerDelegate
- (void)deepgramStreamingManagerDidConnect {
self.voiceInputBar.statusText = @"正在聆听...";
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
didFinishRecordingAtURL:(NSURL *)fileURL
duration:(NSTimeInterval)duration {
NSLog(@"录音完成:%@ %.2fs", fileURL, duration);
// TODO: 上传音频文件并处理转写结果
}
- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms {
[self.voiceInputBar updateVolumeRMS:rms];
- (void)voiceRecordManagerDidRecordTooShort:(KBVoiceRecordManager *)manager {
NSLog(@"录音过短");
}
- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text {
self.voiceInputBar.statusText = text.length > 0 ? text : @"正在识别...";
}
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
self.voiceInputBar.statusText = @"识别完成";
NSLog(@"最终识别结果:%@", text);
// TODO: 处理识别结果
- (void)voiceRecordManager:(KBVoiceRecordManager *)manager
didFailWithError:(NSError *)error {
NSLog(@"录音失败:%@", error.localizedDescription);
}
@end
@@ -316,5 +312,6 @@ _recordButton.tintColor = [UIColor systemBlueColor];
## 🔗 相关组件
- `KBAiRecordButton`:录音按钮(支持长按、波形动画)
- `DeepgramStreamingManager`:语音识别管理器
- `KBVoiceToTextManager`:语音输入事件桥接层
- `KBVoiceRecordManager`:录音文件管理器
- `VoiceChatStreamingManager`:语音聊天管理器

View File

@@ -600,8 +600,6 @@ static void KBChatUpdatedDarwinCallback(CFNotificationCenterRef center,
- (void)setupVoiceToTextManager {
self.voiceToTextManager = [[KBVoiceToTextManager alloc] initWithInputBar:self.voiceInputBar];
self.voiceToTextManager.delegate = self;
self.voiceToTextManager.deepgramEnabled = NO;
[self.voiceToTextManager prepareConnection];
}
/// 5
@@ -1199,16 +1197,6 @@ static void KBChatUpdatedDarwinCallback(CFNotificationCenterRef center,
#pragma mark - KBVoiceToTextManagerDelegate
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager
didReceiveFinalText:(NSString *)text {
[self handleTranscribedText:text];
}
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager
didFailWithError:(NSError *)error {
NSLog(@"[KBAIHomeVC] 语音识别失败:%@", error.localizedDescription);
}
- (void)voiceToTextManagerDidBeginRecording:(KBVoiceToTextManager *)manager {
self.isVoiceRecording = YES;
self.isVoiceProcessing = YES;

View File

@@ -1,50 +0,0 @@
//
// DeepgramStreamingManager.h
// keyBoard
//
// Created by Mac on 2026/1/21.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@protocol DeepgramStreamingManagerDelegate <NSObject>
@optional
- (void)deepgramStreamingManagerDidConnect;
- (void)deepgramStreamingManagerDidDisconnect:(NSError *_Nullable)error;
- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms;
- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text;
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text;
- (void)deepgramStreamingManagerDidFail:(NSError *)error;
@end
/// Manager for Deepgram live transcription.
@interface DeepgramStreamingManager : NSObject
@property(nonatomic, weak) id<DeepgramStreamingManagerDelegate> delegate;
@property(nonatomic, copy) NSString *serverURL; // wss://api.deepgram.com/v1/listen
@property(nonatomic, copy) NSString *apiKey;
@property(nonatomic, copy, nullable) NSString *language;
@property(nonatomic, copy, nullable) NSString *model;
@property(nonatomic, assign) BOOL punctuate;
@property(nonatomic, assign) BOOL smartFormat;
@property(nonatomic, assign) BOOL interimResults;
@property(nonatomic, copy) NSString *encoding; // linear16
@property(nonatomic, assign) double sampleRate;
@property(nonatomic, assign) int channels;
@property(nonatomic, assign, readonly, getter=isStreaming) BOOL streaming;
- (void)start;
- (void)prepareConnection;
- (void)stopAndFinalize;
- (void)cancel;
- (void)disconnect;
@end
NS_ASSUME_NONNULL_END

View File

@@ -1,516 +0,0 @@
//
// DeepgramStreamingManager.m
// keyBoard
//
// Created by Mac on 2026/1/21.
//
#import "DeepgramStreamingManager.h"
#import "AudioCaptureManager.h"
#import "AudioSessionManager.h"
#import "DeepgramWebSocketClient.h"
#import <UIKit/UIKit.h>
static NSString *const kDeepgramStreamingManagerErrorDomain =
@"DeepgramStreamingManager";
@interface DeepgramStreamingManager () <AudioSessionManagerDelegate,
AudioCaptureManagerDelegate,
DeepgramWebSocketClientDelegate>
@property(nonatomic, strong) AudioSessionManager *audioSession;
@property(nonatomic, strong) AudioCaptureManager *audioCapture;
@property(nonatomic, strong) DeepgramWebSocketClient *client;
@property(nonatomic, strong) dispatch_queue_t stateQueue;
@property(nonatomic, assign) BOOL streaming;
@property(nonatomic, strong) NSMutableArray<NSData *> *pendingFrames;
@property(nonatomic, assign) NSUInteger pendingFrameLimit;
@property(nonatomic, assign) BOOL connecting;
@property(nonatomic, assign) BOOL pendingStart;
@property(nonatomic, assign) BOOL keepConnection;
@property(nonatomic, strong) dispatch_source_t keepAliveTimer;
@property(nonatomic, assign) NSInteger reconnectAttempts;
@property(nonatomic, assign) NSInteger maxReconnectAttempts;
@property(nonatomic, assign) BOOL reconnectScheduled;
@property(nonatomic, assign) BOOL appInBackground;
@property(nonatomic, assign) BOOL shouldReconnectOnForeground;
@end
@implementation DeepgramStreamingManager
- (instancetype)init {
self = [super init];
if (self) {
_stateQueue = dispatch_queue_create("com.keyboard.aitalk.deepgram.manager",
DISPATCH_QUEUE_SERIAL);
_audioSession = [AudioSessionManager sharedManager];
_audioSession.delegate = self;
_audioCapture = [[AudioCaptureManager alloc] init];
_audioCapture.delegate = self;
///
// _client = [[DeepgramWebSocketClient alloc] init];
// _client.delegate = self;
_serverURL = @"wss://api.deepgram.com/v1/listen";
_encoding = @"linear16";
_sampleRate = 16000.0;
_channels = 1;
_punctuate = YES;
_smartFormat = YES;
_interimResults = YES;
_pendingFrames = [[NSMutableArray alloc] init];
_pendingFrameLimit = 25;
_connecting = NO;
_pendingStart = NO;
_keepConnection = NO;
_reconnectAttempts = 0;
_maxReconnectAttempts = 5;
_reconnectScheduled = NO;
_appInBackground = NO;
_shouldReconnectOnForeground = NO;
[self setupNotifications];
}
return self;
}
- (void)dealloc {
[self removeNotifications];
[self disconnectInternal];
}
- (void)start {
dispatch_async(self.stateQueue, ^{
if (self.appInBackground) {
self.shouldReconnectOnForeground = YES;
return;
}
self.keepConnection = YES;
self.pendingStart = YES;
self.reconnectAttempts = 0;
if (self.apiKey.length == 0) {
[self reportErrorWithMessage:@"Deepgram API key is required"];
return;
}
if (![self.audioSession hasMicrophonePermission]) {
__weak typeof(self) weakSelf = self;
[self.audioSession requestMicrophonePermission:^(BOOL granted) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
if (!granted) {
[strongSelf reportErrorWithMessage:@"Microphone permission denied"];
return;
}
dispatch_async(strongSelf.stateQueue, ^{
[strongSelf start];
});
}];
return;
}
NSError *error = nil;
if (![self.audioSession configureForConversation:&error]) {
[self reportError:error];
return;
}
if (![self.audioSession activateSession:&error]) {
[self reportError:error];
return;
}
if (![self.audioCapture isCapturing]) {
NSError *captureError = nil;
if (![self.audioCapture startCapture:&captureError]) {
[self reportError:captureError];
return;
}
}
NSLog(@"[DeepgramStreamingManager] Start streaming, server: %@",
self.serverURL);
if (self.client.isConnected) {
[self beginStreamingIfReady];
return;
}
[self connectIfNeeded];
});
}
- (void)prepareConnection {
dispatch_async(self.stateQueue, ^{
if (self.appInBackground) {
self.shouldReconnectOnForeground = YES;
return;
}
self.keepConnection = YES;
self.pendingStart = NO;
self.reconnectAttempts = 0;
if (self.apiKey.length == 0) {
NSLog(@"[DeepgramStreamingManager] Prepare skipped: API key missing");
return;
}
if (self.client.isConnected) {
return;
}
[self connectIfNeeded];
});
}
- (void)stopAndFinalize {
dispatch_async(self.stateQueue, ^{
if (self.streaming) {
[self.audioCapture stopCapture];
self.streaming = NO;
}
[self.pendingFrames removeAllObjects];
self.pendingStart = NO;
if (self.client.isConnected) {
[self.client finish];
}
[self.client disableAudioSending];
[self startKeepAliveIfNeeded];
});
}
- (void)cancel {
dispatch_async(self.stateQueue, ^{
if (self.streaming) {
[self.audioCapture stopCapture];
self.streaming = NO;
}
[self.pendingFrames removeAllObjects];
self.pendingStart = NO;
self.keepConnection = NO;
[self.client disableAudioSending];
[self stopKeepAlive];
[self.client disconnect];
});
}
- (void)disconnect {
dispatch_async(self.stateQueue, ^{
[self disconnectInternal];
});
}
- (void)disconnectInternal {
if (self.streaming) {
[self.audioCapture stopCapture];
self.streaming = NO;
}
[self.pendingFrames removeAllObjects];
self.pendingStart = NO;
self.keepConnection = NO;
self.shouldReconnectOnForeground = NO;
[self.client disableAudioSending];
[self stopKeepAlive];
[self.client disconnect];
[self.audioSession deactivateSession];
}
#pragma mark - AudioCaptureManagerDelegate
- (void)audioCaptureManagerDidOutputPCMFrame:(NSData *)pcmFrame {
if (pcmFrame.length == 0) {
return;
}
dispatch_async(self.stateQueue, ^{
if (!self.streaming || !self.client.isConnected) {
[self.pendingFrames addObject:pcmFrame];
if (self.pendingFrames.count > self.pendingFrameLimit) {
[self.pendingFrames removeObjectAtIndex:0];
}
return;
}
[self.client sendAudioPCMFrame:pcmFrame];
});
}
- (void)audioCaptureManagerDidUpdateRMS:(float)rms {
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector
(deepgramStreamingManagerDidUpdateRMS:)]) {
[self.delegate deepgramStreamingManagerDidUpdateRMS:rms];
}
});
}
#pragma mark - AudioSessionManagerDelegate
- (void)audioSessionManagerDidInterrupt:(KBAudioSessionInterruptionType)type {
if (type == KBAudioSessionInterruptionTypeBegan) {
[self cancel];
}
}
- (void)audioSessionManagerMicrophonePermissionDenied {
[self reportErrorWithMessage:@"Microphone permission denied"];
}
#pragma mark - DeepgramWebSocketClientDelegate
- (void)deepgramClientDidConnect {
dispatch_async(self.stateQueue, ^{
self.connecting = NO;
self.reconnectAttempts = 0;
self.reconnectScheduled = NO;
[self beginStreamingIfReady];
[self startKeepAliveIfNeeded];
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector
(deepgramStreamingManagerDidConnect)]) {
[self.delegate deepgramStreamingManagerDidConnect];
}
});
});
}
- (void)deepgramClientDidDisconnect:(NSError *_Nullable)error {
dispatch_async(self.stateQueue, ^{
if (self.streaming) {
[self.audioCapture stopCapture];
self.streaming = NO;
}
self.connecting = NO;
[self.audioSession deactivateSession];
[self stopKeepAlive];
if (self.pendingStart || self.keepConnection) {
[self scheduleReconnectWithError:error];
}
});
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector
(deepgramStreamingManagerDidDisconnect:)]) {
[self.delegate deepgramStreamingManagerDidDisconnect:error];
}
});
}
- (void)deepgramClientDidReceiveInterimTranscript:(NSString *)text {
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector
(deepgramStreamingManagerDidReceiveInterimTranscript:)]) {
[self.delegate deepgramStreamingManagerDidReceiveInterimTranscript:text];
}
});
}
- (void)deepgramClientDidReceiveFinalTranscript:(NSString *)text {
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector
(deepgramStreamingManagerDidReceiveFinalTranscript:)]) {
[self.delegate deepgramStreamingManagerDidReceiveFinalTranscript:text];
}
});
}
- (void)deepgramClientDidFail:(NSError *)error {
[self reportError:error];
}
#pragma mark - Error Reporting
- (void)reportError:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector
(deepgramStreamingManagerDidFail:)]) {
[self.delegate deepgramStreamingManagerDidFail:error];
}
});
}
- (void)reportErrorWithMessage:(NSString *)message {
NSError *error = [NSError errorWithDomain:kDeepgramStreamingManagerErrorDomain
code:-1
userInfo:@{
NSLocalizedDescriptionKey : message ?: @""
}];
[self reportError:error];
}
- (void)connectIfNeeded {
if (self.connecting || self.client.isConnected) {
return;
}
if (self.serverURL.length == 0) {
[self reportErrorWithMessage:@"Deepgram server URL is required"];
return;
}
self.client.serverURL = self.serverURL;
self.client.apiKey = self.apiKey;
self.client.language = self.language;
self.client.model = self.model;
self.client.punctuate = self.punctuate;
self.client.smartFormat = self.smartFormat;
self.client.interimResults = self.interimResults;
self.client.encoding = self.encoding;
self.client.sampleRate = self.sampleRate;
self.client.channels = self.channels;
[self.client disableAudioSending];
self.connecting = YES;
[self.client connect];
}
- (void)beginStreamingIfReady {
if (!self.pendingStart) {
return;
}
self.streaming = YES;
[self.client enableAudioSending];
[self stopKeepAlive];
if (self.pendingFrames.count > 0) {
NSArray<NSData *> *frames = [self.pendingFrames copy];
[self.pendingFrames removeAllObjects];
for (NSData *frame in frames) {
[self.client sendAudioPCMFrame:frame];
}
NSLog(@"[DeepgramStreamingManager] Flushed %lu pending frames",
(unsigned long)frames.count);
}
}
- (void)scheduleReconnectWithError:(NSError *_Nullable)error {
if (self.reconnectScheduled || self.connecting || self.client.isConnected) {
return;
}
if (self.appInBackground) {
self.shouldReconnectOnForeground = YES;
return;
}
if (self.reconnectAttempts >= self.maxReconnectAttempts) {
NSLog(@"[DeepgramStreamingManager] Reconnect failed %ld times, stop retry. %@",
(long)self.maxReconnectAttempts,
error.localizedDescription ?: @"");
self.pendingStart = NO;
self.keepConnection = NO;
return;
}
self.reconnectAttempts += 1;
self.reconnectScheduled = YES;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)),
self.stateQueue, ^{
self.reconnectScheduled = NO;
if (self.appInBackground) {
self.shouldReconnectOnForeground = YES;
return;
}
if (!self.pendingStart && !self.keepConnection) {
return;
}
[self connectIfNeeded];
});
}
- (void)setupNotifications {
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
selector:@selector(handleAppDidEnterBackground)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
[center addObserver:self
selector:@selector(handleAppWillEnterForeground)
name:UIApplicationWillEnterForegroundNotification
object:nil];
}
- (void)removeNotifications {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)handleAppDidEnterBackground {
dispatch_async(self.stateQueue, ^{
self.appInBackground = YES;
self.shouldReconnectOnForeground =
self.keepConnection || self.pendingStart;
self.pendingStart = NO;
self.keepConnection = NO;
if (self.streaming) {
[self.audioCapture stopCapture];
self.streaming = NO;
}
[self.pendingFrames removeAllObjects];
[self.client disableAudioSending];
[self stopKeepAlive];
[self.client disconnect];
[self.audioSession deactivateSession];
NSLog(@"[DeepgramStreamingManager] App entered background, socket closed");
});
}
- (void)handleAppWillEnterForeground {
dispatch_async(self.stateQueue, ^{
self.appInBackground = NO;
if (self.shouldReconnectOnForeground) {
self.keepConnection = YES;
self.reconnectAttempts = 0;
[self connectIfNeeded];
}
self.shouldReconnectOnForeground = NO;
});
}
- (void)startKeepAliveIfNeeded {
if (!self.keepConnection || !self.client.isConnected || self.streaming) {
return;
}
if (self.keepAliveTimer) {
return;
}
self.keepAliveTimer =
dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,
self.stateQueue);
dispatch_source_set_timer(self.keepAliveTimer,
dispatch_time(DISPATCH_TIME_NOW, 15 * NSEC_PER_SEC),
15 * NSEC_PER_SEC, 1 * NSEC_PER_SEC);
__weak typeof(self) weakSelf = self;
dispatch_source_set_event_handler(self.keepAliveTimer, ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
[strongSelf.client sendKeepAlive];
});
dispatch_resume(self.keepAliveTimer);
}
- (void)stopKeepAlive {
if (self.keepAliveTimer) {
dispatch_source_cancel(self.keepAliveTimer);
self.keepAliveTimer = nil;
}
}
@end

View File

@@ -1,52 +0,0 @@
//
// DeepgramWebSocketClient.h
// keyBoard
//
// Created by Mac on 2026/1/21.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@protocol DeepgramWebSocketClientDelegate <NSObject>
@optional
- (void)deepgramClientDidConnect;
- (void)deepgramClientDidDisconnect:(NSError *_Nullable)error;
- (void)deepgramClientDidReceiveInterimTranscript:(NSString *)text;
- (void)deepgramClientDidReceiveFinalTranscript:(NSString *)text;
- (void)deepgramClientDidFail:(NSError *)error;
@end
/// WebSocket client for Deepgram live transcription.
@interface DeepgramWebSocketClient : NSObject
@property(nonatomic, weak) id<DeepgramWebSocketClientDelegate> delegate;
@property(nonatomic, copy) NSString *serverURL; // wss://api.deepgram.com/v1/listen
@property(nonatomic, copy) NSString *apiKey;
@property(nonatomic, copy, nullable) NSString *language;
@property(nonatomic, copy, nullable) NSString *model;
@property(nonatomic, assign) BOOL punctuate;
@property(nonatomic, assign) BOOL smartFormat;
@property(nonatomic, assign) BOOL interimResults;
@property(nonatomic, copy) NSString *encoding; // linear16
@property(nonatomic, assign) double sampleRate;
@property(nonatomic, assign) int channels;
@property(nonatomic, assign, readonly, getter=isConnected) BOOL connected;
- (void)connect;
- (void)disconnect;
- (void)sendAudioPCMFrame:(NSData *)pcmFrame;
- (void)finish;
- (void)sendKeepAlive;
- (void)enableAudioSending;
- (void)disableAudioSending;
@end
NS_ASSUME_NONNULL_END

View File

@@ -1,411 +0,0 @@
//
// DeepgramWebSocketClient.m
// keyBoard
//
// Created by Mac on 2026/1/21.
//
#import "DeepgramWebSocketClient.h"
static NSString *const kDeepgramWebSocketClientErrorDomain =
@"DeepgramWebSocketClient";
@interface DeepgramWebSocketClient () <NSURLSessionWebSocketDelegate>
@property(nonatomic, strong) NSURLSession *urlSession;
@property(nonatomic, strong) NSURLSessionWebSocketTask *webSocketTask;
@property(nonatomic, strong) dispatch_queue_t networkQueue;
@property(nonatomic, assign) BOOL connected;
@property(nonatomic, assign) BOOL audioSendingEnabled;
@end
@implementation DeepgramWebSocketClient
- (instancetype)init {
self = [super init];
if (self) {
_networkQueue = dispatch_queue_create("com.keyboard.aitalk.deepgram.ws",
DISPATCH_QUEUE_SERIAL);
_serverURL = @"wss://api.deepgram.com/v1/listen";
_encoding = @"linear16";
_sampleRate = 16000.0;
_channels = 1;
_punctuate = YES;
_smartFormat = YES;
_interimResults = YES;
_audioSendingEnabled = NO;
}
return self;
}
- (void)dealloc {
[self disconnectInternal];
}
#pragma mark - Public Methods
- (void)connect {
dispatch_async(self.networkQueue, ^{
[self disconnectInternal];
if (self.apiKey.length == 0) {
[self reportErrorWithMessage:@"Deepgram API key is required"];
return;
}
NSURL *url = [self buildURL];
if (!url) {
[self reportErrorWithMessage:@"Invalid Deepgram URL"];
return;
}
NSLog(@"[DeepgramWebSocketClient] Connecting: %@", url.absoluteString);
NSURLSessionConfiguration *config =
[NSURLSessionConfiguration defaultSessionConfiguration];
config.timeoutIntervalForRequest = 30;
config.timeoutIntervalForResource = 300;
self.urlSession = [NSURLSession sessionWithConfiguration:config
delegate:self
delegateQueue:nil];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
[request setValue:[NSString stringWithFormat:@"Token %@", self.apiKey]
forHTTPHeaderField:@"Authorization"];
self.webSocketTask = [self.urlSession webSocketTaskWithRequest:request];
[self.webSocketTask resume];
[self receiveMessage];
});
}
- (void)disconnect {
dispatch_async(self.networkQueue, ^{
BOOL shouldNotify = self.webSocketTask != nil;
if (shouldNotify) {
NSLog(@"[DeepgramWebSocketClient] Disconnect requested");
}
[self disconnectInternal];
if (shouldNotify) {
[self notifyDisconnect:nil];
}
});
}
- (void)sendAudioPCMFrame:(NSData *)pcmFrame {
if (!self.connected || !self.webSocketTask || pcmFrame.length == 0) {
return;
}
dispatch_async(self.networkQueue, ^{
if (!self.audioSendingEnabled) {
return;
}
if (!self.connected || !self.webSocketTask) {
return;
}
NSURLSessionWebSocketMessage *message =
[[NSURLSessionWebSocketMessage alloc] initWithData:pcmFrame];
[self.webSocketTask
sendMessage:message
completionHandler:^(NSError *_Nullable error) {
if (error) {
[self reportError:error];
} else {
NSLog(@"[DeepgramWebSocketClient] Sent audio frame: %lu bytes",
(unsigned long)pcmFrame.length);
}
}];
});
}
- (void)finish {
NSLog(@"[DeepgramWebSocketClient] Sending CloseStream");
[self sendJSON:@{@"type" : @"CloseStream"}];
}
- (void)sendKeepAlive {
if (!self.connected || !self.webSocketTask) {
return;
}
[self sendJSON:@{@"type" : @"KeepAlive"}];
}
- (void)enableAudioSending {
dispatch_async(self.networkQueue, ^{
self.audioSendingEnabled = YES;
});
}
- (void)disableAudioSending {
dispatch_async(self.networkQueue, ^{
self.audioSendingEnabled = NO;
});
}
#pragma mark - Private Methods
- (NSURL *)buildURL {
if (self.serverURL.length == 0) {
return nil;
}
NSURLComponents *components =
[NSURLComponents componentsWithString:self.serverURL];
if (!components) {
return nil;
}
NSMutableArray<NSURLQueryItem *> *items =
components.queryItems.mutableCopy ?: [NSMutableArray array];
[self upsertQueryItemWithName:@"model" value:self.model items:items];
[self upsertQueryItemWithName:@"language" value:self.language items:items];
[self
upsertQueryItemWithName:@"punctuate"
value:(self.punctuate ? @"true" : @"false")items:items];
[self upsertQueryItemWithName:@"smart_format"
value:(self.smartFormat ? @"true" : @"false")items
:items];
[self upsertQueryItemWithName:@"interim_results"
value:(self.interimResults ? @"true" : @"false")items
:items];
[self upsertQueryItemWithName:@"encoding" value:self.encoding items:items];
[self upsertQueryItemWithName:@"sample_rate"
value:[NSString
stringWithFormat:@"%.0f", self.sampleRate]
items:items];
[self upsertQueryItemWithName:@"channels"
value:[NSString stringWithFormat:@"%d", self.channels]
items:items];
components.queryItems = items;
return components.URL;
}
- (void)upsertQueryItemWithName:(NSString *)name
value:(NSString *)value
items:(NSMutableArray<NSURLQueryItem *> *)items {
if (name.length == 0 || value.length == 0) {
return;
}
for (NSUInteger i = 0; i < items.count; i++) {
NSURLQueryItem *item = items[i];
if ([item.name isEqualToString:name]) {
items[i] = [NSURLQueryItem queryItemWithName:name value:value];
return;
}
}
[items addObject:[NSURLQueryItem queryItemWithName:name value:value]];
}
- (void)sendJSON:(NSDictionary *)dict {
if (!self.webSocketTask) {
return;
}
NSError *jsonError = nil;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dict
options:0
error:&jsonError];
if (jsonError) {
[self reportError:jsonError];
return;
}
NSString *jsonString = [[NSString alloc] initWithData:jsonData
encoding:NSUTF8StringEncoding];
if (!jsonString) {
[self reportErrorWithMessage:@"Failed to encode JSON message"];
return;
}
dispatch_async(self.networkQueue, ^{
NSURLSessionWebSocketMessage *message =
[[NSURLSessionWebSocketMessage alloc] initWithString:jsonString];
[self.webSocketTask sendMessage:message
completionHandler:^(NSError *_Nullable error) {
if (error) {
[self reportError:error];
}
}];
});
}
- (void)receiveMessage {
if (!self.webSocketTask) {
return;
}
__weak typeof(self) weakSelf = self;
[self.webSocketTask receiveMessageWithCompletionHandler:^(
NSURLSessionWebSocketMessage *_Nullable message,
NSError *_Nullable error) {
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) {
return;
}
if (error) {
if (error.code != NSURLErrorCancelled && error.code != 57) {
[strongSelf notifyDisconnect:error];
[strongSelf disconnectInternal];
}
return;
}
if (message.type == NSURLSessionWebSocketMessageTypeString) {
NSLog(@"[DeepgramWebSocketClient] Received text: %@", message.string);
[strongSelf handleTextMessage:message.string];
} else if (message.type == NSURLSessionWebSocketMessageTypeData) {
NSLog(@"[DeepgramWebSocketClient] Received binary: %lu bytes",
(unsigned long)message.data.length);
[strongSelf handleBinaryMessage:message.data];
}
[strongSelf receiveMessage];
}];
}
- (void)handleTextMessage:(NSString *)text {
if (text.length == 0) {
return;
}
NSData *data = [text dataUsingEncoding:NSUTF8StringEncoding];
if (!data) {
return;
}
NSError *jsonError = nil;
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data
options:0
error:&jsonError];
if (jsonError) {
[self reportError:jsonError];
return;
}
NSString *errorMessage = json[@"error"];
if (errorMessage.length > 0) {
[self reportErrorWithMessage:errorMessage];
return;
}
NSDictionary *channel = json[@"channel"];
if (![channel isKindOfClass:[NSDictionary class]]) {
return;
}
NSArray *alternatives = channel[@"alternatives"];
if (![alternatives isKindOfClass:[NSArray class]] ||
alternatives.count == 0) {
return;
}
NSDictionary *firstAlt = alternatives.firstObject;
NSString *transcript = firstAlt[@"transcript"] ?: @"";
BOOL isFinal =
[json[@"is_final"] boolValue] || [json[@"speech_final"] boolValue];
if (transcript.length == 0) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (isFinal) {
if ([self.delegate respondsToSelector:@selector
(deepgramClientDidReceiveFinalTranscript:)]) {
[self.delegate deepgramClientDidReceiveFinalTranscript:transcript];
}
} else {
if ([self.delegate respondsToSelector:@selector
(deepgramClientDidReceiveInterimTranscript:)]) {
[self.delegate deepgramClientDidReceiveInterimTranscript:transcript];
}
}
});
}
- (void)handleBinaryMessage:(NSData *)data {
}
- (void)disconnectInternal {
self.connected = NO;
self.audioSendingEnabled = NO;
if (self.webSocketTask) {
[self.webSocketTask
cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure
reason:nil];
self.webSocketTask = nil;
}
if (self.urlSession) {
[self.urlSession invalidateAndCancel];
self.urlSession = nil;
}
}
- (void)reportError:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector(deepgramClientDidFail:)]) {
[self.delegate deepgramClientDidFail:error];
}
});
}
- (void)reportErrorWithMessage:(NSString *)message {
NSError *error =
[NSError errorWithDomain:kDeepgramWebSocketClientErrorDomain
code:-1
userInfo:@{NSLocalizedDescriptionKey : message ?: @""}];
[self reportError:error];
}
- (void)notifyDisconnect:(NSError *_Nullable)error {
self.connected = NO;
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate
respondsToSelector:@selector(deepgramClientDidDisconnect:)]) {
[self.delegate deepgramClientDidDisconnect:error];
}
});
}
#pragma mark - NSURLSessionWebSocketDelegate
- (void)URLSession:(NSURLSession *)session
webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask
didOpenWithProtocol:(NSString *)protocol {
self.connected = YES;
NSLog(@"[DeepgramWebSocketClient] Connected");
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate
respondsToSelector:@selector(deepgramClientDidConnect)]) {
[self.delegate deepgramClientDidConnect];
}
});
}
- (void)URLSession:(NSURLSession *)session
webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask
didCloseWithCode:(NSURLSessionWebSocketCloseCode)closeCode
reason:(NSData *)reason {
if (!self.webSocketTask) {
return;
}
NSLog(@"[DeepgramWebSocketClient] Closed with code: %ld", (long)closeCode);
[self notifyDisconnect:nil];
[self disconnectInternal];
}
@end

View File

@@ -17,24 +17,15 @@ NS_ASSUME_NONNULL_BEGIN
- (void)voiceToTextManagerDidBeginRecording:(KBVoiceToTextManager *)manager;
- (void)voiceToTextManagerDidEndRecording:(KBVoiceToTextManager *)manager;
- (void)voiceToTextManagerDidCancelRecording:(KBVoiceToTextManager *)manager;
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager
didUpdateInterimText:(NSString *)text;
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager
didReceiveFinalText:(NSString *)text;
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager
didFailWithError:(NSError *)error;
@end
/// Voice-to-text manager (binds KBVoiceInputBar and uses Deepgram).
/// 语音输入事件管理器,仅负责桥接 KBVoiceInputBar 的录音事件。
@interface KBVoiceToTextManager : NSObject
@property(nonatomic, weak) id<KBVoiceToTextManagerDelegate> delegate;
@property(nonatomic, weak, readonly) KBVoiceInputBar *inputBar;
@property(nonatomic, assign) BOOL deepgramEnabled;
- (instancetype)initWithInputBar:(KBVoiceInputBar *)inputBar;
- (void)prepareConnection;
- (void)disconnect;
@end

View File

@@ -6,118 +6,30 @@
//
#import "KBVoiceToTextManager.h"
#import "DeepgramStreamingManager.h"
#import "KBVoiceInputBar.h"
#import "KBLocalizationManager.h"
@interface KBVoiceToTextManager () <KBVoiceInputBarDelegate,
DeepgramStreamingManagerDelegate>
@interface KBVoiceToTextManager () <KBVoiceInputBarDelegate>
@property(nonatomic, strong) DeepgramStreamingManager *deepgramManager;
@property(nonatomic, weak) KBVoiceInputBar *inputBar;
@property(nonatomic, strong) NSMutableString *fullText;
@end
@implementation KBVoiceToTextManager
- (void)setDeepgramEnabled:(BOOL)deepgramEnabled {
if (_deepgramEnabled == deepgramEnabled) {
return;
}
_deepgramEnabled = deepgramEnabled;
if (!deepgramEnabled) {
[self.deepgramManager cancel];
[self resetTranscript];
} else {
[self.deepgramManager prepareConnection];
}
}
- (instancetype)initWithInputBar:(KBVoiceInputBar *)inputBar {
self = [super init];
if (self) {
_inputBar = inputBar;
_inputBar.delegate = self;
_fullText = [[NSMutableString alloc] init];
_deepgramEnabled = YES;
[self setupDeepgram];
}
return self;
}
- (void)dealloc {
[self.deepgramManager disconnect];
}
#pragma mark - Public Methods
- (void)prepareConnection {
if (!self.deepgramEnabled) {
return;
}
[self kb_refreshDeepgramLanguage];
[self.deepgramManager prepareConnection];
}
- (void)disconnect {
if (!self.deepgramEnabled) {
return;
}
[self.deepgramManager disconnect];
}
#pragma mark - Private Methods
- (void)setupDeepgram {
self.deepgramManager = [[DeepgramStreamingManager alloc] init];
self.deepgramManager.delegate = self;
self.deepgramManager.serverURL = @"wss://api.deepgram.com/v1/listen";
self.deepgramManager.apiKey = @"9c792eb63a65d644cbc95785155754cd1e84f8cf";
[self kb_refreshDeepgramLanguage];
self.deepgramManager.model = @"nova-3";
self.deepgramManager.punctuate = YES;
self.deepgramManager.smartFormat = YES;
self.deepgramManager.interimResults = YES;
self.deepgramManager.encoding = @"linear16";
self.deepgramManager.sampleRate = 16000.0;
self.deepgramManager.channels = 1;
}
- (void)resetTranscript {
[self.fullText setString:@""];
}
- (void)kb_refreshDeepgramLanguage {
self.deepgramManager.language = [self kb_currentDeepgramLanguageCode];
}
- (NSString *)kb_currentDeepgramLanguageCode {
NSString *languageCode = [KBLocalizationManager shared].currentLanguageCode ?: @"en";
NSString *lc = languageCode.lowercaseString;
if ([lc hasPrefix:@"es"]) { return @"es"; }
if ([lc hasPrefix:@"id"]) { return @"id"; }
if ([lc hasPrefix:@"pt"]) { return @"pt"; }
if ([lc hasPrefix:@"zh-hant"] || [lc hasPrefix:@"zh_tw"] || [lc hasPrefix:@"zh-tw"] || [lc hasPrefix:@"zh-hk"]) {
return @"zh-TW";
}
if ([lc hasPrefix:@"zh-hans"] || [lc hasPrefix:@"zh_cn"] || [lc hasPrefix:@"zh-cn"]) {
return @"zh-CN";
}
return @"en";
}
#pragma mark - KBVoiceInputBarDelegate
- (void)voiceInputBarDidBeginRecording:(KBVoiceInputBar *)inputBar {
[self resetTranscript];
if (self.deepgramEnabled) {
[self kb_refreshDeepgramLanguage];
inputBar.statusText = KBLocalized(@"Voice Connecting...");
[self.deepgramManager start];
} else {
inputBar.statusText = KBLocalized(@"Voice Recording...");
}
inputBar.statusText = KBLocalized(@"Voice Recording...");
if ([self.delegate respondsToSelector:@selector
(voiceToTextManagerDidBeginRecording:)]) {
@@ -126,12 +38,7 @@
}
- (void)voiceInputBarDidEndRecording:(KBVoiceInputBar *)inputBar {
if (self.deepgramEnabled) {
inputBar.statusText = KBLocalized(@"Voice Recognizing...");
[self.deepgramManager stopAndFinalize];
} else {
inputBar.statusText = KBLocalized(@"Voice Recording Ended");
}
inputBar.statusText = KBLocalized(@"Voice Recording Ended");
if ([self.delegate respondsToSelector:@selector
(voiceToTextManagerDidEndRecording:)]) {
@@ -141,10 +48,6 @@
- (void)voiceInputBarDidCancelRecording:(KBVoiceInputBar *)inputBar {
inputBar.statusText = KBLocalized(@"Voice Cancelled");
[self resetTranscript];
if (self.deepgramEnabled) {
[self.deepgramManager cancel];
}
if ([self.delegate respondsToSelector:@selector
(voiceToTextManagerDidCancelRecording:)]) {
@@ -152,89 +55,4 @@
}
}
#pragma mark - DeepgramStreamingManagerDelegate
- (void)deepgramStreamingManagerDidConnect {
if (!self.deepgramEnabled) {
return;
}
self.inputBar.statusText = KBLocalized(@"Voice Listening...");
}
- (void)deepgramStreamingManagerDidDisconnect:(NSError *_Nullable)error {
if (!self.deepgramEnabled) {
return;
}
if (!error) {
return;
}
self.inputBar.statusText = KBLocalized(@"Voice Recognition Failed");
if ([self.delegate respondsToSelector:@selector
(voiceToTextManager:didFailWithError:)]) {
[self.delegate voiceToTextManager:self didFailWithError:error];
}
}
- (void)deepgramStreamingManagerDidUpdateRMS:(float)rms {
if (!self.deepgramEnabled) {
return;
}
[self.inputBar updateVolumeRMS:rms];
}
- (void)deepgramStreamingManagerDidReceiveInterimTranscript:(NSString *)text {
if (!self.deepgramEnabled) {
return;
}
NSString *displayText = text ?: @"";
if (self.fullText.length > 0 && displayText.length > 0) {
displayText =
[NSString stringWithFormat:@"%@ %@", self.fullText, displayText];
} else if (self.fullText.length > 0) {
displayText = [self.fullText copy];
}
self.inputBar.statusText =
displayText.length > 0 ? displayText : KBLocalized(@"Voice Recognizing...");
if ([self.delegate respondsToSelector:@selector
(voiceToTextManager:didUpdateInterimText:)]) {
[self.delegate voiceToTextManager:self didUpdateInterimText:displayText];
}
}
- (void)deepgramStreamingManagerDidReceiveFinalTranscript:(NSString *)text {
if (!self.deepgramEnabled) {
return;
}
if (text.length > 0) {
if (self.fullText.length > 0) {
[self.fullText appendString:@" "];
}
[self.fullText appendString:text];
}
NSString *finalText = [self.fullText copy];
self.inputBar.statusText =
finalText.length > 0 ? finalText : KBLocalized(@"Voice Recognition Completed");
if (finalText.length > 0 &&
[self.delegate respondsToSelector:@selector
(voiceToTextManager:didReceiveFinalText:)]) {
[self.delegate voiceToTextManager:self didReceiveFinalText:finalText];
}
}
- (void)deepgramStreamingManagerDidFail:(NSError *)error {
if (!self.deepgramEnabled) {
return;
}
self.inputBar.statusText = KBLocalized(@"Voice Recognition Failed");
if ([self.delegate respondsToSelector:@selector
(voiceToTextManager:didFailWithError:)]) {
[self.delegate voiceToTextManager:self didFailWithError:error];
}
}
@end

View File

@@ -11,6 +11,7 @@
#import "KBLoginVM.h"
#import "AppDelegate.h"
#import "BaseTabBarController.h"
#import "KBWebViewViewController.h"
@interface KBEmailLoginVC () <UITextViewDelegate, UITextFieldDelegate>
//
@@ -311,8 +312,10 @@
BOOL hitTerms = (termsRange.location != NSNotFound && NSLocationInRange(charIndex, termsRange));
BOOL hitPrivacy = (privacyRange.location != NSNotFound && NSLocationInRange(charIndex, privacyRange));
if (hitTerms || hitPrivacy) {
KBLOG(@"KBEmailLoginVC tap policy");
if (hitTerms) {
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypeTermsOfService fromViewController:self];
} else if (hitPrivacy) {
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypePrivacyPolicy fromViewController:self];
}
}

View File

@@ -11,6 +11,7 @@
#import "KBLoginVM.h"
#import "AppDelegate.h"
#import "KBRegistVerEmailVC.h"
#import "KBWebViewViewController.h"
@interface KBEmailRegistVC () <UITextViewDelegate, UITextFieldDelegate>
@@ -393,10 +394,10 @@
BOOL hitTerms = (termsRange.location != NSNotFound && NSLocationInRange(charIndex, termsRange));
BOOL hitPrivacy = (privacyRange.location != NSNotFound && NSLocationInRange(charIndex, privacyRange));
if (hitTerms || hitPrivacy) {
KBLOG(@"tap policy in KBEmailRegistVC");
//
[KBHUD showInfo:KBLocalized(@"Open agreement")];
if (hitTerms) {
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypeTermsOfService fromViewController:self];
} else if (hitPrivacy) {
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypePrivacyPolicy fromViewController:self];
}
}

View File

@@ -12,6 +12,7 @@
#import "KBEmailRegistVC.h"
#import "KBEmailLoginVC.h"
#import "KBForgetPwdVC.h"
#import "KBWebViewViewController.h"
@interface KBLoginVC () <UITextViewDelegate>
@@ -210,8 +211,11 @@
}
- (void)onTapPolicy {
// /
KBLOG(@"onTapPolicy");
[self kb_openLegalDocumentType:KBLegalDocumentTypeTermsOfService];
}
- (void)kb_openLegalDocumentType:(KBLegalDocumentType)type {
[KBWebViewViewController presentLegalDocumentType:type fromViewController:self];
}
- (void)onTapSignUp {
@@ -495,8 +499,10 @@
BOOL hitTerms = (termsRange.location != NSNotFound && NSLocationInRange(charIndex, termsRange));
BOOL hitPrivacy = (privacyRange.location != NSNotFound && NSLocationInRange(charIndex, privacyRange));
if (hitTerms || hitPrivacy) {
[self onTapPolicy];
if (hitTerms) {
[self kb_openLegalDocumentType:KBLegalDocumentTypeTermsOfService];
} else if (hitPrivacy) {
[self kb_openLegalDocumentType:KBLegalDocumentTypePrivacyPolicy];
}
}

View File

@@ -18,6 +18,7 @@
#import "KBMyVM.h"
#import "KBConsumptionRecordVC.h"
#import "KBHUD.h"
#import "KBWebViewViewController.h"
@interface MyVC () <UITableViewDelegate, UITableViewDataSource>
@@ -221,9 +222,9 @@
}else if ([itemID isEqualToString:@"4"]){
[self handleEmailCopy];
}else if ([itemID isEqualToString:@"5"]){
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypeTermsOfService fromViewController:self];
}else if ([itemID isEqualToString:@"6"]){
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypePrivacyPolicy fromViewController:self];
}else if ([itemID isEqualToString:@"8"]){
KBConsumptionRecordVC *vc = [[KBConsumptionRecordVC alloc] init];
[self.navigationController pushViewController:vc animated:true];

View File

@@ -10,6 +10,7 @@
#import "KBShopVM.h"
#import "IAPVerifyTransactionObj.h"
#import "keyBoard-Swift.h"
#import "KBWebViewViewController.h"
static NSString * const kKBJfPayCellId = @"kKBJfPayCellId";
@interface KBJfPay () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout>
@@ -357,7 +358,7 @@ static NSString * const kKBJfPayCellId = @"kKBJfPayCellId";
}
- (void)agreementButtonAction{
[KBHUD showInfo:KBLocalized(@"Open agreement")];
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypeMembershipAgreement fromViewController:self];
}
#pragma mark - Lazy UI

View File

@@ -24,6 +24,7 @@
#import "PagingViewTableHeaderView.h"
#import "JXCategoryTitleView.h"
#import "keyBoard-Swift.h"
#import "KBWebViewViewController.h"
static const CGFloat JXTableHeaderViewHeight = 224;
static const CGFloat JXheightForHeaderInSection = 39;
@@ -202,7 +203,7 @@ static const CGFloat JXheightForHeaderInSection = 39;
[self.restoreButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerY.equalTo(self.closeButton);
make.right.equalTo(self.view).offset(-15);
make.width.mas_equalTo(90);
make.left.greaterThanOrEqualTo(self.closeButton.mas_right).offset(12);
make.height.mas_equalTo(32);
}];
@@ -350,7 +351,7 @@ static const CGFloat JXheightForHeaderInSection = 39;
}
- (void)onTapAgreementButton {
[KBHUD showInfo:KBLocalized(@"Open agreement")];
[KBWebViewViewController presentLegalDocumentType:KBLegalDocumentTypeMembershipAgreement fromViewController:self];
}
- (void)onTapRestoreButton {
@@ -392,9 +393,12 @@ static const CGFloat JXheightForHeaderInSection = 39;
[_restoreButton setTitle:KBLocalized(@"Resume Purchase") forState:UIControlStateNormal];
[_restoreButton setTitleColor:[UIColor colorWithHex:0x02BEAC] forState:UIControlStateNormal];
_restoreButton.titleLabel.font = [KBFont medium:10];
_restoreButton.titleLabel.lineBreakMode = NSLineBreakByClipping;
_restoreButton.contentEdgeInsets = UIEdgeInsetsMake(0, 12, 0, 12);
_restoreButton.imageEdgeInsets = UIEdgeInsetsMake(0, -4, 0, 4);
_restoreButton.titleEdgeInsets = UIEdgeInsetsMake(0, 6, 0, -6);
[_restoreButton setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
[_restoreButton setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
[_restoreButton addTarget:self action:@selector(onTapRestoreButton) forControlEvents:UIControlEventTouchUpInside];
}
return _restoreButton;

View File

@@ -1,56 +0,0 @@
//
// KBAuthManager.h
// 主 App 与键盘扩展共享使用
//
// 通过 Keychain Sharing 统一管理用户登录态access/refresh token
// 线程安全;在保存/清空时同时发送进程内通知与 Darwin 跨进程通知,
// 以便键盘扩展正运行在其他 App 时也能及时感知变更。
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Darwin 跨进程通知名称:当令牌更新或清除时发送,用于提示 App/扩展刷新缓存。
extern NSString * const kKBDarwinAuthChanged;
/// 进程内通知NSNotificationCenter令牌更新或清除时发送。
extern NSNotificationName const KBAuthChangedNotification;
/// 简单的会话容器;可按需扩展字段。
@interface KBAuthSession : NSObject <NSSecureCoding>
@property (nonatomic, copy, nullable) NSString *accessToken;
@property (nonatomic, copy, nullable) NSString *refreshToken;
@property (nonatomic, strong, nullable) NSDate *expiryDate; // 可选:过期时间
@property (nonatomic, copy, nullable) NSString *userIdentifier; // 可选:如“用 Apple 登录”的 userIdentifier
@end
/// 基于“共享钥匙串”的鉴权管理器(使用 Keychain Sharing 访问组)。
@interface KBAuthManager : NSObject
+ (instancetype)shared;
/// 当前会话(内存缓存),在加载/保存/清除后更新。
@property (atomic, strong, readonly, nullable) KBAuthSession *current;
/// 是否已登录:存在 accessToken 且未明显过期(未设置过期时间则只要有 token 即视为已登录)。
- (BOOL)isLoggedIn;
/// 从钥匙串加载到内存;通常首次访问时会自动加载。
- (void)reloadFromKeychain;
/// 保存令牌到“共享钥匙串”并通知观察者。
- (BOOL)saveAccessToken:(NSString *)accessToken
refreshToken:(nullable NSString *)refreshToken
expiryDate:(nullable NSDate *)expiryDate
userIdentifier:(nullable NSString *)userIdentifier;
/// 从钥匙串与内存中清除令牌,并通知观察者。
- (void)signOut;
/// 便捷方法:若存在有效令牌,返回 `Authorization` 请求头;否则返回空字典。
- (NSDictionary<NSString *, NSString *> *)authorizationHeader;
@end
NS_ASSUME_NONNULL_END

View File

@@ -1,211 +0,0 @@
//
// KBAuthManager.m
//
//
// - 使 service/account KBAuthSession
// - kSecAttrAccessGroup Keychain Sharing 访 App
// - / Darwin 便
//
#import "KBAuthManager.h"
#import <Security/Security.h>
#import "KBConfig.h" // 访 KBConfig.h
NSString * const kKBDarwinAuthChanged = @"com.loveKey.nyx.auth.changed";
NSNotificationName const KBAuthChangedNotification = @"KBAuthChangedNotification";
static NSString * const kKBKCService = @"com.loveKey.nyx.auth"; // service
static NSString * const kKBKCAccount = @"session"; // account
// Keychain Sharing 访 target entitlements
// Capabilities Keychain Sharing
// $(AppIdentifierPrefix)com.loveKey.nyx.shared
// TN6HHV45BB.com.loveKey.nyx.shared
#ifndef KB_KEYCHAIN_ACCESS_GROUP
#define KB_KEYCHAIN_ACCESS_GROUP @"TN6HHV45BB.com.loveKey.nyx.shared"
#endif
// <=
static const NSTimeInterval kKBExpiryGrace = 5.0; //
@implementation KBAuthSession
+ (BOOL)supportsSecureCoding { return YES; }
- (void)encodeWithCoder:(NSCoder *)coder {
[coder encodeObject:self.accessToken forKey:@"accessToken"];
[coder encodeObject:self.refreshToken forKey:@"refreshToken"];
[coder encodeObject:self.expiryDate forKey:@"expiryDate"];
[coder encodeObject:self.userIdentifier forKey:@"userIdentifier"];
}
- (instancetype)initWithCoder:(NSCoder *)coder {
if (self = [super init]) {
_accessToken = [coder decodeObjectOfClass:NSString.class forKey:@"accessToken"];
_refreshToken = [coder decodeObjectOfClass:NSString.class forKey:@"refreshToken"];
_expiryDate = [coder decodeObjectOfClass:NSDate.class forKey:@"expiryDate"];
_userIdentifier = [coder decodeObjectOfClass:NSString.class forKey:@"userIdentifier"];
}
return self;
}
@end
@interface KBAuthManager ()
@property (atomic, strong, readwrite, nullable) KBAuthSession *current;
@end
@implementation KBAuthManager
+ (instancetype)shared {
static KBAuthManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBAuthManager new]; });
return m;
}
#if DEBUG
static inline void KBLog(NSString *fmt, ...) {
va_list args; va_start(args, fmt);
NSString *msg = [[NSString alloc] initWithFormat:fmt arguments:args];
va_end(args);
NSLog(@"[KBAuth] %@", msg);
}
#endif
- (instancetype)init {
if (self = [super init]) {
[self reloadFromKeychain];
// Darwin App
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(self),
KBAuthDarwinCallback,
(__bridge CFStringRef)kKBDarwinAuthChanged,
NULL,
CFNotificationSuspensionBehaviorDeliverImmediately);
}
return self;
}
static void KBAuthDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) {
KBAuthManager *self = (__bridge KBAuthManager *)observer;
[self reloadFromKeychain];
}
- (void)dealloc {
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL);
}
- (BOOL)isLoggedIn {
KBAuthSession *s = self.current;
if (s.accessToken.length == 0) return NO;
if (!s.expiryDate) return YES; // token
return ([s.expiryDate timeIntervalSinceNow] > kKBExpiryGrace);
}
#pragma mark - Public
- (void)reloadFromKeychain {
NSData *data = [self keychainRead];
KBAuthSession *session = nil;
if (data.length > 0) {
@try {
session = [NSKeyedUnarchiver unarchivedObjectOfClass:KBAuthSession.class fromData:data error:NULL];
} @catch (__unused NSException *e) { session = nil; }
}
self.current = session;
[[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil]; //
}
- (BOOL)saveAccessToken:(NSString *)accessToken
refreshToken:(NSString *)refreshToken
expiryDate:(NSDate *)expiryDate
userIdentifier:(NSString *)userIdentifier {
KBAuthSession *s = [KBAuthSession new];
s.accessToken = accessToken ?: @"";
s.refreshToken = refreshToken;
s.expiryDate = expiryDate;
s.userIdentifier = userIdentifier;
NSError *err = nil;
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:s requiringSecureCoding:YES error:&err];
if (err || data.length == 0) return NO;
BOOL ok = [self keychainWrite:data];
if (ok) {
self.current = s;
//
[[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil];
// App <->
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL, NULL, true);
}
return ok;
}
- (void)signOut {
[self keychainDelete];
self.current = nil;
[[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil];
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL, NULL, true);
}
- (NSDictionary<NSString *,NSString *> *)authorizationHeader {
NSString *t = self.current.accessToken;
if (t.length == 0) return @{}; //
return @{ @"Authorization": [@"Bearer " stringByAppendingString:t] };
}
#pragma mark - Keychain (shared)
- (NSMutableDictionary *)baseKCQuery {
NSMutableDictionary *q = [@{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
(__bridge id)kSecAttrService: kKBKCService,
(__bridge id)kSecAttrAccount: kKBKCAccount } mutableCopy];
// 访App
q[(__bridge id)kSecAttrAccessGroup] = KB_KEYCHAIN_ACCESS_GROUP;
return q;
}
- (BOOL)keychainWrite:(NSData *)data {
if (!data) return NO;
NSMutableDictionary *query = [self baseKCQuery];
SecItemDelete((__bridge CFDictionaryRef)query);
//
query[(__bridge id)kSecValueData] = data;
// 访
query[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly;
OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
#if DEBUG
if (status != errSecSuccess) {
KBLog(@"SecItemAdd failed status=%ld group=%@", (long)status, KB_KEYCHAIN_ACCESS_GROUP);
} else {
KBLog(@"SecItemAdd ok group=%@", KB_KEYCHAIN_ACCESS_GROUP);
}
#endif
return (status == errSecSuccess);
}
- (NSData *)keychainRead {
NSMutableDictionary *query = [self baseKCQuery];
query[(__bridge id)kSecReturnData] = @YES;
query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
CFTypeRef dataRef = NULL;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &dataRef);
#if DEBUG
if (status != errSecSuccess) {
KBLog(@"SecItemCopyMatching status=%ld group=%@ (item not found or no entitlement)", (long)status, KB_KEYCHAIN_ACCESS_GROUP);
} else {
KBLog(@"SecItemCopyMatching ok group=%@", KB_KEYCHAIN_ACCESS_GROUP);
}
#endif
if (status != errSecSuccess || !dataRef) return nil;
return (__bridge_transfer NSData *)dataRef;
}
- (void)keychainDelete {
NSDictionary *query = [self baseKCQuery];
SecItemDelete((__bridge CFDictionaryRef)query);
}
@end

View File

@@ -1,40 +0,0 @@
//
// KBConfig.h
// 主 App 与键盘扩展共用的配置/宏。
//
// 在此处修改后,会通过 PCH 被两个 target 同步引用。
//
#ifndef KBConfig_h
#define KBConfig_h
// 基础baseUrl
#ifndef KB_BASE_URL
#define KB_BASE_URL @"https://devcallback.loveamorkey.com/api"
#endif
// Universal Links 通用链接
#ifndef KB_UL_BASE
#define KB_UL_BASE @"https://app.tknb.net/ul"
#endif
#define KB_UL_LOGIN KB_UL_BASE @"/login"
#define KB_UL_SETTINGS KB_UL_BASE @"/settings"
// 充值入口的通用链接
#define KB_UL_RECHARGE KB_UL_BASE @"/recharge"
// --- 认证/共享钥匙串 配置 ---
// 若已在 Capabilities 中启用 Keychain Sharing并添加访问组
// $(AppIdentifierPrefix)com.loveKey.nyx.shared
// 运行时会展开为TN6HHV45BB.com.loveKey.nyx.shared
// KBAuthManager 通过下面的宏定位访问组;如需修改,可在 Build Settings 或前缀头中覆盖该宏。
#ifndef KB_KEYCHAIN_ACCESS_GROUP
#define KB_KEYCHAIN_ACCESS_GROUP @"TN6HHV45BB.com.loveKey.nyx.shared"
#endif
// 说明:
// - 该头文件同时被主 App 与键盘扩展引用;
// - 设备/窗口相关的 UIKit 辅助方法(如 UIApplication.sharedApplication在扩展中不可用
// 请放到主 App 的前缀头或具体业务代码中,避免引入扩展不允许的 API。
#endif /* KBConfig_h */

View File

@@ -9,9 +9,22 @@
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, KBLegalDocumentType) {
KBLegalDocumentTypeTermsOfService = 0,
KBLegalDocumentTypePrivacyPolicy,
KBLegalDocumentTypeMembershipAgreement,
};
@interface KBWebViewViewController : BaseViewController
@property(nonatomic,copy) NSString * url;
@property(nonatomic,copy,nullable) NSString * htmlString;
@property(nonatomic,copy,nullable) NSString * pageTitle;
+ (instancetype)legalViewControllerWithType:(KBLegalDocumentType)type;
+ (void)presentLegalDocumentType:(KBLegalDocumentType)type fromViewController:(UIViewController *)viewController;
+ (nullable NSNumber *)legalDocumentTypeNumberFromQueryValue:(NSString *)queryValue;
+ (NSString *)queryValueForLegalDocumentType:(KBLegalDocumentType)type;
@end

View File

@@ -7,6 +7,8 @@
#import "KBWebViewViewController.h"
#import <WebKit/WebKit.h>
#import "KBConfig.h"
#import "Masonry.h"
@interface KBWebViewViewController () <WKNavigationDelegate, WKScriptMessageHandler>
@@ -19,8 +21,65 @@
@implementation KBWebViewViewController
+ (instancetype)legalViewControllerWithType:(KBLegalDocumentType)type {
KBWebViewViewController *vc = [[KBWebViewViewController alloc] init];
vc.pageTitle = [self kb_titleForLegalDocumentType:type];
NSString *remoteURL = [self kb_remoteURLForLegalDocumentType:type];
if (remoteURL.length > 0) {
vc.url = remoteURL;
} else {
vc.htmlString = [self kb_htmlForLegalDocumentType:type];
}
return vc;
}
+ (void)presentLegalDocumentType:(KBLegalDocumentType)type fromViewController:(UIViewController *)viewController {
if (![viewController isKindOfClass:UIViewController.class]) { return; }
KBWebViewViewController *vc = [self legalViewControllerWithType:type];
UINavigationController *nav = viewController.navigationController;
if (nav) {
[nav pushViewController:vc animated:YES];
return;
}
[viewController presentViewController:vc animated:YES completion:nil];
}
+ (nullable NSNumber *)legalDocumentTypeNumberFromQueryValue:(NSString *)queryValue {
NSString *value = queryValue.lowercaseString ?: @"";
if ([value isEqualToString:@"privacy"]) {
return @(KBLegalDocumentTypePrivacyPolicy);
}
if ([value isEqualToString:@"membership"]) {
return @(KBLegalDocumentTypeMembershipAgreement);
}
if ([value isEqualToString:@"terms"]) {
return @(KBLegalDocumentTypeTermsOfService);
}
return nil;
}
+ (NSString *)queryValueForLegalDocumentType:(KBLegalDocumentType)type {
switch (type) {
case KBLegalDocumentTypePrivacyPolicy:
return @"privacy";
case KBLegalDocumentTypeMembershipAgreement:
return @"membership";
case KBLegalDocumentTypeTermsOfService:
default:
return @"terms";
}
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
if (self.pageTitle.length > 0) {
self.title = self.pageTitle;
}
}
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = UIColor.whiteColor;
[self configUI];
}
@@ -34,14 +93,15 @@
// 2. webView
self.webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];
self.webView.navigationDelegate = self;
self.webView.translatesAutoresizingMaskIntoConstraints = NO;
self.webView.backgroundColor = UIColor.clearColor;
self.webView.frame = self.view.bounds;
[self.view addSubview:self.webView];
[self.webView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.kb_navView.mas_bottom);
make.left.right.bottom.equalTo(self.view);
}];
// 🟢 3. 2
self.progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
self.progressView.translatesAutoresizingMaskIntoConstraints = NO;
self.progressView.trackTintColor = [UIColor clearColor]; //
self.progressView.progressTintColor = [UIColor greenColor];
@@ -52,12 +112,11 @@
[self.view addSubview:self.progressView];
// 2
[NSLayoutConstraint activateConstraints:@[
[self.progressView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[self.progressView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.progressView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.progressView.heightAnchor constraintEqualToConstant:2.0]
]];
[self.progressView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.kb_navView.mas_bottom);
make.left.right.equalTo(self.view);
make.height.mas_equalTo(2.0);
}];
// 🟢 4. WKWebView estimatedProgress
[self.webView addObserver:self
@@ -66,9 +125,26 @@
context:nil];
self.observingProgress = YES;
// 5. URL
NSURLRequest * req = [NSURLRequest requestWithURL:[NSURL URLWithString:self.url]];
// 5.
if (self.htmlString.length > 0) {
[self.webView loadHTMLString:self.htmlString baseURL:nil];
if (self.pageTitle.length > 0) {
self.title = self.pageTitle;
}
return;
}
NSURL *URL = [NSURL URLWithString:self.url ?: @""];
if (!URL) {
[self.webView loadHTMLString:[self.class kb_fallbackErrorHTML]
baseURL:nil];
return;
}
NSURLRequest * req = [NSURLRequest requestWithURL:URL];
[self.webView loadRequest:req];
[self.view bringSubviewToFront:self.kb_navView];
[self.view bringSubviewToFront:self.progressView];
}
#pragma mark - KVO: estimatedProgress 🟢
@@ -115,7 +191,11 @@
//
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
NSLog(@"页面加载成功");
self.title = webView.title;
if (self.pageTitle.length > 0) {
self.title = self.pageTitle;
} else if (webView.title.length > 0) {
self.title = webView.title;
}
}
//
@@ -159,6 +239,55 @@ didFailProvisionalNavigation:(WKNavigation *)navigation
}
}
#pragma mark - Legal Content
+ (NSString *)kb_titleForLegalDocumentType:(KBLegalDocumentType)type {
switch (type) {
case KBLegalDocumentTypePrivacyPolicy:
return KBLocalized(@"Privacy Policy");
case KBLegalDocumentTypeMembershipAgreement:
return KBLocalized(@"Membership Agreement");
case KBLegalDocumentTypeTermsOfService:
default:
return KBLocalized(@"Agreement");
}
}
+ (NSString *)kb_remoteURLForLegalDocumentType:(KBLegalDocumentType)type {
switch (type) {
case KBLegalDocumentTypePrivacyPolicy:
return KB_PRIVACY_POLICY_URL;
case KBLegalDocumentTypeMembershipAgreement:
return KB_MEMBERSHIP_AGREEMENT_URL;
case KBLegalDocumentTypeTermsOfService:
default:
return KB_TERMS_OF_SERVICE_URL;
}
}
+ (NSString *)kb_htmlForLegalDocumentType:(KBLegalDocumentType)type {
NSString *title = [self kb_titleForLegalDocumentType:type];
NSString *body = @"";
switch (type) {
case KBLegalDocumentTypePrivacyPolicy:
body = @"<section><h2>Overview</h2><p>This in-app privacy disclosure explains how the app and the custom keyboard handle data when you use account, AI, subscription, sync, and voice features.</p></section><section><h2>Full Access</h2><p>Network-based features inside the keyboard require Full Access. If you do not enable Full Access, those features stay unavailable.</p></section><section><h2>Data Used For Features You Trigger</h2><p>When you actively use AI reply, cloud sync, account, purchase verification, or voice input, the content required for that feature may be transmitted to the service provider to complete your request.</p><p>This may include typed text you choose to send, voice audio you record, account identifiers, email address, subscription status, and limited diagnostics needed for app functionality and fraud prevention.</p></section><section><h2>Keyboard Boundaries</h2><p>The custom keyboard does not operate in secure text fields and cannot access content in contexts where iOS blocks third-party keyboards.</p></section><section><h2>Retention And Deletion</h2><p>Account-related data is retained only as needed for app functionality, purchases, support, and legal compliance. Use the in-app account deletion flow to request account removal and associated cleanup.</p></section><section><h2>Important</h2><p>Replace this fallback page with your final published privacy policy URL before App Store submission so that the wording exactly matches App Store Connect privacy labels and your backend behavior.</p></section>";
break;
case KBLegalDocumentTypeMembershipAgreement:
body = @"<section><h2>Subscription Terms</h2><p>Paid membership unlocks subscription benefits for eligible premium features. Pricing, billing period, and any trial details are shown on the purchase sheet before you confirm payment.</p></section><section><h2>Auto-Renewal</h2><p>Subscriptions renew automatically unless cancelled at least 24 hours before the end of the current billing period. Renewal charges are handled by Apple through your App Store account.</p></section><section><h2>Managing Your Subscription</h2><p>You can restore purchases inside the app and manage or cancel subscriptions in Apple ID subscription settings after purchase.</p></section><section><h2>Feature Availability</h2><p>Some premium actions started from the custom keyboard may open the main app to complete login, purchase, or subscription management.</p></section><section><h2>Important</h2><p>Replace this fallback page with your final published membership agreement URL before App Store submission.</p></section>";
break;
case KBLegalDocumentTypeTermsOfService:
default:
body = @"<section><h2>Service Scope</h2><p>This app provides a custom keyboard, account features, premium subscriptions, and optional AI-assisted and voice features. Some capabilities require network access and may open the main app to complete the flow.</p></section><section><h2>Acceptable Use</h2><p>You must not use the service to violate law, harass others, infringe rights, or generate abusive, sexual, hateful, or otherwise prohibited content.</p></section><section><h2>AI And Voice Features</h2><p>AI-generated or transcribed content may be inaccurate, incomplete, or inappropriate. You remain responsible for reviewing content before sending or relying on it.</p></section><section><h2>Accounts And Purchases</h2><p>You are responsible for activity performed through your account. Paid features are subject to Apple billing rules and any product limitations shown in the app.</p></section><section><h2>Important</h2><p>Replace this fallback page with your final published terms URL before App Store submission.</p></section>";
break;
}
return [NSString stringWithFormat:@"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1,maximum-scale=1'><style>body{font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',sans-serif;margin:0;padding:24px 18px 48px;color:#1f2937;background:#ffffff;line-height:1.6;}h1{font-size:28px;line-height:1.2;margin:0 0 20px;color:#111827;}h2{font-size:18px;line-height:1.3;margin:24px 0 10px;color:#111827;}p{font-size:15px;margin:0 0 10px;color:#4b5563;}section{padding-bottom:4px;border-bottom:1px solid #eef2f7;}section:last-child{border-bottom:none;} .note{margin-top:18px;font-size:13px;color:#6b7280;}</style></head><body><h1>%@</h1>%@<p class='note'>Built-in fallback document. Configure the final public URL in KBConfig.h when release content is ready.</p></body></html>", title, body];
}
+ (NSString *)kb_fallbackErrorHTML {
return @"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><style>body{font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',sans-serif;padding:32px;color:#1f2937;}h1{font-size:22px;margin:0 0 12px;}p{font-size:15px;line-height:1.6;color:#4b5563;}</style></head><body><h1>Page unavailable</h1><p>The requested document could not be loaded.</p></body></html>";
}
#pragma mark - Clean up 🟢
- (void)dealloc {