514 Commits

Author SHA1 Message Date
47291934a2 应用的皮肤不能删除 2026-02-28 21:23:38 +08:00
e619f48f93 移除主App后台音频声明负责审查风险 2026-02-28 18:21:36 +08:00
f55a70681c 处理KBFunctionTagCell正在执行又可以点击别的 2026-02-28 16:03:05 +08:00
cb86f7c32c 处理header 2026-02-28 15:38:12 +08:00
40ef964b8c 添加注销账号 2026-02-28 14:50:27 +08:00
4269fde923 1 2026-02-27 16:28:15 +08:00
c3e037e070 添加隐私,注销功能 2026-02-27 14:49:46 +08:00
a711be4c4d 跨进程 键盘用ai 在主应用里也要显示 2026-02-26 21:47:22 +08:00
69bd2b2af9 1:修改ios26tabbar的问题
2:修改键盘AI点击必须要登录装填
2026-02-26 19:38:17 +08:00
82222afd76 修复 KBChatPanel 发送内容校验 2026-02-25 20:16:31 +08:00
92ca5c6180 Fix KBAICommentInputView弹出位置 2026-02-25 17:13:25 +08:00
851c0d9531 去除假的用户信息 2026-02-25 11:15:04 +08:00
1c9013bede 新增获取客服接口 2026-02-24 20:45:15 +08:00
0a16a4f240 修改KBKeyboardPanelModeFunction 必须要登录状态 2026-02-24 18:04:13 +08:00
27d4b2b817 添加hud容错处理 2026-02-24 16:23:57 +08:00
bc623676ca 修改在手机信息页面,复制短信后,键盘按钮不存在, 背景也不存在 2026-02-24 15:24:23 +08:00
5edf1751ff 修改sign。
键盘里ai回复的bug
2026-02-24 14:59:06 +08:00
0ac47925fd 先提交 2026-02-24 13:38:51 +08:00
635ad932c7 修改vip 2026-02-12 20:06:44 +08:00
cbe0a53cac 删除无用的国家化 2026-02-11 21:16:15 +08:00
5c273c3963 修改bug 2026-02-11 21:09:37 +08:00
c9743cb363 处理第一次不滑动界面不传递数据的问题 2026-02-11 20:52:56 +08:00
f0cb69948e bug修复 2026-02-11 19:40:41 +08:00
0144f9cc6d 1 2026-02-11 19:31:12 +08:00
ae4070ae88 1 2026-02-11 19:18:26 +08:00
a83fd918a8 删除无关代码 2026-02-11 18:58:30 +08:00
4168da618e 1 2026-02-11 18:29:00 +08:00
d2ffada83f 添加KeyboardViewControllerHelp文件夹 2026-02-10 19:41:32 +08:00
76d387e08b 修改UI遮挡后面爱心和评论的问题 2026-02-10 19:15:05 +08:00
ea0df4fb19 修改UI 2026-02-10 19:12:09 +08:00
02323fb5f1 处理键盘闪的问题 2026-02-10 18:53:31 +08:00
3c71797b7b 处理键盘崩溃 2026-02-10 13:22:19 +08:00
4c57f16058 1 2026-02-10 10:21:21 +08:00
cb2e8467a7 处理UI 2026-02-09 19:31:47 +08:00
4dfd6f5cbb 处理UI 2026-02-09 16:53:30 +08:00
e4223b3a4c 处理国际化 2026-02-09 16:30:00 +08:00
3d19403539 修改bug 2026-02-09 14:24:31 +08:00
3cb02d5b76 处理键盘ai功能 2026-02-05 16:01:21 +08:00
750b391100 处理键盘语音svip权限弹窗,跳转主app充值 2026-02-05 14:10:24 +08:00
faccf6f10f 1 2026-02-04 23:40:06 +08:00
35b1fc0f1e 处理vip逻辑 2026-02-04 21:49:28 +08:00
b73f225d15 语音vip限制ai弹窗 2026-02-04 20:23:20 +08:00
dd59094a16 处理了评论新增评论,在聊天里的评论数也一同改变 2026-02-04 19:31:30 +08:00
bacaf537f3 调用删除接口 2026-02-04 19:01:58 +08:00
619d356d31 新增接口 2026-02-04 18:45:57 +08:00
db9f07d199 优化长按聊天记录 如果是自己 不要显示举报 2026-02-04 18:39:25 +08:00
3ed120106e 处理二级评论回复用户名称显示问题 2026-02-04 18:29:12 +08:00
ff4edab820 1 2026-02-04 18:10:39 +08:00
3e30f619b9 新增删除弹窗,修改bug 2026-02-04 16:57:19 +08:00
533e23ebfe 1 2026-02-04 16:03:39 +08:00
85fb407717 处理支付 2026-02-04 15:57:22 +08:00
c1b50b407d 2 2026-02-04 15:40:45 +08:00
7c7e2477cb 1 2026-02-04 15:20:53 +08:00
faae0297cb 1 2026-02-04 15:09:03 +08:00
e50eaecbd9 1 2026-02-04 14:59:02 +08:00
879dbb860c 2 2026-02-04 13:14:26 +08:00
b4e4b7b606 处理svip 2026-02-04 12:48:18 +08:00
68a610e0a8 处理kbpayvip 2026-02-04 12:33:01 +08:00
305326aa9a 添加placehold颜色 2026-02-03 20:51:48 +08:00
61095a379f 处理网络 2026-02-03 20:22:28 +08:00
822a814f85 处理搜索bug 2026-02-03 18:03:21 +08:00
0bd0392191 1 2026-02-03 17:00:42 +08:00
b9663037f5 添加侧边栏 2026-02-03 16:54:38 +08:00
a0923c8572 添加消息长按弹窗 2026-02-03 15:53:07 +08:00
d482cfcb7d 处理滚动 2026-02-03 15:01:08 +08:00
9e6d2906f8 修改KBChatUserMessageCell文字居中问题 2026-02-03 14:40:39 +08:00
6f7bb4f960 键盘的背景图添加高斯模糊 2026-02-03 14:22:44 +08:00
fa9af5ff1b 处理聊天信息没回来 切刀下一个collectionviewcell ,信息不对的问题(禁用滚动) 2026-02-03 14:05:29 +08:00
08628bcd1d aihome里统一发布消息控件 2026-02-03 13:31:52 +08:00
19cb29616f 优化发送输入框 2026-02-02 21:25:28 +08:00
6e50cdcd2a 1 2026-02-02 20:36:38 +08:00
f1b52151be 添加音频动画 2026-02-02 19:49:56 +08:00
993ec623af 1 2026-02-02 19:07:00 +08:00
0416a64235 处理为识别到语音 2026-02-02 17:41:23 +08:00
2b75ad90fb 修改举报和UI 2026-02-02 17:07:46 +08:00
0ac9030f80 1 2026-02-02 15:28:00 +08:00
ea9c40f64f 处理语音在B界面删除历史记录有声音问题 2026-02-02 15:27:55 +08:00
48c90fa0be 1 2026-02-02 14:29:42 +08:00
fe59a0cb45 1 2026-02-02 13:26:38 +08:00
81bc50ce17 1 2026-01-31 23:17:58 +08:00
6ae504823b tableview倒置 2026-01-31 22:40:50 +08:00
d2f582b7f8 处理崩溃 2026-01-30 21:38:58 +08:00
cc82396195 基本ok 2026-01-30 21:24:17 +08:00
2ff8a7a4af 处理键盘bug 2026-01-30 13:46:08 +08:00
3c0b7e754c 1 2026-01-30 13:33:23 +08:00
3705db4aab 简单封装 2026-01-30 13:26:02 +08:00
36774a8a2c 处理键盘 2026-01-30 13:17:11 +08:00
36135313d8 处理语音 2026-01-29 20:56:24 +08:00
23c0d14128 处理键盘部分 2026-01-29 19:18:38 +08:00
d0c5cada35 发送文本处理ui和逻辑 2026-01-29 17:56:53 +08:00
b556e6841d 添加举报 2026-01-29 16:42:43 +08:00
26096abbcc 添加个人主页 2026-01-29 16:03:21 +08:00
766c62f3c0 1 2026-01-29 15:53:26 +08:00
07a77149fc 处理评论数 2026-01-29 14:51:42 +08:00
32ebc6fb65 处理滚动底部问题 2026-01-29 14:42:49 +08:00
25fbe9b64e 分类移动文件 2026-01-29 13:44:52 +08:00
4392296616 修复聊天最后一条跑到最上面的问题 2026-01-29 13:13:42 +08:00
ef52cd4872 1 2026-01-28 20:18:18 +08:00
70a8466d9f 1 2026-01-28 19:31:27 +08:00
66d85f78a0 1 2026-01-28 18:58:30 +08:00
93a20cd92a 2 2026-01-28 18:15:58 +08:00
9a54a2ae6c 1 2026-01-28 18:11:46 +08:00
1b9ce1622d 修改动画 2026-01-28 17:21:19 +08:00
b4db79eba8 2 2026-01-28 16:35:47 +08:00
22f77d56ea 隐藏无更多消息文案 2026-01-28 15:32:56 +08:00
d8d5bdc3ae 1 2026-01-28 13:55:11 +08:00
7d583ceb1d 1 2026-01-28 13:43:36 +08:00
51b744ecd7 1 2026-01-28 12:04:31 +08:00
3fd7d2af2e 1 2026-01-27 21:32:52 +08:00
db869552e4 1 2026-01-27 18:53:19 +08:00
b34de116a3 修改布局 2026-01-27 17:49:45 +08:00
e67bc37571 修改bug 2026-01-27 17:03:16 +08:00
2b749cd2b0 1 2026-01-27 16:28:17 +08:00
ce889e1ed0 1 2026-01-27 13:57:32 +08:00
e8b4b2c58a 2 2026-01-26 20:36:51 +08:00
3a5a6395af 1 2026-01-26 18:51:37 +08:00
a22599feda 1 2026-01-26 18:43:07 +08:00
6a177ceebc 新增聊天记录 2026-01-26 18:17:02 +08:00
f9d7579536 新增接口,界面 2026-01-26 16:53:41 +08:00
0fa31418f6 1 2026-01-26 13:51:38 +08:00
77fd46aa34 1 2026-01-23 21:51:37 +08:00
6ad9783bcb 1 2026-01-22 22:03:56 +08:00
edc25c159d 1 2026-01-22 13:47:34 +08:00
06a572c08a 1 2026-01-21 17:59:12 +08:00
36c0b0b210 1 2026-01-21 17:25:38 +08:00
d1d47336c2 处理tabbar 2026-01-19 19:14:20 +08:00
063ceae10f 添加渐变色 2026-01-16 21:37:18 +08:00
552387293c 修改BUG 2026-01-16 20:44:10 +08:00
93489b09d9 1 2026-01-16 20:31:42 +08:00
663cb8493b 处理发送评论 2026-01-16 19:36:41 +08:00
ac0d9584d8 添加二级评论多个的时候的逻辑,默认每次点击出现5条 2026-01-16 19:29:42 +08:00
7fa124d45f 处理一级评论cell头像闪的问题 2026-01-16 19:13:26 +08:00
3dfb8f31e2 处理好评论了 2026-01-16 19:09:54 +08:00
619c02f236 1 2026-01-16 17:41:03 +08:00
28852a8d4b 添加评论 2026-01-16 15:55:08 +08:00
b021fd308f 添加语音websocket等,还没测试 2026-01-16 13:38:03 +08:00
169a1929d7 修改UI 2026-01-15 20:30:03 +08:00
b5da9f35a5 4 2026-01-15 19:14:34 +08:00
8f4deaac4e 删除录音相关文件 2026-01-15 19:00:25 +08:00
d479d1903b 2 2026-01-15 18:49:31 +08:00
32c4138ae0 1 2026-01-15 18:16:56 +08:00
da62d4f411 添加发送ai文本埋点 2026-01-13 17:37:34 +08:00
85dcd72a5d 删除测试精灵3d相关代码
修改上报数据
2026-01-13 16:55:24 +08:00
21fcbe3665 修改暗黑模式功能UI 2026-01-09 19:35:36 +08:00
1b6724f043 1 2026-01-09 19:13:35 +08:00
ef332ecaa1 默认皮肤适配暗黑模式 2026-01-09 13:07:11 +08:00
3d6d673c0b 添加注释 2026-01-09 12:56:53 +08:00
674f09d5b6 处理按钮之间点不到的问题 HitInside 2026-01-08 20:18:37 +08:00
11d8f78b1b 2 2026-01-08 20:04:23 +08:00
bbacef4ff7 1 2026-01-08 18:57:17 +08:00
8e692647d3 2 2026-01-08 17:23:22 +08:00
6f80f969a4 1 2026-01-08 16:54:38 +08:00
bdf2a9af80 修改人设长按排序后,键盘人设要同步问题 2026-01-07 19:05:52 +08:00
e858d35722 修改首页pop对钩问题 2026-01-07 15:45:01 +08:00
f2d5210313 1 2026-01-07 14:32:57 +08:00
1b0af3e2d6 修改颜色 2026-01-07 14:32:49 +08:00
0965cd3c7e 修改了横屏键盘不居中为题 2026-01-07 13:11:23 +08:00
c3909d63da 添加埋点 2026-01-06 19:25:34 +08:00
1096f24c57 修改hud 2025-12-31 17:32:39 +08:00
7ed84fd445 修改分享方式 2025-12-30 20:41:30 +08:00
4e2d7d2908 添加部分通用上报
修改bug 未登录在键盘点击充值要先去跳转登录
2025-12-30 15:27:35 +08:00
34089ddeea 1 2025-12-26 15:51:27 +08:00
6ec98468de Merge branch 'dev_处理立刻清空和撤销删除' into dev_st
# Conflicts:
#	CustomKeyboard/Utils/KBBackspaceLongPressHandler.m
解决冲突
2025-12-26 15:08:14 +08:00
2d5919016f 测试上报 接口报错 2025-12-26 15:02:48 +08:00
c0fa51bb2e 修改用户名UI 2025-12-26 14:48:24 +08:00
6713f36387 修改国际化 2025-12-26 14:38:29 +08:00
f24750458a 修改立刻清空按钮 2025-12-26 14:19:39 +08:00
510a2f4d66 添加邀请链接 2025-12-26 14:07:21 +08:00
ae37730da6 修改 我已经退出界面,然后从新进入界面弹起键盘,为什么撤销删除按钮显示? 2025-12-26 13:55:07 +08:00
203f104ece 修改键盘长按立即清空和撤销删除 2025-12-26 11:47:44 +08:00
8e934dd83a 1 2025-12-25 17:58:33 +08:00
1676916a5c 1 2025-12-25 17:32:34 +08:00
1af5a0e849 添加部分埋点 2025-12-25 17:20:24 +08:00
5b6e0a8fbf 修改key 2025-12-25 13:07:28 +08:00
9968883bab 修改邀请 2025-12-25 13:01:14 +08:00
af5f637d31 1 2025-12-24 20:17:37 +08:00
0a725e845e 处理详情tag的背景色 2025-12-23 20:56:00 +08:00
6a539dc3c5 处理键盘长按删除 撤销出现的bug 2025-12-23 18:05:01 +08:00
73d6ec933a 修改金币超出问题 2025-12-23 15:33:14 +08:00
000d603241 优化下载皮肤❤️ 2025-12-23 15:26:32 +08:00
fbf9fe9f2a 2 2025-12-23 14:37:11 +08:00
8e4d7e1ee8 处理按钮 2025-12-23 14:15:27 +08:00
262eb57b36 修改接口 2025-12-23 14:07:53 +08:00
2e1c261775 修改UI 2025-12-23 14:02:44 +08:00
6ad2079351 修改emoji 更换api 2025-12-23 13:27:25 +08:00
a477592f5d 新增按钮 2025-12-22 21:16:38 +08:00
6f336e8368 国际化 2025-12-22 20:53:24 +08:00
17e038beb1 修改接口 2025-12-22 19:22:12 +08:00
4e6fd90668 1 2025-12-22 19:19:28 +08:00
5cfc76e6c5 修改我的皮肤逻辑 2025-12-22 16:42:56 +08:00
9e33c93763 修改弹窗 2025-12-22 15:49:28 +08:00
1c9ae7bc06 1 2025-12-22 15:37:22 +08:00
472e9ad341 修改联想背景色 2025-12-22 14:02:43 +08:00
19c69f4f6f Merge branch 'dev_联想' into dev_st 2025-12-22 13:47:23 +08:00
8788cbb105 处理UI 2025-12-22 13:46:45 +08:00
ea77e9a5f8 处理苹果bug 默认键盘颜色改为 2025-12-22 13:29:00 +08:00
eaaf0e1ed6 修改UI 2025-12-22 13:08:59 +08:00
8a344b293d 添加联想 2025-12-22 12:54:28 +08:00
9cdd024ce2 修改UI 2025-12-19 22:00:52 +08:00
d612346db5 删除立即充值按钮 2025-12-19 21:36:11 +08:00
2cacaab974 更换AI皮肤逻辑图片 + 更换placeholdimage 2025-12-19 21:29:11 +08:00
200b1ab9f8 修复键盘功能键 发送按钮距离底部很远 2025-12-19 20:40:53 +08:00
8d0939cd78 1 2025-12-19 20:25:22 +08:00
df51620ca9 Emoji 默认选中第一个笑脸 2025-12-19 20:13:57 +08:00
70520fb7d9 AI键移除 放在顶部KBToolBar 2025-12-19 20:08:13 +08:00
7587fe6714 KBFunctionView 长按删除不弹出立刻清空按钮 2025-12-19 19:56:39 +08:00
1c8834caf6 键盘添加撤销操作 2025-12-19 19:21:08 +08:00
68306aa07f 1 2025-12-19 18:58:29 +08:00
9544ad75ff 重构键盘长按删除 2025-12-19 18:45:14 +08:00
d90e080981 修改立刻清空出现延迟时间 2025-12-19 18:14:28 +08:00
fc65052583 1 2025-12-19 18:11:09 +08:00
e0d5ae0257 新增键盘删除长按删除 2025-12-19 18:08:51 +08:00
639ce7eafd 1 2025-12-19 17:06:01 +08:00
e0379d3717 添加长按多文字删除 2025-12-19 16:24:47 +08:00
c108077178 刷新详情头部 2025-12-19 15:40:39 +08:00
182e5b9da1 未登录处理 2025-12-19 13:34:58 +08:00
ea4ecc05b4 添加右滑返回 2025-12-18 14:57:28 +08:00
ae05127292 1 2025-12-18 13:46:14 +08:00
38a3d2879e 优化未登录 键盘点击充值去登录 2025-12-18 13:20:49 +08:00
10ba4cd80f 添加反馈接口 2025-12-17 20:52:23 +08:00
857822c49c 2 2025-12-17 20:43:16 +08:00
85012eab78 2 2025-12-17 20:37:53 +08:00
8aa43d723a 1 2025-12-17 20:33:14 +08:00
1ecb7d60e5 新增搜索 2025-12-17 20:30:01 +08:00
904a6c932a 1 2025-12-17 19:56:22 +08:00
886de394d0 1 2025-12-17 19:45:39 +08:00
8bad475288 1 2025-12-17 19:08:44 +08:00
4a26419e67 1 2025-12-17 18:22:37 +08:00
1e04e7c39a 2 2025-12-17 17:01:49 +08:00
dde8716262 添加键盘里的充值界面 2025-12-17 16:22:41 +08:00
c6b4444589 处理网络请求token为空的问题 2025-12-17 15:39:45 +08:00
5bd20a911f 修改UI 2025-12-16 21:43:00 +08:00
b43567748c 1 2025-12-16 20:20:57 +08:00
59297eac77 重构 2025-12-16 20:05:33 +08:00
9f7d805a52 修改时间 2025-12-16 19:04:21 +08:00
6800864866 新增恢复购买 2025-12-16 16:25:50 +08:00
e8a980ff5b 2 2025-12-16 16:09:14 +08:00
dfbd5efe69 2 2025-12-16 16:03:14 +08:00
4a474e9b44 2 2025-12-16 16:00:00 +08:00
30f2e4f24f 1 2025-12-16 15:47:12 +08:00
c898d16688 删除storkit 1 2025-12-16 14:29:47 +08:00
f10ddd9a31 1 2025-12-16 14:14:49 +08:00
1651258eec 处理storkit2 2025-12-16 13:49:08 +08:00
fd0ddfd45a 1 2025-12-16 13:10:50 +08:00
444877fb73 2 2025-12-15 21:02:48 +08:00
1436464eca 1 2025-12-15 18:56:29 +08:00
05b9a0b823 添加删除按钮 2025-12-15 18:32:54 +08:00
053001170a 1 2025-12-15 18:02:58 +08:00
9cafb0f70e 引导UI修改 2025-12-15 17:56:06 +08:00
a399af53b5 1 2025-12-15 16:52:16 +08:00
a7574cd286 1 2025-12-15 16:41:53 +08:00
06dee39566 1 2025-12-15 15:47:16 +08:00
d5b4ef2b59 2 2025-12-15 15:40:25 +08:00
2620bd6845 1 2025-12-15 15:22:27 +08:00
1f0bdb1bd4 2 2025-12-15 15:11:16 +08:00
b21a2a8193 1 2025-12-15 15:03:35 +08:00
1f07120289 处理点击一个label,再次点击一个label崩溃了 2025-12-15 14:21:42 +08:00
64e0218add 添加emoji的map图标对应key 2025-12-15 13:45:14 +08:00
7972188ac3 修改emoji 2025-12-15 13:32:47 +08:00
6963c8016f 添加emoji1 2025-12-15 13:24:43 +08:00
633e6a9123 1 2025-12-12 20:08:34 +08:00
437f796a08 1 2025-12-12 16:09:14 +08:00
2b08dd44ee 2 2025-12-12 14:54:45 +08:00
1eeeef266b 1 2025-12-12 14:46:38 +08:00
3813974eae 1 2025-12-12 14:16:48 +08:00
6a5bda44e6 1 2025-12-11 22:02:18 +08:00
704553cd4e 1 2025-12-11 21:00:43 +08:00
35597f89ca 2 2025-12-11 20:40:49 +08:00
577b749198 2 2025-12-11 19:43:55 +08:00
cccced6afa 1 2025-12-11 18:36:14 +08:00
14637a21ad 1 2025-12-11 17:59:25 +08:00
111fe42782 1 2025-12-11 17:51:00 +08:00
58da905ade 1 2025-12-11 17:36:16 +08:00
f338a54e41 3 2025-12-11 16:59:14 +08:00
526ac1a7df 2 2025-12-11 16:45:39 +08:00
7f90240731 1 2025-12-11 16:39:22 +08:00
e4442afe72 1 2025-12-11 16:18:00 +08:00
be1d1ad70d 1 2025-12-11 15:39:33 +08:00
4fd0a52a36 2 2025-12-11 15:19:23 +08:00
04c7d19c37 1 2025-12-11 15:00:58 +08:00
94269209e0 2 2025-12-11 14:03:13 +08:00
2b4123741a 1 2025-12-11 13:51:04 +08:00
45695364e9 1 2025-12-11 13:40:32 +08:00
d348b35984 1 2025-12-11 13:16:06 +08:00
e39104c431 处理流逝返回
处理粘贴
2025-12-09 16:12:54 +08:00
7b86b739eb 处理searchresult 2025-12-09 15:19:10 +08:00
ade23e7a20 1 2025-12-09 14:32:21 +08:00
1b2b0c1143 1 2025-12-09 13:59:32 +08:00
0400d2020b 1 2025-12-08 20:46:24 +08:00
2cc93e0b48 1 2025-12-08 19:48:13 +08:00
fd8c08316b 1 2025-12-08 16:39:47 +08:00
0a1c30f669 处理AI回复问题 2025-12-05 21:54:10 +08:00
ca3ea0630e 2 2025-12-05 21:23:54 +08:00
a26b6b58a9 1 2025-12-05 21:15:48 +08:00
6fd4a86a7e 扩展添加token 2025-12-05 20:22:53 +08:00
fa999f502f 1 2025-12-05 20:18:18 +08:00
ad18a47d21 一次性配置内购服务 2025-12-05 13:49:12 +08:00
d2258883df 1 2025-12-04 21:53:47 +08:00
f7d11c5f8b 1 2025-12-04 21:10:32 +08:00
40d9b5aad4 1 2025-12-04 20:57:39 +08:00
6ac6514f89 1 2025-12-04 20:34:23 +08:00
eb7ad1a9f1 1 2025-12-04 20:27:26 +08:00
17ce91d40a 1 2025-12-04 20:04:02 +08:00
515487e748 1 2025-12-04 19:44:54 +08:00
7a25a6a5fa 1 2025-12-04 19:24:42 +08:00
64887054e0 1 2025-12-04 19:12:34 +08:00
8f63741d8c 1 2025-12-04 16:59:59 +08:00
f593ef0b4a 1 2025-12-04 16:41:33 +08:00
231f7f8c13 1 2025-12-04 16:18:43 +08:00
c9863cd353 11 2025-12-04 15:44:43 +08:00
ce4c4f0531 1 2025-12-04 15:35:23 +08:00
af2bcc42fd 1 2025-12-04 15:28:11 +08:00
1596aac717 2 2025-12-04 15:00:15 +08:00
d13bb734b1 1 2025-12-04 14:53:25 +08:00
2665b5ad1f 2 2025-12-04 14:44:56 +08:00
cffd77eeb5 1 2025-12-04 14:26:22 +08:00
b8f8d2e6b0 1 2025-12-04 14:17:47 +08:00
279255a14c 1 2025-12-04 14:07:12 +08:00
b216ddaa61 1 2025-12-04 13:37:11 +08:00
f770f8055e 1 2025-12-03 20:41:24 +08:00
819d74cc8d 2 2025-12-03 20:31:33 +08:00
9651ae7ad7 1 2025-12-03 20:14:14 +08:00
eca168957d 1 2025-12-03 20:02:37 +08:00
f026b9f9fd 1 2025-12-03 19:50:23 +08:00
b368ba0159 2 2025-12-03 19:43:45 +08:00
43e8b85656 1 2025-12-03 19:38:55 +08:00
716f91bdd0 3 2025-12-03 19:19:20 +08:00
b00283cd96 2 2025-12-03 19:05:53 +08:00
25edf2d817 1 2025-12-03 19:00:55 +08:00
1d6371c37e 1 2025-12-03 18:53:15 +08:00
82123fc232 1 2025-12-03 18:49:18 +08:00
6f02bc7cf5 2 2025-12-03 18:19:51 +08:00
a5ff2ce51b 1 2025-12-03 18:02:20 +08:00
4deccb76dc 1 2025-12-03 17:46:28 +08:00
93556ddb9c 6 2025-12-03 16:54:23 +08:00
681fced59d 2 2025-12-03 16:51:46 +08:00
91e2b047eb 5 2025-12-03 16:48:25 +08:00
c1c4c85bd2 2 2025-12-03 16:45:58 +08:00
d06e0499bc 1 2025-12-03 16:38:20 +08:00
49f730b609 3 2025-12-03 16:26:49 +08:00
04a392e7c7 4 2025-12-03 16:05:00 +08:00
22e393e588 2 2025-12-03 15:53:26 +08:00
6556689c8f 1 2025-12-03 15:46:26 +08:00
a50d18b486 1 2025-12-03 15:19:03 +08:00
599a5de3bc 5 2025-12-03 14:30:02 +08:00
c1eb6a3458 4 2025-12-03 13:54:57 +08:00
b87998549c 3 2025-12-03 13:31:02 +08:00
27aa723e7d 1 2025-12-03 12:55:51 +08:00
6be90ebb10 1 2025-12-02 21:32:49 +08:00
2f55e7bfa1 3 2025-12-02 20:33:17 +08:00
c56655c728 1 2025-12-02 19:39:37 +08:00
5e4c16c577 2 2025-12-02 19:19:25 +08:00
8245e7b3d1 1 2025-12-02 18:29:04 +08:00
cafde48f4a 修改UI逻辑 2025-11-28 16:55:26 +08:00
c897111855 1 2025-11-28 16:19:06 +08:00
a2bb61408b 2 2025-11-28 16:06:34 +08:00
9268a21eb8 处理bug 2025-11-28 14:25:13 +08:00
d4c553f072 处理视频退到后台进前台没有继续播放 2025-11-28 13:56:29 +08:00
73802b6e80 1 2025-11-28 13:20:50 +08:00
1a0a444a99 1 2025-11-28 13:07:35 +08:00
c37038f163 2 2025-11-27 21:39:03 +08:00
3144315de5 1 2025-11-27 20:05:39 +08:00
8f16250cbe 1 2025-11-27 19:20:20 +08:00
95d5e2b972 更改显示 2025-11-27 15:36:56 +08:00
2760a070a3 添加guard 蒙层 2025-11-27 15:34:33 +08:00
2435d760e8 添加键盘功能viewUI改动 2025-11-26 21:16:56 +08:00
80e4db86e4 1 2025-11-26 19:46:23 +08:00
4ab8a61a3c 处理因为网络下载失败导致之前的皮肤不在的bug 2025-11-25 22:00:13 +08:00
73c83153f9 缺少键盘图
修改网络环境下载图
2025-11-25 21:50:07 +08:00
1b67998f6a 2 2025-11-25 20:35:08 +08:00
b8cc38aa61 3 2025-11-25 18:54:53 +08:00
c4398a689b 2 2025-11-25 16:53:38 +08:00
b660eb19f4 1 2025-11-25 16:10:08 +08:00
1eb73f5257 封装KBFont,适配字体 2025-11-25 15:36:16 +08:00
71423df1c0 添加苹果登录的测试 2025-11-24 20:47:36 +08:00
709e0f4453 添加local文本 2025-11-24 20:31:23 +08:00
18df76a2b4 新增页面 2025-11-24 20:15:41 +08:00
15e37841bb 处理ios18从其他app用自己键盘 拉起主app的bug
其他问题
2025-11-24 19:58:19 +08:00
8e93f8f86f 动态化高度比例 2025-11-21 21:50:40 +08:00
fd35c5c993 1 2025-11-21 20:59:39 +08:00
fd7b3a7f75 优化键盘弹出宽度
优化键盘按钮视觉效果
2025-11-21 19:40:57 +08:00
af8fff5b13 添加无网络去设置,优化多语言 2025-11-21 18:36:00 +08:00
fc87c545a0 封装跨应用拉起, 2025-11-21 18:26:02 +08:00
0f4ca89060 固定键盘高度250
优化kbkeyboardview
优化ui
2025-11-21 16:22:00 +08:00
c371c7224e 优化键盘 预览宽度固定 2025-11-21 13:48:22 +08:00
31bb72c8f4 按钮之间无间距,按钮里的图片设置间距 2025-11-20 21:53:46 +08:00
faa05e2a10 添加按钮文字预览提示 2025-11-20 21:11:27 +08:00
6bdd111a3a 添加按下去动画 2025-11-20 20:36:31 +08:00
8296ac12b6 修改键盘UI 2025-11-20 20:17:19 +08:00
b2994adc1c UI 2025-11-20 19:57:11 +08:00
75d2e4072a 1 2025-11-20 18:23:56 +08:00
b23927968f 2 2025-11-20 15:35:22 +08:00
c27fd099f6 2 2025-11-20 15:10:22 +08:00
bc1264e28f 2 2025-11-20 14:56:15 +08:00
799b0f3989 添加扩展键盘本地皮肤 2025-11-20 14:27:57 +08:00
b3ce856ad4 添加启动图 2025-11-19 21:58:54 +08:00
f51fe1fac9 恢复默认皮肤 2025-11-19 20:30:30 +08:00
8dbaa9dcf6 配置化json strings 2025-11-19 20:16:19 +08:00
0196128008 1 2025-11-19 19:15:28 +08:00
4108aed4e0 处理键盘图片和自定义文字同时存在的bug 2025-11-19 16:13:30 +08:00
cc55bb107a 添加皮肤 2025-11-19 15:39:47 +08:00
7518a29d2f 2 2025-11-19 15:07:24 +08:00
37e131eb09 1 2025-11-19 14:54:45 +08:00
3dcc4932c3 3 2025-11-18 20:53:47 +08:00
254e65906a 添加app groups 2025-11-18 14:41:35 +08:00
ced0b88ca4 1 2025-11-18 13:48:22 +08:00
b2021dcb3c 6 2025-11-17 21:35:25 +08:00
0ef7b7d1d8 2 2025-11-17 21:08:25 +08:00
7254e2dbd9 2 2025-11-17 20:55:11 +08:00
26ef29ac4e 1 2025-11-17 20:26:39 +08:00
005e3c7581 2 2025-11-17 20:07:39 +08:00
ee433db4ad 66 2025-11-17 18:51:06 +08:00
ea4b8168b7 1 2025-11-17 16:42:32 +08:00
f366a4aa6c 1 2025-11-17 16:16:38 +08:00
d849b201ca 3 2025-11-17 15:39:03 +08:00
dc813fcabc 2 2025-11-17 15:06:05 +08:00
1d215ffdb3 1 2025-11-17 14:53:23 +08:00
d9bfc30c88 1 2025-11-17 13:30:01 +08:00
9305acb69b 1 2025-11-15 14:27:41 +08:00
f9a8955384 1 2025-11-15 00:33:29 +08:00
1f9dbba39d 1 2025-11-14 23:09:04 +08:00
dace0a9309 1 2025-11-14 19:48:15 +08:00
4f2e80e482 1 2025-11-14 18:43:08 +08:00
b27b9f9ee1 1 2025-11-14 18:24:38 +08:00
66a1ddef66 1 2025-11-14 16:34:01 +08:00
eacac8425c 1 2025-11-14 14:07:04 +08:00
d164514fcf 添加pay 2025-11-13 21:22:10 +08:00
ae79d1b1ba 更新UI 2025-11-13 19:20:57 +08:00
50163d02a7 3 2025-11-13 19:07:59 +08:00
5ec950cc61 统一api 2025-11-13 18:03:26 +08:00
a61b5fa2fd 1 2025-11-13 16:23:46 +08:00
f406416698 1 2025-11-13 15:34:56 +08:00
debbe2777b 1 2025-11-13 14:11:44 +08:00
bc261661ae 2 2025-11-12 21:23:31 +08:00
0aead49816 删除无关代码
退出登录
2025-11-12 19:46:07 +08:00
66b7a9218e 处理\n 2025-11-12 17:55:59 +08:00
2f4205ad1a 3 2025-11-12 16:49:19 +08:00
fea22aecab 重构了KBFunctionView 2025-11-12 16:03:30 +08:00
62f3ddae4a 删除测试数据 2025-11-12 15:40:30 +08:00
c317afc0fe 2 2025-11-12 15:31:22 +08:00
1dbe04cdf9 2 2025-11-12 14:36:15 +08:00
afc44cb471 1 2025-11-12 14:18:56 +08:00
39d8b3d547 1 2025-11-12 13:43:48 +08:00
f387b95d0d 测试假数据 2025-11-11 21:48:26 +08:00
1d064c1f31 1 2025-11-11 20:24:13 +08:00
3440cc4773 1 2025-11-11 19:39:33 +08:00
20b13bcffa 1 2025-11-11 17:36:12 +08:00
105e2ddf9b 1 2025-11-11 16:46:05 +08:00
a1a38d821c 添加复制 2025-11-11 15:59:19 +08:00
83987db5ac fix ui 2025-11-11 15:55:52 +08:00
d10114572e name pop 2025-11-11 15:28:22 +08:00
e34288ae56 fix ui 2025-11-11 15:13:43 +08:00
17b8bf2bfd fix 2025-11-11 14:56:57 +08:00
57bd4ba109 添加弹窗 2025-11-11 14:38:38 +08:00
e4ba237a00 1 2025-11-11 14:02:36 +08:00
9059a24637 1 2025-11-10 21:33:00 +08:00
dc0c55c495 2 2025-11-10 20:40:11 +08:00
a007a77db9 fix color 2025-11-10 19:55:50 +08:00
3eb3a86376 2 2025-11-10 19:51:23 +08:00
1dc9560a1f 1 2025-11-10 19:22:31 +08:00
8069b08fab Merge branch 'dev_st'
# Conflicts:
#	keyBoard.xcodeproj/project.pbxproj
解决冲突
2025-11-10 16:12:38 +08:00
2c8142c0d2 增加webview页面 2025-11-10 16:09:47 +08:00
9f4110b24a 添加弹窗 2025-11-10 15:55:36 +08:00
1cdc17b710 1 2025-11-10 15:38:30 +08:00
97316c7989 添加底部view 2025-11-10 15:29:21 +08:00
fac5e7657c Merge branch 'dev_st' 2025-11-10 13:27:44 +08:00
50dcb78417 提交 2025-11-10 13:27:26 +08:00
998fa7aa67 修改项目配置,锦支持iPhone 2025-11-10 13:25:39 +08:00
5e1a1f540e 1 2025-11-09 21:41:35 +08:00
2415e97c97 fix 2025-11-09 21:05:03 +08:00
aa71cc3c4f 1 2025-11-09 20:54:30 +08:00
e5ddcc4308 添加LYEmptyView '~> 0.3.10' 2025-11-09 20:54:14 +08:00
883b222254 fix 2025-11-09 18:07:47 +08:00
dc9ee10023 1 2025-11-09 17:07:43 +08:00
2c4a4329ff 2 2025-11-09 16:05:42 +08:00
553238de0c 1 2025-11-09 15:59:18 +08:00
80b6102673 1 2025-11-09 14:58:44 +08:00
705b0f374e 1 2025-11-09 14:44:31 +08:00
5bdc7ddec0 1 2025-11-09 14:26:02 +08:00
5d2a3de2f4 fix 2025-11-09 13:56:13 +08:00
675a9f6d64 3 2025-11-08 22:25:57 +08:00
41b14ceea4 3 2025-11-08 21:44:41 +08:00
a729396401 3 2025-11-08 20:49:05 +08:00
3b0beb52da 2 2025-11-08 20:04:50 +08:00
faeb930fe3 1 2025-11-08 11:48:06 +08:00
9a39c29e88 2 2025-11-07 22:22:41 +08:00
b23c9a678b 处理所有UI 2025-11-07 21:57:42 +08:00
96cd32ed99 添加箭头 2025-11-07 21:37:31 +08:00
50dd53b0c0 1 2025-11-07 21:05:25 +08:00
48a12f0919 1 2025-11-07 20:58:14 +08:00
91d754b389 1 2025-11-07 19:55:11 +08:00
450798c8bd 2 2025-11-07 19:33:54 +08:00
c3acc11f6a 1 2025-11-07 19:32:02 +08:00
d592c9f12e 1 2025-11-07 16:58:33 +08:00
26e39ce416 1 2025-11-07 16:46:08 +08:00
32521208a0 2 2025-11-07 16:29:15 +08:00
6e969648c6 1 2025-11-07 15:03:45 +08:00
074596ebcb 添加homeheadView 2025-11-07 14:21:03 +08:00
f0542c11c8 修改三方库 JXCategoryIndicatorCell self.contentView.layer.cornerRadius = 4; 2025-11-07 00:12:28 +08:00
0fa3d10284 9 2025-11-06 21:38:58 +08:00
0d13192723 优化ui 2025-11-06 19:51:50 +08:00
a72aae84ef 处理tabbar
处理HomeRankContentVC去除弹性效果 - 适配HWPanModal下拉效果
2025-11-06 19:29:52 +08:00
6ba1339c0b 修改UI 2025-11-06 19:19:12 +08:00
a75afbe4c1 创建nav宏
去除tabbar透明
2025-11-06 16:57:28 +08:00
1f45564539 5 2025-11-06 16:05:28 +08:00
41aec6b89e 4 2025-11-06 15:16:03 +08:00
a1db745b6c 3 2025-11-06 14:59:00 +08:00
15fc9621cd 2 2025-11-06 14:02:22 +08:00
d7874829d9 1 2025-11-06 13:18:27 +08:00
abf32e8457 添加HWPanModal和FLAnimatedImage 2025-11-05 22:04:56 +08:00
efdcf60ed1 在非刘海添加地球 2025-11-05 20:11:10 +08:00
7a1b17d060 处理键盘不能拉起主app的问题 2025-11-05 18:10:56 +08:00
f43f94b94d 添加键盘背景 2025-11-04 21:01:46 +08:00
3e2dc4bcb6 1 2025-11-04 16:37:24 +08:00
1381 changed files with 377463 additions and 3769 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(git checkout:*)",
"Bash(xcodebuild:*)",
"Bash(plutil:*)",
"Bash(find:*)",
"Bash(ls:*)",
"Bash(wc:*)"
]
}
}

