From c1ace5f53e1235c77188d0ead6d785f99caba48f Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Sun, 8 Mar 2026 21:29:10 +0800 Subject: [PATCH] 1 --- CustomKeyboard/Info.plist | 2 +- .../KeyboardViewController+Legacy.m | 25 + .../KeyboardViewController+Subscription.m | 25 + .../View/Buy/KBKeyboardSubscriptionView.h | 1 + .../View/Buy/KBKeyboardSubscriptionView.m | 6 +- Shared/KBConfig.h | 15 + ...p-store-submission-checklist-2026-03-07.md | 290 ++++++++++ docs/privacy-alignment-matrix-2026-03-08.md | 254 +++++++++ keyBoard.xcodeproj/project.pbxproj | 14 +- .../xcshareddata/xcschemes/keyBoard.xcscheme | 2 +- keyBoard/AppDelegate.m | 29 +- .../AiTalk/V/KBVoiceInputBar_使用说明.md | 69 ++- keyBoard/Class/AiTalk/VC/KBAIHomeVC.m | 12 - .../AiTalk/VM/DeepgramStreamingManager.h | 50 -- .../AiTalk/VM/DeepgramStreamingManager.m | 516 ------------------ .../Class/AiTalk/VM/DeepgramWebSocketClient.h | 52 -- .../Class/AiTalk/VM/DeepgramWebSocketClient.m | 411 -------------- .../Class/AiTalk/VM/KBVoiceToTextManager.h | 11 +- .../Class/AiTalk/VM/KBVoiceToTextManager.m | 188 +------ keyBoard/Class/Login/VC/KBEmailLoginVC.m | 7 +- keyBoard/Class/Login/VC/KBEmailRegistVC.m | 9 +- keyBoard/Class/Login/VC/KBLoginVC.m | 14 +- keyBoard/Class/Me/VC/MyVC.m | 5 +- keyBoard/Class/Pay/VC/KBJfPay.m | 3 +- keyBoard/Class/Pay/VC/KBPayMainVC.m | 8 +- keyBoard/Class/Shared/KBAuthManager.h | 56 -- keyBoard/Class/Shared/KBAuthManager.m | 211 ------- keyBoard/Class/Shared/KBConfig.h | 40 -- .../Class/WebView/KBWebViewViewController.h | 13 + .../Class/WebView/KBWebViewViewController.m | 153 +++++- keyBoard/Info.plist | 2 +- keyBoard/Shared/KBAuthManager.h | 56 -- keyBoard/Shared/KBAuthManager.m | 211 ------- keyBoard/Shared/KBConfig.h | 40 -- 34 files changed, 870 insertions(+), 1930 deletions(-) create mode 100644 docs/app-store-submission-checklist-2026-03-07.md create mode 100644 docs/privacy-alignment-matrix-2026-03-08.md delete mode 100644 keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.h delete mode 100644 keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.m delete mode 100644 keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.h delete mode 100644 keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.m delete mode 100644 keyBoard/Class/Shared/KBAuthManager.h delete mode 100644 keyBoard/Class/Shared/KBAuthManager.m delete mode 100644 keyBoard/Class/Shared/KBConfig.h delete mode 100644 keyBoard/Shared/KBAuthManager.h delete mode 100644 keyBoard/Shared/KBAuthManager.m delete mode 100644 keyBoard/Shared/KBConfig.h diff --git a/CustomKeyboard/Info.plist b/CustomKeyboard/Info.plist index baa47a8..63fd316 100644 --- a/CustomKeyboard/Info.plist +++ b/CustomKeyboard/Info.plist @@ -7,7 +7,7 @@ kbkeyboardAppExtension NSMicrophoneUsageDescription - 需要使用麦克风进行语音输入 + Microphone access is required for voice input and speech transcription. NSExtension NSExtensionAttributes diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m index 15f85ee..f39921a 100644 --- a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Legacy.m @@ -1675,6 +1675,31 @@ static NSString *KBFormatMB(uint64_t bytes) { [self kb_openRechargeForProduct:product]; } +- (void)subscriptionViewDidTapAgreement:(KBKeyboardSubscriptionView *)view { + (void)view; + [self hideSubscriptionPanel]; + NSString *query = [NSString stringWithFormat:@"type=%@&src=keyboard", + @"membership"]; + NSString *ulString = [NSString stringWithFormat:@"%@?%@", KB_UL_LEGAL, query]; + NSString *schemeString = + [NSString stringWithFormat:@"%@://legal?%@", KB_APP_SCHEME, query]; + NSURL *ul = [NSURL URLWithString:ulString]; + NSURL *scheme = [NSURL URLWithString:schemeString]; + __weak typeof(self) weakSelf = self; + [KBExtensionAppLauncher openPrimaryURL:ul + fallbackURL:scheme + usingInputController:self + source:(self.view ?: (UIResponder *)weakSelf) + completion:^(BOOL success) { + if (success) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [KBHUD showInfo:KBLocalized(@"Please open the App to view the agreement")]; + }); + }]; +} + #pragma mark - KBChatLimitPopViewDelegate - (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view { diff --git a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m index d8fa290..7285bd2 100644 --- a/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m +++ b/CustomKeyboard/KeyboardViewControllerHelp/KeyboardViewController+Subscription.m @@ -76,6 +76,31 @@ [self kb_openRechargeForProduct:product]; } +- (void)subscriptionViewDidTapAgreement:(KBKeyboardSubscriptionView *)view { + (void)view; + [self hideSubscriptionPanel]; + NSString *query = [NSString stringWithFormat:@"type=%@&src=keyboard", + @"membership"]; + NSString *ulString = [NSString stringWithFormat:@"%@?%@", KB_UL_LEGAL, query]; + NSString *schemeString = + [NSString stringWithFormat:@"%@://legal?%@", KB_APP_SCHEME, query]; + NSURL *ul = [NSURL URLWithString:ulString]; + NSURL *scheme = [NSURL URLWithString:schemeString]; + __weak typeof(self) weakSelf = self; + [KBExtensionAppLauncher openPrimaryURL:ul + fallbackURL:scheme + usingInputController:self + source:(self.view ?: (UIResponder *)weakSelf) + completion:^(BOOL success) { + if (success) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [KBHUD showInfo:KBLocalized(@"Please open the App to view the agreement")]; + }); + }]; +} + #pragma mark - Actions - (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product { diff --git a/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.h b/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.h index 29cd1c6..6477881 100644 --- a/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.h +++ b/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.h @@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN @optional - (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view; - (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product; +- (void)subscriptionViewDidTapAgreement:(KBKeyboardSubscriptionView *)view; @end /// 键盘内的订阅弹层 diff --git a/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.m b/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.m index bc0540d..0efc650 100644 --- a/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.m +++ b/CustomKeyboard/View/Buy/KBKeyboardSubscriptionView.m @@ -192,7 +192,11 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) { } - (void)onTapAgreement { - [KBHUD showInfo:KBLocalized(@"Agreement coming soon")]; + if ([self.delegate respondsToSelector:@selector(subscriptionViewDidTapAgreement:)]) { + [self.delegate subscriptionViewDidTapAgreement:self]; + return; + } + [KBHUD showInfo:KBLocalized(@"Please open the App to view the agreement")]; } #pragma mark - Data diff --git a/Shared/KBConfig.h b/Shared/KBConfig.h index 4541a94..eff4270 100644 --- a/Shared/KBConfig.h +++ b/Shared/KBConfig.h @@ -71,6 +71,21 @@ #define KB_UL_SETTINGS KB_UL_BASE @"/settings" // 充值入口的通用链接:当前复用 /login 路径,通过 query 区分(避免额外配置 AASA 路径) #define KB_UL_RECHARGE KB_UL_BASE @"/recharge" +#define KB_UL_LEGAL KB_UL_BASE @"/legal" + +// 法律文档 URL。 +// 若未配置线上地址,主 App 会自动回退到内置 HTML 页面,避免出现空入口。 +#ifndef KB_TERMS_OF_SERVICE_URL +#define KB_TERMS_OF_SERVICE_URL @"" +#endif + +#ifndef KB_PRIVACY_POLICY_URL +#define KB_PRIVACY_POLICY_URL @"" +#endif + +#ifndef KB_MEMBERSHIP_AGREEMENT_URL +#define KB_MEMBERSHIP_AGREEMENT_URL @"" +#endif #endif /* KBConfig_h */ diff --git a/docs/app-store-submission-checklist-2026-03-07.md b/docs/app-store-submission-checklist-2026-03-07.md new file mode 100644 index 0000000..342ac0d --- /dev/null +++ b/docs/app-store-submission-checklist-2026-03-07.md @@ -0,0 +1,290 @@ +# KeyBoard 提审前清单 + +更新日期:2026-03-07 + +本文档基于两部分信息整理: + +1. 当前工程静态检查结果与最近几轮已修复项 +2. Apple 截至 2026-03-07 仍公开可查的官方要求 + +目标不是“理论合规”,而是尽量提高这次送审的一次通过率。 + +## 一、当前判断 + +项目本身不属于明显的违规型产品,主要风险集中在: + +1. 审核员是否能顺畅走完 `安装 App -> 启用键盘 -> 开启 Full Access -> 登录 -> 使用 AI -> 购买/恢复购买` +2. `RequestsOpenAccess = true` 的隐私披露、权限说明、App Privacy 申报、隐私政策正文是否完全一致 +3. AI / persona / 语音相关内容是否有足够明确的内容安全与举报处理机制 +4. 订阅、协议、隐私、元数据是否存在“能点但不清楚”或“描述过度”的情况 + +## 二、目前已完成 + +以下项已经处理,不再是当前最高风险: + +1. 协议、隐私政策、会员协议入口已经从空实现改为真实跳转/真实页面兜底 +2. `KBWebViewViewController` 的链接错误已经修复 +3. Web 页返回箭头被遮挡的问题已经修复 +4. `KBPayMainVC` 的 `restoreButton` 英文文案显示不全问题已经修复 +5. 重复的 `Shared` / `KBConfig` / `KBAuthManager` 历史副本已经收敛为顶层 [Shared](/Users/mac/Desktop/项目/公司/KeyBoard/Shared) + +注意:协议入口虽然已经打通,但如果你还没换成正式线上 URL,这一项只能算“代码层打通”,不算“提审完成”。 + +## 三、提审前必须完成 + +### 1. 替换正式协议 URL 和正式正文 + +必须完成: + +1. 在 [KBConfig.h](/Users/mac/Desktop/项目/公司/KeyBoard/Shared/KBConfig.h) 中填入正式线上地址: + - `KB_TERMS_OF_SERVICE_URL` + - `KB_PRIVACY_POLICY_URL` + - `KB_MEMBERSHIP_AGREEMENT_URL` +2. 确保线上页面不是占位页,不是空白页,不是“敬请期待” +3. 确保协议正文明确写清: + - 收集哪些数据 + - 是否上传键入文本 + - 是否上传语音 + - 上传目的 + - 是否用于模型训练 + - 数据保留时长 + - 如何删除账号与数据 +4. 确保 App 内文案、隐私政策、App Store Connect 的 App Privacy 三者完全一致 + +对应风险: + +1. `2.1 App Completeness` +2. `2.3.1 Accurate Metadata` +3. `5.1.1 Data Collection and Storage` + +### 2. 完成 App Store Connect 的 App Privacy 申报 + +必须完成: + +1. 逐项盘清主 App 与键盘扩展到底收集了什么 +2. 对以下类型重点核对是否需要申报: + - 账号信息 + - 用户内容 + - 音频数据 + - 诊断数据 + - 使用数据 + - 购买信息 +3. 明确区分: + - 仅设备本地处理 + - 会发往你方服务器 + - 会共享给第三方 SDK +4. 如果某项在键盘扩展里只在 Full Access 后发生,也要按“App 整体最全面数据实践”申报 + +重点核对问题: + +1. 开启 Full Access 后是否会发送键入内容到服务端 +2. 语音输入是否上传服务器做识别或生成 +3. 是否保存聊天内容、persona 对话、历史输入、账号数据 +4. 是否有埋点、诊断、崩溃、分析 SDK + +对应风险: + +1. `5.1.1` +2. App Privacy 填报不实导致的审核驳回 + +### 3. 写清楚 Review Notes + +这是你这类键盘 App 的高优先级项。 + +必须写进 Review Notes: + +1. 审核设备上如何启用键盘 +2. 如何在系统设置里打开 Full Access +3. 哪些功能在键盘扩展内可以直接完成 +4. 哪些功能会拉起主 App 完成 +5. 登录路径 +6. 购买路径 +7. 恢复购买路径 +8. 提供可直接使用的测试账号 +9. 如果 AI、语音、订阅依赖服务器,确认服务在审核期保持可用 + +建议写法要点: + +1. 用步骤写,不要写泛泛描述 +2. 写清楚“如果 reviewer 没开 Full Access,会看到什么” +3. 写清楚“为什么某些购买/登录操作会跳回主 App” + +对应风险: + +1. `2.1 App Completeness` +2. 审核员误判“功能不可用” + +### 4. 补齐 AI / persona / 语音的内容安全兜底 + +必须完成: + +1. 确认服务端存在内容审核或关键词/策略拦截 +2. 对以下类型有最基本的拦截策略: + - 成人/性暗示 + - 仇恨/歧视 + - 骚扰/威胁 + - 自残/极端危险内容 +3. App 内明确保留举报入口,并保证流程可用 +4. 如果存在用户生成的人设、头像、简介、评论,也要有举报与处理机制 + +建议补充: + +1. 在 AI 页面补一段简短说明,明确“内容由 AI 生成,可能不准确” +2. 在审核备注里说明你有内容审核、举报与处理机制 + +对应风险: + +1. `1.1 Objectionable Content` +2. `1.2 User-Generated Content` +3. `4.7` 涉及聊天/插件型软件时的过滤与举报要求 + +### 5. 订阅展示与订阅元数据核对完成 + +必须完成: + +1. 订阅页显示内容与 App Store Connect 配置一致 +2. 恢复购买入口可用 +3. 订阅页能访问隐私政策和 Terms / EULA +4. 审核员能明确知道购买后获得什么权益 +5. 如果有试用、周期、价格说明,必须准确 + +同时核对: + +1. App 描述、截图、预览里是否把付费能力说清楚 +2. 是否把本来受限于订阅的能力误写成所有用户都可用 + +对应风险: + +1. `3.1.1` +2. `3.1.2` +3. `2.3.2` + +### 6. 元数据降承诺,避免夸大 + +必须完成: + +1. 商店描述不要写成“任何 App 都可稳定使用全部 AI / 语音 / 登录 / 购买能力” +2. 明确第三方键盘天然限制: + - 安全输入框不可用 + - 某些系统场景不可用 + - 某些能力依赖 Full Access +3. 不要写“全场景可用”“所有输入框可用”“无需切换即可全部完成” + +对应风险: + +1. `2.3.1 Accurate Metadata` + +### 7. 真机完整走查一遍 + +必须在真机上完成,不建议只看模拟器: + +1. 首装 App +2. 注册 / 登录 +3. Apple 登录 +4. 账号注销 +5. 启用键盘 +6. 开启 Full Access +7. 键盘 AI 输入 +8. 语音输入 +9. 拉起主 App 登录 +10. 拉起主 App 购买 +11. 恢复购买 +12. 协议 / 隐私政策 / 会员协议打开 +13. 断网 / 弱网 / 未登录 / 未开 Full Access 的兜底提示 + +审核关注的是“能不能稳定用”,不是“你本地跑通过一次”。 + +## 四、建议在提审前也完成 + +### 1. 准备一套专门给审核员的测试路径 + +建议准备: + +1. 一个可登录测试账号 +2. 一组固定可复现的 persona / AI 演示输入 +3. 一条可稳定触发订阅购买页的路径 +4. 一条可稳定触发恢复购买的路径 + +### 2. 核对权限申请时机与文案 + +建议检查: + +1. 麦克风权限是否只在真正开始语音功能时再申请 +2. 权限文案是否说明真实用途 +3. 是否存在“先要权限,功能再解释”的情况 + +### 3. 做一次审核视角的异常测试 + +建议故意测试: + +1. 未登录直接进 AI +2. 未开 Full Access 直接进扩展高级功能 +3. 服务端异常 +4. IAP 商品加载失败 +5. 协议 URL 打不开 + +目标是让 reviewer 遇到异常时也能理解,不会直接判“不可用”。 + +## 五、提交物料清单 + +提审前建议你准备好以下材料: + +1. 最终版隐私政策 URL +2. 最终版 Terms / 会员协议 URL +3. App Store Connect 的 App Privacy 填报截图或内部核对表 +4. Review Notes +5. 测试账号 +6. 订阅商品配置清单 +7. 审核演示路径截图或内部 SOP + +## 六、建议的最终提审门槛 + +至少满足下面这些条件再点提交: + +1. 所有协议/隐私/会员协议入口都能打开正式内容 +2. App Privacy、隐私政策、权限文案、Review Notes 四者一致 +3. 审核员不用猜你产品流程,就能完成键盘启用、登录、AI、订阅体验 +4. AI / persona / 语音内容有明确的审核与举报机制 +5. 真机完整走查至少 1 遍,最好 2 台设备、2 个语言环境 + +## 七、你现在最值得优先做的 5 件事 + +按优先级排序: + +1. 填正式协议 URL,并把正文改成最终版 +2. 完成 App Store Connect 的 App Privacy 申报核对 +3. 写最终版 Review Notes,并准备测试账号 +4. 核实 AI / 语音 / persona 的内容安全与举报闭环 +5. 真机完整跑通 `启用键盘 -> Full Access -> 登录 -> AI -> 订阅 -> 恢复购买` + +## 八、官方参考 + +以下页面均为 Apple 官方来源,已于 2026-03-07 核对: + +1. App Review Guidelines + https://developer.apple.com/app-store/review/guidelines/ +2. Offering account deletion in your app + https://developer.apple.com/support/offering-account-deletion-in-your-app/ +3. Manage app privacy + https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy +4. App Privacy Details + https://developer.apple.com/app-store/app-privacy-details/ +5. Creating a custom keyboard / Custom Keyboard guidance + https://developer.apple.com/documentation/UIKit/creating-a-custom-keyboard + https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/CustomKeyboard.html + +## 九、备注 + +这份清单是“上架执行清单”,不是法律意见,也不是 Apple 的通过保证。 + +如果按当前工程状态直接提,风险仍主要集中在: + +1. 隐私披露不一致 +2. 审核员走不通键盘链路 +3. AI 内容安全证据不足 +4. 元数据对键盘能力描述过满 + +如果你要继续推进,下一步最值钱的是再补两份文档: + +1. `Review Notes` 最终版 +2. `App Privacy / 隐私政策披露对照表` diff --git a/docs/privacy-alignment-matrix-2026-03-08.md b/docs/privacy-alignment-matrix-2026-03-08.md new file mode 100644 index 0000000..e151c8e --- /dev/null +++ b/docs/privacy-alignment-matrix-2026-03-08.md @@ -0,0 +1,254 @@ +# KeyBoard 隐私披露对照表 + +更新日期:2026-03-08 + +## 1. 这份表是干什么的 + +这份表专门回答一件事: + +`App 内文案`、`隐私政策`、`App Store Connect 的 App Privacy` 应该如何对齐,避免三处口径打架。 + +你可以把它当成提审前的“隐私总对照表”。 + +## 2. Apple 当前口径 + +结合 Apple 截至 2026-03-08 的官方文档,当前需要特别记住 4 个点: + +1. 你需要申报你自己和第三方 SDK 从 App 中收集的数据。 +2. “收集”通常指数据离开设备,并且你或第三方可以以可读形式访问超出实时处理所需的时间。 +3. 免费文本框和语音录音通常对应: + - `Other User Content` + - `Audio Data` +4. 对第三方键盘来说,开启 `Full Access` 后如果会把键入内容或语音发到服务器,必须明确告诉用户,而且文案必须和实际行为一致。 + +官方来源: + +1. App Privacy Details + https://developer.apple.com/app-store/app-privacy-details/ +2. App Privacy(App Store Connect Help) + https://developer.apple.com/help/app-store-connect/reference/app-privacy +3. App Extension Programming Guide: Custom Keyboard + https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/CustomKeyboard.html + +## 3. 当前工程里已经看到的事实 + +以下结论来自当前代码,不是猜测: + +1. 主 App 和键盘扩展都声明了麦克风权限。 + - `keyBoard/Info.plist` + - `CustomKeyboard/Info.plist` +2. 键盘扩展开启了 `RequestsOpenAccess = true`。 + - `CustomKeyboard/Info.plist` +3. 主 App 存在语音录音,并把录音文件上传到服务端做转写。 + - `AiVM.m` 中有 `uploadFile:API_AI_SPEECH_TRANSCRIBE` +4. AI 文本内容会发到服务端。 + - `AiVM.m` + - `CustomKeyboard/VM/KBVM.m` +5. 存在账号体系,会保存 email、userId、token、用户资料。 + - `KBUserSessionManager` + - `KBAuthManager` + - `KBMyVM` +6. 存在头像上传。 + - `KBMyVM.m` +7. 存在购买记录 / 钱包流水 / 订阅恢复。 + - `KBMyVM.m` + - 支付相关 VC / StoreKit 桥接 +8. Release 下启用了 Bugly。 + - `AppDelegate.m` +9. 工程里有自定义埋点上报器 `KBMaiPointReporter`,默认 Release 关闭,但如果你通过宏配置线上地址,则会真正上传。 +10. 当前 [PrivacyInfo.xcprivacy](/Users/mac/Desktop/项目/公司/KeyBoard/keyBoard/PrivacyInfo.xcprivacy) 只声明了: + - `Email Address` + - `User ID` + - `Other User Content` + 这和当前代码能力相比,大概率不完整。 + +## 4. 对照原则 + +每一项数据,都用同一套问题去核对: + +1. 是否真的收集? +2. 是本地处理,还是上传服务端? +3. 上传只是实时处理,还是会保存? +4. 是否与账号关联? +5. 用途是功能、个性化、分析、诊断,还是别的? + +只要任意一项在三处说法不一致,就有审核风险。 + +## 5. 核心对照表 + +| 数据主题 | 当前代码证据 | App 内文案应该怎么说 | 隐私政策应该怎么写 | App Privacy 建议填写 | 当前状态 | +| --- | --- | --- | --- | --- | --- | +| 账号邮箱 | 登录 / 用户信息 / `PrivacyInfo.xcprivacy` 已声明 `EmailAddress` | 明确告诉用户邮箱用于注册、登录、找回账号、账号识别 | 写明会收集邮箱,用于账号认证、登录、客服、账号找回;是否保存、保存多久、如何删除 | `Email Address`;通常 `Linked = Yes`;`Purpose = App Functionality` | 基本已覆盖,但要核对隐私政策正文是否真的写了 | +| 用户 ID / 账号标识 | `KBUserSessionManager`、`KBAuthManager`、`PrivacyInfo.xcprivacy` 已声明 `UserID` | 告知用户登录后会生成并使用账号标识,以维持登录态和同步功能 | 写明会保存账号 ID / token / 账号资料,用于身份验证、同步和安全 | `User ID`;通常 `Linked = Yes`;`Purpose = App Functionality` | 基本已覆盖,但隐私政策和后台要保持一致 | +| AI 输入文本 / 聊天内容 | `AiVM requestChatMessageWithContent`;`KBVM sendChatMessageWithContent`;聊天记录接口 | 如果用户主动使用 AI、改写、聊天等网络功能,应明确提示“你发送的文本会被传输到服务器处理” | 写明:当用户主动触发 AI 功能时,输入文本、聊天内容和必要上下文可能被上传,用于生成回复、同步记录或安全处理;是否保存、保存多久、是否用于训练必须写清 | 至少评估 `Other User Content`;通常 `Linked = Yes`;`Purpose = App Functionality` | 当前 `PrivacyInfo.xcprivacy` 已有 `OtherUserContent`,但隐私政策正文和 App 内文案还需要更明确 | +| 键盘内输入文本 | 键盘扩展开启 `Full Access`,且键盘内 AI 文本请求会出网 | 不能只写“开启 Full Access 体验全部功能”;必须补一句“仅当你主动使用联网功能时,相关输入内容可能被发送到服务器处理” | 必须明确区分:普通本地输入 vs 用户主动触发的 AI / 联网功能;哪些情况下键入内容会离开设备;是否保留;是否和账号关联 | 若仅实时处理且不保留,按 Apple 口径可能不一定算“collect”;但只要会保留或超出实时处理,就按 `Other User Content` 申报。审核上建议按最保守口径写清 | 这是当前最敏感项,必须你和后端最终确认 | +| 语音录音 / 语音转文字 | `KBAIHomeVC` 录音后调用 `transcribeAudioFileAtURL:`;`AiVM` 上传 `audio/m4a` 到 `/speech/transcribe` | 麦克风权限文案不能只写“用于语音输入”,还应在功能说明里告诉用户“录音会上传到服务器进行识别/转写”,如果确实如此 | 必须写明是否上传语音文件、是否只用于转写、是否保存原始音频、保存多久、是否用于训练模型 | `Audio Data`;通常 `Linked = Yes`;`Purpose = App Functionality` | 当前代码层面已经能确认“主 App 语音录音会上传做转写”,但 `PrivacyInfo.xcprivacy` 还没体现 | +| AI 生成音频 / 音频 URL | `/chat/audio/{audioId}` 获取 AI 音频 URL | 可不单独强调为“用户数据”,但如果会和聊天记录一起保存,可在 AI 说明里写清 | 若服务端会保存 AI 语音内容,建议在隐私政策里说明与聊天内容的关系和保留策略 | 一般不单独作为新的用户数据类型填写,除非其中包含用户上传的语音原件或和用户行为绑定用于分析 | 需要结合服务端保留策略判断 | +| 头像上传 | `KBMyVM upLoadAvatarWithData:` 上传 `avatar.jpg` | 如果用户上传头像,界面需明确“头像将上传并用于个人资料展示” | 写明会收集用户上传的头像图片,用于个人资料展示与账号信息维护;写清删除方式 | 评估 `Photos or Videos`;通常 `Linked = Yes`;`Purpose = App Functionality` | 当前代码已存在上传;App Privacy 很可能漏填 | +| 昵称 / 基本资料 | `updateUserInfo` 会上传 `nickName`、`gender`、`avatarUrl` | 界面上应明确这些资料用于完善个人主页、账号展示或个性化体验 | 隐私政策写清用户资料字段、用途、是否公开展示、如何删除 | `Name` 可能需要评估;`gender` 需结合业务含义进一步评估是否属于普通资料还是敏感信息 | 这项需要你最终按真实展示逻辑确认 | +| 购买记录 / 钱包流水 / 订阅状态 | `fetchWalletTransactionsWithPage`、订阅页、恢复购买 | 订阅页和个人页要清楚说明购买后会记录订阅状态和消费记录 | 写明会处理订阅状态、订单/消费记录,用于开通权益、恢复购买、客服和对账 | 评估 `Purchase History`;通常 `Linked = Yes`;`Purpose = App Functionality` | 当前代码已有相关能力,App Privacy 大概率还没覆盖 | +| 反馈内容 | `submitFeedbackWithContent:` | 反馈页应说明“你提交的反馈内容将用于客服和问题处理” | 写明反馈内容仅用于客服、问题排查、产品支持,是否保存以及保留时长 | 可评估 `Customer Support`;若完全满足 Apple“可选披露”四条件,可不申报;不满足则申报 | 取决于你是否想走“可选披露”路径;为了稳,通常建议披露 | +| 举报内容 / 举报原因 / 聊天证据 | `reportCompanionWithCompanionId` 包含 `reportDesc`、`chatContext`、`evidenceImageUrl` | 举报页面应明确“举报内容和相关上下文将用于审核处理” | 写明会处理举报描述、聊天上下文、证据链接,用于安全审核与申诉处理 | 通常优先评估 `Customer Support` 或 `Other User Content`;如有用户上传图片,还要评估 `Photos or Videos` | 建议明确披露,不建议藏着不说 | +| 崩溃日志 / 诊断 | Release 启用 Bugly | App 内通常不用强提示,但隐私政策要写明使用第三方崩溃诊断服务 | 写明会收集崩溃和诊断信息,用于修复问题、提升稳定性;是否关联用户需按 SDK 实际行为确认 | 至少评估 `Crash Data`;必要时再评估 `Performance Data` / `Other Diagnostic Data` | 当前最需要你确认 Bugly 实际上传字段;如果保留 Release Bugly,不能忽略 | +| 埋点 / 用户交互数据 | `KBMaiPointReporter` 会上传 `eventName / page_id / element_id / token`;默认 Release 关闭,但可被宏开启 | 如果 Release 真的开启埋点,App 内至少要在隐私政策里说明会收集使用行为数据用于分析和优化 | 写明会收集页面曝光、点击、功能使用等行为数据;是否绑定账号、是否用于分析、是否与第三方共享 | 如 Release 开启,评估 `Product Interaction`;通常 `Linked = Yes`;`Purpose = Analytics`,必要时也可能 `App Functionality` | 当前代码默认 Release 关闭;若提审包不启用,可不按已收集算;若线上宏打开,就必须申报 | +| AppGroup / 本地缓存 / 本地文件 | `NSUserDefaults`、App Group、persona 图片、本地录音文件 | 本地缓存通常不需要单独对用户解释成“上传收集”,但可在隐私政策里说明用于本地功能和跨 App/扩展同步 | 若仅本地处理、不离开设备,通常不属于 App Privacy 的“Collected” | 通常不需要在 App Privacy 填“收集”,除非它们会再被上传或长期保存到服务端 | 这一类要和“真正上传到服务器的数据”区分清楚 | + +## 6. 当前最可能不一致的地方 + +按当前代码和现状,以下几项最容易出现“三处不一致”: + +### 6.1 语音数据 + +当前代码已经能看出: + +1. 主 App 会录音 +2. 录音文件会上传到 `/speech/transcribe` + +如果你现在: + +1. App 内只写“用于语音输入” +2. 隐私政策没写“上传服务器做识别” +3. App Privacy 没勾 `Audio Data` + +那就是明显不一致。 + +### 6.2 键盘 Full Access 后的输入内容 + +当前代码能看出: + +1. 键盘扩展启用了 `RequestsOpenAccess = true` +2. 键盘中的 AI 联网能力会把用户主动触发的文本请求发到服务端 + +如果你现在: + +1. App 内只写“开启 Full Access 体验全部功能” +2. 隐私政策没区分“何时会把输入发送到服务端” +3. Review Notes 也没写清 + +这会是审核重点风险。 + +### 6.3 头像图片 + +当前代码里头像上传已经存在。 + +如果你现在: + +1. 隐私政策没提用户上传图片 +2. App Privacy 没评估 `Photos or Videos` + +这也是不一致。 + +### 6.4 崩溃日志 / 诊断 + +当前 Release 有 Bugly。 + +如果你现在: + +1. 隐私政策没提崩溃诊断 +2. App Privacy 也没申报 `Crash Data` + +那至少需要复核,不能直接默认没收集。 + +### 6.5 购买记录 + +当前代码里存在: + +1. 订阅 +2. 恢复购买 +3. 钱包流水 / 消费记录查询 + +如果你现在: + +1. 会员协议只写订阅,不写记录和恢复购买相关处理 +2. App Privacy 没评估 `Purchase History` + +这项也不完整。 + +## 7. 你现在可以直接照着改的口径 + +下面不是最终法律文本,而是“先把口径统一”的写法模板。 + +### 7.1 App 内关于 Full Access 的文案建议 + +当前类似文案: + +`Turn on Allow Full Access to experience all features` + +建议补充为类似意思: + +`开启 Full Access 后,键盘可使用联网功能。仅当你主动使用 AI、账号、同步、订阅或语音等联网功能时,相关文本、语音或必要账号信息可能被发送到服务器处理。` + +### 7.2 App 内关于麦克风 / 语音的文案建议 + +当前权限文案: + +`需要使用麦克风进行语音输入` + +建议在功能页或权限前说明里再补一句: + +`录制的语音会上传到服务器进行识别/转写,用于把语音转换为文本。` + +如果你们实际不保存原始音频,再补一句: + +`原始音频仅用于完成本次识别请求,不作其他用途。` + +前提是这句话必须和真实后端行为一致。 + +### 7.3 隐私政策里 AI / 聊天 / 键盘文本的建议写法 + +建议至少写清 5 件事: + +1. 用户主动触发哪些功能时,文本会离开设备 +2. 是否会上传聊天内容和上下文 +3. 是否会保留聊天记录 +4. 是否用于模型训练 +5. 如何删除账号和相关数据 + +### 7.4 App Privacy 后台填写的建议思路 + +按当前代码,至少应重新复核这些数据类型: + +1. `Email Address` +2. `User ID` +3. `Other User Content` +4. `Audio Data` +5. `Photos or Videos` +6. `Purchase History` +7. `Crash Data` +8. `Customer Support` +9. `Product Interaction` + +其中: + +1. `Product Interaction` 取决于 Release 是否真正开启埋点 +2. `Crash Data` 取决于 Release Bugly 是否真实上传 +3. `Audio Data`、`Photos or Videos`、`Purchase History` 基本都值得重点复核 + +## 8. 当前我对你项目的直接判断 + +如果现在立刻去填 `App Privacy`,最危险的不是“完全没法填”,而是“容易少填或写得太轻”。 + +按当前代码,我更倾向于你至少要重点确认: + +1. `Audio Data` +2. `Other User Content` +3. `Photos or Videos` +4. `Purchase History` +5. `Crash Data` + +当前 [PrivacyInfo.xcprivacy](/Users/mac/Desktop/项目/公司/KeyBoard/keyBoard/PrivacyInfo.xcprivacy) 只写了 `Email / User ID / Other User Content`,明显不足以覆盖你现有功能边界。 + +## 9. 下一步建议 + +我建议你马上做两件事: + +1. 让产品 / 后端 / 你自己确认“语音、键盘文本、聊天内容、头像、崩溃日志、埋点”的真实保留策略 +2. 然后基于这张表去统一: + - App 内提示文案 + - 隐私政策正文 + - App Store Connect 的 App Privacy + +如果你愿意,我下一步可以继续直接帮你出两份可提交材料: + +1. 一版可直接粘到 Apple 后台的 `App Privacy 填写建议` +2. 一版可直接放到网页里的 `隐私政策补充段落` diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 7a27809..f32953e 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -209,6 +209,7 @@ 049FB23C2EC4766700FAB05D /* KBStreamOverlayView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB2392EC4766700FAB05D /* KBStreamOverlayView.m */; }; 049FB23F2EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB23E2EC4B6EF00FAB05D /* KBULBridgeNotification.m */; }; 049FB2402EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB23E2EC4B6EF00FAB05D /* KBULBridgeNotification.m */; }; + 72A1E8D12FB0101100804C36 /* KBWebViewViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 7276DDA72EC1B28300804C36 /* KBWebViewViewController.m */; }; 049FB2432EC4BBB700FAB05D /* KBLoginPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB2422EC4BBB700FAB05D /* KBLoginPopView.m */; }; 049FB31D2EC21BCD00FAB05D /* KBMyKeyboardCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB31C2EC21BCD00FAB05D /* KBMyKeyboardCell.m */; }; 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC97082EB31B14007BD342 /* KBHUD.m */; }; @@ -221,7 +222,6 @@ 04B5A1A22EEFA12300AAAAAA /* KBPayProductModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 04B5A1A12EEFA12300AAAAAA /* KBPayProductModel.m */; }; 04BBF89D2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04BBF89A2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m */; }; 04BBF89E2F3ACD8800B1FBB2 /* KBTestVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 04BBF89C2F3ACD8800B1FBB2 /* KBTestVC.m */; }; - 04BBF9002F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 04BBF8FF2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m */; }; 04C6EABA2EAF86530089C901 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 04C6EAAE2EAF86530089C901 /* Assets.xcassets */; }; 04C6EABC2EAF86530089C901 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 04C6EAB12EAF86530089C901 /* LaunchScreen.storyboard */; }; 04C6EABD2EAF86530089C901 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 04C6EAB42EAF86530089C901 /* Main.storyboard */; }; @@ -234,7 +234,6 @@ 04D1F6B22EDFF10A00B12345 /* KBSkinInstallBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = 04D1F6B12EDFF10A00B12345 /* KBSkinInstallBridge.m */; }; 04D1F6B32EDFF10A00B12345 /* KBSkinInstallBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = 04D1F6B12EDFF10A00B12345 /* KBSkinInstallBridge.m */; }; 04E0383E2F1A7C30002CA5A0 /* KBCustomTabBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0383D2F1A7C30002CA5A0 /* KBCustomTabBar.m */; }; - 04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */; }; 04E038EF2F21F0EC002CA5A0 /* AiVM.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E038EE2F21F0EC002CA5A0 /* AiVM.m */; }; 04E0394B2F236E75002CA5A0 /* KBChatUserMessageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E0394A2F236E75002CA5A0 /* KBChatUserMessageCell.m */; }; 04E0394C2F236E75002CA5A0 /* KBChatTimeCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E039482F236E75002CA5A0 /* KBChatTimeCell.m */; }; @@ -730,8 +729,6 @@ 04BBF89A2F3ACD8800B1FBB2 /* KBKeyboardStressTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBKeyboardStressTestVC.m; sourceTree = ""; }; 04BBF89B2F3ACD8800B1FBB2 /* KBTestVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBTestVC.h; sourceTree = ""; }; 04BBF89C2F3ACD8800B1FBB2 /* KBTestVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBTestVC.m; sourceTree = ""; }; - 04BBF8FE2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramWebSocketClient.h; sourceTree = ""; }; - 04BBF8FF2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramWebSocketClient.m; sourceTree = ""; }; 04C6EAAC2EAF86530089C901 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 04C6EAAD2EAF86530089C901 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 04C6EAAE2EAF86530089C901 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -754,8 +751,6 @@ 04D1F6B12EDFF10A00B12345 /* KBSkinInstallBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinInstallBridge.m; sourceTree = ""; }; 04E0383C2F1A7C30002CA5A0 /* KBCustomTabBar.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBCustomTabBar.h; sourceTree = ""; }; 04E0383D2F1A7C30002CA5A0 /* KBCustomTabBar.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBCustomTabBar.m; sourceTree = ""; }; - 04E038E42F20E877002CA5A0 /* DeepgramStreamingManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DeepgramStreamingManager.h; sourceTree = ""; }; - 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeepgramStreamingManager.m; sourceTree = ""; }; 04E038ED2F21F0EC002CA5A0 /* AiVM.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AiVM.h; sourceTree = ""; }; 04E038EE2F21F0EC002CA5A0 /* AiVM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AiVM.m; sourceTree = ""; }; 04E039422F236E75002CA5A0 /* KBChatAssistantMessageCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBChatAssistantMessageCell.h; sourceTree = ""; }; @@ -1302,10 +1297,6 @@ 046086A02F19239B00757C95 /* AudioCaptureManager.m */, 046086A12F19239B00757C95 /* AudioSessionManager.h */, 046086A22F19239B00757C95 /* AudioSessionManager.m */, - 04E038E42F20E877002CA5A0 /* DeepgramStreamingManager.h */, - 04E038E52F20E877002CA5A0 /* DeepgramStreamingManager.m */, - 04BBF8FE2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.h */, - 04BBF8FF2F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m */, 04E0B1002F300001002CA5A0 /* KBVoiceToTextManager.h */, 04E0B1012F300001002CA5A0 /* KBVoiceToTextManager.m */, 04E0B2002F300002002CA5A0 /* KBVoiceRecordManager.h */, @@ -2674,6 +2665,7 @@ 04C6EABE2EAF86530089C901 /* AppDelegate.m in Sources */, 04791FFF2ED830FA004E8522 /* KBKeyboardMaskView.m in Sources */, 0498BD652EE0116D006CC1D5 /* KBEmailLoginVC.m in Sources */, + 72A1E8D12FB0101100804C36 /* KBWebViewViewController.m in Sources */, 048908E92EBF843000FABA60 /* KBSkinDetailHeaderCell.m in Sources */, 04A9FE1B2EB892460020DB6D /* KBLocalizationManager.m in Sources */, 048908BC2EBE1FCB00FABA60 /* BaseViewController.m in Sources */, @@ -2690,7 +2682,6 @@ 0498BD712EE02A41006CC1D5 /* KBForgetPwdNewPwdVC.m in Sources */, 048908EF2EBF861800FABA60 /* KBSkinSectionTitleCell.m in Sources */, 0450AAE22EF03D5100B6AF06 /* KBPerson.swift in Sources */, - 04E038E92F20E877002CA5A0 /* DeepgramStreamingManager.m in Sources */, 04E0B1022F300001002CA5A0 /* KBVoiceToTextManager.m in Sources */, 04E0B2022F300002002CA5A0 /* KBVoiceRecordManager.m in Sources */, 048908E32EBF821700FABA60 /* KBSkinDetailVC.m in Sources */, @@ -2704,7 +2695,6 @@ 04122F7E2EC5FC5500EF7AB3 /* KBJfPayCell.m in Sources */, 048FFD502F2B52E7005D62AE /* AIReportVC.m in Sources */, 049FB2402EC4B6EF00FAB05D /* KBULBridgeNotification.m in Sources */, - 04BBF9002F3C97CB00B1FBB2 /* DeepgramWebSocketClient.m in Sources */, 04FC95C92EB1E4C9007BD342 /* BaseNavigationController.m in Sources */, 048908DD2EBF67EB00FABA60 /* KBSearchResultVC.m in Sources */, 05A1B2D12F5B1A2B3C4D5E60 /* KBSearchVM.m in Sources */, diff --git a/keyBoard.xcodeproj/xcshareddata/xcschemes/keyBoard.xcscheme b/keyBoard.xcodeproj/xcshareddata/xcschemes/keyBoard.xcscheme index 06a648d..a49a625 100644 --- a/keyBoard.xcodeproj/xcshareddata/xcschemes/keyBoard.xcscheme +++ b/keyBoard.xcodeproj/xcshareddata/xcschemes/keyBoard.xcscheme @@ -40,7 +40,7 @@ *params = [self kb_queryParametersFromURL:url]; + [self kb_presentLegalDocumentWithTypeValue:params[@"type"]]; + return YES; } return NO; } @@ -537,7 +557,14 @@ static NSTimeInterval const kKBSubscriptionPrefillTTL = 10 * 60.0; // [pop pop]; // }); - + +} + +- (void)kb_presentLegalDocumentWithTypeValue:(NSString *)typeValue { + NSNumber *typeNumber = [KBWebViewViewController legalDocumentTypeNumberFromQueryValue:typeValue ?: @""]; + KBLegalDocumentType type = typeNumber ? typeNumber.integerValue : KBLegalDocumentTypeTermsOfService; + KBWebViewViewController *vc = [KBWebViewViewController legalViewControllerWithType:type]; + [KB_CURRENT_NAV pushViewController:vc animated:true]; } - (void)kb_openAppSettings { diff --git a/keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md b/keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md index 2fbe69b..1adb647 100644 --- a/keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md +++ b/keyBoard/Class/AiTalk/V/KBVoiceInputBar_使用说明.md @@ -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 () +@interface YourViewController () @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`:语音聊天管理器 diff --git a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m index a84bc3d..ff571e7 100644 --- a/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m +++ b/keyBoard/Class/AiTalk/VC/KBAIHomeVC.m @@ -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; diff --git a/keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.h b/keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.h deleted file mode 100644 index 9d1f391..0000000 --- a/keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.h +++ /dev/null @@ -1,50 +0,0 @@ -// -// DeepgramStreamingManager.h -// keyBoard -// -// Created by Mac on 2026/1/21. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@protocol DeepgramStreamingManagerDelegate -@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 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 diff --git a/keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.m b/keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.m deleted file mode 100644 index e4991e5..0000000 --- a/keyBoard/Class/AiTalk/VM/DeepgramStreamingManager.m +++ /dev/null @@ -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 - -static NSString *const kDeepgramStreamingManagerErrorDomain = - @"DeepgramStreamingManager"; - -@interface DeepgramStreamingManager () - -@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 *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 *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 diff --git a/keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.h b/keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.h deleted file mode 100644 index 730ebca..0000000 --- a/keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.h +++ /dev/null @@ -1,52 +0,0 @@ -// -// DeepgramWebSocketClient.h -// keyBoard -// -// Created by Mac on 2026/1/21. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@protocol DeepgramWebSocketClientDelegate -@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 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 diff --git a/keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.m b/keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.m deleted file mode 100644 index fb400a8..0000000 --- a/keyBoard/Class/AiTalk/VM/DeepgramWebSocketClient.m +++ /dev/null @@ -1,411 +0,0 @@ -// -// DeepgramWebSocketClient.m -// keyBoard -// -// Created by Mac on 2026/1/21. -// - -#import "DeepgramWebSocketClient.h" - -static NSString *const kDeepgramWebSocketClientErrorDomain = - @"DeepgramWebSocketClient"; - -@interface DeepgramWebSocketClient () - -@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 *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 *)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 diff --git a/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.h b/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.h index 1ceb751..7961d3b 100644 --- a/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.h +++ b/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.h @@ -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 delegate; @property(nonatomic, weak, readonly) KBVoiceInputBar *inputBar; -@property(nonatomic, assign) BOOL deepgramEnabled; - (instancetype)initWithInputBar:(KBVoiceInputBar *)inputBar; -- (void)prepareConnection; -- (void)disconnect; @end diff --git a/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.m b/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.m index b963cab..1feaf68 100644 --- a/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.m +++ b/keyBoard/Class/AiTalk/VM/KBVoiceToTextManager.m @@ -6,118 +6,30 @@ // #import "KBVoiceToTextManager.h" -#import "DeepgramStreamingManager.h" #import "KBVoiceInputBar.h" #import "KBLocalizationManager.h" -@interface KBVoiceToTextManager () +@interface KBVoiceToTextManager () -@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 diff --git a/keyBoard/Class/Login/VC/KBEmailLoginVC.m b/keyBoard/Class/Login/VC/KBEmailLoginVC.m index 2c84b67..161f1b1 100644 --- a/keyBoard/Class/Login/VC/KBEmailLoginVC.m +++ b/keyBoard/Class/Login/VC/KBEmailLoginVC.m @@ -11,6 +11,7 @@ #import "KBLoginVM.h" #import "AppDelegate.h" #import "BaseTabBarController.h" +#import "KBWebViewViewController.h" @interface KBEmailLoginVC () // 背景与顶部装饰 @@ -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]; } } diff --git a/keyBoard/Class/Login/VC/KBEmailRegistVC.m b/keyBoard/Class/Login/VC/KBEmailRegistVC.m index 839cc36..94258e3 100644 --- a/keyBoard/Class/Login/VC/KBEmailRegistVC.m +++ b/keyBoard/Class/Login/VC/KBEmailRegistVC.m @@ -11,6 +11,7 @@ #import "KBLoginVM.h" #import "AppDelegate.h" #import "KBRegistVerEmailVC.h" +#import "KBWebViewViewController.h" @interface KBEmailRegistVC () @@ -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]; } } diff --git a/keyBoard/Class/Login/VC/KBLoginVC.m b/keyBoard/Class/Login/VC/KBLoginVC.m index 78641bf..eef94c9 100644 --- a/keyBoard/Class/Login/VC/KBLoginVC.m +++ b/keyBoard/Class/Login/VC/KBLoginVC.m @@ -12,6 +12,7 @@ #import "KBEmailRegistVC.h" #import "KBEmailLoginVC.h" #import "KBForgetPwdVC.h" +#import "KBWebViewViewController.h" @interface KBLoginVC () @@ -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]; } } diff --git a/keyBoard/Class/Me/VC/MyVC.m b/keyBoard/Class/Me/VC/MyVC.m index 0791244..8e722a6 100644 --- a/keyBoard/Class/Me/VC/MyVC.m +++ b/keyBoard/Class/Me/VC/MyVC.m @@ -18,6 +18,7 @@ #import "KBMyVM.h" #import "KBConsumptionRecordVC.h" #import "KBHUD.h" +#import "KBWebViewViewController.h" @interface MyVC () @@ -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]; diff --git a/keyBoard/Class/Pay/VC/KBJfPay.m b/keyBoard/Class/Pay/VC/KBJfPay.m index 5c73616..d7c49dc 100644 --- a/keyBoard/Class/Pay/VC/KBJfPay.m +++ b/keyBoard/Class/Pay/VC/KBJfPay.m @@ -10,6 +10,7 @@ #import "KBShopVM.h" #import "IAPVerifyTransactionObj.h" #import "keyBoard-Swift.h" +#import "KBWebViewViewController.h" static NSString * const kKBJfPayCellId = @"kKBJfPayCellId"; @interface KBJfPay () @@ -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 diff --git a/keyBoard/Class/Pay/VC/KBPayMainVC.m b/keyBoard/Class/Pay/VC/KBPayMainVC.m index 3ac0c06..0eb3d98 100644 --- a/keyBoard/Class/Pay/VC/KBPayMainVC.m +++ b/keyBoard/Class/Pay/VC/KBPayMainVC.m @@ -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; diff --git a/keyBoard/Class/Shared/KBAuthManager.h b/keyBoard/Class/Shared/KBAuthManager.h deleted file mode 100644 index b43bf2b..0000000 --- a/keyBoard/Class/Shared/KBAuthManager.h +++ /dev/null @@ -1,56 +0,0 @@ -// -// KBAuthManager.h -// 主 App 与键盘扩展共享使用 -// -// 通过 Keychain Sharing 统一管理用户登录态(access/refresh token)。 -// 线程安全;在保存/清空时同时发送进程内通知与 Darwin 跨进程通知, -// 以便键盘扩展正运行在其他 App 时也能及时感知变更。 -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// Darwin 跨进程通知名称:当令牌更新或清除时发送,用于提示 App/扩展刷新缓存。 -extern NSString * const kKBDarwinAuthChanged; - -/// 进程内通知(NSNotificationCenter):令牌更新或清除时发送。 -extern NSNotificationName const KBAuthChangedNotification; - -/// 简单的会话容器;可按需扩展字段。 -@interface KBAuthSession : NSObject -@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 *)authorizationHeader; - -@end - -NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/Shared/KBAuthManager.m b/keyBoard/Class/Shared/KBAuthManager.m deleted file mode 100644 index fcb3177..0000000 --- a/keyBoard/Class/Shared/KBAuthManager.m +++ /dev/null @@ -1,211 +0,0 @@ -// -// KBAuthManager.m -// -// 关键点: -// - 使用固定的 service/account 将 KBAuthSession 序列化后保存到钥匙串; -// - 通过 kSecAttrAccessGroup 指定 Keychain Sharing 访问组,实现 App 与扩展共享; -// - 保存/清除时发送 Darwin 跨进程通知,便于对端刷新缓存; -// - -#import "KBAuthManager.h" -#import -#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 *)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 diff --git a/keyBoard/Class/Shared/KBConfig.h b/keyBoard/Class/Shared/KBConfig.h deleted file mode 100644 index 520225f..0000000 --- a/keyBoard/Class/Shared/KBConfig.h +++ /dev/null @@ -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 */ diff --git a/keyBoard/Class/WebView/KBWebViewViewController.h b/keyBoard/Class/WebView/KBWebViewViewController.h index 4d74711..68e1b6c 100644 --- a/keyBoard/Class/WebView/KBWebViewViewController.h +++ b/keyBoard/Class/WebView/KBWebViewViewController.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 diff --git a/keyBoard/Class/WebView/KBWebViewViewController.m b/keyBoard/Class/WebView/KBWebViewViewController.m index d93e321..c086cde 100644 --- a/keyBoard/Class/WebView/KBWebViewViewController.m +++ b/keyBoard/Class/WebView/KBWebViewViewController.m @@ -7,6 +7,8 @@ #import "KBWebViewViewController.h" #import +#import "KBConfig.h" +#import "Masonry.h" @interface KBWebViewViewController () @@ -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 = @"

