1
This commit is contained in:
@@ -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`:语音聊天管理器
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user