View File

@@ -0,0 +1,17 @@
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
request = "set_developer_dir";
}) because we are not connected to CoreSimulatorService.
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Warning>: Unable to discover any Simulator runtimes. Developer Directory is /Applications/Xcode.app/Contents/Developer.
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Could not kickstart simdiskimaged; SimDiskImageManager services will not be available: Error Domain=NSPOSIXErrorDomain Code=53 "Software caused connection abort" UserInfo={NSLocalizedDescription=Error returned in reply from CoreSimulatorService: Connection invalid}
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: simdiskimaged returned error (invalid), marking disconnected.
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: simdiskimaged returned error (invalid), marking disconnected.
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Could not get list of trusted mount directories: Error Domain=com.apple.CoreSimulator.SimError Code=410 "The service used to manage runtime disk images (simdiskimaged) crashed or is not responding" UserInfo={NSLocalizedDescription=The service used to manage runtime disk images (simdiskimaged) crashed or is not responding}
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
request = "notification_subscription";
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
}) because we are not connected to CoreSimulatorService.
Dec 18 13:42:00 macbookpro com.apple.dt.xcodebuild[42901] <Error>: Unable to deliver request ({
request = "notification_subscription";
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
}) because we are not connected to CoreSimulatorService.

View File

@@ -2,10 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.tknb.net</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.loveKey.nyx</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.loveKey.nyx.shared</string>
</array>
</dict>
</plist>

View File

@@ -2,6 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>kbkeyboardAppExtension</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>需要使用麦克风进行语音输入</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "App_icon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "App_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "App_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "切图 271@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "切图 271@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ai_limit_close@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ai_limit_close@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ai_limit_goto@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ai_limit_goto@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "ai_limit_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "ai_limit_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "back_keybord_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "back_keybord_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "buy_sel_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "buy_sel_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "close_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "close_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "home_ai_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "home_ai_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "home_chat_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "home_chat_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "home_emotion_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "home_emotion_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "home_keyboard_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "home_keyboard_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,86 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "kb_del_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"filename" : "kb_del_icon@2x 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "切图 256@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "kb_del_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"filename" : "kb_del_icon@3x 1.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "切图 256@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "kb_zt_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "kb_zt_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "key_revoke@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "key_revoke@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "keybord_bg_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "keybord_bg_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "upgrad_vip_icon@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "upgrad_vip_icon@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -5,216 +5,237 @@
// Created by Mac on 2025/10/27.
//
#import "KeyboardViewController.h"
#import "KBKeyBoardMainView.h"
#import "KeyboardViewController+Private.h"
#import "KBKey.h"
#import "KBFunctionView.h"
#import "KBSettingView.h"
#import "Masonry.h"
#import "KBAuthManager.h"
#import "KBBackspaceUndoManager.h"
#import "KBChatLimitPopView.h"
#import "KBChatPanelView.h"
#import "KBFullAccessManager.h"
#import "KBFunctionView.h"
#import "KBInputBufferManager.h"
#import "KBKeyBoardMainView.h"
#import "KBKeyboardSubscriptionView.h"
#import "KBLocalizationManager.h"
#import "KBSkinManager.h"
#import "KBSuggestionEngine.h"
#import <SDWebImage/SDWebImage.h>
static CGFloat KEYBOARDHEIGHT = 256 + 20;
#if DEBUG
#import <mach/mach.h>
#endif
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate, KBFunctionViewDelegate>
@property (nonatomic, strong) UIButton *nextKeyboardButton; //
@property (nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 0
@property (nonatomic, strong) KBFunctionView *functionView; // 0
@property (nonatomic, strong) KBSettingView *settingView; //
@end
#if DEBUG
static NSInteger sKBKeyboardVCAliveCount = 0;
static uint64_t KBPhysFootprintBytes(void) {
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO,
(task_info_t)&vmInfo, &count);
if (kr != KERN_SUCCESS) {
return 0;
}
return (uint64_t)vmInfo.phys_footprint;
}
static NSString *KBFormatMB(uint64_t bytes) {
double mb = (double)bytes / 1024.0 / 1024.0;
return [NSString stringWithFormat:@"%.1fMB", mb];
}
#endif
@implementation KeyboardViewController
{
BOOL _kb_didTriggerLoginDeepLinkOnce;
BOOL _kb_didTriggerLoginDeepLinkOnce;
#if DEBUG
BOOL _kb_debugDidCountAlive;
#endif
}
- (void)viewDidLoad {
[super viewDidLoad];
[self setupUI];
// HUD App KeyWindow
[KBHUD setContainerView:self.view];
// 访便
[[KBFullAccessManager shared] bindInputController:self];
__unused id token = [[NSNotificationCenter defaultCenter] addObserverForName:KBFullAccessChangedNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
// 访 UI
}];
[super viewDidLoad];
#if DEBUG
if (!_kb_debugDidCountAlive) {
_kb_debugDidCountAlive = YES;
sKBKeyboardVCAliveCount += 1;
}
NSLog(@"[Keyboard] KeyboardViewController viewDidLoad alive=%ld self=%p mem=%@",
(long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes()));
#endif
// /
[[KBBackspaceUndoManager shared] registerNonClearAction];
[self setupUI];
self.suggestionEngine = [KBSuggestionEngine shared];
self.currentWord = @"";
// HUD App KeyWindow
[KBHUD setContainerView:self.view];
// 访便
[[KBFullAccessManager shared] bindInputController:self];
self.kb_fullAccessObserverToken = [[NSNotificationCenter defaultCenter]
addObserverForName:KBFullAccessChangedNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *_Nonnull note){
// 访 UI
}];
//
__weak typeof(self) weakSelf = self;
self.kb_skinObserverToken = [[NSNotificationCenter defaultCenter]
addObserverForName:KBSkinDidChangeNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *_Nonnull note) {
__strong typeof(weakSelf) self = weakSelf;
if (!self) {
return;
}
[self kb_applyTheme];
}];
[self kb_applyTheme];
[self kb_registerDarwinSkinInstallObserver];
[self kb_consumePendingShopSkin];
[self kb_applyDefaultSkinIfNeeded];
}
- (void)setupUI {
//
[self.view.heightAnchor constraintEqualToConstant:KEYBOARDHEIGHT].active = YES;
//
self.functionView.hidden = YES;
[self.view addSubview:self.functionView];
[self.functionView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view);
make.top.equalTo(self.view).offset(4);
make.bottom.equalTo(self.view.mas_bottom).offset(-4);
}];
[self.view addSubview:self.keyBoardMainView];
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view);
make.top.equalTo(self.view).offset(4);
make.bottom.equalTo(self.view.mas_bottom).offset(-4);
}];
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
//
self.kb_cachedGradientImage = nil;
[self.kb_defaultGradientLayer removeFromSuperlayer];
self.kb_defaultGradientLayer = nil;
[[KBSkinManager shared] clearRuntimeImageCaches];
[[SDImageCache sharedImageCache] clearMemory];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
// FIX: iOS 26
// setupUI 0
//
CGFloat portraitWidth = [self kb_portraitWidth];
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
if (self.kb_heightConstraint) {
self.kb_heightConstraint.constant = keyboardHeight;
}
// /
[[KBBackspaceUndoManager shared] registerNonClearAction];
[[KBInputBufferManager shared] resetWithText:@""];
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
// HUD viewDidDisappear /
[KBHUD setContainerView:self.view];
[self kb_ensureKeyBoardMainViewIfNeeded];
[self kb_applyTheme];
#if DEBUG
NSLog(@"[Keyboard] viewWillAppear self=%p mem=%@",
self, KBFormatMB(KBPhysFootprintBytes()));
#endif
// /QQ 宿 documentContext
// liveText manualSnapshot
[[KBInputBufferManager shared]
updateFromExternalContextBefore:self.textDocumentProxy
.documentContextBeforeInput
after:self.textDocumentProxy
.documentContextAfterInput];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[[KBBackspaceUndoManager shared] registerNonClearAction];
[self kb_releaseMemoryWhenKeyboardHidden];
#if DEBUG
NSLog(@"[Keyboard] viewWillDisappear self=%p mem=%@",
self, KBFormatMB(KBPhysFootprintBytes()));
#endif
}
#pragma mark - Private
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
// 宿 willDisappear didDisappear
[self kb_releaseMemoryWhenKeyboardHidden];
}
/// /
- (void)showFunctionPanel:(BOOL)show {
//
self.functionView.hidden = !show;
self.keyBoardMainView.hidden = show;
//
if (show) {
[self.view bringSubviewToFront:self.functionView];
} else {
[self.view bringSubviewToFront:self.keyBoardMainView];
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
if (@available(iOS 13.0, *)) {
if (previousTraitCollection.userInterfaceStyle !=
self.traitCollection.userInterfaceStyle) {
self.kb_cachedGradientImage = nil;
[self kb_applyDefaultSkinIfNeeded];
}
}
}
/// / keyBoardMainView /
- (void)showSettingView:(BOOL)show {
if (show) {
// if (!self.settingView) {
self.settingView = [[KBSettingView alloc] init];
self.settingView.hidden = YES;
[self.view addSubview:self.settingView];
[self.settingView mas_makeConstraints:^(MASConstraintMaker *make) {
//
make.edges.equalTo(self.keyBoardMainView);
}];
[self.settingView.backButton addTarget:self action:@selector(onTapSettingsBack) forControlEvents:UIControlEventTouchUpInside];
// }
[self.view bringSubviewToFront:self.settingView];
// keyBoardMainView self.view
[self.view layoutIfNeeded];
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
if (w <= 0) { w = CGRectGetWidth(self.view.bounds); }
if (w <= 0) { w = [UIScreen mainScreen].bounds.size.width; }
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
self.settingView.hidden = NO;
[UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
self.settingView.transform = CGAffineTransformIdentity;
} completion:nil];
} else {
if (!self.settingView || self.settingView.hidden) return;
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
if (w <= 0) { w = CGRectGetWidth(self.view.bounds); }
if (w <= 0) { w = [UIScreen mainScreen].bounds.size.width; }
[UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
} completion:^(BOOL finished) {
self.settingView.hidden = YES;
}];
}
- (void)textDidChange:(id<UITextInput>)textInput {
[super textDidChange:textInput];
[[KBInputBufferManager shared]
updateFromExternalContextBefore:self.textDocumentProxy
.documentContextBeforeInput
after:self.textDocumentProxy
.documentContextAfterInput];
}
// MARK: - KBKeyBoardMainViewDelegate
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key {
switch (key.type) {
case KBKeyTypeCharacter:
[self.textDocumentProxy insertText:key.output ?: key.title ?: @""]; break;
case KBKeyTypeBackspace:
[self.textDocumentProxy deleteBackward]; break;
case KBKeyTypeSpace:
[self.textDocumentProxy insertText:@" "]; break;
case KBKeyTypeReturn:
[self.textDocumentProxy insertText:@"\n"]; break;
case KBKeyTypeGlobe:
[self advanceToNextInputMode]; break;
case KBKeyTypeCustom:
// AI
[self showFunctionPanel:YES];
break;
case KBKeyTypeModeChange:
case KBKeyTypeShift:
// KBKeyBoardMainView/KBKeyboardView
break;
}
}
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index {
if (index == 0) {
[self showFunctionPanel:YES];
} else {
[self showFunctionPanel:NO];
}
}
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
[self showSettingView:YES];
}
// MARK: - KBFunctionViewDelegate
- (void)functionView:(KBFunctionView *)functionView didTapToolActionAtIndex:(NSInteger)index {
// index == 0
if (index == 0) {
[self showFunctionPanel:NO];
}
}
#pragma mark - lazy
- (KBKeyBoardMainView *)keyBoardMainView{
if (!_keyBoardMainView) {
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
_keyBoardMainView.delegate = self;
}
return _keyBoardMainView;
}
- (KBFunctionView *)functionView{
if (!_functionView) {
_functionView = [[KBFunctionView alloc] init];
_functionView.delegate = self; // Bar
}
return _functionView;
}
- (KBSettingView *)settingView {
if (!_settingView) {
_settingView = [[KBSettingView alloc] init];
}
return _settingView;
}
#pragma mark - Actions
- (void)onTapSettingsBack {
[self showSettingView:NO];
}
// App App
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
if (!_kb_didTriggerLoginDeepLinkOnce) {
_kb_didTriggerLoginDeepLinkOnce = YES;
// App
if (!KBAuthManager.shared.isLoggedIn) {
[self kb_tryOpenContainerForLoginIfNeeded];
}
}
[super viewDidAppear:animated];
// if (!_kb_didTriggerLoginDeepLinkOnce) {
// _kb_didTriggerLoginDeepLinkOnce = YES;
// // App
// if (!KBAuthManager.shared.isLoggedIn) {
// [self kb_tryOpenContainerForLoginIfNeeded];
// }
// }
}
- (void)kb_tryOpenContainerForLoginIfNeeded {
NSURL *url = [NSURL URLWithString:@"kbkeyboard://login?src=keyboard"];
if (!url) return;
__weak typeof(self) weakSelf = self;
[self.extensionContext openURL:url completionHandler:^(__unused BOOL success) {
// 使
__unused typeof(weakSelf) selfStrong = weakSelf;
}];
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
// [self kb_updateKeyboardLayoutIfNeeded];
//
if (self.contentView.hidden) {
self.contentView.hidden = NO;
}
if (self.kb_defaultGradientLayer) {
self.kb_defaultGradientLayer.frame = self.bgImageView.bounds;
}
}
- (void)viewWillTransitionToSize:(CGSize)size
withTransitionCoordinator:
(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
__weak typeof(self) weakSelf = self;
[coordinator
animateAlongsideTransition:^(
id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
[weakSelf kb_updateKeyboardLayoutIfNeeded];
}
completion:^(
__unused id<
UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
[weakSelf kb_updateKeyboardLayoutIfNeeded];
}];
}
- (void)dealloc {
if (self.kb_fullAccessObserverToken) {
[[NSNotificationCenter defaultCenter]
removeObserver:self.kb_fullAccessObserverToken];
self.kb_fullAccessObserverToken = nil;
}
if (self.kb_skinObserverToken) {
[[NSNotificationCenter defaultCenter]
removeObserver:self.kb_skinObserverToken];
self.kb_skinObserverToken = nil;
}
[self kb_unregisterDarwinSkinInstallObserver];
#if DEBUG
if (_kb_debugDidCountAlive) {
sKBKeyboardVCAliveCount -= 1;
}
NSLog(@"[Keyboard] KeyboardViewController dealloc alive=%ld self=%p mem=%@",
(long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes()));
#endif
}
@end

View File