Overview

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.

Full Access

Network-based features inside the keyboard require Full Access. If you do not enable Full Access, those features stay unavailable.

Data Used For Features You Trigger

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.

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.

Keyboard Boundaries

The custom keyboard does not operate in secure text fields and cannot access content in contexts where iOS blocks third-party keyboards.

Retention And Deletion

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.

Important

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.

"; + break; + case KBLegalDocumentTypeMembershipAgreement: + body = @"

Subscription Terms

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.

Auto-Renewal

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.

Managing Your Subscription

You can restore purchases inside the app and manage or cancel subscriptions in Apple ID subscription settings after purchase.

Feature Availability

Some premium actions started from the custom keyboard may open the main app to complete login, purchase, or subscription management.

Important

Replace this fallback page with your final published membership agreement URL before App Store submission.

"; + break; + case KBLegalDocumentTypeTermsOfService: + default: + body = @"

Service Scope

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.

Acceptable Use

You must not use the service to violate law, harass others, infringe rights, or generate abusive, sexual, hateful, or otherwise prohibited content.

AI And Voice Features

AI-generated or transcribed content may be inaccurate, incomplete, or inappropriate. You remain responsible for reviewing content before sending or relying on it.

Accounts And Purchases

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.

Important

Replace this fallback page with your final published terms URL before App Store submission.

