Compare commits

...

7 Commits

Author SHA1 Message Date
Rocky 03837b0e48 Refine local AI hint fallbacks
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled
Keep clipboard actions in Skills and focus offline hints on distinct daily market briefings while preventing retired cached cards from resurfacing.
2026-08-23 20:26:34 +08:00
Rocky 2cc81c4628 feat(keyboard): expand contextual skills and managed flows
Add local clipboard intent recommendations, webpage and phone actions, and safer host handoffs. Refine managed gateway, catalog refresh, onboarding, and adaptive polish behavior.
2026-08-23 14:02:33 +08:00
Rocky 7df5bbdaa0 Refine adaptive polish and cross-platform account support
Improve learned-style and clipboard skill presentation while keeping shared account clients buildable in the source-composed macOS target.
2026-08-22 17:06:22 +08:00
Rocky fe8f657c8d Fix CI tooling and semantic skill recommendations
Resolve the pinned XcodeGen archive layout and let the keyboard derive bounded clipboard recommendations from the full catalog independently of installation state.
2026-08-22 16:46:37 +08:00
Rocky 386ba140f7 Refine skill catalog installation flow
Move skill installation into dedicated detail pages and keep installed-state rendering compatible with SwiftUI result builders.
2026-08-22 16:41:04 +08:00
Rocky e5a83843db Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
2026-08-22 16:33:18 +08:00
Rocky ac374631ae fix(analytics): prevent database locks during suspension 2026-08-21 21:13:13 +08:00
180 changed files with 27428 additions and 1807 deletions
+3
View File
@@ -62,6 +62,9 @@ Signing.local.xcconfig
# Custom LM compile output (Mac-only; device-side prepare uses .bin from bundle)
OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
# Reproducible Create ML candidates (selected models live in Shared resources)
ModelTraining/ClipboardSemantics/Candidates/
# Python bytecode
__pycache__/
*.pyc
+1 -1
View File
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format
The current source-of-truth version is **2.0.0 (build 86)**. Releases use stable SemVer:
The current source-of-truth version is **2.0.1 (build 89)**. Releases use stable SemVer:
| Field | File | Rule |
|-------|------|------|
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+34 -4
View File
@@ -8,18 +8,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Privacy-preserving product analytics**: add an optional first-party event queue with cross-process SQLite durability, idempotent retry, fixed privacy-safe dimensions, and a device-local opt-out that clears pending events. / **隐私友好的产品分析**:新增可选的第一方事件队列,通过跨进程 SQLite 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务
- **Free OOBE polish**: the first-run lesson defaults to on-device transcription plus OSG managed polish, sends an authenticated OOBE purpose tag, and uses a server-enforced one-time complimentary request without reserving or settling credits. / **免费 OOBE 润色**:首次教学默认使用端侧转写与 OSG 托管润色,发送经过身份验证的 OOBE 用途标签,并由服务端执行一次性免费资格,不预扣也不结算积分
- **Phone-number clipboard actions**: recognize a single copied phone number locally, then offer system calling or a new-contact form with the number prefilled. / **电话号码剪贴板操作**:在本地识别复制内容中的唯一电话号码,并提供系统拨号或预填号码的新建联系人表单
- **Link-aware clipboard skills**: recognize a single copied web link locally, offer direct opening in the default browser, and securely extract bounded public webpage text in the host app before generating a summary. / **链接感知剪贴板技能**:在本地识别复制内容中的唯一网页链接,提供默认浏览器直接打开入口,并由主 App 安全、限量提取公开网页正文后生成总结
- **Local clipboard semantics**: add reproducible bilingual Create ML training, self-contained on-device classifiers for tasks, questions, invitations, complaints, replyable messages, and sentiment, plus deterministic language, date, address, phone, URL, person, and organization labels without uploading clipboard text. / **本地剪贴板语义**:新增可复现的中英语料与 Create ML 训练流程,以及任务、问句、邀约、投诉、可回复消息和情感的端侧自包含分类器,并结合确定性的语言、日期、地址、电话、URL、人名和组织名标签,全程不上传剪贴板原文
- **Contextual clipboard skills**: add source-language, invitation, task, integrated clarification, empathy, business, playful, summary, and list actions, then select up to five relevant keyboard skills from the complete catalog using ephemeral local clipboard labels while limiting reply-style choices to two. / **情境化剪贴板技能**:新增原语言回复、邀约、任务、综合澄清、共情、商务、俏皮、总结与清单操作,并通过本地临时剪贴板标签从完整目录中自动选出最多五个相关技能,同时将回复类选择限制为最多两个。
- **Learned speaking styles**: unlock user-initiated style generation after 5,000 effective dictation characters, prioritize recurring native speech and explicit user edits, use historical polish prompts only to subtract AI-added style, and require review before saving. / **学习说话风格**:累积 5,000 个有效听写字符后,可优先根据反复出现的原生口述与用户明确编辑生成个人风格,历史润色 Prompt 仅用于排除 AI 附加风格,并在保存前强制检查。
### Changed
- **AI Agent first run**: replace the technical six-step setup with a concise privacy, permission, and keyboard flow that presents OSGKeyboard as an AI Agent voice keyboard, teaches on the real keyboard, and verifies the first voice insertion before completion. / **AI Agent 首次体验**:以简洁的隐私、权限与键盘流程取代技术化的六步配置,突出 AI Agent 语音输入法定位,并在真实键盘中完成教学、验证首次语音上屏后再结束
- **Focused clipboard actions**: translate foreign clipboard text into the device's primary system language, consolidate overlapping reply, summary, and clarification skills with legacy-layout migration, distinguish webpage summaries, and avoid playful suggestions for negative messages. / **聚焦剪贴板操作**:将外语剪贴板文本翻译为设备首选系统语言,兼容迁移并整合重复的回复、总结与澄清技能,区分网页总结,并避免为负面消息推荐俏皮回复
- **Personal dictionary empty state**: remove the Rime explanation from the Home card and shorten its empty message to “No suggestions yet.” / **个性词库空状态**:移除首页卡片中的 Rime 说明,并将空状态精简为“暂无推荐词”。
- **Skill catalog layout**: replace the two-column cards with scroll-safe installed and uninstalled lists; selecting a row now opens a full detail page where installation, Shortcut setup, and custom-skill editing are managed. / **技能目录布局**:将双列卡片改为可稳定滚动的已安装与未安装列表;点击列表项进入完整详情页,并统一管理安装、捷径配置与自定义技能编辑。
- **Default-installed text skills**: immediately install newly created custom and newly published official text skills while preserving explicit uninstall choices and Shortcut confirmation. / **默认安装文本技能**:新建的自定义文本技能与新发布的官方文本技能会立即安装,同时保留用户主动卸载选择及捷径确认流程。
- **Conversational skill replies**: make non-business replies sound like an ordinary person talking with friends or colleagues, allow one fitting emoji when emotion clearly calls for it, keep business replies naturally professional, and apply the active user-owned learned style only as a bounded wording-and-rhythm layer. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。
### Fixed
- **Repeatable OOBE practice**: scope each free feature to one use per short-lived guided session, so returning to OOBE starts a fresh four-page experience without misreporting consumed-page conflicts as weak network; also simplify lesson titles, add a voice sample to read aloud, and streamline the completion page. / **可重复的 OOBE 体验**:将每项免费功能限制为每个短期引导会话使用一次,让用户重新进入 OOBE 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
- **Managed hotword answers**: run keyboard hotwords with the same DeepSeek thinking policy as hold-to-ask AI, require online search for time-sensitive cards, preserve hotword usage attribution, and distinguish credit, provider, timeout, and gateway failures. / **积分热词回答**:键盘热词现在采用与长按问 AI 相同的 DeepSeek 思考策略,时效内容强制在线搜索,保留热词用量归因,并区分积分、服务商、超时与网关故障。
## [2.0.1] - 2026-08-21
### Added
- **Rime term suggestions**: move the Personal Dictionary card above History and surface up to five repeated local Rime terms for explicit one-tap confirmation without recording full keystrokes. / **Rime 词条建议**:将个性词库卡片移到历史记录上方,展示最多五个本机 Rime 高频新词,并由用户逐个确认加入,不记录完整击键内容。
- **Private keyboard usage summaries**: count only manually committed Chinese, Latin, and other graphemes plus UTC-day input-session classes, then upload immutable App Group summaries without retaining typed text, host-app context, or per-key events. / **隐私键盘用量摘要**:仅统计手动上屏的中文、拉丁字母及其他字形簇与 UTC 每日输入会话分类,并通过 App Group 上传不可变摘要,不保留输入原文、宿主上下文或逐按键事件。
- **Official Skill Catalog**: fetch validated transform skills anonymously from the OSG content service, cache the last-known-good snapshot in App Group storage, and update enabled keyboard skills without requiring an app release. / **官方技能目录**:从 OSG 内容服务匿名获取并严格校验转换类技能,将最后可用快照缓存到 App Group,并可在无需发布新版 App 的情况下更新已启用的键盘技能。
- **Privacy-preserving product analytics**: add an optional first-party event queue with cross-process SQLite durability, idempotent retry, fixed privacy-safe dimensions, and a device-local opt-out that clears pending events. / **隐私友好的产品分析**:新增可选的第一方事件队列,通过跨进程 SQLite 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关。
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
- **Anonymous four-feature OOBE**: after App Attest verifies the installation, first-run users can try voice input, clipboard translation, smart reply, and hold-to-ask AI once each without an account, API key, or credit charge; an optional sign-in step then offers the existing 1,000-credit signup reward. / **匿名四功能 OOBE**:App Attest 验证安装后,首次用户无需账号、API Key 或积分即可各体验一次语音输入、剪贴板翻译、智能回复与长按询问 AI;随后可选择登录并领取现有的 1000 积分注册奖励。
### Changed
- **Paired voice history corpus**: retain the corrected pre-polish ASR transcript beside the displayed final text, together with translation and active-style metadata, so future style learning can exclude incompatible samples without exposing raw text in History. / **成对语音历史语料**:在展示的最终文本旁保留纠错后的润色前 ASR 转写,并记录翻译与当时风格元数据,便于后续风格学习排除不兼容样本,同时不在历史界面显示原文。
- **Anonymous hint feed**: move AI hint manifests and locale packs to the account content API with ETag revalidation while preserving per-locale last-known-good packs and local evergreen merging. / **匿名提示内容源**:将 AI 提示清单与语言包迁移到账号内容 API,并加入 ETag 重新验证,同时保留各语言最后可用包与本地常驻内容合并策略。
- **Native Liquid Glass tabs**: replace the custom iPhone dock with the system TabView so tab selection uses the native iOS 26 Liquid Glass transition and preserves each tab's navigation context. / **原生液态玻璃标签栏**:以系统 TabView 取代 iPhone 自定义 Dock,让标签切换使用 iOS 26 原生液态玻璃过渡,并保留各标签页的导航上下文。
- **AI Agent first run**: replace the technical six-step setup with a concise privacy, permission, and keyboard flow that teaches four real keyboard capabilities before the optional account offer and verifies every lesson result before completion. / **AI Agent 首次体验**:以简洁的隐私、权限与键盘流程取代技术化的六步配置,在可选账号邀请之前教学四项真实键盘能力,并逐项验证结果后再完成。
- **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
### Fixed
- **Signed-out AI billing safety**: switch every AI path back to user-owned BYOK when the account signs out or expires, and reject cached managed grants unless the host has confirmed an authenticated session. / **未登录 AI 计费安全**:账号退出或过期时将所有 AI 路径切回用户自备 BYOK,并在主 App 未确认有效登录会话时拒绝使用缓存的托管 Grant。
- **Suspension-safe analytics**: batch uploads in the foreground or a system-granted refresh task, close SQLite outside authorized execution windows, release cancelled leases immediately, and keep keyboard extensions recording-only to prevent system termination from database locks. / **安全挂起埋点**:仅在前台或系统授权的刷新任务中批量上传,在授权执行窗口之外关闭 SQLite,并立即释放已取消的租约,同时让键盘扩展仅负责记录,避免数据库锁触发系统终止。
- **One-shot purchase confirmation**: show a successful credit purchase only for the current action, automatically dismiss it, and prevent recovered historical StoreKit transactions from restoring stale green confirmation text. / **单次购买确认**:积分购买成功提示只对应当前操作并自动消失,恢复 StoreKit 历史交易时不再重新显示过期的绿色确认文字。
- **Permanent invitation links**: load the account-scoped server invitation profile after sign-in, cache it per account, and keep sharing the exact stable server URL across refreshes and offline failures. / **永久邀请链接**:登录后异步加载账号级服务端邀请资料,按账号缓存,并在刷新或离线失败时持续分享服务端返回的固定链接。
- **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。
- **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。
- **Account purchase recovery**: keep App Store credit packs independent from account-snapshot failures, retry transient product loading, cancel stale account tasks safely, and return expired sessions to sign-in without requiring a manual sign-out. / **账号内购恢复**:积分包不再受账号快照失败影响,商品瞬时加载失败会自动重试,旧账号任务可安全取消,登录过期后会直接返回登录状态,无需手动退出。
- **Managed AI task routing**: credit-backed requests now preserve dictation, translation, editing, question, clipboard, custom-skill, and agent intent so the gateway can disable costly reasoning for low-latency transforms. / **托管 AI 任务路由**:积分请求现在会保留听写、翻译、编辑、问答、剪贴板、自定义技能与 Agent 意图,让网关可为低延迟转换任务关闭高成本思考。
## [2.0.0] - 2026-08-19
@@ -0,0 +1,52 @@
# Clipboard semantic models
This directory contains the reproducible training inputs and evaluation output
for OSGKeyboard's fully local clipboard analyzer.
## Scope
The model suite predicts five independent intents (`task`, `question`,
`invitation`, `complaint`, and `replyableMessage`) plus three-way sentiment.
`replyableMessage` distinguishes messages that invite a response from terminal
acknowledgments, personal notes, quoted questions, and factual notices. Apple
data detectors remain responsible for dates, addresses, phone numbers, and
URLs; `NLTagger` provides best-effort person and organization names.
The corpus contains 7,272 Chinese and English records:
- 4,660 generated training records
- 1,260 generated validation records
- 1,260 template-held-out test records
- 92 manually authored golden records
No user clipboard content is included.
## Reproduce
```bash
python3 Scripts/clipboard_semantics/generate_corpus.py
xcrun swift Scripts/clipboard_semantics/train_models.swift
```
The trainer balances labels, trains maxEnt and BERT candidates, calibrates
high-precision thresholds, writes detailed errors to `evaluation-report.json`,
and copies the selected models into
`OSGKeyboardShared/Resources/ClipboardSemantics`.
## Deployment decision
Only maxEnt models are eligible for keyboard automatic routing. Create ML BERT
transfer models depend on `NLContextualEmbedding` assets that are not guaranteed
to exist in a simulator or keyboard-extension runtime, so they remain evaluation
candidates only.
The selected intent models passed the automatic-routing precision gates. The
replyable-message model reached 100% test precision and 97.96% golden precision;
its measured recall remains part of release monitoring. The complaint model is
also high precision but remains conservative because golden recall is limited.
Sentiment returns `unknown` unless confidence and top-two margin checks both
pass.
Synthetic results are not treated as production truth. Real opt-in, anonymized
or manually reviewed examples are still required before widening labels or
lowering thresholds.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
{
"containsUserClipboardData": false,
"families": {
"acknowledgment": 388,
"complaint_request": 546,
"complaint_statement": 546,
"conversational_message": 550,
"event_statement": 544,
"information_question": 544,
"invitation_question": 548,
"negative_news": 486,
"neutral_fact": 550,
"positive_feedback": 546,
"quoted_question": 384,
"self_plan": 546,
"task_question": 546,
"task_statement": 548
},
"languages": {
"en": 3636,
"zh-Hans": 3636
},
"positiveIntentLabels": {
"complaint": 1092,
"invitation": 548,
"question": 2182,
"replyable": 4374,
"task": 1094
},
"seed": 20260821,
"sentiments": {
"negative": 1578,
"neutral": 5148,
"positive": 546
},
"splits": {
"golden": 92,
"test": 1260,
"train": 4660,
"validation": 1260
},
"total": 7272
}
@@ -0,0 +1,840 @@
{
"classifiers" : [
{
"candidates" : [
{
"acceptedForAutomaticRouting" : true,
"algorithm" : "maxEnt",
"balancedTrainingCount" : 1440,
"balancedValidationCount" : 360,
"binaryByLanguage" : {
"en" : {
"accuracy" : 0.9619,
"f1" : 0.8462,
"falseNegative" : 24,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.7333,
"total" : 630,
"trueNegative" : 540,
"truePositive" : 66
},
"zh-Hans" : {
"accuracy" : 0.9968,
"f1" : 0.9888,
"falseNegative" : 2,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.9778,
"total" : 630,
"trueNegative" : 540,
"truePositive" : 88
}
},
"goldenBinary" : {
"accuracy" : 0.9239,
"f1" : 0.6667,
"falseNegative" : 7,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.5,
"total" : 92,
"trueNegative" : 78,
"truePositive" : 7
},
"goldenFalseNegativeExamples" : [
"[en] Alex owns the meeting notes and should post them in the project channel today. (score=0.5481)",
"[en] Remember to correct the invoice name and submit it again. (score=0.3677)",
"[en] Would you book a meeting room for Wednesday afternoon? (score=0.4118)",
"[zh-Hans] 记得把发票抬头改好以后重新提交。 (score=0.4999)",
"[zh-Hans] 下一步先联系客户确认交付地址,再更新订单。 (score=0.1536)",
"[zh-Hans] 方便帮我约一下周三下午的会议室吗? (score=0.2326)",
"[zh-Hans] 这些材料可以在月底前准备好吗? (score=0.5621)"
],
"goldenFalsePositiveExamples" : [
],
"modelBytes" : 17299,
"runtimeAssetIndependent" : true,
"testBinary" : {
"accuracy" : 0.9794,
"f1" : 0.9222,
"falseNegative" : 26,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.8556,
"total" : 1260,
"trueNegative" : 1080,
"truePositive" : 154
},
"testFalseNegativeExamples" : [
"[en] The deadline is by the end of the month; make sure you complete the risk register. (score=0.2401)",
"[en] The deadline is by Friday; make sure you confirm the project timeline. (score=0.4156)",
"[en] The deadline is by Friday; make sure you contact the client. (score=0.3509)",
"[en] The deadline is after you receive this message; make sure you check the invoice details. (score=0.385)",
"[en] The deadline is before 3 PM; make sure you contact the client. (score=0.2883)",
"[en] The deadline is after you receive this message; make sure you send the meeting notes. (score=0.3594)",
"[en] The deadline is by Friday; make sure you summarize the test results. (score=0.3174)",
"[en] The deadline is this week; make sure you share the delivery plan. (score=0.0888)",
"[en] The deadline is tomorrow morning; make sure you share the delivery plan. (score=0.1542)",
"[en] The deadline is this week; make sure you update the quotation. (score=0.1379)",
"[en] The deadline is by the end of the month; make sure you update the quotation. (score=0.2238)",
"[en] The deadline is this week; make sure you confirm the project timeline. (score=0.1903)"
],
"testFalsePositiveExamples" : [
],
"threshold" : 0.6,
"validationBinary" : {
"accuracy" : 0.973,
"f1" : 0.8963,
"falseNegative" : 33,
"falsePositive" : 1,
"precision" : 0.9932,
"recall" : 0.8167,
"total" : 1260,
"trueNegative" : 1079,
"truePositive" : 147
}
}
],
"id" : "task",
"labels" : [
"notTask",
"task"
],
"positiveLabel" : "task",
"selectedAlgorithm" : "maxEnt",
"selectedModelFile" : "TaskIntentClassifier.mlmodel"
},
{
"candidates" : [
{
"acceptedForAutomaticRouting" : true,
"algorithm" : "maxEnt",
"balancedTrainingCount" : 2880,
"balancedValidationCount" : 720,
"binaryByLanguage" : {
"en" : {
"accuracy" : 1,
"f1" : 1,
"falseNegative" : 0,
"falsePositive" : 0,
"precision" : 1,
"recall" : 1,
"total" : 630,
"trueNegative" : 450,
"truePositive" : 180
},
"zh-Hans" : {
"accuracy" : 0.9841,
"f1" : 0.9714,
"falseNegative" : 10,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.9444,
"total" : 630,
"trueNegative" : 450,
"truePositive" : 170
}
},
"goldenBinary" : {
"accuracy" : 0.9891,
"f1" : 0.9767,
"falseNegative" : 1,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.9545,
"total" : 92,
"trueNegative" : 70,
"truePositive" : 21
},
"goldenFalseNegativeExamples" : [
"[en] Support has not replied for three days. When will this be handled? (score=0.5261)"
],
"goldenFalsePositiveExamples" : [
],
"modelBytes" : 20231,
"runtimeAssetIndependent" : true,
"testBinary" : {
"accuracy" : 0.9921,
"f1" : 0.9859,
"falseNegative" : 10,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.9722,
"total" : 1260,
"trueNegative" : 900,
"truePositive" : 350
},
"testFalseNegativeExamples" : [
"[zh-Hans] 我需要明确答复:文件无法打开,你们准备如何解决? (score=0.5267)",
"[zh-Hans] 我需要明确答复:订单被重复扣款,你们准备如何解决? (score=0.5534)",
"[zh-Hans] 我需要明确答复:预约记录消失了,你们准备如何解决? (score=0.3034)",
"[zh-Hans] 我需要明确答复:消息始终发不出去,你们准备如何解决? (score=0.556)",
"[zh-Hans] 我需要明确答复:收到的商品有破损,你们准备如何解决? (score=0.5209)",
"[zh-Hans] 我需要明确答复:页面加载特别慢,你们准备如何解决? (score=0.5228)",
"[zh-Hans] 我需要明确答复:账号突然被锁定,你们准备如何解决? (score=0.4902)",
"[zh-Hans] 我需要明确答复:应用一直闪退,你们准备如何解决? (score=0.5513)",
"[zh-Hans] 我需要明确答复:数据同步失败,你们准备如何解决? (score=0.4913)",
"[zh-Hans] 我需要明确答复:发票信息写错了,你们准备如何解决? (score=0.3555)"
],
"testFalsePositiveExamples" : [
],
"threshold" : 0.6,
"validationBinary" : {
"accuracy" : 0.9794,
"f1" : 0.9632,
"falseNegative" : 20,
"falsePositive" : 6,
"precision" : 0.9827,
"recall" : 0.9444,
"total" : 1260,
"trueNegative" : 894,
"truePositive" : 340
}
}
],
"id" : "question",
"labels" : [
"notQuestion",
"question"
],
"positiveLabel" : "question",
"selectedAlgorithm" : "maxEnt",
"selectedModelFile" : "QuestionIntentClassifier.mlmodel"
},
{
"candidates" : [
{
"acceptedForAutomaticRouting" : true,
"algorithm" : "maxEnt",
"balancedTrainingCount" : 720,
"balancedValidationCount" : 180,
"binaryByLanguage" : {
"en" : {
"accuracy" : 0.9571,
"f1" : 0.5714,
"falseNegative" : 27,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.4,
"total" : 630,
"trueNegative" : 585,
"truePositive" : 18
},
"zh-Hans" : {
"accuracy" : 0.9952,
"f1" : 0.9663,
"falseNegative" : 2,
"falsePositive" : 1,
"precision" : 0.9773,
"recall" : 0.9556,
"total" : 630,
"trueNegative" : 584,
"truePositive" : 43
}
},
"goldenBinary" : {
"accuracy" : 0.9783,
"f1" : 0.8571,
"falseNegative" : 2,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.75,
"total" : 92,
"trueNegative" : 84,
"truePositive" : 6
},
"goldenFalseNegativeExamples" : [
"[en] I'd like to invite you to Monday's project kickoff. Are you available? (score=0.284)",
"[zh-Hans] 周末一起去爬山怎么样?早上九点地铁站见。 (score=0.7468)"
],
"goldenFalsePositiveExamples" : [
],
"modelBytes" : 14987,
"runtimeAssetIndependent" : true,
"testBinary" : {
"accuracy" : 0.9762,
"f1" : 0.8026,
"falseNegative" : 29,
"falsePositive" : 1,
"precision" : 0.9839,
"recall" : 0.6778,
"total" : 1260,
"trueNegative" : 1169,
"truePositive" : 61
},
"testFalseNegativeExamples" : [
"[en] I saved you a spot to meet this weekend after work at the campus cafe. How does that sound? (score=0.366)",
"[en] I saved you a spot to have a quick call next Monday at noon at the station. How does that sound? (score=0.5668)",
"[en] I saved you a spot to visit the client at seven tonight at the Wangjing branch. How does that sound? (score=0.5685)",
"[en] I saved you a spot to have dinner this weekend at the campus cafe. How does that sound? (score=0.4259)",
"[en] I saved you a spot to watch a movie before the end of the month at the campus cafe. How does that sound? (score=0.3009)",
"[en] I saved you a spot to attend the design review Friday morning at Meeting Room 5. How does that sound? (score=0.4253)",
"[en] I saved you a spot to watch a movie Friday morning at the station. How does that sound? (score=0.4304)",
"[en] I saved you a spot to visit the client after work at the station. How does that sound? (score=0.3609)",
"[en] I saved you a spot to have dinner this weekend at the station. How does that sound? (score=0.4006)",
"[en] I saved you a spot to attend the design review Wednesday evening at the online meeting room. How does that sound? (score=0.4223)",
"[en] I saved you a spot to get coffee before the end of the month at Meeting Room 5. How does that sound? (score=0.2489)",
"[en] I saved you a spot to get coffee after work at the Wangjing branch. How does that sound? (score=0.3714)"
],
"testFalsePositiveExamples" : [
"[zh-Hans] 文档记载的喝咖啡定于周三晚上在线上会议室进行。 (score=0.8353)"
],
"threshold" : 0.77,
"validationBinary" : {
"accuracy" : 0.9817,
"f1" : 0.8571,
"falseNegative" : 21,
"falsePositive" : 2,
"precision" : 0.9718,
"recall" : 0.7667,
"total" : 1260,
"trueNegative" : 1168,
"truePositive" : 69
}
}
],
"id" : "invitation",
"labels" : [
"notInvitation",
"invitation"
],
"positiveLabel" : "invitation",
"selectedAlgorithm" : "maxEnt",
"selectedModelFile" : "InvitationIntentClassifier.mlmodel"
},
{
"candidates" : [
{
"acceptedForAutomaticRouting" : true,
"algorithm" : "maxEnt",
"balancedTrainingCount" : 1440,
"balancedValidationCount" : 360,
"binaryByLanguage" : {
"en" : {
"accuracy" : 0.9508,
"f1" : 0.7919,
"falseNegative" : 31,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.6556,
"total" : 630,
"trueNegative" : 540,
"truePositive" : 59
},
"zh-Hans" : {
"accuracy" : 0.9841,
"f1" : 0.9419,
"falseNegative" : 9,
"falsePositive" : 1,
"precision" : 0.9878,
"recall" : 0.9,
"total" : 630,
"trueNegative" : 539,
"truePositive" : 81
}
},
"goldenBinary" : {
"accuracy" : 0.9239,
"f1" : 0.5882,
"falseNegative" : 7,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.4167,
"total" : 92,
"trueNegative" : 80,
"truePositive" : 5
},
"goldenFalseNegativeExamples" : [
"[en] The app has crashed constantly since the update, and I lost important data. (score=0.5909)",
"[en] I was charged twice for the same order and still have not received a refund. (score=0.4438)",
"[en] The package arrived damaged and the cup inside was broken. (score=0.3134)",
"[en] My account was locked for no reason. Can you restore it today? (score=0.2417)",
"[en] Support has not replied for three days. When will this be handled? (score=0.3498)",
"[zh-Hans] 收到的商品外包装破损,里面的杯子也碎了。 (score=0.2457)",
"[zh-Hans] 客服已经三天没有回复了,请问什么时候能处理? (score=0.5563)"
],
"goldenFalsePositiveExamples" : [
],
"modelBytes" : 16850,
"runtimeAssetIndependent" : true,
"testBinary" : {
"accuracy" : 0.9675,
"f1" : 0.8723,
"falseNegative" : 40,
"falsePositive" : 1,
"precision" : 0.9929,
"recall" : 0.7778,
"total" : 1260,
"trueNegative" : 1079,
"truePositive" : 140
},
"testFalseNegativeExamples" : [
"[en] I need a clear answer: my reservation disappeared. How are you going to resolve it? (score=0.2123)",
"[en] I need a clear answer: the file will not open. How are you going to resolve it? (score=0.1792)",
"[en] I need a clear answer: the item arrived damaged. How are you going to resolve it? (score=0.202)",
"[en] I need a clear answer: the page loads extremely slowly. How are you going to resolve it? (score=0.21)",
"[en] I need a clear answer: the order was charged twice. How are you going to resolve it? (score=0.186)",
"[en] I need a clear answer: the invoice information is wrong. How are you going to resolve it? (score=0.2094)",
"[en] I need a clear answer: messages never send. How are you going to resolve it? (score=0.1964)",
"[en] I need a clear answer: data synchronization fails. How are you going to resolve it? (score=0.2338)",
"[en] I need a clear answer: my account was suddenly locked. How are you going to resolve it? (score=0.3079)",
"[en] This is not the first time that the file will not open; now support has not resolved it. (score=0.5497)",
"[en] This is not the first time that messages never send; now today's delivery is now at risk. (score=0.4564)",
"[en] This is not the first time that data synchronization fails; now the issue remains after several attempts. (score=0.599)"
],
"testFalsePositiveExamples" : [
"[zh-Hans] 对于订单状态,这次有没有明确答案? (score=0.7457)"
],
"threshold" : 0.68,
"validationBinary" : {
"accuracy" : 1,
"f1" : 1,
"falseNegative" : 0,
"falsePositive" : 0,
"precision" : 1,
"recall" : 1,
"total" : 1260,
"trueNegative" : 1080,
"truePositive" : 180
}
}
],
"id" : "complaint",
"labels" : [
"notComplaint",
"complaint"
],
"positiveLabel" : "complaint",
"selectedAlgorithm" : "maxEnt",
"selectedModelFile" : "ComplaintIntentClassifier.mlmodel"
},
{
"candidates" : [
{
"acceptedForAutomaticRouting" : true,
"algorithm" : "maxEnt",
"balancedTrainingCount" : 3560,
"balancedValidationCount" : 1080,
"binaryByLanguage" : {
"en" : {
"accuracy" : 0.9381,
"f1" : 0.9427,
"falseNegative" : 39,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.8917,
"total" : 630,
"trueNegative" : 270,
"truePositive" : 321
},
"zh-Hans" : {
"accuracy" : 1,
"f1" : 1,
"falseNegative" : 0,
"falsePositive" : 0,
"precision" : 1,
"recall" : 1,
"total" : 630,
"trueNegative" : 270,
"truePositive" : 360
}
},
"goldenBinary" : {
"accuracy" : 0.9239,
"f1" : 0.932,
"falseNegative" : 6,
"falsePositive" : 1,
"precision" : 0.9796,
"recall" : 0.8889,
"total" : 92,
"trueNegative" : 37,
"truePositive" : 48
},
"goldenFalseNegativeExamples" : [
"[en] Alex owns the meeting notes and should post them in the project channel today. (score=0.4433)",
"[en] First confirm the delivery address with the client, then update the order. (score=0.4795)",
"[en] Why is the order still marked as under review? (score=0.4847)",
"[zh-Hans] 记得把发票抬头改好以后重新提交。 (score=0.0669)",
"[zh-Hans] 下一步先联系客户确认交付地址,再更新订单。 (score=0.0634)",
"[zh-Hans] 这些材料可以在月底前准备好吗? (score=0.1104)"
],
"goldenFalsePositiveExamples" : [
"[zh-Hans] 明天我要去财务核对发票,不用你帮忙。 (score=0.9516)"
],
"modelBytes" : 19623,
"runtimeAssetIndependent" : true,
"testBinary" : {
"accuracy" : 0.969,
"f1" : 0.9722,
"falseNegative" : 39,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.9458,
"total" : 1260,
"trueNegative" : 540,
"truePositive" : 681
},
"testFalseNegativeExamples" : [
"[en] This is not the first time that the file will not open; now the issue remains after several attempts. (score=0.4887)",
"[en] A positive review: The keyboard experience solved the problem. Keep it up. (score=0.5028)",
"[en] A positive review: The support response is much more accurate. Keep it up. (score=0.5665)",
"[en] A positive review: This update is easier to use. Keep it up. (score=0.5908)",
"[en] A positive review: The new speech recognition exceeded my expectations. Keep it up. (score=0.4991)",
"[en] A positive review: The latest fix works very smoothly. Keep it up. (score=0.5422)",
"[en] A positive review: The keyboard experience is noticeably more stable. Keep it up. (score=0.5804)",
"[en] A positive review: The interface change is easier to use. Keep it up. (score=0.3015)",
"[en] A positive review: The new speech recognition is much more accurate. Keep it up. (score=0.5761)",
"[en] A positive review: The latest fix solved the problem. Keep it up. (score=0.43)",
"[en] A positive review: The sync speed solved the problem. Keep it up. (score=0.4013)",
"[en] A positive review: The translation result is noticeably more stable. Keep it up. (score=0.5841)"
],
"testFalsePositiveExamples" : [
],
"threshold" : 0.6,
"validationBinary" : {
"accuracy" : 0.95,
"f1" : 0.9555,
"falseNegative" : 43,
"falsePositive" : 20,
"precision" : 0.9713,
"recall" : 0.9403,
"total" : 1260,
"trueNegative" : 520,
"truePositive" : 677
}
}
],
"id" : "replyableMessage",
"labels" : [
"notReplyableMessage",
"replyableMessage"
],
"positiveLabel" : "replyableMessage",
"selectedAlgorithm" : "maxEnt",
"selectedModelFile" : "ConversationalReplyIntentClassifier.mlmodel"
},
{
"candidates" : [
{
"acceptedForAutomaticRouting" : true,
"algorithm" : "maxEnt",
"balancedTrainingCount" : 1080,
"balancedValidationCount" : 270,
"goldenMulticlass" : {
"accuracy" : 0.8913,
"confusion" : {
"negative" : {
"negative" : 13,
"neutral" : 4,
"positive" : 1
},
"neutral" : {
"negative" : 3,
"neutral" : 64,
"positive" : 1
},
"positive" : {
"negative" : 1,
"neutral" : 0,
"positive" : 5
}
},
"macroF1" : 0.8178,
"perLabel" : {
"negative" : {
"accuracy" : 0.9022,
"f1" : 0.7429,
"falseNegative" : 5,
"falsePositive" : 4,
"precision" : 0.7647,
"recall" : 0.7222,
"total" : 92,
"trueNegative" : 70,
"truePositive" : 13
},
"neutral" : {
"accuracy" : 0.913,
"f1" : 0.9412,
"falseNegative" : 4,
"falsePositive" : 4,
"precision" : 0.9412,
"recall" : 0.9412,
"total" : 92,
"trueNegative" : 20,
"truePositive" : 64
},
"positive" : {
"accuracy" : 0.9674,
"f1" : 0.7692,
"falseNegative" : 1,
"falsePositive" : 2,
"precision" : 0.7143,
"recall" : 0.8333,
"total" : 92,
"trueNegative" : 84,
"truePositive" : 5
}
},
"total" : 92
},
"modelBytes" : 16629,
"multiclassByLanguage" : {
"en" : {
"accuracy" : 0.9778,
"confusion" : {
"negative" : {
"negative" : 121,
"neutral" : 14,
"positive" : 0
},
"neutral" : {
"negative" : 0,
"neutral" : 450,
"positive" : 0
},
"positive" : {
"negative" : 0,
"neutral" : 0,
"positive" : 45
}
},
"macroF1" : 0.9767,
"perLabel" : {
"negative" : {
"accuracy" : 0.9778,
"f1" : 0.9453,
"falseNegative" : 14,
"falsePositive" : 0,
"precision" : 1,
"recall" : 0.8963,
"total" : 630,
"trueNegative" : 495,
"truePositive" : 121
},
"neutral" : {
"accuracy" : 0.9778,
"f1" : 0.9847,
"falseNegative" : 0,
"falsePositive" : 14,
"precision" : 0.9698,
"recall" : 1,
"total" : 630,
"trueNegative" : 166,
"truePositive" : 450
},
"positive" : {
"accuracy" : 1,
"f1" : 1,
"falseNegative" : 0,
"falsePositive" : 0,
"precision" : 1,
"recall" : 1,
"total" : 630,
"trueNegative" : 585,
"truePositive" : 45
}
},
"total" : 630
},
"zh-Hans" : {
"accuracy" : 0.9746,
"confusion" : {
"negative" : {
"negative" : 126,
"neutral" : 9,
"positive" : 0
},
"neutral" : {
"negative" : 3,
"neutral" : 443,
"positive" : 4
},
"positive" : {
"negative" : 0,
"neutral" : 0,
"positive" : 45
}
},
"macroF1" : 0.9647,
"perLabel" : {
"negative" : {
"accuracy" : 0.981,
"f1" : 0.9545,
"falseNegative" : 9,
"falsePositive" : 3,
"precision" : 0.9767,
"recall" : 0.9333,
"total" : 630,
"trueNegative" : 492,
"truePositive" : 126
},
"neutral" : {
"accuracy" : 0.9746,
"f1" : 0.9823,
"falseNegative" : 7,
"falsePositive" : 9,
"precision" : 0.9801,
"recall" : 0.9844,
"total" : 630,
"trueNegative" : 171,
"truePositive" : 443
},
"positive" : {
"accuracy" : 0.9937,
"f1" : 0.9574,
"falseNegative" : 0,
"falsePositive" : 4,
"precision" : 0.9184,
"recall" : 1,
"total" : 630,
"trueNegative" : 581,
"truePositive" : 45
}
},
"total" : 630
}
},
"runtimeAssetIndependent" : true,
"testMulticlass" : {
"accuracy" : 0.9762,
"confusion" : {
"negative" : {
"negative" : 247,
"neutral" : 23,
"positive" : 0
},
"neutral" : {
"negative" : 3,
"neutral" : 893,
"positive" : 4
},
"positive" : {
"negative" : 0,
"neutral" : 0,
"positive" : 90
}
},
"macroF1" : 0.9706,
"perLabel" : {
"negative" : {
"accuracy" : 0.9794,
"f1" : 0.95,
"falseNegative" : 23,
"falsePositive" : 3,
"precision" : 0.988,
"recall" : 0.9148,
"total" : 1260,
"trueNegative" : 987,
"truePositive" : 247
},
"neutral" : {
"accuracy" : 0.9762,
"f1" : 0.9835,
"falseNegative" : 7,
"falsePositive" : 23,
"precision" : 0.9749,
"recall" : 0.9922,
"total" : 1260,
"trueNegative" : 337,
"truePositive" : 893
},
"positive" : {
"accuracy" : 0.9968,
"f1" : 0.9783,
"falseNegative" : 0,
"falsePositive" : 4,
"precision" : 0.9574,
"recall" : 1,
"total" : 1260,
"trueNegative" : 1166,
"truePositive" : 90
}
},
"total" : 1260
},
"validationMulticlass" : {
"accuracy" : 0.9754,
"confusion" : {
"negative" : {
"negative" : 262,
"neutral" : 8,
"positive" : 0
},
"neutral" : {
"negative" : 19,
"neutral" : 877,
"positive" : 4
},
"positive" : {
"negative" : 0,
"neutral" : 0,
"positive" : 90
}
},
"macroF1" : 0.9706,
"perLabel" : {
"negative" : {
"accuracy" : 0.9786,
"f1" : 0.951,
"falseNegative" : 8,
"falsePositive" : 19,
"precision" : 0.9324,
"recall" : 0.9704,
"total" : 1260,
"trueNegative" : 971,
"truePositive" : 262
},
"neutral" : {
"accuracy" : 0.9754,
"f1" : 0.9826,
"falseNegative" : 23,
"falsePositive" : 8,
"precision" : 0.991,
"recall" : 0.9744,
"total" : 1260,
"trueNegative" : 352,
"truePositive" : 877
},
"positive" : {
"accuracy" : 0.9968,
"f1" : 0.9783,
"falseNegative" : 0,
"falsePositive" : 4,
"precision" : 0.9574,
"recall" : 1,
"total" : 1260,
"trueNegative" : 1166,
"truePositive" : 90
}
},
"total" : 1260
}
}
],
"id" : "sentiment",
"labels" : [
"negative",
"neutral",
"positive"
],
"selectedAlgorithm" : "maxEnt",
"selectedModelFile" : "SentimentClassifier.mlmodel"
}
],
"corpusCount" : 7272,
"corpusPath" : "ModelTraining/ClipboardSemantics/clipboard_semantic_corpus.jsonl",
"generatedAt" : "2026-08-22T09:30:13Z",
"goldenCount" : 92,
"selectionPolicy" : "Validation only: binary models require precision >= 0.97, then maximize recall; sentiment prioritizes macro-F1. Automatic routing also requires a self-contained maxEnt model because BERT embedding assets are not guaranteed in extensions. Test and golden data gate deployment but never tune model weights.",
"testCount" : 1260,
"trainingCount" : 4660,
"validationCount" : 1260
}
+6 -4
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.osgkeyboard.ios.analytics-sync</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
@@ -98,6 +102,8 @@
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
<key>NSContactsUsageDescription</key>
<string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
<key>NSSpeechRecognitionUsageDescription</key>
@@ -115,10 +121,6 @@
<array>
<string>audio</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.osgkeyboard.ios.analytics-sync</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
+18 -14
View File
@@ -22,20 +22,22 @@
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
<div id="top">
<h1>OSGKeyboard Privacy Policy</h1>
<p><strong>Last updated:</strong> August 20, 2026 · <em>v2.0</em></p>
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, you may optionally sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains both paths.</p>
<p><strong>Last updated:</strong> August 22, 2026 · <em>v2.0</em></p>
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, first-run onboarding includes a limited anonymous AI trial, and you may later sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains these options.</p>
<h2>What we collect</h2>
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. With a user-configured cloud engine, recordings are sent directly to that ASR provider. If you explicitly choose managed credits, recordings are sent through the OSGKeyboard managed gateway at <code>account.osglab.com</code> to the managed ASR provider for transcription. OSGKeyboard does not keep raw recordings after the request is processed.</li>
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you use a configured LLM or managed credits. A small amount of text around the cursor may be included to continue naturally. Secure fields are never captured, and cursor context is not written to logs or voice history. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
<li><strong>Speaking-style learning</strong> — only after you tap “Generate My Style,” eligible pairs of pre-polish and final text, together with the polish-style prompts that produced those examples, are sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. The generated prompt is shown for review and is not saved or activated until you confirm it. When you later invoke a conversational clipboard-reply skill while a reviewed user-created style is active, that style prompt is sent with the copied text so the reply can follow your wording and rhythm; built-in polish personalities are not attached. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the selected AI service to generate an answer. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. When the selected service supports server-side web search, it may retrieve public web results for time-sensitive answers.</li>
<li><strong>Anonymous onboarding AI trial</strong> — before you sign in, onboarding can issue a short-lived, feature-limited credential after Apple App Attest verifies a genuine app installation. The four fixed lessons may send one voice transcript, an explicitly copied lesson sample for translation and reply, and one transcribed AI question through <code>account.osglab.com</code>. This flow does not create an account, process an Apple identity token, use credits, or upload raw voice audio. The copied lesson sample is temporary and does not enable or populate clipboard history. The service keeps only pseudonymous integrity, request-state, feature, and metering records needed to enforce the one-use limits and investigate abuse; it does not retain request or response bodies.</li>
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included. Opening the update page requests public content from <code>download.osglab.com</code>. Those hosts may process ordinary connection metadata such as IP address and request time.</li>
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. Reminders, Calendar, and Notes exports pass the generated result on-device to an Apple Shortcut you install. Navigation opens an address or route in Apple Maps, Amap, or Baidu Maps; the selected provider's policy applies.</li>
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
<li><strong>Optional OSGKeyboard account and purchases</strong> — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
<li><strong>Optional first-party product analytics</strong> — enabled by default and removable at any time in Settings. OSGKeyboard sends only fixed event names and fixed categories about app/keyboard sessions, AI feature outcomes and duration ranges, purchase-page interactions, and invitation actions, together with an app-scoped random installation identifier and safe app/OS version labels. It never includes keyboard input, audio, transcripts, cursor context, prompts, model output, contacts, file or device names, Apple identifiers, email, credentials, advertising identifiers, or free-form properties. Turning analytics off deletes queued events; turning it back on creates a new installation identifier.</li>
<li><strong>Optional first-party product analytics</strong> — enabled by default and removable at any time in Settings. OSGKeyboard sends only fixed event names and fixed categories about app/keyboard sessions, AI feature outcomes and duration ranges, purchase-page interactions, and invitation actions, plus daily aggregate counts of manually committed Chinese, Latin-letter, and other graphemes and four aggregate input-session classes. These records include an app-scoped random installation identifier and safe app/OS version labels. They never include typed text, pinyin, candidates, individual key events or timing, audio, transcripts, cursor context, prompts, model output, host-app or field information, contacts, file or device names, Apple identifiers, email, credentials, advertising identifiers, or free-form properties. Turning analytics off deletes queued records; turning it back on creates a new installation identifier.</li>
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this devices host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
<li><strong>macOS local speech models</strong> — when selected, Qwen3-ASR model files are downloaded from Hugging Face and remain in the Mac app's local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
@@ -53,17 +55,17 @@
<ul>
<li><strong>Microphone</strong> — required for voice input and background voice sessions.</li>
<li><strong>Speech recognition</strong> — required for on-device transcription.</li>
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, communicate with the main app, andonly when clipboard history is enabled—read clipboard text. Full Access does not grant us access to everything you type; we do not exfiltrate keystrokes.</li>
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, communicate with the main app, and read clipboard text only when clipboard history is enabled or when you explicitly copy the built-in onboarding sample for its AI lessons. Full Access does not grant us access to everything you type; we do not exfiltrate keystrokes.</li>
</ul>
<h2>Third parties</h2>
<p>User-configured cloud ASR and LLM requests go directly to the provider you configure. Managed-credit requests go through <code>account.osglab.com</code> to the managed ASR or LLM provider. Apple processes Sign in with Apple and StoreKit purchases. AI skills may hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and updates use <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each service's privacy policy applies.</p>
<p>User-configured cloud ASR and LLM requests go directly to the provider you configure. Anonymous onboarding and managed-credit requests go through <code>account.osglab.com</code> to the managed LLM provider. Apple processes App Attest, Sign in with Apple, and StoreKit purchases. AI skills may hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and updates use <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each service's privacy policy applies.</p>
<h2>Data retention</h2>
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
<p><strong>Voice history</strong> — successful transcripts may be saved in the main apps History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
<p><strong>Voice history and learned styles</strong> — successful transcripts may be saved in the main apps History tab (up to 300 entries). Each row can retain the displayed final text and, when available, the corrected ASR text from before AI polishing plus a deduplicated snapshot of the polish-style prompt used for that take; the pre-polish text is not shown in History. Raw audio and cursor context are not stored with the row. Clearing all history also removes retained prompt snapshots. A generated style is stored only after you review and save it. With iCloud settings sync enabled, eligible history and custom styles may sync across your devices through your private iCloud account.</p>
<p><strong>Clipboard history</strong> — stays in this devices App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
<p><strong>Product analytics</strong> — queued events expire locally after 34 days and the service accepts events no older than 35 days. Account deletion and analytics opt-out delete the local queue. Analytics identifiers are not used for cross-app tracking or advertising.</p>
<p><strong>Product analytics</strong> — queued events expire locally after 34 days. Daily keyboard-usage summaries are upload-eligible only for the previous 35 UTC days; older or server-rejected summaries are removed from the outbox and leave only an identifier-free local status diagnostic. Account deletion and analytics opt-out delete local analytics records. Analytics identifiers are not used for cross-app tracking or advertising.</p>
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
<p><strong>Optional account</strong> — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.</p>
@@ -73,20 +75,22 @@
<hr id="zh">
<h1>OSGKeyboard 隐私政策</h1>
<p><strong>更新日期:</strong>2026 年 8 月 20 日 · <em>v2.0</em></p>
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 用户也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策同时说明这两条可选路径。</p>
<p><strong>更新日期:</strong>2026 年 8 月 22 日 · <em>v2.0</em></p>
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 首次引导还提供限次匿名 AI 体验,之后也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策一并说明这可选路径。</p>
<h2>我们处理的数据</h2>
<ul>
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
<li><strong>转写文字与光标上下文</strong> — 使用已配置的 LLM 或托管积分时,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</li>
<li><strong>说话风格学习</strong> — 只有当你主动点击「生成我的风格」后,符合条件的润色前后文本对及产出这些示例时使用的润色风格 Prompt,才会发送给你配置的 AI 服务。原生口述与用户明确编辑用于归纳反复出现的习惯;历史 Prompt 只用于识别并排除 AI 附加风格。生成结果会先展示供你检查,确认保存前不会写入或启用。之后,当你启用了经检查的用户自定义风格并主动使用口语回复类剪贴板技能时,该风格 Prompt 会随复制文本一起发送,用于贴近你的用词与节奏;内置润色人格不会附加。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</li>
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字或你点选的建议会发送到当前选择的 AI 服务。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。所选服务支持联网搜索时,可能检索公开网页结果回答时效性问题。</li>
<li><strong>匿名首次体验</strong> — 登录前,开机引导可在 Apple App Attest 验证正版 App 后签发短时、限功能的匿名凭证。四项固定教学会经 <code>account.osglab.com</code> 分别发送一次语音转写文字、由你明确复制的教学示例(用于翻译与回复),以及一次语音转写后的 AI 问题;不会创建账号、处理 Apple 身份令牌、使用积分或上传原始语音。教学示例仅短时使用,不会开启或写入剪贴板历史。服务端只保留执行一次性限制和调查滥用所需的匿名完整性、请求状态、功能类型及计量记录,不保留请求或回答正文。</li>
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。相关主机可能处理 IP 地址、请求时间等普通连接元数据。</li>
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</li>
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
<li><strong>可选 OSGKeyboard 账号与购买</strong> — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识绝不包含键盘输入、音频、转录、光标上下文、Prompt、模型输出、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送事件;重新开启会生成新的安装标识。</li>
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,以及手动上屏的中文、拉丁字母、其他字形簇每日汇总数量和四类汇总输入会话。记录会携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识绝不包含输入原文、拼音、候选词、逐按键事件或时间、音频、转录、光标上下文、Prompt、模型输出、宿主 App 或输入框信息、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送记录;重新开启会生成新的安装标识。</li>
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
@@ -104,17 +108,17 @@
<ul>
<li><strong>麦克风</strong> — 语音输入与后台语音会话所需。</li>
<li><strong>语音识别</strong> — 端侧转写所需。</li>
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key、与主 App 通信,并仅在你开启剪贴板历史后读取剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key、与主 App 通信,并仅在你开启剪贴板历史,或主动复制内置教学示例进行 AI 体验时读取对应剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
</ul>
<h2>第三方</h2>
<p>用户自备的云端 ASR 与 LLM 请求直接发送到你配置的服务商;托管积分请求经 <code>account.osglab.com</code> 发送到托管 ASR 或 LLM 服务商。Apple 处理 Apple 登录与 StoreKit 购买。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code><code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各服务的隐私政策适用。</p>
<p>用户自备的云端 ASR 与 LLM 请求直接发送到你配置的服务商;匿名首次体验与托管积分请求经 <code>account.osglab.com</code> 发送到托管 LLM 服务商。Apple 处理 App Attest、Apple 登录与 StoreKit 购买。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code><code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各服务的隐私政策适用。</p>
<h2>数据保留</h2>
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
<p><strong>语音历史与学习风格</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。每条记录可同时保留界面展示的最终文本,以及在可用时保留 AI 润色前、已经过确定性纠错的 ASR 文本与当次使用的润色风格 Prompt 去重快照,供用户日后主动生成个人风格;润色前文本不会在历史界面显示,记录中也不保存原始音频或光标上下文。清空全部历史也会删除保留的 Prompt 快照。生成的风格只有在你检查并保存后才会写入。开启 iCloud 设置同步后,符合条件的历史与自定义风格可能通过你的私人 iCloud 账户在多设备间同步。</p>
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
<p><strong>产品分析</strong> — 待发送事件会在本地满 34 天后过期,服务端只接受 35 天以内的事件。注销账号或关闭产品分析会删除本地队列。分析标识不会用于跨 App 跟踪或广告。</p>
<p><strong>产品分析</strong> — 待发送事件会在本地满 34 天后过期。每日键盘用量摘要仅可上传最近 35 个已完成的 UTC 日期;更早或被服务端拒绝的摘要会从待发送队列移除,只留下不含标识符的本地状态诊断。注销账号或关闭产品分析会删除本地分析记录。分析标识不会用于跨 App 跟踪或广告。</p>
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
@@ -11,9 +11,9 @@ enum AIAgentShortcutRunner {
@MainActor
static func runPendingIfNeeded() {
AIAgentShortcutRun.trace("host.runPending begin")
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
let catalog = AppGroupStore().agentUserSkillCatalog
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else {
let store = AppGroupStore()
guard let payload = store.consumePendingShortcutRun() else { return }
guard let skill = store.resolvedAgentSkillCatalog.first(where: { $0.id == payload.skillID }) else {
AIAgentShortcutRun.trace(
"host.runPending skip unknownSkill=\(payload.skillID)"
)
+109 -43
View File
@@ -8,42 +8,71 @@
import Foundation
import OSGKeyboardShared
enum AIHintRefreshOutcome: Equatable {
case skippedFresh
case completed(updatedLocales: [String])
}
@MainActor
enum AIHintRefreshService {
private static var inFlight: Task<Void, Never>?
final class AIHintRefreshService {
static let shared = AIHintRefreshService()
private let transport: any PublicContentHTTPTransport
private let defaults: UserDefaults?
private let baseURL: URL
private var inFlight: Task<AIHintRefreshOutcome, Never>?
init(
transport: any PublicContentHTTPTransport = URLSessionPublicContentHTTPTransport(),
defaults: UserDefaults? = AppGroup.defaultsIfAvailable,
baseURL: URL = AIHintFeedEndpoints.baseURL
) {
self.transport = transport
self.defaults = defaults
self.baseURL = baseURL
}
static func refreshIfNeeded(reason: String) {
guard AIHintStore.shouldRefresh() else {
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
return
}
shared.scheduleRefreshIfNeeded(reason: reason)
}
func scheduleRefreshIfNeeded(reason: String) {
guard inFlight == nil else {
OSGDiag.log("AIHintRefresh skip (inFlight) reason=\(reason)", category: "hints")
return
}
inFlight = Task {
defer { inFlight = nil }
await runRefresh(reason: reason)
return await refreshNowIfNeeded(reason: reason)
}
}
private static func runRefresh(reason: String) async {
AIHintStore.markAttempt()
func refreshNowIfNeeded(
reason: String,
now: Date = Date(),
force: Bool = false
) async -> AIHintRefreshOutcome {
if !force, !AIHintStore.shouldRefresh(now: now, defaults: defaults) {
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
return .skippedFresh
}
AIHintStore.markAttempt(defaults: defaults)
OSGDiag.log("AIHintRefresh start reason=\(reason)", category: "hints")
// The manifest only supplies fallback dates, so a manifest failure must
// not stop the packs, and one locale's failure must not stop the other.
let manifest = try? await fetchManifest()
let manifest = (try? await fetchManifest()) ?? AIHintStore.loadManifest(defaults: defaults)
var updatedLocales: [String] = []
for locale in AIHintFeedEndpoints.supportedLocales {
if Task.isCancelled { return }
guard AIHintStore.shouldRefresh(locale: locale) else { continue }
if Task.isCancelled { break }
if !force,
!AIHintStore.shouldRefresh(locale: locale, now: now, defaults: defaults) {
continue
}
do {
let pack = try await readyPack(locale: locale, manifest: manifest)
AIHintStore.saveReadyPack(pack)
OSGDiag.log(
"AIHintRefresh wrote locale=\(locale) cards=\(pack.cards.count)",
category: "hints"
)
try await refreshPack(locale: locale, manifest: manifest, now: now)
updatedLocales.append(locale)
} catch {
// Strategy B: keep this locale's previous successful ready pack.
OSGDiag.log(
@@ -53,31 +82,67 @@ enum AIHintRefreshService {
)
}
}
return .completed(updatedLocales: updatedLocales)
}
private static func readyPack(
private func refreshPack(
locale: String,
manifest: AIHintManifest?
) async throws -> AIHintPack {
let remote = try await fetchPack(locale: locale)
manifest: AIHintManifest?,
now: Date
) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent(locale))
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let etag = AIHintStore.packETag(locale: locale, defaults: defaults) {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await transport.data(for: request)
if response.statusCode == 304 {
guard var existing = AIHintStore.loadReadyPack(locale: locale, defaults: defaults) else {
throw PublicContentHTTPError.notModifiedWithoutCache
}
existing.refreshedAt = now
AIHintStore.saveReadyPack(existing, defaults: defaults)
if let etag = response.value(forHTTPHeaderField: "ETag") {
AIHintStore.setPackETag(etag, locale: locale, defaults: defaults)
}
OSGDiag.log("AIHintRefresh kept locale=\(locale) (304)", category: "hints")
return
}
guard response.statusCode == 200 else {
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
}
let remote = try JSONDecoder().decode(AIHintPack.self, from: data)
let filtered = remote.cards.filter { card in
let hay = card.displayText + card.prompt
return !hay.contains("历史上的今天")
&& !hay.localizedCaseInsensitiveContains("on this day")
}
// Prefer remote clipboard cards when present; always keep local evergreen.
let merged = merge(remote: filtered, locale: locale)
let merged = Self.merge(remote: filtered, locale: locale)
let compressed = await AIHintKeywordCompressor().compress(
cards: merged,
locale: locale
)
return AIHintPack(
let ready = AIHintPack(
locale: locale,
generatedAt: remote.generatedAt ?? manifest?.generatedAt,
expiresAt: remote.expiresAt ?? manifest?.expiresAt,
version: max(remote.version, 1),
cards: compressed,
refreshedAt: Date()
refreshedAt: now
)
AIHintStore.saveReadyPack(ready, defaults: defaults)
AIHintStore.setPackETag(
response.value(forHTTPHeaderField: "ETag"),
locale: locale,
defaults: defaults
)
OSGDiag.log(
"AIHintRefresh wrote locale=\(locale) cards=\(ready.cards.count)",
category: "hints"
)
}
@@ -103,25 +168,26 @@ enum AIHintRefreshService {
return Array(byID.values).sorted { $0.priority > $1.priority }
}
private static func fetchManifest() async throws -> AIHintManifest {
let (data, response) = try await URLSession.shared.data(from: AIHintFeedEndpoints.manifestURL)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintManifest.self, from: data)
}
private static func fetchPack(locale: String) async throws -> AIHintPack {
let url = AIHintFeedEndpoints.packURL(locale: locale)
let (data, response) = try await URLSession.shared.data(from: url)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintPack.self, from: data)
}
private static func validateHTTP(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
private func fetchManifest() async throws -> AIHintManifest? {
var request = URLRequest(url: baseURL.appendingPathComponent("manifest"))
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let etag = AIHintStore.manifestETag(defaults: defaults) {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
guard (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
let (data, response) = try await transport.data(for: request)
if response.statusCode == 304 {
return AIHintStore.loadManifest(defaults: defaults)
}
guard response.statusCode == 200 else {
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
}
let manifest = try JSONDecoder().decode(AIHintManifest.self, from: data)
AIHintStore.saveManifest(
manifest,
etag: response.value(forHTTPHeaderField: "ETag"),
defaults: defaults
)
return manifest
}
}
+231 -85
View File
@@ -12,40 +12,6 @@ import OSGKeyboardShared
import OSLog
import UIKit
actor AnalyticsUploadSignal: AnalyticsUploadTriggering {
typealias Action = @Sendable () async -> Void
private var action: Action?
private var isScheduled = false
private let debounce: Duration
init(debounce: Duration = .milliseconds(250)) {
self.debounce = debounce
}
func install(_ action: @escaping Action) {
self.action = action
}
func requestUpload() {
guard !isScheduled, let action else { return }
isScheduled = true
Task {
try? await Task.sleep(for: debounce)
guard !Task.isCancelled else {
uploadFinished()
return
}
await action()
uploadFinished()
}
}
private func uploadFinished() {
isScheduled = false
}
}
final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable {
static let shared = HostAnalyticsBearerBridge()
@@ -82,16 +48,33 @@ struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding {
func bearerToken() async throws -> String? {
guard try await apiClient.currentSession() != nil else { return nil }
return try await apiClient.accessTokenForAuthorizedRequest()
do {
return try await apiClient.accessTokenForAuthorizedRequest()
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String? {
guard let failedToken, !failedToken.isEmpty else { return nil }
return try await apiClient.refreshAccessToken(
afterUnauthorizedAccessToken: failedToken
)
do {
return try await apiClient.refreshAccessToken(
afterUnauthorizedAccessToken: failedToken
)
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
return nil
}
}
private static func isInvalidSession(_ error: AccountAPIError) -> Bool {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
return true
default:
return false
}
}
}
@@ -112,6 +95,12 @@ struct HostAnalyticsLogger: AnalyticsLogging {
@MainActor
final class AnalyticsHostService: ObservableObject {
private enum RequestedDatabaseAccess {
case uninitialized
case foreground
case suspended
}
static let shared = AnalyticsHostService()
static let backgroundTaskIdentifier = "com.osgkeyboard.ios.analytics-sync"
@@ -120,6 +109,7 @@ final class AnalyticsHostService: ObservableObject {
let client: any AnalyticsClient
private let runtime: AnalyticsRuntime
private let keyboardUsageRuntime: KeyboardUsageRuntime
private let uploadSignal: AnalyticsUploadSignal
private let pathMonitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(
@@ -127,74 +117,110 @@ final class AnalyticsHostService: ObservableObject {
qos: .utility
)
private var isMonitoringNetwork = false
private var foregroundUploadTask: Task<Void, Never>?
private var requestedDatabaseAccess = RequestedDatabaseAccess.uninitialized
private var databaseTransitionTask: Task<Void, Never>?
private var databaseTransitionGeneration = 0
private var firstOpenAcquisitionChannel = AnalyticsAcquisitionChannel.unknown
private var pendingAuthenticatedAccountID: UUID?
private var hasPendingAccountDeletion = false
private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid
init(
bearerProvider: any AnalyticsBearerProviding = HostAnalyticsBearerBridge.shared,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork()
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
uploadPolicy: AnalyticsUploadPolicy = .mobileDefault
) {
let signal = AnalyticsUploadSignal()
let signal = AnalyticsUploadSignal(policy: uploadPolicy)
uploadSignal = signal
let environment = Self.environment
let runtime = AnalyticsRuntime.mainApp(
environment: Self.environment,
environment: environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
network: network,
bearerProvider: bearerProvider,
trigger: signal,
logger: HostAnalyticsLogger()
)
let keyboardUsageRuntime = KeyboardUsageRuntime(
environment: environment,
analyticsRepository: runtime.repository,
uploadConfiguration: KeyboardUsageUploadConfiguration(
endpoint: Self.keyboardUsageEndpoint
),
network: network,
bearerProvider: bearerProvider,
trigger: signal,
logger: HostAnalyticsLogger()
)
self.runtime = runtime
self.keyboardUsageRuntime = keyboardUsageRuntime
client = runtime.client
Task {
await signal.install {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4)
guard !Task.isCancelled else { return }
await runtime.uploadCoordinator.uploadAvailableEvents(
maximumBatches: uploadPolicy.maximumBatches
)
guard !Task.isCancelled else { return }
await keyboardUsageRuntime.uploadCoordinator
.uploadAvailableSummaries(
maximumBatches: uploadPolicy.maximumBatches
)
}
}
}
func prepare(firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel) {
self.firstOpenAcquisitionChannel = firstOpenAcquisitionChannel
startNetworkMonitoringIfNeeded()
Task {
await runtime.prepare(
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
)
let enabled = await runtime.isEnabled()
await MainActor.run {
self.isEnabled = enabled
}
client.recordSessionActivity()
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8)
}
}
func appDidBecomeActive() {
client.recordSessionActivity()
requestImmediateUpload(maximumBatches: 8)
requestForegroundDatabaseAccess()
}
func appWillResignActive() {
requestSuspendedDatabaseAccess()
}
func appDidEnterBackground() {
Self.scheduleBackgroundRefresh()
let identifier = UIApplication.shared.beginBackgroundTask(
withName: "analytics-sync"
)
guard identifier != .invalid else { return }
let task = Task {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 2)
await MainActor.run {
UIApplication.shared.endBackgroundTask(identifier)
requestSuspendedDatabaseAccess()
guard backgroundTaskIdentifier == .invalid else { return }
var identifier: UIBackgroundTaskIdentifier = .invalid
identifier = UIApplication.shared.beginBackgroundTask(
withName: "analytics-quiescence"
) { [weak self] in
Task { @MainActor in
self?.finishBackgroundTask(identifier)
}
}
guard identifier != .invalid else { return }
backgroundTaskIdentifier = identifier
let transitionTask = databaseTransitionTask
Task { [weak self] in
await transitionTask?.value
await MainActor.run {
self?.finishBackgroundTask(identifier)
}
}
foregroundUploadTask = task
}
func observeAuthenticatedAccount(_ accountID: UUID) async {
_ = await runtime.observeAccount(stableIdentifier: accountID.uuidString)
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8)
pendingAuthenticatedAccountID = accountID
let transitionTask = databaseTransitionTask
await transitionTask?.value
await processDeferredAccountWork()
}
func handleAccountDeletion() async {
await runtime.handleAccountDeletion()
hasPendingAccountDeletion = true
let transitionTask = databaseTransitionTask
await transitionTask?.value
await processDeferredAccountWork()
}
func setEnabled(_ enabled: Bool) {
@@ -204,8 +230,10 @@ final class AnalyticsHostService: ObservableObject {
await MainActor.run {
self.isEnabled = current
}
if current {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4)
if !current {
await keyboardUsageRuntime.repository.clearAll()
} else {
await uploadSignal.requestActivationUpload()
}
}
}
@@ -219,15 +247,6 @@ final class AnalyticsHostService: ObservableObject {
}
}
func requestImmediateUpload(maximumBatches: Int = 4) {
foregroundUploadTask?.cancel()
foregroundUploadTask = Task {
await runtime.uploadCoordinator.uploadAvailableEvents(
maximumBatches: maximumBatches
)
}
}
static func registerBackgroundTask() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: backgroundTaskIdentifier,
@@ -238,10 +257,12 @@ final class AnalyticsHostService: ObservableObject {
return
}
let operation = Task {
await AnalyticsHostService.shared.runtime.uploadCoordinator
.uploadAvailableEvents(maximumBatches: 4)
refreshTask.setTaskCompleted(success: !Task.isCancelled)
scheduleBackgroundRefresh()
let success = await AnalyticsHostService.shared
.performBackgroundRefresh()
refreshTask.setTaskCompleted(success: success)
if success {
scheduleBackgroundRefresh()
}
}
refreshTask.expirationHandler = {
operation.cancel()
@@ -260,17 +281,142 @@ final class AnalyticsHostService: ObservableObject {
isMonitoringNetwork = true
pathMonitor.pathUpdateHandler = { path in
guard path.status == .satisfied else { return }
Task { @MainActor in
AnalyticsHostService.shared.requestImmediateUpload(maximumBatches: 8)
Task {
await AnalyticsHostService.shared.uploadSignal
.requestActivationUpload()
}
}
pathMonitor.start(queue: monitorQueue)
}
private func requestForegroundDatabaseAccess() {
guard requestedDatabaseAccess != .foreground else { return }
requestedDatabaseAccess = .foreground
databaseTransitionGeneration += 1
let generation = databaseTransitionGeneration
let previous = databaseTransitionTask
let runtime = runtime
let keyboardUsageRuntime = keyboardUsageRuntime
let signal = uploadSignal
let client = client
let acquisitionChannel = firstOpenAcquisitionChannel
let task = Task { [weak self] in
await previous?.value
await runtime.repository.resumeDatabaseAccess()
await keyboardUsageRuntime.repository.resumeDatabaseAccess()
await runtime.prepare(
firstOpenAcquisitionChannel: acquisitionChannel
)
let enabled = await runtime.isEnabled()
await self?.processDeferredAccountWork()
await signal.setActive(true)
client.recordSessionActivity()
await signal.requestActivationUpload()
await MainActor.run {
guard let self else { return }
self.isEnabled = enabled
self.finishDatabaseTransition(generation: generation)
}
}
databaseTransitionTask = task
}
private func requestSuspendedDatabaseAccess() {
guard requestedDatabaseAccess != .suspended else { return }
requestedDatabaseAccess = .suspended
databaseTransitionGeneration += 1
let generation = databaseTransitionGeneration
let previous = databaseTransitionTask
let runtime = runtime
let keyboardUsageRuntime = keyboardUsageRuntime
let signal = uploadSignal
let task = Task { [weak self] in
await previous?.value
await signal.pauseAndWait()
await runtime.repository.suspendDatabaseAccess()
await keyboardUsageRuntime.repository.suspendDatabaseAccess()
await MainActor.run {
self?.finishDatabaseTransition(generation: generation)
}
}
databaseTransitionTask = task
}
private func processDeferredAccountWork() async {
guard requestedDatabaseAccess == .foreground else { return }
if hasPendingAccountDeletion {
await runtime.handleAccountDeletion()
await keyboardUsageRuntime.repository.clearAll()
hasPendingAccountDeletion = false
}
guard let accountID = pendingAuthenticatedAccountID else { return }
let observation = await runtime.observeAccount(
stableIdentifier: accountID.uuidString
)
guard requestedDatabaseAccess == .foreground else { return }
if case .switchedAccount = observation {
await keyboardUsageRuntime.repository.clearAll()
}
if case .unavailable = observation {
return
}
pendingAuthenticatedAccountID = nil
await uploadSignal.requestActivationUpload()
}
private func performBackgroundRefresh() async -> Bool {
let transitionTask = databaseTransitionTask
await transitionTask?.value
guard !Task.isCancelled else { return false }
await runtime.repository.resumeDatabaseAccess()
await keyboardUsageRuntime.repository.resumeDatabaseAccess()
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1)
var success = !Task.isCancelled
if success {
await keyboardUsageRuntime.uploadCoordinator
.uploadAvailableSummaries(maximumBatches: 1)
success = !Task.isCancelled
}
if requestedDatabaseAccess != .foreground {
await runtime.repository.suspendDatabaseAccess()
await keyboardUsageRuntime.repository.suspendDatabaseAccess()
// A foreground transition may race the final close while this
// actor is re-entrant. Re-open if it won during the suspension.
if requestedDatabaseAccess == .foreground {
await runtime.repository.resumeDatabaseAccess()
await keyboardUsageRuntime.repository.resumeDatabaseAccess()
}
}
return success
}
private func finishDatabaseTransition(generation: Int) {
guard databaseTransitionGeneration == generation else { return }
databaseTransitionTask = nil
}
private func finishBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) {
guard identifier != .invalid,
backgroundTaskIdentifier == identifier else {
return
}
UIApplication.shared.endBackgroundTask(identifier)
backgroundTaskIdentifier = .invalid
}
private static let endpoint = URL(
string: "https://account.osglab.com/v1/analytics/events"
)!
private static let keyboardUsageEndpoint = URL(
string: "https://account.osglab.com/v1/analytics/keyboard-usage"
)!
private static var environment: AnalyticsEnvironment {
let appVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
@@ -0,0 +1,156 @@
// AnalyticsUploadScheduling.swift
// OSGKeyboard · Main App
//
// Count- and time-based foreground upload scheduling. Recording remains
// fire-and-forget; networking is single-flight and stops when the app resigns.
import Foundation
import OSGKeyboardShared
struct AnalyticsUploadPolicy: Sendable {
static let mobileDefault = Self(
eventThreshold: 20,
flushInterval: .seconds(60),
activationDelay: .seconds(10),
maximumBatches: 2
)
let eventThreshold: Int
let flushInterval: Duration
let activationDelay: Duration
let maximumBatches: Int
init(
eventThreshold: Int,
flushInterval: Duration,
activationDelay: Duration,
maximumBatches: Int
) {
self.eventThreshold = max(1, eventThreshold)
self.flushInterval = flushInterval
self.activationDelay = activationDelay
self.maximumBatches = max(1, maximumBatches)
}
}
actor AnalyticsUploadSignal: AnalyticsUploadTriggering {
typealias Action = @Sendable () async -> Void
private let policy: AnalyticsUploadPolicy
private var action: Action?
private var isActive = false
private var pendingSignals = 0
private var timerTask: Task<Void, Never>?
private var uploadTask: Task<Void, Never>?
init(policy: AnalyticsUploadPolicy = .mobileDefault) {
self.policy = policy
}
func install(_ action: @escaping Action) {
self.action = action
if isActive, pendingSignals > 0, uploadTask == nil {
schedule(after: policy.activationDelay, replacingTimer: true)
}
}
func requestUpload() {
pendingSignals = min(Int.max, pendingSignals + 1)
guard isActive, uploadTask == nil else { return }
if pendingSignals >= policy.eventThreshold {
schedule(after: .zero, replacingTimer: true)
} else {
schedule(after: policy.flushInterval)
}
}
/// Starts one bounded backlog drain after the foreground has settled.
func requestActivationUpload() {
pendingSignals = max(1, pendingSignals)
guard isActive, uploadTask == nil else { return }
schedule(after: policy.activationDelay, replacingTimer: true)
}
func setActive(_ active: Bool) async {
guard active != isActive else {
if active, pendingSignals > 0, uploadTask == nil {
schedule(after: policy.activationDelay)
}
return
}
isActive = active
if active {
if pendingSignals > 0 {
schedule(after: policy.activationDelay, replacingTimer: true)
}
} else {
await cancelAndWait()
}
}
/// Cancels timers and networking, then waits until the upload action has
/// unwound. Callers can keep a short UIKit background assertion while this
/// returns so no SQLite transaction survives process suspension.
func pauseAndWait() async {
isActive = false
await cancelAndWait()
}
private func schedule(
after delay: Duration,
replacingTimer: Bool = false
) {
guard isActive, action != nil, uploadTask == nil else { return }
if replacingTimer {
timerTask?.cancel()
timerTask = nil
}
guard timerTask == nil else { return }
timerTask = Task { [weak self] in
do {
try await Task.sleep(for: delay)
} catch {
return
}
await self?.timerFired()
}
}
private func timerFired() {
timerTask = nil
guard isActive,
uploadTask == nil,
pendingSignals > 0,
let action else {
return
}
pendingSignals = 0
uploadTask = Task { [weak self] in
await action()
await self?.uploadFinished()
}
}
private func uploadFinished() {
uploadTask = nil
guard isActive, pendingSignals > 0 else { return }
let delay: Duration = pendingSignals >= policy.eventThreshold
? .zero
: policy.flushInterval
schedule(after: delay, replacingTimer: true)
}
private func cancelAndWait() async {
timerTask?.cancel()
timerTask = nil
let task = uploadTask
task?.cancel()
await task?.value
uploadTask = nil
}
}
@@ -0,0 +1,83 @@
// FlowAnalyticsOperationRegistry.swift
// OSGKeyboard · Main App
//
// Binds analytics operations to utterance identity so late async completions
// can never finish a newer user's task.
import OSGKeyboardShared
enum FlowAnalyticsFeatureMapping {
static func feature(for taskKind: ManagedGatewayTaskKind) -> AnalyticsFeature {
switch taskKind {
case .aiQuestion, .currentInformationQuestion:
return .aiAssistant
case .clipboardTransform, .customSkill, .agentPlanning:
return .agent
case .dictationPolish, .translation, .editLastInput:
return .polish
}
}
static func recordingFeature(for mode: FlowUtteranceMode) -> AnalyticsFeature {
mode == .aiQuestion ? .aiAssistant : .transcription
}
}
@MainActor
final class FlowAnalyticsOperationRegistry {
private var operations: [UUID: any AnalyticsAIOperation] = [:]
@discardableResult
func start(
utteranceID: UUID,
feature: AnalyticsFeature,
executionMode: AnalyticsExecutionMode,
client: any AnalyticsClient
) -> any AnalyticsAIOperation {
operations.removeValue(forKey: utteranceID)?.cancel()
let operation = client.startAIFeature(
feature,
executionMode: executionMode
)
operations[utteranceID] = operation
return operation
}
func operation(for utteranceID: UUID?) -> (any AnalyticsAIOperation)? {
guard let utteranceID else { return nil }
return operations[utteranceID]
}
func succeed(utteranceID: UUID?) {
take(utteranceID)?.succeed()
}
func fail(
utteranceID: UUID?,
category: AnalyticsFailureCategory
) {
take(utteranceID)?.fail(category: category)
}
func cancel(utteranceID: UUID?) {
take(utteranceID)?.cancel()
}
func cancelAll() {
let active = Array(operations.values)
operations.removeAll()
active.forEach { $0.cancel() }
}
func discard(utteranceID: UUID?) {
guard let utteranceID else { return }
operations.removeValue(forKey: utteranceID)
}
private func take(
_ utteranceID: UUID?
) -> (any AnalyticsAIOperation)? {
guard let utteranceID else { return nil }
return operations.removeValue(forKey: utteranceID)
}
}
+137 -29
View File
@@ -97,7 +97,11 @@ final class FlowSessionManager: ObservableObject {
private struct PrefilledAIRequest {
let question: String
let conversationID: UUID?
let webPageURL: URL?
let taskKind: ManagedGatewayTaskKind
let requestSource: ManagedGatewayRequestSource?
let requestPurpose: ManagedGatewayRequestPurpose?
let oobeFeature: ManagedGatewayOOBEFeature?
let thinkingEnabled: Bool
}
@@ -116,6 +120,7 @@ final class FlowSessionManager: ObservableObject {
private let polisher: PolishingService
/// AI-mode turns are intentionally process-local and never persisted.
private let aiConversations = AIConversationStore()
private let webPageContentService: WebPageContentService
/// Cached ASR instance. The only on-device backend is iOS
/// `SpeechAnalyzer`, which has no warm-up step we can hand the
/// factory-built service straight back without going through the
@@ -162,6 +167,7 @@ final class FlowSessionManager: ObservableObject {
private var pendingSourceHistoryEntryRevision: Int64?
private var pendingAIConversationID: UUID?
private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose?
private var pendingManagedOOBEFeature: ManagedGatewayOOBEFeature?
private var pendingProcessingDeadlineAt: TimeInterval?
private var pendingStopUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
@@ -174,7 +180,7 @@ final class FlowSessionManager: ObservableObject {
@Published private(set) var isUtteranceProcessing = false
private var finalizeTask: Task<Void, Never>?
private var asrTask: Task<Void, Never>?
private var transcriptionAnalyticsOperation: (any AnalyticsAIOperation)?
private let analyticsOperations = FlowAnalyticsOperationRegistry()
private var utteranceSafetyTask: Task<Void, Never>?
private var chunkedPipeline: ChunkedUtterancePipeline?
private var currentPartial = ""
@@ -218,11 +224,13 @@ final class FlowSessionManager: ObservableObject {
init(
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController()
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController(),
webPageContentService: WebPageContentService = .shared
) {
self.analyticsClient = analyticsClient
self.polisher = PolishingService(analyticsClient: analyticsClient)
self.pipController = pipController
self.webPageContentService = webPageContentService
// Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here after a force-quit that would resurrect capture
@@ -442,6 +450,7 @@ final class FlowSessionManager: ObservableObject {
status: .aborted
)
}
analyticsOperations.cancelAll()
wantsActiveSession = false
pipRecoveryOperation &+= 1
@@ -527,6 +536,7 @@ final class FlowSessionManager: ObservableObject {
status: .aborted
)
}
analyticsOperations.cancelAll()
wantsActiveSession = false
pipRecoveryOperation &+= 1
@@ -1502,6 +1512,7 @@ final class FlowSessionManager: ObservableObject {
)
currentUtteranceMode = command.resolvedUtteranceMode
pendingManagedRequestPurpose = command.managedRequestPurpose
pendingManagedOOBEFeature = command.managedOOBEFeature
pendingAIConversationID = currentUtteranceMode == .aiQuestion
? command.aiConversationID
: nil
@@ -1615,6 +1626,7 @@ final class FlowSessionManager: ObservableObject {
) else { return }
currentUtteranceMode = .aiQuestion
pendingManagedRequestPurpose = command.managedRequestPurpose
pendingManagedOOBEFeature = command.managedOOBEFeature
pendingAIConversationID = command.aiConversationID
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
@@ -1636,7 +1648,11 @@ final class FlowSessionManager: ObservableObject {
request: PrefilledAIRequest(
question: question,
conversationID: conversationID,
webPageURL: command.aiWebPageURL,
taskKind: taskKind,
requestSource: command.managedRequestSource,
requestPurpose: command.managedRequestPurpose,
oobeFeature: command.managedOOBEFeature,
thinkingEnabled: thinkingEnabled
),
sessionId: sessionId,
@@ -1689,17 +1705,30 @@ final class FlowSessionManager: ObservableObject {
// send an XML envelope that must never appear above the mic.
let pipelineStore = AppGroupStore()
do {
let resolvedQuestion = try await resolvedQuestion(for: request)
let taskKind = if pipelineStore.credentialSource == .managed,
request.requestPurpose == nil {
ManagedGatewayQuestionRouter.taskKind(
for: resolvedQuestion,
requestedTaskKind: request.taskKind
)
} else {
request.taskKind
}
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations,
taskKind: request.taskKind,
taskKind: taskKind,
requestSource: request.requestSource,
requestPurpose: request.requestPurpose,
oobeFeature: request.oobeFeature,
thinkingEnabled: request.thinkingEnabled,
analyticsClient: analyticsClient,
analyticsFeature: Self.analyticsFeature(for: request.taskKind)
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
question: request.question,
question: resolvedQuestion,
conversationID: conversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
) { [weak self] partial in
@@ -1725,7 +1754,7 @@ final class FlowSessionManager: ObservableObject {
force: true
)
await service.commitSuccessfulTurn(
question: request.question,
question: resolvedQuestion,
answer: answer,
conversationID: conversationID
)
@@ -1753,6 +1782,17 @@ final class FlowSessionManager: ObservableObject {
}
}
private func resolvedQuestion(
for request: PrefilledAIRequest
) async throws -> String {
guard let url = request.webPageURL else { return request.question }
let webpage = try await webPageContentService.fetchSummarySource(from: url)
return AIClipboardPrompt.compose(
instruction: request.question,
material: webpage.summaryMaterial
)
}
private func beginAudioPrime(_ command: FlowCommand) {
guard startingUtteranceId == nil,
!isUtteranceRecording,
@@ -1870,7 +1910,8 @@ final class FlowSessionManager: ObservableObject {
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
status: FlowResult.Status = .error
) {
finishTranscriptionAnalytics(
finishUtteranceAnalytics(
utteranceID: currentUtteranceId,
success: false,
failureCategory: Self.transcriptionFailureCategory(
kind: kind,
@@ -2168,11 +2209,19 @@ final class FlowSessionManager: ObservableObject {
)
let cloudASRForStreaming = useStreaming ? cloudService : nil
transcriptionAnalyticsOperation?.cancel()
transcriptionAnalyticsOperation = analyticsClient.startAIFeature(
.transcription,
executionMode: analyticsExecutionMode
)
if let taskUtteranceId {
// A healed delivery gate may allow a replacement utterance before
// an old finalize task unwinds. Close any old value task first.
analyticsOperations.cancelAll()
analyticsOperations.start(
utteranceID: taskUtteranceId,
feature: FlowAnalyticsFeatureMapping.recordingFeature(
for: currentUtteranceMode
),
executionMode: analyticsExecutionMode,
client: analyticsClient
)
}
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
let outcome: ChunkedUtterancePipelineOutcome
@@ -2262,7 +2311,21 @@ final class FlowSessionManager: ObservableObject {
manager.failUtterance(message: message, kind: .asrFailed)
}
case .cancelled:
break
// A provider-side cancellation while the mic is still open
// has no later successful finalize path. Close the
// utterance now so its analytics STARTED cannot be orphaned.
let hasRecoverableText = !manager.currentPartial
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !manager.bestPartialSnapshot
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if manager.isUtteranceRecording, !hasRecoverableText {
manager.failUtterance(
message: AppL10n.string(
"flow.error.recognitionInterrupted"
),
kind: .recognitionInterrupted
)
}
}
}
}
@@ -2393,6 +2456,7 @@ final class FlowSessionManager: ObservableObject {
status: .aborted
)
}
analyticsOperations.cancel(utteranceID: utteranceId)
isUtteranceRecording = false
isUtteranceProcessing = false
startingUtteranceId = nil
@@ -2493,6 +2557,7 @@ final class FlowSessionManager: ObservableObject {
pendingSourceHistoryEntryRevision = nil
pendingAIConversationID = nil
pendingManagedRequestPurpose = nil
pendingManagedOOBEFeature = nil
pendingProcessingDeadlineAt = nil
currentUtteranceMode = .dictation
}
@@ -2511,6 +2576,7 @@ final class FlowSessionManager: ObservableObject {
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
let aiConversationID = pendingAIConversationID
let managedRequestPurpose = pendingManagedRequestPurpose
let managedOOBEFeature = pendingManagedOOBEFeature
let processingDeadlineAt = pendingProcessingDeadlineAt
let asrEngineMode = sessionASREngineMode ?? store.engineMode
// ALWAYS clear the processing gate for this utterance. The previous
@@ -2519,12 +2585,17 @@ final class FlowSessionManager: ObservableObject {
// and then skip refreshHostReady keyboard stayed white forever
// while host logs still said "utterance finalized".
defer {
// Any path that returns without an explicit terminal event is a
// cancellation from the user's perspective. The registry is keyed
// by utterance ID, so this cannot finish a newer recording.
analyticsOperations.cancel(utteranceID: finalizeUtteranceId)
pendingFieldContext = nil
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
pendingAIConversationID = nil
pendingManagedRequestPurpose = nil
pendingManagedOOBEFeature = nil
pendingProcessingDeadlineAt = nil
if currentUtteranceMode == utteranceMode {
currentUtteranceMode = .dictation
@@ -2641,7 +2712,12 @@ final class FlowSessionManager: ObservableObject {
)
return
}
finishTranscriptionAnalytics(success: true)
if utteranceMode != .aiQuestion {
finishUtteranceAnalytics(
utteranceID: finalizeUtteranceId,
success: true
)
}
// Re-read App Group at finalize so dictionary and translation changes
// from the keyboard extension are visible before local correction,
@@ -2700,10 +2776,20 @@ final class FlowSessionManager: ObservableObject {
}
do {
let taskKind = if pipelineStore.credentialSource == .managed,
managedRequestPurpose == nil {
// Classify the post-ASR spoken text before clipboard
// resolution can wrap it in an internal prompt envelope.
ManagedGatewayQuestionRouter.taskKind(for: text)
} else {
ManagedGatewayTaskKind.aiQuestion
}
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations,
taskKind: .aiQuestion,
taskKind: taskKind,
requestPurpose: managedRequestPurpose,
oobeFeature: managedOOBEFeature,
analyticsClient: analyticsClient,
analyticsFeature: .aiAssistant
)
@@ -2715,7 +2801,10 @@ final class FlowSessionManager: ObservableObject {
let answer = try await service.answer(
question: question,
conversationID: aiConversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
targetLocaleID: pipelineStore.translationTargetLocaleId,
analyticsOperation: analyticsOperations.operation(
for: finalizeUtteranceId
)
) { [weak self] partial in
Task { @MainActor in
self?.publishStreamingAIAnswerIfNeeded(
@@ -2843,6 +2932,7 @@ final class FlowSessionManager: ObservableObject {
providerIdOverride: pipelineStore.polishProviderIdOverride,
taskKind: isEditLastInput ? .editLastInput : nil,
requestPurpose: managedRequestPurpose,
oobeFeature: managedOOBEFeature,
context: isInstructionMode ? nil : polishContext,
timeoutLimit: processingDeadlineAt.map {
max(0.1, $0 - Date().timeIntervalSince1970)
@@ -2872,6 +2962,10 @@ final class FlowSessionManager: ObservableObject {
? nil
: SpeechHistoryStore.shared.recordUtterance(
text: delivered,
prePolishText: text,
polishStyleID: outcome.polishStyleID
?? pipelineStore.activePolishStyleId,
polishStylePrompt: outcome.polishStylePrompt,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: isInstructionMode
@@ -2951,6 +3045,11 @@ final class FlowSessionManager: ObservableObject {
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
let historyEntry = SpeechHistoryStore.shared.recordUtterance(
text: delivered,
prePolishText: text,
polishStyleID: pipelineStore.activePolishStyleId,
// The provider failed, so this fallback was not produced by
// the style Prompt and must not become Prompt-learning evidence.
polishStylePrompt: nil,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: pipelineStore.isTranslationEffective
@@ -3147,7 +3246,8 @@ final class FlowSessionManager: ObservableObject {
status: FlowResult.Status = .error,
aiConversationID: UUID? = nil
) {
finishTranscriptionAnalytics(
finishUtteranceAnalytics(
utteranceID: utteranceId,
success: false,
failureCategory: Self.transcriptionFailureCategory(
kind: kind,
@@ -3181,14 +3281,7 @@ final class FlowSessionManager: ObservableObject {
private static func analyticsFeature(
for taskKind: ManagedGatewayTaskKind
) -> AnalyticsFeature {
switch taskKind {
case .aiQuestion:
return .hotword
case .clipboardTransform, .customSkill, .agentPlanning:
return .agent
case .dictationPolish, .translation, .editLastInput:
return .other
}
FlowAnalyticsFeatureMapping.feature(for: taskKind)
}
private var analyticsExecutionMode: AnalyticsExecutionMode {
@@ -3198,16 +3291,18 @@ final class FlowSessionManager: ObservableObject {
return store.credentialSource == .managed ? .managed : .byok
}
private func finishTranscriptionAnalytics(
private func finishUtteranceAnalytics(
utteranceID: UUID?,
success: Bool,
failureCategory: AnalyticsFailureCategory = .unknown
) {
guard let operation = transcriptionAnalyticsOperation else { return }
transcriptionAnalyticsOperation = nil
if success {
operation.succeed()
analyticsOperations.succeed(utteranceID: utteranceID)
} else {
operation.fail(category: failureCategory)
analyticsOperations.fail(
utteranceID: utteranceID,
category: failureCategory
)
}
}
@@ -3370,6 +3465,7 @@ final class FlowSessionManager: ObservableObject {
providerIdOverride: String?,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
context: PolishContext?,
timeoutLimit: TimeInterval? = nil
) async throws -> PolishingService.PolishOutcome {
@@ -3383,6 +3479,7 @@ final class FlowSessionManager: ObservableObject {
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
context: context
)
}
@@ -3448,6 +3545,17 @@ final class FlowSessionManager: ObservableObject {
}
private static func aiQuestionFailureMessage(for error: Error) -> String {
if let webPageError = error as? WebPageContentError {
switch webPageError {
case .invalidURL:
return AppL10n.string("flow.error.webPageInvalid")
case .unsupportedContentType, .undecodableText, .emptyContent:
return AppL10n.string("flow.error.webPageEmpty")
case .tooManyRedirects, .invalidResponse, .httpStatus,
.responseTooLarge, .transport:
return AppL10n.string("flow.error.webPageUnavailable")
}
}
if let managedError = error as? ManagedGatewayError {
return managedError.localizedDescription
}
@@ -0,0 +1,131 @@
// OOBEClientInfrastructure.swift
// OSGKeyboard · Main App
//
// Host-facing entry point for anonymous four-feature onboarding practice.
import Foundation
import OSGKeyboardHostSupport
import OSGKeyboardShared
enum OOBEClientInfrastructureError: Error, Equatable {
case unavailable
case invalidSession
}
@MainActor
final class OOBEClientInfrastructure {
static let shared = OOBEClientInfrastructure()
private let provisioning: OOBEGrantProvisioningCoordinator?
init(bundle: Bundle = .main) {
guard let accessGroup = bundle.object(
forInfoDictionaryKey: "OSGPrivateKeychainAccessGroup"
) as? String,
let descriptor = try? HostPrivateAccountKeychainDescriptor(
accessGroup: accessGroup
) else {
provisioning = nil
return
}
let keychain = HostPrivateAccountKeychain(descriptor: descriptor)
let apiClient = AccountAPIClient(sessionVault: keychain)
let integrity = DeviceIntegrityCoordinator(
apiClient: apiClient,
keyStateStore: keychain
)
provisioning = OOBEGrantProvisioningCoordinator(
apiClient: apiClient,
integrity: integrity,
installationIDs: keychain
)
}
@discardableResult
func beginPractice(
feature: ManagedGatewayOOBEFeature,
duration: TimeInterval = 30 * 60,
sampleMaterial: String? = nil
) async throws -> OOBEPracticeSession {
guard let provisioning,
let session = KeyboardSetupBridge.beginOOBEPracticeSession(
expectedFeature: feature,
duration: duration
) else {
throw OOBEClientInfrastructureError.unavailable
}
do {
try seedMaterialIfNeeded(
sampleMaterial,
feature: feature,
sessionID: session.sessionID,
duration: duration
)
try await provisioning.provision()
return session
} catch {
KeyboardSetupBridge.endOOBEPracticeSession()
await provisioning.clear()
throw error
}
}
@discardableResult
func updateExpectedFeature(
_ feature: ManagedGatewayOOBEFeature,
sessionID: UUID,
duration: TimeInterval? = nil,
sampleMaterial: String? = nil
) throws -> OOBEPracticeSession {
guard let session = KeyboardSetupBridge.updateOOBEExpectedFeature(
feature,
sessionID: sessionID,
duration: duration
) else {
throw OOBEClientInfrastructureError.invalidSession
}
try seedMaterialIfNeeded(
sampleMaterial,
feature: feature,
sessionID: sessionID,
duration: duration ?? session.expiresAt.timeIntervalSinceNow
)
return session
}
func completion(
sessionID: UUID,
feature: ManagedGatewayOOBEFeature
) -> OOBEPracticeCompletion? {
KeyboardSetupBridge.oobePracticeCompletion(
sessionID: sessionID,
feature: feature
)
}
func endPractice() async {
KeyboardSetupBridge.endOOBEPracticeSession()
await provisioning?.clear()
}
private func seedMaterialIfNeeded(
_ sampleMaterial: String?,
feature: ManagedGatewayOOBEFeature,
sessionID: UUID,
duration: TimeInterval
) throws {
guard feature == .clipboardReply || feature == .clipboardTranslate else {
return
}
// The host may establish the feature before the user explicitly taps
// Copy sample. Material is seeded only in response to that tap.
guard let sampleMaterial else { return }
guard KeyboardSetupBridge.seedOOBEClipboardMaterial(
sampleMaterial,
sessionID: sessionID,
duration: min(max(duration, 1), 10 * 60)
) != nil else {
throw OOBEClientInfrastructureError.invalidSession
}
}
}
@@ -0,0 +1,140 @@
// OfficialSkillCatalogRefreshService.swift
// OSGKeyboard · Main App
//
// Cache-first anonymous refresh for the official Skill Catalog. Invalid
// responses never replace the App Group last-known-good snapshot.
import Foundation
import OSGKeyboardShared
enum OfficialSkillCatalogRefreshOutcome: Equatable, Sendable {
case skippedFresh
case updated(revision: Int64)
case notModified(revision: Int64)
case failed
var didUpdateCache: Bool {
switch self {
case .updated, .notModified:
return true
case .skippedFresh, .failed:
return false
}
}
}
actor OfficialSkillCatalogRefreshService {
static let shared = OfficialSkillCatalogRefreshService()
/// App-wide foreground checks stay inexpensive while limiting stale
/// catalog copy to a short window. The Skills manager forces ETag validation.
static let refreshInterval: TimeInterval = 15 * 60
static let endpoint = URL(string: "https://account.osglab.com/v1/content/skills")!
private let store: AppGroupStore
private let transport: any PublicContentHTTPTransport
private let endpointURL: URL
private let freshnessInterval: TimeInterval
private var inFlight: Task<OfficialSkillCatalogRefreshOutcome, Never>?
init(
store: AppGroupStore = AppGroupStore(),
transport: any PublicContentHTTPTransport = URLSessionPublicContentHTTPTransport(),
endpointURL: URL = OfficialSkillCatalogRefreshService.endpoint,
freshnessInterval: TimeInterval = OfficialSkillCatalogRefreshService.refreshInterval
) {
self.store = store
self.transport = transport
self.endpointURL = endpointURL
self.freshnessInterval = freshnessInterval
}
func refreshIfNeeded(
reason: String,
now: Date = Date(),
force: Bool = false
) async -> OfficialSkillCatalogRefreshOutcome {
let cached = store.officialSkillCatalog
if !force,
let refreshedAt = cached.refreshedAt,
now.timeIntervalSince(refreshedAt) < freshnessInterval {
OSGDiag.log(
"OfficialSkillCatalog skip fresh reason=\(reason) revision=\(cached.revision)",
category: "skills"
)
return .skippedFresh
}
if let inFlight {
return await inFlight.value
}
let task = Task { [store, transport, endpointURL] in
do {
return try await Self.performRefresh(
store: store,
transport: transport,
endpointURL: endpointURL,
cached: cached,
now: now
)
} catch {
OSGDiag.log(
"OfficialSkillCatalog failed reason=\(reason) "
+ "error=\(error.localizedDescription)",
category: "skills"
)
return .failed
}
}
inFlight = task
let outcome = await task.value
inFlight = nil
return outcome
}
private static func performRefresh(
store: AppGroupStore,
transport: any PublicContentHTTPTransport,
endpointURL: URL,
cached: OfficialSkillCatalog,
now: Date
) async throws -> OfficialSkillCatalogRefreshOutcome {
var request = URLRequest(url: endpointURL)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let etag = cached.etag, !etag.isEmpty {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
let (data, response) = try await transport.data(for: request)
switch response.statusCode {
case 304:
guard cached.refreshedAt != nil || !cached.skills.isEmpty else {
throw PublicContentHTTPError.notModifiedWithoutCache
}
var refreshed = cached
refreshed.refreshedAt = now
refreshed.etag = response.value(forHTTPHeaderField: "ETag") ?? cached.etag
try store.setOfficialSkillCatalog(refreshed)
return .notModified(revision: refreshed.revision)
case 200:
var received = try JSONDecoder().decode(OfficialSkillCatalog.self, from: data)
received.refreshedAt = now
received.etag = response.value(forHTTPHeaderField: "ETag")
received = try received.validated()
if cached.refreshedAt != nil, received.revision < cached.revision {
throw PublicContentHTTPError.staleRevision(
current: cached.revision,
received: received.revision
)
}
try store.setOfficialSkillCatalog(
received,
installingNewDefaultSkills: true
)
return .updated(revision: received.revision)
default:
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
}
}
}
@@ -0,0 +1,36 @@
// PublicContentHTTPTransport.swift
// OSGKeyboard · Main App
//
// Anonymous public-content transport. It is intentionally independent from
// account/auth clients and is never linked into keyboard-extension behavior.
import Foundation
protocol PublicContentHTTPTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
final class URLSessionPublicContentHTTPTransport:
PublicContentHTTPTransport,
@unchecked Sendable {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw PublicContentHTTPError.invalidResponse
}
return (data, response)
}
}
enum PublicContentHTTPError: Error, Equatable {
case invalidResponse
case unexpectedStatus(Int)
case notModifiedWithoutCache
case staleRevision(current: Int64, received: Int64)
}
@@ -11,11 +11,21 @@ extension SpeechHistoryStore {
@discardableResult
func recordUtterance(
text: String,
prePolishText: String,
polishStyleID: String,
polishStylePrompt: String?,
engineMode: String,
duration: TimeInterval,
wasTranslation: Bool
) -> SpeechHistoryEntry? {
let entry = append(text: text, engineMode: engineMode)
let entry = append(
text: text,
prePolishText: prePolishText,
wasTranslation: wasTranslation,
polishStyleID: polishStyleID,
polishStylePrompt: polishStylePrompt,
engineMode: engineMode
)
UsageStatisticsStore.shared.recordUtterance(
text: text,
duration: duration,
@@ -0,0 +1,475 @@
// WebPageContentService.swift
// OSGKeyboard · Main App
//
// Fetches a bounded public HTTPS page in the host process, then turns static
// HTML into untrusted source text for the existing clipboard AI pipeline.
import Foundation
struct WebPageContent: Equatable, Sendable {
let finalURL: URL
let title: String?
let body: String
var summaryMaterial: String {
var sections = ["Source URL: \(finalURL.absoluteString)"]
if let title, !title.isEmpty {
sections.append("Title: \(title)")
}
sections.append("Webpage body:\n\(body)")
return sections.joined(separator: "\n")
}
}
enum WebPageContentError: Error, Equatable, Sendable {
case invalidURL
case tooManyRedirects
case invalidResponse
case httpStatus(Int)
case responseTooLarge
case unsupportedContentType
case undecodableText
case emptyContent
case transport
}
enum WebPageURLPolicy {
static func validatedSummaryURL(_ url: URL) -> URL? {
guard var components = URLComponents(
url: url,
resolvingAgainstBaseURL: false
) else {
return nil
}
guard components.scheme?.lowercased() == "https",
let host = components.host?.lowercased(),
!host.isEmpty,
components.user == nil,
components.password == nil,
!isPrivateHost(host) else {
return nil
}
components.scheme = "https"
return components.url
}
private static func isPrivateHost(_ rawHost: String) -> Bool {
let host = rawHost.trimmingCharacters(in: CharacterSet(charactersIn: "."))
if host == "localhost"
|| host.hasSuffix(".localhost")
|| host.hasSuffix(".local")
|| host.hasSuffix(".internal")
|| host.hasSuffix(".lan") {
return true
}
if host.contains(":") {
// IPv6 literals are uncommon webpage targets and include several
// private/link-local encodings that are easy to disguise.
return true
}
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
let numericHost = host.allSatisfy { $0.isNumber || $0 == "." }
guard numericHost else { return false }
guard parts.count == 4,
parts.allSatisfy({ part in
guard let value = Int(part) else { return false }
return (0...255).contains(value)
}) else {
return true
}
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return true }
let first = octets[0]
let second = octets[1]
return first == 0
|| first == 10
|| first == 127
|| (first == 100 && (64...127).contains(second))
|| (first == 169 && second == 254)
|| (first == 172 && (16...31).contains(second))
|| (first == 192 && second == 168)
|| first >= 224
}
}
final class WebPageContentService: @unchecked Sendable {
static let shared = WebPageContentService()
private static let maximumResponseBytes = 2 * 1_024 * 1_024
private static let maximumBodyCharacters = 40_000
private let configurationTemplate: URLSessionConfiguration
init(configuration: URLSessionConfiguration = .ephemeral) {
let copy = configuration.copy() as? URLSessionConfiguration ?? .ephemeral
copy.timeoutIntervalForRequest = 12
copy.timeoutIntervalForResource = 18
copy.requestCachePolicy = .reloadIgnoringLocalCacheData
copy.urlCache = nil
copy.httpCookieStorage = nil
copy.httpShouldSetCookies = false
copy.httpAdditionalHeaders = [
"Accept": "text/html, application/xhtml+xml, text/plain;q=0.8",
"User-Agent": "OSGKeyboard/2 WebpageSummary"
]
configurationTemplate = copy
}
func fetchSummarySource(from sourceURL: URL) async throws -> WebPageContent {
guard let url = WebPageURLPolicy.validatedSummaryURL(sourceURL) else {
throw WebPageContentError.invalidURL
}
let configuration = configurationTemplate.copy() as? URLSessionConfiguration
?? .ephemeral
let response = try await BoundedWebPageLoader(
configuration: configuration,
maximumBytes: Self.maximumResponseBytes
).load(url)
guard let finalURL = WebPageURLPolicy.validatedSummaryURL(response.finalURL) else {
throw WebPageContentError.invalidURL
}
let html = String(data: response.data, encoding: .utf8)
?? String(data: response.data, encoding: .isoLatin1)
guard let html else {
throw WebPageContentError.undecodableText
}
return try WebPageHTMLExtractor.extract(
html: html,
finalURL: finalURL,
maximumBodyCharacters: Self.maximumBodyCharacters
)
}
}
private struct WebPageHTTPResponse: Sendable {
let data: Data
let finalURL: URL
}
private final class BoundedWebPageLoader:
NSObject,
URLSessionDataDelegate,
URLSessionTaskDelegate,
@unchecked Sendable {
private let maximumBytes: Int
private let queue: OperationQueue
private let lock = NSLock()
private var session: URLSession!
private var continuation: CheckedContinuation<WebPageHTTPResponse, Error>?
private var receivedData = Data()
private var response: HTTPURLResponse?
private var redirectCount = 0
private var terminalError: WebPageContentError?
private var finished = false
init(configuration: URLSessionConfiguration, maximumBytes: Int) {
self.maximumBytes = maximumBytes
queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
queue.qualityOfService = .userInitiated
super.init()
session = URLSession(
configuration: configuration,
delegate: self,
delegateQueue: queue
)
}
func load(_ url: URL) async throws -> WebPageHTTPResponse {
try Task.checkCancellation()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
lock.lock()
guard !finished else {
lock.unlock()
continuation.resume(throwing: CancellationError())
return
}
self.continuation = continuation
lock.unlock()
var request = URLRequest(url: url)
request.httpMethod = "GET"
session.dataTask(with: request).resume()
}
} onCancel: {
self.finish(.failure(CancellationError()))
}
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let http = response as? HTTPURLResponse else {
completionHandler(.cancel)
finish(.failure(WebPageContentError.invalidResponse))
return
}
guard (200..<300).contains(http.statusCode) else {
completionHandler(.cancel)
finish(.failure(WebPageContentError.httpStatus(http.statusCode)))
return
}
if let mimeType = http.mimeType?.lowercased(),
!Self.allowedMIMETypes.contains(mimeType) {
completionHandler(.cancel)
finish(.failure(WebPageContentError.unsupportedContentType))
return
}
if http.expectedContentLength > Int64(maximumBytes) {
completionHandler(.cancel)
finish(.failure(WebPageContentError.responseTooLarge))
return
}
lock.lock()
self.response = http
lock.unlock()
completionHandler(.allow)
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data
) {
lock.lock()
guard !finished else {
lock.unlock()
return
}
guard receivedData.count + data.count <= maximumBytes else {
terminalError = .responseTooLarge
lock.unlock()
dataTask.cancel()
return
}
receivedData.append(data)
lock.unlock()
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
lock.lock()
redirectCount += 1
let count = redirectCount
lock.unlock()
guard count <= 5 else {
completionHandler(nil)
finish(.failure(WebPageContentError.tooManyRedirects))
return
}
guard let redirectedURL = request.url,
let safeURL = WebPageURLPolicy.validatedSummaryURL(redirectedURL) else {
completionHandler(nil)
finish(.failure(WebPageContentError.invalidURL))
return
}
var redirectedRequest = request
redirectedRequest.url = safeURL
redirectedRequest.httpMethod = "GET"
completionHandler(redirectedRequest)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
lock.lock()
let storedError = terminalError
let storedResponse = response
let data = receivedData
lock.unlock()
if let storedError {
finish(.failure(storedError))
} else if error != nil {
finish(.failure(WebPageContentError.transport))
} else if let finalURL = storedResponse?.url {
finish(.success(WebPageHTTPResponse(data: data, finalURL: finalURL)))
} else {
finish(.failure(WebPageContentError.invalidResponse))
}
}
private func finish(_ result: Result<WebPageHTTPResponse, Error>) {
lock.lock()
guard !finished else {
lock.unlock()
return
}
finished = true
let continuation = continuation
self.continuation = nil
lock.unlock()
session.invalidateAndCancel()
continuation?.resume(with: result)
}
private static let allowedMIMETypes: Set<String> = [
"text/html",
"application/xhtml+xml",
"text/plain"
]
}
enum WebPageHTMLExtractor {
static func extract(
html: String,
finalURL: URL,
maximumBodyCharacters: Int
) throws -> WebPageContent {
let title = firstCapture(
pattern: #"(?is)<title\b[^>]*>(.*?)</title>"#,
in: html
).map(cleanInlineText)
let selected = firstCapture(
pattern: #"(?is)<article\b[^>]*>(.*?)</article>"#,
in: html
) ?? firstCapture(
pattern: #"(?is)<main\b[^>]*>(.*?)</main>"#,
in: html
) ?? firstCapture(
pattern: #"(?is)<body\b[^>]*>(.*?)</body>"#,
in: html
) ?? html
var text = replacing(
pattern: #"(?is)<!--.*?-->"#,
in: selected,
with: " "
)
text = replacing(
pattern: #"(?is)<(script|style|noscript|template|svg|canvas|nav|footer|aside|form|dialog)\b[^>]*>.*?</\1\s*>"#,
in: text,
with: " "
)
text = replacing(
pattern: #"(?is)<br\s*/?>|</?(p|div|section|article|main|h[1-6]|li|ul|ol|tr|blockquote|pre)\b[^>]*>"#,
in: text,
with: "\n"
)
text = replacing(pattern: #"(?is)<[^>]+>"#, in: text, with: " ")
text = decodeHTMLEntities(text)
text = normalizedBody(text)
guard text.count >= 40 else {
throw WebPageContentError.emptyContent
}
if text.count > maximumBodyCharacters {
text = String(text.prefix(maximumBodyCharacters))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
let resolvedTitle = title.flatMap { $0.isEmpty ? nil : $0 }
return WebPageContent(
finalURL: finalURL,
title: resolvedTitle,
body: text
)
}
private static func firstCapture(pattern: String, in text: String) -> String? {
guard let expression = try? NSRegularExpression(pattern: pattern),
let match = expression.firstMatch(
in: text,
range: NSRange(text.startIndex..., in: text)
),
match.numberOfRanges > 1,
let range = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[range])
}
private static func replacing(
pattern: String,
in text: String,
with replacement: String
) -> String {
guard let expression = try? NSRegularExpression(pattern: pattern) else {
return text
}
return expression.stringByReplacingMatches(
in: text,
range: NSRange(text.startIndex..., in: text),
withTemplate: replacement
)
}
private static func cleanInlineText(_ text: String) -> String {
normalizedBody(
decodeHTMLEntities(
replacing(pattern: #"(?is)<[^>]+>"#, in: text, with: " ")
)
)
.replacingOccurrences(of: "\n", with: " ")
}
private static func normalizedBody(_ text: String) -> String {
let normalizedNewlines = text
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
return normalizedNewlines
.split(separator: "\n")
.map {
replacing(
pattern: #"[^\S\n]+"#,
in: String($0),
with: " "
).trimmingCharacters(in: .whitespacesAndNewlines)
}
.filter { !$0.isEmpty }
.joined(separator: "\n")
}
private static func decodeHTMLEntities(_ source: String) -> String {
var text = source
let namedEntities = [
"&nbsp;": " ",
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&#39;": "'",
"&apos;": "'",
"&ndash;": "",
"&mdash;": "",
"&hellip;": "",
"&lsquo;": "",
"&rsquo;": "",
"&ldquo;": "",
"&rdquo;": ""
]
for (entity, value) in namedEntities {
text = text.replacingOccurrences(of: entity, with: value)
}
guard let expression = try? NSRegularExpression(
pattern: #"&#(?:([0-9]+)|x([0-9A-Fa-f]+));"#
) else {
return text
}
let matches = expression.matches(
in: text,
range: NSRange(text.startIndex..., in: text)
)
for match in matches.reversed() {
guard let fullRange = Range(match.range, in: text) else { continue }
let decimal = Range(match.range(at: 1), in: text).flatMap {
UInt32(text[$0], radix: 10)
}
let hexadecimal = Range(match.range(at: 2), in: text).flatMap {
UInt32(text[$0], radix: 16)
}
guard let scalarValue = decimal ?? hexadecimal,
let scalar = UnicodeScalar(scalarValue) else {
continue
}
text.replaceSubrange(fullRange, with: String(scalar))
}
return text
}
}
+140 -377
View File
@@ -1,14 +1,13 @@
// AIAgentSkillsView.swift
// OSGKeyboard · Main App
//
// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
// the keyboard after a copy; long-press drag rearranges that row live,
// like Home Screen icons. Export skills confirm a companion Shortcut
// before they can occupy a slot. Users can add custom export skills.
// Catalog of installed and available AI Agent clipboard skills. Selecting a
// row opens its detail page; installation state is managed there. Export
// skills confirm a companion Shortcut before installation completes.
import Foundation
import OSGKeyboardShared
import SwiftUI
import UIKit
struct AIAgentSkillsView: View {
@Environment(\.themePalette) private var palette
@@ -16,21 +15,11 @@ struct AIAgentSkillsView: View {
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
@State private var viewingSkill: AIClipboardSkill?
@State private var editingDraft: SkillEditorDraft?
@State private var showFullAlert = false
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
@State private var pasteAccessNeedsRecovery = false
@State private var showPasteNoTextAlert = false
@State private var showPasteAccessSuccess = false
/// True while a skill card is lifted; locks the page scroll like SpringBoard.
@State private var isReordering = false
private let columns = [
GridItem(.flexible(), spacing: Spacing.sm),
GridItem(.flexible(), spacing: Spacing.sm)
]
private var skillCardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
}
@@ -43,14 +32,13 @@ struct AIAgentSkillsView: View {
clipboardAccessGuide
.transition(.opacity.combined(with: .move(edge: .top)))
}
enabledSection
installedSection
if !store.availableSkills.isEmpty {
availableSection
uninstalledSection
}
}
.tabBarScrollBottomPadding()
}
.scrollDisabled(isReordering)
.background(palette.background)
.navigationTitle("skills.title")
.navigationBarTitleDisplayMode(.large)
@@ -65,15 +53,6 @@ struct AIAgentSkillsView: View {
}
}
}
.sheet(item: $viewingSkill) { skill in
SkillDetailSheet(
store: store,
skill: skill,
onDismiss: { viewingSkill = nil },
onAddOrWarn: addOrWarn,
onConfirmInstall: confirmShortcutInstall
)
}
.sheet(item: $editingDraft) { draft in
SkillEditorSheet(
draft: draft,
@@ -84,14 +63,6 @@ struct AIAgentSkillsView: View {
}
)
}
.alert(
AppL10n.string("skills.full.title", language: config.uiLanguage),
isPresented: $showFullAlert
) {
Button("common.done") { showFullAlert = false }
} message: {
Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
}
.alert(
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
isPresented: $showPasteNoTextAlert
@@ -102,10 +73,19 @@ struct AIAgentSkillsView: View {
}
.onAppear {
store.reload()
refreshOfficialSkillCatalog(
reason: "AIAgentSkillsView.onAppear",
force: true
)
refreshPasteAccessState()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
store.reload()
refreshOfficialSkillCatalog(
reason: "AIAgentSkillsView.active",
force: true
)
refreshPasteAccessState()
pasteAccessNeedsRecovery = false
}
@@ -291,69 +271,73 @@ struct AIAgentSkillsView: View {
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
}
private var enabledSection: some View {
private func refreshOfficialSkillCatalog(reason: String, force: Bool) {
Task {
let outcome = await OfficialSkillCatalogRefreshService.shared.refreshIfNeeded(
reason: reason,
force: force
)
if outcome.didUpdateCache {
store.reload()
}
}
}
private var installedSection: some View {
CardSection(
title: AppL10n.format(
"skills.enabled.section",
"skills.installed.section",
language: config.uiLanguage,
store.enabledSkills.count,
AIAgentSkillLayout.maximumEnabled
store.enabledSkills.count
)
) {
if store.enabledSkills.isEmpty {
Text("skills.enabled.empty")
Text("skills.installed.empty")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
EnabledSkillsReorderGrid(
skills: store.enabledSkills,
isReordering: $isReordering,
onTap: handleCardTap,
onEdit: handleEdit,
onMoveToIndex: { id, index in
store.moveEnabled(id: id, toIndex: index)
},
card: { skill in
skillCardVisual(skill, isEnabled: true)
LazyVStack(spacing: 0) {
ForEach(store.enabledSkills) { skill in
skillListItem(skill)
}
)
}
.background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.clipShape(skillCardShape)
}
}
}
private var availableSection: some View {
CardSection("skills.available.section") {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
private var uninstalledSection: some View {
CardSection("skills.uninstalled.section") {
LazyVStack(spacing: 0) {
ForEach(store.availableSkills) { skill in
skillCardInteractive(skill, isEnabled: false)
skillListItem(skill)
}
}
.background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.clipShape(skillCardShape)
}
}
private func skillCardInteractive(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
ZStack(alignment: .topTrailing) {
Button {
handleCardTap(skill)
} label: {
skillCardVisual(skill, isEnabled: isEnabled)
}
.buttonStyle(.plain)
Button {
handleEdit(skill)
} label: {
Color.clear
.frame(
width: CatalogCardChrome.editHitSize,
height: CatalogCardChrome.editHitSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("skills.edit"))
private func skillListItem(_ skill: AIClipboardSkill) -> some View {
NavigationLink {
SkillDetailView(
store: store,
skill: skill,
onInstall: addOrWarn,
onConfirmInstall: confirmShortcutInstall,
onEdit: {
guard let user = store.userSkill(id: skill.id) else { return }
editingDraft = .from(user)
}
)
} label: {
skillListRow(skill)
}
.buttonStyle(.plain)
.contextMenu {
if skill.isUserCreated {
Button("common.delete", role: .destructive) {
@@ -363,45 +347,45 @@ struct AIAgentSkillsView: View {
}
}
private func skillCardVisual(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
ZStack(alignment: .topTrailing) {
VStack(alignment: .leading, spacing: Spacing.sm) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
private func skillListRow(_ skill: AIClipboardSkill) -> some View {
HStack(spacing: Spacing.md) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 38, height: 38)
.background(palette.accentMuted, in: RoundedRectangle(
cornerRadius: Radius.medium,
style: .continuous
))
VStack(alignment: .leading, spacing: 3) {
Text(skillTitle(skill))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.trailing, 32)
Text(skillDescription(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(2)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
CatalogCardChrome.editIcon(palette: palette)
.padding(Spacing.sm)
.allowsHitTesting(false)
if isEnabled {
CatalogCardChrome.checkIcon(palette: palette)
}
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(width: 18)
}
.background(
isEnabled ? palette.accentMuted : palette.surface,
in: skillCardShape
)
.overlay(skillCardShape.stroke(
isEnabled ? palette.accent : palette.divider,
lineWidth: isEnabled ? 1.5 : 0.5
))
.clipShape(skillCardShape)
.contentShape(skillCardShape)
.containerShape(skillCardShape)
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs)
.background(palette.surface)
.overlay(alignment: .bottom) {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
.padding(.leading, 38 + Spacing.md * 2)
}
.contentShape(Rectangle())
}
private func skillTitle(_ skill: AIClipboardSkill) -> String {
@@ -413,40 +397,18 @@ struct AIAgentSkillsView: View {
}
private func skillDescription(_ skill: AIClipboardSkill) -> String {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
if skill.isUserCreated {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
}
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
}
private func handleCardTap(_ skill: AIClipboardSkill) {
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
viewingSkill = skill
return
}
if store.layout.isEnabled(skill.id) {
store.disable(skill.id)
} else {
addOrWarn(skill)
}
}
private func handleEdit(_ skill: AIClipboardSkill) {
if skill.isUserCreated, let user = store.userSkill(id: skill.id) {
editingDraft = .from(user)
} else {
viewingSkill = skill
}
}
private func addOrWarn(_ skill: AIClipboardSkill) {
switch store.enable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
case .full:
showFullAlert = true
break
case .needsShortcut, .unknown:
break
}
@@ -455,9 +417,7 @@ struct AIAgentSkillsView: View {
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
switch store.confirmShortcutAndEnable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
case .full:
showFullAlert = true
break
case .needsShortcut, .unknown:
break
}
@@ -491,186 +451,6 @@ struct AIAgentSkillsView: View {
}
}
/// Home-screen style reorder: long-press lifts the card, other cells slide
/// into the gap as the finger crosses midpoints, drop settles in place.
private struct EnabledSkillsReorderGrid<Card: View>: View {
let skills: [AIClipboardSkill]
@Binding var isReordering: Bool
let onTap: (AIClipboardSkill) -> Void
let onEdit: (AIClipboardSkill) -> Void
let onMoveToIndex: (String, Int) -> Void
let card: (AIClipboardSkill) -> Card
@State private var draggingID: String?
@State private var dragTranslation: CGSize = .zero
@State private var originFrame: CGRect = .zero
@State private var cellFrames: [String: CGRect] = [:]
@State private var ignoreTap = false
private static var spaceName: String { "enabledSkillsGrid" }
private let columns = [
GridItem(.flexible(), spacing: Spacing.sm),
GridItem(.flexible(), spacing: Spacing.sm)
]
var body: some View {
ZStack(alignment: .topLeading) {
LazyVGrid(columns: columns, spacing: Spacing.sm) {
ForEach(skills) { skill in
gridCell(skill)
}
}
.animation(
.interactiveSpring(response: 0.28, dampingFraction: 0.86),
value: skills.map(\.id)
)
if let draggingID, let skill = skills.first(where: { $0.id == draggingID }) {
card(skill)
.frame(width: originFrame.width, height: originFrame.height)
.scaleEffect(1.07)
.shadow(color: .black.opacity(0.28), radius: 16, y: 10)
.offset(
x: originFrame.minX + dragTranslation.width,
y: originFrame.minY + dragTranslation.height
)
.allowsHitTesting(false)
}
}
.coordinateSpace(.named(Self.spaceName))
.onPreferenceChange(SkillCellFrameKey.self) { cellFrames = $0 }
}
private func gridCell(_ skill: AIClipboardSkill) -> some View {
ZStack(alignment: .topTrailing) {
Button {
guard draggingID == nil, !ignoreTap else { return }
onTap(skill)
} label: {
card(skill)
.opacity(draggingID == skill.id ? 0 : 1)
}
.buttonStyle(.plain)
Button {
guard draggingID == nil, !ignoreTap else { return }
onEdit(skill)
} label: {
Color.clear
.frame(
width: CatalogCardChrome.editHitSize,
height: CatalogCardChrome.editHitSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("skills.edit"))
.opacity(draggingID == skill.id ? 0 : 1)
}
.background {
GeometryReader { proxy in
Color.clear.preference(
key: SkillCellFrameKey.self,
value: [skill.id: proxy.frame(in: .named(Self.spaceName))]
)
}
}
.simultaneousGesture(reorderGesture(for: skill))
}
private func reorderGesture(for skill: AIClipboardSkill) -> some Gesture {
LongPressGesture(minimumDuration: 0.22)
.sequenced(
before: DragGesture(
minimumDistance: 0,
coordinateSpace: .named(Self.spaceName)
)
)
.onChanged { value in
switch value {
case .second(_, let drag):
liftIfNeeded(skill)
guard let drag else { return }
dragTranslation = drag.translation
moveSlotIfNeeded(at: drag.location)
default:
break
}
}
.onEnded { _ in
endDrag()
}
}
private func liftIfNeeded(_ skill: AIClipboardSkill) {
guard draggingID == nil else { return }
originFrame = cellFrames[skill.id] ?? .zero
draggingID = skill.id
isReordering = true
UIImpactFeedbackGenerator(style: .medium).impactOccurred(intensity: 0.9)
}
private func moveSlotIfNeeded(at point: CGPoint) {
guard let draggingID else { return }
guard let target = nearestIndex(to: point) else { return }
guard skills[target].id != draggingID else { return }
UISelectionFeedbackGenerator().selectionChanged()
onMoveToIndex(draggingID, target)
}
private func nearestIndex(to point: CGPoint) -> Int? {
guard !skills.isEmpty else { return nil }
var bestIndex: Int?
var bestDistance = CGFloat.greatestFiniteMagnitude
for (index, skill) in skills.enumerated() {
guard let frame = cellFrames[skill.id] else { continue }
let dx = point.x - frame.midX
let dy = point.y - frame.midY
let distance = dx * dx + dy * dy
if distance < bestDistance {
bestDistance = distance
bestIndex = index
}
}
return bestIndex
}
private func endDrag() {
let wasDragging = draggingID != nil
if let draggingID, let slot = cellFrames[draggingID], originFrame.width > 0 {
withAnimation(.snappy(duration: 0.2)) {
dragTranslation = CGSize(
width: slot.minX - originFrame.minX,
height: slot.minY - originFrame.minY
)
} completion: {
clearDragState()
}
} else {
clearDragState()
}
guard wasDragging else { return }
ignoreTap = true
DispatchQueue.main.async {
ignoreTap = false
}
}
private func clearDragState() {
draggingID = nil
dragTranslation = .zero
isReordering = false
}
}
private struct SkillCellFrameKey: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
private struct SkillEditorDraft: Identifiable, Equatable {
let id: String
let isNew: Bool
@@ -711,81 +491,55 @@ private struct SkillEditorDraft: Identifiable, Equatable {
}
}
private struct SkillDetailSheet: View {
private struct SkillDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@ObservedObject var store: AIAgentSkillLayoutStore
@ObservedObject private var config = ProviderConfig.shared
let skill: AIClipboardSkill
let onDismiss: () -> Void
let onAddOrWarn: (AIClipboardSkill) -> Void
let onInstall: (AIClipboardSkill) -> Void
let onConfirmInstall: (AIClipboardSkill) -> Void
/// Inner stack height; nav chrome + home indicator are added for the detent.
@State private var contentHeight: CGFloat = 280
private let navigationChrome: CGFloat = 72
let onEdit: () -> Void
var body: some View {
NavigationStack {
ScrollView {
CardPageContent(
spacing: Spacing.md,
topPadding: Spacing.md,
bottomPadding: Spacing.xl
) {
header
Text(skillDescription)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
promptBlock
thinkingRow
skillActions
}
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
let next = newHeight + navigationChrome
if abs(contentHeight - next) > 1 {
contentHeight = next
}
}
ScrollView {
CardPageContent(
spacing: Spacing.md,
topPadding: Spacing.md,
bottomPadding: Spacing.xl
) {
header
Text(skillDescription)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
promptBlock
thinkingRow
skillActions
}
.scrollBounceBehavior(.basedOnSize)
.background(palette.background)
.navigationTitle("skills.detail.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done", action: onDismiss)
}
.background(palette.background)
.navigationTitle("skills.detail.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if skill.isUserCreated {
ToolbarItem(placement: .topBarTrailing) {
Button("skills.edit", action: onEdit)
}
}
}
.presentationDetents([.height(clampedSheetHeight)])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.resizes)
.animation(.easeInOut(duration: 0.2), value: clampedSheetHeight)
}
private var clampedSheetHeight: CGFloat {
let screen = UIScreen.main.bounds.height
let maximum = screen * 0.92
return min(max(contentHeight, 280), maximum)
}
private var skillDescription: String {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
if skill.isUserCreated {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
}
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
return AppL10n.string("skills.install.lead", language: config.uiLanguage)
}
if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
return AppL10n.string("skills.action.turnOffHint", language: config.uiLanguage)
}
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
}
@@ -849,37 +603,40 @@ private struct SkillDetailSheet: View {
@ViewBuilder
private var skillActions: some View {
let enabled = store.layout.isEnabled(skill.id)
if skill.requiresShortcut {
if !store.layout.hasConfirmedShortcut(skill.id) {
shortcutInstallBlock
} else {
if enabled {
fullWidthButton("skills.action.turnOff", prominent: false) {
if isInstalled {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
onDismiss()
dismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
fullWidthButton("skills.action.install", prominent: true) {
onInstall(skill)
}
}
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
openShortcutInstall()
}
}
} else if enabled {
fullWidthButton("skills.action.turnOff", prominent: false) {
} else if isInstalled {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
onDismiss()
dismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
fullWidthButton("skills.action.install", prominent: true) {
onInstall(skill)
}
}
}
private var isInstalled: Bool {
store.layout.isEnabled(skill.id)
}
private var shortcutInstallBlock: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
fullWidthButton("skills.install.openShortcuts", prominent: true) {
@@ -888,6 +645,12 @@ private struct SkillDetailSheet: View {
fullWidthButton("skills.install.confirmAdded", prominent: false) {
onConfirmInstall(skill)
}
if store.layout.isEnabled(skill.id) {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
dismiss()
}
}
}
}
@@ -3,7 +3,7 @@
//
// Interactive layout preview and What's New recording host for AI Agent
// clipboard skills on the real unified `AIKeyboardView`. Launch with
// `--ai-skills-demo`, optional `--skills-count=N` (1...8), and optional
// `--ai-skills-demo`, optional `--skills-count=N`, and optional
// `--whats-new-lang=zh|en`.
#if DEBUG
@@ -61,7 +61,7 @@ struct AIClipboardSkillLayoutDemoView: View {
.foregroundStyle(.white)
.frame(minWidth: 72)
Button {
count = min(8, count + 1)
count = min(AIClipboardSkillCatalog.catalog.count, count + 1)
} label: {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28))
@@ -69,7 +69,7 @@ struct AIClipboardSkillLayoutDemoView: View {
}
.foregroundStyle(.green)
HStack(spacing: 8) {
ForEach([3, 5, 8], id: \.self) { n in
ForEach([3, 8, AIClipboardSkillCatalog.catalog.count], id: \.self) { n in
Button("\(n)") {
count = n
}
@@ -103,7 +103,7 @@ struct AIClipboardSkillLayoutDemoView: View {
}
private func showSkills(_ count: Int) {
let clamped = min(max(count, 1), 8)
let clamped = min(max(count, 1), AIClipboardSkillCatalog.catalog.count)
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
state.enabledClipboardSkillIDs = Array(
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
@@ -135,7 +135,7 @@ struct AIClipboardSkillLayoutDemoView: View {
let prefix = "--skills-count="
if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
let value = Int(arg.dropFirst(prefix.count)),
(1...8).contains(value) {
(1...AIClipboardSkillCatalog.catalog.count).contains(value) {
return value
}
return 5
@@ -19,6 +19,7 @@ struct AccountAppleAuthorizationButton: View {
@EnvironmentObject private var coordinator: AccountSessionCoordinator
let purpose: AccountAppleAuthorizationPurpose
var onSignedIn: () -> Void = {}
var onDeleted: () -> Void = {}
@State private var rawNonce: String?
@@ -30,7 +31,8 @@ struct AccountAppleAuthorizationButton: View {
onCompletion: completeAuthorization
)
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(maxWidth: .infinity, minHeight: 50)
.frame(maxWidth: .infinity)
.frame(height: 50)
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.accessibilityLabel(
purpose == .signIn
@@ -85,6 +87,7 @@ struct AccountAppleAuthorizationButton: View {
await coordinator.signIn(with: payload)
if coordinator.isSignedIn {
ProviderConfig.shared.reloadFromPersistedStorage()
onSignedIn()
}
case .deleteAccount:
await coordinator.deleteAccount(with: payload)
@@ -14,7 +14,11 @@ struct AccountCenterUITestHarness: View {
@StateObject private var coordinator: AccountSessionCoordinator
init() {
let service = AccountCenterUITestService()
let service = AccountCenterUITestService(
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
"--account-snapshot-failure"
)
)
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
_coordinator = StateObject(
wrappedValue: AccountSessionCoordinator(
@@ -91,6 +95,11 @@ private actor AccountCenterUITestService:
createdAtEpochSeconds: 1_700_000_000,
displayName: "UI Test Account"
)
private let shouldFailSnapshot: Bool
init(shouldFailSnapshot: Bool = false) {
self.shouldFailSnapshot = shouldFailSnapshot
}
func restoreSession() async throws -> AccountSession? {
account
@@ -108,7 +117,10 @@ private actor AccountCenterUITestService:
}
func loadAccountCenter() async throws -> AccountCenterSnapshot {
AccountCenterSnapshot(
if shouldFailSnapshot {
throw AccountCenterUITestError.snapshotUnavailable
}
return AccountCenterSnapshot(
account: account,
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
referrals: []
@@ -180,6 +192,10 @@ private actor AccountCenterUITestService:
}
}
private enum AccountCenterUITestError: Error {
case snapshotUnavailable
}
@MainActor
private final class AccountCenterUITestCreditStore: AccountCreditStore {
private let accountID: UUID
@@ -32,8 +32,10 @@ struct AccountCenterView: View {
coordinator.creditPurchases.reset()
return
}
await coordinator.refreshAccountData()
await coordinator.creditPurchases.prepare(accountID: accountID)
async let accountRefresh: Void = coordinator.refreshAccountData()
async let purchasePreparation: Void =
coordinator.creditPurchases.prepare(accountID: accountID)
_ = await (accountRefresh, purchasePreparation)
if case let .signedIn(session) = coordinator.sessionPhase {
displayNameDraft = session.displayName ?? ""
}
@@ -148,7 +150,10 @@ struct AccountCenterView: View {
.padding(.bottom, Spacing.lg)
}
.refreshable {
await coordinator.refreshAccountData(force: true)
async let accountRefresh: Void = coordinator.refreshAccountData(force: true)
async let catalogRefresh: Void =
coordinator.creditPurchases.refreshCatalog(accountID: session.accountID)
_ = await (accountRefresh, catalogRefresh)
}
.accessibilityIdentifier("account.center.signedIn")
}
@@ -171,17 +176,19 @@ struct AccountCenterView: View {
Task { await coordinator.refreshAccountData(force: true) }
}
case .loaded:
CardSection("account.storekit.section") {
VStack(spacing: Spacing.sm) {
AccountCreditPurchaseSection(
manager: coordinator.creditPurchases,
accountID: session.accountID,
onGranted: {
Task { await coordinator.refreshAccountData(force: true) }
}
)
purchaseHistoryLink(accountID: session.accountID)
}
EmptyView()
}
CardSection("account.storekit.section") {
VStack(spacing: Spacing.sm) {
AccountCreditPurchaseSection(
manager: coordinator.creditPurchases,
accountID: session.accountID,
onGranted: {
Task { await coordinator.refreshAccountData(force: true) }
}
)
purchaseHistoryLink(accountID: session.accountID)
}
}
}
@@ -522,7 +529,7 @@ private struct AccountCreditPurchaseSection: View {
}
}
.surfaceCard()
} else if manager.state == .loading {
} else if manager.catalogPhase == .loading {
HStack(spacing: Spacing.sm) {
ProgressView()
.controlSize(.small)
@@ -535,6 +542,7 @@ private struct AccountCreditPurchaseSection: View {
.surfaceCard()
}
catalogStateMessage
stateMessage
}
.onChange(of: manager.lastGrantedBalance) { previous, current in
@@ -613,6 +621,37 @@ private struct AccountCreditPurchaseSection: View {
return productID
}
@ViewBuilder
private var catalogStateMessage: some View {
switch manager.catalogPhase {
case .idle:
catalogRetryMessage(messageKey: "account.storekit.error.productUnavailable")
case .loading, .loaded:
if let messageKey = manager.catalogRefreshErrorKey {
catalogRetryMessage(messageKey: messageKey)
}
case .failed(let messageKey):
catalogRetryMessage(messageKey: messageKey)
}
}
private func catalogRetryMessage(messageKey: String) -> some View {
HStack(spacing: Spacing.sm) {
Label(
LocalizedStringKey(messageKey),
systemImage: "exclamationmark.triangle.fill"
)
.font(TypeStyle.footnote)
.foregroundStyle(palette.danger)
Spacer(minLength: Spacing.xs)
Button("account.retry") {
Task { await manager.refreshCatalog(accountID: accountID) }
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.accent)
}
}
@ViewBuilder
private var stateMessage: some View {
Group {
@@ -646,11 +685,6 @@ private struct AccountCreditPurchaseSection: View {
.font(TypeStyle.footnote)
.foregroundStyle(palette.danger)
Spacer(minLength: Spacing.xs)
Button("account.retry") {
Task { await manager.prepare(accountID: accountID) }
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.accent)
}
default:
EmptyView()
@@ -21,13 +21,19 @@ struct AccountCreditPurchaseOption: Identifiable, Equatable {
final class AccountCreditPurchaseManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case purchasing(productID: String)
case pending
case succeeded(credits: Int64)
case failed(messageKey: String)
}
enum CatalogPhase: Equatable {
case idle
case loading
case loaded
case failed(messageKey: String)
}
enum HistoryPhase: Equatable {
case idle
case loading
@@ -36,7 +42,10 @@ final class AccountCreditPurchaseManager: ObservableObject {
}
@Published private(set) var state: State = .idle
@Published private(set) var catalogPhase: CatalogPhase = .idle
@Published private(set) var options: [AccountCreditPurchaseOption] = []
@Published private(set) var isRefreshingCatalog = false
@Published private(set) var catalogRefreshErrorKey: String?
@Published private(set) var lastGrantedBalance: Int64?
@Published private(set) var historyPhase: HistoryPhase = .idle
@Published private(set) var isLoadingMoreHistory = false
@@ -45,80 +54,192 @@ final class AccountCreditPurchaseManager: ObservableObject {
private let service: any AccountCenterServicing
private let store: any AccountCreditStore
private let analyticsClient: any AnalyticsClient
private let successMessageDuration: Duration
private var prepareTask: Task<Void, Never>?
private var prepareRequestID: UUID?
private var updatesTask: Task<Void, Never>?
private var successDismissTask: Task<Void, Never>?
private var activeAccountID: UUID?
private var sessionID: UUID?
private var pendingProductID: String?
private var configuredProductIDs: Set<String> = []
private var nextHistoryCursor: String?
init(
service: any AccountCenterServicing,
store: any AccountCreditStore = LiveAccountCreditStore(),
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
successMessageDuration: Duration = .seconds(4)
) {
self.service = service
self.store = store
self.analyticsClient = analyticsClient
self.successMessageDuration = successMessageDuration
}
deinit {
prepareTask?.cancel()
updatesTask?.cancel()
successDismissTask?.cancel()
}
func prepare(accountID: UUID) async {
guard activeAccountID != accountID || options.isEmpty else { return }
/// Starts one non-blocking StoreKit preparation for a signed-in account.
/// Repeated calls for the same account preserve the current session state.
func startSession(accountID: UUID) {
guard activeAccountID != accountID else { return }
reset(for: accountID)
state = .loading
beginPrepare(accountID: accountID)
}
func endSession() {
reset()
}
func prepare(accountID: UUID, force: Bool = false) async {
if activeAccountID != accountID {
startSession(accountID: accountID)
}
if let prepareTask {
await prepareTask.value
return
}
if !force, catalogPhase == .loaded, !options.isEmpty {
return
}
beginPrepare(accountID: accountID)
await prepareTask?.value
}
func refreshCatalog(accountID: UUID) async {
await prepare(accountID: accountID, force: true)
}
private func beginPrepare(accountID: UUID) {
guard activeAccountID == accountID, prepareTask == nil, let sessionID else { return }
let requestID = UUID()
prepareRequestID = requestID
catalogRefreshErrorKey = nil
isRefreshingCatalog = true
if options.isEmpty {
catalogPhase = .loading
}
prepareTask = Task { @MainActor [weak self] in
await self?.performPrepare(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
)
}
}
private func performPrepare(
accountID: UUID,
sessionID: UUID,
requestID: UUID
) async {
defer { finishPrepare(requestID: requestID) }
do {
try Task.checkCancellation()
let catalog = try await service.loadCreditProducts()
configuredProductIDs = Set(catalog.map(\.productID))
var loadedOptions: [AccountCreditPurchaseOption] = []
for creditProduct in catalog {
guard let product = try await store.product(for: creditProduct.productID) else {
continue
}
loadedOptions.append(
AccountCreditPurchaseOption(
productID: creditProduct.productID,
credits: creditProduct.credits,
displayPrice: product.displayPrice
)
try Task.checkCancellation()
guard isCurrentSession(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
) else {
return
}
let products = try await loadProductsWithRetry(
productIDs: catalog.map(\.productID)
)
try Task.checkCancellation()
guard isCurrentSession(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
) else {
return
}
let productsByID = products.reduce(into: [String: AccountStoreProduct]()) {
$0[$1.id] = $1
}
let loadedOptions: [AccountCreditPurchaseOption] = catalog.compactMap { creditProduct in
guard let product = productsByID[creditProduct.productID] else { return nil }
return AccountCreditPurchaseOption(
productID: creditProduct.productID,
credits: creditProduct.credits,
displayPrice: product.displayPrice
)
}
guard !loadedOptions.isEmpty else {
throw AccountCreditPurchaseError.productUnavailable
}
configuredProductIDs = Set(catalog.map(\.productID))
options = loadedOptions.sorted { $0.credits < $1.credits }
state = .idle
listenForUpdates(accountID: accountID)
_ = await reconcileUnfinished(accountID: accountID)
catalogPhase = .loaded
catalogRefreshErrorKey = nil
listenForUpdates(accountID: accountID, sessionID: sessionID)
_ = await reconcileUnfinished(accountID: accountID, sessionID: sessionID)
} catch is CancellationError {
return
} catch {
state = .failed(messageKey: Self.messageKey(for: error))
guard isCurrentSession(
accountID: accountID,
sessionID: sessionID,
requestID: requestID
) else {
return
}
let messageKey = Self.messageKey(for: error)
catalogRefreshErrorKey = messageKey
catalogPhase = options.isEmpty
? .failed(messageKey: messageKey)
: .loaded
}
}
@discardableResult
func purchase(productID: String, accountID: UUID) async -> Bool {
guard activeAccountID == accountID,
let sessionID,
options.contains(where: { $0.productID == productID }) else {
state = .failed(messageKey: "account.storekit.error.productUnavailable")
return false
}
state = .purchasing(productID: productID)
pendingProductID = nil
cancelSuccessDismissal()
analyticsClient.recordPurchaseStarted()
do {
switch try await store.purchase(productID: productID, accountID: accountID) {
case .success(let verification):
try await process(verification, accountID: accountID)
let result = try await process(
verification,
accountID: accountID,
sessionID: sessionID
)
presentSuccess(credits: result.creditsGranted)
return true
case .pending:
pendingProductID = productID
state = .pending
case .userCancelled:
pendingProductID = nil
state = .idle
analyticsClient.recordPurchaseCancelled()
@unknown default:
pendingProductID = nil
state = .failed(messageKey: "account.storekit.error.unknown")
}
} catch is CancellationError {
return false
} catch {
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
return false
}
pendingProductID = nil
state = .failed(messageKey: Self.messageKey(for: error))
}
return false
@@ -131,6 +252,8 @@ final class AccountCreditPurchaseManager: ObservableObject {
func clearTransientState() {
switch state {
case .succeeded, .failed, .pending:
pendingProductID = nil
cancelSuccessDismissal()
state = .idle
default:
break
@@ -139,6 +262,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
func dismissSuccessMessage() {
guard case .succeeded = state else { return }
cancelSuccessDismissal()
state = .idle
}
@@ -199,10 +323,20 @@ final class AccountCreditPurchaseManager: ObservableObject {
}
func reset() {
prepareTask?.cancel()
prepareTask = nil
prepareRequestID = nil
updatesTask?.cancel()
updatesTask = nil
successDismissTask?.cancel()
successDismissTask = nil
activeAccountID = nil
sessionID = nil
pendingProductID = nil
catalogPhase = .idle
options = []
isRefreshingCatalog = false
catalogRefreshErrorKey = nil
configuredProductIDs = []
lastGrantedBalance = nil
historyPhase = .idle
@@ -213,30 +347,64 @@ final class AccountCreditPurchaseManager: ObservableObject {
}
@discardableResult
private func reconcileUnfinished(accountID: UUID) async -> Bool {
private func reconcileUnfinished(accountID: UUID, sessionID: UUID) async -> Bool {
var reconciled = false
for await verification in store.unfinishedTransactions() {
guard !Task.isCancelled else { break }
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
break
}
do {
try await process(verification, accountID: accountID)
_ = try await process(
verification,
accountID: accountID,
sessionID: sessionID
)
reconciled = true
} catch is CancellationError {
break
} catch {
state = .failed(messageKey: Self.messageKey(for: error))
// Historical recovery is background maintenance; it must not
// replace the current purchase action with stale UI feedback.
continue
}
}
return reconciled
}
private func listenForUpdates(accountID: UUID) {
private func listenForUpdates(accountID: UUID, sessionID: UUID) {
updatesTask?.cancel()
let updates = store.transactionUpdates()
updatesTask = Task { [weak self] in
for await verification in updates {
guard !Task.isCancelled else { return }
guard let self else { return }
guard self.isCurrentSession(
accountID: accountID,
sessionID: sessionID
) else {
return
}
do {
try await self.process(verification, accountID: accountID)
let result = try await self.process(
verification,
accountID: accountID,
sessionID: sessionID
)
if self.pendingProductID == result.productID {
self.pendingProductID = nil
self.presentSuccess(credits: result.creditsGranted)
}
} catch is CancellationError {
return
} catch {
guard self.isCurrentSession(
accountID: accountID,
sessionID: sessionID
), self.pendingProductID != nil else {
continue
}
self.pendingProductID = nil
self.state = .failed(messageKey: Self.messageKey(for: error))
}
}
@@ -245,9 +413,11 @@ final class AccountCreditPurchaseManager: ObservableObject {
private func process(
_ verification: AccountStoreVerification,
accountID: UUID
) async throws {
guard case .verified(let transaction) = verification,
accountID: UUID,
sessionID: UUID
) async throws -> AccountCreditPurchase {
guard isCurrentSession(accountID: accountID, sessionID: sessionID),
case .verified(let transaction) = verification,
transaction.appAccountToken == accountID,
configuredProductIDs.contains(transaction.productID) else {
throw AccountCreditPurchaseError.verificationFailed
@@ -258,23 +428,119 @@ final class AccountCreditPurchaseManager: ObservableObject {
throw AccountCreditPurchaseError.invalidServerResponse
}
await transaction.finish()
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
throw CancellationError()
}
lastGrantedBalance = result.balanceAfter
historyPhase = .idle
nextHistoryCursor = nil
state = .succeeded(credits: result.creditsGranted)
return result
}
private func reset(for accountID: UUID) {
prepareTask?.cancel()
prepareTask = nil
prepareRequestID = nil
updatesTask?.cancel()
updatesTask = nil
successDismissTask?.cancel()
successDismissTask = nil
activeAccountID = accountID
sessionID = UUID()
pendingProductID = nil
catalogPhase = .idle
options = []
isRefreshingCatalog = false
catalogRefreshErrorKey = nil
configuredProductIDs = []
lastGrantedBalance = nil
historyPhase = .idle
isLoadingMoreHistory = false
historyLoadMoreErrorKey = nil
nextHistoryCursor = nil
state = .idle
}
/// Success is acknowledgement of the current purchase action, not durable
/// transaction history. Expire it even when a retained tab never disappears.
private func presentSuccess(credits: Int64) {
cancelSuccessDismissal()
state = .succeeded(credits: credits)
let expectedSessionID = sessionID
let duration = successMessageDuration
successDismissTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: duration)
} catch {
return
}
guard let self,
self.sessionID == expectedSessionID,
self.state == .succeeded(credits: credits) else {
return
}
self.state = .idle
self.successDismissTask = nil
}
}
private func cancelSuccessDismissal() {
successDismissTask?.cancel()
successDismissTask = nil
}
private func finishPrepare(requestID: UUID) {
guard prepareRequestID == requestID else { return }
prepareTask = nil
prepareRequestID = nil
isRefreshingCatalog = false
}
private func isCurrentSession(
accountID: UUID,
sessionID: UUID,
requestID: UUID? = nil
) -> Bool {
guard activeAccountID == accountID, self.sessionID == sessionID else {
return false
}
if let requestID {
return prepareRequestID == requestID
}
return true
}
private func loadProductsWithRetry(
productIDs: [String]
) async throws -> [AccountStoreProduct] {
let expectedIDs = Set(productIDs)
var lastLoaded: [AccountStoreProduct] = []
var lastError: Error?
let retryDelays: [Duration] = [.zero, .milliseconds(200), .milliseconds(600)]
for delay in retryDelays {
if delay != .zero {
try await Task.sleep(for: delay)
}
try Task.checkCancellation()
do {
let loaded = try await store.products(for: productIDs)
lastLoaded = loaded
if Set(loaded.map(\.id)).isSuperset(of: expectedIDs) {
return loaded
}
} catch {
lastError = error
}
}
if !lastLoaded.isEmpty {
return lastLoaded
}
if let lastError {
throw lastError
}
return []
}
private static func messageKey(for error: Error) -> String {
@@ -55,12 +55,25 @@ enum AccountStorePurchaseOutcome: Sendable {
@MainActor
protocol AccountCreditStore {
func product(for productID: String) async throws -> AccountStoreProduct?
func products(for productIDs: [String]) async throws -> [AccountStoreProduct]
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification>
func transactionUpdates() -> AsyncStream<AccountStoreVerification>
func allTransactions() -> AsyncStream<AccountStoreVerification>
}
extension AccountCreditStore {
func products(for productIDs: [String]) async throws -> [AccountStoreProduct] {
var loadedProducts: [AccountStoreProduct] = []
for productID in productIDs {
if let product = try await product(for: productID) {
loadedProducts.append(product)
}
}
return loadedProducts
}
}
@MainActor
final class LiveAccountCreditStore: AccountCreditStore {
private var productsByID: [String: Product] = [:]
@@ -70,6 +83,20 @@ final class LiveAccountCreditStore: AccountCreditStore {
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
}
func products(for productIDs: [String]) async throws -> [AccountStoreProduct] {
var seenIDs: Set<String> = []
let orderedIDs = productIDs.filter { seenIDs.insert($0).inserted }
let missingIDs = orderedIDs.filter { productsByID[$0] == nil }
if !missingIDs.isEmpty {
let loadedProducts = try await Product.products(for: missingIDs)
loadedProducts.forEach { productsByID[$0.id] = $0 }
}
return orderedIDs.compactMap { productID in
guard let product = productsByID[productID] else { return nil }
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
}
}
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome {
guard let product = try await loadProduct(for: productID) else {
return .success(.unverified)
@@ -102,10 +129,8 @@ final class LiveAccountCreditStore: AccountCreditStore {
if let product = productsByID[productID] {
return product
}
let loaded = try await Product.products(for: [productID])
guard let product = loaded.first(where: { $0.id == productID }) else { return nil }
productsByID[productID] = product
return product
_ = try await products(for: [productID])
return productsByID[productID]
}
private static func map(_ verification: VerificationResult<Transaction>) -> AccountStoreVerification {
@@ -268,6 +268,14 @@ protocol AccountSessionServicing: Sendable {
func clearManagedGateway() async
}
enum AccountSessionEvent: Sendable {
case expired
}
protocol AccountSessionEventSourcing: Sendable {
func events() async -> AsyncStream<AccountSessionEvent>
}
extension AccountSessionServicing {
func prepareManagedGateway() async throws {}
func clearManagedGateway() async {}
@@ -321,15 +329,18 @@ protocol ReferralProfileServicing: Sendable {
struct AccountDependencies: Sendable {
let sessionService: any AccountSessionServicing
let sessionEventSource: any AccountSessionEventSourcing
let centerService: any AccountCenterServicing
let referralService: any ReferralProfileServicing
init(
sessionService: any AccountSessionServicing,
sessionEventSource: (any AccountSessionEventSourcing)? = nil,
centerService: any AccountCenterServicing,
referralService: (any ReferralProfileServicing)? = nil
) {
self.sessionService = sessionService
self.sessionEventSource = sessionEventSource ?? UnavailableAccountSessionEventSource()
self.centerService = centerService
self.referralService = referralService ?? UnavailableReferralProfileService()
}
@@ -338,6 +349,7 @@ struct AccountDependencies: Sendable {
let service = UnavailableAccountService()
return AccountDependencies(
sessionService: service,
sessionEventSource: UnavailableAccountSessionEventSource(),
centerService: service,
referralService: UnavailableReferralProfileService()
)
@@ -396,6 +408,12 @@ private struct UnavailableReferralProfileService: ReferralProfileServicing {
}
}
private struct UnavailableAccountSessionEventSource: AccountSessionEventSourcing {
func events() async -> AsyncStream<AccountSessionEvent> {
AsyncStream { $0.finish() }
}
}
enum ReferralUniversalLink {
static let host = "osglab.com"
static let codeLength = 22
@@ -50,11 +50,14 @@ final class AccountSessionCoordinator: ObservableObject {
private let pendingReferralStore: any PendingReferralCodeStoring
private let analyticsClient: any AnalyticsClient
private let onAccountAuthenticated: (UUID) async -> Void
private let onAccountSignedOut: () async -> Void
private let onAccountDeleted: () async -> Void
private let accountRefreshInterval: TimeInterval
private let now: () -> Date
private var didAttemptRestore = false
private var accountRefreshTask: Task<Void, Never>?
private var accountRefreshRequestID: UUID?
private var sessionEventsTask: Task<Void, Never>?
init(
dependencies: AccountDependencies,
@@ -67,6 +70,7 @@ final class AccountSessionCoordinator: ObservableObject {
now: @escaping () -> Date = Date.init,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
onAccountSignedOut: @escaping () async -> Void = {},
onAccountDeleted: @escaping () async -> Void = {}
) {
sessionService = dependencies.sessionService
@@ -83,10 +87,24 @@ final class AccountSessionCoordinator: ObservableObject {
self.pendingReferralStore = pendingReferralStore
self.analyticsClient = analyticsClient
self.onAccountAuthenticated = onAccountAuthenticated
self.onAccountSignedOut = onAccountSignedOut
self.onAccountDeleted = onAccountDeleted
self.accountRefreshInterval = accountRefreshInterval
self.now = now
pendingReferralCode = pendingReferralStore.code
let sessionEventSource = dependencies.sessionEventSource
sessionEventsTask = Task { @MainActor [weak self] in
let events = await sessionEventSource.events()
for await event in events {
guard !Task.isCancelled else { return }
await self?.handleSessionEvent(event)
}
}
}
deinit {
accountRefreshTask?.cancel()
sessionEventsTask?.cancel()
}
var isSignedIn: Bool {
@@ -109,15 +127,20 @@ final class AccountSessionCoordinator: ObservableObject {
do {
guard let session = try await sessionService.restoreSession() else {
await sessionService.clearManagedGateway()
await onAccountSignedOut()
sessionPhase = .signedOut
return
}
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
} catch {
await sessionService.clearManagedGateway()
await onAccountSignedOut()
sessionPhase = .signedOut
operationErrorKey = errorMessageKey(
for: error,
@@ -161,6 +184,7 @@ final class AccountSessionCoordinator: ObservableObject {
let session = try await sessionService.signIn(with: payload)
await onAccountAuthenticated(session.accountID)
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
referralProfile.startSession(accountID: session.accountID)
await redeemPendingReferralIfNeeded()
await refreshAccountData(force: true)
@@ -196,13 +220,18 @@ final class AccountSessionCoordinator: ObservableObject {
snapshotPhase = .loading
}
let requestID = UUID()
accountRefreshRequestID = requestID
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.performAccountRefresh(for: accountID)
await self.performAccountRefresh(
for: accountID,
requestID: requestID
)
}
accountRefreshTask = task
await task.value
accountRefreshTask = nil
finishAccountRefresh(requestID: requestID)
}
func updateDisplayName(_ displayName: String) async -> Bool {
@@ -263,7 +292,8 @@ final class AccountSessionCoordinator: ObservableObject {
do {
try await sessionService.signOut()
creditPurchases.reset()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut
@@ -286,7 +316,8 @@ final class AccountSessionCoordinator: ObservableObject {
do {
try await sessionService.deleteAccount(with: payload)
await onAccountDeleted()
creditPurchases.reset()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession(removeCache: true)
sessionPhase = .signedOut
@@ -309,12 +340,12 @@ final class AccountSessionCoordinator: ObservableObject {
operationErrorKey = nil
}
private func performAccountRefresh(for accountID: UUID) async {
private func performAccountRefresh(for accountID: UUID, requestID: UUID) async {
isRefreshingAccountData = true
accountRefreshErrorKey = nil
defer { isRefreshingAccountData = false }
do {
try Task.checkCancellation()
let cachedSnapshot: AccountCenterSnapshot?
if case let .loaded(snapshot) = snapshotPhase {
cachedSnapshot = snapshot
@@ -324,15 +355,22 @@ final class AccountSessionCoordinator: ObservableObject {
let snapshot = try await centerService.loadAccountCenter(
cachedSnapshot: cachedSnapshot
)
try Task.checkCancellation()
guard self.accountID == accountID,
accountRefreshRequestID == requestID,
snapshot.account.accountID == accountID else {
return
}
snapshotPhase = .loaded(snapshot)
sessionPhase = .signedIn(snapshot.account)
lastAccountRefreshAt = now()
} catch is CancellationError {
return
} catch {
guard self.accountID == accountID else { return }
guard self.accountID == accountID,
accountRefreshRequestID == requestID else {
return
}
let messageKey = errorMessageKey(
for: error,
fallback: "account.error.load"
@@ -348,11 +386,34 @@ final class AccountSessionCoordinator: ObservableObject {
private func clearAccountRefreshState() {
accountRefreshTask?.cancel()
accountRefreshTask = nil
accountRefreshRequestID = nil
lastAccountRefreshAt = nil
isRefreshingAccountData = false
accountRefreshErrorKey = nil
}
private func finishAccountRefresh(requestID: UUID) {
guard accountRefreshRequestID == requestID else { return }
accountRefreshTask = nil
accountRefreshRequestID = nil
isRefreshingAccountData = false
}
private func handleSessionEvent(_ event: AccountSessionEvent) async {
switch event {
case .expired:
guard isSignedIn else { return }
await sessionService.clearManagedGateway()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
sessionPhase = .signedOut
snapshotPhase = .idle
operationErrorKey = "account.error.sessionExpired"
}
}
private func redeemPendingReferralIfNeeded() async {
guard isSignedIn, let code = pendingReferralStore.code else { return }
@@ -2,7 +2,8 @@
// OSGKeyboard · Main App
//
// Adapts the host-private account protocol to the account-center UI. Account
// sessions never enter App Group storage; only scope-limited gateway grants do.
// session credentials never enter App Group storage; only a non-secret local
// eligibility marker and scope-limited gateway grants are shared.
import Foundation
import OSGKeyboardHostSupport
@@ -40,6 +41,7 @@ enum LiveAccountDependencyFactory {
)
return AccountDependencies(
sessionService: service,
sessionEventSource: service,
centerService: service,
referralService: LiveReferralProfileService(apiClient: apiClient)
)
@@ -162,7 +164,10 @@ actor LiveReferralProfileService: ReferralProfileServicing {
}
}
private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing {
private actor LiveAccountService:
AccountSessionServicing,
AccountSessionEventSourcing,
AccountCenterServicing {
private let apiClient: AccountAPIClient
private let accountCenterLoader: AccountCenterSnapshotLoader
private let integrity: DeviceIntegrityCoordinator
@@ -187,14 +192,37 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
self.configuration = configuration
}
func events() async -> AsyncStream<AccountSessionEvent> {
let invalidations = await apiClient.sessionInvalidations()
return AsyncStream { continuation in
let task = Task {
for await invalidation in invalidations {
switch invalidation {
case .expired:
await self.invalidateManagedGatewaySession()
continuation.yield(.expired)
}
}
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
func restoreSession() async throws -> AccountSession? {
guard let cachedSession = try await apiClient.currentSession() else { return nil }
guard let cachedSession = try await apiClient.currentSession() else {
await invalidateManagedGatewaySession()
return nil
}
let account: OSGAccount?
do {
account = try await apiClient.account()
} catch let error as AccountAPIError {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
await invalidateManagedGatewaySession()
return nil
default:
account = nil
@@ -203,6 +231,7 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
account = nil
}
let session = try await apiClient.currentSession() ?? cachedSession
configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant()
return uiSession(
session,
@@ -253,7 +282,6 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
)
throw error
}
await synchronizeManagedGrant()
let account: OSGAccount
do {
account = try await apiClient.account()
@@ -264,6 +292,8 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
)
throw error
}
configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant()
OSGDiag.log("signIn completed", category: "account")
return uiSession(
session,
@@ -273,16 +303,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
}
func signOut() async throws {
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
await revokeManagedGrantIfPossible()
do {
try await apiClient.logout()
} catch {
try? await grants.clearGrant()
configuration.setCredentialSource(.byok)
throw error
}
try? await grants.clearGrant()
configuration.setCredentialSource(.byok)
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
@@ -293,13 +323,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
)
try? await grants.clearGrant()
await integrity.clearLocalKeyState()
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
}
func prepareManagedGateway() async throws {
guard try await apiClient.currentSession() != nil else {
await invalidateManagedGatewaySession()
throw AccountAPIError.sessionUnavailable
}
configuration.setManagedGatewayAccountSessionAvailable(true)
try await ensureManagedGrant()
}
@@ -462,6 +495,14 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
try? await grants.clearGrant()
}
private func invalidateManagedGatewaySession() async {
// Flip the local gate before any await so the extension immediately
// loses access to cached account grants.
configuration.setManagedGatewayAccountSessionAvailable(false)
configuration.setCredentialSource(.byok)
try? await grants.clearGrant()
}
private func uiSession(
_ session: OSGKeyboardHostSupport.AccountSession,
createdAtEpochSeconds: Int64,
+47
View File
@@ -0,0 +1,47 @@
// AppTab.swift
// OSGKeyboard · Main App
//
// Shared top-level destinations for the native iPhone TabView and iPad sidebar.
import SwiftUI
enum AppTab: Int, CaseIterable {
case keyboard
case skills
case styles
case settings
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
}
/// Native phone tab symbol: outline when idle, fill when selected.
/// `wand.and.sparkles` has no `.fill` pair.
func dockSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
var sidebarTitle: LocalizedStringKey { accessibilityKey }
/// iPad sidebar. Home stays `house` in both states; other tabs follow
/// the same outline / fill pairing as the phone tab bar.
func sidebarSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return "house"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
}
@@ -1,119 +0,0 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar icon + label for Home / Skills / Styles / Settings.
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
// fill inside that capsule (Photos-style), not a second glass layer.
// History + dictionary live as Home cards (not dock tabs).
import OSGKeyboardShared
import SwiftUI
enum AppTab: Int, CaseIterable {
case keyboard
case skills
case styles
case settings
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
}
/// Phone dock: outline when idle, fill when selected (system tab-bar
/// convention). `wand.and.sparkles` has no `.fill` pair.
func dockSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
var sidebarTitle: LocalizedStringKey { accessibilityKey }
/// iPad sidebar. Home stays `house` in both states; other tabs follow
/// the same outline / fill pairing as the phone dock.
func sidebarSystemImage(selected: Bool) -> String {
switch self {
case .keyboard: return "house"
case .skills: return "wand.and.sparkles"
case .styles: return selected ? "dial.high.fill" : "dial.high"
case .settings: return selected ? "gearshape.fill" : "gearshape"
}
}
}
struct MinimalTabBar: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@Namespace private var selectionNamespace
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
ForEach(AppTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(Motion.soft) {
selection = tab
}
} label: {
VStack(spacing: 2) {
Image(systemName: tab.dockSystemImage(selected: selection == tab))
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
Text(tab.accessibilityKey)
.font(TypeStyle.caption2)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.foregroundStyle(tabIconColor(for: tab))
.frame(maxWidth: .infinity)
.frame(height: TabBarDockMetrics.itemHeight)
.background {
if selection == tab {
// Stretch into the dock padding so top/bottom
// leftover matches the side leftover (~5 pt).
Capsule()
.fill(palette.accentMuted)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, TabBarDockMetrics.selectionInset)
.padding(
.vertical,
TabBarDockMetrics.selectionInset
- TabBarDockMetrics.dockInsetVertical
)
.matchedGeometryEffect(
id: "main-tab-selection",
in: selectionNamespace
)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.accessibilityKey)
.accessibilityAddTraits(selection == tab ? .isSelected : [])
}
}
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
.glassEffect(.regular.interactive(), in: .capsule)
.frame(maxWidth: 360)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, TabBarDockMetrics.bottomPadding)
}
private func tabIconColor(for tab: AppTab) -> Color {
if selection == tab { return palette.accent }
// dock
return colorScheme == .dark
? Color(white: 0.76)
: palette.textSecondary
}
}
@@ -1,91 +1,30 @@
// TabBarVisibility.swift
// OSGKeyboard · Main App
//
// Push NavigationStack tab TabView
// MainTabView `TabBarHiddenPreferenceKey` `hidesTabBarWhenPushed()`
// Push NavigationStack tab
// `hidesTabBarWhenPushed()` TabView
import OSGKeyboardShared
import SwiftUI
// MARK: - Preference
enum TabBarHiddenPreferenceKey: PreferenceKey {
static let defaultValue = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = value || nextValue()
}
}
// MARK: - Environment
private enum TabBarVisibleEnvironmentKey: EnvironmentKey {
static let defaultValue = true
}
extension EnvironmentValues {
/// `false` when the custom dock is hidden (detail push / sheet over tab content).
var isTabBarVisible: Bool {
get { self[TabBarVisibleEnvironmentKey.self] }
set { self[TabBarVisibleEnvironmentKey.self] = newValue }
}
}
// MARK: - Modifiers
extension View {
/// Marks this view as a pushed detail screen so `MainTabView` hides the dock.
/// Marks this view as a pushed detail screen so the native tab bar hides.
func hidesTabBarWhenPushed() -> some View {
preference(key: TabBarHiddenPreferenceKey.self, value: true)
toolbar(.hidden, for: .tabBar)
}
/// Bottom inset for scroll *content* above the floating dock (ScrollView inner stacks).
/// Adds ordinary footer breathing room; native TabView owns tab-bar clearance.
func tabBarScrollBottomPadding() -> some View {
modifier(TabBarScrollBottomPaddingModifier())
padding(.bottom, Spacing.lg)
}
/// Bottom scroll-content margin for `List` tab roots. Unlike padding on the list
/// container, this extends the scrollable area so rows can scroll above the dock.
/// Adds a scrollable footer margin to `List` content above the system tab bar.
func tabBarListScrollBottomMargin() -> some View {
modifier(TabBarListScrollBottomMarginModifier())
}
}
enum TabBarDockMetrics {
static let itemHeight: CGFloat = 46
static let iconSize: CGFloat = 22
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~34×
/// the 5 pt top/bottom gap.
static let dockInsetHorizontal: CGFloat = 0
static let dockInsetVertical: CGFloat = Spacing.xs
/// Gap between the selected fill and the glass dock / neighbouring tabs.
static let selectionInset: CGFloat = 5
static let bottomPadding: CGFloat = Spacing.xs
/// Clearance above the floating dock (bar + home-indicator slack).
static var scrollClearance: CGFloat {
itemHeight + dockInsetVertical * 2 + bottomPadding + 20
}
}
private struct TabBarScrollBottomPaddingModifier: ViewModifier {
@Environment(\.isTabBarVisible) private var isTabBarVisible
func body(content: Content) -> some View {
content.padding(
contentMargins(
.bottom,
isTabBarVisible ? TabBarDockMetrics.scrollClearance : Spacing.lg
)
}
}
private struct TabBarListScrollBottomMarginModifier: ViewModifier {
@Environment(\.isTabBarVisible) private var isTabBarVisible
func body(content: Content) -> some View {
content.contentMargins(
.bottom,
isTabBarVisible ? TabBarDockMetrics.scrollClearance : Spacing.lg,
Spacing.lg,
for: .scrollContent
)
}
+135 -53
View File
@@ -108,7 +108,11 @@ struct HomeView: View {
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var path = NavigationPath()
@State private var dictionaryPreviewEntries: [PersonalDictionary.Entry] = []
@State private var dictionarySuggestions: [RimeFrequentTerm] = []
@State private var pendingDictionarySuggestion: RimeFrequentTerm?
private let appGroupStore = AppGroupStore()
private let rimeFrequentTermStore = RimeFrequentTermStore()
private var usesWideLayout: Bool {
horizontalSizeClass == .regular
@@ -189,23 +193,49 @@ struct HomeView: View {
}
.onAppear {
refreshPermissionStatuses()
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
refreshPermissionStatuses()
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshPermissionStatuses()
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.onChange(of: path.count) { _, count in
guard count == 0 else { return }
refreshDictionaryPreview()
refreshDictionarySuggestions()
}
.alert(
pendingDictionarySuggestion.map {
AppL10n.format("home.card.dictionary.confirm.title", $0.term)
} ?? "",
isPresented: Binding(
get: { pendingDictionarySuggestion != nil },
set: { isPresented in
if !isPresented {
pendingDictionarySuggestion = nil
}
}
),
presenting: pendingDictionarySuggestion
) { suggestion in
Button("home.card.dictionary.confirm.add") {
addSuggestedTerm(suggestion)
}
Button("common.cancel", role: .cancel) {}
} message: { suggestion in
Text(
AppL10n.format(
"home.card.dictionary.confirm.message",
suggestion.commitCount
)
)
}
}
@@ -305,8 +335,8 @@ struct HomeView: View {
/// and the body grows with its rows.
private var homeLibrarySection: some View {
VStack(spacing: Spacing.md) {
historyCard
dictionaryCard
historyCard
}
}
@@ -344,33 +374,80 @@ struct HomeView: View {
}
private var dictionaryCard: some View {
homeLibraryCard(
titleKey: "settings.personalDictionary.title",
systemImage: "character.book.closed",
route: .dictionary
) {
if dictionaryPreviewEntries.isEmpty {
libraryEmptyLine("home.card.dictionary.empty")
VStack(alignment: .leading, spacing: Spacing.sm) {
Button {
path.append(HomeRoute.dictionary)
} label: {
HStack(spacing: Spacing.xs) {
Image(systemName: "sparkles")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
Text("settings.personalDictionary.title")
.font(.system(size: 13, weight: .semibold))
.tracking(0.6)
.textCase(.uppercase)
.foregroundStyle(palette.textTertiary)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if dictionarySuggestions.isEmpty {
libraryEmptyLine("home.card.dictionary.smart.empty")
} else {
VStack(spacing: 0) {
ForEach(Array(dictionaryPreviewEntries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { libraryRowDivider }
VStack(alignment: .leading, spacing: 2) {
Text(entry.term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text(dictionaryDetailLine(for: entry))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
LazyVGrid(
columns: [
GridItem(.adaptive(minimum: 88, maximum: 160), spacing: Spacing.sm)
],
alignment: .leading,
spacing: Spacing.sm
) {
ForEach(dictionarySuggestions) { suggestion in
Button {
pendingDictionarySuggestion = suggestion
} label: {
Text(suggestion.term)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.accent)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.horizontal, Spacing.sm)
.frame(maxWidth: .infinity, minHeight: 38)
.background(
LinearGradient(
colors: [
palette.accent.opacity(0.16),
palette.accent.opacity(0.07)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: Capsule()
)
.overlay {
Capsule()
.stroke(palette.accent.opacity(0.22), lineWidth: 0.5)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.sm)
.buttonStyle(.plain)
.accessibilityHint(
AppL10n.format(
"home.card.dictionary.smart.accessibilityHint",
suggestion.commitCount
)
)
}
}
}
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.surfaceCard()
}
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
@@ -424,37 +501,40 @@ struct HomeView: View {
.padding(.vertical, Spacing.xs)
}
/// Source · uses · aliases same secondary line as the dictionary page rows.
private func dictionaryDetailLine(for entry: PersonalDictionary.Entry) -> String {
var parts = [SharedL10n.string(entry.source.labelKey, language: config.uiLanguage)]
if entry.usageCount > 1 {
let format = AppL10n.string(
"settings.personalDictionary.usageCount",
language: config.uiLanguage
)
parts.append(String(format: format, entry.usageCount))
}
if !entry.aliases.isEmpty {
parts.append(entry.aliases.joined(separator: " / "))
}
return parts.joined(separator: " · ")
}
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
}
private func refreshDictionaryPreview() {
dictionaryPreviewEntries = AppGroupStore().personalDictionary.entries
.sorted { lhs, rhs in
if lhs.updatedAt != rhs.updatedAt {
return lhs.updatedAt > rhs.updatedAt
}
return lhs.usageCount > rhs.usageCount
}
.prefix(Self.libraryPreviewLimit)
.map { $0 }
private func refreshDictionarySuggestions() {
let dictionary = appGroupStore.personalDictionary
let suggestions = rimeFrequentTermStore.suggestions(
excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
limit: Self.dictionarySuggestionLimit
)
dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount
? suggestions
: []
}
private func addSuggestedTerm(_ suggestion: RimeFrequentTerm) {
var dictionary = appGroupStore.personalDictionary
guard let saved = dictionary.upsertManual(term: suggestion.term),
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else {
return
}
dictionary.entries[index].usageCount = max(
dictionary.entries[index].usageCount,
suggestion.commitCount
)
dictionary.version += 1
appGroupStore.setPersonalDictionary(dictionary)
pendingDictionarySuggestion = nil
refreshDictionarySuggestions()
Task {
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
}
}
private func handlePermissionGuidanceAction() {
@@ -672,6 +752,8 @@ struct HomeView: View {
/// Rows shown inside the history / dictionary preview cards.
private static let libraryPreviewLimit = 3
private static let minimumDictionarySuggestionCount = 3
private static let dictionarySuggestionLimit = 5
private static let previewTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
+68 -5
View File
@@ -9,6 +9,14 @@ import OSGKeyboardHostSupport
import OSGKeyboardShared
import SwiftUI
enum AnalyticsFirstOpenAttribution {
static let ordinaryLaunch: AnalyticsAcquisitionChannel = .appStoreOrganic
static func trustedChannel(for url: URL) -> AnalyticsAcquisitionChannel? {
ReferralUniversalLink.code(from: url) == nil ? nil : .referral
}
}
struct MainAppRoot: View {
@Environment(\.scenePhase) private var scenePhase
@@ -21,7 +29,9 @@ struct MainAppRoot: View {
@StateObject private var accountSession: AccountSessionCoordinator
@State private var clmWarmupTask: Task<Void, Never>?
@State private var rimeStartupTask: Task<Void, Never>?
@State private var firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown
@State private var firstOpenAcquisitionChannel =
AnalyticsFirstOpenAttribution.ordinaryLaunch
@State private var newContactDraft: NewContactDraft?
init(accountDependencies: AccountDependencies? = nil) {
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
@@ -34,8 +44,18 @@ struct MainAppRoot: View {
dependencies: resolvedDependencies,
analyticsClient: analytics.client,
onAccountAuthenticated: { accountID in
AppGroupStore().setManagedGatewayAccountSessionAvailable(true)
await analytics.observeAuthenticatedAccount(accountID)
},
onAccountSignedOut: {
// Revoke the local choice before exposing signed-out UI.
// BYOK then remains the only possible AI execution path.
AppGroupStore().setManagedGatewayAccountSessionAvailable(false)
let config = ProviderConfig.shared
if config.credentialSource != .byok {
config.credentialSource = .byok
}
},
onAccountDeleted: {
await analytics.handleAccountDeletion()
}
@@ -69,6 +89,11 @@ struct MainAppRoot: View {
ReleaseNotesSheet(language: config.uiLanguage)
.environment(\.locale, config.uiLanguage.swiftUILocale)
}
.sheet(item: $newContactDraft) { draft in
NewContactSheet(phoneNumber: draft.phoneNumber) {
newContactDraft = nil
}
}
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
// Register the URL handler BEFORE the foreground auto-start.
@@ -84,12 +109,18 @@ struct MainAppRoot: View {
analytics.prepare(
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
)
if scenePhase == .active {
analytics.appDidBecomeActive()
}
OSGDiag.log(
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
category: "flow"
)
AppCloudSync.shared.startObservingExternalChanges()
if scenePhase == .active {
refreshOfficialSkillCatalog(reason: "MainAppRoot.onAppear")
}
// Heavy work (Flow / CLM / Rime) only after onboarding. Doing it
// earlier jetsams the host (~150 MB+) and the keyboard dies with it.
@@ -101,7 +132,9 @@ struct MainAppRoot: View {
if scenePhase == .active {
activateForegroundServices(reason: "MainAppRoot.onAppear")
AIHintRefreshService.refreshIfNeeded(reason: "MainAppRoot.onAppear")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
if newContactDraft == nil {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
} else {
OSGDiag.log(
"MainAppRoot.onAppear defer foreground services scene="
@@ -132,7 +165,9 @@ struct MainAppRoot: View {
if scenePhase == .active {
activateForegroundServices(reason: "onboardingCompleted")
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
if newContactDraft == nil {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
}
if accountSession.shouldPresentAccountCenter {
Task { @MainActor in
@@ -152,6 +187,8 @@ struct MainAppRoot: View {
flowManager.handleScenePhase(phase)
if phase == .active {
analytics.appDidBecomeActive()
} else if phase == .inactive {
analytics.appWillResignActive()
} else if phase == .background {
analytics.appDidEnterBackground()
}
@@ -163,10 +200,13 @@ struct MainAppRoot: View {
FlowSessionBridge.setHostHeavy(false)
return
}
refreshOfficialSkillCatalog(reason: "scenePhase.active")
if config.hasCompletedOnboarding {
activateForegroundServices(reason: "scenePhase.active")
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
if newContactDraft == nil {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
}
Task {
await AppCloudSync.shared.pullAllIfEnabled()
@@ -174,6 +214,17 @@ struct MainAppRoot: View {
}
}
private func refreshOfficialSkillCatalog(reason: String) {
Task {
let outcome = await OfficialSkillCatalogRefreshService.shared.refreshIfNeeded(
reason: reason
)
if outcome.didUpdateCache {
AIAgentSkillLayoutStore.shared.reload()
}
}
}
/// Starts foreground-only services once per active transition. Rime yields
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
private func activateForegroundServices(reason: String) {
@@ -270,7 +321,9 @@ struct MainAppRoot: View {
private func handleIncomingURL(_ url: URL) {
if accountSession.handleIncomingURL(url) {
firstOpenAcquisitionChannel = .referral
firstOpenAcquisitionChannel =
AnalyticsFirstOpenAttribution.trustedChannel(for: url)
?? AnalyticsFirstOpenAttribution.ordinaryLaunch
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
return
}
@@ -295,6 +348,16 @@ struct MainAppRoot: View {
SettingsDeepLink.setPending(.clipboard)
}
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
case "contact":
guard url.path.contains("new"),
let payload = AppGroupStore().consumePendingContactCreation() else {
return
}
releaseNotes.isPresented = false
Task { @MainActor in
await Task.yield()
newContactDraft = NewContactDraft(phoneNumber: payload.phoneNumber)
}
#if DEBUG
case "seed-demo":
DemoDataSeeder.seedRichPlaceholderData()
+2 -3
View File
@@ -25,9 +25,8 @@ struct MainSplitView: View {
}
.navigationSplitViewStyle(.balanced)
.background(palette.background)
// No floating dock in split mode child scroll views should not
// reserve bottom clearance for the phone tab bar.
.environment(\.isTabBarVisible, false)
// No floating tab bar in split mode; shared footer spacing remains
// useful for the final rows in each scroll view.
}
// MARK: - Sidebar
+36 -22
View File
@@ -7,10 +7,8 @@ import SwiftUI
struct MainTabView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var tab: AppTab = .keyboard
@State private var isTabBarHidden = false
private var usesSplitLayout: Bool {
horizontalSizeClass == .regular
@@ -37,29 +35,45 @@ struct MainTabView: View {
// MARK: - Phone layout
private var phoneTabLayout: some View {
ZStack(alignment: .bottom) {
palette.background.ignoresSafeArea()
TabView(selection: $tab) {
Tab(value: AppTab.keyboard) {
HomeView()
} label: {
tabLabel(for: .keyboard)
}
MainTabContent(tab: tab)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.isTabBarVisible, !isTabBarHidden)
.safeAreaInset(edge: .bottom, spacing: 0) {
if !isTabBarHidden {
// Match floating dock + home-indicator clearance so
// page footers / scroll ends sit above MinimalTabBar.
Color.clear.frame(height: TabBarDockMetrics.scrollClearance)
}
}
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
withAnimation(Motion.quick) {
isTabBarHidden = hidden
}
}
Tab(value: AppTab.skills) {
AIAgentSkillsView()
} label: {
tabLabel(for: .skills)
}
if !isTabBarHidden {
MinimalTabBar(selection: $tab)
.transition(.move(edge: .bottom).combined(with: .opacity))
Tab(value: AppTab.styles) {
PolishStylesView()
} label: {
tabLabel(for: .styles)
}
Tab(value: AppTab.settings) {
SettingsView(presentation: .tab)
} label: {
tabLabel(for: .settings)
}
}
.tint(palette.accent)
.tabBarMinimizeBehavior(.never)
// Native TabView owns the floating dock + home-indicator clearance so
// page footers / scroll ends only need their ordinary bottom spacing.
}
private func tabLabel(for item: AppTab) -> some View {
Label {
Text(item.accessibilityKey)
} icon: {
Image(systemName: item.dockSystemImage(selected: tab == item))
}
// TabView applies `.fill` to every SF Symbol by default. Opt out so
// unselected tabs keep their outline while the selected tab uses fill.
.environment(\.symbolVariants, .none)
}
}
+56
View File
@@ -0,0 +1,56 @@
// NewContactSheet.swift
// OSGKeyboard · Main App
//
// System-owned contact editor used after an explicit keyboard skill handoff.
import Contacts
import ContactsUI
import SwiftUI
struct NewContactDraft: Identifiable, Equatable {
let id = UUID()
let phoneNumber: String
}
struct NewContactSheet: UIViewControllerRepresentable {
let phoneNumber: String
let onComplete: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onComplete: onComplete)
}
func makeUIViewController(context: Context) -> UINavigationController {
let contact = CNMutableContact()
contact.phoneNumbers = [
CNLabeledValue(
label: CNLabelPhoneNumberMobile,
value: CNPhoneNumber(stringValue: phoneNumber)
)
]
let editor = CNContactViewController(forNewContact: contact)
editor.contactStore = CNContactStore()
editor.delegate = context.coordinator
return UINavigationController(rootViewController: editor)
}
func updateUIViewController(
_ uiViewController: UINavigationController,
context: Context
) {}
final class Coordinator: NSObject, CNContactViewControllerDelegate {
private let onComplete: () -> Void
init(onComplete: @escaping () -> Void) {
self.onComplete = onComplete
}
func contactViewController(
_ viewController: CNContactViewController,
didCompleteWith contact: CNContact?
) {
onComplete()
}
}
}
File diff suppressed because it is too large Load Diff
+153
View File
@@ -11,6 +11,7 @@ import SwiftUI
struct PolishStylesView: View {
@Environment(\.themePalette) private var palette
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var history = SpeechHistoryStore.shared
@State private var catalog = AppGroupStore().polishStyleCatalog
@State private var activeID = AppGroupStore().activePolishStyleId
@@ -19,6 +20,7 @@ struct PolishStylesView: View {
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
@State private var errorMessage: String?
@State private var isGeneratingLearnedStyle = false
private let store = AppGroupStore()
private let columns = [
@@ -30,6 +32,7 @@ struct PolishStylesView: View {
NavigationStack {
ScrollView {
CardPageContent(spacing: Spacing.xl) {
styleLearningCard
packGridSection(
title: "polishStyles.builtin.section",
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
@@ -98,6 +101,121 @@ struct PolishStylesView: View {
}
}
private var styleLearningCorpus: PolishStyleLearningCorpus {
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
}
private var styleLearningCard: some View {
let corpus = styleLearningCorpus
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
let isActionAvailable = corpus.isReady && !reachedLimit
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
return VStack(alignment: .leading, spacing: Spacing.md) {
HStack(alignment: .top, spacing: Spacing.md) {
Image(systemName: "waveform")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 42, height: 42)
.background(
palette.accentMuted,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
VStack(alignment: .leading, spacing: 3) {
Text("polishStyles.learn.title")
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text("polishStyles.learn.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
ProgressView(
value: Double(min(corpus.effectiveCharacterCount, required)),
total: Double(required)
)
.tint(palette.accent)
HStack {
Text(
AppL10n.format(
"polishStyles.learn.progress",
Int64(corpus.effectiveCharacterCount),
Int64(required)
)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Spacer()
Text(
corpus.isReady
? AppL10n.string("polishStyles.learn.ready")
: AppL10n.format(
"polishStyles.learn.remaining",
Int64(corpus.remainingCharacterCount)
)
)
.font(TypeStyle.caption2)
.foregroundStyle(corpus.isReady ? palette.accent : palette.textTertiary)
}
Button {
generateLearnedStyle(from: corpus)
} label: {
HStack(spacing: Spacing.sm) {
if isGeneratingLearnedStyle {
ProgressView()
.controlSize(.small)
.tint(isActionAvailable ? palette.textOnAccent : palette.textSecondary)
} else {
Image(systemName: "sparkles")
}
Text(
isGeneratingLearnedStyle
? AppL10n.string("polishStyles.learn.generating")
: AppL10n.string("polishStyles.learn.action")
)
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(
isActionAvailable ? palette.textOnAccent : palette.textSecondary
)
.frame(maxWidth: .infinity, minHeight: 48)
.background(
isActionAvailable ? palette.accent : palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(
isActionAvailable ? Color.clear : palette.dividerStrong,
lineWidth: 0.5
)
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!canGenerate)
Text(
reachedLimit
? AppL10n.string("polishStyles.learn.limit")
: AppL10n.string("polishStyles.learn.privacy")
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.lg)
.surfaceCard()
}
private func packGridSection(
title: LocalizedStringKey,
packs: [PolishStylePack]
@@ -176,6 +294,26 @@ struct PolishStylesView: View {
}
}
private func generateLearnedStyle(from corpus: PolishStyleLearningCorpus) {
guard corpus.isReady, !isGeneratingLearnedStyle else { return }
isGeneratingLearnedStyle = true
Task {
defer { isGeneratingLearnedStyle = false }
do {
let generated = try await PolishStyleLearningService(store: store)
.generateStyle(
from: corpus,
outputLanguage: config.uiLanguage
)
// Always let the user inspect and edit the learned prompt before
// it is saved, synced, or made active.
editingPack = generated
} catch {
errorMessage = localizedLearningError(error)
}
}
}
private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey {
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
switch pack.id {
@@ -255,6 +393,21 @@ struct PolishStylesView: View {
}
}
private func localizedLearningError(_ error: Error) -> String {
switch error as? PolishStyleLearningError {
case .insufficientCorpus:
return AppL10n.string("polishStyles.learn.error.insufficient")
case .invalidResponse:
return AppL10n.string("polishStyles.learn.error.invalidResponse")
case .promptTooLong:
return AppL10n.string("polishStyles.learn.error.promptTooLong")
case .requestTooLarge:
return AppL10n.string("polishStyles.learn.error.requestTooLarge")
case nil:
return AppL10n.string("polishStyles.learn.error.request")
}
}
private func localized(_ error: Error) -> String {
switch error as? PolishStyleValidationError {
case .emptyName: return AppL10n.string("polishStyles.error.emptyName")
+1
View File
@@ -2,3 +2,4 @@
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure.";
"NSContactsUsageDescription" = "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.";
+117 -37
View File
@@ -46,47 +46,76 @@
"onboarding.experience.privacy.cloud.body" = "Audio or text is sent only when you choose cloud recognition or an AI feature, and only to the configured service.";
"onboarding.experience.privacy.anywhere.title" = "Your AI Agent, in any app";
"onboarding.experience.privacy.anywhere.body" = "Dictate, translate, edit, and act from chat, email, notes, or code.";
"onboarding.experience.permissions.title" = "Let OSGKeyboard hear you";
"onboarding.experience.permissions.title" = "Let us hear you";
"onboarding.experience.permissions.subtitle" = "Voice input needs microphone and speech recognition access. Two system prompts will appear next.";
"onboarding.experience.permissions.systemHint" = "These permissions are used only when you start voice input.";
"onboarding.experience.permissions.later" = "Set Up Later";
"onboarding.experience.keyboard.title" = "Add OSGKeyboard";
"onboarding.experience.keyboard.title" = "Add the keyboard";
"onboarding.experience.keyboard.subtitle" = "Add the keyboard in iOS Settings and turn on Allow Full Access.";
"onboarding.experience.keyboard.ready" = "OSGKeyboard and Full Access are ready";
"onboarding.experience.keyboard.fullAccess" = "Full Access connects the keyboard to the host app microphone and optional AI services";
"onboarding.experience.keyboard.openAgain" = "Open Settings Again";
"onboarding.experience.keyboard.practiceAction" = "Try It Now";
"onboarding.experience.keyboard.previewTitle" = "Keyboards";
"onboarding.experience.practice.skip" = "Try Later";
"onboarding.experience.practice.sample" = "“Ill arrive tomorrow afternoon and message you when Im there.”";
"onboarding.experience.keyboardSwitch.title" = "Switch keyboards";
"onboarding.experience.keyboardSwitch.subtitle" = "Once the keyboard is active, well automatically verify Full Access.";
"onboarding.experience.keyboardSwitch.instruction" = "Tap the field below, hold the globe key, then choose OSGKeyboard.";
"onboarding.experience.keyboardSwitch.placeholder" = "Switch and verify the keyboard here";
"onboarding.experience.keyboardSwitch.waiting" = "Waiting for OSGKeyboard…";
"onboarding.experience.keyboardSwitch.ready" = "OSGKeyboard and Full Access are verified";
"onboarding.experience.keyboardSwitch.fullAccessMissing" = "OSGKeyboard appeared, but Full Access has not been detected";
"onboarding.experience.keyboardSwitch.timeout" = "OSGKeyboard has not appeared yet. Make sure you switched keyboards.";
"onboarding.experience.keyboardSwitch.checkSettings" = "Check Full Access in Settings";
"onboarding.experience.keyboardSwitch.waitingAction" = "Waiting for Verification";
"onboarding.experience.practice.skip" = "Skip for Now";
"onboarding.experience.practice.sample" = "Ill arrive tomorrow afternoon and message you when Im there.";
"onboarding.experience.practice.editorTitle" = "New Message";
"onboarding.experience.practice.permissionsTitle" = "Voice access is still needed";
"onboarding.experience.practice.permissionsTitle" = "Enable voice access";
"onboarding.experience.practice.permissionsSubtitle" = "Go back to finish permission setup, then try your first voice input.";
"onboarding.experience.practice.switchTitle" = "Switch to OSGKeyboard";
"onboarding.experience.practice.switchTitle" = "Switch keyboards";
"onboarding.experience.practice.switchSubtitle" = "Tap the field below, hold the globe key, then choose OSGKeyboard.";
"onboarding.experience.practice.fullAccessTitle" = "One more setting";
"onboarding.experience.practice.fullAccessTitle" = "Enable Full Access";
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard is enabled. Turn on Allow Full Access to use voice input.";
"onboarding.experience.practice.fullAccessAction" = "Turn On Full Access";
"onboarding.experience.practice.signInTitle" = "Sign in for a free AI writing trial";
"onboarding.experience.practice.signInSubtitle" = "The lesson uses OSG Credits by default. This OOBE request will not deduct any credits.";
"onboarding.experience.practice.signInBody" = "Sign in with Apple to claim one free OOBE AI polish. Speech is still transcribed on your device.";
"onboarding.experience.practice.cloudTitle" = "Enable the free OOBE experience";
"onboarding.experience.practice.cloudSubtitle" = "Only this transcript is sent to OSG managed AI and explicitly tagged as OOBE.";
"onboarding.experience.practice.cloudBody" = "The server verifies this lesson as OOBE, with no credit reservation or charge. OSG Credits becomes your default AI service afterward.";
"onboarding.experience.practice.cloudAction" = "Agree & Try for Free";
"onboarding.experience.practice.creditsTitle" = "Preparing OSG Credits";
"onboarding.experience.practice.creditsSubtitle" = "This OOBE lesson is completely free.";
"onboarding.experience.practice.preparingCredits" = "Preparing your free OOBE request…";
"onboarding.experience.practice.creditsFailed" = "OSG Credits is temporarily unavailable. Try again—no credits were deducted.";
"onboarding.experience.practice.speakTitle" = "Now, say something";
"onboarding.experience.practice.speakSubtitle" = "Tap the microphone on the keyboard, speak, then tap it again. Your text will appear here.";
"onboarding.experience.complete.verifiedTitle" = "Thats it.";
"onboarding.experience.practice.cloudTitle" = "Start the trial";
"onboarding.experience.practice.cloudSubtitle" = "All four lessons work without an account or credits.";
"onboarding.experience.practice.cloudBody" = "Speech is transcribed on-device. Only the transcript, lesson sample, and question needed for these exercises are sent to OSG managed AI. The anonymous lesson credential expires when onboarding ends.";
"onboarding.experience.practice.cloudAction" = "Agree & Start";
"onboarding.experience.practice.creditsTitle" = "Getting ready";
"onboarding.experience.practice.creditsSubtitle" = "Verifying the app and obtaining a short-lived, restricted lesson credential.";
"onboarding.experience.practice.preparingCredits" = "Securely connecting to OSG AI…";
"onboarding.experience.practice.creditsFailed" = "The anonymous trial is temporarily unavailable. Check your connection and try again. You will not be charged.";
"onboarding.experience.practice.retry" = "Try Again";
"onboarding.experience.practice.progress.voice" = "1 of 4 · Voice Input";
"onboarding.experience.practice.progress.translate" = "2 of 4 · AI Translation";
"onboarding.experience.practice.progress.reply" = "3 of 4 · AI Reply";
"onboarding.experience.practice.progress.askAI" = "4 of 4 · Ask AI";
"onboarding.experience.practice.speakTitle" = "Read this aloud";
"onboarding.experience.practice.speakSubtitle" = "Tap the keyboard microphone, read the sentence below, then tap it again.";
"onboarding.experience.practice.readAloud" = "Read aloud";
"onboarding.experience.practice.voiceSample" = "Today is Thursday. Where are you planning to go for fun?";
"onboarding.experience.practice.translateTitle" = "Try Translate";
"onboarding.experience.practice.translateSubtitle" = "Copy the sample below, tap Translate in OSGKeyboard, then insert the result into the field.";
"onboarding.experience.practice.replyTitle" = "Try Reply";
"onboarding.experience.practice.replySubtitle" = "Use the same sample, tap Reply in the keyboard, then insert the generated response.";
"onboarding.experience.practice.askAITitle" = "Ask AI";
"onboarding.experience.practice.askAISubtitle" = "Hold the keyboard microphone and ask a question. Tap it again to stop recording and wait for the answer, then insert the result.";
"onboarding.experience.practice.copyAction" = "Copy Sample";
"onboarding.experience.practice.copied" = "Copied. Switch to OSGKeyboard.";
"onboarding.experience.practice.waiting" = "Waiting for you to complete this step in the keyboard…";
"onboarding.experience.login.title" = "Sign in for 1,000 credits";
"onboarding.experience.login.subtitle" = "Youve completed all four lessons. Sign in to keep using OSG managed AI without entering an API key.";
"onboarding.experience.login.body" = "If this device is eligible for the first-time signup reward, 1,000 credits are added automatically. Device integrity checks prevent duplicate claims.";
"onboarding.experience.login.skip" = "Not Now";
"onboarding.experience.login.signedIn" = "Signed in. Your credits are up to date.";
"onboarding.experience.login.balance %lld" = "Current balance: %lld credits";
"onboarding.experience.complete.verifiedTitle" = "Youre ready";
"onboarding.experience.complete.title" = "Youre ready";
"onboarding.experience.complete.subtitle" = "Switch to OSGKeyboard in any text field to speak, write, or hand off the next step to your AI Agent.";
"onboarding.experience.complete.subtitle" = "Tap Start Using";
"onboarding.experience.complete.dictate" = "Voice to Text";
"onboarding.experience.complete.translate" = "Translate";
"onboarding.experience.complete.agent" = "AI Agent";
"onboarding.experience.complete.edit" = "Voice Edit";
"onboarding.experience.complete.reply" = "Smart Reply";
"onboarding.experience.complete.agent" = "Ask AI";
"onboarding.experience.complete.action" = "Start Using";
"settings.onboarding.replay" = "Restart permission setup";
@@ -485,6 +514,12 @@
"home.preview.placeholder" = "Tap to type and test…";
"home.card.history.empty" = "No voice transcripts yet";
"home.card.dictionary.empty" = "No words yet";
"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your Rime typing";
"home.card.dictionary.smart.empty" = "No suggestions yet";
"home.card.dictionary.smart.accessibilityHint" = "Used %lld times. Double-tap to add it to your personal dictionary.";
"home.card.dictionary.confirm.title" = "Add “%@” to your personal dictionary?";
"home.card.dictionary.confirm.message" = "Rime has recorded this term %lld times. Once added, it will also improve speech recognition and AI polishing.";
"home.card.dictionary.confirm.add" = "Add to dictionary";
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
"home.wide.mode.cloud" = "Cloud";
"home.wide.mode.local" = "On-device";
@@ -508,9 +543,9 @@
/* AI Agent skills */
"skills.title" = "Skills";
"skills.enabled.section" = "In use (%d/%d)";
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
"skills.available.section" = "Available";
"skills.installed.section" = "Installed (%d)";
"skills.installed.empty" = "No installed skills.";
"skills.uninstalled.section" = "Not installed";
"skills.clipboard.guide.title" = "Enable clipboard skills";
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
@@ -524,18 +559,45 @@
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
"skills.detail.title" = "Skill";
"skills.badge.default" = "Default skill";
"skills.action.turnOff" = "Turn off";
"skills.action.turnOffHint" = "Removes it from the keyboard only.";
"skills.action.addToKeyboard" = "Add to keyboard";
"skills.action.install" = "Install";
"skills.action.uninstall" = "Uninstall";
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
"skills.full.title" = "Skill slots full";
"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
"skills.reply.name" = "Reply";
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
"skills.summarize.name" = "Summarize";
"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
"skills.reply.name" = "Reply in source language";
"skills.reply.description" = "Understand the copied text and reply naturally in its primary language.";
"skills.playfulReply.name" = "Playful reply";
"skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate.";
"skills.replyInSourceLanguage.name" = "Reply in source language";
"skills.replyInSourceLanguage.description" = "Understand the copied text and reply naturally in the same language.";
"skills.summarize.name" = "Summarize key points";
"skills.summarize.description" = "Summarize the content and extract facts, decisions, conclusions, and next steps when present.";
"skills.openLink.name" = "Open Link";
"skills.openLink.description" = "Open the single copied web link in your default browser.";
"skills.summarizeWebPage.name" = "Summarize Webpage";
"skills.summarizeWebPage.description" = "Securely extract the public webpage text, then summarize its key facts and conclusions.";
"skills.callPhone.name" = "Call Number";
"skills.callPhone.description" = "Call the single phone number detected in the copied text.";
"skills.createContact.name" = "Create Contact";
"skills.createContact.description" = "Open the system contact editor with the detected phone number prefilled.";
"skills.extractConclusions.name" = "Extract conclusions";
"skills.extractConclusions.description" = "Pull out decisions, conclusions, and next steps from long text.";
"skills.translate.name" = "Translate";
"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
"skills.translate.description" = "Translate copied text in another language into the device's current primary system language.";
"skills.acceptInvitation.name" = "Accept invitation";
"skills.acceptInvitation.description" = "Draft a concise acceptance and confirm the important details.";
"skills.declineInvitation.name" = "Decline politely";
"skills.declineInvitation.description" = "Draft a sincere decline without inventing an excuse.";
"skills.acceptTask.name" = "Acknowledge task";
"skills.acceptTask.description" = "Confirm the requested work and any stated deadline.";
"skills.clarifyRequest.name" = "Clarify and follow up";
"skills.clarifyRequest.description" = "Ask only the key questions needed for a question, task, or problem.";
"skills.empathyReply.name" = "Empathetic reply";
"skills.empathyReply.description" = "Acknowledge a complaint and offer a safe next step.";
"skills.askForDetails.name" = "Ask for details";
"skills.askForDetails.description" = "Request the information needed to diagnose or resolve a problem.";
"skills.businessReply.name" = "Business reply";
"skills.businessReply.description" = "Draft a professional reply with clear commitment boundaries.";
"skills.organizeList.name" = "Organize list";
"skills.organizeList.description" = "Clean up lists, agendas, and steps without adding new tasks.";
"skills.extractTodos.name" = "Extract tasks";
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
"skills.extractEvents.name" = "Extract events";
@@ -589,6 +651,20 @@
"polishStyles.custom.section" = "My styles";
"polishStyles.intro.title" = "Choose a writing personality";
"polishStyles.intro.body" = "The selected style shapes every polished dictation. Your custom styles sync through iCloud when settings sync is enabled.";
"polishStyles.learn.title" = "Generate Your Style";
"polishStyles.learn.body" = "Learn how you naturally express yourself from past dictation.";
"polishStyles.learn.progress" = "%lld / %lld characters";
"polishStyles.learn.remaining" = "%lld to go";
"polishStyles.learn.ready" = "Ready";
"polishStyles.learn.action" = "Generate Style";
"polishStyles.learn.generating" = "Generating…";
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
"polishStyles.learn.limit" = "Delete a custom style before generating another one.";
"polishStyles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available.";
"polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
"polishStyles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
"polishStyles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again.";
"polishStyles.learn.error.request" = "Couldnt generate a style with the configured AI service. Check your connection and AI settings, then try again.";
"polishStyles.light.description" = "Fix recognition errors and punctuation with minimal rewriting.";
"polishStyles.structured.description" = "Organize multiple points into clear paragraphs and lists.";
"polishStyles.formal.description" = "Professional, restrained writing for email and work.";
@@ -738,6 +814,9 @@
"flow.error.editLastInputFailed" = "Could not complete the edit. Please try again.";
"flow.error.aiQuestionFailed" = "AI response failed. Please try again.";
"flow.error.clipboardUnavailable" = "No clipboard text available. Turn on Clipboard History and copy the text first.";
"flow.error.webPageInvalid" = "This link cannot be summarized.";
"flow.error.webPageUnavailable" = "The webpage could not be loaded. It may require a login or block automated reading.";
"flow.error.webPageEmpty" = "No readable webpage content was found.";
/* Optional account center */
"account.title" = "Account";
@@ -754,6 +833,7 @@
"account.signIn.apple" = "Sign in with Apple";
"account.signIn.hint" = "Creates or opens your optional OSG account.";
"account.signIn.loading" = "Signing in…";
"account.error.sessionExpired" = "Your session expired. Sign in again to continue.";
"account.summary.section" = "Account";
"account.summary.signedIn" = "Signed in";
"account.summary.protected" = "Protected account";
@@ -2,3 +2,4 @@
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,包括你在其他 App 中输入时保持启用的 Flow 会话。";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
"NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。";
+117 -37
View File
@@ -46,47 +46,76 @@
"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。";
"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用";
"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。";
"onboarding.experience.permissions.title" = "让 OSGKeyboard 听见你";
"onboarding.experience.permissions.title" = "让我们听见你";
"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
"onboarding.experience.permissions.later" = "暂时跳过";
"onboarding.experience.keyboard.title" = "添加 OSGKeyboard";
"onboarding.experience.keyboard.title" = "添加键盘";
"onboarding.experience.keyboard.subtitle" = "在 iOS 设置中添加键盘,并打开「允许完全访问」。";
"onboarding.experience.keyboard.ready" = "OSGKeyboard 与完全访问均已就绪";
"onboarding.experience.keyboard.fullAccess" = "完全访问用于连接主 App 的麦克风和可选 AI 服务";
"onboarding.experience.keyboard.openAgain" = "再次打开设置";
"onboarding.experience.keyboard.practiceAction" = "开始体验";
"onboarding.experience.keyboard.previewTitle" = "键盘";
"onboarding.experience.practice.skip" = "稍后体验";
"onboarding.experience.practice.sample" = "“我明天下午到,到了以后给你消息。”";
"onboarding.experience.keyboardSwitch.title" = "切换键盘";
"onboarding.experience.keyboardSwitch.subtitle" = "激活键盘后,我们会自动验证是否已开启完整访问。";
"onboarding.experience.keyboardSwitch.instruction" = "点击下方输入框,长按地球键,然后选择 OSGKeyboard。";
"onboarding.experience.keyboardSwitch.placeholder" = "在这里切换并验证键盘";
"onboarding.experience.keyboardSwitch.waiting" = "等待 OSGKeyboard 出现…";
"onboarding.experience.keyboardSwitch.ready" = "OSGKeyboard 与完整访问均已验证";
"onboarding.experience.keyboardSwitch.fullAccessMissing" = "已检测到 OSGKeyboard,但尚未检测到完整访问";
"onboarding.experience.keyboardSwitch.timeout" = "尚未检测到 OSGKeyboard,请确认已完成切换";
"onboarding.experience.keyboardSwitch.checkSettings" = "返回设置检查完整访问";
"onboarding.experience.keyboardSwitch.waitingAction" = "等待验证";
"onboarding.experience.practice.skip" = "暂时跳过体验";
"onboarding.experience.practice.sample" = "我明天下午到,到了以后给你消息。";
"onboarding.experience.practice.editorTitle" = "新消息";
"onboarding.experience.practice.permissionsTitle" = "还需要语音权限";
"onboarding.experience.practice.permissionsTitle" = "开启语音权限";
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
"onboarding.experience.practice.switchTitle" = "切换到 OSGKeyboard";
"onboarding.experience.practice.switchTitle" = "切换键盘";
"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。";
"onboarding.experience.practice.fullAccessTitle" = "再完成一个设置";
"onboarding.experience.practice.fullAccessTitle" = "开启完全访问";
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard 已启用。打开「允许完全访问」后就能使用语音。";
"onboarding.experience.practice.fullAccessAction" = "去设置打开完全访问";
"onboarding.experience.practice.signInTitle" = "登录,免费体验 AI 成文";
"onboarding.experience.practice.signInSubtitle" = "教学体验默认使用 OSG 积分服务,本次 OOBE 请求不会扣除任何积分。";
"onboarding.experience.practice.signInBody" = "使用 Apple 登录以领取一次免费的 OOBE AI 润色。语音仍在设备端转写。";
"onboarding.experience.practice.cloudTitle" = "启用免费 OOBE 体验";
"onboarding.experience.practice.cloudSubtitle" = "仅将本次转写文字发送到 OSG 托管 AI,并明确标记为 OOBE。";
"onboarding.experience.practice.cloudBody" = "本次教学请求由服务端验证为 OOBE,不预扣、不结算积分。完成后,OSG 积分将作为默认 AI 服务。";
"onboarding.experience.practice.cloudAction" = "同意并免费体验";
"onboarding.experience.practice.creditsTitle" = "正在准备 OSG 积分服务";
"onboarding.experience.practice.creditsSubtitle" = "本次 OOBE 教学完全免费。";
"onboarding.experience.practice.preparingCredits" = "正在准备免费 OOBE 请求…";
"onboarding.experience.practice.creditsFailed" = "暂时无法连接 OSG 积分服务,请重试。不会扣除积分。";
"onboarding.experience.practice.speakTitle" = "现在,说一句话";
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,说完后再点一次。文字会直接出现在这里。";
"onboarding.experience.complete.verifiedTitle" = "就是这样。";
"onboarding.experience.practice.cloudTitle" = "开始体验";
"onboarding.experience.practice.cloudSubtitle" = "四项教学无需账号,也不会消耗积分。";
"onboarding.experience.practice.cloudBody" = "语音在设备端转写;只有本次教学所需的转写文字、示例文本和问题会发送到 OSG 托管 AI。匿名体验凭证将在教学结束后失效。";
"onboarding.experience.practice.cloudAction" = "同意并开始体验";
"onboarding.experience.practice.creditsTitle" = "准备体验";
"onboarding.experience.practice.creditsSubtitle" = "正在验证 App 并获取短时、限权的教学凭证。";
"onboarding.experience.practice.preparingCredits" = "正在安全连接 OSG AI…";
"onboarding.experience.practice.creditsFailed" = "暂时无法准备匿名体验。请检查网络后重试,不会产生任何费用。";
"onboarding.experience.practice.retry" = "重试";
"onboarding.experience.practice.progress.voice" = "1 / 4 · 语音输入";
"onboarding.experience.practice.progress.translate" = "2 / 4 · AI 翻译";
"onboarding.experience.practice.progress.reply" = "3 / 4 · AI 回复";
"onboarding.experience.practice.progress.askAI" = "4 / 4 · 询问 AI";
"onboarding.experience.practice.speakTitle" = "跟着念一句";
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,照着下方文字念,说完后再点一次。";
"onboarding.experience.practice.readAloud" = "请照着念";
"onboarding.experience.practice.voiceSample" = "今天是礼拜四,你打算去哪里玩?";
"onboarding.experience.practice.translateTitle" = "试试翻译";
"onboarding.experience.practice.translateSubtitle" = "先复制下面的示例,再在 OSGKeyboard 中点击「翻译」。确认结果后插入文本框。";
"onboarding.experience.practice.replyTitle" = "试试回复";
"onboarding.experience.practice.replySubtitle" = "使用同一段示例文字,点击键盘中的「回复」,然后插入生成的回复。";
"onboarding.experience.practice.askAITitle" = "问问 AI";
"onboarding.experience.practice.askAISubtitle" = "长按键盘麦克风,说出一个问题;再次点击结束录音并等待回答,然后将结果插入文本框。";
"onboarding.experience.practice.copyAction" = "复制示例文字";
"onboarding.experience.practice.copied" = "已复制,请切换到 OSGKeyboard";
"onboarding.experience.practice.waiting" = "等待你在键盘中完成这一步…";
"onboarding.experience.login.title" = "登录领 1000 积分";
"onboarding.experience.login.subtitle" = "四项体验已经完成。登录后可继续使用 OSG 托管 AI,无需填写 API Key。";
"onboarding.experience.login.body" = "符合首次注册资格时,1000 积分会自动到账。设备完整性检查用于防止重复领取。";
"onboarding.experience.login.skip" = "暂不登录";
"onboarding.experience.login.signedIn" = "登录成功,积分已刷新";
"onboarding.experience.login.balance %lld" = "当前余额:%lld 积分";
"onboarding.experience.complete.verifiedTitle" = "已经准备好了";
"onboarding.experience.complete.title" = "已经准备好了";
"onboarding.experience.complete.subtitle" = "以后在任何输入框切换到 OSGKeyboard,开口成文,也可以继续交给 AI Agent。";
"onboarding.experience.complete.subtitle" = "点击开始使用";
"onboarding.experience.complete.dictate" = "语音成文";
"onboarding.experience.complete.translate" = "翻译";
"onboarding.experience.complete.agent" = "AI Agent";
"onboarding.experience.complete.edit" = "语音编辑";
"onboarding.experience.complete.reply" = "智能回复";
"onboarding.experience.complete.agent" = "询问 AI";
"onboarding.experience.complete.action" = "开始使用";
"settings.onboarding.replay" = "重新开始权限引导";
@@ -484,6 +513,12 @@
"home.preview.placeholder" = "点这里试试键盘";
"home.card.history.empty" = "还没有语音识别记录";
"home.card.dictionary.empty" = "还没有词条";
"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词";
"home.card.dictionary.smart.empty" = "暂无推荐词";
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?";
"home.card.dictionary.confirm.message" = "Rime 已记录这个词 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
"home.card.dictionary.confirm.add" = "加入词库";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.wide.mode.cloud" = "云端";
"home.wide.mode.local" = "本机";
@@ -507,9 +542,9 @@
/* AI Agent 技能 */
"skills.title" = "技能";
"skills.enabled.section" = "使用中(%d/%d";
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
"skills.available.section" = "可添加";
"skills.installed.section" = "已安装(%d";
"skills.installed.empty" = "暂无已安装技能。";
"skills.uninstalled.section" = "未安装";
"skills.clipboard.guide.title" = "启用剪贴板技能";
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
"skills.clipboard.guide.enableHistory" = "开启历史记录";
@@ -523,18 +558,45 @@
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
"skills.detail.title" = "技能";
"skills.badge.default" = "默认技能";
"skills.action.turnOff" = "关闭";
"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
"skills.action.addToKeyboard" = "添加到键盘";
"skills.action.install" = "安装";
"skills.action.uninstall" = "卸载";
"skills.action.reinstallShortcut" = "重新安装捷径";
"skills.full.title" = "技能已满";
"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
"skills.reply.name" = "回复";
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
"skills.summarize.name" = "总结";
"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
"skills.reply.name" = "原语言回复";
"skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。";
"skills.playfulReply.name" = "俏皮回复";
"skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。";
"skills.replyInSourceLanguage.name" = "用原语言回复";
"skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。";
"skills.summarize.name" = "总结要点";
"skills.summarize.description" = "总结核心内容,并按需提炼事实、决定、结论和下一步。";
"skills.openLink.name" = "打开链接";
"skills.openLink.description" = "使用系统默认浏览器打开复制内容中的唯一网页链接。";
"skills.summarizeWebPage.name" = "总结网页";
"skills.summarizeWebPage.description" = "安全提取公开网页正文,再总结其中的关键事实与结论。";
"skills.callPhone.name" = "拨打电话";
"skills.callPhone.description" = "拨打复制内容中识别出的唯一电话号码。";
"skills.createContact.name" = "新建联系人";
"skills.createContact.description" = "打开系统联系人编辑页,并预填识别出的电话号码。";
"skills.extractConclusions.name" = "提取结论";
"skills.extractConclusions.description" = "从长文提取决定、结论和下一步。";
"skills.translate.name" = "翻译";
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
"skills.translate.description" = "将不同语言的复制文本翻译为设备当前的首选系统语言。";
"skills.acceptInvitation.name" = "接受邀约";
"skills.acceptInvitation.description" = "起草简洁的接受回复,并确认重要信息。";
"skills.declineInvitation.name" = "委婉拒绝";
"skills.declineInvitation.description" = "真诚婉拒邀约,不虚构理由。";
"skills.acceptTask.name" = "确认任务";
"skills.acceptTask.description" = "确认收到的事项和原文写明的截止时间。";
"skills.clarifyRequest.name" = "澄清与追问";
"skills.clarifyRequest.description" = "针对问题、任务或故障描述,只追问最关键的信息。";
"skills.empathyReply.name" = "共情回复";
"skills.empathyReply.description" = "理解投诉或负面反馈,并给出稳妥下一步。";
"skills.askForDetails.name" = "询问细节";
"skills.askForDetails.description" = "询问定位或解决问题所需的信息。";
"skills.businessReply.name" = "商务回复";
"skills.businessReply.description" = "起草专业清晰、承诺边界明确的回复。";
"skills.organizeList.name" = "整理清单";
"skills.organizeList.description" = "整理清单、议程和步骤,不新增任务。";
"skills.extractTodos.name" = "提取待办";
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
"skills.extractEvents.name" = "提取日程";
@@ -588,6 +650,20 @@
"polishStyles.custom.section" = "我的风格";
"polishStyles.intro.title" = "选择完整写作人格";
"polishStyles.intro.body" = "选中的风格会影响每次听写润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
"polishStyles.learn.title" = "生成专属风格";
"polishStyles.learn.body" = "从历史听写中学习你的表达习惯。";
"polishStyles.learn.progress" = "%lld / %lld 字";
"polishStyles.learn.remaining" = "还差 %lld 字";
"polishStyles.learn.ready" = "可以生成";
"polishStyles.learn.action" = "生成风格";
"polishStyles.learn.generating" = "生成中…";
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
"polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
"polishStyles.learn.error.request" = "无法通过当前配置的 AI 服务生成风格,请检查网络和 AI 设置后重试。";
"polishStyles.light.description" = "修正识别错误与标点,尽量少改原话。";
"polishStyles.structured.description" = "将多个事项整理为清晰段落与列表。";
"polishStyles.formal.description" = "适合邮件和工作的专业、克制表达。";
@@ -737,6 +813,9 @@
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
"flow.error.webPageInvalid" = "该链接无法用于网页总结。";
"flow.error.webPageUnavailable" = "无法读取该网页,页面可能要求登录或禁止自动读取。";
"flow.error.webPageEmpty" = "网页中没有可读取的正文内容。";
/* 可选账号中心 */
"account.title" = "账号";
@@ -753,6 +832,7 @@
"account.signIn.apple" = "通过 Apple 登录";
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
"account.signIn.loading" = "正在登录…";
"account.error.sessionExpired" = "登录状态已过期,请重新登录后继续。";
"account.summary.section" = "账号";
"account.summary.signedIn" = "已登录";
"account.summary.protected" = "账号已安全保护";
+78 -7
View File
@@ -65,6 +65,9 @@ public final class KeyboardViewController: UIInputViewController {
private var cancellables = Set<AnyCancellable>()
/// Coalesces host-document refreshes after mutations issued by this keyboard.
private var assistantFieldActionRefreshTask: Task<Void, Never>?
/// One random ID per keyboard presentation. The repository splits this ID
/// into independent UTC-day fragments when a presentation crosses midnight.
private var keyboardUsageSessionID = UUID()
private var memoryTelemetryContext: String {
let language = typingSessionStorage?.language.rawValue ?? "-"
@@ -306,6 +309,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
keyboardUsageSessionID = UUID()
AnalyticsExtensionService.shared.recordPresentation(
hasFullAccess: hasFullAccess
)
@@ -329,7 +333,7 @@ public final class KeyboardViewController: UIInputViewController {
state: state,
host: WhatsNewDemoDriver.HostHooks(
insertText: { [weak self] text in
self?.insertTextIntoDocument(text)
self?.insertTextIntoDocument(text, source: .debugDemo)
},
deleteBackward: { [weak self] in
self?.deleteBackwardFromDocument()
@@ -415,7 +419,9 @@ public final class KeyboardViewController: UIInputViewController {
editHintScheduler = EditHintScheduler(state: state)
textInserter = KeyboardTextInserter(
state: state,
insertText: { [weak self] text in self?.insertTextIntoDocument(text) },
insertText: { [weak self] text, source in
self?.insertTextIntoDocument(text, source: source)
},
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
@@ -482,6 +488,15 @@ public final class KeyboardViewController: UIInputViewController {
},
captureInsertionFingerprint: { [weak self] in
self?.captureFieldContext().deliveryFingerprint
},
openWebURL: { [weak self] url in
self?.openWebURL(url)
},
callPhone: { [weak self] phoneNumber in
self?.callPhone(phoneNumber)
},
createContact: { [weak self] phoneNumber in
self?.createContact(phoneNumber)
}
)
clipboardCapture = ClipboardCaptureCoordinator(state: state)
@@ -622,8 +637,12 @@ public final class KeyboardViewController: UIInputViewController {
self?.configSync.persistTranslationTargetLocaleId(id)
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
}
state.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
state.insertNewline = { [weak self] in
self?.insertTextIntoDocument("\n", source: .manualKeyboard)
}
state.insertSpace = { [weak self] in
self?.insertTextIntoDocument(" ", source: .manualKeyboard)
}
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
@@ -771,9 +790,20 @@ public final class KeyboardViewController: UIInputViewController {
/// Keeps field-action UI current even when a host app does not immediately
/// echo this keyboard's own document mutation through `textDidChange`.
private func insertTextIntoDocument(_ text: String) {
private func insertTextIntoDocument(
_ text: String,
source: KeyboardTextInsertionSource
) {
guard !text.isEmpty else { return }
textDocumentProxy.insertText(text)
if source.contributesToKeyboardUsage {
let counts = KeyboardUsageCharacterClassifier.classify(text)
AnalyticsExtensionService.shared.keyboardUsageRecorder
.recordManualKeyboardCounts(
counts,
sessionID: keyboardUsageSessionID
)
}
// A non-empty insertion makes content actions available immediately.
// The next host callback remains the authoritative correction.
refreshAssistantFieldAction(hasTextOverride: true)
@@ -786,7 +816,7 @@ public final class KeyboardViewController: UIInputViewController {
}
private func performDocumentReturn() {
textDocumentProxy.insertText("\n")
insertTextIntoDocument("\n", source: .assistantAction)
scheduleAssistantFieldActionRefresh()
}
@@ -980,7 +1010,7 @@ public final class KeyboardViewController: UIInputViewController {
state: state,
typing: typingSession,
onInsert: { [weak self] text in
self?.insertTextIntoDocument(text)
self?.insertTextIntoDocument(text, source: .manualKeyboard)
},
onDeleteBackward: { [weak self] in
self?.deleteBackwardFromDocument()
@@ -1073,6 +1103,47 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Open host app
private func openWebURL(_ url: URL) {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
return
}
HostAppLauncher.open(url: url, from: self) { [weak self] success in
guard !success else { return }
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.openLinkFailed")
}
}
private func callPhone(_ phoneNumber: String) {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
return
}
guard let url = AIPhoneNumberResolver.telephoneURL(for: phoneNumber) else {
state.skillTipText = ExtL10n.string("keyboard.ai.skill.callPhoneFailed")
return
}
HostAppLauncher.open(url: url, from: self) { [weak self] success in
guard !success else { return }
self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.callPhoneFailed")
}
}
private func createContact(_ phoneNumber: String) {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
return
}
AppGroupStore().setPendingContactCreation(phoneNumber: phoneNumber)
guard let url = URL(string: "osgkeyboard://contact/new") else { return }
HostAppLauncher.open(url: url, from: self) { [weak self] success in
guard !success else { return }
self?.state.skillTipText = ExtL10n.string(
"keyboard.ai.skill.createContactFailed"
)
}
}
private func openSkillShortcutRun() {
guard hasFullAccess else {
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
@@ -15,28 +15,39 @@ final class AIKeyboardCoordinator {
private let insertAnswer: (AIAnswer) -> Bool
private let performReturn: () -> Void
private let captureInsertionFingerprint: () -> String?
private let openWebURL: (URL) -> Void
private let callPhone: (String) -> Void
private let createContact: (String) -> Void
private var requestInsertionFingerprint: String?
private var conversationInsertionFingerprint: String?
private var hasConversationInsertionTarget = false
private var requestOOBEFeature: ManagedGatewayOOBEFeature?
init(
state: KeyboardState,
flow: KeyboardFlowCoordinator,
insertAnswer: @escaping (AIAnswer) -> Bool,
performReturn: @escaping () -> Void,
captureInsertionFingerprint: @escaping () -> String?
captureInsertionFingerprint: @escaping () -> String?,
openWebURL: @escaping (URL) -> Void,
callPhone: @escaping (String) -> Void,
createContact: @escaping (String) -> Void
) {
self.state = state
self.flow = flow
self.insertAnswer = insertAnswer
self.performReturn = performReturn
self.captureInsertionFingerprint = captureInsertionFingerprint
self.openWebURL = openWebURL
self.callPhone = callPhone
self.createContact = createContact
}
func beginNewPresentation() {
endConversationIfNeeded()
state.aiSession.enter()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
conversationInsertionFingerprint = nil
hasConversationInsertionTarget = false
}
@@ -53,6 +64,7 @@ final class AIKeyboardCoordinator {
endConversationIfNeeded()
state.aiSession.leave()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
conversationInsertionFingerprint = nil
hasConversationInsertionTarget = false
}
@@ -66,7 +78,12 @@ final class AIKeyboardCoordinator {
case .idle, .awaitingSend, .inserted, .sent, .failed:
prepareConversationForRequest()
guard let conversationID = state.aiSession.conversationID else { return }
let disposition = flow.beginAIRecording(conversationID: conversationID)
let oobeFeature = expectedOOBEFeature(.askAI)
requestOOBEFeature = oobeFeature
let disposition = flow.beginAIRecording(
conversationID: conversationID,
oobeFeature: oobeFeature
)
if case .rejected(let rejection) = disposition {
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
@@ -81,8 +98,51 @@ final class AIKeyboardCoordinator {
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
guard !skill.requiresShortcut
|| state.confirmedClipboardShortcutIDs.contains(skill.id) else {
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
return
}
let oobeFeature = oobeFeature(for: skill)
let material: String?
if let oobeFeature {
// OOBE clipboard lessons are intentionally isolated from the
// user's real clipboard history.
material = oobeMaterial(for: oobeFeature)
} else if state.oobePracticeSession != nil {
material = nil
} else {
material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
}
if skill.id == AIClipboardSkillCatalog.openLinkID {
guard let material,
let url = ClipboardWebLinkResolver.singleWebURL(in: material) else {
state.skillTipText = ExtL10n.string("keyboard.ai.error.clipboardUnavailable")
return
}
openWebURL(url)
return
}
if skill.id == AIClipboardSkillCatalog.callPhoneID {
guard let material,
let phoneNumber = AIPhoneNumberResolver.singlePhoneNumber(in: material) else {
state.skillTipText = ExtL10n.string("keyboard.ai.error.clipboardUnavailable")
return
}
callPhone(phoneNumber)
return
}
if skill.id == AIClipboardSkillCatalog.createContactID {
guard let material,
let phoneNumber = AIPhoneNumberResolver.singlePhoneNumber(in: material) else {
state.skillTipText = ExtL10n.string("keyboard.ai.error.clipboardUnavailable")
return
}
createContact(phoneNumber)
return
}
prepareConversationForRequest()
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
if skill.kind == .export {
state.pendingClipboardSkillID = skill.id
state.pendingClipboardSkillSource = material
@@ -92,7 +152,8 @@ final class AIKeyboardCoordinator {
var instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId
translationTargetLocaleId: state.translationTargetLocaleId,
replyStyle: state.clipboardReplyStyle
)
if skill.kind == .export {
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
@@ -110,9 +171,31 @@ final class AIKeyboardCoordinator {
if case .materialUnavailable = resolution {
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
}
if skill.id == AIClipboardSkillCatalog.summarizeWebPageID,
let material,
let url = ClipboardWebLinkResolver.singleWebURL(in: material) {
submitResolvedPrompt(
.ready(instruction),
taskKind: skill.managedGatewayTaskKind,
oobeFeature: oobeFeature,
thinkingEnabled: skill.thinkingEnabled,
webPageURL: url
)
return
}
if skill.id == AIClipboardSkillCatalog.summarizeWebPageID {
submitResolvedPrompt(
.materialUnavailable,
taskKind: skill.managedGatewayTaskKind,
oobeFeature: oobeFeature,
thinkingEnabled: skill.thinkingEnabled
)
return
}
submitResolvedPrompt(
resolution,
taskKind: skill.managedGatewayTaskKind,
oobeFeature: oobeFeature,
thinkingEnabled: skill.thinkingEnabled
)
}
@@ -120,12 +203,21 @@ final class AIKeyboardCoordinator {
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
func submitHintCard(_ card: AIHintCard) {
guard canAcceptIdleSubmit else { return }
// The OOBE ask-AI lesson must use the explicit hold-to-talk path. Idle
// cards can otherwise pull unrelated clipboard material into a request.
guard state.oobePracticeSession == nil else { return }
prepareConversationForRequest()
let resolution = AIHintPool.resolvePrompt(
for: card,
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
)
submitResolvedPrompt(resolution, taskKind: .aiQuestion)
submitResolvedPrompt(
resolution,
taskKind: card.taskKind,
requestSource: .hotword,
oobeFeature: nil,
thinkingEnabled: true
)
}
private var canAcceptIdleSubmit: Bool {
@@ -141,7 +233,10 @@ final class AIKeyboardCoordinator {
private func submitResolvedPrompt(
_ resolution: AIClipboardPrompt.Resolution,
taskKind: ManagedGatewayTaskKind,
thinkingEnabled: Bool? = nil
requestSource: ManagedGatewayRequestSource? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil,
webPageURL: URL? = nil
) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
@@ -156,11 +251,15 @@ final class AIKeyboardCoordinator {
clearPendingExportSkill()
return
}
requestOOBEFeature = oobeFeature
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID,
taskKind: taskKind,
thinkingEnabled: thinkingEnabled
requestSource: requestSource,
oobeFeature: oobeFeature,
thinkingEnabled: thinkingEnabled,
webPageURL: webPageURL
)
if case .rejected(let rejection) = disposition {
clearPendingExportSkill()
@@ -172,6 +271,7 @@ final class AIKeyboardCoordinator {
guard state.aiSession.isBusy else { return }
clearPendingExportSkill()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
}
@@ -179,6 +279,7 @@ final class AIKeyboardCoordinator {
func confirmPendingAnswer() {
if state.aiSession.canInsert, let answer = state.aiSession.answer {
guard insertAnswer(answer) else { return }
markOOBECompletedAfterInsertion()
state.aiSession.markAnswerInserted(
offersSend: state.returnKeyRole.usesActionFill
)
@@ -190,6 +291,7 @@ final class AIKeyboardCoordinator {
func discardPendingAnswer() {
state.aiSession.discardReadyAnswer()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
}
func performCurrentFieldAction() {
@@ -274,6 +376,7 @@ final class AIKeyboardCoordinator {
// fallback when the field or caret changed during generation.
return
}
markOOBECompletedAfterInsertion()
state.aiSession.markAnswerInserted(
offersSend: state.returnKeyRole.usesActionFill
)
@@ -284,6 +387,7 @@ final class AIKeyboardCoordinator {
func fail(_ message: String, utteranceID: UUID?) {
clearPendingExportSkill()
requestInsertionFingerprint = nil
requestOOBEFeature = nil
state.aiSession.fail(message, utteranceID: utteranceID)
}
@@ -319,16 +423,51 @@ final class AIKeyboardCoordinator {
requestInsertionFingerprint = currentFingerprint
}
private func oobeFeature(for skill: AIClipboardSkill) -> ManagedGatewayOOBEFeature? {
switch skill.id {
case AIClipboardSkillCatalog.replyID:
return expectedOOBEFeature(.clipboardReply)
case AIClipboardSkillCatalog.translateID:
return expectedOOBEFeature(.clipboardTranslate)
default:
return nil
}
}
private func expectedOOBEFeature(
_ feature: ManagedGatewayOOBEFeature
) -> ManagedGatewayOOBEFeature? {
guard state.oobePracticeSession?.expectedFeature == feature else { return nil }
return feature
}
private func oobeMaterial(for feature: ManagedGatewayOOBEFeature?) -> String? {
guard let feature,
feature == .clipboardReply || feature == .clipboardTranslate,
let session = state.oobePracticeSession else {
return nil
}
return KeyboardSetupBridge.oobeClipboardMaterial(sessionID: session.sessionID)
}
private func markOOBECompletedAfterInsertion() {
guard let feature = requestOOBEFeature,
let session = state.oobePracticeSession else {
return
}
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
sessionID: session.sessionID,
feature: feature
)
}
private var isPendingExportSkill: Bool {
guard let id = state.pendingClipboardSkillID else { return false }
return resolvedSkill(id: id)?.kind == .export
}
private func resolvedSkill(id: String) -> AIClipboardSkill? {
AIClipboardSkillCatalog.skill(
id: id,
userCatalog: AppGroupStore().agentUserSkillCatalog
)
state.clipboardSkillCatalog.first { $0.id == id }
}
/// Parse an export skill. Empty in-keyboard tip, stay in the host app.
@@ -1,102 +1,50 @@
// AnalyticsExtensionService.swift
// OSGKeyboard · Keyboard Extension
//
// The extension records into the shared SQLite queue and only attempts one
// short anonymous batch when Full Access permits network use.
// The extension only records into durable queues. The host app is the sole
// network uploader, avoiding extension-lifecycle and cross-process lock races.
import Foundation
import OSGKeyboardShared
import OSLog
private actor ExtensionAnalyticsUploadSignal: AnalyticsUploadTriggering {
typealias Action = @Sendable () async -> Void
private var action: Action?
private var canUpload = false
private var isUploading = false
func install(_ action: @escaping Action) {
self.action = action
}
func setCanUpload(_ canUpload: Bool) {
self.canUpload = canUpload
}
func requestUpload() {
guard canUpload, !isUploading, let action else { return }
isUploading = true
Task {
await action()
uploadFinished()
}
}
private func uploadFinished() {
isUploading = false
}
}
private struct ExtensionAnalyticsLogger: AnalyticsLogging {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios.keyboard",
category: "analytics"
)
func log(_ entry: AnalyticsUploadLogEntry) {
let statusCode = entry.statusCode ?? 0
let errorCategory = entry.errorCategory?.rawValue ?? "none"
logger.info(
"outcome=\(entry.outcome.rawValue, privacy: .public) count=\(entry.eventCount, privacy: .public) status=\(statusCode, privacy: .public) attempt=\(entry.attempt, privacy: .public) error=\(errorCategory, privacy: .public)"
)
}
}
final class AnalyticsExtensionService: Sendable {
static let shared = AnalyticsExtensionService()
let client: any AnalyticsClient
private let runtime: AnalyticsRuntime
private let uploadSignal: ExtensionAnalyticsUploadSignal
let keyboardUsageRecorder: any KeyboardUsageRecording
private init() {
let signal = ExtensionAnalyticsUploadSignal()
uploadSignal = signal
let environment = Self.environment
let runtime = AnalyticsRuntime.keyboardExtension(
environment: Self.environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
trigger: signal,
logger: ExtensionAnalyticsLogger()
environment: environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint)
)
let keyboardUsageRuntime = KeyboardUsageRuntime(
environment: environment,
analyticsRepository: runtime.repository,
uploadConfiguration: KeyboardUsageUploadConfiguration(
endpoint: Self.keyboardUsageEndpoint
)
)
self.runtime = runtime
client = runtime.client
Task {
await signal.install {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1)
}
}
keyboardUsageRecorder = keyboardUsageRuntime.recorder
}
func recordPresentation(hasFullAccess: Bool) {
Task {
await uploadSignal.setCanUpload(hasFullAccess)
client.recordSessionActivity()
client.recordKeyboardActivated()
}
func recordPresentation(hasFullAccess _: Bool) {
client.recordSessionActivity()
client.recordKeyboardActivated()
}
func keyboardWillDisappear() {
Task {
await uploadSignal.setCanUpload(false)
}
}
func keyboardWillDisappear() {}
private static let endpoint = URL(
string: "https://account.osglab.com/v1/analytics/events"
)!
private static let keyboardUsageEndpoint = URL(
string: "https://account.osglab.com/v1/analytics/keyboard-usage"
)!
private static var environment: AnalyticsEnvironment {
let appVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
@@ -42,7 +42,7 @@ public struct AppGroupPersistor {
state.handednessPreference = store.handednessPreference
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -97,11 +97,41 @@ public struct AppGroupPersistor {
state.handednessPreference = store.handednessPreference
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
}
/// Resolve once outside SwiftUI body evaluation. Assigning the complete
/// value snapshot publishes remote copy changes even when slot IDs are stable.
private func applySkillSnapshot(
store: AppGroupStore,
into state: KeyboardViewController.State
) {
let layout = store.agentSkillLayout
let language = store.uiLanguage
state.uiLanguage = language
state.enabledClipboardSkillIDs = layout.enabledIDs
state.confirmedClipboardShortcutIDs = layout.confirmedShortcutIDs
state.enabledClipboardSkills = AIClipboardSkillCatalog.visible(
enabledIDs: layout.enabledIDs,
officialCatalog: store.officialSkillCatalog,
userCatalog: store.agentUserSkillCatalog,
uiLanguage: language
)
state.clipboardSkillCatalog = AIClipboardSkillCatalog.all(
officialCatalog: store.officialSkillCatalog,
userCatalog: store.agentUserSkillCatalog,
uiLanguage: language
)
let activeStyle = store.activePolishStyle
// Built-in formal/corporate personalities must not make ordinary
// clipboard replies sound less like the user.
state.clipboardReplyStyle = AIClipboardReplyStyleContext.resolve(
activeStyle: activeStyle
)
}
/// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when
/// the polish key is missing show a soft tip above the mic instead.
private func applyAPIKeyAvailability(
@@ -34,6 +34,7 @@ final class ClipboardCaptureCoordinator {
private let state: KeyboardState
private let history: ClipboardHistoryStore
private let semanticRanking: ClipboardSemanticRankingStore
private let pasteboard: ClipboardPasteboardProviding
private var pollTimer: Timer?
private var isSecureProvider: () -> Bool = { false }
@@ -48,10 +49,12 @@ final class ClipboardCaptureCoordinator {
init(
state: KeyboardState,
history: ClipboardHistoryStore = .shared,
semanticRanking: ClipboardSemanticRankingStore = .shared,
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
) {
self.state = state
self.history = history
self.semanticRanking = semanticRanking
self.pasteboard = pasteboard
}
@@ -79,6 +82,9 @@ final class ClipboardCaptureCoordinator {
// A suggestion belongs to one keyboard presentation. Clear any
// presentation state left behind by a reused extension controller.
endCurrentSuggestion()
if let newest = history.newestAIHintEligibleEntry() {
semanticRanking.analyze(newest)
}
forcesNextSample = true
// Delay the system pasteboard read until the first poll tick. A
// Universal Clipboard fetch or paste alert during the appear sequence
@@ -94,6 +100,7 @@ final class ClipboardCaptureCoordinator {
stopPolling()
// A1 policy: closing the keyboard ends this generation's suggestion.
endCurrentSuggestion()
semanticRanking.clear()
}
func refreshFlagsFromStore() {
@@ -102,6 +109,9 @@ final class ClipboardCaptureCoordinator {
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
endCurrentSuggestion()
}
if !state.clipboardHistoryEnabled {
semanticRanking.clear()
}
}
func secureEntryDidChange(isSecure: Bool) {
@@ -117,6 +127,7 @@ final class ClipboardCaptureCoordinator {
return
}
endCurrentSuggestion()
semanticRanking.clear()
state.clipboardOverlay = .none
}
@@ -153,12 +164,16 @@ final class ClipboardCaptureCoordinator {
func clearHistory() {
endCurrentSuggestion()
semanticRanking.clear()
history.clearAll()
}
func deleteEntry(id: UUID) {
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
history.remove(id: id)
if semanticRanking.snapshot?.entryID == id {
semanticRanking.clear()
}
if deletedChangeCount == state.clipboardSuggestionChangeCount {
endCurrentSuggestion()
}
@@ -233,6 +248,7 @@ final class ClipboardCaptureCoordinator {
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
semanticRanking.clear()
history.lastObservedChangeCount = changeCount
return
}
@@ -243,7 +259,9 @@ final class ClipboardCaptureCoordinator {
if isCurrentGeneration {
return
}
semanticRanking.clear()
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
semanticRanking.analyze(entry)
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
@@ -306,6 +324,7 @@ final class ClipboardCaptureCoordinator {
// including generations that contain no acceptable text.
clearSuggestion()
guard sample.hasStrings else {
semanticRanking.clear()
history.lastObservedChangeCount = changeCount
return
}
@@ -313,7 +332,9 @@ final class ClipboardCaptureCoordinator {
// republish content from an already observed generation.
guard !isCurrentGeneration else { return }
semanticRanking.clear()
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
semanticRanking.analyze(entry)
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
@@ -92,7 +92,21 @@ final class KeyboardConfigSync {
// Keychain fallback: a reboot must not resurrect the mic gate when
// App Group transiently reads empty.
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
state.isOnboardingPracticeActive = KeyboardSetupBridge.isOnboardingPracticeActive
let practice = KeyboardSetupBridge.activeOOBEPracticeSession
state.oobePracticeSession = practice
state.isOnboardingPracticeActive = practice != nil
|| KeyboardSetupBridge.isOnboardingPracticeActive
switch practice?.expectedFeature {
case .voiceInput, .askAI:
state.surface = .voice
case .clipboardTranslate, .clipboardReply:
state.surface = .ai
if !state.aiSession.isActive {
state.aiSession.enter()
}
case nil:
break
}
}
func persistLocale(_ id: String) {
@@ -677,16 +677,25 @@ final class KeyboardFlowCoordinator {
}
func beginAIRecording(
conversationID: UUID
conversationID: UUID,
oobeFeature: ManagedGatewayOOBEFeature? = nil
) -> FlowUtteranceStartDisposition {
startUtterance(.aiQuestion(conversationID: conversationID))
startUtterance(
.aiQuestion(
conversationID: conversationID,
oobeFeature: oobeFeature
)
)
}
func submitAIQuestion(
text: String,
conversationID: UUID,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool? = nil
requestSource: ManagedGatewayRequestSource? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil,
webPageURL: URL? = nil
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
@@ -695,7 +704,10 @@ final class KeyboardFlowCoordinator {
conversationID: conversationID,
prefilledQuestion: trimmed,
taskKind: taskKind,
thinkingEnabled: thinkingEnabled
requestSource: requestSource,
oobeFeature: oobeFeature,
thinkingEnabled: thinkingEnabled,
webPageURL: webPageURL
)
)
}
@@ -1181,8 +1193,14 @@ final class KeyboardFlowCoordinator {
let mode: FlowUtteranceMode? = request.mode == .dictation
? nil
: request.mode
let implicitOOBEFeature: ManagedGatewayOOBEFeature? =
state.oobePracticeSession?.expectedFeature == .voiceInput
&& request.mode == .dictation
? .voiceInput
: nil
let managedOOBEFeature = request.managedOOBEFeature ?? implicitOOBEFeature
let managedRequestPurpose = request.managedRequestPurpose
?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil)
?? (managedOOBEFeature == nil ? nil : .oobe)
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
@@ -1200,7 +1218,9 @@ final class KeyboardFlowCoordinator {
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
aiConversationID: request.aiConversationID,
aiTaskKind: request.aiTaskKind,
managedRequestSource: request.managedRequestSource,
managedRequestPurpose: managedRequestPurpose,
managedOOBEFeature: managedOOBEFeature,
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
processingDeadlineAt: action == .stopRecording && request.isEdit
? Date().timeIntervalSince1970
@@ -1908,7 +1928,11 @@ final class KeyboardFlowCoordinator {
utteranceMode: .aiQuestion,
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
aiWebPageURL: currentUtteranceRequest?.aiWebPageURL,
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
managedRequestSource: currentUtteranceRequest?.managedRequestSource,
managedRequestPurpose: currentUtteranceRequest?.managedRequestPurpose,
managedOOBEFeature: currentUtteranceRequest?.managedOOBEFeature,
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
startDeadlineAt: currentStartDeadlineAt
)
@@ -15,7 +15,7 @@ final class KeyboardTextInserter {
private static let caretVerificationLimit = 80
private let state: KeyboardState
private let insertText: (String) -> Void
private let insertText: (String, KeyboardTextInsertionSource) -> Void
private let deleteBackward: () -> Void
private let contextBeforeInput: () -> String?
private let fieldContextProvider: () -> FlowFieldContext?
@@ -41,7 +41,7 @@ final class KeyboardTextInserter {
init(
state: KeyboardState,
insertText: @escaping (String) -> Void,
insertText: @escaping (String, KeyboardTextInsertionSource) -> Void,
deleteBackward: @escaping () -> Void,
contextBeforeInput: @escaping () -> String?,
fieldContextProvider: @escaping () -> FlowFieldContext?,
@@ -87,8 +87,16 @@ final class KeyboardTextInserter {
insertion: trimmed
)
let inserted = separator + trimmed
insertText(inserted)
insertText(inserted, .voiceTranscription)
KeyboardSetupBridge.markVoiceInsertion()
if delivery.polishWarning == nil,
let practice = state.oobePracticeSession,
practice.expectedFeature == .voiceInput {
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
sessionID: practice.sessionID,
feature: .voiceInput
)
}
state.noteUserDidInputText()
recordLastInsertion(
inserted,
@@ -120,7 +128,7 @@ final class KeyboardTextInserter {
insertion: trimmed
)
let inserted = separator + trimmed
insertText(inserted)
insertText(inserted, .aiGenerated)
state.noteUserDidInputText()
let mutation = HistoryMutation(
@@ -152,7 +160,7 @@ final class KeyboardTextInserter {
guard !text.isEmpty else { return }
// Verbatim on purpose paste must reproduce exactly what was copied,
// unlike dictation which needs word-boundary hygiene.
insertText(text)
insertText(text, .pasteboard)
recordUndoableInsertion(text)
// The paste pushed any previous input away from the caret, so the
// "editable last input" hint no longer applies.
@@ -207,7 +215,7 @@ final class KeyboardTextInserter {
state.redoAvailable = false
return
}
insertText(text)
insertText(text, .redo)
recordUndoableInsertion(text)
OSGLog.keyboardExt.info("redo length=\(text.count, privacy: .public)")
}
@@ -375,7 +383,7 @@ final class KeyboardTextInserter {
let inserted = separator + result
transaction.appliedInsertedText = inserted
PendingTextEditTransactionStore.save(transaction)
insertText(inserted)
insertText(inserted, .editGenerated)
let verificationSuffix = String(inserted.suffix(80))
guard contextBeforeInput()?.hasSuffix(verificationSuffix) == true else {
// Never blindly delete after a partial/opaque host insertion. The
@@ -448,7 +456,7 @@ final class KeyboardTextInserter {
switch transaction.deliveryMode {
case .replace:
insertText(transaction.beforeText)
insertText(transaction.beforeText, .editGenerated)
let restore = HistoryMutation(
action: transaction.historyMutation.action == .append ? .delete : .restore,
entryID: transaction.historyMutation.entryID,
+60 -7
View File
@@ -34,6 +34,7 @@ struct AIKeyboardView: View {
static let skillCellSpacing: CGFloat = 10
static let pageDotSize: CGFloat = 5
static let skillPaginationBottomInset: CGFloat = 6
static let maximumSemanticSkills = 5
}
#if DEBUG
@@ -50,6 +51,7 @@ struct AIKeyboardView: View {
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
let onInsert: (String) -> Void
@AppStorage("keyboard.assistant.longPressCoachCount")
@@ -108,10 +110,13 @@ struct AIKeyboardView: View {
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in
.onChange(of: state.clipboardSkillCatalog) { _, _ in
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: semanticRanking.snapshot) { _, _ in
selectedSkillPage = 0
}
.onChange(of: state.skillTipText) { _, tip in
guard let tip, !tip.isEmpty else { return }
#if DEBUG
@@ -292,8 +297,10 @@ struct AIKeyboardView: View {
@ViewBuilder
private var contextArea: some View {
ZStack {
if state.isOnboardingPracticeActive, activeStatus == nil {
Text(ExtL10n.string("keyboard.onboarding.practice.mic"))
if showsOOBEClipboardSkill, activeStatus == nil {
clipboardSkillPager
} else if let onboardingPracticeHint, activeStatus == nil {
Text(onboardingPracticeHint)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
@@ -532,7 +539,7 @@ struct AIKeyboardView: View {
if skill.id == AIClipboardSkillCatalog.translateID {
return AIClipboardSkillCatalog.translateButtonTitle(
translationTargetLocaleId: state.translationTargetLocaleId,
uiLanguage: AppGroupStore().uiLanguage
uiLanguage: state.uiLanguage
)
}
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -550,6 +557,9 @@ struct AIKeyboardView: View {
return assistantIsResting
}
#endif
if showsOOBEClipboardSkill {
return assistantIsResting
}
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
guard !visibleClipboardSkills.isEmpty,
let newest = clipboardHistory.newestEntry,
@@ -568,12 +578,55 @@ struct AIKeyboardView: View {
return preview
}
#endif
return AIClipboardSkillCatalog.visible(
enabledIDs: state.enabledClipboardSkillIDs,
userCatalog: AppGroupStore().agentUserSkillCatalog
if let expectedSkillID = oobeExpectedSkillID {
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
}
guard let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return []
}
return ClipboardSkillSemanticRanker.recommended(
skills: state.clipboardSkillCatalog,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: Layout.maximumSemanticSkills
)
}
private var showsOOBEClipboardSkill: Bool {
oobeExpectedSkillID != nil
}
private var oobeExpectedSkillID: String? {
switch state.oobePracticeSession?.expectedFeature {
case .clipboardTranslate:
return AIClipboardSkillCatalog.translateID
case .clipboardReply:
return AIClipboardSkillCatalog.replyID
case .voiceInput, .askAI, nil:
return nil
}
}
private var onboardingPracticeHint: String? {
switch state.oobePracticeSession?.expectedFeature {
case .voiceInput:
return ExtL10n.string("keyboard.onboarding.practice.mic")
case .clipboardTranslate:
return ExtL10n.string("keyboard.onboarding.practice.translate")
case .clipboardReply:
return ExtL10n.string("keyboard.onboarding.practice.reply")
case .askAI:
return ExtL10n.string("keyboard.onboarding.practice.askAI")
case nil:
return state.isOnboardingPracticeActive
? ExtL10n.string("keyboard.onboarding.practice.mic")
: nil
}
}
// MARK: - Primary actions
private var primaryActionRow: some View {
+23 -2
View File
@@ -15,6 +15,9 @@
"onboarding.api.localReady.title" = "Local ASR is ready";
"onboarding.api.localReady.body" = "Recognition works on-device. Add an API key in Settings for AI polish.";
"keyboard.onboarding.practice.mic" = "Tap the mic, then tap again when done";
"keyboard.onboarding.practice.translate" = "Tap Translate, then insert the generated text";
"keyboard.onboarding.practice.reply" = "Tap Reply and let AI draft a response";
"keyboard.onboarding.practice.askAI" = "Hold the mic and ask your question";
/* Common navigation */
"common.back" = "Back";
@@ -316,9 +319,24 @@
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
"keyboard.ai.skill.reply" = "Reply";
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.reply" = "Same-language reply";
"keyboard.ai.skill.playfulReply" = "Playful";
"keyboard.ai.skill.replyInSourceLanguage" = "Same language";
"keyboard.ai.skill.summarize" = "Key points";
"keyboard.ai.skill.openLink" = "Open";
"keyboard.ai.skill.summarizeWebPage" = "Web summary";
"keyboard.ai.skill.callPhone" = "Call";
"keyboard.ai.skill.createContact" = "New contact";
"keyboard.ai.skill.extractConclusions" = "Conclusions";
"keyboard.ai.skill.translate" = "Translate";
"keyboard.ai.skill.acceptInvitation" = "Accept";
"keyboard.ai.skill.declineInvitation" = "Decline";
"keyboard.ai.skill.acceptTask" = "Acknowledge";
"keyboard.ai.skill.clarifyRequest" = "Clarify";
"keyboard.ai.skill.empathyReply" = "Empathize";
"keyboard.ai.skill.askForDetails" = "Ask details";
"keyboard.ai.skill.businessReply" = "Business";
"keyboard.ai.skill.organizeList" = "Organize";
"keyboard.ai.skill.extractTodos" = "Tasks";
"keyboard.ai.skill.extractEvents" = "Events";
"keyboard.ai.skill.saveToNotes" = "Notes";
@@ -339,3 +357,6 @@
"keyboard.ai.skill.openingMaps" = "Opening Maps…";
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
"keyboard.ai.skill.handoffFailed" = "Couldnt open the app to run the Shortcut";
"keyboard.ai.skill.openLinkFailed" = "Couldnt open this link";
"keyboard.ai.skill.callPhoneFailed" = "Couldnt start this call";
"keyboard.ai.skill.createContactFailed" = "Couldnt open the new contact form";
+23 -2
View File
@@ -15,6 +15,9 @@
"onboarding.api.localReady.title" = "本地识别已就绪";
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
"keyboard.onboarding.practice.translate" = "点击「翻译」,再插入生成的文字";
"keyboard.onboarding.practice.reply" = "点击「回复」,让 AI 起草可发送的回复";
"keyboard.onboarding.practice.askAI" = "长按麦克风,说出你想问的问题";
/* Common navigation */
"common.back" = "返回";
@@ -316,9 +319,24 @@
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.reply" = "原语言回复";
"keyboard.ai.skill.playfulReply" = "俏皮回复";
"keyboard.ai.skill.replyInSourceLanguage" = "原语言回复";
"keyboard.ai.skill.summarize" = "总结要点";
"keyboard.ai.skill.openLink" = "打开";
"keyboard.ai.skill.summarizeWebPage" = "总结网页";
"keyboard.ai.skill.callPhone" = "拨打";
"keyboard.ai.skill.createContact" = "新建联系人";
"keyboard.ai.skill.extractConclusions" = "提取结论";
"keyboard.ai.skill.translate" = "翻译";
"keyboard.ai.skill.acceptInvitation" = "接受邀约";
"keyboard.ai.skill.declineInvitation" = "委婉拒绝";
"keyboard.ai.skill.acceptTask" = "确认任务";
"keyboard.ai.skill.clarifyRequest" = "澄清追问";
"keyboard.ai.skill.empathyReply" = "共情回复";
"keyboard.ai.skill.askForDetails" = "询问细节";
"keyboard.ai.skill.businessReply" = "商务回复";
"keyboard.ai.skill.organizeList" = "整理清单";
"keyboard.ai.skill.extractTodos" = "待办";
"keyboard.ai.skill.extractEvents" = "日程";
"keyboard.ai.skill.saveToNotes" = "备忘录";
@@ -339,3 +357,6 @@
"keyboard.ai.skill.openingMaps" = "正在打开地图…";
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
"keyboard.ai.skill.openLinkFailed" = "无法打开该链接";
"keyboard.ai.skill.callPhoneFailed" = "无法拨打该号码";
"keyboard.ai.skill.createContactFailed" = "无法打开新建联系人页面";
@@ -8,6 +8,38 @@ import Foundation
import XCTest
final class AnalyticsExtensionPrivacyTests: XCTestCase {
func testKeyboardRuntimeRecordsWithoutAutomaticallyUploading() async throws {
let network = ExtensionAnalyticsNetwork()
let runtime = AnalyticsRuntime.keyboardExtension(
environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0"),
repositoryConfiguration: AnalyticsRepositoryConfiguration(
databaseURL: try temporaryDatabaseURL()
),
uploadConfiguration: AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/v1/events")!
),
network: network,
wallClock: ExtensionAnalyticsClock(),
monotonicClock: ExtensionAnalyticsMonotonicClock(),
uuidGenerator: ExtensionAnalyticsUUIDGenerator(),
random: ExtensionAnalyticsRandomGenerator()
)
runtime.client.recordKeyboardActivated()
for _ in 0..<200 {
if await runtime.repository.debugSnapshot().pendingEvents.count == 1 {
break
}
try await Task.sleep(for: .milliseconds(10))
}
try await Task.sleep(for: .milliseconds(50))
let pendingSnapshot = await runtime.repository.debugSnapshot()
let requests = await network.requests()
XCTAssertEqual(pendingSnapshot.pendingEvents.count, 1)
XCTAssertTrue(requests.isEmpty)
}
func testKeyboardRuntimeUploadsWithoutAuthorizationOrSensitiveInputFields() async throws {
let databaseURL = try temporaryDatabaseURL()
let network = ExtensionAnalyticsNetwork()
@@ -57,8 +89,10 @@ final class AnalyticsExtensionPrivacyTests: XCTestCase {
let root = try XCTUnwrap(
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
)
XCTAssertEqual(Set(root.keys), ["installationId", "events"])
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
let event = try XCTUnwrap(eventObjects.first)
XCTAssertFalse(event.keys.contains("installationId"))
XCTAssertFalse(event.keys.contains("properties"))
XCTAssertFalse(event.keys.contains("text"))
XCTAssertFalse(event.keys.contains("prompt"))
@@ -13,6 +13,7 @@
// *don't* attempt that here the State class is what we care about
// for correctness.
import Combine
@testable import OSGKeyboardShared
import XCTest
@@ -144,6 +145,50 @@ final class KeyboardStateTests: XCTestCase {
XCTAssertEqual(state.clipboardOverlay, .none)
}
func testResolvedSkillCopyPublishesWhenIDsStayStable() {
let state = KeyboardState()
state.enabledClipboardSkillIDs = ["official.rewrite"]
let first = AIClipboardSkill(
id: "official.rewrite",
systemImage: "wand.and.stars",
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: .transform,
isDefault: false,
customName: "Rewrite",
customSummary: "Summary",
customPrompt: "First prompt"
)
let second = AIClipboardSkill(
id: "official.rewrite",
systemImage: "wand.and.stars",
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: .transform,
isDefault: false,
customName: "Rewrite updated",
customSummary: "Summary",
customPrompt: "Second prompt"
)
state.enabledClipboardSkills = [first]
let published = expectation(description: "resolved skill snapshot published")
var cancellable: AnyCancellable?
cancellable = state.$enabledClipboardSkills
.dropFirst()
.sink { skills in
XCTAssertEqual(skills.first?.customPrompt, "Second prompt")
published.fulfill()
}
state.enabledClipboardSkills = [second]
wait(for: [published], timeout: 1)
XCTAssertEqual(state.enabledClipboardSkillIDs, ["official.rewrite"])
withExtendedLifetime(cancellable) {}
}
func testReturnKeyRoleActionFillAndTitles() {
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
XCTAssertTrue(KeyboardState.ReturnKeyRole.send.usesActionFill)
@@ -0,0 +1,86 @@
// KeyboardUsageTypingTests.swift
// OSGKeyboardExtTests
//
// Chinese preedit remains in the IME; only committed candidate text is counted.
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class KeyboardUsageTypingTests: XCTestCase {
func testPinyinCompositionDoesNotCountLatinBeforeHanCommit() {
let engine = KeyboardUsageRimeStub()
let typing = TypingSessionController(engine: { engine })
let first = typing.handleKey("n")
let second = typing.handleKey("i")
XCTAssertEqual(first, .none)
XCTAssertEqual(second, .none)
XCTAssertEqual(engine.composition.rawInput, "ni")
let committed = typing.handleSpace()
XCTAssertEqual(committed, .insert("你好"))
let counts = KeyboardUsageCharacterClassifier.classify(committed.text)
XCTAssertEqual(counts.chinese, 2)
XCTAssertEqual(counts.english, 0)
XCTAssertEqual(counts.other, 0)
}
}
@MainActor
private final class KeyboardUsageRimeStub: RimeEngineBridging {
private(set) var composition: TypingComposition = .empty
var isReady = true
var schema: TypingInputSchema = .fullPinyin
func prepare() async throws {}
func teardown() {
composition = .empty
}
func setLanguage(_ language: TypingInputLanguage) {}
@discardableResult
func setSchema(_ schema: TypingInputSchema) -> Bool {
self.schema = schema
return true
}
func processCharacter(_ character: Character) -> String? {
composition.rawInput.append(character)
composition.preedit = composition.rawInput
return nil
}
func processBackspace() -> String? {
if !composition.rawInput.isEmpty {
composition.rawInput.removeLast()
}
composition.preedit = composition.rawInput
return nil
}
func processSpace() -> String? {
composition = .empty
return "你好"
}
func processReturn() -> String? {
processSpace()
}
func selectCandidate(at index: Int) -> String {
processSpace() ?? ""
}
func flushPreedit() -> String {
let raw = composition.rawInput
composition = .empty
return raw
}
func clearComposition() {
composition = .empty
}
}
+248 -4
View File
@@ -38,6 +38,16 @@ final class ManagedGatewayTests: XCTestCase {
XCTAssertEqual(cloudScopes, [.polish, .assistant, .agent, .asr])
}
func testOOBEFeatureConflictIsNotMappedToNetworkFailure() {
let error = ManagedGatewayHTTP.error(
data: errorJSON("oobe_feature_already_used"),
status: 409,
requestId: "oobe-conflict"
)
XCTAssertEqual(error, .oobeFeatureAlreadyUsed)
}
func testCreateGrantSendsAccountTokenButStoresOnlyGrant() async throws {
GatewayStub.shared.enqueue(
201,
@@ -128,6 +138,40 @@ final class ManagedGatewayTests: XCTestCase {
XCTAssertEqual(try jsonBody(request)["refreshToken"] as? String, oldRefresh)
}
func testSignedOutPolicyRejectsCachedAccountGrant() async throws {
let suiteName = "ManagedGatewayTests.signedOut.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let now = Date(timeIntervalSince1970: 3_000)
let store = MemoryGrantStore(
credentials(
accessToken: "cached-account-access",
accessExpiresAt: now.addingTimeInterval(240),
refreshExpiresAt: now.addingTimeInterval(3_600),
receivedAt: now
)
)
let accountState = AppGroupStore(defaults: defaults)
let coordinator = GatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: stubSession(),
now: { now },
accountAccessPolicy: AppGroupManagedGatewayAccountAccessPolicy(
defaults: defaults
)
)
await XCTAssertThrowsManaged(.missingGrant) {
_ = try await coordinator.accessToken(for: .polish)
}
XCTAssertTrue(GatewayStub.shared.requests().isEmpty)
accountState.setManagedGatewayAccountSessionAvailable(true)
let token = try await coordinator.accessToken(for: .polish)
XCTAssertEqual(token, "cached-account-access")
}
func testManagedClientMapsAllCapabilitiesHeadersBodyAndRequestIds() async throws {
let now = Date(timeIntervalSince1970: 3_000)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
@@ -187,6 +231,7 @@ final class ManagedGatewayTests: XCTestCase {
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
(.polish, .translation),
(.polish, .editLastInput),
(.assistant, .currentInformationQuestion),
(.assistant, .clipboardTransform),
(.assistant, .customSkill)
]
@@ -209,15 +254,62 @@ final class ManagedGatewayTests: XCTestCase {
XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue })
}
func testManagedHotwordSerializesSourceAndCurrentInformationIntent() async throws {
let now = Date(timeIntervalSince1970: 3_600)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
let client = ManagedLLMClient(
capability: .assistant,
taskKind: .currentInformationQuestion,
requestSource: .hotword,
grants: makeCoordinator(store: store, now: { now }),
baseURL: baseURL,
session: stubSession()
)
_ = try await client.polish("today's news", systemPrompt: "context")
let request = try XCTUnwrap(GatewayStub.shared.requests().first)
let body = try jsonBody(request)
XCTAssertEqual(body["taskKind"] as? String, "current_information_question")
XCTAssertEqual(body["requestSource"] as? String, "hotword")
}
func testManagedClientSerializesOOBERequestPurpose() async throws {
let now = Date(timeIntervalSince1970: 3_750)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
let store = MemoryGrantStore(credentials(accessToken: "account-access", receivedAt: now))
let oobeStore = MemoryGrantStore(
credentials(
accessToken: "oobe-access",
receivedAt: now,
scopes: [.polish, .assistant]
)
)
let suite = "managed.oobe.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let practice = try XCTUnwrap(
KeyboardSetupBridge.beginOOBEPracticeSession(
expectedFeature: .voiceInput,
defaults: defaults,
now: now
)
)
XCTAssertEqual(practice.expectedFeature, .voiceInput)
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
let client = ManagedLLMClient(
capability: .polish,
taskKind: .dictationPolish,
requestPurpose: .oobe,
oobeFeature: .voiceInput,
grants: makeCoordinator(store: store, now: { now }),
oobeGrants: OOBEGatewayGrantCoordinator(
baseURL: baseURL,
store: oobeStore,
session: stubSession(),
now: { now },
practiceSession: { practice }
),
baseURL: baseURL,
session: stubSession()
)
@@ -228,6 +320,107 @@ final class ManagedGatewayTests: XCTestCase {
let body = try jsonBody(request)
XCTAssertEqual(body["taskKind"] as? String, "dictation_polish")
XCTAssertEqual(body["requestPurpose"] as? String, "oobe")
XCTAssertEqual(body["oobeFeature"] as? String, "voice_input")
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer oobe-access")
let accountCredentials = await store.value()
XCTAssertEqual(accountCredentials?.accessToken, "account-access")
}
func testOOBEGrantRejectsAgentOrASRScopes() async {
let now = Date(timeIntervalSince1970: 3_775)
let coordinator = OOBEGatewayGrantCoordinator(
baseURL: baseURL,
store: MemoryGrantStore(),
session: stubSession(),
now: { now }
)
await XCTAssertThrowsManaged(.invalidGrant) {
try await coordinator.install(
self.credentials(
receivedAt: now,
scopes: [.polish, .assistant, .agent]
)
)
}
await XCTAssertThrowsManaged(.invalidGrant) {
try await coordinator.install(
self.credentials(
receivedAt: now,
scopes: [.polish, .assistant, .asr]
)
)
}
}
func testOOBERefreshUsesDedicatedEndpoint() async throws {
let now = Date(timeIntervalSince1970: 3_790)
let practice = OOBEPracticeSession(
sessionID: UUID(),
expectedFeature: .voiceInput,
startedAt: now.addingTimeInterval(-10),
expiresAt: now.addingTimeInterval(300)
)
let store = MemoryGrantStore(
credentials(
accessToken: "expired",
accessExpiresAt: now.addingTimeInterval(-1),
receivedAt: now.addingTimeInterval(-600),
scopes: [.polish, .assistant]
)
)
GatewayStub.shared.enqueue(
200,
grantJSON(
access: "refreshed",
refresh: "rotated",
scopes: ["polish", "ai"]
)
)
let coordinator = OOBEGatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: stubSession(),
now: { now },
practiceSession: { practice }
)
let token = try await coordinator.accessToken(
for: .polish,
feature: .voiceInput
)
XCTAssertEqual(token, "refreshed")
XCTAssertEqual(
GatewayStub.shared.requests().first?.url?.path,
"/v1/oobe/grants/refresh"
)
}
func testOOBEClientRejectsMissingFeatureWithoutUsingAccountGrant() async {
let now = Date(timeIntervalSince1970: 3_800)
let store = MemoryGrantStore(credentials(accessToken: "account-access", receivedAt: now))
let client = ManagedLLMClient(
capability: .polish,
requestPurpose: .oobe,
grants: makeCoordinator(store: store, now: { now }),
baseURL: baseURL,
session: stubSession()
)
await XCTAssertThrowsManaged(
.server(code: "missing_oobe_feature", status: 400, requestId: "fixed")
) {
_ = try await ManagedLLMClient(
capability: client.capability,
requestPurpose: client.requestPurpose,
grants: makeCoordinator(store: store, now: { now }),
baseURL: self.baseURL,
session: self.stubSession(),
requestId: { "fixed" }
).polish("input", systemPrompt: "context")
}
XCTAssertTrue(GatewayStub.shared.requests().isEmpty)
}
func testManagedTaskKindWireValuesMatchServerContract() {
@@ -238,6 +431,7 @@ final class ManagedGatewayTests: XCTestCase {
"translation",
"edit_last_input",
"ai_question",
"current_information_question",
"clipboard_transform",
"custom_skill",
"agent_planning"
@@ -337,6 +531,49 @@ final class ManagedGatewayTests: XCTestCase {
XCTAssertNil(remaining)
}
func testProviderFailuresAreMappedToDistinctManagedErrors() {
XCTAssertEqual(
ManagedGatewayHTTP.error(
data: errorJSON("provider_unavailable"),
status: 503,
requestId: "provider-unavailable"
),
.providerUnavailable(requestId: "request-id")
)
XCTAssertEqual(
ManagedGatewayHTTP.error(
data: errorJSON("provider_rate_limited"),
status: 503,
requestId: "provider-rate-limited"
),
.providerRateLimited(requestId: "request-id")
)
XCTAssertEqual(
ManagedGatewayHTTP.error(
data: errorJSON("provider_timeout"),
status: 504,
requestId: "provider-timeout"
),
.providerTimeout(requestId: "request-id")
)
XCTAssertEqual(
ManagedGatewayHTTP.error(
data: errorJSON("provider_invalid_response"),
status: 502,
requestId: "provider-invalid"
),
.providerFailure(requestId: "request-id")
)
XCTAssertEqual(
ManagedGatewayHTTP.error(
data: errorJSON("internal_failure"),
status: 500,
requestId: "internal-failure"
),
.internalFailure(requestId: "request-id")
)
}
func testTransportTimeoutMapsToManagedTimeout() async {
let now = Date(timeIntervalSince1970: 6_500)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
@@ -412,7 +649,13 @@ final class ManagedGatewayTests: XCTestCase {
store: MemoryGrantStore,
now: @escaping @Sendable () -> Date = Date.init
) -> GatewayGrantCoordinator {
GatewayGrantCoordinator(baseURL: baseURL, store: store, session: stubSession(), now: now)
GatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: stubSession(),
now: now,
accountAccessPolicy: UnrestrictedManagedGatewayAccountAccessPolicy()
)
}
private func stubSession() -> URLSession {
@@ -426,11 +669,12 @@ final class ManagedGatewayTests: XCTestCase {
refreshToken: String = "refresh-token-value-12345678901234567890",
accessExpiresAt: Date = Date(timeIntervalSince1970: 20_000),
refreshExpiresAt: Date = Date(timeIntervalSince1970: 40_000),
receivedAt: Date = Date(timeIntervalSince1970: 10_000)
receivedAt: Date = Date(timeIntervalSince1970: 10_000),
scopes: Set<ManagedGatewayCapability> = [.polish, .assistant, .agent]
) -> ManagedGatewayGrantCredentials {
ManagedGatewayGrantCredentials(
grantId: "11111111-1111-1111-1111-111111111111",
scopes: [.polish, .assistant, .agent],
scopes: scopes,
accessToken: accessToken,
accessExpiresAt: accessExpiresAt,
refreshToken: refreshToken,
@@ -4,6 +4,9 @@
// The single HTTP exit for account, auth, and integrity traffic.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public protocol AccountHTTPTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
@@ -36,6 +39,7 @@ public actor AccountAPIClient {
case integrityChallenge
case attest
case assert
case oobeGrant
var method: String {
switch self {
@@ -66,12 +70,15 @@ public actor AccountAPIClient {
return "/v1/integrity/attest"
case .assert:
return "/v1/integrity/assert"
case .oobeGrant:
return "/v1/oobe/grants"
}
}
}
private struct RefreshOperation {
let id: UUID
let failedAccessToken: String
let task: Task<AccountSession, Error>
}
@@ -85,6 +92,9 @@ public actor AccountAPIClient {
private var cachedSession: AccountSession?
private var didLoadSession = false
private var refreshOperation: RefreshOperation?
private var invalidationContinuations: [
UUID: AsyncStream<AccountSessionInvalidation>.Continuation
] = [:]
public init(
baseURL: URL = URL(string: "https://account.osglab.com")!,
@@ -117,6 +127,18 @@ public actor AccountAPIClient {
try await loadSessionIfNeeded()
}
public func sessionInvalidations() -> AsyncStream<AccountSessionInvalidation> {
let id = UUID()
return AsyncStream { continuation in
invalidationContinuations[id] = continuation
continuation.onTermination = { [weak self] _ in
Task {
await self?.removeInvalidationContinuation(id: id)
}
}
}
}
public func accessTokenForAuthorizedRequest() async throws -> String {
try await sessionForRequest().accessToken
}
@@ -184,7 +206,9 @@ public actor AccountAPIClient {
)
let retryResponse = try await send(retryRequest)
if retryResponse.response.statusCode == 401 {
try await clearSession()
try await invalidateSession(
ifAccessTokenMatches: replacement.accessToken
)
}
return try validatedData(retryResponse)
}
@@ -273,6 +297,25 @@ public actor AccountAPIClient {
return try decode(AppAssertionResponse.self, from: data).counter
}
public func requestOOBEGrant(
_ request: OOBEGrantRequest
) async throws -> ManagedGatewayGrantCredentials {
let data = try await perform(
endpoint: .oobeGrant,
body: try encode(request),
requiresSession: false
)
do {
let response = try Self.gatewayDecoder().decode(
ManagedGatewayGrantTokenResponse.self,
from: data
)
return response.credentials(receivedAt: now())
} catch {
throw AccountAPIError.decoding
}
}
private func perform(
endpoint: Endpoint,
body: Data?,
@@ -319,7 +362,9 @@ public actor AccountAPIClient {
)
let retryResponse = try await send(retryRequest)
if retryResponse.response.statusCode == 401 {
try await clearSession()
try await invalidateSession(
ifAccessTokenMatches: replacement.accessToken
)
}
return try validatedData(retryResponse)
}
@@ -345,7 +390,9 @@ public actor AccountAPIClient {
}
let nowSeconds = Int64(now().timeIntervalSince1970)
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
try await clearSession()
try await invalidateSession(
ifAccessTokenMatches: session.accessToken
)
throw AccountAPIError.unauthorized("The refresh token has expired.")
}
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
@@ -367,6 +414,7 @@ public actor AccountAPIClient {
let operation = RefreshOperation(
id: UUID(),
failedAccessToken: current.accessToken,
task: Task {
try await self.requestRefresh(using: current.refreshToken)
}
@@ -378,6 +426,18 @@ public actor AccountAPIClient {
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
do {
let replacement = try await operation.task.value
guard let current = try await loadSessionIfNeeded() else {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
throw AccountAPIError.sessionUnavailable
}
if current.accessToken != operation.failedAccessToken {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
return current
}
try await replaceSession(with: replacement)
if refreshOperation?.id == operation.id {
refreshOperation = nil
@@ -387,8 +447,14 @@ public actor AccountAPIClient {
if refreshOperation?.id == operation.id {
refreshOperation = nil
}
if let current = try? await loadSessionIfNeeded(),
current.accessToken != operation.failedAccessToken {
return current
}
if shouldClearSession(afterRefreshError: error) {
try? await clearSession()
try? await invalidateSession(
ifAccessTokenMatches: operation.failedAccessToken
)
}
throw error
}
@@ -451,6 +517,30 @@ public actor AccountAPIClient {
}
}
private func invalidateSession(ifAccessTokenMatches expectedAccessToken: String) async throws {
guard let current = try await loadSessionIfNeeded(),
current.accessToken == expectedAccessToken else {
return
}
do {
try await clearSession()
} catch {
publishSessionInvalidation()
throw error
}
publishSessionInvalidation()
}
private func publishSessionInvalidation() {
invalidationContinuations.values.forEach {
$0.yield(.expired)
}
}
private func removeInvalidationContinuation(id: UUID) {
invalidationContinuations[id] = nil
}
private func makeRequest(
endpoint: Endpoint,
body: Data?,
@@ -564,6 +654,29 @@ public actor AccountAPIClient {
throw AccountAPIError.decoding
}
}
private static func gatewayDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: value) {
return date
}
let standard = ISO8601DateFormatter()
standard.formatOptions = [.withInternetDateTime]
guard let date = standard.date(from: value) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid ISO-8601 date"
)
}
return date
}
return decoder
}
}
extension Data {
@@ -166,6 +166,31 @@ public struct AppAttestAssertion: Codable, Equatable, Sendable {
}
}
/// Anonymous OOBE grant request. It intentionally contains no account session,
/// requested scopes, or mutable feature claim; the server fixes policy to
/// `polish` + `ai` after validating App Attest.
public struct OOBEGrantRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let keyId: String
public let challengeId: UUID
public let challenge: String
public let assertion: String
public init(
installationId: UUID,
keyId: String,
challengeId: UUID,
challenge: String,
assertion: String
) {
self.installationId = installationId
self.keyId = keyId
self.challengeId = challengeId
self.challenge = challenge
self.assertion = assertion
}
}
public enum AppAttestChallengePurpose: String, Codable, Sendable {
case attestation
case assertion
@@ -205,6 +230,10 @@ public protocol AppAttestKeyStateStoring: Sendable {
func clearAppAttestKeyState() async throws
}
public protocol OOBEInstallationIDStoring: Sendable {
func oobeInstallationID() async throws -> UUID
}
public enum AccountAPIError: Error, Equatable, Sendable {
case invalidRequest(String)
case unauthorized(String)
@@ -222,6 +251,10 @@ public enum AccountAPIError: Error, Equatable, Sendable {
case integrityUnavailable
}
public enum AccountSessionInvalidation: Sendable {
case expired
}
extension AccountAPIError: LocalizedError {
public var errorDescription: String? {
switch self {
@@ -128,6 +128,35 @@ public enum AppAttestCanonicalPayload {
return data
}
/// Matches the server's anonymous OOBE grant assertion payload byte for
/// byte. The final line feed is part of the signed UTF-8 value.
public static func oobeGrant(
challenge: String,
installationID: UUID,
keyID: String
) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
}
let canonicalChallenge = challengeBytes.base64URLEncodedString()
let payload = """
osg-app-attest-v1
purpose=oobe-gateway-grant
challenge=\(canonicalChallenge)
key_id=\(keyID)
installation_id=\(installationID.uuidString.lowercased())
scopes=ai,polish
features=ask_ai,clipboard_reply,clipboard_translate,voice_input
grant_ttl_seconds=1800
access_ttl_seconds=300
"""
guard let data = payload.data(using: .utf8) else {
throw AccountAPIError.invalidResponse
}
return data
}
static func challengeHash(_ challenge: String) throws -> Data {
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
throw AccountAPIError.invalidResponse
@@ -187,6 +216,15 @@ public actor DeviceIntegrityCoordinator {
try? await keyStateStore.clearAppAttestKeyState()
}
public func makeOOBEGrantRequest(
installationID: UUID
) async throws -> OOBEGrantRequest {
try await makeOOBEGrantRequest(
installationID: installationID,
allowsKeyRecovery: true
)
}
private func optionalDeviceCheckToken() async -> String? {
try? await makeDeviceCheckToken()
}
@@ -250,6 +288,47 @@ public actor DeviceIntegrityCoordinator {
)
}
private func makeOOBEGrantRequest(
installationID: UUID,
allowsKeyRecovery: Bool
) async throws -> OOBEGrantRequest {
guard appAttest.isSupported else {
throw AccountAPIError.integrityUnavailable
}
let keyID = try await registeredKeyId()
let challenge = try await apiClient.issueAppAttestChallenge(
purpose: .assertion,
keyId: keyID
)
let payload = try AppAttestCanonicalPayload.oobeGrant(
challenge: challenge.challenge,
installationID: installationID,
keyID: keyID
)
let assertion: Data
do {
assertion = try await appAttest.generateAssertion(
keyID,
clientDataHash: AppAttestCanonicalPayload.sha256(payload)
)
} catch where allowsKeyRecovery {
try? await keyStateStore.clearAppAttestKeyState()
return try await makeOOBEGrantRequest(
installationID: installationID,
allowsKeyRecovery: false
)
} catch {
throw AccountAPIError.integrityUnavailable
}
return OOBEGrantRequest(
installationId: installationID,
keyId: keyID,
challengeId: challenge.challengeId,
challenge: challenge.challenge,
assertion: assertion.base64EncodedString()
)
}
private func registeredKeyId(
allowsKeyRecovery: Bool = true
) async throws -> String {
@@ -38,10 +38,14 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
}
}
public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateStoring {
public actor HostPrivateAccountKeychain:
AccountSessionVault,
AppAttestKeyStateStoring,
OOBEInstallationIDStoring {
private enum Account {
static let session = "account.session"
static let appAttestKeyState = "integrity.app-attest-key-state"
static let oobeInstallationID = "oobe.installation-id"
}
let descriptor: HostPrivateAccountKeychainDescriptor
@@ -78,6 +82,15 @@ public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateS
try delete(account: Account.appAttestKeyState)
}
public func oobeInstallationID() async throws -> UUID {
if let existing = try read(UUID.self, account: Account.oobeInstallationID) {
return existing
}
let created = UUID()
try write(created, account: Account.oobeInstallationID)
return created
}
private func read<Value: Decodable>(_ type: Value.Type, account: String) throws -> Value? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
@@ -0,0 +1,44 @@
// OOBEGrantProvisioningCoordinator.swift
// OSGKeyboard · HostSupport
//
// Provisions anonymous, App-Attest-bound OOBE credentials without creating or
// mutating an AccountSession.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public actor OOBEGrantProvisioningCoordinator {
private let apiClient: AccountAPIClient
private let integrity: DeviceIntegrityCoordinator
private let installationIDs: any OOBEInstallationIDStoring
private let grants: OOBEGatewayGrantCoordinator
public init(
apiClient: AccountAPIClient,
integrity: DeviceIntegrityCoordinator,
installationIDs: any OOBEInstallationIDStoring,
grants: OOBEGatewayGrantCoordinator = OOBEGatewayGrantCoordinator()
) {
self.apiClient = apiClient
self.integrity = integrity
self.installationIDs = installationIDs
self.grants = grants
}
@discardableResult
public func provision() async throws -> ManagedGatewayGrantCredentials {
let installationID = try await installationIDs.oobeInstallationID()
let request = try await integrity.makeOOBEGrantRequest(
installationID: installationID
)
let credentials = try await apiClient.requestOOBEGrant(request)
try await grants.install(credentials)
return credentials
}
public func clear() async {
try? await grants.clearGrant()
}
}
@@ -262,6 +262,9 @@ enum MacDictationPipeline {
}
return MacDictationResult(
text: polished,
prePolishText: postASR,
polishStyleID: outcome.polishStyleID,
polishStylePrompt: outcome.polishStylePrompt,
polishWarning: outcome.qualityDegraded
? MacL10n.string("flow.warning.polishDegradedQuality")
: nil,
@@ -276,6 +279,9 @@ enum MacDictationPipeline {
)
return MacDictationResult(
text: delivery.text,
prePolishText: postASR,
polishStyleID: nil,
polishStylePrompt: nil,
polishWarning: delivery.polishWarning,
chunkWarning: nil
)
+5
View File
@@ -5,6 +5,11 @@ import Foundation
struct MacDictationResult: Sendable, Equatable {
let text: String
/// Corrected ASR transcript before LLM polishing; never shown in History.
let prePolishText: String
/// Exact style metadata returned by the successful polish request.
let polishStyleID: String?
let polishStylePrompt: String?
/// Shown when DeepSeek / cloud polish failed but raw ASR was delivered.
let polishWarning: String?
/// Non-fatal per-chunk ASR issues from long utterance chunking.
+9 -1
View File
@@ -423,7 +423,15 @@ final class MacDictationViewModel: ObservableObject {
targetApplication: targetApplication
)
self.recordUsage(for: result.text)
self.speechHistory.append(text: result.text)
self.speechHistory.append(
text: result.text,
prePolishText: result.prePolishText,
wasTranslation: store.isTranslationEffective,
polishStyleID: result.polishStyleID
?? store.activePolishStyleId,
polishStylePrompt: result.polishStylePrompt,
engineMode: store.engineMode
)
self.appendToOverview(result.text)
self.statusMessage = self.statusAfterDelivery(
pasted: pasted,
+132
View File
@@ -8,11 +8,13 @@ import SwiftUI
struct MacPolishStylesView: View {
@ObservedObject var viewModel: MacDictationViewModel
@ObservedObject private var history = SpeechHistoryStore.shared
@Environment(\.themePalette) private var palette
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
@State private var errorMessage: String?
@State private var isGeneratingLearnedStyle = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var store: AppGroupStore { AppGroupStore(defaults: viewModel.defaults) }
@@ -59,6 +61,7 @@ struct MacPolishStylesView: View {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
styleLearningCard
styleSection(
title: MacL10n.string("mac.styles.builtin", language: lang),
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
@@ -114,6 +117,103 @@ struct MacPolishStylesView: View {
}
}
private var styleLearningCorpus: PolishStyleLearningCorpus {
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
}
private var styleLearningCard: some View {
let corpus = styleLearningCorpus
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
return VStack(alignment: .leading, spacing: Spacing.md) {
HStack(alignment: .top, spacing: Spacing.md) {
Image(systemName: "waveform")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 42, height: 42)
.background(
palette.accentMuted,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
VStack(alignment: .leading, spacing: 3) {
Text(MacL10n.string("mac.styles.learn.title", language: lang))
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(MacL10n.string("mac.styles.learn.body", language: lang))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
}
ProgressView(
value: Double(min(corpus.effectiveCharacterCount, required)),
total: Double(required)
)
.tint(palette.accent)
HStack {
Text(
MacL10n.format(
"mac.styles.learn.progress",
language: lang,
Int64(corpus.effectiveCharacterCount),
Int64(required)
)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Spacer()
Text(
corpus.isReady
? MacL10n.string("mac.styles.learn.ready", language: lang)
: MacL10n.format(
"mac.styles.learn.remaining",
language: lang,
Int64(corpus.remainingCharacterCount)
)
)
.font(TypeStyle.caption2)
.foregroundStyle(corpus.isReady ? palette.accent : palette.textTertiary)
}
HStack {
Text(
MacL10n.string(
reachedLimit
? "mac.styles.learn.limit"
: "mac.styles.learn.privacy",
language: lang
)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Spacer()
Button {
generateLearnedStyle(from: corpus)
} label: {
if isGeneratingLearnedStyle {
ProgressView()
.controlSize(.small)
}
Text(
MacL10n.string(
isGeneratingLearnedStyle
? "mac.styles.learn.generating"
: "mac.styles.learn.action",
language: lang
)
)
}
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.disabled(!corpus.isReady || isGeneratingLearnedStyle || reachedLimit)
}
}
.padding(Spacing.lg)
.surfaceCard()
}
private func styleSection(title: String, packs: [PolishStylePack]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(title)
@@ -160,6 +260,38 @@ struct MacPolishStylesView: View {
)
}
private func generateLearnedStyle(from corpus: PolishStyleLearningCorpus) {
guard corpus.isReady, !isGeneratingLearnedStyle else { return }
isGeneratingLearnedStyle = true
Task {
defer { isGeneratingLearnedStyle = false }
do {
let generated = try await PolishStyleLearningService(store: store)
.generateStyle(from: corpus, outputLanguage: lang)
// Review is mandatory before the learned prompt enters sync or
// becomes the active dictation personality.
editingPack = generated
} catch {
errorMessage = localizedLearningError(error)
}
}
}
private func localizedLearningError(_ error: Error) -> String {
switch error as? PolishStyleLearningError {
case .insufficientCorpus:
return MacL10n.string("mac.styles.learn.error.insufficient", language: lang)
case .invalidResponse:
return MacL10n.string("mac.styles.learn.error.invalidResponse", language: lang)
case .promptTooLong:
return MacL10n.string("mac.styles.learn.error.promptTooLong", language: lang)
case .requestTooLarge:
return MacL10n.string("mac.styles.learn.error.requestTooLarge", language: lang)
case nil:
return MacL10n.string("mac.styles.learn.error.request", language: lang)
}
}
private func subtitle(for pack: PolishStylePack) -> String {
if pack.kind == .user {
return MacL10n.string("mac.styles.customDescription", language: lang)
+2 -2
View File
@@ -16,12 +16,12 @@ public enum AppGroup {
/// The suite alone can appear to open while the container is still `(null)`
/// when provisioning is misconfigured that case produces the
/// `CFPrefsPlistSource Container: (null)` console warning.
public static let isAvailable: Bool = {
public static var isAvailable: Bool {
guard UserDefaults(suiteName: identifier) != nil else { return false }
return FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: identifier
) != nil
}()
}
/// On-disk container path (or `"nil"`) for diagnostics. When this reads
/// `nil` in a process, that process cannot share App Group state the
@@ -42,7 +42,8 @@ public protocol ConfigurationStore: Sendable {
func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?
requestPurpose: ManagedGatewayRequestPurpose?,
oobeFeature: ManagedGatewayOOBEFeature?
) -> LLMClient
}
@@ -51,10 +52,21 @@ public extension ConfigurationStore {
var credentialSource: CredentialSource { .byok }
func makeClient() -> LLMClient {
makeClient(taskKind: nil, requestPurpose: nil)
makeClient(taskKind: nil, requestPurpose: nil, oobeFeature: nil)
}
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
makeClient(taskKind: taskKind, requestPurpose: nil)
makeClient(taskKind: taskKind, requestPurpose: nil, oobeFeature: nil)
}
func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?
) -> LLMClient {
makeClient(
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: nil
)
}
}
@@ -126,13 +126,15 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
public func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?
requestPurpose: ManagedGatewayRequestPurpose?,
oobeFeature: ManagedGatewayOOBEFeature?
) -> LLMClient {
if credentialSource == .managed {
if credentialSource == .managed || requestPurpose == .oobe {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
grants: GatewayGrantCoordinator()
)
}
@@ -174,7 +174,7 @@ public struct AnalyticsRepositoryConfiguration: Sendable {
maximumEventCount: Int = 10_000,
maximumStoredBytes: Int = 5 * 1_024 * 1_024,
eventRetention: TimeInterval = 34 * 24 * 60 * 60,
busyTimeoutMilliseconds: Int32 = 2_000
busyTimeoutMilliseconds: Int32 = 250
) {
self.databaseURL = databaseURL
self.maximumEventCount = max(1, maximumEventCount)
@@ -89,7 +89,6 @@ public enum AnalyticsModelError: Error, Sendable {
}
public struct AnalyticsEvent: Codable, Equatable, Sendable {
public let installationId: UUID
public let clientEventId: UUID
public let eventType: AnalyticsEventType
public let occurredAt: Date
@@ -103,7 +102,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
public let durationBucket: AnalyticsDurationBucket?
public init(
installationId: UUID,
clientEventId: UUID,
eventType: AnalyticsEventType,
occurredAt: Date,
@@ -121,7 +119,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
throw AnalyticsModelError.invalidVersion
}
self.installationId = installationId
self.clientEventId = clientEventId
self.eventType = eventType
self.occurredAt = occurredAt
@@ -140,7 +137,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case clientEventId
case eventType
case occurredAt
@@ -160,7 +156,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
let installationId = try container.decode(UUID.self, forKey: .installationId)
let clientEventId = try container.decode(UUID.self, forKey: .clientEventId)
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
@@ -172,7 +167,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
)
}
try self.init(
installationId: installationId,
clientEventId: clientEventId,
eventType: eventType,
occurredAt: occurredAt,
@@ -204,7 +198,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
throw AnalyticsModelError.invalidDimensions(eventType)
}
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(installationId, forKey: .installationId)
try container.encode(clientEventId, forKey: .clientEventId)
try container.encode(eventType, forKey: .eventType)
try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt)
@@ -237,13 +230,16 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
}
public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let events: [AnalyticsEvent]
public init(events: [AnalyticsEvent]) {
public init(installationId: UUID, events: [AnalyticsEvent]) {
self.installationId = installationId
self.events = events
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case events
}
@@ -253,6 +249,7 @@ public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
installationId = try container.decode(UUID.self, forKey: .installationId)
events = try container.decode([AnalyticsEvent].self, forKey: .events)
}
}
@@ -335,6 +332,7 @@ private extension AnalyticsEventType {
switch self {
case .firstOpen, .inviteOpened:
return [
.none,
DimensionSet(
acquisitionChannel: true,
feature: false,
@@ -72,6 +72,7 @@ struct AnalyticsLeasedEvent: Sendable {
struct AnalyticsLeasedBatch: Sendable {
let leaseID: String
let installationID: UUID
let events: [AnalyticsLeasedEvent]
let body: Data
}
@@ -96,6 +97,7 @@ public actor AnalyticsRepository {
private let clock: any AnalyticsWallClock
private let uuidGenerator: any AnalyticsUUIDGenerating
private var database: SQLiteDatabase?
private var isDatabaseAccessSuspended = false
public init(
configuration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
@@ -107,6 +109,19 @@ public actor AnalyticsRepository {
self.uuidGenerator = uuidGenerator
}
/// Re-enables process-local access before foreground or BGTask work.
public func resumeDatabaseAccess() {
isDatabaseAccessSuspended = false
}
/// Runs after earlier actor operations, then closes the process-local
/// connection. Later fire-and-forget records become best-effort no-ops
/// until an explicitly authorized execution window resumes access.
public func suspendDatabaseAccess() {
isDatabaseAccessSuspended = true
database = nil
}
/// The host calls this only after cold-start attribution has been resolved.
/// Installation identity, FIRST_OPEN and its marker share one transaction.
public func prepare(
@@ -127,7 +142,6 @@ public actor AnalyticsRepository {
return
}
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: .firstOpen,
occurredAt: clock.now(),
@@ -136,7 +150,11 @@ public actor AnalyticsRepository {
osVersion: context.environment.osVersion,
acquisitionChannel: firstOpenAcquisitionChannel
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try setMetadata(
"1",
for: MetadataKey.firstOpenRecorded,
@@ -158,6 +176,21 @@ public actor AnalyticsRepository {
}
}
/// Returns the existing App Group installation UUID, creating it only while
/// analytics is enabled. Aggregate keyboard usage reuses this identity.
public func installationIdentifierIfEnabled() -> UUID? {
guard let database = openDatabaseIfNeeded() else { return nil }
do {
return try database.immediateTransaction {
_ = try ensureEnabled(database)
guard try isEnabled(database) else { return nil }
return try ensureInstallation(database)
}
} catch {
return nil
}
}
/// Read-only diagnostics for tests and support tooling. Event payload content
/// is deliberately excluded from this public snapshot.
public func debugSnapshot() -> AnalyticsRepositoryDebugSnapshot {
@@ -341,7 +374,6 @@ public actor AnalyticsRepository {
guard try isEnabled(database) else { return }
let installationID = try ensureInstallation(database)
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: eventType,
occurredAt: clock.now(),
@@ -354,7 +386,11 @@ public actor AnalyticsRepository {
failureCategory: dimensions.failureCategory,
durationBucket: dimensions.durationBucket
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try enforceStoragePolicy(database)
}
}
@@ -388,7 +424,6 @@ public actor AnalyticsRepository {
let installationID = try ensureInstallation(database)
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: .sessionStarted,
occurredAt: now,
@@ -396,7 +431,11 @@ public actor AnalyticsRepository {
appVersion: context.environment.appVersion,
osVersion: context.environment.osVersion
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try setMetadata(
String(now.timeIntervalSince1970),
for: key,
@@ -427,19 +466,42 @@ public actor AnalyticsRepository {
try releaseExpiredEventLeases(now: now, database: database)
try deleteExpiredEvents(now: now, database: database)
guard let installationText = try database.query(
"""
SELECT installation_id
FROM pending_events
WHERE next_attempt_at <= ? AND lease_id IS NULL
ORDER BY priority DESC, created_at ASC
LIMIT 1
""",
bindings: [.double(now)]
).first?.text(at: 0),
let installationID = UUID(uuidString: installationText) else {
try clearUploadLease(database)
return nil
}
let candidates = try database.query(
"""
SELECT id, payload, attempt_count
FROM pending_events
WHERE next_attempt_at <= ? AND lease_id IS NULL
WHERE installation_id = ?
AND next_attempt_at <= ?
AND lease_id IS NULL
ORDER BY priority DESC, created_at ASC
LIMIT ?
""",
bindings: [.double(now), .int64(Int64(upload.maximumBatchCount))]
bindings: [
.text(installationText),
.double(now),
.int64(Int64(upload.maximumBatchCount))
]
)
var selected: [AnalyticsLeasedEvent] = []
var bodySize = Self.emptyRequestBody.count
var bodySize = Self.emptyRequestBody(
installationID: installationID
).count
for row in candidates {
guard let rowID = row.int64(at: 0),
let payload = row.data(at: 1),
@@ -490,8 +552,12 @@ public actor AnalyticsRepository {
}
return AnalyticsLeasedBatch(
leaseID: leaseID,
installationID: installationID,
events: selected,
body: Self.requestBody(for: selected)
body: Self.requestBody(
for: selected,
installationID: installationID
)
)
}
} catch {
@@ -682,18 +748,20 @@ public actor AnalyticsRepository {
private func insert(
_ event: AnalyticsEvent,
installationID: UUID,
database: SQLiteDatabase
) throws {
let payload = try AnalyticsCanonicalJSON.encode(event)
try database.execute(
"""
INSERT OR IGNORE INTO pending_events (
client_event_id, event_type, occurred_at, surface, payload,
payload_size, priority, lease_id, lease_expires_at,
installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, priority, lease_id, lease_expires_at,
attempt_count, next_attempt_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
""",
bindings: [
.text(installationID.uuidString.lowercased()),
.text(event.clientEventId.uuidString.lowercased()),
.text(event.eventType.rawValue),
.double(event.occurredAt.timeIntervalSince1970),
@@ -873,11 +941,12 @@ public actor AnalyticsRepository {
try database.execute(
"""
INSERT INTO quarantined_events (
client_event_id, event_type, occurred_at, surface, payload,
payload_size, attempt_count, reason, quarantined_at
installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, attempt_count, reason,
quarantined_at
)
SELECT client_event_id, event_type, occurred_at, surface, payload,
payload_size, attempt_count, ?, ?
SELECT installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, attempt_count, ?, ?
FROM pending_events
WHERE id = ?\(leaseClause)
""",
@@ -893,10 +962,18 @@ public actor AnalyticsRepository {
try enforceStoragePolicy(database)
}
private static let emptyRequestBody = Data(#"{"events":[]}"#.utf8)
private static func emptyRequestBody(installationID: UUID) -> Data {
requestBody(for: [], installationID: installationID)
}
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
var body = Data(#"{"events":["#.utf8)
private static func requestBody(
for events: [AnalyticsLeasedEvent],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
.utf8
)
for index in events.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
@@ -926,6 +1003,7 @@ public actor AnalyticsRepository {
// MARK: - Metadata and setup
private func openDatabaseIfNeeded() -> SQLiteDatabase? {
guard !isDatabaseAccessSuspended else { return nil }
if let database {
return database
}
@@ -952,7 +1030,7 @@ public actor AnalyticsRepository {
private func migrate(_ database: SQLiteDatabase) throws {
let version = try database.scalarInt64("PRAGMA user_version") ?? 0
guard version <= 1 else {
guard version <= 2 else {
throw SQLiteStoreError(
code: SQLITE_MISMATCH,
category: .schema,
@@ -969,58 +1047,90 @@ public actor AnalyticsRepository {
) WITHOUT ROWID
"""
)
try createCurrentQueueSchema(database)
try database.execute("PRAGMA user_version = 2")
}
} else if version == 1 {
try database.immediateTransaction {
// Another process may have completed the migration while this
// connection waited for BEGIN IMMEDIATE.
guard try database.scalarInt64("PRAGMA user_version") == 1 else {
return
}
// v1 payloads contain installationId inside each event and are
// incompatible with the server's strict v2 envelope. Drop them
// without decoding so neither old content nor identifiers can
// escape through logs or a partially migrated retry.
try database.execute("DROP TABLE IF EXISTS pending_events")
try database.execute("DROP TABLE IF EXISTS quarantined_events")
try createCurrentQueueSchema(database)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL CHECK(payload_size >= 0),
priority INTEGER NOT NULL,
lease_id TEXT,
lease_expires_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
"""
"DELETE FROM metadata WHERE key IN (?, ?, ?)",
bindings: [
.text(MetadataKey.firstOpenRecorded),
.text(MetadataKey.uploadLeaseOwner),
.text(MetadataKey.uploadLeaseExpiresAt)
]
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_ready
ON pending_events(next_attempt_at, lease_id, priority, created_at)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_expiry
ON pending_events(occurred_at)
"""
)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS quarantined_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL,
attempt_count INTEGER NOT NULL,
reason TEXT NOT NULL,
quarantined_at REAL NOT NULL
)
"""
)
try database.execute("PRAGMA user_version = 1")
try database.execute("PRAGMA user_version = 2")
}
}
}
private func createCurrentQueueSchema(_ database: SQLiteDatabase) throws {
try database.execute(
"""
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
installation_id TEXT NOT NULL,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL CHECK(payload_size >= 0),
priority INTEGER NOT NULL,
lease_id TEXT,
lease_expires_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_ready
ON pending_events(
installation_id, next_attempt_at, lease_id, priority, created_at
)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_expiry
ON pending_events(occurred_at)
"""
)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS quarantined_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
installation_id TEXT NOT NULL,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL,
attempt_count INTEGER NOT NULL,
reason TEXT NOT NULL,
quarantined_at REAL NOT NULL
)
"""
)
}
private func applyFileProtection(to databaseURL: URL) {
#if os(iOS)
for url in [
@@ -1146,7 +1256,7 @@ private struct SQLiteStoreError: Error, Sendable {
let operation: String
}
private enum SQLiteBinding {
enum SQLiteBinding {
case null
case int64(Int64)
case double(Double)
@@ -1154,7 +1264,7 @@ private enum SQLiteBinding {
case blob(Data)
}
private struct SQLiteRow {
struct SQLiteRow {
private let values: [SQLiteValue]
init(statement: OpaquePointer) {
@@ -1228,7 +1338,7 @@ private enum SQLiteValue {
case blob(Data)
}
private final class SQLiteDatabase {
final class SQLiteDatabase {
private static let transient = unsafeBitCast(
-1,
to: sqlite3_destructor_type.self
@@ -77,7 +77,7 @@ public actor AnalyticsUploadCoordinator {
/// Performs a bounded drain. The default keeps extension execution time and
/// memory predictable while allowing a host app to request more batches.
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
guard !uploadInProgress else { return }
guard !uploadInProgress, !Task.isCancelled else { return }
uploadInProgress = true
defer { uploadInProgress = false }
@@ -95,18 +95,35 @@ public actor AnalyticsUploadCoordinator {
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
let batchLimit = max(1, maximumBatches)
for _ in 0..<batchLimit {
guard !Task.isCancelled else { return }
guard let batch = await repository.leaseBatch(
ownerID: ownerID,
configuration: configuration
) else {
return
}
guard !Task.isCancelled else {
await releaseForCancellation(
events: batch.events,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
var authorization = AuthorizationState()
if let bearerProvider {
do {
authorization.token = try await bearerProvider.bearerToken()
} catch {
if Task.isCancelled {
await releaseForCancellation(
events: batch.events,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
await scheduleRetry(
events: batch.events,
leaseID: batch.leaseID,
@@ -120,21 +137,28 @@ public actor AnalyticsUploadCoordinator {
await process(
events: batch.events,
installationID: batch.installationID,
leaseID: batch.leaseID,
ownerID: ownerID,
authorization: &authorization
)
await repository.releaseGlobalLease(ownerID: ownerID)
guard !Task.isCancelled else { return }
}
}
private func process(
events: [AnalyticsLeasedEvent],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !events.isEmpty else { return }
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
guard await renewLease(
events: events,
leaseID: leaseID,
@@ -142,11 +166,23 @@ public actor AnalyticsUploadCoordinator {
) else {
return
}
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
let response: AnalyticsHTTPResponse
do {
response = try await send(events: events, token: authorization.token)
response = try await send(
events: events,
installationID: installationID,
token: authorization.token
)
} catch {
if Task.isCancelled {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
await scheduleRetry(
events: events,
leaseID: leaseID,
@@ -172,15 +208,24 @@ public actor AnalyticsUploadCoordinator {
) else {
return
}
let refreshed = try await send(events: events, token: authorization.token)
let refreshed = try await send(
events: events,
installationID: installationID,
token: authorization.token
)
await processResponse(
refreshed,
events: events,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
} catch {
if Task.isCancelled {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
await scheduleRetry(
events: events,
leaseID: leaseID,
@@ -195,6 +240,7 @@ public actor AnalyticsUploadCoordinator {
await processResponse(
response,
events: events,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
@@ -204,10 +250,15 @@ public actor AnalyticsUploadCoordinator {
private func processResponse(
_ response: AnalyticsHTTPResponse,
events: [AnalyticsLeasedEvent],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
switch response.statusCode {
case 200:
let decoded: AnalyticsUploadResponse
@@ -270,12 +321,14 @@ public actor AnalyticsUploadCoordinator {
let midpoint = events.count / 2
await process(
events: Array(events[..<midpoint]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
await process(
events: Array(events[midpoint...]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
@@ -340,8 +393,18 @@ public actor AnalyticsUploadCoordinator {
}
}
private func releaseForCancellation(
events: [AnalyticsLeasedEvent],
leaseID: String,
ownerID: String
) async {
await repository.releaseEvents(events, leaseID: leaseID)
await repository.releaseGlobalLease(ownerID: ownerID)
}
private func send(
events: [AnalyticsLeasedEvent],
installationID: UUID,
token: String?
) async throws -> AnalyticsHTTPResponse {
var headers = [
@@ -355,7 +418,10 @@ public actor AnalyticsUploadCoordinator {
AnalyticsHTTPRequest(
url: configuration.endpoint,
headers: headers,
body: Self.requestBody(for: events)
body: Self.requestBody(
for: events,
installationID: installationID
)
)
)
}
@@ -445,8 +511,14 @@ public actor AnalyticsUploadCoordinator {
events.map(\.attemptCount).max() ?? 0
}
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
var body = Data(#"{"events":["#.utf8)
private static func requestBody(
for events: [AnalyticsLeasedEvent],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
.utf8
)
for index in events.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
@@ -0,0 +1,129 @@
// KeyboardUsageClient.swift
// OSGKeyboard · Shared
//
// Fire-and-forget numeric recording facade. Calls are serialized per process so
// a UTC rollover cannot finalize a day ahead of an earlier queued insertion.
import Foundation
public protocol KeyboardUsageRecording: Sendable {
func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
)
}
public struct NoopKeyboardUsageRecorder: KeyboardUsageRecording {
public init() {}
public func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
) {}
}
public final class LiveKeyboardUsageRecorder:
KeyboardUsageRecording,
@unchecked Sendable {
private let analyticsRepository: AnalyticsRepository
private let repository: KeyboardUsageRepository
private let environment: AnalyticsEnvironment
private let clock: any AnalyticsWallClock
private let trigger: any AnalyticsUploadTriggering
private let lock = NSLock()
private var tailTask: Task<Void, Never>?
public init(
analyticsRepository: AnalyticsRepository,
repository: KeyboardUsageRepository,
environment: AnalyticsEnvironment,
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger()
) {
self.analyticsRepository = analyticsRepository
self.repository = repository
self.environment = environment
self.clock = clock
self.trigger = trigger
}
public func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
) {
guard !counts.isEmpty else { return }
let occurredAt = clock.now()
let analyticsRepository = analyticsRepository
let repository = repository
let environment = environment
let trigger = trigger
lock.withLock {
let previous = tailTask
tailTask = Task {
await previous?.value
guard let installationID =
await analyticsRepository.installationIdentifierIfEnabled() else {
return
}
await repository.record(
counts: counts,
sessionID: sessionID,
installationID: installationID,
occurredAt: occurredAt,
environment: environment
)
await trigger.requestUpload()
}
}
}
func waitForPendingRecords() async {
let task = lock.withLock { tailTask }
await task?.value
}
}
public struct KeyboardUsageRuntime: Sendable {
public let repository: KeyboardUsageRepository
public let recorder: any KeyboardUsageRecording
public let uploadCoordinator: KeyboardUsageUploadCoordinator
public init(
environment: AnalyticsEnvironment,
analyticsRepository: AnalyticsRepository,
repositoryConfiguration: KeyboardUsageRepositoryConfiguration = .appGroupDefault(),
uploadConfiguration: KeyboardUsageUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) {
let repository = KeyboardUsageRepository(
configuration: repositoryConfiguration,
clock: wallClock,
uuidGenerator: uuidGenerator
)
self.repository = repository
recorder = LiveKeyboardUsageRecorder(
analyticsRepository: analyticsRepository,
repository: repository,
environment: environment,
clock: wallClock,
trigger: trigger
)
uploadCoordinator = KeyboardUsageUploadCoordinator(
repository: repository,
configuration: uploadConfiguration,
network: network,
bearerProvider: bearerProvider,
clock: wallClock,
uuidGenerator: uuidGenerator,
random: random,
logger: logger
)
}
}
@@ -0,0 +1,424 @@
// KeyboardUsageModels.swift
// OSGKeyboard · Shared
//
// Fixed-schema keyboard usage summaries. Raw text never crosses this model
// boundary: callers provide only grapheme counts and random session IDs.
import Foundation
public enum KeyboardTextInsertionSource: String, CaseIterable, Sendable {
case manualKeyboard = "MANUAL_KEYBOARD"
case voiceTranscription = "VOICE_TRANSCRIPTION"
case aiGenerated = "AI_GENERATED"
case pasteboard = "PASTEBOARD"
case editGenerated = "EDIT_GENERATED"
case redo = "REDO"
case assistantAction = "ASSISTANT_ACTION"
case debugDemo = "DEBUG_DEMO"
public var contributesToKeyboardUsage: Bool {
self == .manualKeyboard
}
}
public struct KeyboardUsageCharacterCounts: Equatable, Sendable {
public static let maximumPerCategory = 1_000_000
public let chinese: Int
public let english: Int
public let other: Int
public init(chinese: Int = 0, english: Int = 0, other: Int = 0) {
self.chinese = Self.clamped(chinese)
self.english = Self.clamped(english)
self.other = Self.clamped(other)
}
public var total: Int {
chinese + english + other
}
public var isEmpty: Bool {
total == 0
}
private static func clamped(_ value: Int) -> Int {
min(maximumPerCategory, max(0, value))
}
}
public enum KeyboardUsageCharacterClassifier {
public static func classify(_ text: String) -> KeyboardUsageCharacterCounts {
var chinese = 0
var english = 0
var other = 0
for character in text {
switch classification(of: character) {
case .chinese:
chinese = saturatingIncrement(chinese)
case .english:
english = saturatingIncrement(english)
case .other:
other = saturatingIncrement(other)
}
}
return KeyboardUsageCharacterCounts(
chinese: chinese,
english: english,
other: other
)
}
private enum Classification {
case chinese
case english
case other
}
private static func classification(of character: Character) -> Classification {
// ICU Script properties cover CJK extensions and future Unicode updates;
// checking the whole grapheme keeps combining sequences at one count.
if character.unicodeScalars.contains(where: {
matchesScript($0, regularExpression: hanScriptExpression)
}) {
return .chinese
}
if character.unicodeScalars.contains(where: {
isLetter($0) && matchesScript($0, regularExpression: latinScriptExpression)
}) {
return .english
}
return .other
}
private static func matchesScript(
_ scalar: Unicode.Scalar,
regularExpression: NSRegularExpression
) -> Bool {
let text = String(scalar)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return regularExpression.firstMatch(
in: text,
options: [],
range: range
) != nil
}
private static func isLetter(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.properties.generalCategory {
case .uppercaseLetter,
.lowercaseLetter,
.titlecaseLetter,
.modifierLetter,
.otherLetter:
return true
default:
return false
}
}
private static func saturatingIncrement(_ value: Int) -> Int {
min(KeyboardUsageCharacterCounts.maximumPerCategory, value + 1)
}
private static let hanScriptExpression = try! NSRegularExpression(
pattern: #"^\p{sc=Han}$"#
)
private static let latinScriptExpression = try! NSRegularExpression(
pattern: #"^\p{sc=Latin}$"#
)
}
public enum KeyboardUsageSessionClassification: Int, CaseIterable, Sendable {
case chineseOnly = 0
case englishOnly = 1
case mixedLanguage = 2
case otherOnly = 3
init(counts: KeyboardUsageCharacterCounts) {
switch (counts.chinese > 0, counts.english > 0) {
case (true, true):
self = .mixedLanguage
case (true, false):
self = .chineseOnly
case (false, true):
self = .englishOnly
case (false, false):
self = .otherOnly
}
}
func merging(_ counts: KeyboardUsageCharacterCounts) -> Self {
let hasChinese = self == .chineseOnly
|| self == .mixedLanguage
|| counts.chinese > 0
let hasEnglish = self == .englishOnly
|| self == .mixedLanguage
|| counts.english > 0
switch (hasChinese, hasEnglish) {
case (true, true):
return .mixedLanguage
case (true, false):
return .chineseOnly
case (false, true):
return .englishOnly
case (false, false):
return .otherOnly
}
}
}
public enum KeyboardUsageModelError: Error, Sendable {
case invalidDate
case invalidCount
case invalidSessionPartition
case invalidVersion
case invalidResponseCounts
case unknownField(String)
}
public struct KeyboardUsageSummary: Codable, Equatable, Sendable {
public let clientSummaryId: UUID
public let summaryDate: String
public let chineseCharacterCount: Int
public let englishCharacterCount: Int
public let otherCharacterCount: Int
public let inputSessionCount: Int
public let chineseOnlySessionCount: Int
public let englishOnlySessionCount: Int
public let mixedLanguageSessionCount: Int
public let otherOnlySessionCount: Int
public let appVersion: String
public let osVersion: String
public init(
clientSummaryId: UUID,
summaryDate: String,
chineseCharacterCount: Int,
englishCharacterCount: Int,
otherCharacterCount: Int,
inputSessionCount: Int,
chineseOnlySessionCount: Int,
englishOnlySessionCount: Int,
mixedLanguageSessionCount: Int,
otherOnlySessionCount: Int,
appVersion: String,
osVersion: String
) throws {
let characterCounts = [
chineseCharacterCount,
englishCharacterCount,
otherCharacterCount
]
let sessionCounts = [
inputSessionCount,
chineseOnlySessionCount,
englishOnlySessionCount,
mixedLanguageSessionCount,
otherOnlySessionCount
]
guard KeyboardUsageUTCDate.date(from: summaryDate) != nil else {
throw KeyboardUsageModelError.invalidDate
}
guard characterCounts.allSatisfy({
(0...KeyboardUsageCharacterCounts.maximumPerCategory).contains($0)
}),
sessionCounts.allSatisfy({ (0...100_000).contains($0) }) else {
throw KeyboardUsageModelError.invalidCount
}
guard chineseOnlySessionCount
+ englishOnlySessionCount
+ mixedLanguageSessionCount
+ otherOnlySessionCount == inputSessionCount,
characterCounts.reduce(0, +) >= inputSessionCount else {
throw KeyboardUsageModelError.invalidSessionPartition
}
guard AnalyticsEnvironment.isSafeVersion(appVersion),
AnalyticsEnvironment.isSafeVersion(osVersion) else {
throw KeyboardUsageModelError.invalidVersion
}
self.clientSummaryId = clientSummaryId
self.summaryDate = summaryDate
self.chineseCharacterCount = chineseCharacterCount
self.englishCharacterCount = englishCharacterCount
self.otherCharacterCount = otherCharacterCount
self.inputSessionCount = inputSessionCount
self.chineseOnlySessionCount = chineseOnlySessionCount
self.englishOnlySessionCount = englishOnlySessionCount
self.mixedLanguageSessionCount = mixedLanguageSessionCount
self.otherOnlySessionCount = otherOnlySessionCount
self.appVersion = appVersion
self.osVersion = osVersion
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case clientSummaryId
case summaryDate
case chineseCharacterCount
case englishCharacterCount
case otherCharacterCount
case inputSessionCount
case chineseOnlySessionCount
case englishOnlySessionCount
case mixedLanguageSessionCount
case otherOnlySessionCount
case appVersion
case osVersion
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
clientSummaryId: container.decode(UUID.self, forKey: .clientSummaryId),
summaryDate: container.decode(String.self, forKey: .summaryDate),
chineseCharacterCount: container.decode(Int.self, forKey: .chineseCharacterCount),
englishCharacterCount: container.decode(Int.self, forKey: .englishCharacterCount),
otherCharacterCount: container.decode(Int.self, forKey: .otherCharacterCount),
inputSessionCount: container.decode(Int.self, forKey: .inputSessionCount),
chineseOnlySessionCount: container.decode(
Int.self,
forKey: .chineseOnlySessionCount
),
englishOnlySessionCount: container.decode(
Int.self,
forKey: .englishOnlySessionCount
),
mixedLanguageSessionCount: container.decode(
Int.self,
forKey: .mixedLanguageSessionCount
),
otherOnlySessionCount: container.decode(
Int.self,
forKey: .otherOnlySessionCount
),
appVersion: container.decode(String.self, forKey: .appVersion),
osVersion: container.decode(String.self, forKey: .osVersion)
)
}
}
public struct KeyboardUsageUploadRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let summaries: [KeyboardUsageSummary]
public init(installationId: UUID, summaries: [KeyboardUsageSummary]) {
self.installationId = installationId
self.summaries = summaries
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case summaries
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
installationId = try container.decode(UUID.self, forKey: .installationId)
summaries = try container.decode([KeyboardUsageSummary].self, forKey: .summaries)
}
}
public struct KeyboardUsageUploadResponse: Codable, Equatable, Sendable {
public let accepted: Int
public let replayed: Int
public init(accepted: Int, replayed: Int) throws {
guard accepted >= 0, replayed >= 0 else {
throw KeyboardUsageModelError.invalidResponseCounts
}
self.accepted = accepted
self.replayed = replayed
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case accepted
case replayed
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
accepted: container.decode(Int.self, forKey: .accepted),
replayed: container.decode(Int.self, forKey: .replayed)
)
}
}
enum KeyboardUsageUTCDate {
static func string(from date: Date) -> String {
formatter.string(from: date)
}
static func date(from value: String) -> Date? {
guard value.utf8.count == 10 else { return nil }
return formatter.date(from: value)
}
static func oldestAcceptedDate(relativeTo today: String) -> String? {
guard let todayDate = date(from: today),
let oldest = calendar.date(byAdding: .day, value: -35, to: todayDate) else {
return nil
}
return string(from: oldest)
}
private static var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
formatter.isLenient = false
return formatter
}
private static var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
return calendar
}
}
private enum KeyboardUsageCodableAllowlist {
static func rejectUnknownKeys(
in decoder: Decoder,
allowed: Set<String>
) throws {
let container = try decoder.container(keyedBy: KeyboardUsageAnyCodingKey.self)
if let unknown = container.allKeys.first(where: {
!allowed.contains($0.stringValue)
}) {
throw KeyboardUsageModelError.unknownField(unknown.stringValue)
}
}
}
private struct KeyboardUsageAnyCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue: Int) {
stringValue = String(intValue)
self.intValue = intValue
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,547 @@
// KeyboardUsageUploadCoordinator.swift
// OSGKeyboard · Shared
//
// Uploads immutable daily summaries with the existing analytics transport,
// optional account bearer, bounded retry and cross-process leases.
import Foundation
public struct KeyboardUsageUploadConfiguration: Sendable {
public let endpoint: URL
public let maximumBatchCount: Int
public let globalLeaseDuration: TimeInterval
public let summaryLeaseDuration: TimeInterval
public let maximumBackoff: TimeInterval
public init(
endpoint: URL,
maximumBatchCount: Int = 50,
globalLeaseDuration: TimeInterval = 2 * 60,
summaryLeaseDuration: TimeInterval = 5 * 60,
maximumBackoff: TimeInterval = 6 * 60 * 60
) {
self.endpoint = endpoint
self.maximumBatchCount = min(50, max(1, maximumBatchCount))
self.globalLeaseDuration = max(60, globalLeaseDuration)
self.summaryLeaseDuration = max(60, summaryLeaseDuration)
self.maximumBackoff = min(6 * 60 * 60, max(60, maximumBackoff))
}
}
public actor KeyboardUsageUploadCoordinator {
private struct AuthorizationState {
var token: String?
var didRefresh = false
}
private let repository: KeyboardUsageRepository
private let configuration: KeyboardUsageUploadConfiguration
private let network: any AnalyticsNetworking
private let bearerProvider: (any AnalyticsBearerProviding)?
private let clock: any AnalyticsWallClock
private let uuidGenerator: any AnalyticsUUIDGenerating
private let random: any AnalyticsRandomGenerating
private let logger: any AnalyticsLogging
private var uploadInProgress = false
public init(
repository: KeyboardUsageRepository,
configuration: KeyboardUsageUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) {
self.repository = repository
self.configuration = configuration
self.network = network
self.bearerProvider = bearerProvider
self.clock = clock
self.uuidGenerator = uuidGenerator
self.random = random
self.logger = logger
}
public func uploadAvailableSummaries(maximumBatches: Int = 1) async {
guard !uploadInProgress, !Task.isCancelled else { return }
uploadInProgress = true
defer { uploadInProgress = false }
guard configuration.endpoint.scheme?.lowercased() == "https" else {
log(
outcome: .skipped,
count: 0,
category: .client
)
return
}
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
for _ in 0..<max(1, maximumBatches) {
guard !Task.isCancelled else { return }
guard let batch = await repository.leaseBatch(
ownerID: ownerID,
configuration: configuration
) else {
return
}
guard !Task.isCancelled else {
await releaseForCancellation(
summaries: batch.summaries,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
var authorization = AuthorizationState()
if let bearerProvider {
do {
authorization.token = try await bearerProvider.bearerToken()
} catch {
if Task.isCancelled {
await releaseForCancellation(
summaries: batch.summaries,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
await scheduleRetry(
batch.summaries,
leaseID: batch.leaseID,
response: nil,
category: .authentication
)
await repository.releaseGlobalLease(ownerID: ownerID)
return
}
}
await process(
batch.summaries,
installationID: batch.installationID,
leaseID: batch.leaseID,
ownerID: ownerID,
authorization: &authorization
)
await repository.releaseGlobalLease(ownerID: ownerID)
guard !Task.isCancelled else { return }
}
}
private func process(
_ summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !summaries.isEmpty else { return }
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
guard await renewLease(
summaries: summaries,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
let response: AnalyticsHTTPResponse
do {
response = try await send(
summaries: summaries,
installationID: installationID,
token: authorization.token
)
} catch {
if Task.isCancelled {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
await scheduleRetry(
summaries,
leaseID: leaseID,
response: nil,
category: Self.networkCategory(for: error)
)
return
}
if response.statusCode == 401,
!authorization.didRefresh,
let bearerProvider {
authorization.didRefresh = true
let failedToken = authorization.token
do {
authorization.token = try await bearerProvider.refreshBearerToken(
afterUnauthorizedAccessToken: failedToken
)
guard await renewLease(
summaries: summaries,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
let refreshed = try await send(
summaries: summaries,
installationID: installationID,
token: authorization.token
)
await processResponse(
refreshed,
summaries: summaries,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
} catch {
if Task.isCancelled {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
}
return
}
await processResponse(
response,
summaries: summaries,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
}
private func processResponse(
_ response: AnalyticsHTTPResponse,
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
switch response.statusCode {
case 200:
let decoded: KeyboardUsageUploadResponse
do {
decoded = try JSONDecoder().decode(
KeyboardUsageUploadResponse.self,
from: response.body
)
} catch {
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .decoding
)
return
}
guard decoded.accepted + decoded.replayed == summaries.count else {
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .countMismatch
)
return
}
let completed = await repository.complete(
rowIDs: summaries.map(\.rowID),
leaseID: leaseID
)
log(
outcome: completed ? .uploaded : .retryScheduled,
count: summaries.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: summaries),
category: completed ? nil : .storage
)
case 400, 409, 422:
if summaries.count == 1 {
await repository.quarantine(
rowIDs: [summaries[0].rowID],
leaseID: leaseID,
statusCode: response.statusCode
)
log(
outcome: .quarantined,
count: 1,
statusCode: response.statusCode,
attempt: summaries[0].attemptCount,
category: .client
)
return
}
let midpoint = summaries.count / 2
await process(
Array(summaries[..<midpoint]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
await process(
Array(summaries[midpoint...]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
case 401:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
case 408:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .timeout
)
case 429:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .rateLimited
)
case 500...599:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .server
)
case 400...499:
await repository.quarantine(
rowIDs: summaries.map(\.rowID),
leaseID: leaseID,
statusCode: response.statusCode
)
log(
outcome: .quarantined,
count: summaries.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: summaries),
category: .client
)
default:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .server
)
}
}
private func releaseForCancellation(
summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
ownerID: String
) async {
await repository.release(summaries: summaries, leaseID: leaseID)
await repository.releaseGlobalLease(ownerID: ownerID)
}
private func send(
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
token: String?
) async throws -> AnalyticsHTTPResponse {
var headers = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token, !token.isEmpty {
headers["Authorization"] = "Bearer \(token)"
}
return try await network.send(
AnalyticsHTTPRequest(
url: configuration.endpoint,
headers: headers,
body: Self.requestBody(
summaries: summaries,
installationID: installationID
)
)
)
}
private func renewLease(
summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
ownerID: String
) async -> Bool {
let renewed = await repository.renewLease(
ownerID: ownerID,
leaseID: leaseID,
configuration: configuration
)
guard renewed else {
await repository.release(summaries: summaries, leaseID: leaseID)
log(
outcome: .skipped,
count: summaries.count,
attempt: maximumAttempt(in: summaries),
category: .storage
)
return false
}
return true
}
private func scheduleRetry(
_ summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
response: AnalyticsHTTPResponse?,
category: AnalyticsUploadErrorCategory,
minimumDelay: TimeInterval = 0
) async {
let attempt = maximumAttempt(in: summaries) + 1
let exponent = min(attempt - 1, 16)
let ceiling = min(
configuration.maximumBackoff,
pow(2, Double(exponent))
)
let jitterMilliseconds = random.next(
upperBound: UInt64(max(0, ceiling * 1_000))
)
let jitter = TimeInterval(jitterMilliseconds) / 1_000
let retryAfter = response.flatMap(retryAfterDelay) ?? 0
let delay = min(
configuration.maximumBackoff,
max(minimumDelay, retryAfter, jitter)
)
await repository.scheduleRetry(
summaries: summaries,
leaseID: leaseID,
delay: delay
)
log(
outcome: .retryScheduled,
count: summaries.count,
statusCode: response?.statusCode,
attempt: attempt,
category: category
)
}
private func retryAfterDelay(
_ response: AnalyticsHTTPResponse
) -> TimeInterval? {
guard let value = response.header(named: "Retry-After")?
.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
if let seconds = TimeInterval(value) {
return min(configuration.maximumBackoff, max(0, seconds))
}
guard let date = Self.httpDateFormatter.date(from: value) else {
return nil
}
return min(
configuration.maximumBackoff,
max(0, date.timeIntervalSince(clock.now()))
)
}
private func maximumAttempt(
in summaries: [KeyboardUsageLeasedSummary]
) -> Int {
summaries.map(\.attemptCount).max() ?? 0
}
private func log(
outcome: AnalyticsUploadLogEntry.Outcome,
count: Int,
statusCode: Int? = nil,
attempt: Int = 0,
category: AnalyticsUploadErrorCategory?
) {
logger.log(
AnalyticsUploadLogEntry(
outcome: outcome,
eventCount: count,
statusCode: statusCode,
attempt: attempt,
errorCategory: category
)
)
}
private static func requestBody(
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","summaries":["#
.utf8
)
for index in summaries.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
}
body.append(summaries[index].payload)
}
body.append(Data("]}".utf8))
return body
}
private static func networkCategory(
for error: Error
) -> AnalyticsUploadErrorCategory {
guard let urlError = error as? URLError else { return .network }
switch urlError.code {
case .timedOut:
return .timeout
case .userAuthenticationRequired,
.userCancelledAuthentication:
return .authentication
default:
return .network
}
}
private static var httpDateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
return formatter
}
}
@@ -14,18 +14,25 @@ public actor GatewayGrantCoordinator {
private let store: any GatewayGrantCredentialStore
private let session: URLSession
private let now: @Sendable () -> Date
private let refreshPath: String
private let accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing
private var refreshTask: Task<ManagedGatewayGrantCredentials, Error>?
public init(
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
store: any GatewayGrantCredentialStore = GatewayGrantKeychainStore(),
session: URLSession = .shared,
now: @escaping @Sendable () -> Date = Date.init
refreshPath: String = "v1/gateway/grants/refresh",
now: @escaping @Sendable () -> Date = Date.init,
accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing =
AppGroupManagedGatewayAccountAccessPolicy()
) {
self.baseURL = baseURL
self.store = store
self.session = session
self.refreshPath = refreshPath
self.now = now
self.accountAccessPolicy = accountAccessPolicy
}
/// Host-only integration point. The account access token authorizes grant
@@ -73,6 +80,12 @@ public actor GatewayGrantCoordinator {
for scope: ManagedGatewayCapability,
forceRefresh: Bool = false
) async throws -> String {
// A valid cached grant is not proof of a current account session.
// Check this before loading or refreshing credentials so signed-out
// callers cannot consume credits through stale Keychain state.
guard accountAccessPolicy.allowsAccountManagedAccess() else {
throw ManagedGatewayError.missingGrant
}
guard let credentials = try await store.load() else {
throw ManagedGatewayError.missingGrant
}
@@ -123,7 +136,7 @@ public actor GatewayGrantCoordinator {
let refreshToken: String
}
var request = URLRequest(url: endpoint("v1/gateway/grants/refresh"))
var request = URLRequest(url: endpoint(refreshPath))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(
@@ -227,13 +240,44 @@ enum ManagedGatewayHTTP {
let code = decoded?.code ?? HTTPURLResponse.localizedString(forStatusCode: status)
let resolvedRequestId = decoded?.requestId ?? requestId
return error(code: code, status: status, requestId: resolvedRequestId)
}
static func error(
code: String,
status: Int,
requestId: String?
) -> ManagedGatewayError {
switch code.lowercased() {
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
return .insufficientCredits
case "oobe_feature_already_used":
return .oobeFeatureAlreadyUsed
case "unauthorized", "invalid_gateway_refresh", "gateway_grant_denied", "invalid_grant":
return .invalidGrant
case "provider_unavailable":
return .providerUnavailable(requestId: requestId)
case "provider_rate_limited":
return .providerRateLimited(requestId: requestId)
case "provider_timeout":
return .providerTimeout(requestId: requestId)
case "provider_failure", "provider_invalid_response", "provider_error", "gateway_failure":
return .providerFailure(requestId: requestId)
case "internal_failure":
return .internalFailure(requestId: requestId)
default:
return .server(code: code, status: status, requestId: resolvedRequestId)
switch status {
case 429:
return .providerRateLimited(requestId: requestId)
case 502:
return .providerFailure(requestId: requestId)
case 503:
return .providerUnavailable(requestId: requestId)
case 504:
return .providerTimeout(requestId: requestId)
default:
return .server(code: code, status: status, requestId: requestId)
}
}
}
@@ -14,18 +14,25 @@ public protocol GatewayGrantCredentialStore: Sendable {
}
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
public enum Slot: String, Sendable {
case account = "scope-limited.active"
case oobe = "scope-limited.oobe"
}
public enum StoreError: Error, Equatable, Sendable {
case unexpectedStatus(OSStatus)
case invalidStoredValue
}
private static let service = "com.osgkeyboard.gateway-grant"
private static let account = "scope-limited.active"
private let slot: Slot
public init() {}
public init(slot: Slot = .account) {
self.slot = slot
}
public func load() async throws -> ManagedGatewayGrantCredentials? {
var query = Self.baseQuery
var query = baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
@@ -51,14 +58,14 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
let data = try Self.encoder.encode(credentials)
let updateStatus = SecItemUpdate(
Self.baseQuery as CFDictionary,
baseQuery as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
var query = Self.baseQuery
var query = baseQuery
query[kSecValueData as String] = data
// The extension can refresh after reboot without making account
// credentials readable; this item contains only the limited grant.
@@ -73,17 +80,17 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
}
public func delete() async throws {
let status = SecItemDelete(Self.baseQuery as CFDictionary)
let status = SecItemDelete(baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw StoreError.unexpectedStatus(status)
}
}
private static var baseQuery: [String: Any] {
private var baseQuery: [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: slot.rawValue,
kSecAttrSynchronizable as String: kCFBooleanFalse!
]
#if os(macOS)
@@ -104,3 +111,23 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
return decoder
}
}
/// Dedicated extension-readable slot for anonymous onboarding grants. It can
/// never overwrite or load the signed-in account's normal managed grant.
public struct OOBEGatewayGrantKeychainStore: GatewayGrantCredentialStore, Sendable {
private let storage = GatewayGrantKeychainStore(slot: .oobe)
public init() {}
public func load() async throws -> ManagedGatewayGrantCredentials? {
try await storage.load()
}
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
try await storage.save(credentials)
}
public func delete() async throws {
try await storage.delete()
}
}
@@ -0,0 +1,40 @@
// ManagedGatewayAccountAccessPolicy.swift
// OSGKeyboard · Shared
//
// Device-local account-session gate for account-funded managed requests.
// The marker is intentionally non-secret and is never part of iCloud settings.
import Foundation
public protocol ManagedGatewayAccountAccessAuthorizing: Sendable {
func allowsAccountManagedAccess() -> Bool
}
/// Production policy shared by the host and keyboard extension. A cached grant
/// alone is insufficient: the host must also have confirmed an account session.
public struct AppGroupManagedGatewayAccountAccessPolicy:
ManagedGatewayAccountAccessAuthorizing,
@unchecked Sendable {
private let defaults: UserDefaults?
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
self.defaults = defaults
}
public func allowsAccountManagedAccess() -> Bool {
defaults?.bool(
forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable
) == true
}
}
/// Anonymous OOBE grants have their own bounded practice-session policy and do
/// not represent account-funded access. Tests may also inject this explicitly.
public struct UnrestrictedManagedGatewayAccountAccessPolicy:
ManagedGatewayAccountAccessAuthorizing {
public init() {}
public func allowsAccountManagedAccess() -> Bool {
true
}
}
@@ -19,17 +19,43 @@ public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable {
case translation
case editLastInput = "edit_last_input"
case aiQuestion = "ai_question"
case currentInformationQuestion = "current_information_question"
case clipboardTransform = "clipboard_transform"
case customSkill = "custom_skill"
case agentPlanning = "agent_planning"
}
/// Audited product entry point. It affects usage attribution, never provider
/// selection or billing authority.
public enum ManagedGatewayRequestSource: String, Codable, Sendable {
case hotword
}
/// Optional server-audited purpose. A purpose may affect billing only when the
/// authenticated gateway independently verifies its eligibility.
public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
case oobe
}
/// Server-audited onboarding capability. This value is carried independently
/// from `taskKind` so billing and abuse policy never infer OOBE eligibility
/// from a generic clipboard or AI operation.
public enum ManagedGatewayOOBEFeature: String, Codable, CaseIterable, Sendable {
case voiceInput = "voice_input"
case clipboardTranslate = "clipboard_translate"
case clipboardReply = "clipboard_reply"
case askAI = "ask_ai"
public var requiredCapability: ManagedGatewayCapability {
switch self {
case .voiceInput:
return .polish
case .clipboardTranslate, .clipboardReply, .askAI:
return .assistant
}
}
}
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
public static let maximumAccessLifetime: TimeInterval = 5 * 60
@@ -85,7 +111,13 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
case scopeNotGranted(ManagedGatewayCapability)
case invalidGrant
case insufficientCredits
case oobeFeatureAlreadyUsed
case timeout
case providerUnavailable(requestId: String?)
case providerRateLimited(requestId: String?)
case providerTimeout(requestId: String?)
case providerFailure(requestId: String?)
case internalFailure(requestId: String?)
case server(code: String, status: Int, requestId: String?)
public var errorDescription: String? {
@@ -102,8 +134,20 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
return SharedL10n.string("managed.error.grantRejected")
case .insufficientCredits:
return SharedL10n.string("managed.error.insufficientCredits")
case .oobeFeatureAlreadyUsed:
return SharedL10n.string("managed.error.oobeFeatureAlreadyUsed")
case .timeout:
return SharedL10n.string("managed.error.timeout")
case .providerUnavailable:
return SharedL10n.string("managed.error.providerUnavailable")
case .providerRateLimited:
return SharedL10n.string("managed.error.providerRateLimited")
case .providerTimeout:
return SharedL10n.string("managed.error.providerTimeout")
case .providerFailure:
return SharedL10n.string("managed.error.providerFailure")
case .internalFailure:
return SharedL10n.string("managed.error.internalFailure")
case .server(let code, let status, _):
return SharedL10n.format(
"managed.error.server",
@@ -115,15 +159,15 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
}
}
struct ManagedGatewayGrantTokenResponse: Decodable {
let grantId: String
let scopes: Set<ManagedGatewayCapability>
let accessToken: String
let accessExpiresAt: Date
let refreshToken: String
let refreshExpiresAt: Date
public struct ManagedGatewayGrantTokenResponse: Decodable, Sendable {
public let grantId: String
public let scopes: Set<ManagedGatewayCapability>
public let accessToken: String
public let accessExpiresAt: Date
public let refreshToken: String
public let refreshExpiresAt: Date
func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
public func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
ManagedGatewayGrantCredentials(
grantId: grantId,
scopes: scopes,
@@ -149,5 +193,7 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
let temperature: Double
let stream: Bool
let taskKind: ManagedGatewayTaskKind
let requestSource: ManagedGatewayRequestSource?
let requestPurpose: ManagedGatewayRequestPurpose?
let oobeFeature: ManagedGatewayOOBEFeature?
}
@@ -0,0 +1,55 @@
// ManagedGatewayQuestionRouter.swift
// OSGKeyboard · Shared
//
// Deterministic, on-device routing for questions that cannot be answered
// reliably without current information. No prompt content is persisted.
import Foundation
public enum ManagedGatewayQuestionRouter {
public static func taskKind(
for question: String,
requestedTaskKind: ManagedGatewayTaskKind = .aiQuestion
) -> ManagedGatewayTaskKind {
guard requestedTaskKind == .aiQuestion else { return requestedTaskKind }
return requiresCurrentInformation(question)
? .currentInformationQuestion
: .aiQuestion
}
public static func requiresCurrentInformation(_ question: String) -> Bool {
let normalized = question
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return false }
if strongCurrentSignals.contains(where: normalized.contains) {
return true
}
let hasTemporalSignal = temporalSignals.contains(where: normalized.contains)
let hasCurrentSubject = currentSubjects.contains(where: normalized.contains)
return hasTemporalSignal && hasCurrentSubject
}
private static let strongCurrentSignals = [
"最新", "实时", "热点", "头条", "热搜", "要闻", "路况",
"breaking news", "latest news", "current events", "live score",
"stock price", "exchange rate", "traffic conditions"
]
private static let temporalSignals = [
"昨天", "今天", "今日", "今晚", "明天", "后天", "现在", "当前",
"此刻", "刚刚", "最近", "本周", "本周末", "本月", "今年",
"yesterday", "today", "tonight", "tomorrow", "right now",
"currently", "recent", "this week", "this weekend", "this month", "this year"
]
private static let currentSubjects = [
"新闻", "天气", "气温", "下雨", "台风", "股价", "股票", "大盘",
"汇率", "价格", "票价",
"比分", "赛果", "排名", "航班", "油价", "金价", "发生", "大事",
"news", "weather", "temperature", "price", "score", "ranking",
"flight", "forecast", "what happened"
]
}
@@ -50,19 +50,25 @@ public struct ManagedLLMClient: LLMClient {
public let capability: Capability
public let taskKind: ManagedGatewayTaskKind
public let requestSource: ManagedGatewayRequestSource?
public let requestPurpose: ManagedGatewayRequestPurpose?
public let oobeFeature: ManagedGatewayOOBEFeature?
public let requestTimeout: TimeInterval
private let baseURL: URL
private let grants: GatewayGrantCoordinator
private let oobeGrants: OOBEGatewayGrantCoordinator
private let session: URLSession
private let requestId: @Sendable () -> String
public init(
capability: Capability,
taskKind: ManagedGatewayTaskKind? = nil,
requestSource: ManagedGatewayRequestSource? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
grants: GatewayGrantCoordinator,
oobeGrants: OOBEGatewayGrantCoordinator? = nil,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared,
requestTimeout: TimeInterval = 15,
@@ -70,8 +76,14 @@ public struct ManagedLLMClient: LLMClient {
) {
self.capability = capability
self.taskKind = taskKind ?? capability.defaultTaskKind
self.requestSource = requestSource
self.requestPurpose = requestPurpose
self.oobeFeature = oobeFeature
self.grants = grants
self.oobeGrants = oobeGrants ?? OOBEGatewayGrantCoordinator(
baseURL: baseURL,
session: session
)
self.baseURL = baseURL
self.session = session
self.requestTimeout = requestTimeout
@@ -189,7 +201,7 @@ public struct ManagedLLMClient: LLMClient {
do {
return try await bufferedAttempt(attempt.forcingRefresh())
} catch ManagedGatewayError.invalidGrant {
try? await grants.clearGrant()
try? await clearSelectedGrant()
throw ManagedGatewayError.invalidGrant
}
}
@@ -280,10 +292,34 @@ public struct ManagedLLMClient: LLMClient {
)
}
let token = try await grants.accessToken(
for: capability.grantScope,
forceRefresh: attempt.forceRefresh
)
let token: String
switch requestPurpose {
case .oobe:
guard let oobeFeature else {
throw ManagedGatewayError.server(
code: "missing_oobe_feature",
status: 400,
requestId: attempt.requestId
)
}
token = try await oobeGrants.accessToken(
for: capability.grantScope,
feature: oobeFeature,
forceRefresh: attempt.forceRefresh
)
case nil:
guard oobeFeature == nil else {
throw ManagedGatewayError.server(
code: "unexpected_oobe_feature",
status: 400,
requestId: attempt.requestId
)
}
token = try await grants.accessToken(
for: capability.grantScope,
forceRefresh: attempt.forceRefresh
)
}
let body = ManagedGatewayTextRequest(
input: trimmedInput,
context: boundedContext,
@@ -291,7 +327,9 @@ public struct ManagedLLMClient: LLMClient {
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
stream: stream,
taskKind: taskKind,
requestPurpose: requestPurpose
requestSource: requestSource,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature
)
var request = URLRequest(
@@ -307,6 +345,14 @@ public struct ManagedLLMClient: LLMClient {
return request
}
private func clearSelectedGrant() async throws {
if requestPurpose == .oobe {
try await oobeGrants.clearGrant()
} else {
try await grants.clearGrant()
}
}
static func payload(
from messages: [LLMRequest.Message]
) -> (input: String, context: String?) {
@@ -410,13 +456,11 @@ public struct ManagedLLMClient: LLMClient {
object["message"] != nil || code.hasSuffix("_error") else {
return nil
}
if ["insufficient_credits", "insufficient_balance"].contains(code) {
return .insufficientCredits
}
if ["unauthorized", "gateway_grant_denied", "invalid_grant"].contains(code) {
return .invalidGrant
}
return .server(code: code, status: 200, requestId: requestId)
return ManagedGatewayHTTP.error(
code: code,
status: 200,
requestId: object["requestId"] as? String ?? requestId
)
}
}
@@ -424,6 +468,7 @@ public enum ManagedGatewayLLMClientFactory {
public static func polish(
taskKind: ManagedGatewayTaskKind = .dictationPolish,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
@@ -432,6 +477,7 @@ public enum ManagedGatewayLLMClientFactory {
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
grants: grants,
baseURL: baseURL,
session: session
@@ -440,6 +486,8 @@ public enum ManagedGatewayLLMClientFactory {
public static func ai(
taskKind: ManagedGatewayTaskKind = .aiQuestion,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
grants: GatewayGrantCoordinator,
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
session: URLSession = .shared
@@ -447,6 +495,8 @@ public enum ManagedGatewayLLMClientFactory {
ManagedLLMClient(
capability: .assistant,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
grants: grants,
baseURL: baseURL,
session: session
@@ -0,0 +1,77 @@
// OOBEGatewayGrantCoordinator.swift
// OSGKeyboard · Shared
//
// Routes anonymous onboarding requests through a credential slot that is
// isolated from signed-in account grants.
import Foundation
public actor OOBEGatewayGrantCoordinator {
public static let allowedScopes: Set<ManagedGatewayCapability> = [
.polish,
.assistant
]
private let grants: GatewayGrantCoordinator
private let store: any GatewayGrantCredentialStore
private let now: @Sendable () -> Date
private let practiceSession: @Sendable () -> OOBEPracticeSession?
public init(
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
store: any GatewayGrantCredentialStore = OOBEGatewayGrantKeychainStore(),
session: URLSession = .shared,
now: @escaping @Sendable () -> Date = Date.init,
practiceSession: @escaping @Sendable () -> OOBEPracticeSession? = {
KeyboardSetupBridge.activeOOBEPracticeSession
}
) {
self.store = store
self.now = now
self.practiceSession = practiceSession
self.grants = GatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: session,
refreshPath: "v1/oobe/grants/refresh",
now: now,
accountAccessPolicy: UnrestrictedManagedGatewayAccountAccessPolicy()
)
}
/// Host-only provisioning handoff after App Attest succeeds.
public func install(_ credentials: ManagedGatewayGrantCredentials) async throws {
guard credentials.scopes == Self.allowedScopes,
credentials.hasUsableRefreshToken(at: now()),
credentials.refreshExpiresAt
<= credentials.receivedAt.addingTimeInterval(30 * 60 + 1) else {
throw ManagedGatewayError.invalidGrant
}
try await store.save(credentials)
}
public func accessToken(
for capability: ManagedGatewayCapability,
feature: ManagedGatewayOOBEFeature,
forceRefresh: Bool = false
) async throws -> String {
guard Self.allowedScopes.contains(capability),
feature.requiredCapability == capability else {
throw ManagedGatewayError.scopeNotGranted(capability)
}
guard let practice = practiceSession(),
practice.isActive(at: now()),
practice.expectedFeature == feature else {
try? await clearGrant()
throw ManagedGatewayError.missingGrant
}
return try await grants.accessToken(
for: capability,
forceRefresh: forceRefresh
)
}
public func clearGrant() async throws {
try await grants.clearGrant()
}
}
@@ -1,22 +1,19 @@
// AIAgentSkillLayout.swift
// OSGKeyboard · Shared
//
// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
// the user confirmed a companion Shortcut for. Missing storage hydrates
// to the three default transform skills; an explicit empty list is kept
// Enabled clipboard skills (ordered) plus which export skills the user
// confirmed a companion Shortcut for. Missing storage hydrates to every
// built-in default skill; an explicit empty list is kept
// so turning every skill off is distinct from a fresh install.
import Foundation
public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
public static let maximumEnabled = 8
public static let defaultEnabledIDs = [
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.translateID
]
public static let defaultEnabledIDs = AIClipboardSkillCatalog.catalog
.filter(\.isDefault)
.map(\.id)
/// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
/// Keyboard chip order. Unknown and duplicate IDs are dropped on sanitize.
public var enabledIDs: [String]
/// Export skills whose companion Shortcut the user marked as added.
public var confirmedShortcutIDs: [String]
@@ -31,10 +28,6 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
self.confirmedShortcutIDs = confirmedShortcutIDs
}
public var isFull: Bool {
enabledIDs.count >= Self.maximumEnabled
}
public func isEnabled(_ id: String) -> Bool {
enabledIDs.contains(id)
}
@@ -43,20 +36,20 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
confirmedShortcutIDs.contains(id)
}
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
/// Drops unknown IDs and duplicates. Shortcut setup is tracked separately
/// so bundled export skills can remain visible as installed defaults.
public func sanitized(
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
) -> AIAgentSkillLayout {
let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
var seenEnabled = Set<String>()
let enabled = enabledIDs.filter { id in
guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
if skill.requiresShortcut {
return confirmedShortcutIDs.contains(id)
let enabled = enabledIDs.compactMap { id -> String? in
let canonical = AIClipboardSkillCatalog.canonicalID(for: id)
guard known[canonical] != nil, seenEnabled.insert(canonical).inserted else {
return nil
}
return true
return canonical
}
.prefix(Self.maximumEnabled)
var seenConfirmed = Set<String>()
let confirmed = confirmedShortcutIDs.filter { id in
@@ -65,7 +58,7 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
}
return AIAgentSkillLayout(
enabledIDs: Array(enabled),
enabledIDs: enabled,
confirmedShortcutIDs: confirmed
)
}
@@ -75,6 +68,5 @@ public enum AIAgentSkillEnableResult: Equatable, Sendable {
case enabled
case alreadyEnabled
case needsShortcut
case full
case unknown
}
+22 -5
View File
@@ -46,6 +46,9 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
public var locale: String
public var conditions: [String]
public var metadata: AIHintMetadata?
/// Server policy intent. Ordinary cards match hold-to-talk AI; current
/// information cards additionally require online search.
public var taskKind: ManagedGatewayTaskKind
public init(
id: String,
@@ -56,7 +59,8 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
source: String = "local",
locale: String = "zh",
conditions: [String] = [],
metadata: AIHintMetadata? = nil
metadata: AIHintMetadata? = nil,
taskKind: ManagedGatewayTaskKind = .aiQuestion
) {
self.id = id
self.displayText = displayText
@@ -67,6 +71,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
self.locale = locale
self.conditions = conditions
self.metadata = metadata
self.taskKind = taskKind
}
public var requiresClipboard30s: Bool {
@@ -83,7 +88,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
}
enum CodingKeys: String, CodingKey {
case id, displayText, text, prompt, category, priority, source, locale, conditions, metadata
case id, displayText, text, prompt, category, priority, source, locale, conditions, metadata, taskKind
}
public init(from decoder: Decoder) throws {
@@ -96,6 +101,10 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
locale = try container.decodeIfPresent(String.self, forKey: .locale) ?? "zh"
conditions = try container.decodeIfPresent([String].self, forKey: .conditions) ?? []
metadata = try container.decodeIfPresent(AIHintMetadata.self, forKey: .metadata)
taskKind = try container.decodeIfPresent(
ManagedGatewayTaskKind.self,
forKey: .taskKind
) ?? .aiQuestion
if let display = try container.decodeIfPresent(String.self, forKey: .displayText),
!display.isEmpty {
displayText = display
@@ -115,6 +124,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
try container.encode(locale, forKey: .locale)
try container.encode(conditions, forKey: .conditions)
try container.encodeIfPresent(metadata, forKey: .metadata)
try container.encode(taskKind, forKey: .taskKind)
}
}
@@ -167,13 +177,13 @@ public struct AIHintManifest: Codable, Equatable, Sendable {
}
public enum AIHintFeedEndpoints {
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
public static let baseURL = URL(string: "https://account.osglab.com/v1/content/hints")!
public static let manifestURL = baseURL.appendingPathComponent("manifest")
/// Packs the app fetches and the keyboard can resolve.
public static let supportedLocales = ["zh", "en"]
public static func packURL(locale: String) -> URL {
baseURL.appendingPathComponent("hints-\(locale).json")
baseURL.appendingPathComponent(locale)
}
}
@@ -194,6 +204,9 @@ public enum AIHintAppGroupKeys {
public static let readyPackPrefix = "hints.ready."
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
public static let manifest = "hints.meta.manifest.v1"
public static let manifestETag = "hints.meta.manifestETag.v1"
public static let packETagPrefix = "hints.meta.packETag."
public static func readyPackKey(locale: String) -> String {
readyPackPrefix + locale
@@ -203,4 +216,8 @@ public enum AIHintAppGroupKeys {
public static func lastSuccessKey(locale: String) -> String {
lastSuccessPrefix + locale
}
public static func packETagKey(locale: String) -> String {
packETagPrefix + locale
}
}
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let engineMode = "config.engineMode"
/// Credential ownership is independent from local/cloud ASR selection.
public static let credentialSource = "config.credentialSource"
/// Device-local, non-secret gate for account-funded managed requests.
/// This key is deliberately excluded from iCloud settings payloads.
public static let managedGatewayAccountSessionAvailable =
"account.managedGateway.sessionAvailable.v1"
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
public static let onboardingPage = "config.onboardingPage"
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
@@ -73,8 +77,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
/// One-shot migrations for newly default-installed built-in and official skills.
public static let agentSkillDefaultsMigrationVersion =
"config.aiAgentSkills.defaultsMigrationVersion"
/// User-created clipboard skills (no cloud sync; App Group only).
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
/// Last-known-good official catalog plus revision, freshness, and ETag metadata.
public static let officialSkillCatalog = "content.officialSkills.snapshot.v1"
}
// MARK: - Stored fields
@@ -195,13 +204,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public func makeClient(
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil
requestPurpose: ManagedGatewayRequestPurpose? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil
) -> LLMClient {
if credentialSource == .managed {
if credentialSource == .managed || requestPurpose == .oobe {
return ManagedLLMClient(
capability: .polish,
taskKind: taskKind,
requestPurpose: requestPurpose,
oobeFeature: oobeFeature,
grants: GatewayGrantCoordinator()
)
}
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
case submitAIQuestion
}
/// Wire version that includes managed-gateway request purpose.
public static let currentProtocolVersion = 7
/// Wire version that includes host-side webpage summary extraction.
public static let currentProtocolVersion = 10
public let protocolVersion: Int
public let sessionId: UUID
@@ -41,10 +41,16 @@ public struct FlowCommand: Codable, Equatable, Sendable {
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
/// Public webpage fetched by the host for an explicit summary skill.
public let aiWebPageURL: URL?
/// Fine-grained managed-gateway intent for AI question submissions.
public let aiTaskKind: ManagedGatewayTaskKind?
/// Audited product entry point for managed usage attribution.
public let managedRequestSource: ManagedGatewayRequestSource?
/// Optional server-audited purpose for managed gateway billing policy.
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
/// Absolute wall-clock deadlines survive extension reconstruction.
@@ -66,8 +72,11 @@ public struct FlowCommand: Codable, Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiWebPageURL: URL? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
managedRequestSource: ManagedGatewayRequestSource? = nil,
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
aiThinkingEnabled: Bool? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
@@ -86,8 +95,11 @@ public struct FlowCommand: Codable, Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiWebPageURL = aiWebPageURL
self.aiTaskKind = aiTaskKind
self.managedRequestSource = managedRequestSource
self.managedRequestPurpose = managedRequestPurpose
self.managedOOBEFeature = managedOOBEFeature
self.aiThinkingEnabled = aiThinkingEnabled
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
@@ -13,10 +13,16 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let aiConversationID: UUID?
/// When set with `.aiQuestion`, host skips ASR and answers this text.
public let aiQuestionText: String?
/// Public HTTPS page to fetch in the host before a webpage-summary request.
public let aiWebPageURL: URL?
/// Fine-grained managed-gateway intent. Regular questions keep the default.
public let aiTaskKind: ManagedGatewayTaskKind?
/// Audited product entry point for managed usage attribution.
public let managedRequestSource: ManagedGatewayRequestSource?
/// Optional server-audited purpose for managed gateway billing policy.
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
public let aiThinkingEnabled: Bool?
@@ -29,8 +35,11 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
aiWebPageURL: URL? = nil,
aiTaskKind: ManagedGatewayTaskKind? = nil,
managedRequestSource: ManagedGatewayRequestSource? = nil,
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
aiThinkingEnabled: Bool? = nil
) {
self.mode = mode
@@ -39,8 +48,11 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.aiWebPageURL = aiWebPageURL
self.aiTaskKind = aiTaskKind
self.managedRequestSource = managedRequestSource
self.managedRequestPurpose = managedRequestPurpose
self.managedOOBEFeature = managedOOBEFeature
self.aiThinkingEnabled = aiThinkingEnabled
}
@@ -62,13 +74,20 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
conversationID: UUID,
prefilledQuestion: String? = nil,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool? = nil
requestSource: ManagedGatewayRequestSource? = nil,
oobeFeature: ManagedGatewayOOBEFeature? = nil,
thinkingEnabled: Bool? = nil,
webPageURL: URL? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
mode: .aiQuestion,
aiConversationID: conversationID,
aiQuestionText: prefilledQuestion,
aiWebPageURL: webPageURL,
aiTaskKind: taskKind,
managedRequestSource: requestSource,
managedRequestPurpose: oobeFeature == nil ? nil : .oobe,
managedOOBEFeature: oobeFeature,
aiThinkingEnabled: thinkingEnabled
)
}
@@ -0,0 +1,273 @@
// OfficialSkillCatalog.swift
// OSGKeyboard · Shared
//
// Validated, host-fetched official clipboard skills. The keyboard extension
// only reads the last-known-good App Group snapshot and never performs network work.
import Foundation
public struct OfficialSkillLocalization: Codable, Equatable, Sendable {
public let name: String
public let summary: String
public let prompt: String
public init(name: String, summary: String, prompt: String) {
self.name = name
self.summary = summary
self.prompt = prompt
}
}
public struct OfficialSkillDefinition: Codable, Equatable, Identifiable, Sendable {
public let id: String
public let systemImage: String
public let sortOrder: Int
public let kind: AIClipboardSkillKind
public let thinkingEnabled: Bool
public let localizations: [String: OfficialSkillLocalization]
public init(
id: String,
systemImage: String,
sortOrder: Int,
kind: AIClipboardSkillKind,
thinkingEnabled: Bool,
localizations: [String: OfficialSkillLocalization]
) {
self.id = id
self.systemImage = systemImage
self.sortOrder = sortOrder
self.kind = kind
self.thinkingEnabled = thinkingEnabled
self.localizations = localizations
}
public func localization(
language: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
) -> OfficialSkillLocalization {
localization(locale: language.resolvedLanguageCode(preferredLanguages: preferredLanguages))
}
public func localization(locale: String) -> OfficialSkillLocalization {
let normalized = locale.lowercased()
let key = normalized.hasPrefix("zh") ? "zh-Hans" : "en"
// A validated definition always contains both keys. Keep an English
// fallback so a corrupt in-memory fixture cannot crash the extension.
return localizations[key] ?? localizations["en"] ?? OfficialSkillLocalization(
name: id,
summary: "",
prompt: ""
)
}
public func asClipboardSkill(
language: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
) -> AIClipboardSkill {
asClipboardSkill(
localization: localization(
language: language,
preferredLanguages: preferredLanguages
)
)
}
public func asClipboardSkill(locale: String) -> AIClipboardSkill {
asClipboardSkill(localization: localization(locale: locale))
}
private func asClipboardSkill(localization: OfficialSkillLocalization) -> AIClipboardSkill {
AIClipboardSkill(
id: id,
systemImage: systemImage,
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: kind,
isDefault: true,
customName: localization.name,
customSummary: localization.summary,
customPrompt: localization.prompt,
thinkingEnabled: thinkingEnabled
)
}
}
public enum OfficialSkillCatalogValidationError: Error, Equatable, Sendable {
case unsupportedSchemaVersion(Int)
case invalidRevision
case invalidGeneratedAt
case tooManySkills(maximum: Int)
case invalidID(String)
case duplicateID(String)
case conflictsWithBuiltInID(String)
case invalidSystemImage(String)
case invalidSortOrder(String)
case unsupportedKind(String)
case invalidLocalizations(String)
case emptyName(String, locale: String)
case emptySummary(String, locale: String)
case emptyPrompt(String, locale: String)
case promptTooLong(String, locale: String, maximum: Int)
}
public struct OfficialSkillCatalog: Codable, Equatable, Sendable {
public static let supportedSchemaVersion = 1
public static let maximumSkillCount = 100
public static let maximumIDCharacters = 100
public static let maximumSystemImageCharacters = 100
public static let maximumSortOrder = 100_000
public static let maximumNameCharacters = 40
public static let maximumSummaryCharacters = 200
public static let maximumPromptCharacters = 6_000
public let schemaVersion: Int
public let revision: Int64
public let generatedAt: String?
public let skills: [OfficialSkillDefinition]
/// Host wall clock for cache freshness. Absent in the server response.
public var refreshedAt: Date?
/// Last response ETag. Absent in the server response.
public var etag: String?
public init(
schemaVersion: Int = supportedSchemaVersion,
revision: Int64,
generatedAt: String? = nil,
skills: [OfficialSkillDefinition],
refreshedAt: Date? = nil,
etag: String? = nil
) {
self.schemaVersion = schemaVersion
self.revision = revision
self.generatedAt = generatedAt
self.skills = skills
self.refreshedAt = refreshedAt
self.etag = etag
}
public static let empty = OfficialSkillCatalog(revision: 0, skills: [])
public func validated(
builtInIDs: Set<String> = Set(AIClipboardSkillCatalog.catalog.map(\.id))
) throws -> OfficialSkillCatalog {
guard schemaVersion == Self.supportedSchemaVersion else {
throw OfficialSkillCatalogValidationError.unsupportedSchemaVersion(schemaVersion)
}
guard revision >= 0 else {
throw OfficialSkillCatalogValidationError.invalidRevision
}
if let generatedAt, Self.iso8601Date(from: generatedAt) == nil {
throw OfficialSkillCatalogValidationError.invalidGeneratedAt
}
guard skills.count <= Self.maximumSkillCount else {
throw OfficialSkillCatalogValidationError.tooManySkills(
maximum: Self.maximumSkillCount
)
}
var seen = Set<String>()
for skill in skills {
guard Self.isValidID(skill.id) else {
throw OfficialSkillCatalogValidationError.invalidID(skill.id)
}
guard seen.insert(skill.id).inserted else {
throw OfficialSkillCatalogValidationError.duplicateID(skill.id)
}
guard !builtInIDs.contains(skill.id) else {
throw OfficialSkillCatalogValidationError.conflictsWithBuiltInID(skill.id)
}
let image = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
guard !image.isEmpty, image.count <= Self.maximumSystemImageCharacters else {
throw OfficialSkillCatalogValidationError.invalidSystemImage(skill.id)
}
guard (0...Self.maximumSortOrder).contains(skill.sortOrder) else {
throw OfficialSkillCatalogValidationError.invalidSortOrder(skill.id)
}
guard skill.kind == .transform else {
throw OfficialSkillCatalogValidationError.unsupportedKind(skill.id)
}
guard Set(skill.localizations.keys) == ["zh-Hans", "en"] else {
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
}
for locale in ["zh-Hans", "en"] {
guard let localization = skill.localizations[locale] else {
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
}
let name = localization.name.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = localization.summary.trimmingCharacters(in: .whitespacesAndNewlines)
let prompt = localization.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.count <= Self.maximumNameCharacters else {
throw OfficialSkillCatalogValidationError.emptyName(skill.id, locale: locale)
}
guard !summary.isEmpty,
summary.count <= Self.maximumSummaryCharacters else {
throw OfficialSkillCatalogValidationError.emptySummary(skill.id, locale: locale)
}
guard !prompt.isEmpty else {
throw OfficialSkillCatalogValidationError.emptyPrompt(skill.id, locale: locale)
}
guard prompt.count <= Self.maximumPromptCharacters else {
throw OfficialSkillCatalogValidationError.promptTooLong(
skill.id,
locale: locale,
maximum: Self.maximumPromptCharacters
)
}
}
}
return OfficialSkillCatalog(
schemaVersion: schemaVersion,
revision: revision,
generatedAt: generatedAt,
skills: skills.sorted {
if $0.sortOrder == $1.sortOrder { return $0.id < $1.id }
return $0.sortOrder < $1.sortOrder
},
refreshedAt: refreshedAt,
etag: etag
)
}
public func resolvedSkills(
language: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
) -> [AIClipboardSkill] {
skills.map {
$0.asClipboardSkill(
language: language,
preferredLanguages: preferredLanguages
)
}
}
public func resolvedSkills(locale: String) -> [AIClipboardSkill] {
skills.map { $0.asClipboardSkill(locale: locale) }
}
private static func isValidID(_ id: String) -> Bool {
guard id.hasPrefix("official."), id.count <= Self.maximumIDCharacters else {
return false
}
let suffix = id.dropFirst("official.".count)
guard !suffix.isEmpty else { return false }
return suffix.unicodeScalars.allSatisfy { scalar in
let value = scalar.value
return (97...122).contains(value)
|| (48...57).contains(value)
|| value == 46
|| value == 45
|| value == 95
}
}
private static func iso8601Date(from value: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
}
@@ -12,7 +12,18 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
}
public let id: UUID
/// Final text delivered to the host app and shown in history.
public let text: String
/// ASR transcript after deterministic correction but before LLM polishing.
/// Stored for future speaking-style learning and intentionally not shown.
public let prePolishText: String?
/// Translation outputs must not be treated as same-language style examples.
public let wasTranslation: Bool
/// Style active when the final text was produced; nil for legacy or AI rows.
public let polishStyleID: String?
/// SHA-256 key into `SyncedSpeechHistory.polishStylePromptSnapshots`.
/// Deduplication preserves the exact prompt without repeating it per row.
public let polishStylePromptFingerprint: String?
public let createdAt: Date
/// Last content mutation. Legacy rows default to `createdAt`.
public let modifiedAt: Date
@@ -26,6 +37,10 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
public init(
id: UUID = UUID(),
text: String,
prePolishText: String? = nil,
wasTranslation: Bool = false,
polishStyleID: String? = nil,
polishStylePromptFingerprint: String? = nil,
createdAt: Date = Date(),
modifiedAt: Date? = nil,
revision: Int64 = 0,
@@ -34,6 +49,14 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
) {
self.id = id
self.text = text
let trimmedPrePolishText = prePolishText?
.trimmingCharacters(in: .whitespacesAndNewlines)
self.prePolishText = trimmedPrePolishText?.isEmpty == false
? trimmedPrePolishText
: nil
self.wasTranslation = wasTranslation
self.polishStyleID = polishStyleID
self.polishStylePromptFingerprint = polishStylePromptFingerprint
self.createdAt = createdAt
self.modifiedAt = modifiedAt ?? createdAt
self.revision = revision
@@ -45,6 +68,13 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
text = try container.decode(String.self, forKey: .text)
prePolishText = try container.decodeIfPresent(String.self, forKey: .prePolishText)
wasTranslation = try container.decodeIfPresent(Bool.self, forKey: .wasTranslation) ?? false
polishStyleID = try container.decodeIfPresent(String.self, forKey: .polishStyleID)
polishStylePromptFingerprint = try container.decodeIfPresent(
String.self,
forKey: .polishStylePromptFingerprint
)
createdAt = try container.decode(Date.self, forKey: .createdAt)
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
@@ -4,10 +4,11 @@
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
// propagate single-entry deletes and "clear all" across devices.
import CryptoKit
import Foundation
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
public static let schemaVersion = 3
public static let schemaVersion = 5
public static let kvsKey = "speechHistory.v2"
public static let legacyKVSKey = "speechHistory.v1"
public static let maxEntries = 300
@@ -26,17 +27,24 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
public var schemaVersion: Int
public var updatedAt: Date
public var entries: [SpeechHistoryEntry]
/// Deduplicated historical style prompts keyed by SHA-256. Keeping prompt
/// snapshots outside rows avoids multiplying a 6,000-character prompt by
/// every dictation while still preserving the prompt used at that moment.
public var polishStylePromptSnapshots: [String: String]
/// Entry IDs deleted on any device, with deletion timestamps.
public var deletedEntryIDs: [UUID: Date]
/// Recent idempotency keys for keyboard-originated history mutations.
public var appliedMutationIDs: [UUID]
/// When set, entries created at or before this instant are excluded.
public var clearedAt: Date?
public static let maxPolishStylePromptSnapshots = 32
public static let maxPolishStylePromptSnapshotCharacters = 96_000
public init(
schemaVersion: Int = Self.schemaVersion,
updatedAt: Date = Date(),
entries: [SpeechHistoryEntry] = [],
polishStylePromptSnapshots: [String: String] = [:],
deletedEntryIDs: [UUID: Date] = [:],
appliedMutationIDs: [UUID] = [],
clearedAt: Date? = nil
@@ -44,6 +52,7 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
self.schemaVersion = schemaVersion
self.updatedAt = updatedAt
self.entries = entries
self.polishStylePromptSnapshots = polishStylePromptSnapshots
self.deletedEntryIDs = deletedEntryIDs
self.appliedMutationIDs = appliedMutationIDs
self.clearedAt = clearedAt
@@ -54,6 +63,10 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
polishStylePromptSnapshots = try container.decodeIfPresent(
[String: String].self,
forKey: .polishStylePromptSnapshots
) ?? [:]
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
deletedEntryIDs = map
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
@@ -105,20 +118,30 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
entries = Array(entries.prefix(maxEntries))
}
return SyncedSpeechHistory(
var snapshots = local.polishStylePromptSnapshots
for (fingerprint, prompt) in remote.polishStylePromptSnapshots {
snapshots[fingerprint] = snapshots[fingerprint] ?? prompt
}
var merged = SyncedSpeechHistory(
updatedAt: max(local.updatedAt, remote.updatedAt),
entries: entries,
polishStylePromptSnapshots: snapshots,
deletedEntryIDs: deletedIDs,
appliedMutationIDs: appliedMutationIDs,
clearedAt: clearedAt
)
merged.prunePolishStylePromptSnapshots()
return merged
}
/// Trim to the newest `maxEntries` rows (call after local-only appends).
public mutating func trimEntries() {
guard entries.count > Self.maxEntries else { return }
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
updatedAt = Date()
if entries.count > Self.maxEntries {
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
updatedAt = Date()
}
prunePolishStylePromptSnapshots()
}
public mutating func pruneTombstonesIfNeeded() {
@@ -162,19 +185,93 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
_ candidate: SpeechHistoryEntry,
over existing: SpeechHistoryEntry
) -> SpeechHistoryEntry {
let winner: SpeechHistoryEntry
let fallback: SpeechHistoryEntry
if candidate.revision != existing.revision {
return candidate.revision > existing.revision ? candidate : existing
(winner, fallback) = candidate.revision > existing.revision
? (candidate, existing)
: (existing, candidate)
} else if candidate.modifiedAt != existing.modifiedAt {
(winner, fallback) = candidate.modifiedAt > existing.modifiedAt
? (candidate, existing)
: (existing, candidate)
} else {
(winner, fallback) = candidate.createdAt >= existing.createdAt
? (candidate, existing)
: (existing, candidate)
}
if candidate.modifiedAt != existing.modifiedAt {
return candidate.modifiedAt > existing.modifiedAt ? candidate : existing
return preservingCorpusMetadata(of: winner, fallback: fallback)
}
/// Older app versions decode and re-encode history without the v4/v5 corpus
/// fields. A newer visible-text revision must win without erasing the only
/// retained pre-polish sample.
private static func preservingCorpusMetadata(
of winner: SpeechHistoryEntry,
fallback: SpeechHistoryEntry
) -> SpeechHistoryEntry {
let usesFallbackTranscript = winner.prePolishText == nil
&& fallback.prePolishText != nil
let prePolishText = winner.prePolishText ?? fallback.prePolishText
let polishStyleID = winner.polishStyleID ?? fallback.polishStyleID
let polishStylePromptFingerprint = winner.polishStylePromptFingerprint
?? fallback.polishStylePromptFingerprint
guard prePolishText != winner.prePolishText
|| polishStyleID != winner.polishStyleID
|| polishStylePromptFingerprint
!= winner.polishStylePromptFingerprint else {
return winner
}
return candidate.createdAt >= existing.createdAt ? candidate : existing
return SpeechHistoryEntry(
id: winner.id,
text: winner.text,
prePolishText: prePolishText,
wasTranslation: usesFallbackTranscript
? fallback.wasTranslation
: winner.wasTranslation,
polishStyleID: polishStyleID,
polishStylePromptFingerprint: polishStylePromptFingerprint,
createdAt: winner.createdAt,
modifiedAt: winner.modifiedAt,
revision: winner.revision,
engineMode: winner.engineMode,
source: winner.source
)
}
public static func polishStylePromptFingerprint(for prompt: String) -> String {
SHA256.hash(data: Data(prompt.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
mutating func prunePolishStylePromptSnapshots() {
var retained: [String: String] = [:]
var retainedCharacters = 0
let newestEntries = entries.sorted { $0.createdAt > $1.createdAt }
for entry in newestEntries {
guard retained.count < Self.maxPolishStylePromptSnapshots,
let fingerprint = entry.polishStylePromptFingerprint,
retained[fingerprint] == nil,
let prompt = polishStylePromptSnapshots[fingerprint] else {
continue
}
guard retainedCharacters + prompt.count
<= Self.maxPolishStylePromptSnapshotCharacters else {
continue
}
retained[fingerprint] = prompt
retainedCharacters += prompt.count
}
polishStylePromptSnapshots = retained
}
}
extension SyncedSpeechHistory {
mutating func recordClearAll(at date: Date = Date()) {
entries.removeAll()
polishStylePromptSnapshots.removeAll()
clearedAt = date
}
}
@@ -114,3 +114,76 @@ public enum TranslationLanguageCatalog {
return all.first { $0.id == offLocaleId } ?? all[0]
}
}
/// Resolves the device's first preferred language for clipboard translation.
/// This intentionally ignores the optional post-dictation translation target.
public enum SystemLanguageResolver {
public static func primaryIdentifier(
preferredLanguages: [String] = Locale.preferredLanguages
) -> String {
normalizedIdentifier(
preferredLanguages.first ?? Locale.autoupdatingCurrent.identifier
)
}
public static func promptLanguageName(
preferredLanguages: [String] = Locale.preferredLanguages
) -> String {
let identifier = primaryIdentifier(preferredLanguages: preferredLanguages)
if let known = TranslationLanguageCatalog.all.first(where: { $0.id == identifier }) {
return known.promptLanguageName
}
return Locale(identifier: "en").localizedString(forIdentifier: identifier)
?? identifier
}
public static func displayLanguageName(
uiLanguage: AppUILanguage,
preferredLanguages: [String] = Locale.preferredLanguages
) -> String {
let identifier = primaryIdentifier(preferredLanguages: preferredLanguages)
let displayLocale = Locale(identifier: uiLanguage.resolvedLanguageCode())
return displayLocale.localizedString(forIdentifier: identifier)
?? promptLanguageName(preferredLanguages: preferredLanguages)
}
public static func isSameLanguage(
sourceIdentifier: String,
targetIdentifier: String
) -> Bool {
let source = normalizedIdentifier(sourceIdentifier)
let target = normalizedIdentifier(targetIdentifier)
if source.hasPrefix("zh"), target.hasPrefix("zh") {
let sourceScript = chineseScript(in: source)
let targetScript = chineseScript(in: target)
return sourceScript == nil || targetScript == nil || sourceScript == targetScript
}
return source == target
}
private static func normalizedIdentifier(_ identifier: String) -> String {
let language = Locale.Language(identifier: identifier)
guard let rawCode = language.languageCode?.identifier else {
return identifier.lowercased()
}
let code = rawCode.lowercased()
guard code == "zh" || code == "yue" else { return code }
let script = language.script?.identifier.lowercased()
let region = Locale(identifier: identifier).region?.identifier.uppercased()
if script == "hant" || ["HK", "MO", "TW"].contains(region) {
return "zh-Hant"
}
if script == "hans" || ["CN", "MY", "SG"].contains(region) {
return "zh-Hans"
}
return "zh"
}
private static func chineseScript(in identifier: String) -> String? {
let normalized = identifier.lowercased()
if normalized.contains("hant") { return "Hant" }
if normalized.contains("hans") { return "Hans" }
return nil
}
}

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