@@ -0,0 +1,712 @@
//
// KeyboardViewController+Chat.m
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController+Private.h"
#import "KBChatLimitPopView.h"
#import "KBChatMessage.h"
#import "KBChatPanelView.h"
#import "KBFullAccessManager.h"
#import "KBHostAppLauncher.h"
#import "KBInputBufferManager.h"
#import "KBNetworkManager.h"
#import "KBVM.h"
#import "Masonry.h"
#import <AVFoundation/AVFoundation.h>
static const NSUInteger kKBChatMessageLimit = 6;
@implementation KeyboardViewController (Chat)
#pragma mark - KBChatPanelViewDelegate
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text {
NSString *trim =
[text stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (trim.length == 0) {
return;
}
[self kb_sendChatText:trim];
}
- (void)chatPanelView:(KBChatPanelView *)view
didTapMessage:(KBChatMessage *)message {
if (message.audioFilePath.length == 0) {
return;
}
[self kb_playChatAudioAtPath:message.audioFilePath];
}
- (void)chatPanelView:(KBChatPanelView *)view
didTapVoiceButtonForMessage:(KBChatMessage *)message {
if (!message)
return;
// audioData
if (message.audioData && message.audioData.length > 0) {
[self kb_playChatAudioData:message.audioData];
return;
}
// audioFilePath
if (message.audioFilePath.length > 0) {
[self kb_playChatAudioAtPath:message.audioFilePath];
return;
}
NSLog(@"[Keyboard] 没有音频数据可播放");
}
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view {
// chatPanelView
[view kb_reloadWithMessages:@[]];
if (self.chatAudioPlayer.isPlaying) {
[self.chatAudioPlayer stop];
}
self.chatAudioPlayer = nil;
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
}
#pragma mark - Chat Helpers
- (void)kb_handleChatSendAction {
if (!self.chatPanelVisible) {
return;
}
[[KBInputBufferManager shared]
refreshFromProxyIfPossible:self.textDocumentProxy];
NSString *fullText = [KBInputBufferManager shared].liveText ?: @"";
// 宿线
NSString *baseline = self.chatPanelBaselineText ?: @"";
NSString *rawText = fullText;
if (baseline.length > 0 && [fullText hasPrefix:baseline]) {
rawText = [fullText substringFromIndex:baseline.length];
}
NSString *trim =
[rawText stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
NSString *textToClear = rawText;
if (trim.length == 0) {
//
//
NSString *fullTrim =
[fullText stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (fullTrim.length > 0) {
trim = fullTrim;
textToClear = fullText;
}
}
if (trim.length == 0) {
[KBHUD showInfo:KBLocalized(@"请输入内容")];
return;
}
[self kb_sendChatText:trim];
//
[self kb_clearHostInputForText:textToClear];
}
- (void)kb_sendChatText:(NSString *)text {
if (text.length == 0) {
return;
}
NSLog(@"[KB] 发送消息: %@", text);
KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text];
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
[self.chatPanelView kb_addUserMessage:text];
[self kb_prefetchAvatarForMessage:outgoing];
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
[KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
return;
}
// loading
[self.chatPanelView kb_addLoadingAssistantMessage];
//
[self kb_requestChatMessageWithContent:text];
}
#pragma mark - Chat Limit Pop
- (void)kb_showChatLimitPopWithMessage:(NSString *)message {
[self kb_dismissChatLimitPop];
UIControl *mask = [[UIControl alloc] init];
mask.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
mask.alpha = 0.0;
[mask addTarget:self
action:@selector(kb_dismissChatLimitPop)
forControlEvents:UIControlEventTouchUpInside];
[self.contentView addSubview:mask];
[mask mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
CGFloat width = 252.0;
CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0;
KBChatLimitPopView *content =
[[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
content.message = message ?: @"";
content.delegate = self;
[mask addSubview:content];
[content mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.equalTo(mask);
make.width.mas_equalTo(width);
make.height.mas_equalTo(height);
}];
self.chatLimitMaskView = mask;
[self.contentView bringSubviewToFront:mask];
[UIView animateWithDuration:0.18
animations:^{
mask.alpha = 1.0;
}];
}
- (void)kb_dismissChatLimitPop {
if (!self.chatLimitMaskView) {
return;
}
UIControl *mask = self.chatLimitMaskView;
self.chatLimitMaskView = nil;
[UIView animateWithDuration:0.15
animations:^{
mask.alpha = 0.0;
}
completion:^(__unused BOOL finished) {
[mask removeFromSuperview];
}];
}
- (void)kb_clearHostInputForText:(NSString *)text {
if (text.length == 0) {
return;
}
NSUInteger count = [self kb_composedCharacterCountForString:text];
for (NSUInteger i = 0; i < count; i++) {
[self.textDocumentProxy deleteBackward];
}
[[KBInputBufferManager shared] clearAllLiveText];
[self kb_clearCurrentWord];
}
- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text {
if (text.length == 0) {
return 0;
}
__block NSUInteger count = 0;
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
options:NSStringEnumerationByComposedCharacterSequences
usingBlock:^(__unused NSString *substring,
__unused NSRange substringRange,
__unused NSRange enclosingRange,
__unused BOOL *stop) {
count += 1;
}];
return count;
}
- (NSString *)kb_sharedUserAvatarURL {
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
NSString *url = [ud stringForKey:AppGroup_UserAvatarURL];
return url ?: @"";
}
- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message {
if (!message || message.avatarImage) {
return;
}
NSString *urlString = message.avatarURL ?: @"";
if (urlString.length == 0) {
return;
}
if (![[KBFullAccessManager shared] hasFullAccess]) {
return;
}
__weak typeof(self) weakSelf = self;
[[KBVM shared] downloadAvatarFromURL:urlString
completion:^(UIImage *image, NSError *error) {
__strong typeof(weakSelf) self = weakSelf;
if (!self || !image)
return;
message.avatarImage = image;
[self kb_reloadChatRowForMessage:message];
}];
}
- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message {
//
//
//
}
- (void)kb_requestChatAudioForText:(NSString *)text {
NSString *mockPath = [self kb_mockChatAudioPath];
if (mockPath.length > 0) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
NSString *displayText = KBLocalized(@"语音回复");
KBChatMessage *incoming =
[KBChatMessage messageWithText:displayText
outgoing:NO
audioFilePath:mockPath];
incoming.displayName = KBLocalized(@"AI助手");
[self kb_appendChatMessage:incoming];
[self kb_playChatAudioAtPath:mockPath];
});
return;
}
NSDictionary *payload = @{@"message" : text ?: @""};
__weak typeof(self) weakSelf = self;
[[KBNetworkManager shared] POST:API_AI_TALK
jsonBody:payload
headers:nil
completion:^(NSDictionary *json, NSURLResponse *response,
NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
__strong typeof(weakSelf) self = weakSelf;
if (!self) {
return;
}
if (error) {
NSString *tip = error.localizedDescription
?: KBLocalized(@"请求失败");
[KBHUD showInfo:tip];
return;
}
NSString *displayText =
[self kb_chatTextFromJSON:json];
NSString *audioURL =
[self kb_chatAudioURLFromJSON:json];
NSString *audioBase64 =
[self kb_chatAudioBase64FromJSON:json];
if (audioURL.length > 0) {
[self kb_downloadChatAudioFromURL:audioURL
displayText:displayText];
return;
}
if (audioBase64.length > 0) {
NSData *data = [[NSData alloc]
initWithBase64EncodedString:audioBase64
options:0];
if (data.length == 0) {
[KBHUD showInfo:KBLocalized(@"音频数据解析失败")];
return;
}
[self kb_handleChatAudioData:data
fileExtension:@"m4a"
displayText:displayText];
return;
}
[KBHUD showInfo:KBLocalized(@"未获取到音频文件")];
});
}];
}
#pragma mark - New Chat API (with typewriter effect and audio preload)
/// audioId
- (void)kb_requestChatMessageWithContent:(NSString *)content {
if (content.length == 0) {
[self.chatPanelView kb_removeLoadingAssistantMessage];
return;
}
NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup];
NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId);
__weak typeof(self) weakSelf = self;
[[KBVM shared] sendChatMessageWithContent:content
companionId:companionId
completion:^(KBChatResponse *response) {
__strong typeof(weakSelf) self = weakSelf;
if (!self)
return;
if (response.code != 0) {
if (response.code == 50030) {
NSLog(@"[KB] ⚠️ 次数用尽: %@",
response.message);
[self.chatPanelView
kb_removeLoadingAssistantMessage];
[self kb_showChatLimitPopWithMessage:
response.message];
return;
}
NSLog(@"[KB] ❌ 请求失败: %@",
response.message);
[self.chatPanelView
kb_removeLoadingAssistantMessage];
[KBHUD showInfo:response.message
?: KBLocalized(@"请求失败")];
return;
}
NSLog(@"[KB] ✅ 收到回复: %@",
response.data.aiResponse);
if (response.data.aiResponse.length == 0) {
[self.chatPanelView
kb_removeLoadingAssistantMessage];
[KBHUD showInfo:KBLocalized(@"未获取到回复内容")];
return;
}
// AI
NSLog(@"[KB] 准备添加 AI 消息");
[self.chatPanelView
kb_addAssistantMessage:response.data.aiResponse
audioId:response.data.audioId];
NSLog(@"[KB] AI 消息添加完成");
// App persona
[self kb_notifyMainAppChatUpdatedWithCompanionId:companionId];
// audioId
if (response.data.audioId.length > 0) {
[self kb_preloadAudioWithAudioId:
response.data.audioId];
}
}];
}
/// AppGroup persona companionId
- (NSInteger)kb_selectedCompanionId {
return [[KBVM shared] selectedCompanionIdFromAppGroup];
}
#pragma mark - Audio Preload
/// audioURL
- (void)kb_preloadAudioWithAudioId:(NSString *)audioId {
if (audioId.length == 0)
return;
NSLog(@"[Keyboard] 开始预加载音频audioId: %@", audioId);
__weak typeof(self) weakSelf = self;
[[KBVM shared] pollAudioURLWithAudioId:audioId
maxRetries:10
interval:1.0
completion:^(KBAudioResponse *response) {
__strong typeof(weakSelf) self = weakSelf;
if (!self)
return;
if (!response.success ||
response.audioURL.length == 0) {
NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@",
response.errorMessage);
return;
}
NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功");
//
[[KBVM shared]
downloadAudioFromURL:response.audioURL
completion:^(
KBAudioResponse *audioResponse) {
if (!audioResponse.success) {
NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@",
audioResponse.errorMessage);
return;
}
// AI
[self.chatPanelView
kb_updateLastAssistantMessageWithAudioData:
audioResponse.audioData
duration:
audioResponse.duration];
NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒",
audioResponse.duration);
}];
}];
}
- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL
displayText:(NSString *)displayText {
__weak typeof(self) weakSelf = self;
[[KBVM shared] downloadAudioFromURL:audioURL
completion:^(KBAudioResponse *response) {
__strong typeof(weakSelf) self = weakSelf;
if (!self)
return;
if (!response.success) {
[KBHUD showInfo:response.errorMessage
?: KBLocalized(@"下载失败")];
return;
}
if (!response.audioData ||
response.audioData.length == 0) {
[KBHUD showInfo:KBLocalized(@"未获取到音频数据")];
return;
}
NSString *ext = @"m4a";
NSURL *url = [NSURL URLWithString:audioURL];
if (url.pathExtension.length > 0) {
ext = url.pathExtension;
}
[self kb_handleChatAudioData:response.audioData
fileExtension:ext
displayText:displayText];
}];
}
- (void)kb_handleChatAudioData:(NSData *)data
fileExtension:(NSString *)extension
displayText:(NSString *)displayText {
if (data.length == 0) {
[KBHUD showInfo:KBLocalized(@"音频数据为空")];
return;
}
NSString *ext = extension.length > 0 ? extension : @"m4a";
NSString *fileName = [NSString
stringWithFormat:@"kb_chat_%@.%@",
@((long long)([NSDate date].timeIntervalSince1970 *
1000)),
ext];
NSString *filePath =
[NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
if (![data writeToFile:filePath atomically:YES]) {
[KBHUD showInfo:KBLocalized(@"音频保存失败")];
return;
}
NSString *text =
displayText.length > 0 ? displayText : KBLocalized(@"语音消息");
KBChatMessage *incoming =
[KBChatMessage messageWithText:text outgoing:NO audioFilePath:filePath];
incoming.displayName = KBLocalized(@"AI助手");
[self kb_appendChatMessage:incoming];
}
- (void)kb_appendChatMessage:(KBChatMessage *)message {
if (!message) {
return;
}
[self.chatMessages addObject:message];
if (self.chatMessages.count > kKBChatMessageLimit) {
NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit;
NSArray<KBChatMessage *> *removed =
[self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)];
[self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)];
for (KBChatMessage *msg in removed) {
if (msg.audioFilePath.length > 0) {
NSString *tmpRoot = NSTemporaryDirectory();
if (tmpRoot.length > 0 && [msg.audioFilePath hasPrefix:tmpRoot]) {
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
error:nil];
}
}
}
}
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
}
- (NSString *)kb_mockChatAudioPath {
NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test"
ofType:@"m4a"];
return path ?: @"";
}
- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json {
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
NSString *text =
[self kb_stringValueInDict:data keys:@[ @"text", @"message", @"content" ]];
if (text.length == 0) {
text = [self kb_stringValueInDict:json
keys:@[ @"text", @"message", @"content" ]];
}
return text ?: @"";
}
- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json {
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
NSArray<NSString *> *keys =
@[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl",
@"file_url", @"audioFileUrl", @"audio_file_url" ];
NSString *url = [self kb_stringValueInDict:data keys:keys];
if (url.length == 0) {
url = [self kb_stringValueInDict:json keys:keys];
}
return url ?: @"";
}
- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json {
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
NSArray<NSString *> *keys =
@[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data",
@"base64" ];
NSString *b64 = [self kb_stringValueInDict:data keys:keys];
if (b64.length == 0) {
b64 = [self kb_stringValueInDict:json keys:keys];
}
return b64 ?: @"";
}
- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json {
if (![json isKindOfClass:[NSDictionary class]]) {
return @{};
}
id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"];
if ([dataObj isKindOfClass:[NSDictionary class]]) {
return (NSDictionary *)dataObj;
}
return @{};
}
- (NSString *)kb_stringValueInDict:(NSDictionary *)dict
keys:(NSArray<NSString *> *)keys {
if (![dict isKindOfClass:[NSDictionary class]]) {
return @"";
}
for (NSString *key in keys) {
id value = dict[key];
if ([value isKindOfClass:[NSString class]] &&
((NSString *)value).length > 0) {
return (NSString *)value;
}
}
return @"";
}
- (void)kb_playChatAudioAtPath:(NSString *)path {
if (path.length == 0) {
return;
}
NSURL *url = [NSURL fileURLWithPath:path];
if (![NSFileManager.defaultManager fileExistsAtPath:path]) {
[KBHUD showInfo:KBLocalized(@"音频文件不存在")];
return;
}
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
NSURL *currentURL = self.chatAudioPlayer.url;
if ([currentURL isEqual:url]) {
[self.chatAudioPlayer stop];
self.chatAudioPlayer = nil;
return;
}
[self.chatAudioPlayer stop];
self.chatAudioPlayer = nil;
}
NSError *sessionError = nil;
AVAudioSession *session = [AVAudioSession sharedInstance];
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
[session setCategory:AVAudioSessionCategoryPlayback
withOptions:AVAudioSessionCategoryOptionDuckOthers
error:&sessionError];
} else {
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
}
[session setActive:YES error:nil];
NSError *playerError = nil;
AVAudioPlayer *player =
[[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError];
if (playerError || !player) {
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
return;
}
self.chatAudioPlayer = player;
[player prepareToPlay];
[player play];
}
///
- (void)kb_playChatAudioData:(NSData *)audioData {
if (!audioData || audioData.length == 0) {
NSLog(@"[Keyboard] 音频数据为空");
return;
}
//
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
[self.chatAudioPlayer stop];
self.chatAudioPlayer = nil;
}
//
NSError *sessionError = nil;
AVAudioSession *session = [AVAudioSession sharedInstance];
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
[session setCategory:AVAudioSessionCategoryPlayback
withOptions:AVAudioSessionCategoryOptionDuckOthers
error:&sessionError];
} else {
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
}
[session setActive:YES error:nil];
//
NSError *playerError = nil;
AVAudioPlayer *player =
[[AVAudioPlayer alloc] initWithData:audioData error:&playerError];
if (playerError || !player) {
NSLog(@"[Keyboard] 音频播放器初始化失败: %@",
playerError.localizedDescription);
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
return;
}
self.chatAudioPlayer = player;
player.volume = 1.0;
[player prepareToPlay];
[player play];
NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration);
}
#pragma mark - Notify Main App
/// App persona
- (void)kb_notifyMainAppChatUpdatedWithCompanionId:(NSInteger)companionId {
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
[ud setInteger:companionId forKey:AppGroup_ChatUpdatedCompanionId];
[ud synchronize];
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge CFStringRef)kKBDarwinChatUpdated,
NULL, NULL, true);
NSLog(@"[KB] 已通知主 App 刷新 companionId=%ld 的聊天记录", (long)companionId);
}
#pragma mark - KBChatLimitPopViewDelegate
- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view {
[self kb_dismissChatLimitPop];
}
- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view {
[self kb_dismissChatLimitPop];
NSString *urlString =
[NSString stringWithFormat:@"%@://recharge?src=keyboard&vipType=svip",
KB_APP_SCHEME];
NSURL *scheme = [NSURL URLWithString:urlString];
BOOL success = [KBHostAppLauncher openHostAppURL:scheme
fromResponder:self.view];
if (!success) {
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
}
}
@end

View File

@@ -0,0 +1,96 @@
//
// KeyboardViewController+Layout.m
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController+Private.h"
// 375 稿
static const CGFloat kKBKeyboardBaseHeight = 250.0f;
static const CGFloat kKBChatPanelHeight = 180;
@implementation KeyboardViewController (Layout)
- (CGFloat)kb_portraitWidth {
CGSize s = [UIScreen mainScreen].bounds.size;
return MIN(s.width, s.height);
}
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width {
if (width <= 0) {
width = KB_DESIGN_WIDTH;
}
CGFloat scale = width / KB_DESIGN_WIDTH;
CGFloat baseHeight = kKBKeyboardBaseHeight * scale;
CGFloat chatHeight = kKBChatPanelHeight * scale;
if (self.chatPanelVisible) {
return baseHeight + chatHeight;
}
return baseHeight;
}
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width {
if (width <= 0) {
width = KB_DESIGN_WIDTH;
}
CGFloat scale = width / KB_DESIGN_WIDTH;
return kKBKeyboardBaseHeight * scale;
}
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width {
if (width <= 0) {
width = KB_DESIGN_WIDTH;
}
CGFloat scale = width / KB_DESIGN_WIDTH;
return kKBChatPanelHeight * scale;
}
- (void)kb_updateKeyboardLayoutIfNeeded {
CGFloat portraitWidth = [self kb_portraitWidth];
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
CGFloat keyboardBaseHeight =
[self kb_keyboardBaseHeightForWidth:portraitWidth];
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds);
if (containerWidth <= 0) {
containerWidth = CGRectGetWidth(self.view.window.bounds);
}
if (containerWidth <= 0) {
containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
}
BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5);
BOOL heightChanged =
(fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5);
if (!widthChanged && !heightChanged && containerWidth > 0 &&
self.kb_widthConstraint.constant == containerWidth) {
return;
}
self.kb_lastPortraitWidth = portraitWidth;
self.kb_lastKeyboardHeight = keyboardHeight;
if (self.kb_heightConstraint) {
self.kb_heightConstraint.constant = keyboardHeight;
}
if (containerWidth > 0 && self.kb_widthConstraint) {
self.kb_widthConstraint.constant = containerWidth;
}
if (self.contentWidthConstraint) {
[self.contentWidthConstraint setOffset:portraitWidth];
}
if (self.contentHeightConstraint) {
[self.contentHeightConstraint setOffset:keyboardHeight];
}
if (self.keyBoardMainHeightConstraint) {
[self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
}
if (self.chatPanelHeightConstraint) {
[self.chatPanelHeightConstraint setOffset:chatPanelHeight];
}
[self.view layoutIfNeeded];
}
@end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,656 @@
//
// KeyboardViewController+Panels.m
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController+Private.h"
#import "KBAuthManager.h"
#import "KBBackspaceUndoManager.h"
#import "KBChatMessage.h"
#import "KBChatPanelView.h"
#import "KBFunctionView.h"
#import "KBFullAccessManager.h"
#import "KBHostAppLauncher.h"
#import "KBInputBufferManager.h"
#import "KBKey.h"
#import "KBKeyBoardMainView.h"
#import "KBKeyboardSubscriptionView.h"
#import "KBSettingView.h"
#import "Masonry.h"
#import <SDWebImage/SDWebImage.h>
#import <AVFoundation/AVAudioPlayer.h>
@implementation KeyboardViewController (Panels)
#pragma mark - Panel Mode
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated {
if (mode == self.kb_panelMode) {
return;
}
KBKeyboardPanelMode fromMode = self.kb_panelMode;
// AI 访
if (mode == KBKeyboardPanelModeFunction &&
![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
return;
}
// mode Function
BOOL islogin = YES;
if (mode == KBKeyboardPanelModeFunction) {
[[KBAuthManager shared] reloadFromKeychain];
islogin = KBAuthManager.shared.isLoggedIn;
}
#if DEBUG
if (mode == KBKeyboardPanelModeFunction) {
NSString *token = [KBAuthManager shared].current.accessToken ?: @"";
NSLog(@"[AuthTrace][Ext] tapAI mode=%ld isLoggedIn=%d tokenLen=%lu",
(long)mode, islogin, (unsigned long)token.length);
}
#endif
if (mode == KBKeyboardPanelModeFunction && !islogin) {
[KBHUD showInfo:KBLocalized(@"请先登录后使用AI功能")];
NSString *schemeStr =
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
NSURL *scheme = [NSURL URLWithString:schemeStr];
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
if (!ok) {
[KBHUD showInfo:KBLocalized(@"请回到桌面手动打开App登录")];
}
return;
}
self.kb_panelMode = mode;
//
[self kb_ensureKeyBoardMainViewIfNeeded];
// 1) /
[self kb_setSubscriptionPanelVisible:NO animated:animated];
[self kb_setSettingViewVisible:NO animated:animated];
[self kb_setChatPanelVisible:NO animated:animated];
[self kb_setFunctionPanelVisible:NO];
// 2)
switch (mode) {
case KBKeyboardPanelModeFunction:
[self kb_setFunctionPanelVisible:YES];
break;
case KBKeyboardPanelModeChat:
[self kb_setChatPanelVisible:YES animated:animated];
break;
case KBKeyboardPanelModeSettings:
[self kb_setSettingViewVisible:YES animated:animated];
break;
case KBKeyboardPanelModeSubscription:
[self kb_setSubscriptionPanelVisible:YES animated:animated];
break;
case KBKeyboardPanelModeMain:
default:
break;
}
// 3) /
if (mode == KBKeyboardPanelModeFunction) {
[[KBMaiPointReporter sharedReporter]
reportPageExposureWithEventName:@"enter_keyboard_function_panel"
pageId:@"keyboard_function_panel"
extra:nil
completion:nil];
} else if (mode == KBKeyboardPanelModeMain &&
fromMode == KBKeyboardPanelModeFunction) {
[[KBMaiPointReporter sharedReporter]
reportPageExposureWithEventName:@"enter_keyboard_main_panel"
pageId:@"keyboard_main_panel"
extra:nil
completion:nil];
} else if (mode == KBKeyboardPanelModeSettings) {
[[KBMaiPointReporter sharedReporter]
reportPageExposureWithEventName:@"enter_keyboard_settings"
pageId:@"keyboard_settings"
extra:nil
completion:nil];
} else if (mode == KBKeyboardPanelModeSubscription) {
[[KBMaiPointReporter sharedReporter]
reportPageExposureWithEventName:@"enter_keyboard_subscription_panel"
pageId:@"keyboard_subscription_panel"
extra:nil
completion:nil];
}
// 4)
if (mode == KBKeyboardPanelModeSubscription) {
[self.contentView bringSubviewToFront:self.subscriptionView];
} else if (mode == KBKeyboardPanelModeSettings) {
[self.contentView bringSubviewToFront:self.settingView];
} else if (mode == KBKeyboardPanelModeChat) {
[self.contentView bringSubviewToFront:self.chatPanelView];
} else if (mode == KBKeyboardPanelModeFunction) {
[self.contentView bringSubviewToFront:self.functionView];
} else {
[self.contentView bringSubviewToFront:self.keyBoardMainView];
}
}
/// /
- (void)showFunctionPanel:(BOOL)show {
if (show) {
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
return;
}
if (self.kb_panelMode == KBKeyboardPanelModeFunction) {
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
}
}
/// / keyBoardMainView /
- (void)showSettingView:(BOOL)show {
if (show) {
[self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES];
return;
}
if (self.kb_panelMode == KBKeyboardPanelModeSettings) {
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
}
}
/// /
- (void)showChatPanel:(BOOL)show {
if (show) {
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
return;
}
if (self.kb_panelMode == KBKeyboardPanelModeChat) {
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
}
}
- (void)kb_setFunctionPanelVisible:(BOOL)visible {
if (visible) {
[self kb_ensureFunctionViewIfNeeded];
}
if (_functionView) {
_functionView.hidden = !visible;
} else if (visible) {
// ensure
self.functionView.hidden = NO;
}
self.keyBoardMainView.hidden = visible;
}
- (void)kb_setChatPanelVisible:(BOOL)visible animated:(BOOL)animated {
if (visible == self.chatPanelVisible) {
return;
}
self.chatPanelVisible = visible;
if (visible) {
// 宿
[[KBInputBufferManager shared]
refreshFromProxyIfPossible:self.textDocumentProxy];
self.chatPanelBaselineText = [KBInputBufferManager shared].liveText ?: @"";
[self kb_ensureChatPanelViewIfNeeded];
self.chatPanelView.hidden = NO;
self.chatPanelView.alpha = 0.0;
if (animated) {
[UIView animateWithDuration:0.2
delay:0
options:UIViewAnimationOptionCurveEaseOut
animations:^{
self.chatPanelView.alpha = 1.0;
}
completion:nil];
} else {
self.chatPanelView.alpha = 1.0;
}
} else {
// show/hide
if (!_chatPanelView) {
[self kb_updateKeyboardLayoutIfNeeded];
return;
}
if (animated) {
[UIView animateWithDuration:0.18
delay:0
options:UIViewAnimationOptionCurveEaseIn
animations:^{
self.chatPanelView.alpha = 0.0;
}
completion:^(BOOL finished) {
self.chatPanelView.hidden = YES;
}];
} else {
self.chatPanelView.alpha = 0.0;
self.chatPanelView.hidden = YES;
}
}
[self kb_updateKeyboardLayoutIfNeeded];
}
- (void)kb_setSettingViewVisible:(BOOL)visible animated:(BOOL)animated {
if (visible) {
KBSettingView *settingView = self.settingView;
if (!settingView.superview) {
settingView.hidden = YES;
[self.contentView addSubview:settingView];
[settingView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
[settingView.backButton addTarget:self
action:@selector(onTapSettingsBack)
forControlEvents:UIControlEventTouchUpInside];
}
[self.contentView bringSubviewToFront:settingView];
// keyBoardMainView self.view
[self.contentView layoutIfNeeded];
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
if (w <= 0) {
w = CGRectGetWidth(self.contentView.bounds);
}
if (w <= 0) {
w = [self kb_portraitWidth];
}
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
settingView.hidden = NO;
if (animated) {
[UIView animateWithDuration:0.25
delay:0
options:UIViewAnimationOptionCurveEaseOut
animations:^{
settingView.transform = CGAffineTransformIdentity;
}
completion:nil];
} else {
settingView.transform = CGAffineTransformIdentity;
}
} else {
KBSettingView *settingView = _settingView;
if (!settingView) {
return;
}
if (!settingView.superview || settingView.hidden) {
return;
}
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
if (w <= 0) {
w = CGRectGetWidth(self.contentView.bounds);
}
if (w <= 0) {
w = [self kb_portraitWidth];
}
if (animated) {
[UIView animateWithDuration:0.22
delay:0
options:UIViewAnimationOptionCurveEaseIn
animations:^{
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
}
completion:^(BOOL finished) {
settingView.hidden = YES;
}];
} else {
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
settingView.hidden = YES;
}
}
}
- (void)kb_setSubscriptionPanelVisible:(BOOL)visible animated:(BOOL)animated {
if (visible) {
KBKeyboardSubscriptionView *panel = self.subscriptionView;
if (!panel.superview) {
panel.hidden = YES;
[self.contentView addSubview:panel];
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
}
[self.contentView bringSubviewToFront:panel];
panel.hidden = NO;
panel.alpha = 0.0;
CGFloat height = CGRectGetHeight(self.contentView.bounds);
if (height <= 0) {
height = 260;
}
panel.transform = CGAffineTransformMakeTranslation(0, height);
[panel refreshProductsIfNeeded];
if (animated) {
[UIView animateWithDuration:0.25
delay:0
options:UIViewAnimationOptionCurveEaseOut
animations:^{
panel.alpha = 1.0;
panel.transform = CGAffineTransformIdentity;
}
completion:nil];
} else {
panel.alpha = 1.0;
panel.transform = CGAffineTransformIdentity;
}
return;
}
KBKeyboardSubscriptionView *panel = _subscriptionView;
if (!panel) {
return;
}
if (!panel.superview || panel.hidden) {
return;
}
CGFloat height = CGRectGetHeight(panel.bounds);
if (height <= 0) {
height = CGRectGetHeight(self.contentView.bounds);
}
if (animated) {
[UIView animateWithDuration:0.22
delay:0
options:UIViewAnimationOptionCurveEaseIn
animations:^{
panel.alpha = 0.0;
panel.transform = CGAffineTransformMakeTranslation(0, height);
}
completion:^(BOOL finished) {
panel.hidden = YES;
panel.alpha = 1.0;
panel.transform = CGAffineTransformIdentity;
}];
} else {
panel.hidden = YES;
panel.alpha = 1.0;
panel.transform = CGAffineTransformIdentity;
}
}
// /
- (void)kb_ensureFunctionViewIfNeeded {
if (_functionView && _functionView.superview) {
return;
}
KBFunctionView *v = self.functionView;
if (!v.superview) {
v.hidden = YES;
[self.contentView addSubview:v];
[v mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
}
}
// /
- (void)kb_ensureChatPanelViewIfNeeded {
if (_chatPanelView && _chatPanelView.superview) {
return;
}
CGFloat portraitWidth = [self kb_portraitWidth];
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
KBChatPanelView *v = self.chatPanelView;
if (!v.superview) {
[self.contentView addSubview:v];
[v mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.contentView);
make.bottom.equalTo(self.keyBoardMainView.mas_top);
self.chatPanelHeightConstraint =
make.height.mas_equalTo(chatPanelHeight);
}];
v.hidden = YES;
}
}
//
- (void)kb_ensureKeyBoardMainViewIfNeeded {
if (_keyBoardMainView && _keyBoardMainView.superview) {
return;
}
CGFloat portraitWidth = [self kb_portraitWidth];
CGFloat keyboardBaseHeight =
[self kb_keyboardBaseHeightForWidth:portraitWidth];
KBKeyBoardMainView *v = self.keyBoardMainView;
if (!v.superview) {
[self.contentView addSubview:v];
[v mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.contentView);
make.bottom.equalTo(self.contentView);
self.keyBoardMainHeightConstraint =
make.height.mas_equalTo(keyboardBaseHeight);
}];
}
[self.contentView bringSubviewToFront:v];
}
// //
- (void)kb_releaseMemoryWhenKeyboardHidden {
[KBHUD setContainerView:nil];
self.bgImageView.image = nil;
self.kb_cachedGradientImage = nil;
[self.kb_defaultGradientLayer removeFromSuperlayer];
self.kb_defaultGradientLayer = nil;
[[SDImageCache sharedImageCache] clearMemory];
// /
if (self.chatAudioPlayer) {
[self.chatAudioPlayer stop];
self.chatAudioPlayer = nil;
}
if (_chatMessages.count > 0) {
NSString *tmpRoot = NSTemporaryDirectory();
for (KBChatMessage *msg in _chatMessages.copy) {
if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 &&
[msg.audioFilePath hasPrefix:tmpRoot]) {
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
error:nil];
}
}
[_chatMessages removeAllObjects];
}
if (_keyBoardMainView) {
[_keyBoardMainView removeFromSuperview];
_keyBoardMainView = nil;
}
self.keyBoardMainHeightConstraint = nil;
if (_functionView) {
[_functionView removeFromSuperview];
_functionView = nil;
}
if (_chatPanelView) {
[_chatPanelView removeFromSuperview];
_chatPanelView = nil;
}
self.chatPanelVisible = NO;
self.kb_panelMode = KBKeyboardPanelModeMain;
if (_subscriptionView) {
[_subscriptionView removeFromSuperview];
_subscriptionView = nil;
}
if (_settingView) {
[_settingView removeFromSuperview];
_settingView = nil;
}
}
// MARK: - KBKeyBoardMainViewDelegate
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
didTapKey:(KBKey *)key {
switch (key.type) {
case KBKeyTypeCharacter: {
[[KBBackspaceUndoManager shared] registerNonClearAction];
NSString *text = key.output ?: key.title ?: @"";
[self.textDocumentProxy insertText:text];
[self kb_updateCurrentWordWithInsertedText:text];
[[KBInputBufferManager shared] appendText:text];
} break;
case KBKeyTypeBackspace:
[[KBInputBufferManager shared]
refreshFromProxyIfPossible:self.textDocumentProxy];
[[KBInputBufferManager shared]
prepareSnapshotForDeleteWithContextBefore:
self.textDocumentProxy.documentContextBeforeInput
after:
self.textDocumentProxy
.documentContextAfterInput];
[[KBBackspaceUndoManager shared]
captureAndDeleteBackwardFromProxy:self.textDocumentProxy
count:1];
[self kb_scheduleContextRefreshResetSuppression:NO];
[[KBInputBufferManager shared] applyHoldDeleteCount:1];
break;
case KBKeyTypeSpace:
[[KBBackspaceUndoManager shared] registerNonClearAction];
[self.textDocumentProxy insertText:@" "];
[self kb_clearCurrentWord];
[[KBInputBufferManager shared] appendText:@" "];
break;
case KBKeyTypeReturn:
if (self.chatPanelVisible) {
[self kb_handleChatSendAction];
break;
}
[[KBBackspaceUndoManager shared] registerNonClearAction];
[self.textDocumentProxy insertText:@"\n"];
[self kb_clearCurrentWord];
[[KBInputBufferManager shared] appendText:@"\n"];
break;
case KBKeyTypeGlobe:
[self advanceToNextInputMode];
break;
case KBKeyTypeCustom:
[[KBBackspaceUndoManager shared] registerNonClearAction];
//
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
[self kb_clearCurrentWord];
break;
case KBKeyTypeModeChange:
case KBKeyTypeShift:
// KBKeyBoardMainView/KBKeyboardView
break;
}
}
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
didTapToolActionAtIndex:(NSInteger)index {
NSDictionary *extra = @{@"index" : @(index)};
[[KBMaiPointReporter sharedReporter]
reportClickWithEventName:@"click_keyboard_toolbar_action"
pageId:@"keyboard_main_panel"
elementId:@"toolbar_action"
extra:extra
completion:nil];
if (index == 0) {
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:YES];
[self kb_clearCurrentWord];
return;
}
if (index == 1) {
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
return;
}
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
}
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
[[KBMaiPointReporter sharedReporter]
reportClickWithEventName:@"click_keyboard_settings_btn"
pageId:@"keyboard_main_panel"
elementId:@"settings_btn"
extra:nil
completion:nil];
[self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES];
}
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
didSelectEmoji:(NSString *)emoji {
if (emoji.length == 0) {
return;
}
[[KBBackspaceUndoManager shared] registerNonClearAction];
[self.textDocumentProxy insertText:emoji];
[self kb_clearCurrentWord];
[[KBInputBufferManager shared] appendText:emoji];
}
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
[[KBMaiPointReporter sharedReporter]
reportClickWithEventName:@"click_keyboard_undo_btn"
pageId:@"keyboard_main_panel"
elementId:@"undo_btn"
extra:nil
completion:nil];
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
[self kb_scheduleContextRefreshResetSuppression:YES];
}
- (void)keyBoardMainViewDidTapEmojiSearch:
(KBKeyBoardMainView *)keyBoardMainView {
// [[KBMaiPointReporter sharedReporter]
// reportClickWithEventName:@"click_keyboard_emoji_search_btn"
// pageId:@"keyboard_main_panel"
// elementId:@"emoji_search_btn"
// extra:nil
// completion:nil];
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
}
// MARK: - KBFunctionViewDelegate
- (void)functionView:(KBFunctionView *)functionView
didTapToolActionAtIndex:(NSInteger)index {
// index == 0
if (index == 0) {
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
}
}
- (void)functionView:(KBFunctionView *_Nullable)functionView
didRightTapToolActionAtIndex:(NSInteger)index {
[[KBMaiPointReporter sharedReporter]
reportClickWithEventName:@"click_keyboard_function_right_action"
pageId:@"keyboard_function_panel"
elementId:@"right_action"
extra:@{@"action" : @"login_or_recharge"}
completion:nil];
if (!KBAuthManager.shared.isLoggedIn) {
NSString *schemeStr =
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
NSURL *scheme = [NSURL URLWithString:schemeStr];
// UIApplication App
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
return;
}
NSString *schemeStr =
[NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME];
NSURL *scheme = [NSURL URLWithString:schemeStr];
// UIApplication App
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
if (!ok) {
//
// XXX App /
[KBHUD showInfo:@"请回到桌面手动打开App进行充值"];
}
}
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
[self showSubscriptionPanel];
}
#pragma mark - Actions
- (void)onTapSettingsBack {
[[KBMaiPointReporter sharedReporter]
reportClickWithEventName:@"click_keyboard_settings_back_btn"
pageId:@"keyboard_settings"
elementId:@"back_btn"
extra:nil
completion:nil];
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
}
@end