"; + break; + } + + return [NSString stringWithFormat:@"

%@

%@

Built-in fallback document. Configure the final public URL in KBConfig.h when release content is ready.

", title, body]; +} + ++ (NSString *)kb_fallbackErrorHTML { + return @"

Page unavailable

The requested document could not be loaded.

"; +} + #pragma mark - Clean up 🟢 - (void)dealloc { diff --git a/keyBoard/Info.plist b/keyBoard/Info.plist index 7074000..254abed 100644 --- a/keyBoard/Info.plist +++ b/keyBoard/Info.plist @@ -21,7 +21,7 @@
NSMicrophoneUsageDescription - 需要使用麦克风进行语音输入 + Microphone access is required for voice input and speech transcription. UIDesignRequiresCompatibility diff --git a/keyBoard/Shared/KBAuthManager.h b/keyBoard/Shared/KBAuthManager.h deleted file mode 100644 index b43bf2b..0000000 --- a/keyBoard/Shared/KBAuthManager.h +++ /dev/null @@ -1,56 +0,0 @@ -// -// KBAuthManager.h -// 主 App 与键盘扩展共享使用 -// -// 通过 Keychain Sharing 统一管理用户登录态(access/refresh token)。 -// 线程安全;在保存/清空时同时发送进程内通知与 Darwin 跨进程通知, -// 以便键盘扩展正运行在其他 App 时也能及时感知变更。 -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// Darwin 跨进程通知名称:当令牌更新或清除时发送,用于提示 App/扩展刷新缓存。 -extern NSString * const kKBDarwinAuthChanged; - -/// 进程内通知(NSNotificationCenter):令牌更新或清除时发送。 -extern NSNotificationName const KBAuthChangedNotification; - -/// 简单的会话容器;可按需扩展字段。 -@interface KBAuthSession : NSObject -@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 *)authorizationHeader; - -@end - -NS_ASSUME_NONNULL_END diff --git a/keyBoard/Shared/KBAuthManager.m b/keyBoard/Shared/KBAuthManager.m deleted file mode 100644 index fcb3177..0000000 --- a/keyBoard/Shared/KBAuthManager.m +++ /dev/null @@ -1,211 +0,0 @@ -// -// KBAuthManager.m -// -// 关键点: -// - 使用固定的 service/account 将 KBAuthSession 序列化后保存到钥匙串; -// - 通过 kSecAttrAccessGroup 指定 Keychain Sharing 访问组,实现 App 与扩展共享; -// - 保存/清除时发送 Darwin 跨进程通知,便于对端刷新缓存; -// - -#import "KBAuthManager.h" -#import -#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 *)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 diff --git a/keyBoard/Shared/KBConfig.h b/keyBoard/Shared/KBConfig.h deleted file mode 100644 index 520225f..0000000 --- a/keyBoard/Shared/KBConfig.h +++ /dev/null @@ -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 */