View File

@@ -0,0 +1,154 @@
//
// KeyboardViewController+Private.h
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController.h"
#import "Masonry.h"
@class AVAudioPlayer;
@class CAGradientLayer;
@class KBChatMessage;
@class KBChatPanelView;
@class KBFunctionView;
@class KBKeyBoardMainView;
@class KBKeyboardSubscriptionView;
@class KBSettingView;
@class KBSuggestionEngine;
@protocol KBChatLimitPopViewDelegate;
@protocol KBChatPanelViewDelegate;
@protocol KBFunctionViewDelegate;
@protocol KBKeyBoardMainViewDelegate;
@protocol KBKeyboardSubscriptionViewDelegate;
typedef NS_ENUM(NSInteger, KBKeyboardPanelMode) {
KBKeyboardPanelModeMain = 0,
KBKeyboardPanelModeFunction,
KBKeyboardPanelModeChat,
KBKeyboardPanelModeSettings,
KBKeyboardPanelModeSubscription,
};
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate,
KBFunctionViewDelegate,
KBKeyboardSubscriptionViewDelegate,
KBChatPanelViewDelegate,
KBChatLimitPopViewDelegate>
{
UIButton *_nextKeyboardButton;
UIView *_contentView;
KBKeyBoardMainView *_keyBoardMainView;
KBFunctionView *_functionView;
KBSettingView *_settingView;
UIImageView *_bgImageView;
KBChatPanelView *_chatPanelView;
KBKeyboardSubscriptionView *_subscriptionView;
KBSuggestionEngine *_suggestionEngine;
NSString *_currentWord;
UIControl *_chatLimitMaskView;
MASConstraint *_contentWidthConstraint;
MASConstraint *_contentHeightConstraint;
MASConstraint *_keyBoardMainHeightConstraint;
MASConstraint *_chatPanelHeightConstraint;
NSLayoutConstraint *_kb_heightConstraint;
NSLayoutConstraint *_kb_widthConstraint;
CGFloat _kb_lastPortraitWidth;
CGFloat _kb_lastKeyboardHeight;
UIImage *_kb_cachedGradientImage;
CGSize _kb_cachedGradientSize;
CAGradientLayer *_kb_defaultGradientLayer;
NSString *_kb_lastAppliedThemeKey;
NSMutableArray<KBChatMessage *> *_chatMessages;
AVAudioPlayer *_chatAudioPlayer;
BOOL _suppressSuggestions;
BOOL _chatPanelVisible;
NSString *_chatPanelBaselineText;
id _kb_fullAccessObserverToken;
id _kb_skinObserverToken;
KBKeyboardPanelMode _kb_panelMode;
}
@property(nonatomic, strong)
UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
@property(nonatomic, strong) UIView *contentView;
@property(nonatomic, strong) KBKeyBoardMainView
*keyBoardMainView; // 功能面板视图点击工具栏第0个时显示
@property(nonatomic, strong)
KBFunctionView *functionView; // 功能面板视图点击工具栏第0个时显示
@property(nonatomic, strong) KBSettingView *settingView; // 设置页
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
@property(nonatomic, strong) KBChatPanelView *chatPanelView;
@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine;
@property(nonatomic, copy) NSString *currentWord;
@property(nonatomic, assign) BOOL suppressSuggestions;
@property(nonatomic, strong) UIControl *chatLimitMaskView;
@property(nonatomic, strong) MASConstraint *contentWidthConstraint;
@property(nonatomic, strong) MASConstraint *contentHeightConstraint;
@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint;
@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint;
@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint;
@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint;
@property(nonatomic, assign) CGFloat kb_lastPortraitWidth;
@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight;
@property(nonatomic, strong) UIImage *kb_cachedGradientImage;
@property(nonatomic, assign) CGSize kb_cachedGradientSize;
@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer;
@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey;
@property(nonatomic, strong) NSMutableArray<KBChatMessage *> *chatMessages;
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
@property(nonatomic, assign) BOOL chatPanelVisible;
@property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本
@property(nonatomic, strong, nullable) id kb_fullAccessObserverToken;
@property(nonatomic, strong, nullable) id kb_skinObserverToken;
@property(nonatomic, assign) KBKeyboardPanelMode kb_panelMode;
@end
@interface KeyboardViewController (KBPrivate)
// UI
- (void)setupUI;
- (nullable KBFunctionView *)kb_functionViewIfCreated;
// Panels
- (void)showFunctionPanel:(BOOL)show;
- (void)showSettingView:(BOOL)show;
- (void)showChatPanel:(BOOL)show;
- (void)showSubscriptionPanel;
- (void)hideSubscriptionPanel;
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated;
- (void)kb_ensureFunctionViewIfNeeded;
- (void)kb_ensureChatPanelViewIfNeeded;
- (void)kb_ensureKeyBoardMainViewIfNeeded;
- (void)kb_releaseMemoryWhenKeyboardHidden;
// Suggestions
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text;
- (void)kb_clearCurrentWord;
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression;
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
(BOOL)resetSuppression;
- (void)kb_updateSuggestionsForCurrentWord;
// Chat
- (void)kb_handleChatSendAction;
// Theme
- (void)kb_applyTheme;
- (void)kb_applyDefaultSkinIfNeeded;
- (void)kb_consumePendingShopSkin;
- (void)kb_registerDarwinSkinInstallObserver;
- (void)kb_unregisterDarwinSkinInstallObserver;
// Layout
- (CGFloat)kb_portraitWidth;
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width;
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width;
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width;
- (void)kb_updateKeyboardLayoutIfNeeded;
@end

View File

@@ -0,0 +1,117 @@
//
// KeyboardViewController+Subscription.m
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController+Private.h"
#import "KBAuthManager.h"
#import "KBFullAccessManager.h"
#import "KBHostAppLauncher.h"
#import "KBKeyboardSubscriptionProduct.h"
#import "KBKeyboardSubscriptionView.h"
@implementation KeyboardViewController (Subscription)
- (void)showSubscriptionPanel {
// 1) 访
if (![[KBFullAccessManager shared] hasFullAccess]) {
// 访
// [KBHUD showInfo:KBLocalized(@"处理中…")];
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
return;
}
//
// 2) -> App App
if (!KBAuthManager.shared.isLoggedIn) {
NSString *schemeStr =
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
NSURL *scheme = [NSURL URLWithString:schemeStr];
// UIApplication App
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
return;
}
[self kb_setPanelMode:KBKeyboardPanelModeSubscription animated:YES];
}
- (void)hideSubscriptionPanel {
if (self.kb_panelMode != KBKeyboardPanelModeSubscription) {
return;
}
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
}
#pragma mark - KBKeyboardSubscriptionViewDelegate
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
[[KBMaiPointReporter sharedReporter]
reportClickWithEventName:@"click_keyboard_subscription_close_btn"
pageId:@"keyboard_subscription_panel"
elementId:@"close_btn"
extra:nil
completion:nil];
[self hideSubscriptionPanel];
}
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view
didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
if ([product.productId isKindOfClass:NSString.class] &&
product.productId.length > 0) {
extra[@"product_id"] = product.productId;
}
[[KBMaiPointReporter sharedReporter]
reportClickWithEventName:@"click_keyboard_subscription_product_btn"
pageId:@"keyboard_subscription_panel"
elementId:@"product_btn"
extra:extra.copy
completion:nil];
[self hideSubscriptionPanel];
[self kb_openRechargeForProduct:product];
}
#pragma mark - Actions
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] ||
product.productId.length == 0) {
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
return;
}
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
NSString *title = [product displayTitle];
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
NSMutableArray<NSString *> *params =
[NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
if (encodedId.length) {
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
}
if (encodedTitle.length) {
[params
addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
}
NSString *query = [params componentsJoinedByString:@"&"];
NSString *urlString = [NSString
stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
NSURL *scheme = [NSURL URLWithString:urlString];
BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
if (!success) {
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
}
}
+ (NSString *)kb_urlEncodedString:(NSString *)value {
if (value.length == 0) {
return @"";
}
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
NSMutableCharacterSet *allowed =
[[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
[allowed removeCharactersInString:reserved];
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed]
?: @"";
}
@end

View File

@@ -0,0 +1,178 @@
//
// KeyboardViewController+Suggestions.m
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController+Private.h"
#import "KBBackspaceUndoManager.h"
#import "KBInputBufferManager.h"
#import "KBKeyBoardMainView.h"
#import "KBSuggestionEngine.h"
@implementation KeyboardViewController (Suggestions)
// MARK: - Suggestions
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text {
if (text.length == 0) {
return;
}
if ([self kb_isAlphabeticString:text]) {
NSString *current = self.currentWord ?: @"";
self.currentWord = [current stringByAppendingString:text];
self.suppressSuggestions = NO;
[self kb_updateSuggestionsForCurrentWord];
} else {
[self kb_clearCurrentWord];
}
}
- (void)kb_clearCurrentWord {
self.currentWord = @"";
[self.keyBoardMainView kb_setSuggestions:@[]];
self.suppressSuggestions = NO;
}
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression {
dispatch_async(dispatch_get_main_queue(), ^{
[self kb_refreshCurrentWordFromDocumentContextResetSuppression:
resetSuppression];
});
}
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
(BOOL)resetSuppression {
NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @"";
NSString *word = [self kb_extractTrailingWordFromContext:context];
self.currentWord = word ?: @"";
if (resetSuppression) {
self.suppressSuggestions = NO;
}
[self kb_updateSuggestionsForCurrentWord];
}
- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context {
if (context.length == 0) {
return @"";
}
static NSCharacterSet *letters = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
letters = [NSCharacterSet
characterSetWithCharactersInString:
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
});
NSInteger idx = (NSInteger)context.length - 1;
while (idx >= 0) {
unichar ch = [context characterAtIndex:(NSUInteger)idx];
if (![letters characterIsMember:ch]) {
break;
}
idx -= 1;
}
NSUInteger start = (NSUInteger)(idx + 1);
if (start >= context.length) {
return @"";
}
return [context substringFromIndex:start];
}
- (BOOL)kb_isAlphabeticString:(NSString *)text {
if (text.length == 0) {
return NO;
}
static NSCharacterSet *letters = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
letters = [NSCharacterSet
characterSetWithCharactersInString:
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
});
for (NSUInteger i = 0; i < text.length; i++) {
if (![letters characterIsMember:[text characterAtIndex:i]]) {
return NO;
}
}
return YES;
}
- (void)kb_updateSuggestionsForCurrentWord {
NSString *prefix = self.currentWord ?: @"";
if (prefix.length == 0) {
[self.keyBoardMainView kb_setSuggestions:@[]];
return;
}
if (self.suppressSuggestions) {
[self.keyBoardMainView kb_setSuggestions:@[]];
return;
}
NSArray<NSString *> *items =
[self.suggestionEngine suggestionsForPrefix:prefix limit:5];
NSArray<NSString *> *cased = [self kb_applyCaseToSuggestions:items
prefix:prefix];
[self.keyBoardMainView kb_setSuggestions:cased];
}
- (NSArray<NSString *> *)kb_applyCaseToSuggestions:(NSArray<NSString *> *)items
prefix:(NSString *)prefix {
if (items.count == 0 || prefix.length == 0) {
return items;
}
BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString];
BOOL firstUpper = [[prefix substringToIndex:1]
isEqualToString:[[prefix substringToIndex:1] uppercaseString]];
if (!allUpper && !firstUpper) {
return items;
}
NSMutableArray<NSString *> *result =
[NSMutableArray arrayWithCapacity:items.count];
for (NSString *word in items) {
if (allUpper) {
[result addObject:word.uppercaseString];
} else {
NSString *first = [[word substringToIndex:1] uppercaseString];
NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @"";
[result addObject:[first stringByAppendingString:rest]];
}
}
return result.copy;
}
// MARK: - KBKeyBoardMainViewDelegate (Suggestion)
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
didSelectSuggestion:(NSString *)suggestion {
if (suggestion.length == 0) {
return;
}
NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)};
// [[KBMaiPointReporter sharedReporter]
// reportClickWithEventName:@"click_keyboard_suggestion_item"
// pageId:@"keyboard_main_panel"
// elementId:@"suggestion_item"
// extra:extra
// completion:nil];
[[KBBackspaceUndoManager shared] registerNonClearAction];
NSString *current = self.currentWord ?: @"";
if (current.length > 0) {
for (NSUInteger i = 0; i < current.length; i++) {
[self.textDocumentProxy deleteBackward];
}
}
[self.textDocumentProxy insertText:suggestion];
self.currentWord = suggestion;
[self.suggestionEngine recordSelection:suggestion];
self.suppressSuggestions = YES;
[self.keyBoardMainView kb_setSuggestions:@[]];
[[KBInputBufferManager shared] replaceTailWithText:suggestion
deleteCount:current.length];
}
@end

View File

@@ -0,0 +1,376 @@
//
// KeyboardViewController+Theme.m
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController+Private.h"
#import "KBFunctionView.h"
#import "KBKeyBoardMainView.h"
#import "KBSkinInstallBridge.h"
#import "KBSkinManager.h"
#import "UIImage+KBColor.h"
#import <QuartzCore/QuartzCore.h>
static NSString *const kKBDefaultSkinIdLight = @"normal_them";
static NSString *const kKBDefaultSkinZipNameLight = @"normal_them";
static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them";
static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them";
// 使 static kb_consumePendingShopSkin
@interface KeyboardViewController (KBSkinShopBridge)
- (void)kb_consumePendingShopSkin;
@end
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
void *observer, CFStringRef name,
const void *object,
CFDictionaryRef userInfo) {
KeyboardViewController *strongSelf =
(__bridge KeyboardViewController *)observer;
if (!strongSelf) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
[strongSelf kb_consumePendingShopSkin];
}
});
}
@implementation KeyboardViewController (Theme)
- (void)kb_registerDarwinSkinInstallObserver {
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(self), KBSkinInstallNotificationCallback,
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL,
CFNotificationSuspensionBehaviorDeliverImmediately);
}
- (void)kb_unregisterDarwinSkinInstallObserver {
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(self),
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL);
}
- (void)kb_applyTheme {
@autoreleasepool {
KBSkinTheme *t = [KBSkinManager shared].current;
UIImage *img = nil;
BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t];
BOOL isDarkMode = [self kb_isDarkModeActive];
NSString *skinId = t.skinId ?: @"";
NSString *themeKey =
[NSString stringWithFormat:@"%@|default=%d|dark=%d", skinId,
isDefaultTheme, isDarkMode];
BOOL themeChanged =
(self.kb_lastAppliedThemeKey.length == 0 ||
![self.kb_lastAppliedThemeKey isEqualToString:themeKey]);
if (themeChanged) {
self.kb_lastAppliedThemeKey = themeKey;
}
CGSize size = self.bgImageView.bounds.size;
if (isDefaultTheme) {
if (isDarkMode) {
// 使使
//
img = nil;
self.bgImageView.image = nil;
[self.kb_defaultGradientLayer removeFromSuperlayer];
self.kb_defaultGradientLayer = nil;
// 使
if (@available(iOS 13.0, *)) {
// iOS 使 (RGB: 44, 44, 46 in sRGB, #2C2C2E)
// 使
UIColor *kbBgColor =
[UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
UITraitCollection *_Nonnull traitCollection) {
if (traitCollection.userInterfaceStyle ==
UIUserInterfaceStyleDark) {
//
return [UIColor colorWithRed:43.0 / 255.0
green:43.0 / 255.0
blue:43.0 / 255.0
alpha:1.0];
} else {
return [UIColor colorWithRed:209.0 / 255.0
green:211.0 / 255.0
blue:219.0 / 255.0
alpha:1.0];
}
}];
self.contentView.backgroundColor = kbBgColor;
self.bgImageView.backgroundColor = kbBgColor;
} else {
UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0
green:43.0 / 255.0
blue:43.0 / 255.0
alpha:1.0];
self.contentView.backgroundColor = darkColor;
self.bgImageView.backgroundColor = darkColor;
}
} else {
// 使
if (size.width <= 0 || size.height <= 0) {
[self.view layoutIfNeeded];
size = self.bgImageView.bounds.size;
}
if (size.width <= 0 || size.height <= 0) {
size = self.view.bounds.size;
}
if (size.width <= 0 || size.height <= 0) {
size = [UIScreen mainScreen].bounds.size;
}
UIColor *topColor = [UIColor colorWithHex:0xDEDFE4];
UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB];
UIColor *resolvedTopColor = topColor;
UIColor *resolvedBottomColor = bottomColor;
if (@available(iOS 13.0, *)) {
resolvedTopColor =
[topColor resolvedColorWithTraitCollection:self.traitCollection];
resolvedBottomColor =
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
}
CAGradientLayer *layer = self.kb_defaultGradientLayer;
if (!layer) {
layer = [CAGradientLayer layer];
layer.startPoint = CGPointMake(0.5, 0.0);
layer.endPoint = CGPointMake(0.5, 1.0);
[self.bgImageView.layer insertSublayer:layer atIndex:0];
self.kb_defaultGradientLayer = layer;
}
layer.colors =
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
layer.frame = (CGRect){CGPointZero, size};
img = nil;
self.bgImageView.image = nil;
self.contentView.backgroundColor = [UIColor clearColor];
self.bgImageView.backgroundColor = [UIColor clearColor];
}
NSLog(@"===");
} else {
// 使
self.contentView.backgroundColor = [UIColor clearColor];
self.bgImageView.backgroundColor = [UIColor clearColor];
[self.kb_defaultGradientLayer removeFromSuperlayer];
self.kb_defaultGradientLayer = nil;
img = [[KBSkinManager shared] currentBackgroundImage];
}
NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil));
[self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img];
self.bgImageView.image = img;
//
if (themeChanged &&
[self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
// method declared in KBKeyBoardMainView.h
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
#pragma clang diagnostic pop
}
// 访 self.functionView
KBFunctionView *functionView = [self kb_functionViewIfCreated];
if (themeChanged && functionView &&
[functionView respondsToSelector:@selector(kb_applyTheme)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[functionView performSelector:@selector(kb_applyTheme)];
#pragma clang diagnostic pop
}
}
}
- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme {
NSString *skinId = theme.skinId ?: @"";
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
return YES;
}
if ([skinId isEqualToString:kKBDefaultSkinIdLight]) {
return YES;
}
return [skinId isEqualToString:kKBDefaultSkinIdDark];
}
- (BOOL)kb_isDarkModeActive {
if (@available(iOS 13.0, *)) {
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
}
return NO;
}
- (NSString *)kb_defaultSkinIdForCurrentStyle {
return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark
: kKBDefaultSkinIdLight;
}
- (NSString *)kb_defaultSkinZipNameForCurrentStyle {
return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark
: kKBDefaultSkinZipNameLight;
}
- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size
topColor:(UIColor *)topColor
bottomColor:(UIColor *)bottomColor {
if (size.width <= 0 || size.height <= 0) {
return nil;
}
//
if (self.kb_cachedGradientImage &&
CGSizeEqualToSize(self.kb_cachedGradientSize, size)) {
return self.kb_cachedGradientImage;
}
UIColor *resolvedTopColor = topColor;
UIColor *resolvedBottomColor = bottomColor;
if (@available(iOS 13.0, *)) {
resolvedTopColor =
[topColor resolvedColorWithTraitCollection:self.traitCollection];
resolvedBottomColor =
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
}
CAGradientLayer *layer = [CAGradientLayer layer];
layer.frame = CGRectMake(0, 0, size.width, size.height);
layer.startPoint = CGPointMake(0.5, 0.0);
layer.endPoint = CGPointMake(0.5, 1.0);
layer.colors =
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
UIGraphicsBeginImageContextWithOptions(size, YES, 0);
[layer renderInContext:UIGraphicsGetCurrentContext()];
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
self.kb_cachedGradientImage = image;
self.kb_cachedGradientSize = size;
return image;
}
- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme
backgroundImage:(UIImage *)image {
#if DEBUG
NSString *skinId = theme.skinId ?: @"";
NSString *name = theme.name ?: @"";
NSMutableArray<NSString *> *roots = [NSMutableArray array];
NSURL *containerURL = [[NSFileManager defaultManager]
containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (containerURL.path.length > 0) {
[roots addObject:containerURL.path];
}
NSString *cacheRoot = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES)
.firstObject;
if (cacheRoot.length > 0) {
[roots addObject:cacheRoot];
}
NSFileManager *fm = [NSFileManager defaultManager];
NSMutableArray<NSString *> *lines = [NSMutableArray array];
for (NSString *root in roots) {
NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"]
stringByAppendingPathComponent:skinId];
iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"];
BOOL isDir = NO;
BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir;
NSArray *contents =
exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil;
NSUInteger count = contents.count;
BOOL hasQ =
exists &&
[fm fileExistsAtPath:[iconsDir
stringByAppendingPathComponent:@"key_q.png"]];
BOOL hasQUp =
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
@"key_q_up.png"]];
BOOL hasDel =
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
@"key_del.png"]];
BOOL hasShift =
exists &&
[fm fileExistsAtPath:[iconsDir
stringByAppendingPathComponent:@"key_up.png"]];
BOOL hasShiftUpper =
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
@"key_up_upper.png"]];
NSString *line = [NSString
stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d "
@"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d",
root, iconsDir, exists, count, hasQ, hasQUp, hasDel,
hasShift, hasShiftUpper];
[lines addObject:line];
}
NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name,
(image != nil), [lines componentsJoinedByString:@"\n"]);
#endif
}
- (void)kb_consumePendingShopSkin {
KBWeakSelf [KBSkinInstallBridge
consumePendingRequestFromBundle:NSBundle.mainBundle
completion:^(BOOL success,
NSError *_Nullable error) {
if (!success) {
if (error) {
NSLog(@"[Keyboard] skin request failed: %@",
error);
[KBHUD
showInfo:
KBLocalized(
@"皮肤资源准备失败,请稍后再试")];
}
return;
}
[weakSelf kb_applyTheme];
[KBHUD showInfo:KBLocalized(
@"皮肤已更新,立即体验吧")];
}];
}
- (void)kb_applyDefaultSkinIfNeeded {
NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload];
if (pending.count > 0) {
return;
}
NSString *currentId = [KBSkinManager shared].current.skinId ?: @"";
BOOL isDefault =
(currentId.length == 0 || [currentId isEqualToString:@"default"]);
BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight];
BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark];
if (!isDefault && !isLightDefault && !isDarkDefault) {
//
return;
}
NSString *targetId = [self kb_defaultSkinIdForCurrentStyle];
NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle];
if (currentId.length > 0 && [currentId isEqualToString:targetId]) {
return;
}
NSError *applyError = nil;
if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId error:&applyError]) {
return;
}
[KBSkinInstallBridge publishBundleSkinRequestWithId:targetId
name:targetId
zipName:targetZip
iconShortNames:nil];
[KBSkinInstallBridge
consumePendingRequestFromBundle:NSBundle.mainBundle
completion:^(__unused BOOL success,
__unused NSError *_Nullable error) {
//
}];
}
@end

View File

@@ -0,0 +1,151 @@
//
// KeyboardViewController+UI.m
// CustomKeyboard
//
// Created by Codex on 2026/02/22.
//
#import "KeyboardViewController+Private.h"
#import "KBChatMessage.h"
#import "KBChatPanelView.h"
#import "KBFunctionView.h"
#import "KBKeyBoardMainView.h"
#import "KBKeyboardSubscriptionView.h"
#import "KBSettingView.h"
#import "Masonry.h"
@implementation KeyboardViewController (UI)
- (void)setupUI {
self.view.translatesAutoresizingMaskIntoConstraints = NO;
//
CGFloat portraitWidth = [self kb_portraitWidth];
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
// FIX: iOS 26
// iOS 26 self.view view
// view
// UI
// 0
// viewWillAppear:
// iOS 18
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:0];
NSLayoutConstraint *w =
[self.view.widthAnchor constraintEqualToConstant:screenWidth];
self.kb_heightConstraint = h;
self.kb_widthConstraint = w;
h.priority = UILayoutPriorityRequired;
w.priority = UILayoutPriorityRequired;
[NSLayoutConstraint activateConstraints:@[ h, w ]];
// UIInputView
if ([self.view isKindOfClass:[UIInputView class]]) {
UIInputView *iv = (UIInputView *)self.view;
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
iv.allowsSelfSizing = NO;
}
}
//
[self.view addSubview:self.contentView];
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerX.equalTo(self.view);
make.bottom.equalTo(self.view);
self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth);
self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight);
}];
//
[self.contentView addSubview:self.bgImageView];
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
[self.contentView addSubview:self.keyBoardMainView];
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.contentView);
make.bottom.equalTo(self.contentView);
self.keyBoardMainHeightConstraint =
make.height.mas_equalTo(keyboardBaseHeight);
}];
//
self.contentView.hidden = YES;
}
#pragma mark - Lazy
- (nullable KBFunctionView *)kb_functionViewIfCreated {
return _functionView;
}
- (UIView *)contentView {
if (!_contentView) {
_contentView = [[UIView alloc] init];
_contentView.backgroundColor = [UIColor clearColor];
}
return _contentView;
}
- (UIImageView *)bgImageView {
if (!_bgImageView) {
_bgImageView = [[UIImageView alloc] init];
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
_bgImageView.clipsToBounds = YES;
}
return _bgImageView;
}
- (KBKeyBoardMainView *)keyBoardMainView {
if (!_keyBoardMainView) {
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
_keyBoardMainView.delegate = self;
}
return _keyBoardMainView;
}
- (KBFunctionView *)functionView {
if (!_functionView) {
_functionView = [[KBFunctionView alloc] init];
_functionView.delegate = self; // Bar
}
return _functionView;
}
- (KBSettingView *)settingView {
if (!_settingView) {
_settingView = [[KBSettingView alloc] init];
}
return _settingView;
}
- (KBChatPanelView *)chatPanelView {
if (!_chatPanelView) {
NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!");
_chatPanelView = [[KBChatPanelView alloc] init];
_chatPanelView.delegate = self;
}
return _chatPanelView;
}
- (NSMutableArray<KBChatMessage *> *)chatMessages {
if (!_chatMessages) {
_chatMessages = [NSMutableArray array];
}
return _chatMessages;
}
- (KBKeyboardSubscriptionView *)subscriptionView {
if (!_subscriptionView) {
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
_subscriptionView.delegate = self;
_subscriptionView.hidden = YES;
_subscriptionView.alpha = 0.0;
}
return _subscriptionView;
}
@end

View File

@@ -0,0 +1,46 @@
//
// KBEmojiDataProvider.h
// CustomKeyboard
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
FOUNDATION_EXPORT NSString * const KBEmojiRecentsDidChangeNotification;
@class KBEmojiCategory, KBEmojiItem;
@interface KBEmojiItem : NSObject <NSCopying>
@property (nonatomic, copy, readonly) NSString *value;
@property (nonatomic, copy, readonly) NSString *name;
- (instancetype)initWithValue:(NSString *)value name:(NSString *)name;
@end
@interface KBEmojiCategory : NSObject
@property (nonatomic, copy, readonly) NSString *identifier;
@property (nonatomic, copy, readonly) NSString *displayTitle;
@property (nonatomic, copy, readonly) NSString *iconSymbol;
@property (nonatomic, assign, readonly, getter=isDynamic) BOOL dynamic;
@property (nonatomic, copy, readonly) NSArray<KBEmojiItem *> *items;
@end
@interface KBEmojiDataProvider : NSObject
+ (instancetype)shared;
/// 所有分类(按系统顺序),包含“常用”。
@property (nonatomic, copy, readonly) NSArray<KBEmojiCategory *> *categories;
/// 记录一次 emoji 选择,并刷新“常用”分类。
- (void)recordEmojiSelection:(NSString *)emoji;
/// 重新加载 JSON若首次调用
- (void)reloadIfNeeded;
/// 更新当前语言对应的分类标题。
- (void)refreshLocalizedTitles;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,270 @@
//
// KBEmojiDataProvider.m
// CustomKeyboard
//
#import "KBEmojiDataProvider.h"
#import "KBLocalizationManager.h"
#import "KBConfig.h"
NSString * const KBEmojiRecentsDidChangeNotification = @"KBEmojiRecentsDidChangeNotification";
static NSString * const kKBEmojiJSONFileName = @"emoji_categories";
static NSString * const kKBEmojiRecentsStoreKey = @"KBEmojiRecentEmojis";
static NSString * const kKBEmojiRecentsCategoryId = @"recents";
static const NSUInteger kKBEmojiRecentsLimit = 32;
#pragma mark - Model Implementations
@interface KBEmojiItem ()
@property (nonatomic, copy, readwrite) NSString *value;
@property (nonatomic, copy, readwrite) NSString *name;
@end
@implementation KBEmojiItem
- (instancetype)initWithValue:(NSString *)value name:(NSString *)name {
if (self = [super init]) {
_value = [value copy];
_name = [name copy];
}
return self;
}
- (id)copyWithZone:(NSZone *)zone {
KBEmojiItem *item = [[[self class] allocWithZone:zone] initWithValue:self.value name:self.name];
return item;
}
@end
@interface KBEmojiCategory ()
@property (nonatomic, copy, readwrite) NSString *identifier;
@property (nonatomic, copy) NSDictionary<NSString *, NSString *> *titleMap;
@property (nonatomic, copy, readwrite) NSString *displayTitle;
@property (nonatomic, copy, readwrite) NSString *iconSymbol;
@property (nonatomic, assign, readwrite, getter=isDynamic) BOOL dynamic;
@property (nonatomic, copy, readwrite) NSArray<KBEmojiItem *> *items;
@end
@implementation KBEmojiCategory
- (void)refreshDisplayTitleForLanguage:(NSString *)lang {
if (lang.length == 0) {
lang = KBLanguageCodeEnglish;
}
NSString *title = self.titleMap[lang];
if (title.length == 0) {
if ([lang.lowercaseString hasPrefix:@"zh"]) {
title = self.titleMap[@"zh-Hans"] ?: self.titleMap[@"zh-hans"];
}
}
if (title.length == 0) {
title = self.titleMap[@"en"];
}
if (title.length == 0) {
title = self.titleMap.allValues.firstObject;
}
self.displayTitle = title ?: @"";
}
@end
#pragma mark - Data Provider
@interface KBEmojiDataProvider ()
@property (nonatomic, copy) NSArray<KBEmojiCategory *> *categoriesInternal;
@property (nonatomic, strong) NSMutableDictionary<NSString *, KBEmojiItem *> *itemLookup;
@property (nonatomic, strong) NSMutableOrderedSet<NSString *> *recentValues;
@end
@implementation KBEmojiDataProvider
+ (instancetype)shared {
static KBEmojiDataProvider *m;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
m = [KBEmojiDataProvider new];
[[NSNotificationCenter defaultCenter] addObserver:m
selector:@selector(onLocalizationChanged:)
name:KBLocalizationDidChangeNotification
object:nil];
});
return m;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (NSArray<KBEmojiCategory *> *)categories {
[self reloadIfNeeded];
return self.categoriesInternal ?: @[];
}
- (void)reloadIfNeeded {
if (self.categoriesInternal.count > 0) { return; }
[self loadEmojiJSON];
[self refreshLocalizedTitles];
[self loadRecentsFromStore];
[self rebuildRecentsCategory];
}
- (void)loadEmojiJSON {
NSString *path = [[NSBundle mainBundle] pathForResource:kKBEmojiJSONFileName ofType:@"json"];
if (path.length == 0) {
return;
}
NSData *data = [NSData dataWithContentsOfFile:path];
if (data.length == 0) { return; }
NSError *err = nil;
NSDictionary *root = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
if (!root || err) {
NSLog(@"[Emoji] failed to parse json: %@", err);
return;
}
NSArray *catArray = root[@"categories"];
if (![catArray isKindOfClass:NSArray.class]) {
return;
}
NSMutableArray<KBEmojiCategory *> *tmpCats = [NSMutableArray arrayWithCapacity:catArray.count];
self.itemLookup = [NSMutableDictionary dictionary];
for (NSDictionary *catDict in catArray) {
if (![catDict isKindOfClass:NSDictionary.class]) continue;
KBEmojiCategory *category = [KBEmojiCategory new];
category.identifier = catDict[@"id"] ?: @"";
NSDictionary *titleMap = catDict[@"title"];
if ([titleMap isKindOfClass:NSDictionary.class]) {
category.titleMap = titleMap;
} else {
category.titleMap = @{};
}
NSString *iconKey = catDict[@"icon"];
category.iconSymbol = [self symbolForIconKey:iconKey];
NSString *type = catDict[@"type"];
category.dynamic = [type.lowercaseString isEqualToString:@"dynamic"];
NSArray *emojiArray = catDict[@"emojis"];
NSMutableArray<KBEmojiItem *> *items = [NSMutableArray arrayWithCapacity:[emojiArray count]];
if ([emojiArray isKindOfClass:NSArray.class]) {
for (NSDictionary *emojiDict in emojiArray) {
if (![emojiDict isKindOfClass:NSDictionary.class]) continue;
NSString *value = emojiDict[@"value"];
if (value.length == 0) continue;
NSString *name = emojiDict[@"name"] ?: @"";
KBEmojiItem *item = [[KBEmojiItem alloc] initWithValue:value name:name];
[items addObject:item];
if (value.length > 0) {
self.itemLookup[value] = item;
}
}
}
category.items = items.copy;
[tmpCats addObject:category];
}
self.categoriesInternal = tmpCats.copy;
}
- (NSString *)symbolForIconKey:(NSString *)key {
static NSDictionary<NSString *, NSString *> *map;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
map = @{
@"emoji_tab_recent": @"🕘",
@"emoji_tab_people": @"😊",
@"emoji_tab_nature": @"🌿",
@"emoji_tab_food": @"🍔",
@"emoji_tab_activity": @"🏀",
@"emoji_tab_travel": @"✈️",
@"emoji_tab_objects": @"💡",
@"emoji_tab_symbols": @"♾",
@"emoji_tab_flags": @"🏳️"
};
});
NSString *symbol = map[key];
return symbol.length ? symbol : @"●";
}
- (void)refreshLocalizedTitles {
NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
for (KBEmojiCategory *cat in self.categoriesInternal) {
[cat refreshDisplayTitleForLanguage:lang];
}
}
- (void)onLocalizationChanged:(__unused NSNotification *)note {
[self refreshLocalizedTitles];
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
}
- (void)recordEmojiSelection:(NSString *)emoji {
if (emoji.length == 0) return;
[self reloadIfNeeded];
if (!self.recentValues) {
self.recentValues = [NSMutableOrderedSet orderedSet];
}
[self.recentValues removeObject:emoji];
[self.recentValues insertObject:emoji atIndex:0];
while (self.recentValues.count > kKBEmojiRecentsLimit) {
[self.recentValues removeObjectAtIndex:self.recentValues.count - 1];
}
[self saveRecentsToStore];
[self rebuildRecentsCategory];
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
}
- (void)loadRecentsFromStore {
NSUserDefaults *defs = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
if (!defs) { defs = NSUserDefaults.standardUserDefaults; }
NSArray *stored = [defs objectForKey:kKBEmojiRecentsStoreKey];
NSMutableOrderedSet *set = [NSMutableOrderedSet orderedSet];
if ([stored isKindOfClass:NSArray.class]) {
for (id obj in stored) {
if (![obj isKindOfClass:NSString.class]) continue;
NSString *str = (NSString *)obj;
if (str.length == 0) continue;
[set addObject:str];
if (set.count >= kKBEmojiRecentsLimit) break;
}
}
self.recentValues = set;
}
- (void)saveRecentsToStore {
if (!self.recentValues) return;
NSArray *arr = self.recentValues.array;
NSUserDefaults *defs = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
if (!defs) { defs = NSUserDefaults.standardUserDefaults; }
[defs setObject:arr forKey:kKBEmojiRecentsStoreKey];
[defs synchronize];
}
- (void)rebuildRecentsCategory {
KBEmojiCategory *recent = [self categoryForIdentifier:kKBEmojiRecentsCategoryId];
if (!recent) return;
NSArray<NSString *> *values = self.recentValues.array ?: @[];
NSMutableArray<KBEmojiItem *> *items = [NSMutableArray arrayWithCapacity:values.count];
for (NSString *value in values) {
KBEmojiItem *item = self.itemLookup[value];
if (!item) {
item = [[KBEmojiItem alloc] initWithValue:value name:@""];
}
[items addObject:item];
}
recent.items = items.copy;
}
- (KBEmojiCategory *)categoryForIdentifier:(NSString *)identifier {
if (identifier.length == 0) return nil;
for (KBEmojiCategory *cat in self.categoriesInternal) {
if ([cat.identifier isEqualToString:identifier]) {
return cat;
}
}
return nil;
}
@end

View File

@@ -0,0 +1,23 @@
//
// KBSuggestionEngine.h
// CustomKeyboard
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Simple local suggestion engine (prefix match + lightweight ranking).
@interface KBSuggestionEngine : NSObject
+ (instancetype)shared;
/// Returns suggestions for prefix (lowercase expected), limited by count.
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit;
/// Record a selection to slightly boost ranking next time.
- (void)recordSelection:(NSString *)word;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,167 @@
//
// KBSuggestionEngine.m
// CustomKeyboard
//
#import "KBSuggestionEngine.h"
#import "KBConfig.h"
@interface KBSuggestionEngine ()
@property (nonatomic, copy) NSArray<NSString *> *words;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *selectionCounts;
@property (nonatomic, strong) NSSet<NSString *> *priorityWords;
@end
@implementation KBSuggestionEngine
+ (instancetype)shared {
static KBSuggestionEngine *engine;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
engine = [[KBSuggestionEngine alloc] init];
});
return engine;
}
- (instancetype)init {
if (self = [super init]) {
_selectionCounts = [NSMutableDictionary dictionary];
NSArray<NSString *> *defaults = [self.class kb_defaultWords];
_priorityWords = [NSSet setWithArray:defaults];
_words = [self kb_loadWords];
}
return self;
}
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
if (prefix.length == 0 || limit == 0) { return @[]; }
NSString *lower = prefix.lowercaseString;
NSMutableArray<NSString *> *matches = [NSMutableArray array];
for (NSString *word in self.words) {
if ([word hasPrefix:lower]) {
[matches addObject:word];
if (matches.count >= limit * 3) {
// Avoid scanning too many matches for long lists.
break;
}
}
}
if (matches.count == 0) { return @[]; }
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
NSInteger ca = self.selectionCounts[a].integerValue;
NSInteger cb = self.selectionCounts[b].integerValue;
if (ca != cb) {
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
}
BOOL pa = [self.priorityWords containsObject:a];
BOOL pb = [self.priorityWords containsObject:b];
if (pa != pb) {
return pa ? NSOrderedAscending : NSOrderedDescending;
}
return [a compare:b];
}];
if (matches.count > limit) {
return [matches subarrayWithRange:NSMakeRange(0, limit)];
}
return matches.copy;
}
- (void)recordSelection:(NSString *)word {
if (word.length == 0) { return; }
NSString *key = word.lowercaseString;
NSInteger count = self.selectionCounts[key].integerValue + 1;
self.selectionCounts[key] = @(count);
}
#pragma mark - Defaults
- (NSArray<NSString *> *)kb_loadWords {
NSMutableOrderedSet<NSString *> *set = [[NSMutableOrderedSet alloc] init];
[set addObjectsFromArray:[self.class kb_defaultWords]];
NSArray<NSString *> *paths = [self kb_wordListPaths];
for (NSString *path in paths) {
if (path.length == 0) { continue; }
NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
if (content.length == 0) { continue; }
NSArray<NSString *> *lines = [content componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
for (NSString *line in lines) {
NSString *word = [self kb_sanitizedWordFromLine:line];
if (word.length == 0) { continue; }
[set addObject:word];
}
}
NSArray<NSString *> *result = set.array ?: @[];
return result;
}
- (NSArray<NSString *> *)kb_wordListPaths {
NSMutableArray<NSString *> *paths = [NSMutableArray array];
// 1) App Group override (allows server-downloaded large list).
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (containerURL.path.length > 0) {
NSString *groupPath = [[containerURL path] stringByAppendingPathComponent:@"kb_words.txt"];
[paths addObject:groupPath];
}
// 2) Bundle fallback.
NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"kb_words" ofType:@"txt"];
if (bundlePath.length > 0) {
[paths addObject:bundlePath];
}
return paths;
}
- (NSString *)kb_sanitizedWordFromLine:(NSString *)line {
NSString *trimmed = [[line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString];
if (trimmed.length == 0) { return @""; }
static NSCharacterSet *letters = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
letters = [NSCharacterSet characterSetWithCharactersInString:@"abcdefghijklmnopqrstuvwxyz"];
});
for (NSUInteger i = 0; i < trimmed.length; i++) {
if (![letters characterIsMember:[trimmed characterAtIndex:i]]) {
return @"";
}
}
return trimmed;
}
+ (NSArray<NSString *> *)kb_defaultWords {
return @[
@"a", @"an", @"and", @"are", @"as", @"at",
@"app", @"ap", @"apple", @"apply", @"april", @"application",
@"about", @"above", @"after", @"again", @"against", @"all",
@"am", @"among", @"amount", @"any", @"around",
@"be", @"because", @"been", @"before", @"being", @"below",
@"best", @"between", @"both", @"but", @"by",
@"can", @"could", @"come", @"common", @"case",
@"do", @"does", @"down", @"day",
@"each", @"early", @"end", @"even", @"every",
@"for", @"from", @"first", @"found", @"free",
@"get", @"good", @"great", @"go",
@"have", @"has", @"had", @"help", @"how",
@"in", @"is", @"it", @"if", @"into",
@"just", @"keep", @"kind", @"know",
@"like", @"look", @"long", @"last",
@"make", @"more", @"most", @"my",
@"new", @"no", @"not", @"now",
@"of", @"on", @"one", @"or", @"other", @"our", @"out",
@"people", @"place", @"please",
@"quick", @"quite",
@"right", @"read", @"real",
@"see", @"say", @"some", @"such", @"so",
@"the", @"to", @"this", @"that", @"them", @"then", @"there", @"they", @"these", @"time",
@"use", @"up", @"under",
@"very",
@"we", @"with", @"what", @"when", @"where", @"who", @"why", @"will", @"would",
@"you", @"your"
];
}
@end

View File

@@ -0,0 +1,49 @@
//
// KBChatMessage.h
// CustomKeyboard
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface KBChatMessage : NSObject
@property (nonatomic, copy) NSString *text;
@property (nonatomic, assign) BOOL outgoing;
@property (nonatomic, copy, nullable) NSString *audioFilePath;
@property (nonatomic, copy, nullable) NSString *avatarURL;
@property (nonatomic, copy, nullable) NSString *displayName;
@property (nonatomic, strong, nullable) UIImage *avatarImage;
/// 是否处于加载状态
@property (nonatomic, assign) BOOL isLoading;
/// 是否完成(用于打字机效果)
@property (nonatomic, assign) BOOL isComplete;
/// 是否需要打字机效果
@property (nonatomic, assign) BOOL needsTypewriterEffect;
/// 音频 ID用于异步加载音频
@property (nonatomic, copy, nullable) NSString *audioId;
/// 音频数据(缓存)
@property (nonatomic, strong, nullable) NSData *audioData;
/// 音频时长(秒)
@property (nonatomic, assign) NSTimeInterval audioDuration;
+ (instancetype)messageWithText:(NSString *)text
outgoing:(BOOL)outgoing
audioFilePath:(nullable NSString *)audioFilePath;
/// 创建用户消息
+ (instancetype)userMessageWithText:(NSString *)text;
/// 创建 AI 消息(带 audioId
+ (instancetype)assistantMessageWithText:(NSString *)text
audioId:(nullable NSString *)audioId;
/// 创建加载中的 AI 消息
+ (instancetype)loadingAssistantMessage;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,55 @@
//
// KBChatMessage.m
// CustomKeyboard
//
#import "KBChatMessage.h"
@implementation KBChatMessage
+ (instancetype)messageWithText:(NSString *)text
outgoing:(BOOL)outgoing
audioFilePath:(NSString *)audioFilePath {
KBChatMessage *msg = [[KBChatMessage alloc] init];
msg.text = text ?: @"";
msg.outgoing = outgoing;
msg.audioFilePath = audioFilePath;
msg.isComplete = YES;
msg.isLoading = NO;
msg.needsTypewriterEffect = NO;
return msg;
}
+ (instancetype)userMessageWithText:(NSString *)text {
KBChatMessage *msg = [[KBChatMessage alloc] init];
msg.text = text ?: @"";
msg.outgoing = YES;
msg.isComplete = YES;
msg.isLoading = NO;
msg.needsTypewriterEffect = NO;
return msg;
}
+ (instancetype)assistantMessageWithText:(NSString *)text
audioId:(NSString *)audioId {
KBChatMessage *msg = [[KBChatMessage alloc] init];
msg.text = text ?: @"";
msg.outgoing = NO;
msg.audioId = audioId;
msg.isComplete = NO;
msg.isLoading = NO;
msg.needsTypewriterEffect = YES;
return msg;
}
+ (instancetype)loadingAssistantMessage {
KBChatMessage *msg = [[KBChatMessage alloc] init];
msg.text = @"";
msg.outgoing = NO;
msg.isComplete = NO;
msg.isLoading = YES;
msg.needsTypewriterEffect = NO;
return msg;
}
@end

View File

@@ -16,17 +16,35 @@ typedef NS_ENUM(NSInteger, KBKeyType) {
KBKeyTypeSpace, // 空格
KBKeyTypeReturn, // 回车/发送
KBKeyTypeGlobe, // 系统地球键
KBKeyTypeCustom, // 自定义功能占位
KBKeyTypeCustom, // 自定义功能占位(如 AI/Emoji
KBKeyTypeSymbolsToggle // 数字面板内的“#+=/123”切换
};
FOUNDATION_EXPORT NSString * const KBKeyIdentifierEmojiPanel;
/// 字母键的大小写变体标记(非字母键使用 KBKeyCaseVariantNone
typedef NS_ENUM(NSInteger, KBKeyCaseVariant) {
KBKeyCaseVariantNone = 0,
KBKeyCaseVariantLower = 1,
KBKeyCaseVariantUpper = 2,
};
@interface KBKey : NSObject
@property (nonatomic, assign) KBKeyType type;
@property (nonatomic, copy) NSString *title; // 显示标题
@property (nonatomic, copy) NSString *output; // 字符键插入的文本
/// 逻辑按键标识,用于皮肤映射(如 @"letter_q" @"space" @"backspace"
@property (nonatomic, copy, nullable) NSString *identifier;
/// 字母键的大小写变体(便于皮肤为大小写准备不同图)
@property (nonatomic, assign) KBKeyCaseVariant caseVariant;
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output;
+ (instancetype)keyWithTitle:(NSString *)title type:(KBKeyType)type;
/// 通用构造方法:用于指定 identifier便于皮肤做精细控制
+ (instancetype)keyWithIdentifier:(nullable NSString *)identifier
title:(NSString *)title
output:(NSString *)output
type:(KBKeyType)type;
@end

View File

@@ -5,6 +5,8 @@
#import "KBKey.h"
NSString * const KBKeyIdentifierEmojiPanel = @"emoji_panel";
@implementation KBKey
+ (instancetype)keyWithTitle:(NSString *)title output:(NSString *)output {
@@ -12,6 +14,7 @@
k.type = KBKeyTypeCharacter;
k.title = title ?: @"";
k.output = output ?: title ?: @"";
k.caseVariant = KBKeyCaseVariantNone;
return k;
}
@@ -20,8 +23,21 @@
k.type = type;
k.title = title ?: @"";
k.output = @"";
k.caseVariant = KBKeyCaseVariantNone;
return k;
}
+ (instancetype)keyWithIdentifier:(NSString *)identifier
title:(NSString *)title
output:(NSString *)output
type:(KBKeyType)type {
KBKey *k = [[KBKey alloc] init];
k.type = type;
k.identifier = identifier;
k.title = title ?: @"";
k.output = output ?: @"";
k.caseVariant = KBKeyCaseVariantNone;
return k;
}
@end

View File

@@ -0,0 +1,96 @@
//
// KBKeyboardLayoutConfig.h
// CustomKeyboard
//
// 键盘布局配置模型(由 JSON 驱动)
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface KBKeyboardLayoutMetrics : NSObject
@property (nonatomic, strong, nullable) NSNumber *rowSpacing;
@property (nonatomic, strong, nullable) NSNumber *topInset;
@property (nonatomic, strong, nullable) NSNumber *bottomInset;
@property (nonatomic, strong, nullable) NSNumber *keyHeight;
@property (nonatomic, strong, nullable) NSNumber *edgeInset;
@property (nonatomic, strong, nullable) NSNumber *gap;
@property (nonatomic, strong, nullable) NSNumber *letterWidth;
@property (nonatomic, strong, nullable) NSNumber *controlWidth;
@property (nonatomic, strong, nullable) NSNumber *sendWidth;
@property (nonatomic, strong, nullable) NSNumber *symbolsWideWidth;
@property (nonatomic, strong, nullable) NSNumber *symbolsSideWidth;
@end
@interface KBKeyboardLayoutFonts : NSObject
@property (nonatomic, strong, nullable) NSNumber *letter;
@property (nonatomic, strong, nullable) NSNumber *digit;
@property (nonatomic, strong, nullable) NSNumber *symbol;
@property (nonatomic, strong, nullable) NSNumber *mode;
@property (nonatomic, strong, nullable) NSNumber *space;
@property (nonatomic, strong, nullable) NSNumber *send;
@end
@interface KBKeyboardKeyDef : NSObject
@property (nonatomic, copy, nullable) NSString *type;
@property (nonatomic, copy, nullable) NSString *title;
@property (nonatomic, copy, nullable) NSString *selectedTitle;
@property (nonatomic, copy, nullable) NSString *symbolName;
@property (nonatomic, copy, nullable) NSString *selectedSymbolName;
@property (nonatomic, copy, nullable) NSString *font;
@property (nonatomic, copy, nullable) NSString *width;
@property (nonatomic, strong, nullable) NSNumber *widthValue;
@property (nonatomic, copy, nullable) NSString *backgroundColor;
@end
@interface KBKeyboardRowItem : NSObject
@property (nonatomic, copy, nullable) NSString *itemId;
@property (nonatomic, copy, nullable) NSString *width;
@property (nonatomic, strong, nullable) NSNumber *widthValue;
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw;
@end
@interface KBKeyboardRowSegments : NSObject
@property (nonatomic, strong, nullable) NSArray *left;
@property (nonatomic, strong, nullable) NSArray *center;
@property (nonatomic, strong, nullable) NSArray *right;
- (NSArray<KBKeyboardRowItem *> *)leftItems;
- (NSArray<KBKeyboardRowItem *> *)centerItems;
- (NSArray<KBKeyboardRowItem *> *)rightItems;
@end
@interface KBKeyboardRowConfig : NSObject
@property (nonatomic, strong, nullable) NSNumber *height;
@property (nonatomic, strong, nullable) NSNumber *insetLeft;
@property (nonatomic, strong, nullable) NSNumber *insetRight;
@property (nonatomic, strong, nullable) NSNumber *gap;
@property (nonatomic, copy, nullable) NSString *align;
@property (nonatomic, strong, nullable) NSArray *items;
@property (nonatomic, strong, nullable) KBKeyboardRowSegments *segments;
- (NSArray<KBKeyboardRowItem *> *)resolvedItems;
@end
@interface KBKeyboardLayout : NSObject
@property (nonatomic, strong, nullable) NSArray<KBKeyboardRowConfig *> *rows;
@end
@interface KBKeyboardLayoutConfig : NSObject
@property (nonatomic, assign) CGFloat designWidth;
@property (nonatomic, strong, nullable) KBKeyboardLayoutMetrics *metrics;
@property (nonatomic, strong, nullable) KBKeyboardLayoutFonts *fonts;
@property (nonatomic, copy, nullable) NSString *defaultKeyBackground;
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardKeyDef *> *keyDefs;
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardLayout *> *layouts;
+ (nullable instancetype)sharedConfig;
+ (nullable instancetype)configFromJSONData:(NSData *)data;
- (CGFloat)scaledValue:(CGFloat)designValue;
- (CGFloat)keyboardAreaDesignHeight;
- (CGFloat)keyboardAreaScaledHeight;
- (nullable KBKeyboardLayout *)layoutForName:(NSString *)name;
- (nullable KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,187 @@
//
// KBKeyboardLayoutConfig.m
// CustomKeyboard
//
#import "KBKeyboardLayoutConfig.h"
#import <MJExtension/MJExtension.h>
#import "KBConfig.h"
static NSString * const kKBKeyboardLayoutConfigFileName = @"kb_keyboard_layout_config";
@implementation KBKeyboardLayoutMetrics
@end
@implementation KBKeyboardLayoutFonts
@end
@implementation KBKeyboardKeyDef
@end
@implementation KBKeyboardRowItem
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
return @{ @"itemId": @"id" };
}
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw {
if (![raw isKindOfClass:[NSArray class]] || raw.count == 0) {
return @[];
}
NSMutableArray<KBKeyboardRowItem *> *items = [NSMutableArray arrayWithCapacity:raw.count];
for (id obj in raw) {
if ([obj isKindOfClass:[NSString class]]) {
KBKeyboardRowItem *item = [KBKeyboardRowItem new];
item.itemId = (NSString *)obj;
[items addObject:item];
continue;
}
if ([obj isKindOfClass:[NSDictionary class]]) {
KBKeyboardRowItem *item = [KBKeyboardRowItem mj_objectWithKeyValues:obj];
if (item.itemId.length == 0) {
NSString *fallback = ((NSDictionary *)obj)[@"id"];
if ([fallback isKindOfClass:[NSString class]]) {
item.itemId = fallback;
}
}
if (item.itemId.length > 0) {
[items addObject:item];
}
}
}
return items.copy;
}
@end
@implementation KBKeyboardRowSegments
- (NSArray<KBKeyboardRowItem *> *)leftItems {
return [KBKeyboardRowItem itemsFromRawArray:self.left ?: @[]];
}
- (NSArray<KBKeyboardRowItem *> *)centerItems {
return [KBKeyboardRowItem itemsFromRawArray:self.center ?: @[]];
}
- (NSArray<KBKeyboardRowItem *> *)rightItems {
return [KBKeyboardRowItem itemsFromRawArray:self.right ?: @[]];
}
@end
@implementation KBKeyboardRowConfig
- (NSArray<KBKeyboardRowItem *> *)resolvedItems {
return [KBKeyboardRowItem itemsFromRawArray:self.items ?: @[]];
}
@end
@implementation KBKeyboardLayout
+ (NSDictionary *)mj_objectClassInArray {
return @{ @"rows": [KBKeyboardRowConfig class] };
}
@end
@implementation KBKeyboardLayoutConfig
+ (instancetype)sharedConfig {
static KBKeyboardLayoutConfig *config = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *path = [[NSBundle mainBundle] pathForResource:kKBKeyboardLayoutConfigFileName ofType:@"json"];
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
config = data ? [KBKeyboardLayoutConfig configFromJSONData:data] : nil;
});
return config;
}
+ (instancetype)configFromJSONData:(NSData *)data {
if (data.length == 0) { return nil; }
NSError *error = nil;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (error || ![json isKindOfClass:[NSDictionary class]]) {
return nil;
}
NSDictionary *dict = (NSDictionary *)json;
KBKeyboardLayoutConfig *config = [KBKeyboardLayoutConfig mj_objectWithKeyValues:dict];
NSDictionary *keyDefsRaw = dict[@"keyDefs"];
if ([keyDefsRaw isKindOfClass:[NSDictionary class]]) {
NSMutableDictionary<NSString *, KBKeyboardKeyDef *> *defs = [NSMutableDictionary dictionaryWithCapacity:keyDefsRaw.count];
[keyDefsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
return;
}
KBKeyboardKeyDef *def = [KBKeyboardKeyDef mj_objectWithKeyValues:obj];
if (def) {
defs[key] = def;
}
}];
config.keyDefs = defs.copy;
}
NSDictionary *layoutsRaw = dict[@"layouts"];
if ([layoutsRaw isKindOfClass:[NSDictionary class]]) {
NSMutableDictionary<NSString *, KBKeyboardLayout *> *layouts = [NSMutableDictionary dictionaryWithCapacity:layoutsRaw.count];
[layoutsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
return;
}
KBKeyboardLayout *layout = [KBKeyboardLayout mj_objectWithKeyValues:obj];
if (layout) {
layouts[key] = layout;
}
}];
config.layouts = layouts.copy;
}
return config;
}
- (CGFloat)scaledValue:(CGFloat)designValue {
CGFloat baseWidth = (self.designWidth > 0.0) ? self.designWidth : KB_DESIGN_WIDTH;
CGFloat scale = KBScreenWidth() / baseWidth;
return designValue * scale;
}
- (CGFloat)keyboardAreaDesignHeight {
KBKeyboardLayout *layout = [self layoutForName:@"letters"] ?: self.layouts.allValues.firstObject;
NSUInteger rowCount = layout.rows.count;
if (rowCount == 0) { return 0.0; }
CGFloat rowSpacing = self.metrics.rowSpacing.doubleValue;
CGFloat topInset = self.metrics.topInset.doubleValue;
CGFloat bottomInset = self.metrics.bottomInset.doubleValue;
CGFloat total = topInset + bottomInset + rowSpacing * (rowCount - 1);
for (KBKeyboardRowConfig *row in layout.rows) {
CGFloat height = row.height.doubleValue;
if (height <= 0.0) {
height = self.metrics.keyHeight.doubleValue;
}
if (height <= 0.0) { height = 40.0; }
total += height;
}
return total;
}
- (CGFloat)keyboardAreaScaledHeight {
CGFloat designHeight = [self keyboardAreaDesignHeight];
return designHeight > 0.0 ? [self scaledValue:designHeight] : 0.0;
}
- (KBKeyboardLayout *)layoutForName:(NSString *)name {
if (name.length == 0) { return nil; }
return self.layouts[name];
}
- (KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier {
if (identifier.length == 0) { return nil; }
return self.keyDefs[identifier];
}
@end

View File

@@ -0,0 +1,43 @@
//
// KBKeyboardSubscriptionProduct.h
// CustomKeyboard
//
// 订阅商品模型(键盘扩展专用),用于展示与主 App 相同的订阅列表。
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface KBKeyboardSubscriptionProduct : NSObject
/// 主键 id
@property (nonatomic, assign) NSInteger identifier;
/// Apple 商品编号
@property (nonatomic, copy, nullable) NSString *productId;
/// 商品名称,如 Monthly
@property (nonatomic, copy, nullable) NSString *name;
/// 单位,如 Subscription
@property (nonatomic, copy, nullable) NSString *unit;
/// 商品描述
@property (nonatomic, copy, nullable) NSString *productDescription;
/// 货币符号
@property (nonatomic, copy, nullable) NSString *currency;
/// 现价
@property (nonatomic, assign) double price;
/// 原价(如接口未返回,则回退为 price 的 1.25 倍)
@property (nonatomic, assign) double originPrice;
/// 有效期数值
@property (nonatomic, assign) NSInteger durationValue;
/// 有效期单位
@property (nonatomic, copy, nullable) NSString *durationUnit;
/// 标题(描述 > name+unit > name > unit
- (NSString *)displayTitle;
/// 当前价格文本
- (NSString *)priceDisplayText;
/// 划线价文本
- (nullable NSString *)strikePriceDisplayText;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,55 @@
//
// KBKeyboardSubscriptionProduct.m
// CustomKeyboard
//
#import "KBKeyboardSubscriptionProduct.h"
#import <MJExtension/MJExtension.h>
#import "KBLocalizationManager.h"
@implementation KBKeyboardSubscriptionProduct
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
return @{
@"identifier": @"id",
@"productDescription": @"description",
};
}
- (NSString *)displayTitle {
if (self.productDescription.length > 0) {
return self.productDescription;
}
NSString *name = self.name ?: @"";
NSString *unit = self.unit ?: @"";
if (name.length && unit.length) {
return [NSString stringWithFormat:@"%@ %@", name, unit];
}
if (name.length) { return name; }
if (unit.length) { return unit; }
if (self.durationValue > 0 && self.durationUnit.length > 0) {
return [NSString stringWithFormat:@"%ld %@", (long)self.durationValue, self.durationUnit];
}
return KBLocalized(@"Subscription");
}
- (NSString *)priceDisplayText {
double priceValue = self.price;
if (priceValue <= 0) {
return @"$0.00";
}
NSString *currency = self.currency.length ? self.currency : @"$";
return [NSString stringWithFormat:@"%@%.2f", currency, priceValue];
}
- (nullable NSString *)strikePriceDisplayText {
double rawValue = self.originPrice;
if (rawValue <= 0 && self.price > 0) {
rawValue = self.price * 1.25;
}
if (rawValue <= 0) { return nil; }
NSString *currency = self.currency.length ? self.currency : @"$";
return [NSString stringWithFormat:@"%@%.2f", currency, rawValue];
}
@end

View File

@@ -19,8 +19,15 @@ typedef NS_ERROR_ENUM(KBNetworkErrorDomain, KBNetworkError) {
KBNetworkErrorDecodeFailed = 4,
};
/// 简单的 JSON 回调json 为 NSDictionary/NSArray 或者在非 JSON 情况下返回 NSData
typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nullable response, NSError * _Nullable error);
/// JSON 回调(扩展侧目前很少使用 JSON可按需扩展
typedef void(^KBNetworkCompletion)(NSDictionary *_Nullable json,
NSURLResponse * _Nullable response,
NSError * _Nullable error);
/// 二进制回调:用于下载 zip、图片等原始数据
typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
NSURLResponse *_Nullable response,
NSError *_Nullable error);
@interface KBNetworkManager : NSObject
@@ -45,13 +52,27 @@ typedef void(^KBNetworkCompletion)(id _Nullable jsonOrData, NSURLResponse * _Nul
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
completion:(KBNetworkCompletion)completion;
/// GET 原始二进制数据(不做 JSON 解析)
- (nullable NSURLSessionDataTask *)GETData:(NSString *)path
parameters:(nullable NSDictionary *)parameters
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
completion:(KBNetworkDataCompletion)completion;
/// POST JSON 请求jsonBody 会以 application/json 发送
- (nullable NSURLSessionDataTask *)POST:(NSString *)path
jsonBody:(nullable id)jsonBody
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
completion:(KBNetworkCompletion)completion;
/// POST multipart 上传文件(常用于语音/图片等文件)
- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
fileURL:(NSURL *)fileURL
name:(NSString *)name
mimeType:(NSString *)mimeType
parameters:(nullable NSDictionary *)parameters
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
completion:(KBNetworkCompletion)completion;
@end
NS_ASSUME_NONNULL_END

View File

@@ -6,7 +6,8 @@
#import "KBNetworkManager.h"
#import "AFNetworking.h"
#import "KBAuthManager.h"
//#import "KBUserSessionManager.h"
#import "KBSignUtils.h"
NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
@interface KBNetworkManager ()
@@ -26,37 +27,59 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
if (self = [super init]) {
_enabled = NO; //
_timeout = 10.0;
_defaultHeaders = @{ @"Accept": @"application/json" };
// Accept + 使 Accept-Language
NSString *lang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
// NSString *token = [KBUserSessionManager shared].accessToken ? [KBUserSessionManager shared].accessToken : @"";
_defaultHeaders = @{
@"Accept": @"*/*",
@"Accept-Language": lang
};
//
_baseURL = [NSURL URLWithString:KB_BASE_URL];
}
return self;
}
- (void)getSignWithParare:(NSDictionary *)bodyParams{
NSDictionary<NSString *, NSString *> *signHeaders = [KBSignUtils signHeadersWithBodyParams:bodyParams];
NSMutableDictionary<NSString *, NSString *> *headers =
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
[headers addEntriesFromDictionary:signHeaders ?: @{}];
self.defaultHeaders = headers;
}
#pragma mark - Public
- (NSURLSessionDataTask *)GET:(NSString *)path
parameters:(NSDictionary *)parameters
headers:(NSDictionary<NSString *,NSString *> *)headers
completion:(KBNetworkCompletion)completion {
[self getSignWithParare:parameters];
if (![self ensureEnabled:completion]) return nil;
NSString *urlString = [self buildURLStringWithPath:path];
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
// 使 AFHTTPRequestSerializer
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
serializer.timeoutInterval = self.timeout;
NSError *serror = nil;
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
URLString:urlString
parameters:parameters
error:NULL];
error:&serror];
if (serror || !req) {
if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
return nil;
}
[self applyHeaders:headers toMutableRequest:req contentType:nil];
return [self startAFTaskWithRequest:req completion:completion];
return [self startAFJSONTaskWithRequest:req completion:completion];
}
- (NSURLSessionDataTask *)POST:(NSString *)path
jsonBody:(id)jsonBody
headers:(NSDictionary<NSString *,NSString *> *)headers
completion:(KBNetworkCompletion)completion {
[self getSignWithParare:jsonBody];
if (![self ensureEnabled:completion]) return nil;
NSString *urlString = [self buildURLStringWithPath:path];
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
@@ -70,14 +93,127 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
error:&error];
if (error) { if (completion) completion(nil, nil, error); return nil; }
[self applyHeaders:headers toMutableRequest:req contentType:nil];
return [self startAFTaskWithRequest:req completion:completion];
return [self startAFJSONTaskWithRequest:req completion:completion];
}
- (NSURLSessionDataTask *)uploadFile:(NSString *)path
fileURL:(NSURL *)fileURL
name:(NSString *)name
mimeType:(NSString *)mimeType
parameters:(NSDictionary *)parameters
headers:(NSDictionary<NSString *, NSString *> *)headers
completion:(KBNetworkCompletion)completion {
[self getSignWithParare:parameters];
if (![self ensureEnabled:completion]) return nil;
NSString *urlString = [self buildURLStringWithPath:path];
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
if (!fileURL) {
if (completion) completion(nil, nil, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]);
return nil;
}
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
serializer.timeoutInterval = self.timeout;
NSError *error = nil;
NSMutableURLRequest *req = [serializer multipartFormRequestWithMethod:@"POST"
URLString:urlString
parameters:parameters
constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
NSString *safeName = (name.length > 0) ? name : @"file";
NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin";
NSString *type = (mimeType.length > 0) ? mimeType : @"application/octet-stream";
[formData appendPartWithFileURL:fileURL name:safeName fileName:fileName mimeType:type error:nil];
} error:&error];
if (error || !req) {
if (completion) completion(nil, nil, error ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
return nil;
}
[self applyHeaders:headers toMutableRequest:req contentType:nil];
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSURLSessionUploadTask *task = [self.manager uploadTaskWithStreamedRequest:req progress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
if (error) {
if (completion) completion(nil, response, error);
return;
}
NSData *data = (NSData *)responseObject;
if (![data isKindOfClass:[NSData class]]) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
return;
}
NSString *ct = nil;
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
}
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
if (!looksJSON) {
const unsigned char *bytes = data.bytes;
NSUInteger len = data.length;
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
unsigned char c = bytes[i];
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
looksJSON = (c == '{' || c == '[');
break;
}
}
if (looksJSON) {
NSError *jsonErr = nil;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
if (jsonErr) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
return;
}
if (![json isKindOfClass:[NSDictionary class]]) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
return;
}
if (completion) completion((NSDictionary *)json, response, nil);
} else {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
}
}];
[task resume];
return task;
}
- (NSURLSessionDataTask *)GETData:(NSString *)path
parameters:(NSDictionary *)parameters
headers:(NSDictionary<NSString *,NSString *> *)headers
completion:(KBNetworkDataCompletion)completion {
[self getSignWithParare:parameters];
if (!self.isEnabled) {
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
code:KBNetworkErrorDisabled
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}];
if (completion) completion(nil, nil, e);
return nil;
}
NSString *urlString = [self buildURLStringWithPath:path];
if (!urlString) {
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain
code:KBNetworkErrorInvalidURL
userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}];
if (completion) completion(nil, nil, e);
return nil;
}
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
serializer.timeoutInterval = self.timeout;
NSError *serror = nil;
NSMutableURLRequest *req = [serializer requestWithMethod:@"GET"
URLString:urlString
parameters:parameters
error:&serror];
if (serror || !req) {
if (completion) completion(nil, nil, serror ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
return nil;
}
[self applyHeaders:headers toMutableRequest:req contentType:nil];
return [self startAFDataTaskWithRequest:req completion:completion];
}
#pragma mark - Core
- (BOOL)ensureEnabled:(KBNetworkCompletion)completion {
if (!self.isEnabled) {
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: @"网络未启用(可能未开启完全访问)"}];
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDisabled userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Network disabled (Full Access may be off)")}];
if (completion) completion(nil, nil, e);
return NO;
}
@@ -90,7 +226,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
return path;
}
if (self.baseURL) {
return [NSURL URLWithString:path relativeToURL:self.baseURL].absoluteURL.absoluteString;
// base / path / base
NSString *base = self.baseURL.absoluteString ?: @"";
if (![base hasSuffix:@"/"]) { base = [base stringByAppendingString:@"/"]; }
NSURL *dirBase = [NSURL URLWithString:base];
NSString *relative = ([path hasPrefix:@"/"]) ? [path substringFromIndex:1] : path;
return [NSURL URLWithString:relative relativeToURL:dirBase].absoluteURL.absoluteString;
}
return path; // baseURL path URL AFN
}
@@ -98,6 +239,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
- (void)applyHeaders:(NSDictionary<NSString *,NSString *> *)headers toMutableRequest:(NSMutableURLRequest *)req contentType:(NSString *)contentType {
//
NSMutableDictionary *all = [self.defaultHeaders mutableCopy] ?: [NSMutableDictionary new];
NSString *token = [KBAuthManager shared].current.accessToken;
if (token.length > 0) {
all[@"auth-token"] = token;
} else {
[all removeObjectForKey:@"auth-token"];
}
NSDictionary *auth = [[KBAuthManager shared] authorizationHeader];
[auth enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { all[key] = obj; }];
if (contentType) all[@"Content-Type"] = contentType;
@@ -105,42 +252,82 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
[all enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj, BOOL *stop) { [req setValue:obj forHTTPHeaderField:key]; }];
}
- (NSURLSessionDataTask *)startAFTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion {
- (NSURLSessionDataTask *)startAFJSONTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkCompletion)completion {
// Content-Type JSON
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
if (error) { if (completion) completion(nil, response, error); return; }
// AFN 2xx error
if (error) {
if (completion) completion(nil, response, error);
return;
}
NSData *data = (NSData *)responseObject;
if (![data isKindOfClass:[NSData class]]) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:@"无数据"}]);
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
return;
}
NSString *ct = nil;
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
}
BOOL looksJSON = (ct && [ct.lowercaseString containsString:@"application/json"]);
// JSON Content-Type json { / [
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
if (!looksJSON) {
//
const unsigned char *bytes = data.bytes;
NSUInteger len = data.length;
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
unsigned char c = bytes[i];
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
looksJSON = (c == '{' || c == '[');
break;
}
}
if (looksJSON) {
NSError *jsonErr = nil;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
if (jsonErr) { if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:@"JSON解析失败"}]); return; }
if (completion) completion(json, response, nil);
if (jsonErr) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
return;
}
if (![json isKindOfClass:[NSDictionary class]]) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
return;
}
if (completion) completion((NSDictionary *)json, response, nil);
} else {
if (completion) completion(data, response, nil);
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
}
}];
[task resume];
return task;
}
- (NSURLSessionDataTask *)startAFDataTaskWithRequest:(NSURLRequest *)req completion:(KBNetworkDataCompletion)completion {
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
NSURLSessionDataTask *task = [self.manager dataTaskWithRequest:req uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
if (error) {
if (completion) completion(nil, response, error);
return;
}
NSData *data = (NSData *)responseObject;
if (![data isKindOfClass:[NSData class]]) {
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
return;
}
if (completion) completion(data, response, nil);
}];
[task resume];
return task;
}
#pragma mark - AFHTTPSessionManager
- (AFHTTPSessionManager *)manager {
if (!_manager) {
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration ephemeralSessionConfiguration];
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
cfg.timeoutIntervalForRequest = self.timeout;
cfg.timeoutIntervalForResource = MAX(self.timeout, 30.0);
// per-request serializer.timeoutInterval
if (@available(iOS 11.0, *)) { cfg.waitsForConnectivity = YES; }
_manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:cfg];
// 使 JSON
@@ -152,12 +339,12 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
#pragma mark - Private helpers
- (void)fail:(KBNetworkError)code completion:(KBNetworkCompletion)completion {
NSString *msg = @"网络错误";
NSString *msg = KBLocalized(@"Network error");
switch (code) {
case KBNetworkErrorDisabled: msg = @"网络未启用(可能未开启完全访问)"; break;
case KBNetworkErrorInvalidURL: msg = @"无效的URL"; break;
case KBNetworkErrorInvalidResponse: msg = @"无效的响应"; break;
case KBNetworkErrorDecodeFailed: msg = @"解析失败"; break;
case KBNetworkErrorDisabled: msg = KBLocalized(@"Network disabled (Full Access may be off)"); break;
case KBNetworkErrorInvalidURL: msg = KBLocalized(@"Invalid URL"); break;
case KBNetworkErrorInvalidResponse: msg = KBLocalized(@"Invalid response"); break;
case KBNetworkErrorDecodeFailed: msg = KBLocalized(@"Parse failed"); break;
default: break;
}
NSError *e = [NSError errorWithDomain:KBNetworkErrorDomain

View File

@@ -0,0 +1,60 @@
//
// KBStreamFetcher.h
// CustomKeyboard
//
// 轻量网络流拉取器:支持纯文本分块与 SSE(text/event-stream) 两种形式的“边下边显”。
// - 增量解码:按 UTF-8 安全前缀逐步转成字符串,避免半个多字节字符导致阻塞/乱码
// - SSE 解析:按 \n\n 切事件,合并 data: 行,移除前缀,仅回传正文
// - 兼容后端“/t”作为分段标记可自动替换为制表符“\t”
// - 首段去首个“\t”若首次正文以一个制表符起始允许前导空白可只移除“一个”\t
//
// 暂未使用
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^KBStreamFetcherChunkHandler)(NSString *chunk);
typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
@interface KBStreamFetcher : NSObject <NSURLSessionDataDelegate>
// 便利构造
+ (instancetype)fetcherWithURL:(NSURL *)url;
// 必填:请求地址
@property (nonatomic, strong) NSURL *url;
/// HTTP Method默认为 GET
@property (nonatomic, copy, nullable) NSString *httpMethod;
/// 自定义请求体(例如 POST 的 JSON body
@property (nonatomic, strong, nullable) NSData *httpBody;
// 可选 Header
@property (nonatomic, copy, nullable) NSDictionary<NSString *, NSString *> *extraHeaders;
// 配置项(默认值见注释)
@property (nonatomic, assign) BOOL acceptEventStream; // 默认 NO置 YES 时发送 Accept: text/event-stream
@property (nonatomic, assign) BOOL disableCompression; // 默认 YES发送 Accept-Encoding: identity
@property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES将“/t”替换为“\t”
@property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES首次正文起始的“\t”删一个忽略前导空白
@property (nonatomic, assign) NSTimeInterval requestTimeout; // 默认 30s
/// UI 刷新节奏:当一次回调内解析出多段(如多条 SSE 事件)时,按该间隔逐条回调(默认 0.10s)。
@property (nonatomic, assign) NSTimeInterval flushInterval;
/// 非 SSE 且一次性拿到大段文本时,是否按空格切词逐条回调(模拟“逐词流式”),默认 YES。
@property (nonatomic, assign) BOOL splitLargeDeltasOnWhitespace;
/// 调试日志:默认 YES。输出起止时刻、首包耗时、各分片内容截断等关键信息。
@property (nonatomic, assign) BOOL loggingEnabled;
// 回调(统一在主线程触发)
@property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk;
@property (nonatomic, copy, nullable) KBStreamFetcherFinishHandler onFinish;
// 控制
- (void)start;
- (void)cancel;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,519 @@
//
// KBStreamFetcher.m
//
#import "KBStreamFetcher.h"
#import "KBLocalizationManager.h"
@interface KBStreamFetcher () <NSURLSessionDataDelegate>
@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSURLSessionDataTask *task;
@property (nonatomic, strong) NSMutableData *buffer; //
@property (nonatomic, assign) NSStringEncoding textEncoding; // UTF-8
@property (nonatomic, assign) BOOL isSSE; // SSE
@property (nonatomic, strong) NSMutableString *sseTextBuffer; // SSE
@property (nonatomic, assign) NSInteger decodedPrefixBytes; // sseTextBuffer SSE
@property (nonatomic, assign) NSInteger deliveredCharCount; // SSE
@property (nonatomic, assign) BOOL hasEmitted; // 1 \t
@property (nonatomic, assign) BOOL lastChunkEndedWithTab; // "\t" \t
@property (nonatomic, strong) NSMutableArray<NSString *> *pendingQueue; //
@property (nonatomic, strong) NSTimer *flushTimer; //
@property (nonatomic, strong, nullable) NSError *finishError; //
@property (nonatomic, copy, nullable) NSString *pendingSplitTokenPrefix; // `<SPLIT>`
// Metrics
@property (nonatomic, assign) CFAbsoluteTime tStart; // start()
@property (nonatomic, assign) CFAbsoluteTime tFirstByte; //
@property (nonatomic, assign) CFAbsoluteTime tFinish; // /
@property (nonatomic, assign) NSInteger emittedChunkCount; //
@end
// UTF-8
static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
const unsigned char *bytes = (const unsigned char *)data.bytes;
NSUInteger n = data.length;
if (n == 0) return 0;
NSInteger i = (NSInteger)n - 1;
while (i >= 0 && (bytes[i] & 0xC0) == 0x80) { i--; } // 10xxxxxx
if (i < 0) return 0; //
unsigned char b = bytes[i];
NSUInteger expected = 1;
if ((b & 0x80) == 0x00) expected = 1; // 0xxxxxxx
else if ((b & 0xE0) == 0xC0) expected = 2; // 110xxxxx
else if ((b & 0xF0) == 0xE0) expected = 3; // 1110xxxx
else if ((b & 0xF8) == 0xF0) expected = 4; // 11110xxx
else return (NSUInteger)i; // i
NSUInteger remain = n - (NSUInteger)i;
return (remain >= expected) ? n : (NSUInteger)i;
}
static NSString * const kKBStreamSplitToken = @"<SPLIT>";
@implementation KBStreamFetcher
+ (instancetype)fetcherWithURL:(NSURL *)url {
KBStreamFetcher *f = [[self alloc] init];
f.url = url;
return f;
}
- (instancetype)init {
if (self = [super init]) {
_httpMethod = @"GET";
_acceptEventStream = NO;
_disableCompression = YES;
_treatSlashTAsTab = YES;
_trimLeadingTabOnce = YES;
_requestTimeout = 30.0;
_textEncoding = NSUTF8StringEncoding;
_buffer = [NSMutableData data];
_sseTextBuffer = [NSMutableString string];
_pendingQueue = [NSMutableArray array];
_flushInterval = 0.1;
_splitLargeDeltasOnWhitespace = YES;
_loggingEnabled = YES;
_pendingSplitTokenPrefix = nil;
}
return self;
}
- (void)start {
if (!self.url) return;
[self cancel];
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration];
cfg.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
cfg.timeoutIntervalForRequest = self.requestTimeout;
cfg.timeoutIntervalForResource = MAX(self.requestTimeout, 60.0);
self.session = [NSURLSession sessionWithConfiguration:cfg delegate:self delegateQueue:[NSOperationQueue mainQueue]];
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:self.url];
NSString *method = self.httpMethod.length > 0 ? self.httpMethod : @"GET";
req.HTTPMethod = method;
if (self.httpBody.length > 0) {
req.HTTPBody = self.httpBody;
}
if (self.disableCompression) { [req setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; }
if (self.acceptEventStream) { [req setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"]; }
[req setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
[req setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
[self.extraHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *k, NSString *v, BOOL *stop){ [req setValue:v forHTTPHeaderField:k]; }];
//
[self.buffer setLength:0];
[self.sseTextBuffer setString:@""];
self.isSSE = NO;
self.textEncoding = NSUTF8StringEncoding;
self.decodedPrefixBytes = 0;
self.deliveredCharCount = 0;
self.hasEmitted = NO;
self.lastChunkEndedWithTab = NO;
[self.pendingQueue removeAllObjects];
[self.flushTimer invalidate]; self.flushTimer = nil;
self.finishError = nil;
self.pendingSplitTokenPrefix = nil;
self.tStart = CFAbsoluteTimeGetCurrent();
self.tFirstByte = 0;
self.tFinish = 0;
self.emittedChunkCount = 0;
if (self.loggingEnabled) {
NSLog(@"[KBStream] start url=%@ acceptSSE=%@ disableCompression=%@ flush=%.0fms splitWords=%@",
self.url.absoluteString,
self.acceptEventStream?@"YES":@"NO",
self.disableCompression?@"YES":@"NO",
self.flushInterval*1000.0,
self.splitLargeDeltasOnWhitespace?@"YES":@"NO");
}
self.task = [self.session dataTaskWithRequest:req];
[self.task resume];
}
- (void)cancel {
[self.task cancel];
self.task = nil;
[self.session invalidateAndCancel];
self.session = nil;
[self.buffer setLength:0];
[self.sseTextBuffer setString:@""];
self.decodedPrefixBytes = 0;
self.deliveredCharCount = 0;
self.hasEmitted = NO;
self.lastChunkEndedWithTab = NO;
[self.pendingQueue removeAllObjects];
[self.flushTimer invalidate]; self.flushTimer = nil;
self.finishError = nil;
self.pendingSplitTokenPrefix = nil;
}
#pragma mark - NSURLSessionDataDelegate
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
self.isSSE = NO;
self.textEncoding = NSUTF8StringEncoding;
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *r = (NSHTTPURLResponse *)response;
NSString *ct = r.allHeaderFields[@"Content-Type"] ?: r.allHeaderFields[@"content-type"];
if ([ct isKindOfClass:[NSString class]]) {
NSString *lower = [ct lowercaseString];
if ([lower containsString:@"text/event-stream"]) self.isSSE = YES;
NSRange pos = [lower rangeOfString:@"charset="];
if (pos.location != NSNotFound) {
NSString *charset = [[lower substringFromIndex:pos.location + pos.length] componentsSeparatedByString:@";"][0];
if ([charset containsString:@"utf-8"] || [charset containsString:@"utf8"]) {
self.textEncoding = NSUTF8StringEncoding;
} else if ([charset containsString:@"iso-8859-1"] || [charset containsString:@"latin1"]) {
self.textEncoding = NSISOLatin1StringEncoding;
}
}
}
}
[self.sseTextBuffer setString:@""];
self.decodedPrefixBytes = 0;
if (completionHandler) completionHandler(NSURLSessionResponseAllow);
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
if (data.length == 0) return;
[self.buffer appendData:data];
NSUInteger validLen = (self.textEncoding == NSUTF8StringEncoding)
? kb_validUTF8PrefixLen(self.buffer)
: self.buffer.length;
if (validLen > 0 && self.tFirstByte == 0) {
self.tFirstByte = CFAbsoluteTimeGetCurrent();
if (self.loggingEnabled) {
NSLog(@"[KBStream] first-bytes after %.0fms (encoding=%@, SSE=%@)",
(self.tFirstByte - self.tStart)*1000.0,
(self.textEncoding==NSUTF8StringEncoding?@"UTF-8":@"Other"),
self.isSSE?@"YES":@"NO");
}
}
if (validLen == 0) return; //
if (self.isSSE) {
if ((NSUInteger)self.decodedPrefixBytes < validLen) {
NSRange rng = NSMakeRange((NSUInteger)self.decodedPrefixBytes, validLen - (NSUInteger)self.decodedPrefixBytes);
NSString *piece = [[NSString alloc] initWithBytes:(const char *)self.buffer.bytes + rng.location
length:rng.length
encoding:self.textEncoding];
if (piece.length > 0) {
[self.sseTextBuffer appendString:piece];
self.decodedPrefixBytes = (NSInteger)validLen;
}
}
// SSE \n\n
if (self.sseTextBuffer.length > 0) {
NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"];
[self.sseTextBuffer setString:normalized];
while (1) {
NSRange sep = [self.sseTextBuffer rangeOfString:@"\n\n"]; //
if (sep.location == NSNotFound) break;
NSString *event = [self.sseTextBuffer substringToIndex:sep.location];
[self.sseTextBuffer deleteCharactersInRange:NSMakeRange(0, sep.location + sep.length)];
// data:
NSArray<NSString *> *lines = [event componentsSeparatedByString:@"\n"];
NSMutableString *payload = [NSMutableString string];
for (NSString *ln in lines) {
if ([ln hasPrefix:@"data:"]) {
NSString *v = [ln substringFromIndex:5];
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
[payload appendString:v ?: @""];
}
}
if (payload.length > 0) {
if (self.loggingEnabled) {
NSLog(@"[KBStream] SSE raw payload: %@", payload);
}
NSString *llmText = nil;
if ([self processLLMChunkPayload:payload output:&llmText]) {
if (llmText.length > 0) { [self enqueueChunk:llmText]; }
} else {
[self enqueueChunk:payload];
}
}
}
}
return;
}
// SSE
NSString *prefix = [[NSString alloc] initWithBytes:self.buffer.bytes length:validLen encoding:self.textEncoding];
if (!prefix) return;
if (self.deliveredCharCount < (NSInteger)prefix.length) {
NSString *delta = [prefix substringFromIndex:self.deliveredCharCount];
self.deliveredCharCount = prefix.length;
if (self.splitLargeDeltasOnWhitespace && delta.length > 16) {
// 使
NSArray<NSString *> *parts = [delta componentsSeparatedByString:@" "];
for (NSUInteger i = 0; i < parts.count; i++) {
NSString *w = parts[i];
if (w.length == 0) { [self enqueueChunk:@" "]; continue; }
if (i + 1 < parts.count) {
[self enqueueChunk:[w stringByAppendingString:@" "]];
} else {
[self enqueueChunk:w];
}
}
} else {
[self enqueueChunk:delta];
}
}
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
if (!error && self.isSSE && self.sseTextBuffer.length > 0) {
// \n\n
NSString *normalized = [self.sseTextBuffer stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"];
NSArray<NSString *> *lines = [normalized componentsSeparatedByString:@"\n"];
NSMutableString *payload = [NSMutableString string];
for (NSString *ln in lines) {
if ([ln hasPrefix:@"data:"]) {
NSString *v = [ln substringFromIndex:5];
if (v.length > 0 && [v hasPrefix:@" "]) v = [v substringFromIndex:1];
[payload appendString:v ?: @""];
}
}
if (payload.length > 0) {
if (self.loggingEnabled) {
NSLog(@"[KBStream] SSE raw payload: %@", payload);
}
NSString *delta = nil;
if ((NSInteger)payload.length >= self.deliveredCharCount) {
delta = [payload substringFromIndex:self.deliveredCharCount];
} else {
delta = payload;
}
self.deliveredCharCount = payload.length;
if (delta.length > 0) {
NSString *llmText = nil;
if ([self processLLMChunkPayload:delta output:&llmText]) {
if (llmText.length > 0) { [self emitChunk:llmText]; }
} else {
[self emitChunk:delta];
}
}
}
}
if (self.pendingSplitTokenPrefix.length > 0) {
NSString *carry = self.pendingSplitTokenPrefix;
self.pendingSplitTokenPrefix = nil;
if (carry.length > 0) { [self enqueueChunk:carry]; }
}
self.tFinish = CFAbsoluteTimeGetCurrent();
if (self.loggingEnabled) {
double t0 = (self.tFirstByte>0? (self.tFirstByte - self.tStart)*1000.0 : -1);
double t1 = (self.tFirstByte>0? (self.tFinish - self.tFirstByte)*1000.0 : -1);
double tt = (self.tFinish - self.tStart)*1000.0;
NSLog(@"[KBStream] finish chunks=%ld firstByte=%.0fms after start, tail=%.0fms, total=%.0fms error=%@",
(long)self.emittedChunkCount, t0, t1, tt, error);
}
// finish
if (self.pendingQueue.count > 0) {
self.finishError = error;
[self startFlushTimerIfNeeded];
} else {
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); });
[self cancel];
}
}
#pragma mark - Helpers
- (void)emitChunk:(NSString *)rawText {
if (rawText.length == 0) return;
// 便
if (self.loggingEnabled) {
// NSLog(@"[KBStream] RAW chunk#%ld len=%lu text=\"%@\"",
// (long)(self.emittedChunkCount + 1),
// (unsigned long)rawText.length,
// KBPrintableSnippet(rawText, 160));
}
NSString *text = rawText;
// 0) \r/\n "\n\t""\r\n\t""\r\t" "\t"
text = [text stringByReplacingOccurrencesOfString:@"\r\n\t" withString:@"\t"];
text = [text stringByReplacingOccurrencesOfString:@"\n\t" withString:@"\t"];
text = [text stringByReplacingOccurrencesOfString:@"\r\t" withString:@"\t"];
while (text.length > 0) {
unichar c0 = [text characterAtIndex:0];
if (c0 == '\n' || c0 == '\r') { text = [text substringFromIndex:1]; continue; }
break;
}
// 1) /t -> \t
if (self.treatSlashTAsTab) {
text = [text stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"];
}
// 2) "\t"
if (!self.hasEmitted && self.trimLeadingTabOnce) {
if (text.length > 0 && [text characterAtIndex:0] == '\t') {
NSUInteger start = 1;
if (start < text.length && [text characterAtIndex:start] == ' ') start++;
text = [text substringFromIndex:start];
}
}
// 3) \t -> \t
if (text.length > 0) {
// \t
if (self.lastChunkEndedWithTab) {
NSUInteger j = 0;
while (j < text.length && [text characterAtIndex:j] == ' ') { j++; }
if (j > 0) {
text = [text substringFromIndex:1]; //
}
}
// \t \t
text = [text stringByReplacingOccurrencesOfString:@"\t " withString:@"\t"];
}
if (text.length == 0) { self.lastChunkEndedWithTab = NO; return; }
self.emittedChunkCount += 1;
if (self.loggingEnabled) {
NSLog(@"[KBStream] chunk#%ld len=%lu text=\"%@\"",
(long)self.emittedChunkCount, (unsigned long)text.length, KBPrintableSnippet(text, 160));
}
if (self.onChunk) dispatch_async(dispatch_get_main_queue(), ^{ self.onChunk(text); });
self.hasEmitted = YES;
// \t
unichar lastc = [text characterAtIndex:text.length - 1];
self.lastChunkEndedWithTab = (lastc == '\t');
}
- (BOOL)processLLMChunkPayload:(NSString *)payload output:(NSString * _Nullable __autoreleasing *)output {
if (output) { *output = nil; }
if (payload.length == 0) { return NO; }
NSData *jsonData = [payload dataUsingEncoding:NSUTF8StringEncoding];
if (!jsonData) { return NO; }
NSError *jsonError = nil;
id obj = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&jsonError];
if (jsonError || ![obj isKindOfClass:[NSDictionary class]]) { return NO; }
NSString *type = ((NSDictionary *)obj)[@"type"];
if (![type isKindOfClass:[NSString class]]) { return NO; }
if ([type isEqualToString:@"llm_chunk"]) {
id dataValue = ((NSDictionary *)obj)[@"data"];
if (![dataValue isKindOfClass:[NSString class]]) {
if (output) { *output = @""; }
return YES;
}
NSString *normalized = [self normalizedLLMDataString:(NSString *)dataValue];
if (output) { *output = normalized; }
return YES;
}
if ([type isEqualToString:@"search_result"]) {
NSString *searchText = [self normalizedSearchResultString:((NSDictionary *)obj)[@"data"]];
if (output) { *output = searchText ?: @""; }
return YES;
}
if ([type isEqualToString:@"done"]) {
if (output) { *output = @""; }
return YES;
}
return NO;
}
- (NSString *)normalizedLLMDataString:(NSString *)dataString {
NSString *combined = dataString ?: @"";
if (self.pendingSplitTokenPrefix.length > 0) {
combined = [self.pendingSplitTokenPrefix stringByAppendingString:combined];
self.pendingSplitTokenPrefix = nil;
}
if (combined.length == 0) { return @""; }
NSString *result = [combined stringByReplacingOccurrencesOfString:kKBStreamSplitToken withString:@"\t"];
NSString *suffix = [self pendingSplitPrefixSuffixForString:result];
if (suffix.length > 0) {
self.pendingSplitTokenPrefix = suffix;
result = [result substringToIndex:result.length - suffix.length];
}
return result;
}
- (NSString *)normalizedSearchResultString:(id)dataValue {
if (![dataValue isKindOfClass:[NSArray class]]) { return @""; }
NSArray *list = (NSArray *)dataValue;
NSMutableArray<NSString *> *segments = [NSMutableArray array];
for (NSUInteger i = 0; i < list.count; i++) {
id item = list[i];
NSString *payload = nil;
if ([item isKindOfClass:[NSDictionary class]]) {
id val = ((NSDictionary *)item)[@"payload"];
if ([val isKindOfClass:[NSString class]]) {
payload = (NSString *)val;
}
} else if ([item isKindOfClass:[NSString class]]) {
payload = (NSString *)item;
}
payload = [payload stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (payload.length == 0) { continue; }
NSString *line = [NSString stringWithFormat:@"%lu. %@", (unsigned long)(segments.count + 1), payload];
[segments addObject:line];
}
if (segments.count == 0) { return @""; }
NSString *title = KBLocalized(@"Search result");
NSMutableString *text = [NSMutableString string];
[text appendString:@"\t"];
[text appendFormat:@"%@:", title.length > 0 ? title : @"Search result"];
for (NSString *line in segments) {
[text appendString:@"\t"];
[text appendString:line];
}
return text;
}
- (NSString *)pendingSplitPrefixSuffixForString:(NSString *)text {
if (text.length == 0) { return @""; }
NSUInteger tokenLen = kKBStreamSplitToken.length;
if (tokenLen <= 1) { return @""; }
NSUInteger maxLen = MIN(tokenLen - 1, text.length);
for (NSUInteger len = maxLen; len > 0; len--) {
NSString *suffix = [text substringFromIndex:text.length - len];
NSString *prefix = [kKBStreamSplitToken substringToIndex:len];
if ([suffix isEqualToString:prefix]) {
return suffix;
}
}
return @"";
}
#pragma mark - Queue/Flush
- (void)enqueueChunk:(NSString *)s {
if (s.length == 0) return;
[self.pendingQueue addObject:s];
[self startFlushTimerIfNeeded];
}
- (void)startFlushTimerIfNeeded {
if (self.flushTimer) return;
__weak typeof(self) weakSelf = self;
self.flushTimer = [NSTimer scheduledTimerWithTimeInterval:MAX(0.01, self.flushInterval)
repeats:YES
block:^(NSTimer * _Nonnull t) {
__strong typeof(weakSelf) self = weakSelf; if (!self) { [t invalidate]; return; }
if (self.pendingQueue.count == 0) {
[t invalidate]; self.flushTimer = nil;
if (self.finishError || self.finishError == nil) {
NSError *err = self.finishError; self.finishError = nil;
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(err); });
[self cancel];
}
return;
}
NSString *first = self.pendingQueue.firstObject;
[self.pendingQueue removeObjectAtIndex:0];
[self emitChunk:first];
}];
}
#pragma mark - Logging helpers
static NSString *KBPrintableSnippet(NSString *s, NSUInteger maxLen) {
if (!s) return @"";
NSString *x = [s stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
if (x.length > maxLen) {
x = [[x substringToIndex:maxLen] stringByAppendingString:@"…"];
}
return x;
}
@end

View File

@@ -0,0 +1,71 @@
//
// NetworkStreamHandler.h
// CustomKeyboard
//
// Created by Mac on 2025/11/12.
//
// 暂未使用
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSUInteger, NetworkStreamState) {
NetworkStreamStateIdle,
NetworkStreamStateConnecting,
NetworkStreamStateReceiving,
NetworkStreamStateCompleted,
NetworkStreamStateError
};
@class NetworkStreamHandler;
@protocol NetworkStreamDelegate <NSObject>
@optional
// 接收到数据块
- (void)networkStream:(NetworkStreamHandler *)stream didReceiveData:(NSData *)data;
// 接收到文本数据(如果是文本内容)
- (void)networkStream:(NetworkStreamHandler *)stream didReceiveText:(NSString *)text;
// 进度更新
- (void)networkStream:(NetworkStreamHandler *)stream downloadProgress:(float)progress;
// 状态改变
- (void)networkStream:(NetworkStreamHandler *)stream stateChanged:(NetworkStreamState)state;
// 请求完成
- (void)networkStream:(NetworkStreamHandler *)stream didCompleteWithError:(NSError * _Nullable)error;
@end
typedef void (^NetworkStreamProgressBlock)(float progress);
typedef void (^NetworkStreamDataBlock)(NSData *data);
typedef void (^NetworkStreamTextBlock)(NSString *text);
typedef void (^NetworkStreamCompletionBlock)(NSError * _Nullable error);
@interface NetworkStreamHandler : NSObject <NSURLSessionDataDelegate>
@property (nonatomic, weak) id<NetworkStreamDelegate> delegate;
@property (nonatomic, assign, readonly) NetworkStreamState state;
@property (nonatomic, strong, readonly) NSURLResponse *response;
@property (nonatomic, assign, readonly) long long totalBytesReceived;
// 初始化方法
- (instancetype)initWithURL:(NSURL *)url;
- (instancetype)initWithRequest:(NSURLRequest *)request;
// 开始请求(使用代理回调)
- (void)startRequest;
// 开始请求(使用 Block 回调)
- (void)startRequestWithProgress:(NetworkStreamProgressBlock _Nullable)progress
onData:(NetworkStreamDataBlock _Nullable)dataBlock
onText:(NetworkStreamTextBlock _Nullable)textBlock
completion:(NetworkStreamCompletionBlock _Nullable)completion;
// 取消请求
- (void)cancelRequest;
// 构建默认请求(包含常见的请求头)
+ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,253 @@
//
// NetworkStreamHandler.m
// CustomKeyboard
//
// Created by Mac on 2025/11/12.
//
#import "NetworkStreamHandler.h"
@interface NetworkStreamHandler ()
@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@property (nonatomic, strong) NSURLRequest *request;
@property (nonatomic, strong) NSMutableData *receivedData;
@property (nonatomic, assign) long long expectedContentLength;
@property (nonatomic, assign) NetworkStreamState state;
@property (nonatomic, strong) NSURLResponse *response;
// Block
@property (nonatomic, copy) NetworkStreamProgressBlock progressBlock;
@property (nonatomic, copy) NetworkStreamDataBlock dataBlock;
@property (nonatomic, copy) NetworkStreamTextBlock textBlock;
@property (nonatomic, copy) NetworkStreamCompletionBlock completionBlock;
@end
@implementation NetworkStreamHandler
- (instancetype)initWithURL:(NSURL *)url {
NSURLRequest *request = [NetworkStreamHandler createDefaultRequestWithURL:url method:@"GET"];
return [self initWithRequest:request];
}
- (instancetype)initWithRequest:(NSURLRequest *)request {
self = [super init];
if (self) {
_request = request;
_receivedData = [NSMutableData data];
_state = NetworkStreamStateIdle;
_totalBytesReceived = 0;
// URLSession
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
config.timeoutIntervalForRequest = 30.0;
config.timeoutIntervalForResource = 300.0;
config.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
// URLSession
_session = [NSURLSession sessionWithConfiguration:config
delegate:self
delegateQueue:[NSOperationQueue mainQueue]];
}
return self;
}
- (void)dealloc {
[self cancelRequest];
}
#pragma mark - Public Methods
- (void)startRequest {
if (self.state != NetworkStreamStateIdle) {
NSLog(@"Request already in progress");
return;
}
[self updateState:NetworkStreamStateConnecting];
self.dataTask = [self.session dataTaskWithRequest:self.request];
[self.dataTask resume];
}
- (void)startRequestWithProgress:(NetworkStreamProgressBlock)progress
onData:(NetworkStreamDataBlock)dataBlock
onText:(NetworkStreamTextBlock)textBlock
completion:(NetworkStreamCompletionBlock)completion {
self.progressBlock = progress;
self.dataBlock = dataBlock;
self.textBlock = textBlock;
self.completionBlock = completion;
[self startRequest];
}
- (void)cancelRequest {
if (self.dataTask) {
[self.dataTask cancel];
self.dataTask = nil;
}
[self updateState:NetworkStreamStateIdle];
}
+ (NSURLRequest *)createDefaultRequestWithURL:(NSURL *)url method:(NSString *)method {
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = method;
request.timeoutInterval = 30.0;
//
[request setValue:@"text/html, application/xhtml+xml, application/xml; q=0.9, image/avif, image/webp, image/apng, */*; q=0.8, application/signed-exchange; v=b3; q=0.7" forHTTPHeaderField:@"Accept"];
[request setValue:@"gzip, deflate" forHTTPHeaderField:@"Accept-Encoding"];
[request setValue:@"zh-CN, zh; q=0.9, ko; q=0.8, ja; q=0.7" forHTTPHeaderField:@"Accept-Language"];
[request setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
[request setValue:@"1" forHTTPHeaderField:@"Upgrade-Insecure-Requests"];
//
NSString *userAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1";
[request setValue:userAgent forHTTPHeaderField:@"User-Agent"];
return [request copy];
}
#pragma mark - Private Methods
- (void)updateState:(NetworkStreamState)newState {
if (_state != newState) {
_state = newState;
//
if ([self.delegate respondsToSelector:@selector(networkStream:stateChanged:)]) {
[self.delegate networkStream:self stateChanged:newState];
}
}
}
- (void)notifyProgress:(float)progress {
if (self.progressBlock) {
self.progressBlock(progress);
}
if ([self.delegate respondsToSelector:@selector(networkStream:downloadProgress:)]) {
[self.delegate networkStream:self downloadProgress:progress];
}
}
- (void)notifyReceivedData:(NSData *)data {
if (self.dataBlock) {
self.dataBlock(data);
}
if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveData:)]) {
[self.delegate networkStream:self didReceiveData:data];
}
//
if (self.textBlock || [self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) {
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
if (text) {
if (self.textBlock) {
self.textBlock(text);
}
if ([self.delegate respondsToSelector:@selector(networkStream:didReceiveText:)]) {
[self.delegate networkStream:self didReceiveText:text];
}
}
}
}
- (void)notifyCompletionWithError:(NSError * _Nullable)error {
if (self.completionBlock) {
self.completionBlock(error);
}
if ([self.delegate respondsToSelector:@selector(networkStream:didCompleteWithError:)]) {
[self.delegate networkStream:self didCompleteWithError:error];
}
}
#pragma mark - NSURLSessionDataDelegate
- (void)URLSession:(NSURLSession *)session
dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
self.response = response;
self.expectedContentLength = response.expectedContentLength;
_totalBytesReceived = 0;
[self.receivedData setLength:0];
[self updateState:NetworkStreamStateReceiving];
// CORS
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
NSLog(@"Response headers: %@", httpResponse.allHeaderFields);
// CORS
NSString *allowOrigin = httpResponse.allHeaderFields[@"Access-Control-Allow-Origin"];
if (allowOrigin) {
NSLog(@"CORS Allow Origin: %@", allowOrigin);
}
}
completionHandler(NSURLSessionResponseAllow);
}
- (void)URLSession:(NSURLSession *)session
dataTask:(NSURLSessionDataTask *)dataTask
didReceiveData:(NSData *)data {
_totalBytesReceived += data.length;
[self.receivedData appendData:data];
//
[self notifyReceivedData:data];
//
if (self.expectedContentLength != NSURLResponseUnknownLength) {
float progress = (float)self.totalBytesReceived / (float)self.expectedContentLength;
[self notifyProgress:progress];
} else {
// chunked
[self notifyProgress:-1]; // 使 -1
}
}
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didCompleteWithError:(NSError *)error {
if (error) {
[self updateState:NetworkStreamStateError];
NSLog(@"Request failed with error: %@", error);
} else {
[self updateState:NetworkStreamStateCompleted];
NSLog(@"Request completed successfully. Total bytes: %lld", self.totalBytesReceived);
}
[self notifyCompletionWithError:error];
//
[self.session finishTasksAndInvalidate];
self.dataTask = nil;
}
#pragma mark - URL Session Delegate ( SSL/)
- (void)URLSession:(NSURLSession *)session
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler {
// SSL
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
} else {
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
}
}
@end

View File

@@ -0,0 +1,70 @@
//
// WJXEventSource.h
// WJXEventSource
//
// Created by JiuxingWang on 2025/2/9.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
#ifdef __cplusplus
#define WJX_EXTERN extern "C" __attribute__((visibility ("default")))
#else
#define WJX_EXTERN extern __attribute__((visibility ("default")))
#endif
/// 消息事件
typedef NSString *WJXEventName NS_TYPED_EXTENSIBLE_ENUM;
/// 消息事件
WJX_EXTERN WJXEventName const WJXEventNameMessage;
/// readyState 变化事件
WJX_EXTERN WJXEventName const WJXEventNameReadyState;
/// open 事件
WJX_EXTERN WJXEventName const WJXEventNameOpen;
/// error 事件
WJX_EXTERN WJXEventName const WJXEventNameError;
typedef NS_ENUM(NSUInteger, WJXEventState) {
WJXEventStateConnecting = 0,
WJXEventStateOpen,
WJXEventStateClosed,
};
@interface WJXEvent : NSObject
@property (nonatomic, strong, nullable) id eventId;
@property (nonatomic, copy, nullable) NSString *event;
@property (nonatomic, copy, nullable) NSString *data;
@property (nonatomic, assign) WJXEventState readyState;
@property (nonatomic, strong, nullable) NSError *error;
- (instancetype)initWithReadyState:(WJXEventState)readyState;
@end
typedef void(^WJXEventSourceEventHandler)(WJXEvent *event);
@interface WJXEventSource : NSObject
@property (nonatomic, assign) BOOL ignoreRetryAction;
- (instancetype)initWithRquest:(NSURLRequest *)request;
- (void)addListener:(WJXEventSourceEventHandler)listener
forEvent:(WJXEventName)eventName
queue:(nullable NSOperationQueue *)queue;
- (void)open;
- (void)close;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,309 @@
//
// WJXEventSource.m
// WJXEventSource
//
// Created by JiuxingWang on 2025/2/9.
//
#import "WJXEventSource.h"
///
WJXEventName const WJXEventNameMessage = @"message";
/// readyState
WJXEventName const WJXEventNameReadyState = @"readyState";
/// open
WJXEventName const WJXEventNameOpen = @"open";
/// error
WJXEventName const WJXEventNameError = @"error";
#pragma mark -
#pragma mark WJXEvent
@implementation WJXEvent
- (instancetype)initWithReadyState:(WJXEventState)readyState;
{
if (self = [super init]) {
self.readyState = readyState;
}
return self;
}
- (NSString *)description
{
NSString *state = nil;
switch (_readyState) {
case WJXEventStateConnecting: {
state = @"CONNECTING";
} break;
case WJXEventStateOpen: {
state = @"OPEN";
} break;
case WJXEventStateClosed: {
state = @"CLOSED";
} break;
}
return [NSString stringWithFormat:@"<%@: readyState: %@, id: %@; event: %@; data: %@>", [self class], state, _eventId, _event, _data];
}
@end
#pragma mark -
#pragma mark WJXEventHandler
@interface WJXEventHandler : NSObject
@property (nonatomic, copy, nonnull) WJXEventSourceEventHandler handler;
@property (nonatomic, strong, nullable) NSOperationQueue *queue;
@end
@implementation WJXEventHandler
- (instancetype)initWithHandler:(WJXEventSourceEventHandler)handler queue:(NSOperationQueue *)queue
{
if (self = [super init]) {
self.handler = handler;
self.queue = queue;
}
return self;
}
@end
#pragma mark -
#pragma mark WJXEventSource
@interface WJXEventSource () <NSURLSessionDataDelegate>
@property (nonatomic, strong) NSMutableURLRequest *request;
@property (nonatomic, strong) NSMutableDictionary<WJXEventName, NSMutableArray<WJXEventHandler *> *> *listeners;
@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@property (nonatomic, copy) NSString *lastEventId;
@property (nonatomic, assign) NSTimeInterval retryInterval;
@property (nonatomic, assign) BOOL closedByUser;
@property (nonatomic, strong) NSMutableData *buffer;
@end
@implementation WJXEventSource
- (instancetype)initWithRquest:(NSURLRequest *)request;
{
if (self = [super init]) {
self.request = [request mutableCopy];
self.listeners = [NSMutableDictionary dictionary];
self.session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration] delegate:self delegateQueue:NSOperationQueue.mainQueue];
self.buffer = [NSMutableData data];
}
return self;
}
- (void)dealloc
{
[_session finishTasksAndInvalidate];
}
- (void)addListener:(WJXEventSourceEventHandler)listener
forEvent:(WJXEventName)eventName
queue:(nullable NSOperationQueue *)queue;
{
if (nil == listener) {
return;
}
NSMutableArray *listeners = self.listeners[eventName];
if (nil == listeners) {
self.listeners[eventName] = listeners = [NSMutableArray array];
}
[listeners addObject:[[WJXEventHandler alloc] initWithHandler:listener queue:queue]];
}
- (void)open;
{
if (_lastEventId.length) {
[_request setValue:_lastEventId forHTTPHeaderField:@"Last-Event-ID"];
}
self.dataTask = [_session dataTaskWithRequest:_request];
[_dataTask resume];
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateConnecting];
[self _dispatchEvent:event forName:WJXEventNameReadyState];
}
- (void)close;
{
self.closedByUser = YES;
[_dataTask cancel];
[_session finishTasksAndInvalidate];
_buffer = [NSMutableData data];
}
#pragma mark -
#pragma mark NSURLSessionDataDelegate
- (void)URLSession:(NSURLSession *)session
dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;
{
NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
if (200 == HTTPResponse.statusCode) {
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateOpen];
[self _dispatchEvent:event forName:WJXEventNameReadyState];
[self _dispatchEvent:event forName:WJXEventNameOpen];
}
if (nil != completionHandler) {
completionHandler(NSURLSessionResponseAllow);
}
}
- (void)URLSession:(NSURLSession *)session
dataTask:(NSURLSessionDataTask *)dataTask
didReceiveData:(NSData *)data;
{
[_buffer appendData:data];
[self _processBuffer];
}
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error;
{
if (_closedByUser) {
_buffer = [NSMutableData data];
return;
}
[self _dispatchPlainBufferIfNeeded];
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateClosed];
if (nil == (event.error = error)) {
event.error = [NSError errorWithDomain:@"WJXEventSource" code:event.readyState userInfo:@{
NSLocalizedDescriptionKey: @"Connection with the event source was closed without error",
}];
}
[self _dispatchEvent:event forName:WJXEventNameReadyState];
if (nil != error) {
[self _dispatchEvent:event forName:WJXEventNameError];
if (!_ignoreRetryAction) {
[self performSelector:@selector(open) withObject:nil afterDelay:_retryInterval];
}
}
}
#pragma mark -
#pragma mark Private
- (void)_processBuffer
{
NSData *separatorLFLFData = [NSData dataWithBytes:"\n\n" length:2];
NSRange range = [_buffer rangeOfData:separatorLFLFData options:kNilOptions range:(NSRange) {
.length = _buffer.length
}];
while (NSNotFound != range.location) {
// Extract event data
NSData *eventData = [_buffer subdataWithRange:(NSRange) {
.length = range.location
}];
[_buffer replaceBytesInRange:(NSRange) {
.length = range.location + 2
} withBytes:NULL length:0];
[self _parseEventData:eventData];
// Look for next event
range = [_buffer rangeOfData:separatorLFLFData options:kNilOptions range:(NSRange) {
.length = _buffer.length
}];
}
}
- (void)_parseEventData:(NSData *)data
{
WJXEvent *event = [[WJXEvent alloc] initWithReadyState:WJXEventStateOpen];
NSString *eventString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
if (eventString.length == 0) { return; }
NSArray *lines = [eventString componentsSeparatedByCharactersInSet:NSCharacterSet.newlineCharacterSet];
BOOL hasDataLine = NO;
for (NSString *line in lines) {
if ([line hasPrefix:@"id:"]) {
event.eventId = [[line substringFromIndex:3] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
} else if ([line hasPrefix:@"event:"]) {
event.event = [[line substringFromIndex:6] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
} else if ([line hasPrefix:@"data:"]) {
hasDataLine = YES;
NSString *data = [[line substringFromIndex:5] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
event.data = event.data ? [event.data stringByAppendingFormat:@"\n%@", data] : data;
} else if ([line hasPrefix:@"retry:"]) {
NSString *retryString = [[line substringFromIndex:6] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
self.retryInterval = [retryString doubleValue] / 1000;
}
}
if (!hasDataLine) {
NSString *trimmed = [eventString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (trimmed.length > 0) {
event.data = trimmed;
}
}
if (event.eventId) {
self.lastEventId = event.eventId;
}
[self _dispatchEvent:event forName:WJXEventNameMessage];
}
- (void)_dispatchEvent:(WJXEvent *)event forName:(WJXEventName)name
{
NSMutableArray<WJXEventHandler *> *listeners = self.listeners[name];
[listeners enumerateObjectsUsingBlock:^(WJXEventHandler * _Nonnull handler, NSUInteger idx, BOOL * _Nonnull stop) {
NSOperationQueue *queue = handler.queue ?: NSOperationQueue.mainQueue;
[queue addOperationWithBlock:^{
handler.handler(event);
}];
}];
}
- (void)_dispatchPlainBufferIfNeeded
{
if (_buffer.length == 0) { return; }
NSData *data = [_buffer copy];
[_buffer setLength:0];
if (data.length == 0) { return; }
[self _parseEventData:data];
}
#pragma mark -
#pragma mark Setters
- (void)setDataTask:(NSURLSessionDataTask *)dataTask
{
self.closedByUser = YES; {
[_dataTask cancel];
_dataTask = dataTask;
} self.closedByUser = NO;
}
@end

View File

@@ -17,15 +17,25 @@
// 公共配置
#import "KBConfig.h"
#import "KBAPI.h" // 接口路径宏(统一管理)
#import "Masonry.h"
#import "KBHUD.h" // 复用 App 内的 HUD 封装
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
#import "KBMaiPointReporter.h"
//#import "KBLog.h"
// 通用链接Universal Links统一配置
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
#define KB_UL_BASE @"https://your.domain/ul" // 替换为你的真实域名与前缀路径
#define KB_UL_BASE @"https://app.tknb.net/ul" // 与 Associated Domains 一致
#define KB_UL_LOGIN KB_UL_BASE @"/login"
#define KB_UL_SETTINGS KB_UL_BASE @"/settings"
// 在扩展内,启用 URL Bridge仅在显式的用户点击动作中使用
// 这样即便宿主 App如备忘录拒绝 extensionContext 的 openURL仍可通过响应链兜底拉起容器 App。
#ifndef KB_URL_BRIDGE_ENABLE
#define KB_URL_BRIDGE_ENABLE 1
#endif
#endif /* PrefixHeader_pch */

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,248 @@
/* 字母 q小写 */
"letter_q_lower" = "key_q";
/* 字母 Q大写 */
"letter_q_upper" = "key_q_up";
/* 字母 w小写 */
"letter_w_lower" = "key_w";
/* 字母 W大写 */
"letter_w_upper" = "key_w_up";
/* 字母 e小写 */
"letter_e_lower" = "key_e";
/* 字母 E大写 */
"letter_e_upper" = "key_e_up";
/* 字母 r小写 */
"letter_r_lower" = "key_r";
/* 字母 R大写 */
"letter_r_upper" = "key_r_up";
/* 字母 t小写 */
"letter_t_lower" = "key_t";
/* 字母 T大写 */
"letter_t_upper" = "key_t_up";
/* 字母 y小写 */
"letter_y_lower" = "key_y";
/* 字母 Y大写 */
"letter_y_upper" = "key_y_up";
/* 字母 u小写 */
"letter_u_lower" = "key_u";
/* 字母 U大写 */
"letter_u_upper" = "key_u_up";
/* 字母 i小写 */
"letter_i_lower" = "key_i";
/* 字母 I大写 */
"letter_i_upper" = "key_i_up";
/* 字母 o小写 */
"letter_o_lower" = "key_o";
/* 字母 O大写 */
"letter_o_upper" = "key_o_up";
/* 字母 p小写 */
"letter_p_lower" = "key_p";
/* 字母 P大写 */
"letter_p_upper" = "key_p_up";
/* 字母 a小写 */
"letter_a_lower" = "key_a";
/* 字母 A大写 */
"letter_a_upper" = "key_a_up";
/* 字母 s小写 */
"letter_s_lower" = "key_s";
/* 字母 S大写 */
"letter_s_upper" = "key_s_up";
/* 字母 d小写 */
"letter_d_lower" = "key_d";
/* 字母 D大写 */
"letter_d_upper" = "key_d_up";
/* 字母 f小写 */
"letter_f_lower" = "key_f";
/* 字母 F大写 */
"letter_f_upper" = "key_f_up";
/* 字母 g小写 */
"letter_g_lower" = "key_g";
/* 字母 G大写 */
"letter_g_upper" = "key_g_up";
/* 字母 h小写 */
"letter_h_lower" = "key_h";
/* 字母 H大写 */
"letter_h_upper" = "key_h_up";
/* 字母 j小写 */
"letter_j_lower" = "key_j";
/* 字母 J大写 */
"letter_j_upper" = "key_j_up";
/* 字母 k小写 */
"letter_k_lower" = "key_k";
/* 字母 K大写 */
"letter_k_upper" = "key_k_up";
/* 字母 l小写 */
"letter_l_lower" = "key_l";
/* 字母 L大写 */
"letter_l_upper" = "key_l_up";
/* 字母 z小写 */
"letter_z_lower" = "key_z";
/* 字母 Z大写 */
"letter_z_upper" = "key_z_up";
/* 字母 x小写 */
"letter_x_lower" = "key_x";
/* 字母 X大写 */
"letter_x_upper" = "key_x_up";
/* 字母 c小写 */
"letter_c_lower" = "key_c";
/* 字母 C大写 */
"letter_c_upper" = "key_c_up";
/* 字母 v小写 */
"letter_v_lower" = "key_v";
/* 字母 V大写 */
"letter_v_upper" = "key_v_up";
/* 字母 b小写 */
"letter_b_lower" = "key_b";
/* 字母 B大写 */
"letter_b_upper" = "key_b_up";
/* 字母 n小写 */
"letter_n_lower" = "key_n";
/* 字母 N大写 */
"letter_n_upper" = "key_n_up";
/* 字母 m小写 */
"letter_m_lower" = "key_m";
/* 字母 M大写 */
"letter_m_upper" = "key_m_up";
/* 数字 1 */
"digit_1" = "key_1";
/* 数字 2 */
"digit_2" = "key_2";
/* 数字 3 */
"digit_3" = "key_3";
/* 数字 4 */
"digit_4" = "key_4";
/* 数字 5 */
"digit_5" = "key_5";
/* 数字 6 */
"digit_6" = "key_6";
/* 数字 7 */
"digit_7" = "key_7";
/* 数字 8 */
"digit_8" = "key_8";
/* 数字 9 */
"digit_9" = "key_9";
/* 数字 0 */
"digit_0" = "key_0";
/* '-' */
"sym_minus" = "key_minus";
/* '/' */
"sym_slash" = "key_slash";
/* ':' */
"sym_colon" = "key_colon";
/* ';' */
"sym_semicolon" = "key_semicolon";
/* '(' */
"sym_paren_l" = "key_paren_l";
/* ')' */
"sym_paren_r" = "key_paren_r";
/* '$' */
"sym_dollar" = "key_dollar";
/* '&' */
"sym_amp" = "key_amp";
/* '@' */
"sym_at" = "key_at";
/* 双引号 " */
"sym_quote_double" = "key_quote_d";
/* ',' */
"sym_comma" = "key_comma";
/* '.' */
"sym_dot" = "key_dot";
/* '?' */
"sym_question" = "key_question";
/* '!' */
"sym_exclam" = "key_exclam";
/* 单引号 ' */
"sym_quote_single" = "key_quote";
/* '[' */
"sym_bracket_l" = "key_bracket_l";
/* ']' */
"sym_bracket_r" = "key_bracket_r";
/* '{' */
"sym_brace_l" = "key_brace_l";
/* '}' */
"sym_brace_r" = "key_brace_r";
/* '#' */
"sym_hash" = "key_hash";
/* '%' */
"sym_percent" = "key_percent";
/* '^' */
"sym_caret" = "key_caret";
/* '*' */
"sym_asterisk" = "key_asterisk";
/* '+' */
"sym_plus" = "key_plus";
/* '=' */
"sym_equal" = "key_equal";
/* '_' */
"sym_underscore" = "key_underscore";
/* '\' */
"sym_backslash" = "key_backslash";
/* '|' */
"sym_pipe" = "key_pipe";
/* '~' */
"sym_tilde" = "key_tilde";
/* '<' */
"sym_lt" = "key_lt";
/* '>' */
"sym_gt" = "key_gt";
/* '¥' */
"sym_money" = "key_money";
/* '€' */
"sym_euro" = "key_euro";
/* '£' */
"sym_pound" = "key_pound";
/* '•' */
"sym_bullet" = "key_bullet";
/* 空格键 */
"space" = "key_space";
/* 删除键(⌫) */
"backspace" = "key_del";
/* Shift */
"shift" = "key_up";
/* Shift大写 */
"shift_upper" = "key_up_upper";
/* 字母面板左下角 "123" */
"mode_123" = "key_123";
/* 数字面板左下角 "abc" */
"mode_abc" = "key_abc";
/* 数字面板内 "123 -> #+=" */
"symbols_toggle_more" = "key_symbols_more";
/* 数字面板内 "#+= -> 123" */
"symbols_toggle_123" = "key_symbols_123";
/* 自定义 AI 功能键 */
"ai" = "key_ai";
/* Emoji功能键 */
//"emoji" = "key_emoji";
"emoji_panel" = "key_emoji";
/* 发送/换行键 */
"return" = "key_send";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,414 @@
{
"__comment": "键盘布局配置:所有尺寸为设计稿值(会按 designWidth 等比缩放)",
"designWidth": 375,
"__comment_designWidth": "设计稿宽度(如 375用于计算缩放比例",
"defaultKeyBackground": "#FFFFFF",
"__comment_defaultKeyBackground": "无皮肤时按键默认背景色",
"metrics": {
"__comment": "全局尺寸参数单位pt按 designWidth 缩放)",
"rowSpacing": 8,
"__comment_rowSpacing": "行间距(垂直)",
"topInset": 8,
"__comment_topInset": "键盘顶部内边距",
"bottomInset": 6,
"__comment_bottomInset": "键盘底部内边距",
"keyHeight": 41,
"__comment_keyHeight": "默认按键高度",
"edgeInset": 4,
"__comment_edgeInset": "行左右内边距(默认)",
"gap": 5,
"__comment_gap": "按键之间水平间距",
"letterWidth": 32,
"__comment_letterWidth": "字母键默认宽度",
"controlWidth": 41,
"__comment_controlWidth": "控制键宽度(如 shift/backspace/123",
"sendWidth": 88,
"__comment_sendWidth": "send 键宽度",
"symbolsWideWidth": 47,
"__comment_symbolsWideWidth": "符号第3行中间大键宽度",
"symbolsSideWidth": 41,
"__comment_symbolsSideWidth": "符号第3行左右控制键宽度"
},
"fonts": {
"__comment": "字体大小pt",
"letter": 20,
"__comment_letter": "字母键字体大小",
"digit": 20,
"__comment_digit": "数字键字体大小",
"symbol": 18,
"__comment_symbol": "符号键字体大小",
"mode": 14,
"__comment_mode": "模式切换键字体大小ABC/#+=/123",
"space": 18,
"__comment_space": "空格键字体大小",
"send": 18,
"__comment_send": "发送键字体大小"
},
"keyDefs": {
"__comment": "特殊功能键配置id 对应布局中的 item",
"shift": {
"__comment": "大小写切换键",
"type": "shift",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "⇧",
"__comment_title": "按钮文本(无皮肤时显示)",
"symbolName": "shift",
"__comment_symbolName": "无皮肤时使用 SF Symbol 名称",
"selectedSymbolName": "shift.fill",
"__comment_selectedSymbolName": "选中态 SF Symbol 名称",
"font": "symbol",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "controlWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
},
"backspace": {
"__comment": "删除键",
"type": "backspace",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "⌫",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "symbol",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "controlWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
},
"mode_123": {
"__comment": "字母面板左下角 123",
"type": "mode",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "123",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "mode",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "controlWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
},
"mode_abc": {
"__comment": "数字面板左下角 ABC",
"type": "mode",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "ABC",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "mode",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "controlWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
},
"symbols_toggle_more": {
"__comment": "数字面板内 123 -> #+=",
"type": "symbolsToggle",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "#+=",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "mode",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "symbolsSideWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
},
"symbols_toggle_123": {
"__comment": "数字面板内 #+= -> 123",
"type": "symbolsToggle",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "123",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "mode",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "symbolsSideWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
},
"emoji": {
"__comment": "emoji 功能键",
"type": "custom",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "😁",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "symbol",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "controlWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
},
"space": {
"__comment": "空格键",
"type": "space",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "space",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "space",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "flex",
"__comment_width": "flex 表示自动占满剩余空间"
},
"send": {
"__comment": "发送键",
"type": "return",
"__comment_type": "类型shift/backspace/mode/symbolsToggle/space/return/custom",
"title": "send",
"__comment_title": "按钮文本(无皮肤时显示)",
"font": "send",
"__comment_font": "使用 fonts 中哪一类字号",
"width": "sendWidth",
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
"backgroundColor": "#B7BBC4",
"__comment_backgroundColor": "按键背景色"
}
},
"layouts": {
"__comment": "布局集合letters/numbers/symbolsMore",
"letters": {
"__comment": "字母布局(小写/大写共用)",
"rows": [
{
"__comment": "字母第一行 qwertyuiop",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
},
{
"__comment": "字母第二行 asdfghjkl",
"align": "center",
"__comment_align": "对齐方式left/center",
"insetLeft": 0,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 0,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
"letter:h", "letter:j", "letter:k", "letter:l"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
},
{
"__comment": "字母第三行:左 shift中间字母右 backspace",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"segments": {
"__comment": "分段布局left/center/right",
"left": [
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
],
"__comment_left": "左侧固定按钮",
"center": [
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
],
"__comment_center": "中间字母键集合,整体居中",
"right": [
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
],
"__comment_right": "右侧固定按钮"
}
},
{
"__comment": "字母第四行123/emoji/space/send",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"mode_123", "emoji", "space", "send"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
}
]
},
"numbers": {
"__comment": "数字面板布局123 页)",
"rows": [
{
"__comment": "数字第一行 1234567890",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"digit:1", "digit:2", "digit:3", "digit:4", "digit:5",
"digit:6", "digit:7", "digit:8", "digit:9", "digit:0"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
},
{
"__comment": "数字第二行 - / : ; ( ) ¥ & @ “",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"sym:-", "sym:/", "sym::", "sym:;", "sym:(",
"sym:)", "sym:¥", "sym:&", "sym:@", "sym:“"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
},
{
"__comment": "数字第三行:#+= / 中间符号 / 删除",
"align": "center",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"segments": {
"__comment": "分段布局left/center/right",
"left": [
{ "id": "symbols_toggle_more", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
],
"__comment_left": "左侧切换按钮",
"center": [
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
],
"__comment_center": "中间符号键集合,整体居中",
"right": [
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
],
"__comment_right": "右侧删除键"
}
},
{
"__comment": "数字第四行ABC/emoji/space/send",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"mode_abc", "emoji", "space", "send"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
}
]
},
"symbolsMore": {
"__comment": "符号面板布局(#+= 页)",
"rows": [
{
"__comment": "符号第一行 [ ] { } # % ^ * + =",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"sym:[", "sym:]", "sym:{", "sym:}", "sym:#",
"sym:%", "sym:^", "sym:*", "sym:+", "sym:="
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
},
{
"__comment": "符号第二行 _ \\ | ~ < > € ¥ $ ·",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"sym:_", "sym:\\", "sym:|", "sym:~", "sym:<",
"sym:>", "sym:€", "sym:¥", "sym:$", "sym:·"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
},
{
"__comment": "符号第三行123 / 中间符号 / 删除",
"align": "center",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"segments": {
"__comment": "分段布局left/center/right",
"left": [
{ "id": "symbols_toggle_123", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
],
"__comment_left": "左侧切换按钮",
"center": [
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
{ "id": "sym:", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
],
"__comment_center": "中间符号键集合,整体居中",
"right": [
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
],
"__comment_right": "右侧删除键"
}
},
{
"__comment": "符号第四行ABC/emoji/space/send",
"align": "left",
"__comment_align": "对齐方式left/center",
"insetLeft": 4,
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset",
"insetRight": 4,
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset",
"gap": 5,
"__comment_gap": "本行按键间距(覆盖 metrics.gap",
"items": [
"mode_abc", "emoji", "space", "send"
],
"__comment_items": "本行按键列表letter:x/digit:x/sym:x 或 keyDefs 中的 id"
}
]
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
//
// KBBackspaceLongPressHandler.h
// CustomKeyboard
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface KBBackspaceLongPressHandler : NSObject
- (instancetype)initWithContainerView:(UIView *)containerView;
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
- (void)performClearAction;
@end
NS_ASSUME_NONNULL_END

Some files were not shown because too many files have changed in this diff Show More