Compare commits
7 Commits
7bd77509b6
...
03837b0e48
| Author | SHA1 | Date | |
|---|---|---|---|
| 03837b0e48 | |||
| 2cc81c4628 | |||
| 7df5bbdaa0 | |||
| fe8f657c8d | |||
| 386ba140f7 | |||
| e5a83843db | |||
| ac374631ae |
@@ -62,6 +62,9 @@ Signing.local.xcconfig
|
|||||||
# Custom LM compile output (Mac-only; device-side prepare uses .bin from bundle)
|
# Custom LM compile output (Mac-only; device-side prepare uses .bin from bundle)
|
||||||
OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
|
OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
|
||||||
|
|
||||||
|
# Reproducible Create ML candidates (selected models live in Shared resources)
|
||||||
|
ModelTraining/ClipboardSemantics/Candidates/
|
||||||
|
|
||||||
# Python bytecode
|
# Python bytecode
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
|||||||
|
|
||||||
### Version format
|
### 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 |
|
| 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
@@ -8,18 +8,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关。
|
- **Phone-number clipboard actions**: recognize a single copied phone number locally, then offer system calling or a new-contact form with the number prefilled. / **电话号码剪贴板操作**:在本地识别复制内容中的唯一电话号码,并提供系统拨号或预填号码的新建联系人表单。
|
||||||
- **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 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
|
- **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 安全、限量提取公开网页正文后生成总结。
|
||||||
- **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 用途标签,并由服务端执行一次性免费资格,不预扣也不结算积分。
|
- **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
|
### 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. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
|
- **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
|
### 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. / **永久邀请链接**:登录后异步加载账号级服务端邀请资料,按账号缓存,并在刷新或离线失败时持续分享服务端返回的固定链接。
|
- **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 或键盘扩展的旧快照覆盖其他较新的设置。
|
- **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-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 意图,让网关可为低延迟转换任务关闭高成本思考。
|
- **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
|
## [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
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.osgkeyboard.ios.analytics-sync</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -98,6 +102,8 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSContactsUsageDescription</key>
|
||||||
|
<string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
|
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
@@ -115,10 +121,6 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
</array>
|
</array>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>com.osgkeyboard.ios.analytics-sync</string>
|
|
||||||
</array>
|
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
|
|||||||
@@ -22,20 +22,22 @@
|
|||||||
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
||||||
<div id="top">
|
<div id="top">
|
||||||
<h1>OSGKeyboard Privacy Policy</h1>
|
<h1>OSGKeyboard Privacy Policy</h1>
|
||||||
<p><strong>Last updated:</strong> August 20, 2026 · <em>v2.0</em></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, you may optionally sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains both paths.</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>
|
<h2>What we collect</h2>
|
||||||
<ul>
|
<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>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>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>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 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>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>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>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>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 device’s 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>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 device’s 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>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>
|
<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>
|
<ul>
|
||||||
<li><strong>Microphone</strong> — required for voice input and background voice sessions.</li>
|
<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>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, and—only 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>
|
</ul>
|
||||||
|
|
||||||
<h2>Third parties</h2>
|
<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>
|
<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>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 app’s 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 app’s 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 device’s 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>Clipboard history</strong> — stays in this device’s 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>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>
|
<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">
|
<hr id="zh">
|
||||||
<h1>OSGKeyboard 隐私政策</h1>
|
<h1>OSGKeyboard 隐私政策</h1>
|
||||||
<p><strong>更新日期:</strong>2026 年 8 月 20 日 · <em>v2.0</em></p>
|
<p><strong>更新日期:</strong>2026 年 8 月 22 日 · <em>v2.0</em></p>
|
||||||
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 用户也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策同时说明这两条可选路径。</p>
|
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 首次引导还提供限次匿名 AI 体验,之后也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策一并说明这些可选路径。</p>
|
||||||
|
|
||||||
<h2>我们处理的数据</h2>
|
<h2>我们处理的数据</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
|
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
|
||||||
<li><strong>转写文字与光标上下文</strong> — 使用已配置的 LLM 或托管积分时,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</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>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 建议与 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>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</li>
|
||||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</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>可选 OSGKeyboard 账号与购买</strong> — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。</li>
|
||||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</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> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
||||||
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||||
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
|
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
|
||||||
@@ -104,17 +108,17 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><strong>麦克风</strong> — 语音输入与后台语音会话所需。</li>
|
<li><strong>麦克风</strong> — 语音输入与后台语音会话所需。</li>
|
||||||
<li><strong>语音识别</strong> — 端侧转写所需。</li>
|
<li><strong>语音识别</strong> — 端侧转写所需。</li>
|
||||||
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key、与主 App 通信,并仅在你开启剪贴板历史后读取剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
|
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key、与主 App 通信,并仅在你开启剪贴板历史,或主动复制内置教学示例进行 AI 体验时读取对应剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>第三方</h2>
|
<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>
|
<h2>数据保留</h2>
|
||||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
<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> — 仅保存在本机 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>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
||||||
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ enum AIAgentShortcutRunner {
|
|||||||
@MainActor
|
@MainActor
|
||||||
static func runPendingIfNeeded() {
|
static func runPendingIfNeeded() {
|
||||||
AIAgentShortcutRun.trace("host.runPending begin")
|
AIAgentShortcutRun.trace("host.runPending begin")
|
||||||
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
|
let store = AppGroupStore()
|
||||||
let catalog = AppGroupStore().agentUserSkillCatalog
|
guard let payload = store.consumePendingShortcutRun() else { return }
|
||||||
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else {
|
guard let skill = store.resolvedAgentSkillCatalog.first(where: { $0.id == payload.skillID }) else {
|
||||||
AIAgentShortcutRun.trace(
|
AIAgentShortcutRun.trace(
|
||||||
"host.runPending skip unknownSkill=\(payload.skillID)"
|
"host.runPending skip unknownSkill=\(payload.skillID)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,42 +8,71 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
enum AIHintRefreshOutcome: Equatable {
|
||||||
|
case skippedFresh
|
||||||
|
case completed(updatedLocales: [String])
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum AIHintRefreshService {
|
final class AIHintRefreshService {
|
||||||
private static var inFlight: Task<Void, Never>?
|
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) {
|
static func refreshIfNeeded(reason: String) {
|
||||||
guard AIHintStore.shouldRefresh() else {
|
shared.scheduleRefreshIfNeeded(reason: reason)
|
||||||
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scheduleRefreshIfNeeded(reason: String) {
|
||||||
guard inFlight == nil else {
|
guard inFlight == nil else {
|
||||||
OSGDiag.log("AIHintRefresh skip (inFlight) reason=\(reason)", category: "hints")
|
OSGDiag.log("AIHintRefresh skip (inFlight) reason=\(reason)", category: "hints")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inFlight = Task {
|
inFlight = Task {
|
||||||
defer { inFlight = nil }
|
defer { inFlight = nil }
|
||||||
await runRefresh(reason: reason)
|
return await refreshNowIfNeeded(reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func runRefresh(reason: String) async {
|
func refreshNowIfNeeded(
|
||||||
AIHintStore.markAttempt()
|
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")
|
OSGDiag.log("AIHintRefresh start reason=\(reason)", category: "hints")
|
||||||
|
|
||||||
// The manifest only supplies fallback dates, so a manifest failure must
|
// 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.
|
// 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 {
|
for locale in AIHintFeedEndpoints.supportedLocales {
|
||||||
if Task.isCancelled { return }
|
if Task.isCancelled { break }
|
||||||
guard AIHintStore.shouldRefresh(locale: locale) else { continue }
|
if !force,
|
||||||
|
!AIHintStore.shouldRefresh(locale: locale, now: now, defaults: defaults) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let pack = try await readyPack(locale: locale, manifest: manifest)
|
try await refreshPack(locale: locale, manifest: manifest, now: now)
|
||||||
AIHintStore.saveReadyPack(pack)
|
updatedLocales.append(locale)
|
||||||
OSGDiag.log(
|
|
||||||
"AIHintRefresh wrote locale=\(locale) cards=\(pack.cards.count)",
|
|
||||||
category: "hints"
|
|
||||||
)
|
|
||||||
} catch {
|
} catch {
|
||||||
// Strategy B: keep this locale's previous successful ready pack.
|
// Strategy B: keep this locale's previous successful ready pack.
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
@@ -53,31 +82,67 @@ enum AIHintRefreshService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return .completed(updatedLocales: updatedLocales)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readyPack(
|
private func refreshPack(
|
||||||
locale: String,
|
locale: String,
|
||||||
manifest: AIHintManifest?
|
manifest: AIHintManifest?,
|
||||||
) async throws -> AIHintPack {
|
now: Date
|
||||||
let remote = try await fetchPack(locale: locale)
|
) 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 filtered = remote.cards.filter { card in
|
||||||
let hay = card.displayText + card.prompt
|
let hay = card.displayText + card.prompt
|
||||||
return !hay.contains("历史上的今天")
|
return !hay.contains("历史上的今天")
|
||||||
&& !hay.localizedCaseInsensitiveContains("on this day")
|
&& !hay.localizedCaseInsensitiveContains("on this day")
|
||||||
}
|
}
|
||||||
// Prefer remote clipboard cards when present; always keep local evergreen.
|
// 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(
|
let compressed = await AIHintKeywordCompressor().compress(
|
||||||
cards: merged,
|
cards: merged,
|
||||||
locale: locale
|
locale: locale
|
||||||
)
|
)
|
||||||
return AIHintPack(
|
let ready = AIHintPack(
|
||||||
locale: locale,
|
locale: locale,
|
||||||
generatedAt: remote.generatedAt ?? manifest?.generatedAt,
|
generatedAt: remote.generatedAt ?? manifest?.generatedAt,
|
||||||
expiresAt: remote.expiresAt ?? manifest?.expiresAt,
|
expiresAt: remote.expiresAt ?? manifest?.expiresAt,
|
||||||
version: max(remote.version, 1),
|
version: max(remote.version, 1),
|
||||||
cards: compressed,
|
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 }
|
return Array(byID.values).sorted { $0.priority > $1.priority }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetchManifest() async throws -> AIHintManifest {
|
private func fetchManifest() async throws -> AIHintManifest? {
|
||||||
let (data, response) = try await URLSession.shared.data(from: AIHintFeedEndpoints.manifestURL)
|
var request = URLRequest(url: baseURL.appendingPathComponent("manifest"))
|
||||||
try validateHTTP(response)
|
request.httpMethod = "GET"
|
||||||
return try JSONDecoder().decode(AIHintManifest.self, from: data)
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let etag = AIHintStore.manifestETag(defaults: defaults) {
|
||||||
|
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
||||||
}
|
}
|
||||||
|
let (data, response) = try await transport.data(for: request)
|
||||||
private static func fetchPack(locale: String) async throws -> AIHintPack {
|
if response.statusCode == 304 {
|
||||||
let url = AIHintFeedEndpoints.packURL(locale: locale)
|
return AIHintStore.loadManifest(defaults: defaults)
|
||||||
let (data, response) = try await URLSession.shared.data(from: url)
|
|
||||||
try validateHTTP(response)
|
|
||||||
return try JSONDecoder().decode(AIHintPack.self, from: data)
|
|
||||||
}
|
}
|
||||||
|
guard response.statusCode == 200 else {
|
||||||
private static func validateHTTP(_ response: URLResponse) throws {
|
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
|
||||||
guard let http = response as? HTTPURLResponse else {
|
|
||||||
throw URLError(.badServerResponse)
|
|
||||||
}
|
|
||||||
guard (200..<300).contains(http.statusCode) else {
|
|
||||||
throw URLError(.badServerResponse)
|
|
||||||
}
|
}
|
||||||
|
let manifest = try JSONDecoder().decode(AIHintManifest.self, from: data)
|
||||||
|
AIHintStore.saveManifest(
|
||||||
|
manifest,
|
||||||
|
etag: response.value(forHTTPHeaderField: "ETag"),
|
||||||
|
defaults: defaults
|
||||||
|
)
|
||||||
|
return manifest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,40 +12,6 @@ import OSGKeyboardShared
|
|||||||
import OSLog
|
import OSLog
|
||||||
import UIKit
|
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 {
|
final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable {
|
||||||
static let shared = HostAnalyticsBearerBridge()
|
static let shared = HostAnalyticsBearerBridge()
|
||||||
|
|
||||||
@@ -82,16 +48,33 @@ struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding {
|
|||||||
|
|
||||||
func bearerToken() async throws -> String? {
|
func bearerToken() async throws -> String? {
|
||||||
guard try await apiClient.currentSession() != nil else { return nil }
|
guard try await apiClient.currentSession() != nil else { return nil }
|
||||||
|
do {
|
||||||
return try await apiClient.accessTokenForAuthorizedRequest()
|
return try await apiClient.accessTokenForAuthorizedRequest()
|
||||||
|
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshBearerToken(
|
func refreshBearerToken(
|
||||||
afterUnauthorizedAccessToken failedToken: String?
|
afterUnauthorizedAccessToken failedToken: String?
|
||||||
) async throws -> String? {
|
) async throws -> String? {
|
||||||
guard let failedToken, !failedToken.isEmpty else { return nil }
|
guard let failedToken, !failedToken.isEmpty else { return nil }
|
||||||
|
do {
|
||||||
return try await apiClient.refreshAccessToken(
|
return try await apiClient.refreshAccessToken(
|
||||||
afterUnauthorizedAccessToken: failedToken
|
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
|
@MainActor
|
||||||
final class AnalyticsHostService: ObservableObject {
|
final class AnalyticsHostService: ObservableObject {
|
||||||
|
private enum RequestedDatabaseAccess {
|
||||||
|
case uninitialized
|
||||||
|
case foreground
|
||||||
|
case suspended
|
||||||
|
}
|
||||||
|
|
||||||
static let shared = AnalyticsHostService()
|
static let shared = AnalyticsHostService()
|
||||||
static let backgroundTaskIdentifier = "com.osgkeyboard.ios.analytics-sync"
|
static let backgroundTaskIdentifier = "com.osgkeyboard.ios.analytics-sync"
|
||||||
|
|
||||||
@@ -120,6 +109,7 @@ final class AnalyticsHostService: ObservableObject {
|
|||||||
let client: any AnalyticsClient
|
let client: any AnalyticsClient
|
||||||
|
|
||||||
private let runtime: AnalyticsRuntime
|
private let runtime: AnalyticsRuntime
|
||||||
|
private let keyboardUsageRuntime: KeyboardUsageRuntime
|
||||||
private let uploadSignal: AnalyticsUploadSignal
|
private let uploadSignal: AnalyticsUploadSignal
|
||||||
private let pathMonitor = NWPathMonitor()
|
private let pathMonitor = NWPathMonitor()
|
||||||
private let monitorQueue = DispatchQueue(
|
private let monitorQueue = DispatchQueue(
|
||||||
@@ -127,74 +117,110 @@ final class AnalyticsHostService: ObservableObject {
|
|||||||
qos: .utility
|
qos: .utility
|
||||||
)
|
)
|
||||||
private var isMonitoringNetwork = false
|
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(
|
init(
|
||||||
bearerProvider: any AnalyticsBearerProviding = HostAnalyticsBearerBridge.shared,
|
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
|
uploadSignal = signal
|
||||||
|
let environment = Self.environment
|
||||||
let runtime = AnalyticsRuntime.mainApp(
|
let runtime = AnalyticsRuntime.mainApp(
|
||||||
environment: Self.environment,
|
environment: environment,
|
||||||
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
|
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
|
||||||
network: network,
|
network: network,
|
||||||
bearerProvider: bearerProvider,
|
bearerProvider: bearerProvider,
|
||||||
trigger: signal,
|
trigger: signal,
|
||||||
logger: HostAnalyticsLogger()
|
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.runtime = runtime
|
||||||
|
self.keyboardUsageRuntime = keyboardUsageRuntime
|
||||||
client = runtime.client
|
client = runtime.client
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await signal.install {
|
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) {
|
func prepare(firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel) {
|
||||||
|
self.firstOpenAcquisitionChannel = firstOpenAcquisitionChannel
|
||||||
startNetworkMonitoringIfNeeded()
|
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() {
|
func appDidBecomeActive() {
|
||||||
client.recordSessionActivity()
|
requestForegroundDatabaseAccess()
|
||||||
requestImmediateUpload(maximumBatches: 8)
|
}
|
||||||
|
|
||||||
|
func appWillResignActive() {
|
||||||
|
requestSuspendedDatabaseAccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
func appDidEnterBackground() {
|
func appDidEnterBackground() {
|
||||||
Self.scheduleBackgroundRefresh()
|
Self.scheduleBackgroundRefresh()
|
||||||
let identifier = UIApplication.shared.beginBackgroundTask(
|
requestSuspendedDatabaseAccess()
|
||||||
withName: "analytics-sync"
|
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 }
|
guard identifier != .invalid else { return }
|
||||||
let task = Task {
|
backgroundTaskIdentifier = identifier
|
||||||
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 2)
|
|
||||||
|
let transitionTask = databaseTransitionTask
|
||||||
|
Task { [weak self] in
|
||||||
|
await transitionTask?.value
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
UIApplication.shared.endBackgroundTask(identifier)
|
self?.finishBackgroundTask(identifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foregroundUploadTask = task
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func observeAuthenticatedAccount(_ accountID: UUID) async {
|
func observeAuthenticatedAccount(_ accountID: UUID) async {
|
||||||
_ = await runtime.observeAccount(stableIdentifier: accountID.uuidString)
|
pendingAuthenticatedAccountID = accountID
|
||||||
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8)
|
let transitionTask = databaseTransitionTask
|
||||||
|
await transitionTask?.value
|
||||||
|
await processDeferredAccountWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAccountDeletion() async {
|
func handleAccountDeletion() async {
|
||||||
await runtime.handleAccountDeletion()
|
hasPendingAccountDeletion = true
|
||||||
|
let transitionTask = databaseTransitionTask
|
||||||
|
await transitionTask?.value
|
||||||
|
await processDeferredAccountWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setEnabled(_ enabled: Bool) {
|
func setEnabled(_ enabled: Bool) {
|
||||||
@@ -204,8 +230,10 @@ final class AnalyticsHostService: ObservableObject {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isEnabled = current
|
self.isEnabled = current
|
||||||
}
|
}
|
||||||
if current {
|
if !current {
|
||||||
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4)
|
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() {
|
static func registerBackgroundTask() {
|
||||||
BGTaskScheduler.shared.register(
|
BGTaskScheduler.shared.register(
|
||||||
forTaskWithIdentifier: backgroundTaskIdentifier,
|
forTaskWithIdentifier: backgroundTaskIdentifier,
|
||||||
@@ -238,11 +257,13 @@ final class AnalyticsHostService: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let operation = Task {
|
let operation = Task {
|
||||||
await AnalyticsHostService.shared.runtime.uploadCoordinator
|
let success = await AnalyticsHostService.shared
|
||||||
.uploadAvailableEvents(maximumBatches: 4)
|
.performBackgroundRefresh()
|
||||||
refreshTask.setTaskCompleted(success: !Task.isCancelled)
|
refreshTask.setTaskCompleted(success: success)
|
||||||
|
if success {
|
||||||
scheduleBackgroundRefresh()
|
scheduleBackgroundRefresh()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
refreshTask.expirationHandler = {
|
refreshTask.expirationHandler = {
|
||||||
operation.cancel()
|
operation.cancel()
|
||||||
}
|
}
|
||||||
@@ -260,17 +281,142 @@ final class AnalyticsHostService: ObservableObject {
|
|||||||
isMonitoringNetwork = true
|
isMonitoringNetwork = true
|
||||||
pathMonitor.pathUpdateHandler = { path in
|
pathMonitor.pathUpdateHandler = { path in
|
||||||
guard path.status == .satisfied else { return }
|
guard path.status == .satisfied else { return }
|
||||||
Task { @MainActor in
|
Task {
|
||||||
AnalyticsHostService.shared.requestImmediateUpload(maximumBatches: 8)
|
await AnalyticsHostService.shared.uploadSignal
|
||||||
|
.requestActivationUpload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pathMonitor.start(queue: monitorQueue)
|
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(
|
private static let endpoint = URL(
|
||||||
string: "https://account.osglab.com/v1/analytics/events"
|
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 {
|
private static var environment: AnalyticsEnvironment {
|
||||||
let appVersion = Bundle.main.object(
|
let appVersion = Bundle.main.object(
|
||||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private struct PrefilledAIRequest {
|
private struct PrefilledAIRequest {
|
||||||
let question: String
|
let question: String
|
||||||
let conversationID: UUID?
|
let conversationID: UUID?
|
||||||
|
let webPageURL: URL?
|
||||||
let taskKind: ManagedGatewayTaskKind
|
let taskKind: ManagedGatewayTaskKind
|
||||||
|
let requestSource: ManagedGatewayRequestSource?
|
||||||
|
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
let oobeFeature: ManagedGatewayOOBEFeature?
|
||||||
let thinkingEnabled: Bool
|
let thinkingEnabled: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +120,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private let polisher: PolishingService
|
private let polisher: PolishingService
|
||||||
/// AI-mode turns are intentionally process-local and never persisted.
|
/// AI-mode turns are intentionally process-local and never persisted.
|
||||||
private let aiConversations = AIConversationStore()
|
private let aiConversations = AIConversationStore()
|
||||||
|
private let webPageContentService: WebPageContentService
|
||||||
/// Cached ASR instance. The only on-device backend is iOS
|
/// Cached ASR instance. The only on-device backend is iOS
|
||||||
/// `SpeechAnalyzer`, which has no warm-up step — we can hand the
|
/// `SpeechAnalyzer`, which has no warm-up step — we can hand the
|
||||||
/// factory-built service straight back without going through the
|
/// factory-built service straight back without going through the
|
||||||
@@ -162,6 +167,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private var pendingSourceHistoryEntryRevision: Int64?
|
private var pendingSourceHistoryEntryRevision: Int64?
|
||||||
private var pendingAIConversationID: UUID?
|
private var pendingAIConversationID: UUID?
|
||||||
private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose?
|
private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
private var pendingManagedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||||
private var pendingProcessingDeadlineAt: TimeInterval?
|
private var pendingProcessingDeadlineAt: TimeInterval?
|
||||||
private var pendingStopUtteranceId: UUID?
|
private var pendingStopUtteranceId: UUID?
|
||||||
private var currentCommandSeq: Int64 = 0
|
private var currentCommandSeq: Int64 = 0
|
||||||
@@ -174,7 +180,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
@Published private(set) var isUtteranceProcessing = false
|
@Published private(set) var isUtteranceProcessing = false
|
||||||
private var finalizeTask: Task<Void, Never>?
|
private var finalizeTask: Task<Void, Never>?
|
||||||
private var asrTask: 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 utteranceSafetyTask: Task<Void, Never>?
|
||||||
private var chunkedPipeline: ChunkedUtterancePipeline?
|
private var chunkedPipeline: ChunkedUtterancePipeline?
|
||||||
private var currentPartial = ""
|
private var currentPartial = ""
|
||||||
@@ -218,11 +224,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||||
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController()
|
pipController: any FlowPictureInPictureControlling = FlowPictureInPictureController(),
|
||||||
|
webPageContentService: WebPageContentService = .shared
|
||||||
) {
|
) {
|
||||||
self.analyticsClient = analyticsClient
|
self.analyticsClient = analyticsClient
|
||||||
self.polisher = PolishingService(analyticsClient: analyticsClient)
|
self.polisher = PolishingService(analyticsClient: analyticsClient)
|
||||||
self.pipController = pipController
|
self.pipController = pipController
|
||||||
|
self.webPageContentService = webPageContentService
|
||||||
// Sessions are (re)started explicitly on app foreground via
|
// Sessions are (re)started explicitly on app foreground via
|
||||||
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
||||||
// stored session here — after a force-quit that would resurrect capture
|
// stored session here — after a force-quit that would resurrect capture
|
||||||
@@ -442,6 +450,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
status: .aborted
|
status: .aborted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
analyticsOperations.cancelAll()
|
||||||
|
|
||||||
wantsActiveSession = false
|
wantsActiveSession = false
|
||||||
pipRecoveryOperation &+= 1
|
pipRecoveryOperation &+= 1
|
||||||
@@ -527,6 +536,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
status: .aborted
|
status: .aborted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
analyticsOperations.cancelAll()
|
||||||
|
|
||||||
wantsActiveSession = false
|
wantsActiveSession = false
|
||||||
pipRecoveryOperation &+= 1
|
pipRecoveryOperation &+= 1
|
||||||
@@ -1502,6 +1512,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
currentUtteranceMode = command.resolvedUtteranceMode
|
currentUtteranceMode = command.resolvedUtteranceMode
|
||||||
pendingManagedRequestPurpose = command.managedRequestPurpose
|
pendingManagedRequestPurpose = command.managedRequestPurpose
|
||||||
|
pendingManagedOOBEFeature = command.managedOOBEFeature
|
||||||
pendingAIConversationID = currentUtteranceMode == .aiQuestion
|
pendingAIConversationID = currentUtteranceMode == .aiQuestion
|
||||||
? command.aiConversationID
|
? command.aiConversationID
|
||||||
: nil
|
: nil
|
||||||
@@ -1615,6 +1626,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
) else { return }
|
) else { return }
|
||||||
currentUtteranceMode = .aiQuestion
|
currentUtteranceMode = .aiQuestion
|
||||||
pendingManagedRequestPurpose = command.managedRequestPurpose
|
pendingManagedRequestPurpose = command.managedRequestPurpose
|
||||||
|
pendingManagedOOBEFeature = command.managedOOBEFeature
|
||||||
pendingAIConversationID = command.aiConversationID
|
pendingAIConversationID = command.aiConversationID
|
||||||
pendingEditSourceText = nil
|
pendingEditSourceText = nil
|
||||||
pendingSourceHistoryEntryID = nil
|
pendingSourceHistoryEntryID = nil
|
||||||
@@ -1636,7 +1648,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
request: PrefilledAIRequest(
|
request: PrefilledAIRequest(
|
||||||
question: question,
|
question: question,
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
|
webPageURL: command.aiWebPageURL,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestSource: command.managedRequestSource,
|
||||||
|
requestPurpose: command.managedRequestPurpose,
|
||||||
|
oobeFeature: command.managedOOBEFeature,
|
||||||
thinkingEnabled: thinkingEnabled
|
thinkingEnabled: thinkingEnabled
|
||||||
),
|
),
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
@@ -1689,17 +1705,30 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
// send an XML envelope that must never appear above the mic.
|
// send an XML envelope that must never appear above the mic.
|
||||||
let pipelineStore = AppGroupStore()
|
let pipelineStore = AppGroupStore()
|
||||||
do {
|
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(
|
let service = try AIQuestionService.configured(
|
||||||
store: pipelineStore,
|
store: pipelineStore,
|
||||||
conversations: aiConversations,
|
conversations: aiConversations,
|
||||||
taskKind: request.taskKind,
|
taskKind: taskKind,
|
||||||
|
requestSource: request.requestSource,
|
||||||
|
requestPurpose: request.requestPurpose,
|
||||||
|
oobeFeature: request.oobeFeature,
|
||||||
thinkingEnabled: request.thinkingEnabled,
|
thinkingEnabled: request.thinkingEnabled,
|
||||||
analyticsClient: analyticsClient,
|
analyticsClient: analyticsClient,
|
||||||
analyticsFeature: Self.analyticsFeature(for: request.taskKind)
|
analyticsFeature: Self.analyticsFeature(for: request.taskKind)
|
||||||
)
|
)
|
||||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||||
let answer = try await service.answer(
|
let answer = try await service.answer(
|
||||||
question: request.question,
|
question: resolvedQuestion,
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
targetLocaleID: pipelineStore.translationTargetLocaleId
|
targetLocaleID: pipelineStore.translationTargetLocaleId
|
||||||
) { [weak self] partial in
|
) { [weak self] partial in
|
||||||
@@ -1725,7 +1754,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
force: true
|
force: true
|
||||||
)
|
)
|
||||||
await service.commitSuccessfulTurn(
|
await service.commitSuccessfulTurn(
|
||||||
question: request.question,
|
question: resolvedQuestion,
|
||||||
answer: answer,
|
answer: answer,
|
||||||
conversationID: conversationID
|
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) {
|
private func beginAudioPrime(_ command: FlowCommand) {
|
||||||
guard startingUtteranceId == nil,
|
guard startingUtteranceId == nil,
|
||||||
!isUtteranceRecording,
|
!isUtteranceRecording,
|
||||||
@@ -1870,7 +1910,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
||||||
status: FlowResult.Status = .error
|
status: FlowResult.Status = .error
|
||||||
) {
|
) {
|
||||||
finishTranscriptionAnalytics(
|
finishUtteranceAnalytics(
|
||||||
|
utteranceID: currentUtteranceId,
|
||||||
success: false,
|
success: false,
|
||||||
failureCategory: Self.transcriptionFailureCategory(
|
failureCategory: Self.transcriptionFailureCategory(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
@@ -2168,11 +2209,19 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let cloudASRForStreaming = useStreaming ? cloudService : nil
|
let cloudASRForStreaming = useStreaming ? cloudService : nil
|
||||||
transcriptionAnalyticsOperation?.cancel()
|
if let taskUtteranceId {
|
||||||
transcriptionAnalyticsOperation = analyticsClient.startAIFeature(
|
// A healed delivery gate may allow a replacement utterance before
|
||||||
.transcription,
|
// an old finalize task unwinds. Close any old value task first.
|
||||||
executionMode: analyticsExecutionMode
|
analyticsOperations.cancelAll()
|
||||||
|
analyticsOperations.start(
|
||||||
|
utteranceID: taskUtteranceId,
|
||||||
|
feature: FlowAnalyticsFeatureMapping.recordingFeature(
|
||||||
|
for: currentUtteranceMode
|
||||||
|
),
|
||||||
|
executionMode: analyticsExecutionMode,
|
||||||
|
client: analyticsClient
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
||||||
let outcome: ChunkedUtterancePipelineOutcome
|
let outcome: ChunkedUtterancePipelineOutcome
|
||||||
@@ -2262,7 +2311,21 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
manager.failUtterance(message: message, kind: .asrFailed)
|
manager.failUtterance(message: message, kind: .asrFailed)
|
||||||
}
|
}
|
||||||
case .cancelled:
|
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
|
status: .aborted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
analyticsOperations.cancel(utteranceID: utteranceId)
|
||||||
isUtteranceRecording = false
|
isUtteranceRecording = false
|
||||||
isUtteranceProcessing = false
|
isUtteranceProcessing = false
|
||||||
startingUtteranceId = nil
|
startingUtteranceId = nil
|
||||||
@@ -2493,6 +2557,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
pendingSourceHistoryEntryRevision = nil
|
pendingSourceHistoryEntryRevision = nil
|
||||||
pendingAIConversationID = nil
|
pendingAIConversationID = nil
|
||||||
pendingManagedRequestPurpose = nil
|
pendingManagedRequestPurpose = nil
|
||||||
|
pendingManagedOOBEFeature = nil
|
||||||
pendingProcessingDeadlineAt = nil
|
pendingProcessingDeadlineAt = nil
|
||||||
currentUtteranceMode = .dictation
|
currentUtteranceMode = .dictation
|
||||||
}
|
}
|
||||||
@@ -2511,6 +2576,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
|
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
|
||||||
let aiConversationID = pendingAIConversationID
|
let aiConversationID = pendingAIConversationID
|
||||||
let managedRequestPurpose = pendingManagedRequestPurpose
|
let managedRequestPurpose = pendingManagedRequestPurpose
|
||||||
|
let managedOOBEFeature = pendingManagedOOBEFeature
|
||||||
let processingDeadlineAt = pendingProcessingDeadlineAt
|
let processingDeadlineAt = pendingProcessingDeadlineAt
|
||||||
let asrEngineMode = sessionASREngineMode ?? store.engineMode
|
let asrEngineMode = sessionASREngineMode ?? store.engineMode
|
||||||
// ALWAYS clear the processing gate for this utterance. The previous
|
// 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
|
// and then skip refreshHostReady — keyboard stayed white forever
|
||||||
// while host logs still said "utterance finalized".
|
// while host logs still said "utterance finalized".
|
||||||
defer {
|
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
|
pendingFieldContext = nil
|
||||||
pendingEditSourceText = nil
|
pendingEditSourceText = nil
|
||||||
pendingSourceHistoryEntryID = nil
|
pendingSourceHistoryEntryID = nil
|
||||||
pendingSourceHistoryEntryRevision = nil
|
pendingSourceHistoryEntryRevision = nil
|
||||||
pendingAIConversationID = nil
|
pendingAIConversationID = nil
|
||||||
pendingManagedRequestPurpose = nil
|
pendingManagedRequestPurpose = nil
|
||||||
|
pendingManagedOOBEFeature = nil
|
||||||
pendingProcessingDeadlineAt = nil
|
pendingProcessingDeadlineAt = nil
|
||||||
if currentUtteranceMode == utteranceMode {
|
if currentUtteranceMode == utteranceMode {
|
||||||
currentUtteranceMode = .dictation
|
currentUtteranceMode = .dictation
|
||||||
@@ -2641,7 +2712,12 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
finishTranscriptionAnalytics(success: true)
|
if utteranceMode != .aiQuestion {
|
||||||
|
finishUtteranceAnalytics(
|
||||||
|
utteranceID: finalizeUtteranceId,
|
||||||
|
success: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-read App Group at finalize so dictionary and translation changes
|
// Re-read App Group at finalize so dictionary and translation changes
|
||||||
// from the keyboard extension are visible before local correction,
|
// from the keyboard extension are visible before local correction,
|
||||||
@@ -2700,10 +2776,20 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
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(
|
let service = try AIQuestionService.configured(
|
||||||
store: pipelineStore,
|
store: pipelineStore,
|
||||||
conversations: aiConversations,
|
conversations: aiConversations,
|
||||||
taskKind: .aiQuestion,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: managedRequestPurpose,
|
||||||
|
oobeFeature: managedOOBEFeature,
|
||||||
analyticsClient: analyticsClient,
|
analyticsClient: analyticsClient,
|
||||||
analyticsFeature: .aiAssistant
|
analyticsFeature: .aiAssistant
|
||||||
)
|
)
|
||||||
@@ -2715,7 +2801,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let answer = try await service.answer(
|
let answer = try await service.answer(
|
||||||
question: question,
|
question: question,
|
||||||
conversationID: aiConversationID,
|
conversationID: aiConversationID,
|
||||||
targetLocaleID: pipelineStore.translationTargetLocaleId
|
targetLocaleID: pipelineStore.translationTargetLocaleId,
|
||||||
|
analyticsOperation: analyticsOperations.operation(
|
||||||
|
for: finalizeUtteranceId
|
||||||
|
)
|
||||||
) { [weak self] partial in
|
) { [weak self] partial in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.publishStreamingAIAnswerIfNeeded(
|
self?.publishStreamingAIAnswerIfNeeded(
|
||||||
@@ -2843,6 +2932,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
||||||
taskKind: isEditLastInput ? .editLastInput : nil,
|
taskKind: isEditLastInput ? .editLastInput : nil,
|
||||||
requestPurpose: managedRequestPurpose,
|
requestPurpose: managedRequestPurpose,
|
||||||
|
oobeFeature: managedOOBEFeature,
|
||||||
context: isInstructionMode ? nil : polishContext,
|
context: isInstructionMode ? nil : polishContext,
|
||||||
timeoutLimit: processingDeadlineAt.map {
|
timeoutLimit: processingDeadlineAt.map {
|
||||||
max(0.1, $0 - Date().timeIntervalSince1970)
|
max(0.1, $0 - Date().timeIntervalSince1970)
|
||||||
@@ -2872,6 +2962,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
? nil
|
? nil
|
||||||
: SpeechHistoryStore.shared.recordUtterance(
|
: SpeechHistoryStore.shared.recordUtterance(
|
||||||
text: delivered,
|
text: delivered,
|
||||||
|
prePolishText: text,
|
||||||
|
polishStyleID: outcome.polishStyleID
|
||||||
|
?? pipelineStore.activePolishStyleId,
|
||||||
|
polishStylePrompt: outcome.polishStylePrompt,
|
||||||
engineMode: engineMode,
|
engineMode: engineMode,
|
||||||
duration: recordingDuration,
|
duration: recordingDuration,
|
||||||
wasTranslation: isInstructionMode
|
wasTranslation: isInstructionMode
|
||||||
@@ -2951,6 +3045,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
|
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
|
||||||
let historyEntry = SpeechHistoryStore.shared.recordUtterance(
|
let historyEntry = SpeechHistoryStore.shared.recordUtterance(
|
||||||
text: delivered,
|
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,
|
engineMode: engineMode,
|
||||||
duration: recordingDuration,
|
duration: recordingDuration,
|
||||||
wasTranslation: pipelineStore.isTranslationEffective
|
wasTranslation: pipelineStore.isTranslationEffective
|
||||||
@@ -3147,7 +3246,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
status: FlowResult.Status = .error,
|
status: FlowResult.Status = .error,
|
||||||
aiConversationID: UUID? = nil
|
aiConversationID: UUID? = nil
|
||||||
) {
|
) {
|
||||||
finishTranscriptionAnalytics(
|
finishUtteranceAnalytics(
|
||||||
|
utteranceID: utteranceId,
|
||||||
success: false,
|
success: false,
|
||||||
failureCategory: Self.transcriptionFailureCategory(
|
failureCategory: Self.transcriptionFailureCategory(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
@@ -3181,14 +3281,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private static func analyticsFeature(
|
private static func analyticsFeature(
|
||||||
for taskKind: ManagedGatewayTaskKind
|
for taskKind: ManagedGatewayTaskKind
|
||||||
) -> AnalyticsFeature {
|
) -> AnalyticsFeature {
|
||||||
switch taskKind {
|
FlowAnalyticsFeatureMapping.feature(for: taskKind)
|
||||||
case .aiQuestion:
|
|
||||||
return .hotword
|
|
||||||
case .clipboardTransform, .customSkill, .agentPlanning:
|
|
||||||
return .agent
|
|
||||||
case .dictationPolish, .translation, .editLastInput:
|
|
||||||
return .other
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var analyticsExecutionMode: AnalyticsExecutionMode {
|
private var analyticsExecutionMode: AnalyticsExecutionMode {
|
||||||
@@ -3198,16 +3291,18 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
return store.credentialSource == .managed ? .managed : .byok
|
return store.credentialSource == .managed ? .managed : .byok
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishTranscriptionAnalytics(
|
private func finishUtteranceAnalytics(
|
||||||
|
utteranceID: UUID?,
|
||||||
success: Bool,
|
success: Bool,
|
||||||
failureCategory: AnalyticsFailureCategory = .unknown
|
failureCategory: AnalyticsFailureCategory = .unknown
|
||||||
) {
|
) {
|
||||||
guard let operation = transcriptionAnalyticsOperation else { return }
|
|
||||||
transcriptionAnalyticsOperation = nil
|
|
||||||
if success {
|
if success {
|
||||||
operation.succeed()
|
analyticsOperations.succeed(utteranceID: utteranceID)
|
||||||
} else {
|
} else {
|
||||||
operation.fail(category: failureCategory)
|
analyticsOperations.fail(
|
||||||
|
utteranceID: utteranceID,
|
||||||
|
category: failureCategory
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3370,6 +3465,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
providerIdOverride: String?,
|
providerIdOverride: String?,
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
context: PolishContext?,
|
context: PolishContext?,
|
||||||
timeoutLimit: TimeInterval? = nil
|
timeoutLimit: TimeInterval? = nil
|
||||||
) async throws -> PolishingService.PolishOutcome {
|
) async throws -> PolishingService.PolishOutcome {
|
||||||
@@ -3383,6 +3479,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
providerIdOverride: providerIdOverride,
|
providerIdOverride: providerIdOverride,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
requestPurpose: requestPurpose,
|
requestPurpose: requestPurpose,
|
||||||
|
oobeFeature: oobeFeature,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3448,6 +3545,17 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func aiQuestionFailureMessage(for error: Error) -> String {
|
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 {
|
if let managedError = error as? ManagedGatewayError {
|
||||||
return managedError.localizedDescription
|
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
|
@discardableResult
|
||||||
func recordUtterance(
|
func recordUtterance(
|
||||||
text: String,
|
text: String,
|
||||||
|
prePolishText: String,
|
||||||
|
polishStyleID: String,
|
||||||
|
polishStylePrompt: String?,
|
||||||
engineMode: String,
|
engineMode: String,
|
||||||
duration: TimeInterval,
|
duration: TimeInterval,
|
||||||
wasTranslation: Bool
|
wasTranslation: Bool
|
||||||
) -> SpeechHistoryEntry? {
|
) -> 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(
|
UsageStatisticsStore.shared.recordUtterance(
|
||||||
text: text,
|
text: text,
|
||||||
duration: duration,
|
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 = [
|
||||||
|
" ": " ",
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
""": "\"",
|
||||||
|
"'": "'",
|
||||||
|
"'": "'",
|
||||||
|
"–": "–",
|
||||||
|
"—": "—",
|
||||||
|
"…": "…",
|
||||||
|
"‘": "‘",
|
||||||
|
"’": "’",
|
||||||
|
"“": "“",
|
||||||
|
"”": "”"
|
||||||
|
]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
// AIAgentSkillsView.swift
|
// AIAgentSkillsView.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
|
// Catalog of installed and available AI Agent clipboard skills. Selecting a
|
||||||
// the keyboard after a copy; long-press drag rearranges that row live,
|
// row opens its detail page; installation state is managed there. Export
|
||||||
// like Home Screen icons. Export skills confirm a companion Shortcut
|
// skills confirm a companion Shortcut before installation completes.
|
||||||
// before they can occupy a slot. Users can add custom export skills.
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
|
|
||||||
struct AIAgentSkillsView: View {
|
struct AIAgentSkillsView: View {
|
||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
@@ -16,21 +15,11 @@ struct AIAgentSkillsView: View {
|
|||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
||||||
|
|
||||||
@State private var viewingSkill: AIClipboardSkill?
|
|
||||||
@State private var editingDraft: SkillEditorDraft?
|
@State private var editingDraft: SkillEditorDraft?
|
||||||
@State private var showFullAlert = false
|
|
||||||
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
@State private var pasteAccessNeedsRecovery = false
|
@State private var pasteAccessNeedsRecovery = false
|
||||||
@State private var showPasteNoTextAlert = false
|
@State private var showPasteNoTextAlert = false
|
||||||
@State private var showPasteAccessSuccess = 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 {
|
private var skillCardShape: RoundedRectangle {
|
||||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||||
}
|
}
|
||||||
@@ -43,14 +32,13 @@ struct AIAgentSkillsView: View {
|
|||||||
clipboardAccessGuide
|
clipboardAccessGuide
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
enabledSection
|
installedSection
|
||||||
if !store.availableSkills.isEmpty {
|
if !store.availableSkills.isEmpty {
|
||||||
availableSection
|
uninstalledSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabBarScrollBottomPadding()
|
.tabBarScrollBottomPadding()
|
||||||
}
|
}
|
||||||
.scrollDisabled(isReordering)
|
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
.navigationTitle("skills.title")
|
.navigationTitle("skills.title")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.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
|
.sheet(item: $editingDraft) { draft in
|
||||||
SkillEditorSheet(
|
SkillEditorSheet(
|
||||||
draft: draft,
|
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(
|
.alert(
|
||||||
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||||
isPresented: $showPasteNoTextAlert
|
isPresented: $showPasteNoTextAlert
|
||||||
@@ -102,10 +73,19 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
store.reload()
|
store.reload()
|
||||||
|
refreshOfficialSkillCatalog(
|
||||||
|
reason: "AIAgentSkillsView.onAppear",
|
||||||
|
force: true
|
||||||
|
)
|
||||||
refreshPasteAccessState()
|
refreshPasteAccessState()
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
guard phase == .active else { return }
|
guard phase == .active else { return }
|
||||||
|
store.reload()
|
||||||
|
refreshOfficialSkillCatalog(
|
||||||
|
reason: "AIAgentSkillsView.active",
|
||||||
|
force: true
|
||||||
|
)
|
||||||
refreshPasteAccessState()
|
refreshPasteAccessState()
|
||||||
pasteAccessNeedsRecovery = false
|
pasteAccessNeedsRecovery = false
|
||||||
}
|
}
|
||||||
@@ -291,69 +271,73 @@ struct AIAgentSkillsView: View {
|
|||||||
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
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(
|
CardSection(
|
||||||
title: AppL10n.format(
|
title: AppL10n.format(
|
||||||
"skills.enabled.section",
|
"skills.installed.section",
|
||||||
language: config.uiLanguage,
|
language: config.uiLanguage,
|
||||||
store.enabledSkills.count,
|
store.enabledSkills.count
|
||||||
AIAgentSkillLayout.maximumEnabled
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if store.enabledSkills.isEmpty {
|
if store.enabledSkills.isEmpty {
|
||||||
Text("skills.enabled.empty")
|
Text("skills.installed.empty")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
} else {
|
} else {
|
||||||
EnabledSkillsReorderGrid(
|
LazyVStack(spacing: 0) {
|
||||||
skills: store.enabledSkills,
|
ForEach(store.enabledSkills) { skill in
|
||||||
isReordering: $isReordering,
|
skillListItem(skill)
|
||||||
onTap: handleCardTap,
|
|
||||||
onEdit: handleEdit,
|
|
||||||
onMoveToIndex: { id, index in
|
|
||||||
store.moveEnabled(id: id, toIndex: index)
|
|
||||||
},
|
|
||||||
card: { skill in
|
|
||||||
skillCardVisual(skill, isEnabled: true)
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
.background(palette.surface, in: skillCardShape)
|
||||||
|
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||||
|
.clipShape(skillCardShape)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var availableSection: some View {
|
private var uninstalledSection: some View {
|
||||||
CardSection("skills.available.section") {
|
CardSection("skills.uninstalled.section") {
|
||||||
LazyVGrid(columns: columns, spacing: Spacing.sm) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(store.availableSkills) { skill in
|
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 {
|
private func skillListItem(_ skill: AIClipboardSkill) -> some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
NavigationLink {
|
||||||
Button {
|
SkillDetailView(
|
||||||
handleCardTap(skill)
|
store: store,
|
||||||
} label: {
|
skill: skill,
|
||||||
skillCardVisual(skill, isEnabled: isEnabled)
|
onInstall: addOrWarn,
|
||||||
|
onConfirmInstall: confirmShortcutInstall,
|
||||||
|
onEdit: {
|
||||||
|
guard let user = store.userSkill(id: skill.id) else { return }
|
||||||
|
editingDraft = .from(user)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
handleEdit(skill)
|
|
||||||
} label: {
|
|
||||||
Color.clear
|
|
||||||
.frame(
|
|
||||||
width: CatalogCardChrome.editHitSize,
|
|
||||||
height: CatalogCardChrome.editHitSize
|
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
} label: {
|
||||||
|
skillListRow(skill)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text("skills.edit"))
|
|
||||||
}
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if skill.isUserCreated {
|
if skill.isUserCreated {
|
||||||
Button("common.delete", role: .destructive) {
|
Button("common.delete", role: .destructive) {
|
||||||
@@ -363,45 +347,45 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func skillCardVisual(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
|
private func skillListRow(_ skill: AIClipboardSkill) -> some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
HStack(spacing: Spacing.md) {
|
||||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
|
||||||
Image(systemName: skill.systemImage)
|
Image(systemName: skill.systemImage)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.foregroundStyle(palette.accent)
|
.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))
|
Text(skillTitle(skill))
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.trailing, 32)
|
|
||||||
Text(skillDescription(skill))
|
Text(skillDescription(skill))
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(Spacing.md)
|
|
||||||
|
|
||||||
CatalogCardChrome.editIcon(palette: palette)
|
Image(systemName: "chevron.right")
|
||||||
.padding(Spacing.sm)
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.allowsHitTesting(false)
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
.frame(width: 18)
|
||||||
if isEnabled {
|
|
||||||
CatalogCardChrome.checkIcon(palette: palette)
|
|
||||||
}
|
}
|
||||||
|
.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)
|
||||||
}
|
}
|
||||||
.background(
|
.contentShape(Rectangle())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func skillTitle(_ skill: AIClipboardSkill) -> String {
|
private func skillTitle(_ skill: AIClipboardSkill) -> String {
|
||||||
@@ -413,40 +397,18 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func skillDescription(_ skill: AIClipboardSkill) -> String {
|
private func skillDescription(_ skill: AIClipboardSkill) -> String {
|
||||||
if skill.isUserCreated {
|
|
||||||
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if !summary.isEmpty { return summary }
|
if !summary.isEmpty { return summary }
|
||||||
|
if skill.isUserCreated {
|
||||||
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
|
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
|
||||||
}
|
}
|
||||||
return AppL10n.string(skill.descriptionKey, 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) {
|
private func addOrWarn(_ skill: AIClipboardSkill) {
|
||||||
switch store.enable(skill.id) {
|
switch store.enable(skill.id) {
|
||||||
case .enabled, .alreadyEnabled:
|
case .enabled, .alreadyEnabled:
|
||||||
viewingSkill = nil
|
break
|
||||||
case .full:
|
|
||||||
showFullAlert = true
|
|
||||||
case .needsShortcut, .unknown:
|
case .needsShortcut, .unknown:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -455,9 +417,7 @@ struct AIAgentSkillsView: View {
|
|||||||
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
|
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
|
||||||
switch store.confirmShortcutAndEnable(skill.id) {
|
switch store.confirmShortcutAndEnable(skill.id) {
|
||||||
case .enabled, .alreadyEnabled:
|
case .enabled, .alreadyEnabled:
|
||||||
viewingSkill = nil
|
break
|
||||||
case .full:
|
|
||||||
showFullAlert = true
|
|
||||||
case .needsShortcut, .unknown:
|
case .needsShortcut, .unknown:
|
||||||
break
|
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 {
|
private struct SkillEditorDraft: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let isNew: Bool
|
let isNew: Bool
|
||||||
@@ -711,22 +491,18 @@ private struct SkillEditorDraft: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SkillDetailSheet: View {
|
private struct SkillDetailView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
@ObservedObject var store: AIAgentSkillLayoutStore
|
@ObservedObject var store: AIAgentSkillLayoutStore
|
||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
|
|
||||||
let skill: AIClipboardSkill
|
let skill: AIClipboardSkill
|
||||||
let onDismiss: () -> Void
|
let onInstall: (AIClipboardSkill) -> Void
|
||||||
let onAddOrWarn: (AIClipboardSkill) -> Void
|
|
||||||
let onConfirmInstall: (AIClipboardSkill) -> Void
|
let onConfirmInstall: (AIClipboardSkill) -> Void
|
||||||
|
let onEdit: () -> Void
|
||||||
/// Inner stack height; nav chrome + home indicator are added for the detent.
|
|
||||||
@State private var contentHeight: CGFloat = 280
|
|
||||||
private let navigationChrome: CGFloat = 72
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
CardPageContent(
|
CardPageContent(
|
||||||
spacing: Spacing.md,
|
spacing: Spacing.md,
|
||||||
@@ -742,50 +518,28 @@ private struct SkillDetailSheet: View {
|
|||||||
thinkingRow
|
thinkingRow
|
||||||
skillActions
|
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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
.navigationTitle("skills.detail.title")
|
.navigationTitle("skills.detail.title")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
if skill.isUserCreated {
|
||||||
Button("common.done", action: onDismiss)
|
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 {
|
private var skillDescription: String {
|
||||||
if skill.isUserCreated {
|
|
||||||
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if !summary.isEmpty { return summary }
|
if !summary.isEmpty { return summary }
|
||||||
|
if skill.isUserCreated {
|
||||||
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
|
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
|
||||||
}
|
}
|
||||||
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
|
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
|
||||||
return AppL10n.string("skills.install.lead", language: config.uiLanguage)
|
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)
|
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,37 +603,40 @@ private struct SkillDetailSheet: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var skillActions: some View {
|
private var skillActions: some View {
|
||||||
let enabled = store.layout.isEnabled(skill.id)
|
|
||||||
if skill.requiresShortcut {
|
if skill.requiresShortcut {
|
||||||
if !store.layout.hasConfirmedShortcut(skill.id) {
|
if !store.layout.hasConfirmedShortcut(skill.id) {
|
||||||
shortcutInstallBlock
|
shortcutInstallBlock
|
||||||
} else {
|
} else {
|
||||||
if enabled {
|
if isInstalled {
|
||||||
fullWidthButton("skills.action.turnOff", prominent: false) {
|
fullWidthButton("skills.action.uninstall", prominent: false) {
|
||||||
store.disable(skill.id)
|
store.disable(skill.id)
|
||||||
onDismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
|
fullWidthButton("skills.action.install", prominent: true) {
|
||||||
onAddOrWarn(skill)
|
onInstall(skill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
|
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
|
||||||
openShortcutInstall()
|
openShortcutInstall()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if enabled {
|
} else if isInstalled {
|
||||||
fullWidthButton("skills.action.turnOff", prominent: false) {
|
fullWidthButton("skills.action.uninstall", prominent: false) {
|
||||||
store.disable(skill.id)
|
store.disable(skill.id)
|
||||||
onDismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
|
fullWidthButton("skills.action.install", prominent: true) {
|
||||||
onAddOrWarn(skill)
|
onInstall(skill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isInstalled: Bool {
|
||||||
|
store.layout.isEnabled(skill.id)
|
||||||
|
}
|
||||||
|
|
||||||
private var shortcutInstallBlock: some View {
|
private var shortcutInstallBlock: some View {
|
||||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
fullWidthButton("skills.install.openShortcuts", prominent: true) {
|
fullWidthButton("skills.install.openShortcuts", prominent: true) {
|
||||||
@@ -888,6 +645,12 @@ private struct SkillDetailSheet: View {
|
|||||||
fullWidthButton("skills.install.confirmAdded", prominent: false) {
|
fullWidthButton("skills.install.confirmAdded", prominent: false) {
|
||||||
onConfirmInstall(skill)
|
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
|
// Interactive layout preview and What's New recording host for AI Agent
|
||||||
// clipboard skills on the real unified `AIKeyboardView`. Launch with
|
// 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`.
|
// `--whats-new-lang=zh|en`.
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -61,7 +61,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
|||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(minWidth: 72)
|
.frame(minWidth: 72)
|
||||||
Button {
|
Button {
|
||||||
count = min(8, count + 1)
|
count = min(AIClipboardSkillCatalog.catalog.count, count + 1)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
@@ -69,7 +69,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
|||||||
}
|
}
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach([3, 5, 8], id: \.self) { n in
|
ForEach([3, 8, AIClipboardSkillCatalog.catalog.count], id: \.self) { n in
|
||||||
Button("\(n)") {
|
Button("\(n)") {
|
||||||
count = n
|
count = n
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showSkills(_ count: Int) {
|
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)
|
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
|
||||||
state.enabledClipboardSkillIDs = Array(
|
state.enabledClipboardSkillIDs = Array(
|
||||||
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
|
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
|
||||||
@@ -135,7 +135,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
|||||||
let prefix = "--skills-count="
|
let prefix = "--skills-count="
|
||||||
if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
|
if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
|
||||||
let value = Int(arg.dropFirst(prefix.count)),
|
let value = Int(arg.dropFirst(prefix.count)),
|
||||||
(1...8).contains(value) {
|
(1...AIClipboardSkillCatalog.catalog.count).contains(value) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return 5
|
return 5
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct AccountAppleAuthorizationButton: View {
|
|||||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||||
|
|
||||||
let purpose: AccountAppleAuthorizationPurpose
|
let purpose: AccountAppleAuthorizationPurpose
|
||||||
|
var onSignedIn: () -> Void = {}
|
||||||
var onDeleted: () -> Void = {}
|
var onDeleted: () -> Void = {}
|
||||||
|
|
||||||
@State private var rawNonce: String?
|
@State private var rawNonce: String?
|
||||||
@@ -30,7 +31,8 @@ struct AccountAppleAuthorizationButton: View {
|
|||||||
onCompletion: completeAuthorization
|
onCompletion: completeAuthorization
|
||||||
)
|
)
|
||||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||||
.frame(maxWidth: .infinity, minHeight: 50)
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||||
.accessibilityLabel(
|
.accessibilityLabel(
|
||||||
purpose == .signIn
|
purpose == .signIn
|
||||||
@@ -85,6 +87,7 @@ struct AccountAppleAuthorizationButton: View {
|
|||||||
await coordinator.signIn(with: payload)
|
await coordinator.signIn(with: payload)
|
||||||
if coordinator.isSignedIn {
|
if coordinator.isSignedIn {
|
||||||
ProviderConfig.shared.reloadFromPersistedStorage()
|
ProviderConfig.shared.reloadFromPersistedStorage()
|
||||||
|
onSignedIn()
|
||||||
}
|
}
|
||||||
case .deleteAccount:
|
case .deleteAccount:
|
||||||
await coordinator.deleteAccount(with: payload)
|
await coordinator.deleteAccount(with: payload)
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ struct AccountCenterUITestHarness: View {
|
|||||||
@StateObject private var coordinator: AccountSessionCoordinator
|
@StateObject private var coordinator: AccountSessionCoordinator
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let service = AccountCenterUITestService()
|
let service = AccountCenterUITestService(
|
||||||
|
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
|
||||||
|
"--account-snapshot-failure"
|
||||||
|
)
|
||||||
|
)
|
||||||
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
||||||
_coordinator = StateObject(
|
_coordinator = StateObject(
|
||||||
wrappedValue: AccountSessionCoordinator(
|
wrappedValue: AccountSessionCoordinator(
|
||||||
@@ -91,6 +95,11 @@ private actor AccountCenterUITestService:
|
|||||||
createdAtEpochSeconds: 1_700_000_000,
|
createdAtEpochSeconds: 1_700_000_000,
|
||||||
displayName: "UI Test Account"
|
displayName: "UI Test Account"
|
||||||
)
|
)
|
||||||
|
private let shouldFailSnapshot: Bool
|
||||||
|
|
||||||
|
init(shouldFailSnapshot: Bool = false) {
|
||||||
|
self.shouldFailSnapshot = shouldFailSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
func restoreSession() async throws -> AccountSession? {
|
func restoreSession() async throws -> AccountSession? {
|
||||||
account
|
account
|
||||||
@@ -108,7 +117,10 @@ private actor AccountCenterUITestService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||||
AccountCenterSnapshot(
|
if shouldFailSnapshot {
|
||||||
|
throw AccountCenterUITestError.snapshotUnavailable
|
||||||
|
}
|
||||||
|
return AccountCenterSnapshot(
|
||||||
account: account,
|
account: account,
|
||||||
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
|
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
|
||||||
referrals: []
|
referrals: []
|
||||||
@@ -180,6 +192,10 @@ private actor AccountCenterUITestService:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum AccountCenterUITestError: Error {
|
||||||
|
case snapshotUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class AccountCenterUITestCreditStore: AccountCreditStore {
|
private final class AccountCenterUITestCreditStore: AccountCreditStore {
|
||||||
private let accountID: UUID
|
private let accountID: UUID
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ struct AccountCenterView: View {
|
|||||||
coordinator.creditPurchases.reset()
|
coordinator.creditPurchases.reset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await coordinator.refreshAccountData()
|
async let accountRefresh: Void = coordinator.refreshAccountData()
|
||||||
await coordinator.creditPurchases.prepare(accountID: accountID)
|
async let purchasePreparation: Void =
|
||||||
|
coordinator.creditPurchases.prepare(accountID: accountID)
|
||||||
|
_ = await (accountRefresh, purchasePreparation)
|
||||||
if case let .signedIn(session) = coordinator.sessionPhase {
|
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||||
displayNameDraft = session.displayName ?? ""
|
displayNameDraft = session.displayName ?? ""
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,10 @@ struct AccountCenterView: View {
|
|||||||
.padding(.bottom, Spacing.lg)
|
.padding(.bottom, Spacing.lg)
|
||||||
}
|
}
|
||||||
.refreshable {
|
.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")
|
.accessibilityIdentifier("account.center.signedIn")
|
||||||
}
|
}
|
||||||
@@ -171,6 +176,9 @@ struct AccountCenterView: View {
|
|||||||
Task { await coordinator.refreshAccountData(force: true) }
|
Task { await coordinator.refreshAccountData(force: true) }
|
||||||
}
|
}
|
||||||
case .loaded:
|
case .loaded:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
CardSection("account.storekit.section") {
|
CardSection("account.storekit.section") {
|
||||||
VStack(spacing: Spacing.sm) {
|
VStack(spacing: Spacing.sm) {
|
||||||
AccountCreditPurchaseSection(
|
AccountCreditPurchaseSection(
|
||||||
@@ -184,7 +192,6 @@ struct AccountCenterView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func purchaseHistoryLink(accountID: UUID) -> some View {
|
private func purchaseHistoryLink(accountID: UUID) -> some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -522,7 +529,7 @@ private struct AccountCreditPurchaseSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
} else if manager.state == .loading {
|
} else if manager.catalogPhase == .loading {
|
||||||
HStack(spacing: Spacing.sm) {
|
HStack(spacing: Spacing.sm) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -535,6 +542,7 @@ private struct AccountCreditPurchaseSection: View {
|
|||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catalogStateMessage
|
||||||
stateMessage
|
stateMessage
|
||||||
}
|
}
|
||||||
.onChange(of: manager.lastGrantedBalance) { previous, current in
|
.onChange(of: manager.lastGrantedBalance) { previous, current in
|
||||||
@@ -613,6 +621,37 @@ private struct AccountCreditPurchaseSection: View {
|
|||||||
return productID
|
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
|
@ViewBuilder
|
||||||
private var stateMessage: some View {
|
private var stateMessage: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -646,11 +685,6 @@ private struct AccountCreditPurchaseSection: View {
|
|||||||
.font(TypeStyle.footnote)
|
.font(TypeStyle.footnote)
|
||||||
.foregroundStyle(palette.danger)
|
.foregroundStyle(palette.danger)
|
||||||
Spacer(minLength: Spacing.xs)
|
Spacer(minLength: Spacing.xs)
|
||||||
Button("account.retry") {
|
|
||||||
Task { await manager.prepare(accountID: accountID) }
|
|
||||||
}
|
|
||||||
.font(TypeStyle.bodyEmph)
|
|
||||||
.foregroundStyle(palette.accent)
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
|
|||||||
@@ -21,13 +21,19 @@ struct AccountCreditPurchaseOption: Identifiable, Equatable {
|
|||||||
final class AccountCreditPurchaseManager: ObservableObject {
|
final class AccountCreditPurchaseManager: ObservableObject {
|
||||||
enum State: Equatable {
|
enum State: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case loading
|
|
||||||
case purchasing(productID: String)
|
case purchasing(productID: String)
|
||||||
case pending
|
case pending
|
||||||
case succeeded(credits: Int64)
|
case succeeded(credits: Int64)
|
||||||
case failed(messageKey: String)
|
case failed(messageKey: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CatalogPhase: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case loaded
|
||||||
|
case failed(messageKey: String)
|
||||||
|
}
|
||||||
|
|
||||||
enum HistoryPhase: Equatable {
|
enum HistoryPhase: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case loading
|
case loading
|
||||||
@@ -36,7 +42,10 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Published private(set) var state: State = .idle
|
@Published private(set) var state: State = .idle
|
||||||
|
@Published private(set) var catalogPhase: CatalogPhase = .idle
|
||||||
@Published private(set) var options: [AccountCreditPurchaseOption] = []
|
@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 lastGrantedBalance: Int64?
|
||||||
@Published private(set) var historyPhase: HistoryPhase = .idle
|
@Published private(set) var historyPhase: HistoryPhase = .idle
|
||||||
@Published private(set) var isLoadingMoreHistory = false
|
@Published private(set) var isLoadingMoreHistory = false
|
||||||
@@ -45,80 +54,192 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
private let service: any AccountCenterServicing
|
private let service: any AccountCenterServicing
|
||||||
private let store: any AccountCreditStore
|
private let store: any AccountCreditStore
|
||||||
private let analyticsClient: any AnalyticsClient
|
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 updatesTask: Task<Void, Never>?
|
||||||
|
private var successDismissTask: Task<Void, Never>?
|
||||||
private var activeAccountID: UUID?
|
private var activeAccountID: UUID?
|
||||||
|
private var sessionID: UUID?
|
||||||
|
private var pendingProductID: String?
|
||||||
private var configuredProductIDs: Set<String> = []
|
private var configuredProductIDs: Set<String> = []
|
||||||
private var nextHistoryCursor: String?
|
private var nextHistoryCursor: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
service: any AccountCenterServicing,
|
service: any AccountCenterServicing,
|
||||||
store: any AccountCreditStore = LiveAccountCreditStore(),
|
store: any AccountCreditStore = LiveAccountCreditStore(),
|
||||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||||
|
successMessageDuration: Duration = .seconds(4)
|
||||||
) {
|
) {
|
||||||
self.service = service
|
self.service = service
|
||||||
self.store = store
|
self.store = store
|
||||||
self.analyticsClient = analyticsClient
|
self.analyticsClient = analyticsClient
|
||||||
|
self.successMessageDuration = successMessageDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
prepareTask?.cancel()
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
|
successDismissTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepare(accountID: UUID) async {
|
/// Starts one non-blocking StoreKit preparation for a signed-in account.
|
||||||
guard activeAccountID != accountID || options.isEmpty else { return }
|
/// Repeated calls for the same account preserve the current session state.
|
||||||
|
func startSession(accountID: UUID) {
|
||||||
|
guard activeAccountID != accountID else { return }
|
||||||
reset(for: accountID)
|
reset(for: accountID)
|
||||||
state = .loading
|
beginPrepare(accountID: accountID)
|
||||||
do {
|
|
||||||
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(
|
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()
|
||||||
|
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,
|
productID: creditProduct.productID,
|
||||||
credits: creditProduct.credits,
|
credits: creditProduct.credits,
|
||||||
displayPrice: product.displayPrice
|
displayPrice: product.displayPrice
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
guard !loadedOptions.isEmpty else {
|
guard !loadedOptions.isEmpty else {
|
||||||
throw AccountCreditPurchaseError.productUnavailable
|
throw AccountCreditPurchaseError.productUnavailable
|
||||||
}
|
}
|
||||||
|
configuredProductIDs = Set(catalog.map(\.productID))
|
||||||
options = loadedOptions.sorted { $0.credits < $1.credits }
|
options = loadedOptions.sorted { $0.credits < $1.credits }
|
||||||
state = .idle
|
catalogPhase = .loaded
|
||||||
listenForUpdates(accountID: accountID)
|
catalogRefreshErrorKey = nil
|
||||||
_ = await reconcileUnfinished(accountID: accountID)
|
listenForUpdates(accountID: accountID, sessionID: sessionID)
|
||||||
|
_ = await reconcileUnfinished(accountID: accountID, sessionID: sessionID)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
} catch {
|
} 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
|
@discardableResult
|
||||||
func purchase(productID: String, accountID: UUID) async -> Bool {
|
func purchase(productID: String, accountID: UUID) async -> Bool {
|
||||||
guard activeAccountID == accountID,
|
guard activeAccountID == accountID,
|
||||||
|
let sessionID,
|
||||||
options.contains(where: { $0.productID == productID }) else {
|
options.contains(where: { $0.productID == productID }) else {
|
||||||
state = .failed(messageKey: "account.storekit.error.productUnavailable")
|
state = .failed(messageKey: "account.storekit.error.productUnavailable")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
state = .purchasing(productID: productID)
|
state = .purchasing(productID: productID)
|
||||||
|
pendingProductID = nil
|
||||||
|
cancelSuccessDismissal()
|
||||||
analyticsClient.recordPurchaseStarted()
|
analyticsClient.recordPurchaseStarted()
|
||||||
do {
|
do {
|
||||||
switch try await store.purchase(productID: productID, accountID: accountID) {
|
switch try await store.purchase(productID: productID, accountID: accountID) {
|
||||||
case .success(let verification):
|
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
|
return true
|
||||||
case .pending:
|
case .pending:
|
||||||
|
pendingProductID = productID
|
||||||
state = .pending
|
state = .pending
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
|
pendingProductID = nil
|
||||||
state = .idle
|
state = .idle
|
||||||
analyticsClient.recordPurchaseCancelled()
|
analyticsClient.recordPurchaseCancelled()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
|
pendingProductID = nil
|
||||||
state = .failed(messageKey: "account.storekit.error.unknown")
|
state = .failed(messageKey: "account.storekit.error.unknown")
|
||||||
}
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
|
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pendingProductID = nil
|
||||||
state = .failed(messageKey: Self.messageKey(for: error))
|
state = .failed(messageKey: Self.messageKey(for: error))
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -131,6 +252,8 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
func clearTransientState() {
|
func clearTransientState() {
|
||||||
switch state {
|
switch state {
|
||||||
case .succeeded, .failed, .pending:
|
case .succeeded, .failed, .pending:
|
||||||
|
pendingProductID = nil
|
||||||
|
cancelSuccessDismissal()
|
||||||
state = .idle
|
state = .idle
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -139,6 +262,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
|
|
||||||
func dismissSuccessMessage() {
|
func dismissSuccessMessage() {
|
||||||
guard case .succeeded = state else { return }
|
guard case .succeeded = state else { return }
|
||||||
|
cancelSuccessDismissal()
|
||||||
state = .idle
|
state = .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,10 +323,20 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
|
prepareTask?.cancel()
|
||||||
|
prepareTask = nil
|
||||||
|
prepareRequestID = nil
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
updatesTask = nil
|
updatesTask = nil
|
||||||
|
successDismissTask?.cancel()
|
||||||
|
successDismissTask = nil
|
||||||
activeAccountID = nil
|
activeAccountID = nil
|
||||||
|
sessionID = nil
|
||||||
|
pendingProductID = nil
|
||||||
|
catalogPhase = .idle
|
||||||
options = []
|
options = []
|
||||||
|
isRefreshingCatalog = false
|
||||||
|
catalogRefreshErrorKey = nil
|
||||||
configuredProductIDs = []
|
configuredProductIDs = []
|
||||||
lastGrantedBalance = nil
|
lastGrantedBalance = nil
|
||||||
historyPhase = .idle
|
historyPhase = .idle
|
||||||
@@ -213,30 +347,64 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func reconcileUnfinished(accountID: UUID) async -> Bool {
|
private func reconcileUnfinished(accountID: UUID, sessionID: UUID) async -> Bool {
|
||||||
var reconciled = false
|
var reconciled = false
|
||||||
for await verification in store.unfinishedTransactions() {
|
for await verification in store.unfinishedTransactions() {
|
||||||
guard !Task.isCancelled else { break }
|
guard !Task.isCancelled else { break }
|
||||||
|
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
|
||||||
|
break
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try await process(verification, accountID: accountID)
|
_ = try await process(
|
||||||
|
verification,
|
||||||
|
accountID: accountID,
|
||||||
|
sessionID: sessionID
|
||||||
|
)
|
||||||
reconciled = true
|
reconciled = true
|
||||||
|
} catch is CancellationError {
|
||||||
|
break
|
||||||
} catch {
|
} 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
|
return reconciled
|
||||||
}
|
}
|
||||||
|
|
||||||
private func listenForUpdates(accountID: UUID) {
|
private func listenForUpdates(accountID: UUID, sessionID: UUID) {
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
let updates = store.transactionUpdates()
|
let updates = store.transactionUpdates()
|
||||||
updatesTask = Task { [weak self] in
|
updatesTask = Task { [weak self] in
|
||||||
for await verification in updates {
|
for await verification in updates {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
guard self.isCurrentSession(
|
||||||
|
accountID: accountID,
|
||||||
|
sessionID: sessionID
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
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 {
|
} catch {
|
||||||
|
guard self.isCurrentSession(
|
||||||
|
accountID: accountID,
|
||||||
|
sessionID: sessionID
|
||||||
|
), self.pendingProductID != nil else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
self.pendingProductID = nil
|
||||||
self.state = .failed(messageKey: Self.messageKey(for: error))
|
self.state = .failed(messageKey: Self.messageKey(for: error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,9 +413,11 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
|
|
||||||
private func process(
|
private func process(
|
||||||
_ verification: AccountStoreVerification,
|
_ verification: AccountStoreVerification,
|
||||||
accountID: UUID
|
accountID: UUID,
|
||||||
) async throws {
|
sessionID: UUID
|
||||||
guard case .verified(let transaction) = verification,
|
) async throws -> AccountCreditPurchase {
|
||||||
|
guard isCurrentSession(accountID: accountID, sessionID: sessionID),
|
||||||
|
case .verified(let transaction) = verification,
|
||||||
transaction.appAccountToken == accountID,
|
transaction.appAccountToken == accountID,
|
||||||
configuredProductIDs.contains(transaction.productID) else {
|
configuredProductIDs.contains(transaction.productID) else {
|
||||||
throw AccountCreditPurchaseError.verificationFailed
|
throw AccountCreditPurchaseError.verificationFailed
|
||||||
@@ -258,23 +428,119 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
|||||||
throw AccountCreditPurchaseError.invalidServerResponse
|
throw AccountCreditPurchaseError.invalidServerResponse
|
||||||
}
|
}
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
|
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
lastGrantedBalance = result.balanceAfter
|
lastGrantedBalance = result.balanceAfter
|
||||||
historyPhase = .idle
|
historyPhase = .idle
|
||||||
nextHistoryCursor = nil
|
nextHistoryCursor = nil
|
||||||
state = .succeeded(credits: result.creditsGranted)
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reset(for accountID: UUID) {
|
private func reset(for accountID: UUID) {
|
||||||
|
prepareTask?.cancel()
|
||||||
|
prepareTask = nil
|
||||||
|
prepareRequestID = nil
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
updatesTask = nil
|
updatesTask = nil
|
||||||
|
successDismissTask?.cancel()
|
||||||
|
successDismissTask = nil
|
||||||
activeAccountID = accountID
|
activeAccountID = accountID
|
||||||
|
sessionID = UUID()
|
||||||
|
pendingProductID = nil
|
||||||
|
catalogPhase = .idle
|
||||||
options = []
|
options = []
|
||||||
|
isRefreshingCatalog = false
|
||||||
|
catalogRefreshErrorKey = nil
|
||||||
configuredProductIDs = []
|
configuredProductIDs = []
|
||||||
lastGrantedBalance = nil
|
lastGrantedBalance = nil
|
||||||
historyPhase = .idle
|
historyPhase = .idle
|
||||||
isLoadingMoreHistory = false
|
isLoadingMoreHistory = false
|
||||||
historyLoadMoreErrorKey = nil
|
historyLoadMoreErrorKey = nil
|
||||||
nextHistoryCursor = 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 {
|
private static func messageKey(for error: Error) -> String {
|
||||||
|
|||||||
@@ -55,12 +55,25 @@ enum AccountStorePurchaseOutcome: Sendable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol AccountCreditStore {
|
protocol AccountCreditStore {
|
||||||
func product(for productID: String) async throws -> AccountStoreProduct?
|
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 purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome
|
||||||
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification>
|
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification>
|
||||||
func transactionUpdates() -> AsyncStream<AccountStoreVerification>
|
func transactionUpdates() -> AsyncStream<AccountStoreVerification>
|
||||||
func allTransactions() -> 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
|
@MainActor
|
||||||
final class LiveAccountCreditStore: AccountCreditStore {
|
final class LiveAccountCreditStore: AccountCreditStore {
|
||||||
private var productsByID: [String: Product] = [:]
|
private var productsByID: [String: Product] = [:]
|
||||||
@@ -70,6 +83,20 @@ final class LiveAccountCreditStore: AccountCreditStore {
|
|||||||
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
|
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 {
|
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome {
|
||||||
guard let product = try await loadProduct(for: productID) else {
|
guard let product = try await loadProduct(for: productID) else {
|
||||||
return .success(.unverified)
|
return .success(.unverified)
|
||||||
@@ -102,10 +129,8 @@ final class LiveAccountCreditStore: AccountCreditStore {
|
|||||||
if let product = productsByID[productID] {
|
if let product = productsByID[productID] {
|
||||||
return product
|
return product
|
||||||
}
|
}
|
||||||
let loaded = try await Product.products(for: [productID])
|
_ = try await products(for: [productID])
|
||||||
guard let product = loaded.first(where: { $0.id == productID }) else { return nil }
|
return productsByID[productID]
|
||||||
productsByID[productID] = product
|
|
||||||
return product
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func map(_ verification: VerificationResult<Transaction>) -> AccountStoreVerification {
|
private static func map(_ verification: VerificationResult<Transaction>) -> AccountStoreVerification {
|
||||||
|
|||||||
@@ -268,6 +268,14 @@ protocol AccountSessionServicing: Sendable {
|
|||||||
func clearManagedGateway() async
|
func clearManagedGateway() async
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AccountSessionEvent: Sendable {
|
||||||
|
case expired
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol AccountSessionEventSourcing: Sendable {
|
||||||
|
func events() async -> AsyncStream<AccountSessionEvent>
|
||||||
|
}
|
||||||
|
|
||||||
extension AccountSessionServicing {
|
extension AccountSessionServicing {
|
||||||
func prepareManagedGateway() async throws {}
|
func prepareManagedGateway() async throws {}
|
||||||
func clearManagedGateway() async {}
|
func clearManagedGateway() async {}
|
||||||
@@ -321,15 +329,18 @@ protocol ReferralProfileServicing: Sendable {
|
|||||||
|
|
||||||
struct AccountDependencies: Sendable {
|
struct AccountDependencies: Sendable {
|
||||||
let sessionService: any AccountSessionServicing
|
let sessionService: any AccountSessionServicing
|
||||||
|
let sessionEventSource: any AccountSessionEventSourcing
|
||||||
let centerService: any AccountCenterServicing
|
let centerService: any AccountCenterServicing
|
||||||
let referralService: any ReferralProfileServicing
|
let referralService: any ReferralProfileServicing
|
||||||
|
|
||||||
init(
|
init(
|
||||||
sessionService: any AccountSessionServicing,
|
sessionService: any AccountSessionServicing,
|
||||||
|
sessionEventSource: (any AccountSessionEventSourcing)? = nil,
|
||||||
centerService: any AccountCenterServicing,
|
centerService: any AccountCenterServicing,
|
||||||
referralService: (any ReferralProfileServicing)? = nil
|
referralService: (any ReferralProfileServicing)? = nil
|
||||||
) {
|
) {
|
||||||
self.sessionService = sessionService
|
self.sessionService = sessionService
|
||||||
|
self.sessionEventSource = sessionEventSource ?? UnavailableAccountSessionEventSource()
|
||||||
self.centerService = centerService
|
self.centerService = centerService
|
||||||
self.referralService = referralService ?? UnavailableReferralProfileService()
|
self.referralService = referralService ?? UnavailableReferralProfileService()
|
||||||
}
|
}
|
||||||
@@ -338,6 +349,7 @@ struct AccountDependencies: Sendable {
|
|||||||
let service = UnavailableAccountService()
|
let service = UnavailableAccountService()
|
||||||
return AccountDependencies(
|
return AccountDependencies(
|
||||||
sessionService: service,
|
sessionService: service,
|
||||||
|
sessionEventSource: UnavailableAccountSessionEventSource(),
|
||||||
centerService: service,
|
centerService: service,
|
||||||
referralService: UnavailableReferralProfileService()
|
referralService: UnavailableReferralProfileService()
|
||||||
)
|
)
|
||||||
@@ -396,6 +408,12 @@ private struct UnavailableReferralProfileService: ReferralProfileServicing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct UnavailableAccountSessionEventSource: AccountSessionEventSourcing {
|
||||||
|
func events() async -> AsyncStream<AccountSessionEvent> {
|
||||||
|
AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum ReferralUniversalLink {
|
enum ReferralUniversalLink {
|
||||||
static let host = "osglab.com"
|
static let host = "osglab.com"
|
||||||
static let codeLength = 22
|
static let codeLength = 22
|
||||||
|
|||||||
@@ -50,11 +50,14 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
private let pendingReferralStore: any PendingReferralCodeStoring
|
private let pendingReferralStore: any PendingReferralCodeStoring
|
||||||
private let analyticsClient: any AnalyticsClient
|
private let analyticsClient: any AnalyticsClient
|
||||||
private let onAccountAuthenticated: (UUID) async -> Void
|
private let onAccountAuthenticated: (UUID) async -> Void
|
||||||
|
private let onAccountSignedOut: () async -> Void
|
||||||
private let onAccountDeleted: () async -> Void
|
private let onAccountDeleted: () async -> Void
|
||||||
private let accountRefreshInterval: TimeInterval
|
private let accountRefreshInterval: TimeInterval
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
private var didAttemptRestore = false
|
private var didAttemptRestore = false
|
||||||
private var accountRefreshTask: Task<Void, Never>?
|
private var accountRefreshTask: Task<Void, Never>?
|
||||||
|
private var accountRefreshRequestID: UUID?
|
||||||
|
private var sessionEventsTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
dependencies: AccountDependencies,
|
dependencies: AccountDependencies,
|
||||||
@@ -67,6 +70,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
now: @escaping () -> Date = Date.init,
|
now: @escaping () -> Date = Date.init,
|
||||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||||
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
|
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
|
||||||
|
onAccountSignedOut: @escaping () async -> Void = {},
|
||||||
onAccountDeleted: @escaping () async -> Void = {}
|
onAccountDeleted: @escaping () async -> Void = {}
|
||||||
) {
|
) {
|
||||||
sessionService = dependencies.sessionService
|
sessionService = dependencies.sessionService
|
||||||
@@ -83,10 +87,24 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
self.pendingReferralStore = pendingReferralStore
|
self.pendingReferralStore = pendingReferralStore
|
||||||
self.analyticsClient = analyticsClient
|
self.analyticsClient = analyticsClient
|
||||||
self.onAccountAuthenticated = onAccountAuthenticated
|
self.onAccountAuthenticated = onAccountAuthenticated
|
||||||
|
self.onAccountSignedOut = onAccountSignedOut
|
||||||
self.onAccountDeleted = onAccountDeleted
|
self.onAccountDeleted = onAccountDeleted
|
||||||
self.accountRefreshInterval = accountRefreshInterval
|
self.accountRefreshInterval = accountRefreshInterval
|
||||||
self.now = now
|
self.now = now
|
||||||
pendingReferralCode = pendingReferralStore.code
|
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 {
|
var isSignedIn: Bool {
|
||||||
@@ -109,15 +127,20 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
guard let session = try await sessionService.restoreSession() else {
|
guard let session = try await sessionService.restoreSession() else {
|
||||||
|
await sessionService.clearManagedGateway()
|
||||||
|
await onAccountSignedOut()
|
||||||
sessionPhase = .signedOut
|
sessionPhase = .signedOut
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await onAccountAuthenticated(session.accountID)
|
await onAccountAuthenticated(session.accountID)
|
||||||
sessionPhase = .signedIn(session)
|
sessionPhase = .signedIn(session)
|
||||||
|
creditPurchases.startSession(accountID: session.accountID)
|
||||||
referralProfile.startSession(accountID: session.accountID)
|
referralProfile.startSession(accountID: session.accountID)
|
||||||
await redeemPendingReferralIfNeeded()
|
await redeemPendingReferralIfNeeded()
|
||||||
await refreshAccountData(force: true)
|
await refreshAccountData(force: true)
|
||||||
} catch {
|
} catch {
|
||||||
|
await sessionService.clearManagedGateway()
|
||||||
|
await onAccountSignedOut()
|
||||||
sessionPhase = .signedOut
|
sessionPhase = .signedOut
|
||||||
operationErrorKey = errorMessageKey(
|
operationErrorKey = errorMessageKey(
|
||||||
for: error,
|
for: error,
|
||||||
@@ -161,6 +184,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
let session = try await sessionService.signIn(with: payload)
|
let session = try await sessionService.signIn(with: payload)
|
||||||
await onAccountAuthenticated(session.accountID)
|
await onAccountAuthenticated(session.accountID)
|
||||||
sessionPhase = .signedIn(session)
|
sessionPhase = .signedIn(session)
|
||||||
|
creditPurchases.startSession(accountID: session.accountID)
|
||||||
referralProfile.startSession(accountID: session.accountID)
|
referralProfile.startSession(accountID: session.accountID)
|
||||||
await redeemPendingReferralIfNeeded()
|
await redeemPendingReferralIfNeeded()
|
||||||
await refreshAccountData(force: true)
|
await refreshAccountData(force: true)
|
||||||
@@ -196,13 +220,18 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
snapshotPhase = .loading
|
snapshotPhase = .loading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requestID = UUID()
|
||||||
|
accountRefreshRequestID = requestID
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.performAccountRefresh(for: accountID)
|
await self.performAccountRefresh(
|
||||||
|
for: accountID,
|
||||||
|
requestID: requestID
|
||||||
|
)
|
||||||
}
|
}
|
||||||
accountRefreshTask = task
|
accountRefreshTask = task
|
||||||
await task.value
|
await task.value
|
||||||
accountRefreshTask = nil
|
finishAccountRefresh(requestID: requestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDisplayName(_ displayName: String) async -> Bool {
|
func updateDisplayName(_ displayName: String) async -> Bool {
|
||||||
@@ -263,7 +292,8 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await sessionService.signOut()
|
try await sessionService.signOut()
|
||||||
creditPurchases.reset()
|
await onAccountSignedOut()
|
||||||
|
creditPurchases.endSession()
|
||||||
clearAccountRefreshState()
|
clearAccountRefreshState()
|
||||||
referralProfile.endSession()
|
referralProfile.endSession()
|
||||||
sessionPhase = .signedOut
|
sessionPhase = .signedOut
|
||||||
@@ -286,7 +316,8 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
try await sessionService.deleteAccount(with: payload)
|
try await sessionService.deleteAccount(with: payload)
|
||||||
await onAccountDeleted()
|
await onAccountDeleted()
|
||||||
creditPurchases.reset()
|
await onAccountSignedOut()
|
||||||
|
creditPurchases.endSession()
|
||||||
clearAccountRefreshState()
|
clearAccountRefreshState()
|
||||||
referralProfile.endSession(removeCache: true)
|
referralProfile.endSession(removeCache: true)
|
||||||
sessionPhase = .signedOut
|
sessionPhase = .signedOut
|
||||||
@@ -309,12 +340,12 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
operationErrorKey = nil
|
operationErrorKey = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performAccountRefresh(for accountID: UUID) async {
|
private func performAccountRefresh(for accountID: UUID, requestID: UUID) async {
|
||||||
isRefreshingAccountData = true
|
isRefreshingAccountData = true
|
||||||
accountRefreshErrorKey = nil
|
accountRefreshErrorKey = nil
|
||||||
defer { isRefreshingAccountData = false }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
try Task.checkCancellation()
|
||||||
let cachedSnapshot: AccountCenterSnapshot?
|
let cachedSnapshot: AccountCenterSnapshot?
|
||||||
if case let .loaded(snapshot) = snapshotPhase {
|
if case let .loaded(snapshot) = snapshotPhase {
|
||||||
cachedSnapshot = snapshot
|
cachedSnapshot = snapshot
|
||||||
@@ -324,15 +355,22 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
let snapshot = try await centerService.loadAccountCenter(
|
let snapshot = try await centerService.loadAccountCenter(
|
||||||
cachedSnapshot: cachedSnapshot
|
cachedSnapshot: cachedSnapshot
|
||||||
)
|
)
|
||||||
|
try Task.checkCancellation()
|
||||||
guard self.accountID == accountID,
|
guard self.accountID == accountID,
|
||||||
|
accountRefreshRequestID == requestID,
|
||||||
snapshot.account.accountID == accountID else {
|
snapshot.account.accountID == accountID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snapshotPhase = .loaded(snapshot)
|
snapshotPhase = .loaded(snapshot)
|
||||||
sessionPhase = .signedIn(snapshot.account)
|
sessionPhase = .signedIn(snapshot.account)
|
||||||
lastAccountRefreshAt = now()
|
lastAccountRefreshAt = now()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
guard self.accountID == accountID else { return }
|
guard self.accountID == accountID,
|
||||||
|
accountRefreshRequestID == requestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
let messageKey = errorMessageKey(
|
let messageKey = errorMessageKey(
|
||||||
for: error,
|
for: error,
|
||||||
fallback: "account.error.load"
|
fallback: "account.error.load"
|
||||||
@@ -348,11 +386,34 @@ final class AccountSessionCoordinator: ObservableObject {
|
|||||||
private func clearAccountRefreshState() {
|
private func clearAccountRefreshState() {
|
||||||
accountRefreshTask?.cancel()
|
accountRefreshTask?.cancel()
|
||||||
accountRefreshTask = nil
|
accountRefreshTask = nil
|
||||||
|
accountRefreshRequestID = nil
|
||||||
lastAccountRefreshAt = nil
|
lastAccountRefreshAt = nil
|
||||||
isRefreshingAccountData = false
|
isRefreshingAccountData = false
|
||||||
accountRefreshErrorKey = nil
|
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 {
|
private func redeemPendingReferralIfNeeded() async {
|
||||||
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Adapts the host-private account protocol to the account-center UI. Account
|
// 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 Foundation
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
@@ -40,6 +41,7 @@ enum LiveAccountDependencyFactory {
|
|||||||
)
|
)
|
||||||
return AccountDependencies(
|
return AccountDependencies(
|
||||||
sessionService: service,
|
sessionService: service,
|
||||||
|
sessionEventSource: service,
|
||||||
centerService: service,
|
centerService: service,
|
||||||
referralService: LiveReferralProfileService(apiClient: apiClient)
|
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 apiClient: AccountAPIClient
|
||||||
private let accountCenterLoader: AccountCenterSnapshotLoader
|
private let accountCenterLoader: AccountCenterSnapshotLoader
|
||||||
private let integrity: DeviceIntegrityCoordinator
|
private let integrity: DeviceIntegrityCoordinator
|
||||||
@@ -187,14 +192,37 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
|||||||
self.configuration = configuration
|
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? {
|
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?
|
let account: OSGAccount?
|
||||||
do {
|
do {
|
||||||
account = try await apiClient.account()
|
account = try await apiClient.account()
|
||||||
} catch let error as AccountAPIError {
|
} catch let error as AccountAPIError {
|
||||||
switch error {
|
switch error {
|
||||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||||
|
await invalidateManagedGatewaySession()
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
account = nil
|
account = nil
|
||||||
@@ -203,6 +231,7 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
|||||||
account = nil
|
account = nil
|
||||||
}
|
}
|
||||||
let session = try await apiClient.currentSession() ?? cachedSession
|
let session = try await apiClient.currentSession() ?? cachedSession
|
||||||
|
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||||
await synchronizeManagedGrant()
|
await synchronizeManagedGrant()
|
||||||
return uiSession(
|
return uiSession(
|
||||||
session,
|
session,
|
||||||
@@ -253,7 +282,6 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
|||||||
)
|
)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
await synchronizeManagedGrant()
|
|
||||||
let account: OSGAccount
|
let account: OSGAccount
|
||||||
do {
|
do {
|
||||||
account = try await apiClient.account()
|
account = try await apiClient.account()
|
||||||
@@ -264,6 +292,8 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
|||||||
)
|
)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||||
|
await synchronizeManagedGrant()
|
||||||
OSGDiag.log("signIn completed", category: "account")
|
OSGDiag.log("signIn completed", category: "account")
|
||||||
return uiSession(
|
return uiSession(
|
||||||
session,
|
session,
|
||||||
@@ -273,16 +303,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async throws {
|
func signOut() async throws {
|
||||||
|
configuration.setManagedGatewayAccountSessionAvailable(false)
|
||||||
|
configuration.setCredentialSource(.byok)
|
||||||
await revokeManagedGrantIfPossible()
|
await revokeManagedGrantIfPossible()
|
||||||
do {
|
do {
|
||||||
try await apiClient.logout()
|
try await apiClient.logout()
|
||||||
} catch {
|
} catch {
|
||||||
try? await grants.clearGrant()
|
try? await grants.clearGrant()
|
||||||
configuration.setCredentialSource(.byok)
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
try? await grants.clearGrant()
|
try? await grants.clearGrant()
|
||||||
configuration.setCredentialSource(.byok)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||||
@@ -293,13 +323,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
|||||||
)
|
)
|
||||||
try? await grants.clearGrant()
|
try? await grants.clearGrant()
|
||||||
await integrity.clearLocalKeyState()
|
await integrity.clearLocalKeyState()
|
||||||
|
configuration.setManagedGatewayAccountSessionAvailable(false)
|
||||||
configuration.setCredentialSource(.byok)
|
configuration.setCredentialSource(.byok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareManagedGateway() async throws {
|
func prepareManagedGateway() async throws {
|
||||||
guard try await apiClient.currentSession() != nil else {
|
guard try await apiClient.currentSession() != nil else {
|
||||||
|
await invalidateManagedGatewaySession()
|
||||||
throw AccountAPIError.sessionUnavailable
|
throw AccountAPIError.sessionUnavailable
|
||||||
}
|
}
|
||||||
|
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||||
try await ensureManagedGrant()
|
try await ensureManagedGrant()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +495,14 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
|||||||
try? await grants.clearGrant()
|
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(
|
private func uiSession(
|
||||||
_ session: OSGKeyboardHostSupport.AccountSession,
|
_ session: OSGKeyboardHostSupport.AccountSession,
|
||||||
createdAtEpochSeconds: Int64,
|
createdAtEpochSeconds: Int64,
|
||||||
|
|||||||
@@ -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
|
// TabBarVisibility.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Push 进 NavigationStack 子页时隐藏底部自定义 tab 栏(对齐系统 TabView 行为)。
|
// Push 进 NavigationStack 子页时隐藏底部系统 tab 栏。
|
||||||
// MainTabView 读取 `TabBarHiddenPreferenceKey`;子页用 `hidesTabBarWhenPushed()` 声明。
|
// 子页用 `hidesTabBarWhenPushed()` 声明,根页面保持原生 TabView 导航可见。
|
||||||
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
import SwiftUI
|
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
|
// MARK: - Modifiers
|
||||||
|
|
||||||
extension View {
|
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 {
|
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 {
|
func tabBarScrollBottomPadding() -> some View {
|
||||||
modifier(TabBarScrollBottomPaddingModifier())
|
padding(.bottom, Spacing.lg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bottom scroll-content margin for `List` tab roots. Unlike padding on the list
|
/// Adds a scrollable footer margin to `List` content above the system tab bar.
|
||||||
/// container, this extends the scrollable area so rows can scroll above the dock.
|
|
||||||
func tabBarListScrollBottomMargin() -> some View {
|
func tabBarListScrollBottomMargin() -> some View {
|
||||||
modifier(TabBarListScrollBottomMarginModifier())
|
contentMargins(
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ~3–4×
|
|
||||||
/// 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(
|
|
||||||
.bottom,
|
.bottom,
|
||||||
isTabBarVisible ? TabBarDockMetrics.scrollClearance : Spacing.lg
|
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,
|
|
||||||
for: .scrollContent
|
for: .scrollContent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,11 @@ struct HomeView: View {
|
|||||||
@State private var micStatus = AppPermissions.micStatus
|
@State private var micStatus = AppPermissions.micStatus
|
||||||
@State private var speechStatus = AppPermissions.speechStatus
|
@State private var speechStatus = AppPermissions.speechStatus
|
||||||
@State private var path = NavigationPath()
|
@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 {
|
private var usesWideLayout: Bool {
|
||||||
horizontalSizeClass == .regular
|
horizontalSizeClass == .regular
|
||||||
@@ -189,23 +193,49 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
refreshPermissionStatuses()
|
refreshPermissionStatuses()
|
||||||
refreshDictionaryPreview()
|
refreshDictionarySuggestions()
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
guard phase == .active else { return }
|
guard phase == .active else { return }
|
||||||
refreshPermissionStatuses()
|
refreshPermissionStatuses()
|
||||||
refreshDictionaryPreview()
|
refreshDictionarySuggestions()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
refreshPermissionStatuses()
|
refreshPermissionStatuses()
|
||||||
refreshDictionaryPreview()
|
refreshDictionarySuggestions()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||||
refreshDictionaryPreview()
|
refreshDictionarySuggestions()
|
||||||
}
|
}
|
||||||
.onChange(of: path.count) { _, count in
|
.onChange(of: path.count) { _, count in
|
||||||
guard count == 0 else { return }
|
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.
|
/// and the body grows with its rows.
|
||||||
private var homeLibrarySection: some View {
|
private var homeLibrarySection: some View {
|
||||||
VStack(spacing: Spacing.md) {
|
VStack(spacing: Spacing.md) {
|
||||||
historyCard
|
|
||||||
dictionaryCard
|
dictionaryCard
|
||||||
|
historyCard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,33 +374,80 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var dictionaryCard: some View {
|
private var dictionaryCard: some View {
|
||||||
homeLibraryCard(
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
titleKey: "settings.personalDictionary.title",
|
Button {
|
||||||
systemImage: "character.book.closed",
|
path.append(HomeRoute.dictionary)
|
||||||
route: .dictionary
|
} label: {
|
||||||
) {
|
HStack(spacing: Spacing.xs) {
|
||||||
if dictionaryPreviewEntries.isEmpty {
|
Image(systemName: "sparkles")
|
||||||
libraryEmptyLine("home.card.dictionary.empty")
|
.font(.system(size: 16, weight: .semibold))
|
||||||
} else {
|
.foregroundStyle(palette.accent)
|
||||||
VStack(spacing: 0) {
|
.symbolRenderingMode(.hierarchical)
|
||||||
ForEach(Array(dictionaryPreviewEntries.enumerated()), id: \.element.id) { index, entry in
|
Text("settings.personalDictionary.title")
|
||||||
if index > 0 { libraryRowDivider }
|
.font(.system(size: 13, weight: .semibold))
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.tracking(0.6)
|
||||||
Text(entry.term)
|
.textCase(.uppercase)
|
||||||
.font(TypeStyle.body)
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
.lineLimit(1)
|
|
||||||
Text(dictionaryDetailLine(for: entry))
|
|
||||||
.font(TypeStyle.caption2)
|
|
||||||
.foregroundStyle(palette.textTertiary)
|
.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 {
|
||||||
|
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)
|
.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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint(
|
||||||
|
AppL10n.format(
|
||||||
|
"home.card.dictionary.smart.accessibilityHint",
|
||||||
|
suggestion.commitCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Spacing.md)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.vertical, Spacing.sm)
|
.surfaceCard()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
|
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
|
||||||
@@ -424,37 +501,40 @@ struct HomeView: View {
|
|||||||
.padding(.vertical, Spacing.xs)
|
.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() {
|
private func refreshPermissionStatuses() {
|
||||||
micStatus = AppPermissions.micStatus
|
micStatus = AppPermissions.micStatus
|
||||||
speechStatus = AppPermissions.speechStatus
|
speechStatus = AppPermissions.speechStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshDictionaryPreview() {
|
private func refreshDictionarySuggestions() {
|
||||||
dictionaryPreviewEntries = AppGroupStore().personalDictionary.entries
|
let dictionary = appGroupStore.personalDictionary
|
||||||
.sorted { lhs, rhs in
|
let suggestions = rimeFrequentTermStore.suggestions(
|
||||||
if lhs.updatedAt != rhs.updatedAt {
|
excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
|
||||||
return lhs.updatedAt > rhs.updatedAt
|
limit: Self.dictionarySuggestionLimit
|
||||||
|
)
|
||||||
|
dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount
|
||||||
|
? suggestions
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
return lhs.usageCount > rhs.usageCount
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
.prefix(Self.libraryPreviewLimit)
|
|
||||||
.map { $0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePermissionGuidanceAction() {
|
private func handlePermissionGuidanceAction() {
|
||||||
@@ -672,6 +752,8 @@ struct HomeView: View {
|
|||||||
|
|
||||||
/// Rows shown inside the history / dictionary preview cards.
|
/// Rows shown inside the history / dictionary preview cards.
|
||||||
private static let libraryPreviewLimit = 3
|
private static let libraryPreviewLimit = 3
|
||||||
|
private static let minimumDictionarySuggestionCount = 3
|
||||||
|
private static let dictionarySuggestionLimit = 5
|
||||||
|
|
||||||
private static let previewTimeFormatter: DateFormatter = {
|
private static let previewTimeFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import OSGKeyboardHostSupport
|
|||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
import SwiftUI
|
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 {
|
struct MainAppRoot: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -21,7 +29,9 @@ struct MainAppRoot: View {
|
|||||||
@StateObject private var accountSession: AccountSessionCoordinator
|
@StateObject private var accountSession: AccountSessionCoordinator
|
||||||
@State private var clmWarmupTask: Task<Void, Never>?
|
@State private var clmWarmupTask: Task<Void, Never>?
|
||||||
@State private var rimeStartupTask: 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) {
|
init(accountDependencies: AccountDependencies? = nil) {
|
||||||
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
|
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
|
||||||
@@ -34,8 +44,18 @@ struct MainAppRoot: View {
|
|||||||
dependencies: resolvedDependencies,
|
dependencies: resolvedDependencies,
|
||||||
analyticsClient: analytics.client,
|
analyticsClient: analytics.client,
|
||||||
onAccountAuthenticated: { accountID in
|
onAccountAuthenticated: { accountID in
|
||||||
|
AppGroupStore().setManagedGatewayAccountSessionAvailable(true)
|
||||||
await analytics.observeAuthenticatedAccount(accountID)
|
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: {
|
onAccountDeleted: {
|
||||||
await analytics.handleAccountDeletion()
|
await analytics.handleAccountDeletion()
|
||||||
}
|
}
|
||||||
@@ -69,6 +89,11 @@ struct MainAppRoot: View {
|
|||||||
ReleaseNotesSheet(language: config.uiLanguage)
|
ReleaseNotesSheet(language: config.uiLanguage)
|
||||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $newContactDraft) { draft in
|
||||||
|
NewContactSheet(phoneNumber: draft.phoneNumber) {
|
||||||
|
newContactDraft = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
flowManager.setAppForeground(scenePhase == .active)
|
flowManager.setAppForeground(scenePhase == .active)
|
||||||
// Register the URL handler BEFORE the foreground auto-start.
|
// Register the URL handler BEFORE the foreground auto-start.
|
||||||
@@ -84,12 +109,18 @@ struct MainAppRoot: View {
|
|||||||
analytics.prepare(
|
analytics.prepare(
|
||||||
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
|
firstOpenAcquisitionChannel: firstOpenAcquisitionChannel
|
||||||
)
|
)
|
||||||
|
if scenePhase == .active {
|
||||||
|
analytics.appDidBecomeActive()
|
||||||
|
}
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
|
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
|
||||||
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
|
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
|
||||||
category: "flow"
|
category: "flow"
|
||||||
)
|
)
|
||||||
AppCloudSync.shared.startObservingExternalChanges()
|
AppCloudSync.shared.startObservingExternalChanges()
|
||||||
|
if scenePhase == .active {
|
||||||
|
refreshOfficialSkillCatalog(reason: "MainAppRoot.onAppear")
|
||||||
|
}
|
||||||
|
|
||||||
// Heavy work (Flow / CLM / Rime) only after onboarding. Doing it
|
// Heavy work (Flow / CLM / Rime) only after onboarding. Doing it
|
||||||
// earlier jetsams the host (~150 MB+) and the keyboard dies with it.
|
// earlier jetsams the host (~150 MB+) and the keyboard dies with it.
|
||||||
@@ -101,7 +132,9 @@ struct MainAppRoot: View {
|
|||||||
if scenePhase == .active {
|
if scenePhase == .active {
|
||||||
activateForegroundServices(reason: "MainAppRoot.onAppear")
|
activateForegroundServices(reason: "MainAppRoot.onAppear")
|
||||||
AIHintRefreshService.refreshIfNeeded(reason: "MainAppRoot.onAppear")
|
AIHintRefreshService.refreshIfNeeded(reason: "MainAppRoot.onAppear")
|
||||||
|
if newContactDraft == nil {
|
||||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"MainAppRoot.onAppear defer foreground services scene="
|
"MainAppRoot.onAppear defer foreground services scene="
|
||||||
@@ -132,8 +165,10 @@ struct MainAppRoot: View {
|
|||||||
if scenePhase == .active {
|
if scenePhase == .active {
|
||||||
activateForegroundServices(reason: "onboardingCompleted")
|
activateForegroundServices(reason: "onboardingCompleted")
|
||||||
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
|
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
|
||||||
|
if newContactDraft == nil {
|
||||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if accountSession.shouldPresentAccountCenter {
|
if accountSession.shouldPresentAccountCenter {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Let MainTabView enter the hierarchy before publishing.
|
// Let MainTabView enter the hierarchy before publishing.
|
||||||
@@ -152,6 +187,8 @@ struct MainAppRoot: View {
|
|||||||
flowManager.handleScenePhase(phase)
|
flowManager.handleScenePhase(phase)
|
||||||
if phase == .active {
|
if phase == .active {
|
||||||
analytics.appDidBecomeActive()
|
analytics.appDidBecomeActive()
|
||||||
|
} else if phase == .inactive {
|
||||||
|
analytics.appWillResignActive()
|
||||||
} else if phase == .background {
|
} else if phase == .background {
|
||||||
analytics.appDidEnterBackground()
|
analytics.appDidEnterBackground()
|
||||||
}
|
}
|
||||||
@@ -163,17 +200,31 @@ struct MainAppRoot: View {
|
|||||||
FlowSessionBridge.setHostHeavy(false)
|
FlowSessionBridge.setHostHeavy(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
refreshOfficialSkillCatalog(reason: "scenePhase.active")
|
||||||
if config.hasCompletedOnboarding {
|
if config.hasCompletedOnboarding {
|
||||||
activateForegroundServices(reason: "scenePhase.active")
|
activateForegroundServices(reason: "scenePhase.active")
|
||||||
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
|
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
|
||||||
|
if newContactDraft == nil {
|
||||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Task {
|
Task {
|
||||||
await AppCloudSync.shared.pullAllIfEnabled()
|
await AppCloudSync.shared.pullAllIfEnabled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
/// Starts foreground-only services once per active transition. Rime yields
|
||||||
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
|
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
|
||||||
private func activateForegroundServices(reason: String) {
|
private func activateForegroundServices(reason: String) {
|
||||||
@@ -270,7 +321,9 @@ struct MainAppRoot: View {
|
|||||||
|
|
||||||
private func handleIncomingURL(_ url: URL) {
|
private func handleIncomingURL(_ url: URL) {
|
||||||
if accountSession.handleIncomingURL(url) {
|
if accountSession.handleIncomingURL(url) {
|
||||||
firstOpenAcquisitionChannel = .referral
|
firstOpenAcquisitionChannel =
|
||||||
|
AnalyticsFirstOpenAttribution.trustedChannel(for: url)
|
||||||
|
?? AnalyticsFirstOpenAttribution.ordinaryLaunch
|
||||||
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -295,6 +348,16 @@ struct MainAppRoot: View {
|
|||||||
SettingsDeepLink.setPending(.clipboard)
|
SettingsDeepLink.setPending(.clipboard)
|
||||||
}
|
}
|
||||||
NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil)
|
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
|
#if DEBUG
|
||||||
case "seed-demo":
|
case "seed-demo":
|
||||||
DemoDataSeeder.seedRichPlaceholderData()
|
DemoDataSeeder.seedRichPlaceholderData()
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ struct MainSplitView: View {
|
|||||||
}
|
}
|
||||||
.navigationSplitViewStyle(.balanced)
|
.navigationSplitViewStyle(.balanced)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
// No floating dock in split mode — child scroll views should not
|
// No floating tab bar in split mode; shared footer spacing remains
|
||||||
// reserve bottom clearance for the phone tab bar.
|
// useful for the final rows in each scroll view.
|
||||||
.environment(\.isTabBarVisible, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sidebar
|
// MARK: - Sidebar
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import SwiftUI
|
|||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
|
||||||
|
|
||||||
@State private var tab: AppTab = .keyboard
|
@State private var tab: AppTab = .keyboard
|
||||||
@State private var isTabBarHidden = false
|
|
||||||
|
|
||||||
private var usesSplitLayout: Bool {
|
private var usesSplitLayout: Bool {
|
||||||
horizontalSizeClass == .regular
|
horizontalSizeClass == .regular
|
||||||
@@ -37,29 +35,45 @@ struct MainTabView: View {
|
|||||||
// MARK: - Phone layout
|
// MARK: - Phone layout
|
||||||
|
|
||||||
private var phoneTabLayout: some View {
|
private var phoneTabLayout: some View {
|
||||||
ZStack(alignment: .bottom) {
|
TabView(selection: $tab) {
|
||||||
palette.background.ignoresSafeArea()
|
Tab(value: AppTab.keyboard) {
|
||||||
|
HomeView()
|
||||||
MainTabContent(tab: tab)
|
} label: {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
tabLabel(for: .keyboard)
|
||||||
.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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isTabBarHidden {
|
Tab(value: AppTab.skills) {
|
||||||
MinimalTabBar(selection: $tab)
|
AIAgentSkillsView()
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
} label: {
|
||||||
|
tabLabel(for: .skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
struct PolishStylesView: View {
|
struct PolishStylesView: View {
|
||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
|
@ObservedObject private var history = SpeechHistoryStore.shared
|
||||||
|
|
||||||
@State private var catalog = AppGroupStore().polishStyleCatalog
|
@State private var catalog = AppGroupStore().polishStyleCatalog
|
||||||
@State private var activeID = AppGroupStore().activePolishStyleId
|
@State private var activeID = AppGroupStore().activePolishStyleId
|
||||||
@@ -19,6 +20,7 @@ struct PolishStylesView: View {
|
|||||||
@State private var editingPack: PolishStylePack?
|
@State private var editingPack: PolishStylePack?
|
||||||
@State private var viewingPack: PolishStylePack?
|
@State private var viewingPack: PolishStylePack?
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var isGeneratingLearnedStyle = false
|
||||||
|
|
||||||
private let store = AppGroupStore()
|
private let store = AppGroupStore()
|
||||||
private let columns = [
|
private let columns = [
|
||||||
@@ -30,6 +32,7 @@ struct PolishStylesView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
CardPageContent(spacing: Spacing.xl) {
|
CardPageContent(spacing: Spacing.xl) {
|
||||||
|
styleLearningCard
|
||||||
packGridSection(
|
packGridSection(
|
||||||
title: "polishStyles.builtin.section",
|
title: "polishStyles.builtin.section",
|
||||||
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
|
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(
|
private func packGridSection(
|
||||||
title: LocalizedStringKey,
|
title: LocalizedStringKey,
|
||||||
packs: [PolishStylePack]
|
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 {
|
private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey {
|
||||||
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
|
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
|
||||||
switch pack.id {
|
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 {
|
private func localized(_ error: Error) -> String {
|
||||||
switch error as? PolishStyleValidationError {
|
switch error as? PolishStyleValidationError {
|
||||||
case .emptyName: return AppL10n.string("polishStyles.error.emptyName")
|
case .emptyName: return AppL10n.string("polishStyles.error.emptyName")
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.";
|
"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.";
|
"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.";
|
||||||
|
|||||||
@@ -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.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.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.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.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.systemHint" = "These permissions are used only when you start voice input.";
|
||||||
"onboarding.experience.permissions.later" = "Set Up Later";
|
"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.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.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.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.openAgain" = "Open Settings Again";
|
||||||
"onboarding.experience.keyboard.practiceAction" = "Try It Now";
|
"onboarding.experience.keyboard.practiceAction" = "Try It Now";
|
||||||
"onboarding.experience.keyboard.previewTitle" = "Keyboards";
|
"onboarding.experience.keyboard.previewTitle" = "Keyboards";
|
||||||
"onboarding.experience.practice.skip" = "Try Later";
|
"onboarding.experience.keyboardSwitch.title" = "Switch keyboards";
|
||||||
"onboarding.experience.practice.sample" = "“I’ll arrive tomorrow afternoon and message you when I’m there.”";
|
"onboarding.experience.keyboardSwitch.subtitle" = "Once the keyboard is active, we’ll 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" = "I’ll arrive tomorrow afternoon and message you when I’m there.";
|
||||||
"onboarding.experience.practice.editorTitle" = "New Message";
|
"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.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.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.fullAccessSubtitle" = "OSGKeyboard is enabled. Turn on Allow Full Access to use voice input.";
|
||||||
"onboarding.experience.practice.fullAccessAction" = "Turn On Full Access";
|
"onboarding.experience.practice.fullAccessAction" = "Turn On Full Access";
|
||||||
"onboarding.experience.practice.signInTitle" = "Sign in for a free AI writing trial";
|
"onboarding.experience.practice.cloudTitle" = "Start the trial";
|
||||||
"onboarding.experience.practice.signInSubtitle" = "The lesson uses OSG Credits by default. This OOBE request will not deduct any credits.";
|
"onboarding.experience.practice.cloudSubtitle" = "All four lessons work without an account or 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.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.cloudTitle" = "Enable the free OOBE experience";
|
"onboarding.experience.practice.cloudAction" = "Agree & Start";
|
||||||
"onboarding.experience.practice.cloudSubtitle" = "Only this transcript is sent to OSG managed AI and explicitly tagged as OOBE.";
|
"onboarding.experience.practice.creditsTitle" = "Getting ready";
|
||||||
"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.creditsSubtitle" = "Verifying the app and obtaining a short-lived, restricted lesson credential.";
|
||||||
"onboarding.experience.practice.cloudAction" = "Agree & Try for Free";
|
"onboarding.experience.practice.preparingCredits" = "Securely connecting to OSG AI…";
|
||||||
"onboarding.experience.practice.creditsTitle" = "Preparing OSG Credits";
|
"onboarding.experience.practice.creditsFailed" = "The anonymous trial is temporarily unavailable. Check your connection and try again. You will not be charged.";
|
||||||
"onboarding.experience.practice.creditsSubtitle" = "This OOBE lesson is completely free.";
|
"onboarding.experience.practice.retry" = "Try Again";
|
||||||
"onboarding.experience.practice.preparingCredits" = "Preparing your free OOBE request…";
|
"onboarding.experience.practice.progress.voice" = "1 of 4 · Voice Input";
|
||||||
"onboarding.experience.practice.creditsFailed" = "OSG Credits is temporarily unavailable. Try again—no credits were deducted.";
|
"onboarding.experience.practice.progress.translate" = "2 of 4 · AI Translation";
|
||||||
"onboarding.experience.practice.speakTitle" = "Now, say something";
|
"onboarding.experience.practice.progress.reply" = "3 of 4 · AI Reply";
|
||||||
"onboarding.experience.practice.speakSubtitle" = "Tap the microphone on the keyboard, speak, then tap it again. Your text will appear here.";
|
"onboarding.experience.practice.progress.askAI" = "4 of 4 · Ask AI";
|
||||||
"onboarding.experience.complete.verifiedTitle" = "That’s it.";
|
"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" = "You’ve 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" = "You’re ready";
|
||||||
"onboarding.experience.complete.title" = "You’re ready";
|
"onboarding.experience.complete.title" = "You’re 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.dictate" = "Voice to Text";
|
||||||
"onboarding.experience.complete.translate" = "Translate";
|
"onboarding.experience.complete.translate" = "Translate";
|
||||||
"onboarding.experience.complete.agent" = "AI Agent";
|
"onboarding.experience.complete.reply" = "Smart Reply";
|
||||||
"onboarding.experience.complete.edit" = "Voice Edit";
|
"onboarding.experience.complete.agent" = "Ask AI";
|
||||||
"onboarding.experience.complete.action" = "Start Using";
|
"onboarding.experience.complete.action" = "Start Using";
|
||||||
"settings.onboarding.replay" = "Restart permission setup";
|
"settings.onboarding.replay" = "Restart permission setup";
|
||||||
|
|
||||||
@@ -485,6 +514,12 @@
|
|||||||
"home.preview.placeholder" = "Tap to type and test…";
|
"home.preview.placeholder" = "Tap to type and test…";
|
||||||
"home.card.history.empty" = "No voice transcripts yet";
|
"home.card.history.empty" = "No voice transcripts yet";
|
||||||
"home.card.dictionary.empty" = "No words 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.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||||
"home.wide.mode.cloud" = "Cloud";
|
"home.wide.mode.cloud" = "Cloud";
|
||||||
"home.wide.mode.local" = "On-device";
|
"home.wide.mode.local" = "On-device";
|
||||||
@@ -508,9 +543,9 @@
|
|||||||
|
|
||||||
/* AI Agent skills */
|
/* AI Agent skills */
|
||||||
"skills.title" = "Skills";
|
"skills.title" = "Skills";
|
||||||
"skills.enabled.section" = "In use (%d/%d)";
|
"skills.installed.section" = "Installed (%d)";
|
||||||
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
"skills.installed.empty" = "No installed skills.";
|
||||||
"skills.available.section" = "Available";
|
"skills.uninstalled.section" = "Not installed";
|
||||||
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
||||||
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
|
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
|
||||||
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
|
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
|
||||||
@@ -524,18 +559,45 @@
|
|||||||
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
||||||
"skills.detail.title" = "Skill";
|
"skills.detail.title" = "Skill";
|
||||||
"skills.badge.default" = "Default skill";
|
"skills.badge.default" = "Default skill";
|
||||||
"skills.action.turnOff" = "Turn off";
|
"skills.action.install" = "Install";
|
||||||
"skills.action.turnOffHint" = "Removes it from the keyboard only.";
|
"skills.action.uninstall" = "Uninstall";
|
||||||
"skills.action.addToKeyboard" = "Add to keyboard";
|
|
||||||
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
||||||
"skills.full.title" = "Skill slots full";
|
"skills.reply.name" = "Reply in source language";
|
||||||
"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
|
"skills.reply.description" = "Understand the copied text and reply naturally in its primary language.";
|
||||||
"skills.reply.name" = "Reply";
|
"skills.playfulReply.name" = "Playful reply";
|
||||||
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
|
"skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate.";
|
||||||
"skills.summarize.name" = "Summarize";
|
"skills.replyInSourceLanguage.name" = "Reply in source language";
|
||||||
"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
|
"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.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.name" = "Extract tasks";
|
||||||
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
|
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
|
||||||
"skills.extractEvents.name" = "Extract events";
|
"skills.extractEvents.name" = "Extract events";
|
||||||
@@ -589,6 +651,20 @@
|
|||||||
"polishStyles.custom.section" = "My styles";
|
"polishStyles.custom.section" = "My styles";
|
||||||
"polishStyles.intro.title" = "Choose a writing personality";
|
"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.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" = "Couldn’t 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.light.description" = "Fix recognition errors and punctuation with minimal rewriting.";
|
||||||
"polishStyles.structured.description" = "Organize multiple points into clear paragraphs and lists.";
|
"polishStyles.structured.description" = "Organize multiple points into clear paragraphs and lists.";
|
||||||
"polishStyles.formal.description" = "Professional, restrained writing for email and work.";
|
"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.editLastInputFailed" = "Could not complete the edit. Please try again.";
|
||||||
"flow.error.aiQuestionFailed" = "AI response failed. 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.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 */
|
/* Optional account center */
|
||||||
"account.title" = "Account";
|
"account.title" = "Account";
|
||||||
@@ -754,6 +833,7 @@
|
|||||||
"account.signIn.apple" = "Sign in with Apple";
|
"account.signIn.apple" = "Sign in with Apple";
|
||||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||||
"account.signIn.loading" = "Signing in…";
|
"account.signIn.loading" = "Signing in…";
|
||||||
|
"account.error.sessionExpired" = "Your session expired. Sign in again to continue.";
|
||||||
"account.summary.section" = "Account";
|
"account.summary.section" = "Account";
|
||||||
"account.summary.signedIn" = "Signed in";
|
"account.summary.signedIn" = "Signed in";
|
||||||
"account.summary.protected" = "Protected account";
|
"account.summary.protected" = "Protected account";
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,包括你在其他 App 中输入时保持启用的 Flow 会话。";
|
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,包括你在其他 App 中输入时保持启用的 Flow 会话。";
|
||||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
|
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
|
||||||
|
"NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。";
|
||||||
|
|||||||
@@ -46,47 +46,76 @@
|
|||||||
"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。";
|
"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。";
|
||||||
"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用";
|
"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用";
|
||||||
"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。";
|
"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。";
|
||||||
"onboarding.experience.permissions.title" = "让 OSGKeyboard 听见你";
|
"onboarding.experience.permissions.title" = "让我们听见你";
|
||||||
"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
|
"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
|
||||||
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
|
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
|
||||||
"onboarding.experience.permissions.later" = "暂时跳过";
|
"onboarding.experience.permissions.later" = "暂时跳过";
|
||||||
"onboarding.experience.keyboard.title" = "添加 OSGKeyboard";
|
"onboarding.experience.keyboard.title" = "添加键盘";
|
||||||
"onboarding.experience.keyboard.subtitle" = "在 iOS 设置中添加键盘,并打开「允许完全访问」。";
|
"onboarding.experience.keyboard.subtitle" = "在 iOS 设置中添加键盘,并打开「允许完全访问」。";
|
||||||
"onboarding.experience.keyboard.ready" = "OSGKeyboard 与完全访问均已就绪";
|
"onboarding.experience.keyboard.ready" = "OSGKeyboard 与完全访问均已就绪";
|
||||||
"onboarding.experience.keyboard.fullAccess" = "完全访问用于连接主 App 的麦克风和可选 AI 服务";
|
"onboarding.experience.keyboard.fullAccess" = "完全访问用于连接主 App 的麦克风和可选 AI 服务";
|
||||||
"onboarding.experience.keyboard.openAgain" = "再次打开设置";
|
"onboarding.experience.keyboard.openAgain" = "再次打开设置";
|
||||||
"onboarding.experience.keyboard.practiceAction" = "开始体验";
|
"onboarding.experience.keyboard.practiceAction" = "开始体验";
|
||||||
"onboarding.experience.keyboard.previewTitle" = "键盘";
|
"onboarding.experience.keyboard.previewTitle" = "键盘";
|
||||||
"onboarding.experience.practice.skip" = "稍后体验";
|
"onboarding.experience.keyboardSwitch.title" = "切换键盘";
|
||||||
"onboarding.experience.practice.sample" = "“我明天下午到,到了以后给你消息。”";
|
"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.editorTitle" = "新消息";
|
||||||
"onboarding.experience.practice.permissionsTitle" = "还需要语音权限";
|
"onboarding.experience.practice.permissionsTitle" = "开启语音权限";
|
||||||
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
|
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
|
||||||
"onboarding.experience.practice.switchTitle" = "切换到 OSGKeyboard";
|
"onboarding.experience.practice.switchTitle" = "切换键盘";
|
||||||
"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。";
|
"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。";
|
||||||
"onboarding.experience.practice.fullAccessTitle" = "再完成一个设置";
|
"onboarding.experience.practice.fullAccessTitle" = "开启完全访问";
|
||||||
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard 已启用。打开「允许完全访问」后就能使用语音。";
|
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard 已启用。打开「允许完全访问」后就能使用语音。";
|
||||||
"onboarding.experience.practice.fullAccessAction" = "去设置打开完全访问";
|
"onboarding.experience.practice.fullAccessAction" = "去设置打开完全访问";
|
||||||
"onboarding.experience.practice.signInTitle" = "登录,免费体验 AI 成文";
|
"onboarding.experience.practice.cloudTitle" = "开始体验";
|
||||||
"onboarding.experience.practice.signInSubtitle" = "教学体验默认使用 OSG 积分服务,本次 OOBE 请求不会扣除任何积分。";
|
"onboarding.experience.practice.cloudSubtitle" = "四项教学无需账号,也不会消耗积分。";
|
||||||
"onboarding.experience.practice.signInBody" = "使用 Apple 登录以领取一次免费的 OOBE AI 润色。语音仍在设备端转写。";
|
"onboarding.experience.practice.cloudBody" = "语音在设备端转写;只有本次教学所需的转写文字、示例文本和问题会发送到 OSG 托管 AI。匿名体验凭证将在教学结束后失效。";
|
||||||
"onboarding.experience.practice.cloudTitle" = "启用免费 OOBE 体验";
|
"onboarding.experience.practice.cloudAction" = "同意并开始体验";
|
||||||
"onboarding.experience.practice.cloudSubtitle" = "仅将本次转写文字发送到 OSG 托管 AI,并明确标记为 OOBE。";
|
"onboarding.experience.practice.creditsTitle" = "准备体验";
|
||||||
"onboarding.experience.practice.cloudBody" = "本次教学请求由服务端验证为 OOBE,不预扣、不结算积分。完成后,OSG 积分将作为默认 AI 服务。";
|
"onboarding.experience.practice.creditsSubtitle" = "正在验证 App 并获取短时、限权的教学凭证。";
|
||||||
"onboarding.experience.practice.cloudAction" = "同意并免费体验";
|
"onboarding.experience.practice.preparingCredits" = "正在安全连接 OSG AI…";
|
||||||
"onboarding.experience.practice.creditsTitle" = "正在准备 OSG 积分服务";
|
"onboarding.experience.practice.creditsFailed" = "暂时无法准备匿名体验。请检查网络后重试,不会产生任何费用。";
|
||||||
"onboarding.experience.practice.creditsSubtitle" = "本次 OOBE 教学完全免费。";
|
"onboarding.experience.practice.retry" = "重试";
|
||||||
"onboarding.experience.practice.preparingCredits" = "正在准备免费 OOBE 请求…";
|
"onboarding.experience.practice.progress.voice" = "1 / 4 · 语音输入";
|
||||||
"onboarding.experience.practice.creditsFailed" = "暂时无法连接 OSG 积分服务,请重试。不会扣除积分。";
|
"onboarding.experience.practice.progress.translate" = "2 / 4 · AI 翻译";
|
||||||
"onboarding.experience.practice.speakTitle" = "现在,说一句话";
|
"onboarding.experience.practice.progress.reply" = "3 / 4 · AI 回复";
|
||||||
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,说完后再点一次。文字会直接出现在这里。";
|
"onboarding.experience.practice.progress.askAI" = "4 / 4 · 询问 AI";
|
||||||
"onboarding.experience.complete.verifiedTitle" = "就是这样。";
|
"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.title" = "已经准备好了";
|
||||||
"onboarding.experience.complete.subtitle" = "以后在任何输入框切换到 OSGKeyboard,开口成文,也可以继续交给 AI Agent。";
|
"onboarding.experience.complete.subtitle" = "点击开始使用";
|
||||||
"onboarding.experience.complete.dictate" = "语音成文";
|
"onboarding.experience.complete.dictate" = "语音成文";
|
||||||
"onboarding.experience.complete.translate" = "翻译";
|
"onboarding.experience.complete.translate" = "翻译";
|
||||||
"onboarding.experience.complete.agent" = "AI Agent";
|
"onboarding.experience.complete.reply" = "智能回复";
|
||||||
"onboarding.experience.complete.edit" = "语音编辑";
|
"onboarding.experience.complete.agent" = "询问 AI";
|
||||||
"onboarding.experience.complete.action" = "开始使用";
|
"onboarding.experience.complete.action" = "开始使用";
|
||||||
"settings.onboarding.replay" = "重新开始权限引导";
|
"settings.onboarding.replay" = "重新开始权限引导";
|
||||||
|
|
||||||
@@ -484,6 +513,12 @@
|
|||||||
"home.preview.placeholder" = "点这里试试键盘";
|
"home.preview.placeholder" = "点这里试试键盘";
|
||||||
"home.card.history.empty" = "还没有语音识别记录";
|
"home.card.history.empty" = "还没有语音识别记录";
|
||||||
"home.card.dictionary.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.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||||
"home.wide.mode.cloud" = "云端";
|
"home.wide.mode.cloud" = "云端";
|
||||||
"home.wide.mode.local" = "本机";
|
"home.wide.mode.local" = "本机";
|
||||||
@@ -507,9 +542,9 @@
|
|||||||
|
|
||||||
/* AI Agent 技能 */
|
/* AI Agent 技能 */
|
||||||
"skills.title" = "技能";
|
"skills.title" = "技能";
|
||||||
"skills.enabled.section" = "使用中(%d/%d)";
|
"skills.installed.section" = "已安装(%d)";
|
||||||
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
"skills.installed.empty" = "暂无已安装技能。";
|
||||||
"skills.available.section" = "可添加";
|
"skills.uninstalled.section" = "未安装";
|
||||||
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
||||||
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
|
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
|
||||||
"skills.clipboard.guide.enableHistory" = "开启历史记录";
|
"skills.clipboard.guide.enableHistory" = "开启历史记录";
|
||||||
@@ -523,18 +558,45 @@
|
|||||||
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
||||||
"skills.detail.title" = "技能";
|
"skills.detail.title" = "技能";
|
||||||
"skills.badge.default" = "默认技能";
|
"skills.badge.default" = "默认技能";
|
||||||
"skills.action.turnOff" = "关闭";
|
"skills.action.install" = "安装";
|
||||||
"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
|
"skills.action.uninstall" = "卸载";
|
||||||
"skills.action.addToKeyboard" = "添加到键盘";
|
|
||||||
"skills.action.reinstallShortcut" = "重新安装捷径";
|
"skills.action.reinstallShortcut" = "重新安装捷径";
|
||||||
"skills.full.title" = "技能已满";
|
"skills.reply.name" = "原语言回复";
|
||||||
"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
|
"skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。";
|
||||||
"skills.reply.name" = "回复";
|
"skills.playfulReply.name" = "俏皮回复";
|
||||||
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
|
"skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。";
|
||||||
"skills.summarize.name" = "总结";
|
"skills.replyInSourceLanguage.name" = "用原语言回复";
|
||||||
"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
|
"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.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.name" = "提取待办";
|
||||||
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
|
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
|
||||||
"skills.extractEvents.name" = "提取日程";
|
"skills.extractEvents.name" = "提取日程";
|
||||||
@@ -588,6 +650,20 @@
|
|||||||
"polishStyles.custom.section" = "我的风格";
|
"polishStyles.custom.section" = "我的风格";
|
||||||
"polishStyles.intro.title" = "选择完整写作人格";
|
"polishStyles.intro.title" = "选择完整写作人格";
|
||||||
"polishStyles.intro.body" = "选中的风格会影响每次听写润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
|
"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.light.description" = "修正识别错误与标点,尽量少改原话。";
|
||||||
"polishStyles.structured.description" = "将多个事项整理为清晰段落与列表。";
|
"polishStyles.structured.description" = "将多个事项整理为清晰段落与列表。";
|
||||||
"polishStyles.formal.description" = "适合邮件和工作的专业、克制表达。";
|
"polishStyles.formal.description" = "适合邮件和工作的专业、克制表达。";
|
||||||
@@ -737,6 +813,9 @@
|
|||||||
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
|
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
|
||||||
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
|
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
|
||||||
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
|
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
|
||||||
|
"flow.error.webPageInvalid" = "该链接无法用于网页总结。";
|
||||||
|
"flow.error.webPageUnavailable" = "无法读取该网页,页面可能要求登录或禁止自动读取。";
|
||||||
|
"flow.error.webPageEmpty" = "网页中没有可读取的正文内容。";
|
||||||
|
|
||||||
/* 可选账号中心 */
|
/* 可选账号中心 */
|
||||||
"account.title" = "账号";
|
"account.title" = "账号";
|
||||||
@@ -753,6 +832,7 @@
|
|||||||
"account.signIn.apple" = "通过 Apple 登录";
|
"account.signIn.apple" = "通过 Apple 登录";
|
||||||
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||||
"account.signIn.loading" = "正在登录…";
|
"account.signIn.loading" = "正在登录…";
|
||||||
|
"account.error.sessionExpired" = "登录状态已过期,请重新登录后继续。";
|
||||||
"account.summary.section" = "账号";
|
"account.summary.section" = "账号";
|
||||||
"account.summary.signedIn" = "已登录";
|
"account.summary.signedIn" = "已登录";
|
||||||
"account.summary.protected" = "账号已安全保护";
|
"account.summary.protected" = "账号已安全保护";
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||||
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
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 {
|
private var memoryTelemetryContext: String {
|
||||||
let language = typingSessionStorage?.language.rawValue ?? "-"
|
let language = typingSessionStorage?.language.rawValue ?? "-"
|
||||||
@@ -306,6 +309,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewDidAppear(_ animated: Bool) {
|
public override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
keyboardUsageSessionID = UUID()
|
||||||
AnalyticsExtensionService.shared.recordPresentation(
|
AnalyticsExtensionService.shared.recordPresentation(
|
||||||
hasFullAccess: hasFullAccess
|
hasFullAccess: hasFullAccess
|
||||||
)
|
)
|
||||||
@@ -329,7 +333,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
host: WhatsNewDemoDriver.HostHooks(
|
host: WhatsNewDemoDriver.HostHooks(
|
||||||
insertText: { [weak self] text in
|
insertText: { [weak self] text in
|
||||||
self?.insertTextIntoDocument(text)
|
self?.insertTextIntoDocument(text, source: .debugDemo)
|
||||||
},
|
},
|
||||||
deleteBackward: { [weak self] in
|
deleteBackward: { [weak self] in
|
||||||
self?.deleteBackwardFromDocument()
|
self?.deleteBackwardFromDocument()
|
||||||
@@ -415,7 +419,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
editHintScheduler = EditHintScheduler(state: state)
|
editHintScheduler = EditHintScheduler(state: state)
|
||||||
textInserter = KeyboardTextInserter(
|
textInserter = KeyboardTextInserter(
|
||||||
state: state,
|
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() },
|
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
||||||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||||||
@@ -482,6 +488,15 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
},
|
},
|
||||||
captureInsertionFingerprint: { [weak self] in
|
captureInsertionFingerprint: { [weak self] in
|
||||||
self?.captureFieldContext().deliveryFingerprint
|
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)
|
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
||||||
@@ -622,8 +637,12 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self?.configSync.persistTranslationTargetLocaleId(id)
|
self?.configSync.persistTranslationTargetLocaleId(id)
|
||||||
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||||||
}
|
}
|
||||||
state.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
|
state.insertNewline = { [weak self] in
|
||||||
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
|
self?.insertTextIntoDocument("\n", source: .manualKeyboard)
|
||||||
|
}
|
||||||
|
state.insertSpace = { [weak self] in
|
||||||
|
self?.insertTextIntoDocument(" ", source: .manualKeyboard)
|
||||||
|
}
|
||||||
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
||||||
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
||||||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
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
|
/// Keeps field-action UI current even when a host app does not immediately
|
||||||
/// echo this keyboard's own document mutation through `textDidChange`.
|
/// 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 }
|
guard !text.isEmpty else { return }
|
||||||
textDocumentProxy.insertText(text)
|
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.
|
// A non-empty insertion makes content actions available immediately.
|
||||||
// The next host callback remains the authoritative correction.
|
// The next host callback remains the authoritative correction.
|
||||||
refreshAssistantFieldAction(hasTextOverride: true)
|
refreshAssistantFieldAction(hasTextOverride: true)
|
||||||
@@ -786,7 +816,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func performDocumentReturn() {
|
private func performDocumentReturn() {
|
||||||
textDocumentProxy.insertText("\n")
|
insertTextIntoDocument("\n", source: .assistantAction)
|
||||||
scheduleAssistantFieldActionRefresh()
|
scheduleAssistantFieldActionRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1010,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
typing: typingSession,
|
typing: typingSession,
|
||||||
onInsert: { [weak self] text in
|
onInsert: { [weak self] text in
|
||||||
self?.insertTextIntoDocument(text)
|
self?.insertTextIntoDocument(text, source: .manualKeyboard)
|
||||||
},
|
},
|
||||||
onDeleteBackward: { [weak self] in
|
onDeleteBackward: { [weak self] in
|
||||||
self?.deleteBackwardFromDocument()
|
self?.deleteBackwardFromDocument()
|
||||||
@@ -1073,6 +1103,47 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
// MARK: - Open host app
|
// 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() {
|
private func openSkillShortcutRun() {
|
||||||
guard hasFullAccess else {
|
guard hasFullAccess else {
|
||||||
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||||||
|
|||||||
@@ -15,28 +15,39 @@ final class AIKeyboardCoordinator {
|
|||||||
private let insertAnswer: (AIAnswer) -> Bool
|
private let insertAnswer: (AIAnswer) -> Bool
|
||||||
private let performReturn: () -> Void
|
private let performReturn: () -> Void
|
||||||
private let captureInsertionFingerprint: () -> String?
|
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 requestInsertionFingerprint: String?
|
||||||
private var conversationInsertionFingerprint: String?
|
private var conversationInsertionFingerprint: String?
|
||||||
private var hasConversationInsertionTarget = false
|
private var hasConversationInsertionTarget = false
|
||||||
|
private var requestOOBEFeature: ManagedGatewayOOBEFeature?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
flow: KeyboardFlowCoordinator,
|
flow: KeyboardFlowCoordinator,
|
||||||
insertAnswer: @escaping (AIAnswer) -> Bool,
|
insertAnswer: @escaping (AIAnswer) -> Bool,
|
||||||
performReturn: @escaping () -> Void,
|
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.state = state
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
self.insertAnswer = insertAnswer
|
self.insertAnswer = insertAnswer
|
||||||
self.performReturn = performReturn
|
self.performReturn = performReturn
|
||||||
self.captureInsertionFingerprint = captureInsertionFingerprint
|
self.captureInsertionFingerprint = captureInsertionFingerprint
|
||||||
|
self.openWebURL = openWebURL
|
||||||
|
self.callPhone = callPhone
|
||||||
|
self.createContact = createContact
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginNewPresentation() {
|
func beginNewPresentation() {
|
||||||
endConversationIfNeeded()
|
endConversationIfNeeded()
|
||||||
state.aiSession.enter()
|
state.aiSession.enter()
|
||||||
requestInsertionFingerprint = nil
|
requestInsertionFingerprint = nil
|
||||||
|
requestOOBEFeature = nil
|
||||||
conversationInsertionFingerprint = nil
|
conversationInsertionFingerprint = nil
|
||||||
hasConversationInsertionTarget = false
|
hasConversationInsertionTarget = false
|
||||||
}
|
}
|
||||||
@@ -53,6 +64,7 @@ final class AIKeyboardCoordinator {
|
|||||||
endConversationIfNeeded()
|
endConversationIfNeeded()
|
||||||
state.aiSession.leave()
|
state.aiSession.leave()
|
||||||
requestInsertionFingerprint = nil
|
requestInsertionFingerprint = nil
|
||||||
|
requestOOBEFeature = nil
|
||||||
conversationInsertionFingerprint = nil
|
conversationInsertionFingerprint = nil
|
||||||
hasConversationInsertionTarget = false
|
hasConversationInsertionTarget = false
|
||||||
}
|
}
|
||||||
@@ -66,7 +78,12 @@ final class AIKeyboardCoordinator {
|
|||||||
case .idle, .awaitingSend, .inserted, .sent, .failed:
|
case .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||||
prepareConversationForRequest()
|
prepareConversationForRequest()
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
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 {
|
if case .rejected(let rejection) = disposition {
|
||||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
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.
|
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
||||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
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()
|
prepareConversationForRequest()
|
||||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
|
||||||
if skill.kind == .export {
|
if skill.kind == .export {
|
||||||
state.pendingClipboardSkillID = skill.id
|
state.pendingClipboardSkillID = skill.id
|
||||||
state.pendingClipboardSkillSource = material
|
state.pendingClipboardSkillSource = material
|
||||||
@@ -92,7 +152,8 @@ final class AIKeyboardCoordinator {
|
|||||||
var instruction = AIClipboardSkillCatalog.instruction(
|
var instruction = AIClipboardSkillCatalog.instruction(
|
||||||
for: skill,
|
for: skill,
|
||||||
locale: AIHintLocaleResolver.packLocale(),
|
locale: AIHintLocaleResolver.packLocale(),
|
||||||
translationTargetLocaleId: state.translationTargetLocaleId
|
translationTargetLocaleId: state.translationTargetLocaleId,
|
||||||
|
replyStyle: state.clipboardReplyStyle
|
||||||
)
|
)
|
||||||
if skill.kind == .export {
|
if skill.kind == .export {
|
||||||
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
|
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
|
||||||
@@ -110,9 +171,31 @@ final class AIKeyboardCoordinator {
|
|||||||
if case .materialUnavailable = resolution {
|
if case .materialUnavailable = resolution {
|
||||||
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
|
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(
|
submitResolvedPrompt(
|
||||||
resolution,
|
resolution,
|
||||||
taskKind: skill.managedGatewayTaskKind,
|
taskKind: skill.managedGatewayTaskKind,
|
||||||
|
oobeFeature: oobeFeature,
|
||||||
thinkingEnabled: skill.thinkingEnabled
|
thinkingEnabled: skill.thinkingEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,12 +203,21 @@ final class AIKeyboardCoordinator {
|
|||||||
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
||||||
func submitHintCard(_ card: AIHintCard) {
|
func submitHintCard(_ card: AIHintCard) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
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()
|
prepareConversationForRequest()
|
||||||
let resolution = AIHintPool.resolvePrompt(
|
let resolution = AIHintPool.resolvePrompt(
|
||||||
for: card,
|
for: card,
|
||||||
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||||
)
|
)
|
||||||
submitResolvedPrompt(resolution, taskKind: .aiQuestion)
|
submitResolvedPrompt(
|
||||||
|
resolution,
|
||||||
|
taskKind: card.taskKind,
|
||||||
|
requestSource: .hotword,
|
||||||
|
oobeFeature: nil,
|
||||||
|
thinkingEnabled: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canAcceptIdleSubmit: Bool {
|
private var canAcceptIdleSubmit: Bool {
|
||||||
@@ -141,7 +233,10 @@ final class AIKeyboardCoordinator {
|
|||||||
private func submitResolvedPrompt(
|
private func submitResolvedPrompt(
|
||||||
_ resolution: AIClipboardPrompt.Resolution,
|
_ resolution: AIClipboardPrompt.Resolution,
|
||||||
taskKind: ManagedGatewayTaskKind,
|
taskKind: ManagedGatewayTaskKind,
|
||||||
thinkingEnabled: Bool? = nil
|
requestSource: ManagedGatewayRequestSource? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
|
thinkingEnabled: Bool? = nil,
|
||||||
|
webPageURL: URL? = nil
|
||||||
) {
|
) {
|
||||||
guard case .ready(let prompt) = resolution else {
|
guard case .ready(let prompt) = resolution else {
|
||||||
// The clipboard window closed between rendering and this tap.
|
// The clipboard window closed between rendering and this tap.
|
||||||
@@ -156,11 +251,15 @@ final class AIKeyboardCoordinator {
|
|||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
requestOOBEFeature = oobeFeature
|
||||||
let disposition = flow.submitAIQuestion(
|
let disposition = flow.submitAIQuestion(
|
||||||
text: prompt,
|
text: prompt,
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
thinkingEnabled: thinkingEnabled
|
requestSource: requestSource,
|
||||||
|
oobeFeature: oobeFeature,
|
||||||
|
thinkingEnabled: thinkingEnabled,
|
||||||
|
webPageURL: webPageURL
|
||||||
)
|
)
|
||||||
if case .rejected(let rejection) = disposition {
|
if case .rejected(let rejection) = disposition {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
@@ -172,6 +271,7 @@ final class AIKeyboardCoordinator {
|
|||||||
guard state.aiSession.isBusy else { return }
|
guard state.aiSession.isBusy else { return }
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
requestInsertionFingerprint = nil
|
requestInsertionFingerprint = nil
|
||||||
|
requestOOBEFeature = nil
|
||||||
flow.cancelAIRecording()
|
flow.cancelAIRecording()
|
||||||
state.aiSession.cancelCurrentWork()
|
state.aiSession.cancelCurrentWork()
|
||||||
}
|
}
|
||||||
@@ -179,6 +279,7 @@ final class AIKeyboardCoordinator {
|
|||||||
func confirmPendingAnswer() {
|
func confirmPendingAnswer() {
|
||||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||||
guard insertAnswer(answer) else { return }
|
guard insertAnswer(answer) else { return }
|
||||||
|
markOOBECompletedAfterInsertion()
|
||||||
state.aiSession.markAnswerInserted(
|
state.aiSession.markAnswerInserted(
|
||||||
offersSend: state.returnKeyRole.usesActionFill
|
offersSend: state.returnKeyRole.usesActionFill
|
||||||
)
|
)
|
||||||
@@ -190,6 +291,7 @@ final class AIKeyboardCoordinator {
|
|||||||
func discardPendingAnswer() {
|
func discardPendingAnswer() {
|
||||||
state.aiSession.discardReadyAnswer()
|
state.aiSession.discardReadyAnswer()
|
||||||
requestInsertionFingerprint = nil
|
requestInsertionFingerprint = nil
|
||||||
|
requestOOBEFeature = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func performCurrentFieldAction() {
|
func performCurrentFieldAction() {
|
||||||
@@ -274,6 +376,7 @@ final class AIKeyboardCoordinator {
|
|||||||
// fallback when the field or caret changed during generation.
|
// fallback when the field or caret changed during generation.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOOBECompletedAfterInsertion()
|
||||||
state.aiSession.markAnswerInserted(
|
state.aiSession.markAnswerInserted(
|
||||||
offersSend: state.returnKeyRole.usesActionFill
|
offersSend: state.returnKeyRole.usesActionFill
|
||||||
)
|
)
|
||||||
@@ -284,6 +387,7 @@ final class AIKeyboardCoordinator {
|
|||||||
func fail(_ message: String, utteranceID: UUID?) {
|
func fail(_ message: String, utteranceID: UUID?) {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
requestInsertionFingerprint = nil
|
requestInsertionFingerprint = nil
|
||||||
|
requestOOBEFeature = nil
|
||||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,16 +423,51 @@ final class AIKeyboardCoordinator {
|
|||||||
requestInsertionFingerprint = currentFingerprint
|
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 {
|
private var isPendingExportSkill: Bool {
|
||||||
guard let id = state.pendingClipboardSkillID else { return false }
|
guard let id = state.pendingClipboardSkillID else { return false }
|
||||||
return resolvedSkill(id: id)?.kind == .export
|
return resolvedSkill(id: id)?.kind == .export
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedSkill(id: String) -> AIClipboardSkill? {
|
private func resolvedSkill(id: String) -> AIClipboardSkill? {
|
||||||
AIClipboardSkillCatalog.skill(
|
state.clipboardSkillCatalog.first { $0.id == id }
|
||||||
id: id,
|
|
||||||
userCatalog: AppGroupStore().agentUserSkillCatalog
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an export skill. Empty → in-keyboard tip, stay in the host app.
|
/// Parse an export skill. Empty → in-keyboard tip, stay in the host app.
|
||||||
|
|||||||
@@ -1,102 +1,50 @@
|
|||||||
// AnalyticsExtensionService.swift
|
// AnalyticsExtensionService.swift
|
||||||
// OSGKeyboard · Keyboard Extension
|
// OSGKeyboard · Keyboard Extension
|
||||||
//
|
//
|
||||||
// The extension records into the shared SQLite queue and only attempts one
|
// The extension only records into durable queues. The host app is the sole
|
||||||
// short anonymous batch when Full Access permits network use.
|
// network uploader, avoiding extension-lifecycle and cross-process lock races.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSGKeyboardShared
|
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 {
|
final class AnalyticsExtensionService: Sendable {
|
||||||
static let shared = AnalyticsExtensionService()
|
static let shared = AnalyticsExtensionService()
|
||||||
|
|
||||||
let client: any AnalyticsClient
|
let client: any AnalyticsClient
|
||||||
|
let keyboardUsageRecorder: any KeyboardUsageRecording
|
||||||
private let runtime: AnalyticsRuntime
|
|
||||||
private let uploadSignal: ExtensionAnalyticsUploadSignal
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let signal = ExtensionAnalyticsUploadSignal()
|
let environment = Self.environment
|
||||||
uploadSignal = signal
|
|
||||||
let runtime = AnalyticsRuntime.keyboardExtension(
|
let runtime = AnalyticsRuntime.keyboardExtension(
|
||||||
environment: Self.environment,
|
environment: environment,
|
||||||
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
|
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint)
|
||||||
trigger: signal,
|
)
|
||||||
logger: ExtensionAnalyticsLogger()
|
let keyboardUsageRuntime = KeyboardUsageRuntime(
|
||||||
|
environment: environment,
|
||||||
|
analyticsRepository: runtime.repository,
|
||||||
|
uploadConfiguration: KeyboardUsageUploadConfiguration(
|
||||||
|
endpoint: Self.keyboardUsageEndpoint
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.runtime = runtime
|
|
||||||
client = runtime.client
|
client = runtime.client
|
||||||
|
keyboardUsageRecorder = keyboardUsageRuntime.recorder
|
||||||
Task {
|
|
||||||
await signal.install {
|
|
||||||
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordPresentation(hasFullAccess: Bool) {
|
func recordPresentation(hasFullAccess _: Bool) {
|
||||||
Task {
|
|
||||||
await uploadSignal.setCanUpload(hasFullAccess)
|
|
||||||
client.recordSessionActivity()
|
client.recordSessionActivity()
|
||||||
client.recordKeyboardActivated()
|
client.recordKeyboardActivated()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func keyboardWillDisappear() {
|
func keyboardWillDisappear() {}
|
||||||
Task {
|
|
||||||
await uploadSignal.setCanUpload(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let endpoint = URL(
|
private static let endpoint = URL(
|
||||||
string: "https://account.osglab.com/v1/analytics/events"
|
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 {
|
private static var environment: AnalyticsEnvironment {
|
||||||
let appVersion = Bundle.main.object(
|
let appVersion = Bundle.main.object(
|
||||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public struct AppGroupPersistor {
|
|||||||
state.handednessPreference = store.handednessPreference
|
state.handednessPreference = store.handednessPreference
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
applySkillSnapshot(store: store, into: state)
|
||||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||||
applyAPIKeyAvailability(store: store, into: state)
|
applyAPIKeyAvailability(store: store, into: state)
|
||||||
|
|
||||||
@@ -97,11 +97,41 @@ public struct AppGroupPersistor {
|
|||||||
state.handednessPreference = store.handednessPreference
|
state.handednessPreference = store.handednessPreference
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
applySkillSnapshot(store: store, into: state)
|
||||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||||
applyAPIKeyAvailability(store: store, into: state)
|
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
|
/// 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.
|
/// the polish key is missing — show a soft tip above the mic instead.
|
||||||
private func applyAPIKeyAvailability(
|
private func applyAPIKeyAvailability(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ final class ClipboardCaptureCoordinator {
|
|||||||
|
|
||||||
private let state: KeyboardState
|
private let state: KeyboardState
|
||||||
private let history: ClipboardHistoryStore
|
private let history: ClipboardHistoryStore
|
||||||
|
private let semanticRanking: ClipboardSemanticRankingStore
|
||||||
private let pasteboard: ClipboardPasteboardProviding
|
private let pasteboard: ClipboardPasteboardProviding
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var isSecureProvider: () -> Bool = { false }
|
private var isSecureProvider: () -> Bool = { false }
|
||||||
@@ -48,10 +49,12 @@ final class ClipboardCaptureCoordinator {
|
|||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
history: ClipboardHistoryStore = .shared,
|
history: ClipboardHistoryStore = .shared,
|
||||||
|
semanticRanking: ClipboardSemanticRankingStore = .shared,
|
||||||
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
|
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
|
||||||
) {
|
) {
|
||||||
self.state = state
|
self.state = state
|
||||||
self.history = history
|
self.history = history
|
||||||
|
self.semanticRanking = semanticRanking
|
||||||
self.pasteboard = pasteboard
|
self.pasteboard = pasteboard
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,9 @@ final class ClipboardCaptureCoordinator {
|
|||||||
// A suggestion belongs to one keyboard presentation. Clear any
|
// A suggestion belongs to one keyboard presentation. Clear any
|
||||||
// presentation state left behind by a reused extension controller.
|
// presentation state left behind by a reused extension controller.
|
||||||
endCurrentSuggestion()
|
endCurrentSuggestion()
|
||||||
|
if let newest = history.newestAIHintEligibleEntry() {
|
||||||
|
semanticRanking.analyze(newest)
|
||||||
|
}
|
||||||
forcesNextSample = true
|
forcesNextSample = true
|
||||||
// Delay the system pasteboard read until the first poll tick. A
|
// Delay the system pasteboard read until the first poll tick. A
|
||||||
// Universal Clipboard fetch or paste alert during the appear sequence
|
// Universal Clipboard fetch or paste alert during the appear sequence
|
||||||
@@ -94,6 +100,7 @@ final class ClipboardCaptureCoordinator {
|
|||||||
stopPolling()
|
stopPolling()
|
||||||
// A1 policy: closing the keyboard ends this generation's suggestion.
|
// A1 policy: closing the keyboard ends this generation's suggestion.
|
||||||
endCurrentSuggestion()
|
endCurrentSuggestion()
|
||||||
|
semanticRanking.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshFlagsFromStore() {
|
func refreshFlagsFromStore() {
|
||||||
@@ -102,6 +109,9 @@ final class ClipboardCaptureCoordinator {
|
|||||||
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
|
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
|
||||||
endCurrentSuggestion()
|
endCurrentSuggestion()
|
||||||
}
|
}
|
||||||
|
if !state.clipboardHistoryEnabled {
|
||||||
|
semanticRanking.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func secureEntryDidChange(isSecure: Bool) {
|
func secureEntryDidChange(isSecure: Bool) {
|
||||||
@@ -117,6 +127,7 @@ final class ClipboardCaptureCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
endCurrentSuggestion()
|
endCurrentSuggestion()
|
||||||
|
semanticRanking.clear()
|
||||||
state.clipboardOverlay = .none
|
state.clipboardOverlay = .none
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +164,16 @@ final class ClipboardCaptureCoordinator {
|
|||||||
|
|
||||||
func clearHistory() {
|
func clearHistory() {
|
||||||
endCurrentSuggestion()
|
endCurrentSuggestion()
|
||||||
|
semanticRanking.clear()
|
||||||
history.clearAll()
|
history.clearAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteEntry(id: UUID) {
|
func deleteEntry(id: UUID) {
|
||||||
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
|
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
|
||||||
history.remove(id: id)
|
history.remove(id: id)
|
||||||
|
if semanticRanking.snapshot?.entryID == id {
|
||||||
|
semanticRanking.clear()
|
||||||
|
}
|
||||||
if deletedChangeCount == state.clipboardSuggestionChangeCount {
|
if deletedChangeCount == state.clipboardSuggestionChangeCount {
|
||||||
endCurrentSuggestion()
|
endCurrentSuggestion()
|
||||||
}
|
}
|
||||||
@@ -233,6 +248,7 @@ final class ClipboardCaptureCoordinator {
|
|||||||
|
|
||||||
// Prefer hasStrings peek before reading body (reduces empty reads).
|
// Prefer hasStrings peek before reading body (reduces empty reads).
|
||||||
guard pasteboard.hasStrings else {
|
guard pasteboard.hasStrings else {
|
||||||
|
semanticRanking.clear()
|
||||||
history.lastObservedChangeCount = changeCount
|
history.lastObservedChangeCount = changeCount
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -243,7 +259,9 @@ final class ClipboardCaptureCoordinator {
|
|||||||
if isCurrentGeneration {
|
if isCurrentGeneration {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
semanticRanking.clear()
|
||||||
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
|
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
|
||||||
|
semanticRanking.analyze(entry)
|
||||||
updateSuggestion(with: entry, changeCount: changeCount)
|
updateSuggestion(with: entry, changeCount: changeCount)
|
||||||
} else {
|
} else {
|
||||||
history.lastObservedChangeCount = changeCount
|
history.lastObservedChangeCount = changeCount
|
||||||
@@ -306,6 +324,7 @@ final class ClipboardCaptureCoordinator {
|
|||||||
// including generations that contain no acceptable text.
|
// including generations that contain no acceptable text.
|
||||||
clearSuggestion()
|
clearSuggestion()
|
||||||
guard sample.hasStrings else {
|
guard sample.hasStrings else {
|
||||||
|
semanticRanking.clear()
|
||||||
history.lastObservedChangeCount = changeCount
|
history.lastObservedChangeCount = changeCount
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -313,7 +332,9 @@ final class ClipboardCaptureCoordinator {
|
|||||||
// republish content from an already observed generation.
|
// republish content from an already observed generation.
|
||||||
guard !isCurrentGeneration else { return }
|
guard !isCurrentGeneration else { return }
|
||||||
|
|
||||||
|
semanticRanking.clear()
|
||||||
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
|
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
|
||||||
|
semanticRanking.analyze(entry)
|
||||||
updateSuggestion(with: entry, changeCount: changeCount)
|
updateSuggestion(with: entry, changeCount: changeCount)
|
||||||
} else {
|
} else {
|
||||||
history.lastObservedChangeCount = changeCount
|
history.lastObservedChangeCount = changeCount
|
||||||
|
|||||||
@@ -92,7 +92,21 @@ final class KeyboardConfigSync {
|
|||||||
// Keychain fallback: a reboot must not resurrect the mic gate when
|
// Keychain fallback: a reboot must not resurrect the mic gate when
|
||||||
// App Group transiently reads empty.
|
// App Group transiently reads empty.
|
||||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
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) {
|
func persistLocale(_ id: String) {
|
||||||
|
|||||||
@@ -677,16 +677,25 @@ final class KeyboardFlowCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func beginAIRecording(
|
func beginAIRecording(
|
||||||
conversationID: UUID
|
conversationID: UUID,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil
|
||||||
) -> FlowUtteranceStartDisposition {
|
) -> FlowUtteranceStartDisposition {
|
||||||
startUtterance(.aiQuestion(conversationID: conversationID))
|
startUtterance(
|
||||||
|
.aiQuestion(
|
||||||
|
conversationID: conversationID,
|
||||||
|
oobeFeature: oobeFeature
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func submitAIQuestion(
|
func submitAIQuestion(
|
||||||
text: String,
|
text: String,
|
||||||
conversationID: UUID,
|
conversationID: UUID,
|
||||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||||
thinkingEnabled: Bool? = nil
|
requestSource: ManagedGatewayRequestSource? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
|
thinkingEnabled: Bool? = nil,
|
||||||
|
webPageURL: URL? = nil
|
||||||
) -> FlowUtteranceStartDisposition {
|
) -> FlowUtteranceStartDisposition {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
|
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
|
||||||
@@ -695,7 +704,10 @@ final class KeyboardFlowCoordinator {
|
|||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
prefilledQuestion: trimmed,
|
prefilledQuestion: trimmed,
|
||||||
taskKind: taskKind,
|
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
|
let mode: FlowUtteranceMode? = request.mode == .dictation
|
||||||
? nil
|
? nil
|
||||||
: request.mode
|
: request.mode
|
||||||
|
let implicitOOBEFeature: ManagedGatewayOOBEFeature? =
|
||||||
|
state.oobePracticeSession?.expectedFeature == .voiceInput
|
||||||
|
&& request.mode == .dictation
|
||||||
|
? .voiceInput
|
||||||
|
: nil
|
||||||
|
let managedOOBEFeature = request.managedOOBEFeature ?? implicitOOBEFeature
|
||||||
let managedRequestPurpose = request.managedRequestPurpose
|
let managedRequestPurpose = request.managedRequestPurpose
|
||||||
?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil)
|
?? (managedOOBEFeature == nil ? nil : .oobe)
|
||||||
let command = FlowCommand(
|
let command = FlowCommand(
|
||||||
sessionId: activeSessionId,
|
sessionId: activeSessionId,
|
||||||
utteranceId: currentUtteranceId,
|
utteranceId: currentUtteranceId,
|
||||||
@@ -1200,7 +1218,9 @@ final class KeyboardFlowCoordinator {
|
|||||||
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
|
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
|
||||||
aiConversationID: request.aiConversationID,
|
aiConversationID: request.aiConversationID,
|
||||||
aiTaskKind: request.aiTaskKind,
|
aiTaskKind: request.aiTaskKind,
|
||||||
|
managedRequestSource: request.managedRequestSource,
|
||||||
managedRequestPurpose: managedRequestPurpose,
|
managedRequestPurpose: managedRequestPurpose,
|
||||||
|
managedOOBEFeature: managedOOBEFeature,
|
||||||
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
||||||
processingDeadlineAt: action == .stopRecording && request.isEdit
|
processingDeadlineAt: action == .stopRecording && request.isEdit
|
||||||
? Date().timeIntervalSince1970
|
? Date().timeIntervalSince1970
|
||||||
@@ -1908,7 +1928,11 @@ final class KeyboardFlowCoordinator {
|
|||||||
utteranceMode: .aiQuestion,
|
utteranceMode: .aiQuestion,
|
||||||
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
||||||
aiQuestionText: text,
|
aiQuestionText: text,
|
||||||
|
aiWebPageURL: currentUtteranceRequest?.aiWebPageURL,
|
||||||
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
|
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
|
||||||
|
managedRequestSource: currentUtteranceRequest?.managedRequestSource,
|
||||||
|
managedRequestPurpose: currentUtteranceRequest?.managedRequestPurpose,
|
||||||
|
managedOOBEFeature: currentUtteranceRequest?.managedOOBEFeature,
|
||||||
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
||||||
startDeadlineAt: currentStartDeadlineAt
|
startDeadlineAt: currentStartDeadlineAt
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class KeyboardTextInserter {
|
|||||||
private static let caretVerificationLimit = 80
|
private static let caretVerificationLimit = 80
|
||||||
|
|
||||||
private let state: KeyboardState
|
private let state: KeyboardState
|
||||||
private let insertText: (String) -> Void
|
private let insertText: (String, KeyboardTextInsertionSource) -> Void
|
||||||
private let deleteBackward: () -> Void
|
private let deleteBackward: () -> Void
|
||||||
private let contextBeforeInput: () -> String?
|
private let contextBeforeInput: () -> String?
|
||||||
private let fieldContextProvider: () -> FlowFieldContext?
|
private let fieldContextProvider: () -> FlowFieldContext?
|
||||||
@@ -41,7 +41,7 @@ final class KeyboardTextInserter {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
insertText: @escaping (String) -> Void,
|
insertText: @escaping (String, KeyboardTextInsertionSource) -> Void,
|
||||||
deleteBackward: @escaping () -> Void,
|
deleteBackward: @escaping () -> Void,
|
||||||
contextBeforeInput: @escaping () -> String?,
|
contextBeforeInput: @escaping () -> String?,
|
||||||
fieldContextProvider: @escaping () -> FlowFieldContext?,
|
fieldContextProvider: @escaping () -> FlowFieldContext?,
|
||||||
@@ -87,8 +87,16 @@ final class KeyboardTextInserter {
|
|||||||
insertion: trimmed
|
insertion: trimmed
|
||||||
)
|
)
|
||||||
let inserted = separator + trimmed
|
let inserted = separator + trimmed
|
||||||
insertText(inserted)
|
insertText(inserted, .voiceTranscription)
|
||||||
KeyboardSetupBridge.markVoiceInsertion()
|
KeyboardSetupBridge.markVoiceInsertion()
|
||||||
|
if delivery.polishWarning == nil,
|
||||||
|
let practice = state.oobePracticeSession,
|
||||||
|
practice.expectedFeature == .voiceInput {
|
||||||
|
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
|
||||||
|
sessionID: practice.sessionID,
|
||||||
|
feature: .voiceInput
|
||||||
|
)
|
||||||
|
}
|
||||||
state.noteUserDidInputText()
|
state.noteUserDidInputText()
|
||||||
recordLastInsertion(
|
recordLastInsertion(
|
||||||
inserted,
|
inserted,
|
||||||
@@ -120,7 +128,7 @@ final class KeyboardTextInserter {
|
|||||||
insertion: trimmed
|
insertion: trimmed
|
||||||
)
|
)
|
||||||
let inserted = separator + trimmed
|
let inserted = separator + trimmed
|
||||||
insertText(inserted)
|
insertText(inserted, .aiGenerated)
|
||||||
state.noteUserDidInputText()
|
state.noteUserDidInputText()
|
||||||
|
|
||||||
let mutation = HistoryMutation(
|
let mutation = HistoryMutation(
|
||||||
@@ -152,7 +160,7 @@ final class KeyboardTextInserter {
|
|||||||
guard !text.isEmpty else { return }
|
guard !text.isEmpty else { return }
|
||||||
// Verbatim on purpose — paste must reproduce exactly what was copied,
|
// Verbatim on purpose — paste must reproduce exactly what was copied,
|
||||||
// unlike dictation which needs word-boundary hygiene.
|
// unlike dictation which needs word-boundary hygiene.
|
||||||
insertText(text)
|
insertText(text, .pasteboard)
|
||||||
recordUndoableInsertion(text)
|
recordUndoableInsertion(text)
|
||||||
// The paste pushed any previous input away from the caret, so the
|
// The paste pushed any previous input away from the caret, so the
|
||||||
// "editable last input" hint no longer applies.
|
// "editable last input" hint no longer applies.
|
||||||
@@ -207,7 +215,7 @@ final class KeyboardTextInserter {
|
|||||||
state.redoAvailable = false
|
state.redoAvailable = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
insertText(text)
|
insertText(text, .redo)
|
||||||
recordUndoableInsertion(text)
|
recordUndoableInsertion(text)
|
||||||
OSGLog.keyboardExt.info("redo length=\(text.count, privacy: .public)")
|
OSGLog.keyboardExt.info("redo length=\(text.count, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -375,7 +383,7 @@ final class KeyboardTextInserter {
|
|||||||
let inserted = separator + result
|
let inserted = separator + result
|
||||||
transaction.appliedInsertedText = inserted
|
transaction.appliedInsertedText = inserted
|
||||||
PendingTextEditTransactionStore.save(transaction)
|
PendingTextEditTransactionStore.save(transaction)
|
||||||
insertText(inserted)
|
insertText(inserted, .editGenerated)
|
||||||
let verificationSuffix = String(inserted.suffix(80))
|
let verificationSuffix = String(inserted.suffix(80))
|
||||||
guard contextBeforeInput()?.hasSuffix(verificationSuffix) == true else {
|
guard contextBeforeInput()?.hasSuffix(verificationSuffix) == true else {
|
||||||
// Never blindly delete after a partial/opaque host insertion. The
|
// Never blindly delete after a partial/opaque host insertion. The
|
||||||
@@ -448,7 +456,7 @@ final class KeyboardTextInserter {
|
|||||||
|
|
||||||
switch transaction.deliveryMode {
|
switch transaction.deliveryMode {
|
||||||
case .replace:
|
case .replace:
|
||||||
insertText(transaction.beforeText)
|
insertText(transaction.beforeText, .editGenerated)
|
||||||
let restore = HistoryMutation(
|
let restore = HistoryMutation(
|
||||||
action: transaction.historyMutation.action == .append ? .delete : .restore,
|
action: transaction.historyMutation.action == .append ? .delete : .restore,
|
||||||
entryID: transaction.historyMutation.entryID,
|
entryID: transaction.historyMutation.entryID,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct AIKeyboardView: View {
|
|||||||
static let skillCellSpacing: CGFloat = 10
|
static let skillCellSpacing: CGFloat = 10
|
||||||
static let pageDotSize: CGFloat = 5
|
static let pageDotSize: CGFloat = 5
|
||||||
static let skillPaginationBottomInset: CGFloat = 6
|
static let skillPaginationBottomInset: CGFloat = 6
|
||||||
|
static let maximumSemanticSkills = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -50,6 +51,7 @@ struct AIKeyboardView: View {
|
|||||||
@ObservedObject var state: KeyboardState
|
@ObservedObject var state: KeyboardState
|
||||||
@ObservedObject var typing: TypingSessionController
|
@ObservedObject var typing: TypingSessionController
|
||||||
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
||||||
|
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
|
||||||
let onInsert: (String) -> Void
|
let onInsert: (String) -> Void
|
||||||
|
|
||||||
@AppStorage("keyboard.assistant.longPressCoachCount")
|
@AppStorage("keyboard.assistant.longPressCoachCount")
|
||||||
@@ -108,10 +110,13 @@ struct AIKeyboardView: View {
|
|||||||
selectedSkillPage = 0
|
selectedSkillPage = 0
|
||||||
resetCarousel()
|
resetCarousel()
|
||||||
}
|
}
|
||||||
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in
|
.onChange(of: state.clipboardSkillCatalog) { _, _ in
|
||||||
selectedSkillPage = 0
|
selectedSkillPage = 0
|
||||||
resetCarousel()
|
resetCarousel()
|
||||||
}
|
}
|
||||||
|
.onChange(of: semanticRanking.snapshot) { _, _ in
|
||||||
|
selectedSkillPage = 0
|
||||||
|
}
|
||||||
.onChange(of: state.skillTipText) { _, tip in
|
.onChange(of: state.skillTipText) { _, tip in
|
||||||
guard let tip, !tip.isEmpty else { return }
|
guard let tip, !tip.isEmpty else { return }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -292,8 +297,10 @@ struct AIKeyboardView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var contextArea: some View {
|
private var contextArea: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if state.isOnboardingPracticeActive, activeStatus == nil {
|
if showsOOBEClipboardSkill, activeStatus == nil {
|
||||||
Text(ExtL10n.string("keyboard.onboarding.practice.mic"))
|
clipboardSkillPager
|
||||||
|
} else if let onboardingPracticeHint, activeStatus == nil {
|
||||||
|
Text(onboardingPracticeHint)
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -532,7 +539,7 @@ struct AIKeyboardView: View {
|
|||||||
if skill.id == AIClipboardSkillCatalog.translateID {
|
if skill.id == AIClipboardSkillCatalog.translateID {
|
||||||
return AIClipboardSkillCatalog.translateButtonTitle(
|
return AIClipboardSkillCatalog.translateButtonTitle(
|
||||||
translationTargetLocaleId: state.translationTargetLocaleId,
|
translationTargetLocaleId: state.translationTargetLocaleId,
|
||||||
uiLanguage: AppGroupStore().uiLanguage
|
uiLanguage: state.uiLanguage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
@@ -550,6 +557,9 @@ struct AIKeyboardView: View {
|
|||||||
return assistantIsResting
|
return assistantIsResting
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
if showsOOBEClipboardSkill {
|
||||||
|
return assistantIsResting
|
||||||
|
}
|
||||||
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
|
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
|
||||||
guard !visibleClipboardSkills.isEmpty,
|
guard !visibleClipboardSkills.isEmpty,
|
||||||
let newest = clipboardHistory.newestEntry,
|
let newest = clipboardHistory.newestEntry,
|
||||||
@@ -568,12 +578,55 @@ struct AIKeyboardView: View {
|
|||||||
return preview
|
return preview
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
return AIClipboardSkillCatalog.visible(
|
if let expectedSkillID = oobeExpectedSkillID {
|
||||||
enabledIDs: state.enabledClipboardSkillIDs,
|
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
|
||||||
userCatalog: AppGroupStore().agentUserSkillCatalog
|
}
|
||||||
|
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
|
// MARK: - Primary actions
|
||||||
|
|
||||||
private var primaryActionRow: some View {
|
private var primaryActionRow: some View {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"onboarding.api.localReady.title" = "Local ASR is ready";
|
"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.";
|
"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.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 navigation */
|
||||||
"common.back" = "Back";
|
"common.back" = "Back";
|
||||||
@@ -316,9 +319,24 @@
|
|||||||
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
|
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
|
||||||
"keyboard.ai.error.requestFailed" = "AI response failed. 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.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
|
||||||
"keyboard.ai.skill.reply" = "Reply";
|
"keyboard.ai.skill.reply" = "Same-language reply";
|
||||||
"keyboard.ai.skill.summarize" = "Summarize";
|
"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.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.extractTodos" = "Tasks";
|
||||||
"keyboard.ai.skill.extractEvents" = "Events";
|
"keyboard.ai.skill.extractEvents" = "Events";
|
||||||
"keyboard.ai.skill.saveToNotes" = "Notes";
|
"keyboard.ai.skill.saveToNotes" = "Notes";
|
||||||
@@ -339,3 +357,6 @@
|
|||||||
"keyboard.ai.skill.openingMaps" = "Opening Maps…";
|
"keyboard.ai.skill.openingMaps" = "Opening Maps…";
|
||||||
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
|
"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
|
||||||
"keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut";
|
"keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut";
|
||||||
|
"keyboard.ai.skill.openLinkFailed" = "Couldn’t open this link";
|
||||||
|
"keyboard.ai.skill.callPhoneFailed" = "Couldn’t start this call";
|
||||||
|
"keyboard.ai.skill.createContactFailed" = "Couldn’t open the new contact form";
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"onboarding.api.localReady.title" = "本地识别已就绪";
|
"onboarding.api.localReady.title" = "本地识别已就绪";
|
||||||
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
|
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
|
||||||
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
|
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
|
||||||
|
"keyboard.onboarding.practice.translate" = "点击「翻译」,再插入生成的文字";
|
||||||
|
"keyboard.onboarding.practice.reply" = "点击「回复」,让 AI 起草可发送的回复";
|
||||||
|
"keyboard.onboarding.practice.askAI" = "长按麦克风,说出你想问的问题";
|
||||||
|
|
||||||
/* Common navigation */
|
/* Common navigation */
|
||||||
"common.back" = "返回";
|
"common.back" = "返回";
|
||||||
@@ -316,9 +319,24 @@
|
|||||||
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
|
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
|
||||||
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
|
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
|
||||||
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
|
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
|
||||||
"keyboard.ai.skill.reply" = "回复";
|
"keyboard.ai.skill.reply" = "原语言回复";
|
||||||
"keyboard.ai.skill.summarize" = "总结";
|
"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.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.extractTodos" = "待办";
|
||||||
"keyboard.ai.skill.extractEvents" = "日程";
|
"keyboard.ai.skill.extractEvents" = "日程";
|
||||||
"keyboard.ai.skill.saveToNotes" = "备忘录";
|
"keyboard.ai.skill.saveToNotes" = "备忘录";
|
||||||
@@ -339,3 +357,6 @@
|
|||||||
"keyboard.ai.skill.openingMaps" = "正在打开地图…";
|
"keyboard.ai.skill.openingMaps" = "正在打开地图…";
|
||||||
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
|
"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
|
||||||
"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
|
"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
|
import XCTest
|
||||||
|
|
||||||
final class AnalyticsExtensionPrivacyTests: XCTestCase {
|
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 {
|
func testKeyboardRuntimeUploadsWithoutAuthorizationOrSensitiveInputFields() async throws {
|
||||||
let databaseURL = try temporaryDatabaseURL()
|
let databaseURL = try temporaryDatabaseURL()
|
||||||
let network = ExtensionAnalyticsNetwork()
|
let network = ExtensionAnalyticsNetwork()
|
||||||
@@ -57,8 +89,10 @@ final class AnalyticsExtensionPrivacyTests: XCTestCase {
|
|||||||
let root = try XCTUnwrap(
|
let root = try XCTUnwrap(
|
||||||
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
||||||
)
|
)
|
||||||
|
XCTAssertEqual(Set(root.keys), ["installationId", "events"])
|
||||||
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
|
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
|
||||||
let event = try XCTUnwrap(eventObjects.first)
|
let event = try XCTUnwrap(eventObjects.first)
|
||||||
|
XCTAssertFalse(event.keys.contains("installationId"))
|
||||||
XCTAssertFalse(event.keys.contains("properties"))
|
XCTAssertFalse(event.keys.contains("properties"))
|
||||||
XCTAssertFalse(event.keys.contains("text"))
|
XCTAssertFalse(event.keys.contains("text"))
|
||||||
XCTAssertFalse(event.keys.contains("prompt"))
|
XCTAssertFalse(event.keys.contains("prompt"))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
// *don't* attempt that here — the State class is what we care about
|
// *don't* attempt that here — the State class is what we care about
|
||||||
// for correctness.
|
// for correctness.
|
||||||
|
|
||||||
|
import Combine
|
||||||
@testable import OSGKeyboardShared
|
@testable import OSGKeyboardShared
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@@ -144,6 +145,50 @@ final class KeyboardStateTests: XCTestCase {
|
|||||||
XCTAssertEqual(state.clipboardOverlay, .none)
|
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() {
|
func testReturnKeyRoleActionFillAndTitles() {
|
||||||
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
|
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
|
||||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.send.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,16 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
XCTAssertEqual(cloudScopes, [.polish, .assistant, .agent, .asr])
|
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 {
|
func testCreateGrantSendsAccountTokenButStoresOnlyGrant() async throws {
|
||||||
GatewayStub.shared.enqueue(
|
GatewayStub.shared.enqueue(
|
||||||
201,
|
201,
|
||||||
@@ -128,6 +138,40 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
XCTAssertEqual(try jsonBody(request)["refreshToken"] as? String, oldRefresh)
|
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 {
|
func testManagedClientMapsAllCapabilitiesHeadersBodyAndRequestIds() async throws {
|
||||||
let now = Date(timeIntervalSince1970: 3_000)
|
let now = Date(timeIntervalSince1970: 3_000)
|
||||||
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
||||||
@@ -187,6 +231,7 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
|
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
|
||||||
(.polish, .translation),
|
(.polish, .translation),
|
||||||
(.polish, .editLastInput),
|
(.polish, .editLastInput),
|
||||||
|
(.assistant, .currentInformationQuestion),
|
||||||
(.assistant, .clipboardTransform),
|
(.assistant, .clipboardTransform),
|
||||||
(.assistant, .customSkill)
|
(.assistant, .customSkill)
|
||||||
]
|
]
|
||||||
@@ -209,15 +254,62 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue })
|
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 {
|
func testManagedClientSerializesOOBERequestPurpose() async throws {
|
||||||
let now = Date(timeIntervalSince1970: 3_750)
|
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))
|
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
|
||||||
let client = ManagedLLMClient(
|
let client = ManagedLLMClient(
|
||||||
capability: .polish,
|
capability: .polish,
|
||||||
taskKind: .dictationPolish,
|
taskKind: .dictationPolish,
|
||||||
requestPurpose: .oobe,
|
requestPurpose: .oobe,
|
||||||
|
oobeFeature: .voiceInput,
|
||||||
grants: makeCoordinator(store: store, now: { now }),
|
grants: makeCoordinator(store: store, now: { now }),
|
||||||
|
oobeGrants: OOBEGatewayGrantCoordinator(
|
||||||
|
baseURL: baseURL,
|
||||||
|
store: oobeStore,
|
||||||
|
session: stubSession(),
|
||||||
|
now: { now },
|
||||||
|
practiceSession: { practice }
|
||||||
|
),
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
session: stubSession()
|
session: stubSession()
|
||||||
)
|
)
|
||||||
@@ -228,6 +320,107 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
let body = try jsonBody(request)
|
let body = try jsonBody(request)
|
||||||
XCTAssertEqual(body["taskKind"] as? String, "dictation_polish")
|
XCTAssertEqual(body["taskKind"] as? String, "dictation_polish")
|
||||||
XCTAssertEqual(body["requestPurpose"] as? String, "oobe")
|
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() {
|
func testManagedTaskKindWireValuesMatchServerContract() {
|
||||||
@@ -238,6 +431,7 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
"translation",
|
"translation",
|
||||||
"edit_last_input",
|
"edit_last_input",
|
||||||
"ai_question",
|
"ai_question",
|
||||||
|
"current_information_question",
|
||||||
"clipboard_transform",
|
"clipboard_transform",
|
||||||
"custom_skill",
|
"custom_skill",
|
||||||
"agent_planning"
|
"agent_planning"
|
||||||
@@ -337,6 +531,49 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
XCTAssertNil(remaining)
|
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 {
|
func testTransportTimeoutMapsToManagedTimeout() async {
|
||||||
let now = Date(timeIntervalSince1970: 6_500)
|
let now = Date(timeIntervalSince1970: 6_500)
|
||||||
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
||||||
@@ -412,7 +649,13 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
store: MemoryGrantStore,
|
store: MemoryGrantStore,
|
||||||
now: @escaping @Sendable () -> Date = Date.init
|
now: @escaping @Sendable () -> Date = Date.init
|
||||||
) -> GatewayGrantCoordinator {
|
) -> 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 {
|
private func stubSession() -> URLSession {
|
||||||
@@ -426,11 +669,12 @@ final class ManagedGatewayTests: XCTestCase {
|
|||||||
refreshToken: String = "refresh-token-value-12345678901234567890",
|
refreshToken: String = "refresh-token-value-12345678901234567890",
|
||||||
accessExpiresAt: Date = Date(timeIntervalSince1970: 20_000),
|
accessExpiresAt: Date = Date(timeIntervalSince1970: 20_000),
|
||||||
refreshExpiresAt: Date = Date(timeIntervalSince1970: 40_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 {
|
||||||
ManagedGatewayGrantCredentials(
|
ManagedGatewayGrantCredentials(
|
||||||
grantId: "11111111-1111-1111-1111-111111111111",
|
grantId: "11111111-1111-1111-1111-111111111111",
|
||||||
scopes: [.polish, .assistant, .agent],
|
scopes: scopes,
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
accessExpiresAt: accessExpiresAt,
|
accessExpiresAt: accessExpiresAt,
|
||||||
refreshToken: refreshToken,
|
refreshToken: refreshToken,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
// The single HTTP exit for account, auth, and integrity traffic.
|
// The single HTTP exit for account, auth, and integrity traffic.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(OSGKeyboardShared)
|
||||||
|
import OSGKeyboardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
public protocol AccountHTTPTransport: Sendable {
|
public protocol AccountHTTPTransport: Sendable {
|
||||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||||
@@ -36,6 +39,7 @@ public actor AccountAPIClient {
|
|||||||
case integrityChallenge
|
case integrityChallenge
|
||||||
case attest
|
case attest
|
||||||
case assert
|
case assert
|
||||||
|
case oobeGrant
|
||||||
|
|
||||||
var method: String {
|
var method: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -66,12 +70,15 @@ public actor AccountAPIClient {
|
|||||||
return "/v1/integrity/attest"
|
return "/v1/integrity/attest"
|
||||||
case .assert:
|
case .assert:
|
||||||
return "/v1/integrity/assert"
|
return "/v1/integrity/assert"
|
||||||
|
case .oobeGrant:
|
||||||
|
return "/v1/oobe/grants"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct RefreshOperation {
|
private struct RefreshOperation {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
|
let failedAccessToken: String
|
||||||
let task: Task<AccountSession, Error>
|
let task: Task<AccountSession, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +92,9 @@ public actor AccountAPIClient {
|
|||||||
private var cachedSession: AccountSession?
|
private var cachedSession: AccountSession?
|
||||||
private var didLoadSession = false
|
private var didLoadSession = false
|
||||||
private var refreshOperation: RefreshOperation?
|
private var refreshOperation: RefreshOperation?
|
||||||
|
private var invalidationContinuations: [
|
||||||
|
UUID: AsyncStream<AccountSessionInvalidation>.Continuation
|
||||||
|
] = [:]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
baseURL: URL = URL(string: "https://account.osglab.com")!,
|
baseURL: URL = URL(string: "https://account.osglab.com")!,
|
||||||
@@ -117,6 +127,18 @@ public actor AccountAPIClient {
|
|||||||
try await loadSessionIfNeeded()
|
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 {
|
public func accessTokenForAuthorizedRequest() async throws -> String {
|
||||||
try await sessionForRequest().accessToken
|
try await sessionForRequest().accessToken
|
||||||
}
|
}
|
||||||
@@ -184,7 +206,9 @@ public actor AccountAPIClient {
|
|||||||
)
|
)
|
||||||
let retryResponse = try await send(retryRequest)
|
let retryResponse = try await send(retryRequest)
|
||||||
if retryResponse.response.statusCode == 401 {
|
if retryResponse.response.statusCode == 401 {
|
||||||
try await clearSession()
|
try await invalidateSession(
|
||||||
|
ifAccessTokenMatches: replacement.accessToken
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return try validatedData(retryResponse)
|
return try validatedData(retryResponse)
|
||||||
}
|
}
|
||||||
@@ -273,6 +297,25 @@ public actor AccountAPIClient {
|
|||||||
return try decode(AppAssertionResponse.self, from: data).counter
|
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(
|
private func perform(
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
body: Data?,
|
body: Data?,
|
||||||
@@ -319,7 +362,9 @@ public actor AccountAPIClient {
|
|||||||
)
|
)
|
||||||
let retryResponse = try await send(retryRequest)
|
let retryResponse = try await send(retryRequest)
|
||||||
if retryResponse.response.statusCode == 401 {
|
if retryResponse.response.statusCode == 401 {
|
||||||
try await clearSession()
|
try await invalidateSession(
|
||||||
|
ifAccessTokenMatches: replacement.accessToken
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return try validatedData(retryResponse)
|
return try validatedData(retryResponse)
|
||||||
}
|
}
|
||||||
@@ -345,7 +390,9 @@ public actor AccountAPIClient {
|
|||||||
}
|
}
|
||||||
let nowSeconds = Int64(now().timeIntervalSince1970)
|
let nowSeconds = Int64(now().timeIntervalSince1970)
|
||||||
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
|
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
|
||||||
try await clearSession()
|
try await invalidateSession(
|
||||||
|
ifAccessTokenMatches: session.accessToken
|
||||||
|
)
|
||||||
throw AccountAPIError.unauthorized("The refresh token has expired.")
|
throw AccountAPIError.unauthorized("The refresh token has expired.")
|
||||||
}
|
}
|
||||||
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
|
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
|
||||||
@@ -367,6 +414,7 @@ public actor AccountAPIClient {
|
|||||||
|
|
||||||
let operation = RefreshOperation(
|
let operation = RefreshOperation(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
failedAccessToken: current.accessToken,
|
||||||
task: Task {
|
task: Task {
|
||||||
try await self.requestRefresh(using: current.refreshToken)
|
try await self.requestRefresh(using: current.refreshToken)
|
||||||
}
|
}
|
||||||
@@ -378,6 +426,18 @@ public actor AccountAPIClient {
|
|||||||
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
||||||
do {
|
do {
|
||||||
let replacement = try await operation.task.value
|
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)
|
try await replaceSession(with: replacement)
|
||||||
if refreshOperation?.id == operation.id {
|
if refreshOperation?.id == operation.id {
|
||||||
refreshOperation = nil
|
refreshOperation = nil
|
||||||
@@ -387,8 +447,14 @@ public actor AccountAPIClient {
|
|||||||
if refreshOperation?.id == operation.id {
|
if refreshOperation?.id == operation.id {
|
||||||
refreshOperation = nil
|
refreshOperation = nil
|
||||||
}
|
}
|
||||||
|
if let current = try? await loadSessionIfNeeded(),
|
||||||
|
current.accessToken != operation.failedAccessToken {
|
||||||
|
return current
|
||||||
|
}
|
||||||
if shouldClearSession(afterRefreshError: error) {
|
if shouldClearSession(afterRefreshError: error) {
|
||||||
try? await clearSession()
|
try? await invalidateSession(
|
||||||
|
ifAccessTokenMatches: operation.failedAccessToken
|
||||||
|
)
|
||||||
}
|
}
|
||||||
throw error
|
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(
|
private func makeRequest(
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
body: Data?,
|
body: Data?,
|
||||||
@@ -564,6 +654,29 @@ public actor AccountAPIClient {
|
|||||||
throw AccountAPIError.decoding
|
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 {
|
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 {
|
public enum AppAttestChallengePurpose: String, Codable, Sendable {
|
||||||
case attestation
|
case attestation
|
||||||
case assertion
|
case assertion
|
||||||
@@ -205,6 +230,10 @@ public protocol AppAttestKeyStateStoring: Sendable {
|
|||||||
func clearAppAttestKeyState() async throws
|
func clearAppAttestKeyState() async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public protocol OOBEInstallationIDStoring: Sendable {
|
||||||
|
func oobeInstallationID() async throws -> UUID
|
||||||
|
}
|
||||||
|
|
||||||
public enum AccountAPIError: Error, Equatable, Sendable {
|
public enum AccountAPIError: Error, Equatable, Sendable {
|
||||||
case invalidRequest(String)
|
case invalidRequest(String)
|
||||||
case unauthorized(String)
|
case unauthorized(String)
|
||||||
@@ -222,6 +251,10 @@ public enum AccountAPIError: Error, Equatable, Sendable {
|
|||||||
case integrityUnavailable
|
case integrityUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum AccountSessionInvalidation: Sendable {
|
||||||
|
case expired
|
||||||
|
}
|
||||||
|
|
||||||
extension AccountAPIError: LocalizedError {
|
extension AccountAPIError: LocalizedError {
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
|
|||||||
@@ -128,6 +128,35 @@ public enum AppAttestCanonicalPayload {
|
|||||||
return data
|
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 {
|
static func challengeHash(_ challenge: String) throws -> Data {
|
||||||
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
|
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
|
||||||
throw AccountAPIError.invalidResponse
|
throw AccountAPIError.invalidResponse
|
||||||
@@ -187,6 +216,15 @@ public actor DeviceIntegrityCoordinator {
|
|||||||
try? await keyStateStore.clearAppAttestKeyState()
|
try? await keyStateStore.clearAppAttestKeyState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func makeOOBEGrantRequest(
|
||||||
|
installationID: UUID
|
||||||
|
) async throws -> OOBEGrantRequest {
|
||||||
|
try await makeOOBEGrantRequest(
|
||||||
|
installationID: installationID,
|
||||||
|
allowsKeyRecovery: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func optionalDeviceCheckToken() async -> String? {
|
private func optionalDeviceCheckToken() async -> String? {
|
||||||
try? await makeDeviceCheckToken()
|
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(
|
private func registeredKeyId(
|
||||||
allowsKeyRecovery: Bool = true
|
allowsKeyRecovery: Bool = true
|
||||||
) async throws -> String {
|
) 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 {
|
private enum Account {
|
||||||
static let session = "account.session"
|
static let session = "account.session"
|
||||||
static let appAttestKeyState = "integrity.app-attest-key-state"
|
static let appAttestKeyState = "integrity.app-attest-key-state"
|
||||||
|
static let oobeInstallationID = "oobe.installation-id"
|
||||||
}
|
}
|
||||||
|
|
||||||
let descriptor: HostPrivateAccountKeychainDescriptor
|
let descriptor: HostPrivateAccountKeychainDescriptor
|
||||||
@@ -78,6 +82,15 @@ public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateS
|
|||||||
try delete(account: Account.appAttestKeyState)
|
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? {
|
private func read<Value: Decodable>(_ type: Value.Type, account: String) throws -> Value? {
|
||||||
var query = baseQuery(account: account)
|
var query = baseQuery(account: account)
|
||||||
query[kSecReturnData as String] = true
|
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(
|
return MacDictationResult(
|
||||||
text: polished,
|
text: polished,
|
||||||
|
prePolishText: postASR,
|
||||||
|
polishStyleID: outcome.polishStyleID,
|
||||||
|
polishStylePrompt: outcome.polishStylePrompt,
|
||||||
polishWarning: outcome.qualityDegraded
|
polishWarning: outcome.qualityDegraded
|
||||||
? MacL10n.string("flow.warning.polishDegradedQuality")
|
? MacL10n.string("flow.warning.polishDegradedQuality")
|
||||||
: nil,
|
: nil,
|
||||||
@@ -276,6 +279,9 @@ enum MacDictationPipeline {
|
|||||||
)
|
)
|
||||||
return MacDictationResult(
|
return MacDictationResult(
|
||||||
text: delivery.text,
|
text: delivery.text,
|
||||||
|
prePolishText: postASR,
|
||||||
|
polishStyleID: nil,
|
||||||
|
polishStylePrompt: nil,
|
||||||
polishWarning: delivery.polishWarning,
|
polishWarning: delivery.polishWarning,
|
||||||
chunkWarning: nil
|
chunkWarning: nil
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import Foundation
|
|||||||
|
|
||||||
struct MacDictationResult: Sendable, Equatable {
|
struct MacDictationResult: Sendable, Equatable {
|
||||||
let text: String
|
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.
|
/// Shown when DeepSeek / cloud polish failed but raw ASR was delivered.
|
||||||
let polishWarning: String?
|
let polishWarning: String?
|
||||||
/// Non-fatal per-chunk ASR issues from long utterance chunking.
|
/// Non-fatal per-chunk ASR issues from long utterance chunking.
|
||||||
|
|||||||
@@ -423,7 +423,15 @@ final class MacDictationViewModel: ObservableObject {
|
|||||||
targetApplication: targetApplication
|
targetApplication: targetApplication
|
||||||
)
|
)
|
||||||
self.recordUsage(for: result.text)
|
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.appendToOverview(result.text)
|
||||||
self.statusMessage = self.statusAfterDelivery(
|
self.statusMessage = self.statusAfterDelivery(
|
||||||
pasted: pasted,
|
pasted: pasted,
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MacPolishStylesView: View {
|
struct MacPolishStylesView: View {
|
||||||
@ObservedObject var viewModel: MacDictationViewModel
|
@ObservedObject var viewModel: MacDictationViewModel
|
||||||
|
@ObservedObject private var history = SpeechHistoryStore.shared
|
||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
|
|
||||||
@State private var editingPack: PolishStylePack?
|
@State private var editingPack: PolishStylePack?
|
||||||
@State private var viewingPack: PolishStylePack?
|
@State private var viewingPack: PolishStylePack?
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var isGeneratingLearnedStyle = false
|
||||||
|
|
||||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||||
private var store: AppGroupStore { AppGroupStore(defaults: viewModel.defaults) }
|
private var store: AppGroupStore { AppGroupStore(defaults: viewModel.defaults) }
|
||||||
@@ -59,6 +61,7 @@ struct MacPolishStylesView: View {
|
|||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||||
|
styleLearningCard
|
||||||
styleSection(
|
styleSection(
|
||||||
title: MacL10n.string("mac.styles.builtin", language: lang),
|
title: MacL10n.string("mac.styles.builtin", language: lang),
|
||||||
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
|
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 {
|
private func styleSection(title: String, packs: [PolishStylePack]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
Text(title)
|
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 {
|
private func subtitle(for pack: PolishStylePack) -> String {
|
||||||
if pack.kind == .user {
|
if pack.kind == .user {
|
||||||
return MacL10n.string("mac.styles.customDescription", language: lang)
|
return MacL10n.string("mac.styles.customDescription", language: lang)
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ public enum AppGroup {
|
|||||||
/// The suite alone can appear to open while the container is still `(null)`
|
/// The suite alone can appear to open while the container is still `(null)`
|
||||||
/// when provisioning is misconfigured — that case produces the
|
/// when provisioning is misconfigured — that case produces the
|
||||||
/// `CFPrefsPlistSource … Container: (null)` console warning.
|
/// `CFPrefsPlistSource … Container: (null)` console warning.
|
||||||
public static let isAvailable: Bool = {
|
public static var isAvailable: Bool {
|
||||||
guard UserDefaults(suiteName: identifier) != nil else { return false }
|
guard UserDefaults(suiteName: identifier) != nil else { return false }
|
||||||
return FileManager.default.containerURL(
|
return FileManager.default.containerURL(
|
||||||
forSecurityApplicationGroupIdentifier: identifier
|
forSecurityApplicationGroupIdentifier: identifier
|
||||||
) != nil
|
) != nil
|
||||||
}()
|
}
|
||||||
|
|
||||||
/// On-disk container path (or `"nil"`) for diagnostics. When this reads
|
/// On-disk container path (or `"nil"`) for diagnostics. When this reads
|
||||||
/// `nil` in a process, that process cannot share App Group state — the
|
/// `nil` in a process, that process cannot share App Group state — the
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ public protocol ConfigurationStore: Sendable {
|
|||||||
|
|
||||||
func makeClient(
|
func makeClient(
|
||||||
taskKind: ManagedGatewayTaskKind?,
|
taskKind: ManagedGatewayTaskKind?,
|
||||||
requestPurpose: ManagedGatewayRequestPurpose?
|
requestPurpose: ManagedGatewayRequestPurpose?,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature?
|
||||||
) -> LLMClient
|
) -> LLMClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +52,21 @@ public extension ConfigurationStore {
|
|||||||
var credentialSource: CredentialSource { .byok }
|
var credentialSource: CredentialSource { .byok }
|
||||||
|
|
||||||
func makeClient() -> LLMClient {
|
func makeClient() -> LLMClient {
|
||||||
makeClient(taskKind: nil, requestPurpose: nil)
|
makeClient(taskKind: nil, requestPurpose: nil, oobeFeature: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
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(
|
public func makeClient(
|
||||||
taskKind: ManagedGatewayTaskKind?,
|
taskKind: ManagedGatewayTaskKind?,
|
||||||
requestPurpose: ManagedGatewayRequestPurpose?
|
requestPurpose: ManagedGatewayRequestPurpose?,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature?
|
||||||
) -> LLMClient {
|
) -> LLMClient {
|
||||||
if credentialSource == .managed {
|
if credentialSource == .managed || requestPurpose == .oobe {
|
||||||
return ManagedLLMClient(
|
return ManagedLLMClient(
|
||||||
capability: .polish,
|
capability: .polish,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
requestPurpose: requestPurpose,
|
requestPurpose: requestPurpose,
|
||||||
|
oobeFeature: oobeFeature,
|
||||||
grants: GatewayGrantCoordinator()
|
grants: GatewayGrantCoordinator()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ public struct AnalyticsRepositoryConfiguration: Sendable {
|
|||||||
maximumEventCount: Int = 10_000,
|
maximumEventCount: Int = 10_000,
|
||||||
maximumStoredBytes: Int = 5 * 1_024 * 1_024,
|
maximumStoredBytes: Int = 5 * 1_024 * 1_024,
|
||||||
eventRetention: TimeInterval = 34 * 24 * 60 * 60,
|
eventRetention: TimeInterval = 34 * 24 * 60 * 60,
|
||||||
busyTimeoutMilliseconds: Int32 = 2_000
|
busyTimeoutMilliseconds: Int32 = 250
|
||||||
) {
|
) {
|
||||||
self.databaseURL = databaseURL
|
self.databaseURL = databaseURL
|
||||||
self.maximumEventCount = max(1, maximumEventCount)
|
self.maximumEventCount = max(1, maximumEventCount)
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ public enum AnalyticsModelError: Error, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||||
public let installationId: UUID
|
|
||||||
public let clientEventId: UUID
|
public let clientEventId: UUID
|
||||||
public let eventType: AnalyticsEventType
|
public let eventType: AnalyticsEventType
|
||||||
public let occurredAt: Date
|
public let occurredAt: Date
|
||||||
@@ -103,7 +102,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
|||||||
public let durationBucket: AnalyticsDurationBucket?
|
public let durationBucket: AnalyticsDurationBucket?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
installationId: UUID,
|
|
||||||
clientEventId: UUID,
|
clientEventId: UUID,
|
||||||
eventType: AnalyticsEventType,
|
eventType: AnalyticsEventType,
|
||||||
occurredAt: Date,
|
occurredAt: Date,
|
||||||
@@ -121,7 +119,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
|||||||
throw AnalyticsModelError.invalidVersion
|
throw AnalyticsModelError.invalidVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
self.installationId = installationId
|
|
||||||
self.clientEventId = clientEventId
|
self.clientEventId = clientEventId
|
||||||
self.eventType = eventType
|
self.eventType = eventType
|
||||||
self.occurredAt = occurredAt
|
self.occurredAt = occurredAt
|
||||||
@@ -140,7 +137,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||||
case installationId
|
|
||||||
case clientEventId
|
case clientEventId
|
||||||
case eventType
|
case eventType
|
||||||
case occurredAt
|
case occurredAt
|
||||||
@@ -160,7 +156,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
|||||||
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||||
)
|
)
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
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 clientEventId = try container.decode(UUID.self, forKey: .clientEventId)
|
||||||
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
|
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
|
||||||
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
|
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
|
||||||
@@ -172,7 +167,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
try self.init(
|
try self.init(
|
||||||
installationId: installationId,
|
|
||||||
clientEventId: clientEventId,
|
clientEventId: clientEventId,
|
||||||
eventType: eventType,
|
eventType: eventType,
|
||||||
occurredAt: occurredAt,
|
occurredAt: occurredAt,
|
||||||
@@ -204,7 +198,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
|||||||
throw AnalyticsModelError.invalidDimensions(eventType)
|
throw AnalyticsModelError.invalidDimensions(eventType)
|
||||||
}
|
}
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try container.encode(installationId, forKey: .installationId)
|
|
||||||
try container.encode(clientEventId, forKey: .clientEventId)
|
try container.encode(clientEventId, forKey: .clientEventId)
|
||||||
try container.encode(eventType, forKey: .eventType)
|
try container.encode(eventType, forKey: .eventType)
|
||||||
try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt)
|
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 struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
|
||||||
|
public let installationId: UUID
|
||||||
public let events: [AnalyticsEvent]
|
public let events: [AnalyticsEvent]
|
||||||
|
|
||||||
public init(events: [AnalyticsEvent]) {
|
public init(installationId: UUID, events: [AnalyticsEvent]) {
|
||||||
|
self.installationId = installationId
|
||||||
self.events = events
|
self.events = events
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||||
|
case installationId
|
||||||
case events
|
case events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +249,7 @@ public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
|
|||||||
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||||
)
|
)
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
installationId = try container.decode(UUID.self, forKey: .installationId)
|
||||||
events = try container.decode([AnalyticsEvent].self, forKey: .events)
|
events = try container.decode([AnalyticsEvent].self, forKey: .events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +332,7 @@ private extension AnalyticsEventType {
|
|||||||
switch self {
|
switch self {
|
||||||
case .firstOpen, .inviteOpened:
|
case .firstOpen, .inviteOpened:
|
||||||
return [
|
return [
|
||||||
|
.none,
|
||||||
DimensionSet(
|
DimensionSet(
|
||||||
acquisitionChannel: true,
|
acquisitionChannel: true,
|
||||||
feature: false,
|
feature: false,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ struct AnalyticsLeasedEvent: Sendable {
|
|||||||
|
|
||||||
struct AnalyticsLeasedBatch: Sendable {
|
struct AnalyticsLeasedBatch: Sendable {
|
||||||
let leaseID: String
|
let leaseID: String
|
||||||
|
let installationID: UUID
|
||||||
let events: [AnalyticsLeasedEvent]
|
let events: [AnalyticsLeasedEvent]
|
||||||
let body: Data
|
let body: Data
|
||||||
}
|
}
|
||||||
@@ -96,6 +97,7 @@ public actor AnalyticsRepository {
|
|||||||
private let clock: any AnalyticsWallClock
|
private let clock: any AnalyticsWallClock
|
||||||
private let uuidGenerator: any AnalyticsUUIDGenerating
|
private let uuidGenerator: any AnalyticsUUIDGenerating
|
||||||
private var database: SQLiteDatabase?
|
private var database: SQLiteDatabase?
|
||||||
|
private var isDatabaseAccessSuspended = false
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
configuration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
|
configuration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
|
||||||
@@ -107,6 +109,19 @@ public actor AnalyticsRepository {
|
|||||||
self.uuidGenerator = uuidGenerator
|
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.
|
/// The host calls this only after cold-start attribution has been resolved.
|
||||||
/// Installation identity, FIRST_OPEN and its marker share one transaction.
|
/// Installation identity, FIRST_OPEN and its marker share one transaction.
|
||||||
public func prepare(
|
public func prepare(
|
||||||
@@ -127,7 +142,6 @@ public actor AnalyticsRepository {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let event = try AnalyticsEvent(
|
let event = try AnalyticsEvent(
|
||||||
installationId: installationID,
|
|
||||||
clientEventId: uuidGenerator.makeUUID(),
|
clientEventId: uuidGenerator.makeUUID(),
|
||||||
eventType: .firstOpen,
|
eventType: .firstOpen,
|
||||||
occurredAt: clock.now(),
|
occurredAt: clock.now(),
|
||||||
@@ -136,7 +150,11 @@ public actor AnalyticsRepository {
|
|||||||
osVersion: context.environment.osVersion,
|
osVersion: context.environment.osVersion,
|
||||||
acquisitionChannel: firstOpenAcquisitionChannel
|
acquisitionChannel: firstOpenAcquisitionChannel
|
||||||
)
|
)
|
||||||
try insert(event, database: database)
|
try insert(
|
||||||
|
event,
|
||||||
|
installationID: installationID,
|
||||||
|
database: database
|
||||||
|
)
|
||||||
try setMetadata(
|
try setMetadata(
|
||||||
"1",
|
"1",
|
||||||
for: MetadataKey.firstOpenRecorded,
|
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
|
/// Read-only diagnostics for tests and support tooling. Event payload content
|
||||||
/// is deliberately excluded from this public snapshot.
|
/// is deliberately excluded from this public snapshot.
|
||||||
public func debugSnapshot() -> AnalyticsRepositoryDebugSnapshot {
|
public func debugSnapshot() -> AnalyticsRepositoryDebugSnapshot {
|
||||||
@@ -341,7 +374,6 @@ public actor AnalyticsRepository {
|
|||||||
guard try isEnabled(database) else { return }
|
guard try isEnabled(database) else { return }
|
||||||
let installationID = try ensureInstallation(database)
|
let installationID = try ensureInstallation(database)
|
||||||
let event = try AnalyticsEvent(
|
let event = try AnalyticsEvent(
|
||||||
installationId: installationID,
|
|
||||||
clientEventId: uuidGenerator.makeUUID(),
|
clientEventId: uuidGenerator.makeUUID(),
|
||||||
eventType: eventType,
|
eventType: eventType,
|
||||||
occurredAt: clock.now(),
|
occurredAt: clock.now(),
|
||||||
@@ -354,7 +386,11 @@ public actor AnalyticsRepository {
|
|||||||
failureCategory: dimensions.failureCategory,
|
failureCategory: dimensions.failureCategory,
|
||||||
durationBucket: dimensions.durationBucket
|
durationBucket: dimensions.durationBucket
|
||||||
)
|
)
|
||||||
try insert(event, database: database)
|
try insert(
|
||||||
|
event,
|
||||||
|
installationID: installationID,
|
||||||
|
database: database
|
||||||
|
)
|
||||||
try enforceStoragePolicy(database)
|
try enforceStoragePolicy(database)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +424,6 @@ public actor AnalyticsRepository {
|
|||||||
|
|
||||||
let installationID = try ensureInstallation(database)
|
let installationID = try ensureInstallation(database)
|
||||||
let event = try AnalyticsEvent(
|
let event = try AnalyticsEvent(
|
||||||
installationId: installationID,
|
|
||||||
clientEventId: uuidGenerator.makeUUID(),
|
clientEventId: uuidGenerator.makeUUID(),
|
||||||
eventType: .sessionStarted,
|
eventType: .sessionStarted,
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
@@ -396,7 +431,11 @@ public actor AnalyticsRepository {
|
|||||||
appVersion: context.environment.appVersion,
|
appVersion: context.environment.appVersion,
|
||||||
osVersion: context.environment.osVersion
|
osVersion: context.environment.osVersion
|
||||||
)
|
)
|
||||||
try insert(event, database: database)
|
try insert(
|
||||||
|
event,
|
||||||
|
installationID: installationID,
|
||||||
|
database: database
|
||||||
|
)
|
||||||
try setMetadata(
|
try setMetadata(
|
||||||
String(now.timeIntervalSince1970),
|
String(now.timeIntervalSince1970),
|
||||||
for: key,
|
for: key,
|
||||||
@@ -427,19 +466,42 @@ public actor AnalyticsRepository {
|
|||||||
try releaseExpiredEventLeases(now: now, database: database)
|
try releaseExpiredEventLeases(now: now, database: database)
|
||||||
try deleteExpiredEvents(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(
|
let candidates = try database.query(
|
||||||
"""
|
"""
|
||||||
SELECT id, payload, attempt_count
|
SELECT id, payload, attempt_count
|
||||||
FROM pending_events
|
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
|
ORDER BY priority DESC, created_at ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
bindings: [.double(now), .int64(Int64(upload.maximumBatchCount))]
|
bindings: [
|
||||||
|
.text(installationText),
|
||||||
|
.double(now),
|
||||||
|
.int64(Int64(upload.maximumBatchCount))
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
var selected: [AnalyticsLeasedEvent] = []
|
var selected: [AnalyticsLeasedEvent] = []
|
||||||
var bodySize = Self.emptyRequestBody.count
|
var bodySize = Self.emptyRequestBody(
|
||||||
|
installationID: installationID
|
||||||
|
).count
|
||||||
for row in candidates {
|
for row in candidates {
|
||||||
guard let rowID = row.int64(at: 0),
|
guard let rowID = row.int64(at: 0),
|
||||||
let payload = row.data(at: 1),
|
let payload = row.data(at: 1),
|
||||||
@@ -490,8 +552,12 @@ public actor AnalyticsRepository {
|
|||||||
}
|
}
|
||||||
return AnalyticsLeasedBatch(
|
return AnalyticsLeasedBatch(
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
|
installationID: installationID,
|
||||||
events: selected,
|
events: selected,
|
||||||
body: Self.requestBody(for: selected)
|
body: Self.requestBody(
|
||||||
|
for: selected,
|
||||||
|
installationID: installationID
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -682,18 +748,20 @@ public actor AnalyticsRepository {
|
|||||||
|
|
||||||
private func insert(
|
private func insert(
|
||||||
_ event: AnalyticsEvent,
|
_ event: AnalyticsEvent,
|
||||||
|
installationID: UUID,
|
||||||
database: SQLiteDatabase
|
database: SQLiteDatabase
|
||||||
) throws {
|
) throws {
|
||||||
let payload = try AnalyticsCanonicalJSON.encode(event)
|
let payload = try AnalyticsCanonicalJSON.encode(event)
|
||||||
try database.execute(
|
try database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO pending_events (
|
INSERT OR IGNORE INTO pending_events (
|
||||||
client_event_id, event_type, occurred_at, surface, payload,
|
installation_id, client_event_id, event_type, occurred_at,
|
||||||
payload_size, priority, lease_id, lease_expires_at,
|
surface, payload, payload_size, priority, lease_id, lease_expires_at,
|
||||||
attempt_count, next_attempt_at, created_at
|
attempt_count, next_attempt_at, created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
|
||||||
""",
|
""",
|
||||||
bindings: [
|
bindings: [
|
||||||
|
.text(installationID.uuidString.lowercased()),
|
||||||
.text(event.clientEventId.uuidString.lowercased()),
|
.text(event.clientEventId.uuidString.lowercased()),
|
||||||
.text(event.eventType.rawValue),
|
.text(event.eventType.rawValue),
|
||||||
.double(event.occurredAt.timeIntervalSince1970),
|
.double(event.occurredAt.timeIntervalSince1970),
|
||||||
@@ -873,11 +941,12 @@ public actor AnalyticsRepository {
|
|||||||
try database.execute(
|
try database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO quarantined_events (
|
INSERT INTO quarantined_events (
|
||||||
client_event_id, event_type, occurred_at, surface, payload,
|
installation_id, client_event_id, event_type, occurred_at,
|
||||||
payload_size, attempt_count, reason, quarantined_at
|
surface, payload, payload_size, attempt_count, reason,
|
||||||
|
quarantined_at
|
||||||
)
|
)
|
||||||
SELECT client_event_id, event_type, occurred_at, surface, payload,
|
SELECT installation_id, client_event_id, event_type, occurred_at,
|
||||||
payload_size, attempt_count, ?, ?
|
surface, payload, payload_size, attempt_count, ?, ?
|
||||||
FROM pending_events
|
FROM pending_events
|
||||||
WHERE id = ?\(leaseClause)
|
WHERE id = ?\(leaseClause)
|
||||||
""",
|
""",
|
||||||
@@ -893,10 +962,18 @@ public actor AnalyticsRepository {
|
|||||||
try enforceStoragePolicy(database)
|
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 {
|
private static func requestBody(
|
||||||
var body = Data(#"{"events":["#.utf8)
|
for events: [AnalyticsLeasedEvent],
|
||||||
|
installationID: UUID
|
||||||
|
) -> Data {
|
||||||
|
var body = Data(
|
||||||
|
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
|
||||||
|
.utf8
|
||||||
|
)
|
||||||
for index in events.indices {
|
for index in events.indices {
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
body.append(UInt8(ascii: ","))
|
body.append(UInt8(ascii: ","))
|
||||||
@@ -926,6 +1003,7 @@ public actor AnalyticsRepository {
|
|||||||
// MARK: - Metadata and setup
|
// MARK: - Metadata and setup
|
||||||
|
|
||||||
private func openDatabaseIfNeeded() -> SQLiteDatabase? {
|
private func openDatabaseIfNeeded() -> SQLiteDatabase? {
|
||||||
|
guard !isDatabaseAccessSuspended else { return nil }
|
||||||
if let database {
|
if let database {
|
||||||
return database
|
return database
|
||||||
}
|
}
|
||||||
@@ -952,7 +1030,7 @@ public actor AnalyticsRepository {
|
|||||||
|
|
||||||
private func migrate(_ database: SQLiteDatabase) throws {
|
private func migrate(_ database: SQLiteDatabase) throws {
|
||||||
let version = try database.scalarInt64("PRAGMA user_version") ?? 0
|
let version = try database.scalarInt64("PRAGMA user_version") ?? 0
|
||||||
guard version <= 1 else {
|
guard version <= 2 else {
|
||||||
throw SQLiteStoreError(
|
throw SQLiteStoreError(
|
||||||
code: SQLITE_MISMATCH,
|
code: SQLITE_MISMATCH,
|
||||||
category: .schema,
|
category: .schema,
|
||||||
@@ -969,10 +1047,42 @@ public actor AnalyticsRepository {
|
|||||||
) WITHOUT ROWID
|
) 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(
|
||||||
|
"DELETE FROM metadata WHERE key IN (?, ?, ?)",
|
||||||
|
bindings: [
|
||||||
|
.text(MetadataKey.firstOpenRecorded),
|
||||||
|
.text(MetadataKey.uploadLeaseOwner),
|
||||||
|
.text(MetadataKey.uploadLeaseExpiresAt)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
try database.execute("PRAGMA user_version = 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createCurrentQueueSchema(_ database: SQLiteDatabase) throws {
|
||||||
try database.execute(
|
try database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS pending_events (
|
CREATE TABLE IF NOT EXISTS pending_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
installation_id TEXT NOT NULL,
|
||||||
client_event_id TEXT UNIQUE NOT NULL,
|
client_event_id TEXT UNIQUE NOT NULL,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
occurred_at REAL NOT NULL,
|
occurred_at REAL NOT NULL,
|
||||||
@@ -991,7 +1101,9 @@ public actor AnalyticsRepository {
|
|||||||
try database.execute(
|
try database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS pending_events_ready
|
CREATE INDEX IF NOT EXISTS pending_events_ready
|
||||||
ON pending_events(next_attempt_at, lease_id, priority, created_at)
|
ON pending_events(
|
||||||
|
installation_id, next_attempt_at, lease_id, priority, created_at
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
try database.execute(
|
try database.execute(
|
||||||
@@ -1004,6 +1116,7 @@ public actor AnalyticsRepository {
|
|||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS quarantined_events (
|
CREATE TABLE IF NOT EXISTS quarantined_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
installation_id TEXT NOT NULL,
|
||||||
client_event_id TEXT UNIQUE NOT NULL,
|
client_event_id TEXT UNIQUE NOT NULL,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
occurred_at REAL NOT NULL,
|
occurred_at REAL NOT NULL,
|
||||||
@@ -1016,9 +1129,6 @@ public actor AnalyticsRepository {
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
try database.execute("PRAGMA user_version = 1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyFileProtection(to databaseURL: URL) {
|
private func applyFileProtection(to databaseURL: URL) {
|
||||||
@@ -1146,7 +1256,7 @@ private struct SQLiteStoreError: Error, Sendable {
|
|||||||
let operation: String
|
let operation: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SQLiteBinding {
|
enum SQLiteBinding {
|
||||||
case null
|
case null
|
||||||
case int64(Int64)
|
case int64(Int64)
|
||||||
case double(Double)
|
case double(Double)
|
||||||
@@ -1154,7 +1264,7 @@ private enum SQLiteBinding {
|
|||||||
case blob(Data)
|
case blob(Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SQLiteRow {
|
struct SQLiteRow {
|
||||||
private let values: [SQLiteValue]
|
private let values: [SQLiteValue]
|
||||||
|
|
||||||
init(statement: OpaquePointer) {
|
init(statement: OpaquePointer) {
|
||||||
@@ -1228,7 +1338,7 @@ private enum SQLiteValue {
|
|||||||
case blob(Data)
|
case blob(Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class SQLiteDatabase {
|
final class SQLiteDatabase {
|
||||||
private static let transient = unsafeBitCast(
|
private static let transient = unsafeBitCast(
|
||||||
-1,
|
-1,
|
||||||
to: sqlite3_destructor_type.self
|
to: sqlite3_destructor_type.self
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
/// Performs a bounded drain. The default keeps extension execution time and
|
/// Performs a bounded drain. The default keeps extension execution time and
|
||||||
/// memory predictable while allowing a host app to request more batches.
|
/// memory predictable while allowing a host app to request more batches.
|
||||||
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
|
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
|
||||||
guard !uploadInProgress else { return }
|
guard !uploadInProgress, !Task.isCancelled else { return }
|
||||||
uploadInProgress = true
|
uploadInProgress = true
|
||||||
defer { uploadInProgress = false }
|
defer { uploadInProgress = false }
|
||||||
|
|
||||||
@@ -95,18 +95,35 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
|
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
|
||||||
let batchLimit = max(1, maximumBatches)
|
let batchLimit = max(1, maximumBatches)
|
||||||
for _ in 0..<batchLimit {
|
for _ in 0..<batchLimit {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
guard let batch = await repository.leaseBatch(
|
guard let batch = await repository.leaseBatch(
|
||||||
ownerID: ownerID,
|
ownerID: ownerID,
|
||||||
configuration: configuration
|
configuration: configuration
|
||||||
) else {
|
) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
await releaseForCancellation(
|
||||||
|
events: batch.events,
|
||||||
|
leaseID: batch.leaseID,
|
||||||
|
ownerID: ownerID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var authorization = AuthorizationState()
|
var authorization = AuthorizationState()
|
||||||
if let bearerProvider {
|
if let bearerProvider {
|
||||||
do {
|
do {
|
||||||
authorization.token = try await bearerProvider.bearerToken()
|
authorization.token = try await bearerProvider.bearerToken()
|
||||||
} catch {
|
} catch {
|
||||||
|
if Task.isCancelled {
|
||||||
|
await releaseForCancellation(
|
||||||
|
events: batch.events,
|
||||||
|
leaseID: batch.leaseID,
|
||||||
|
ownerID: ownerID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
await scheduleRetry(
|
await scheduleRetry(
|
||||||
events: batch.events,
|
events: batch.events,
|
||||||
leaseID: batch.leaseID,
|
leaseID: batch.leaseID,
|
||||||
@@ -120,21 +137,28 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
|
|
||||||
await process(
|
await process(
|
||||||
events: batch.events,
|
events: batch.events,
|
||||||
|
installationID: batch.installationID,
|
||||||
leaseID: batch.leaseID,
|
leaseID: batch.leaseID,
|
||||||
ownerID: ownerID,
|
ownerID: ownerID,
|
||||||
authorization: &authorization
|
authorization: &authorization
|
||||||
)
|
)
|
||||||
await repository.releaseGlobalLease(ownerID: ownerID)
|
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func process(
|
private func process(
|
||||||
events: [AnalyticsLeasedEvent],
|
events: [AnalyticsLeasedEvent],
|
||||||
|
installationID: UUID,
|
||||||
leaseID: String,
|
leaseID: String,
|
||||||
ownerID: String,
|
ownerID: String,
|
||||||
authorization: inout AuthorizationState
|
authorization: inout AuthorizationState
|
||||||
) async {
|
) async {
|
||||||
guard !events.isEmpty else { return }
|
guard !events.isEmpty else { return }
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
await repository.releaseEvents(events, leaseID: leaseID)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard await renewLease(
|
guard await renewLease(
|
||||||
events: events,
|
events: events,
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
@@ -142,11 +166,23 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
) else {
|
) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
await repository.releaseEvents(events, leaseID: leaseID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let response: AnalyticsHTTPResponse
|
let response: AnalyticsHTTPResponse
|
||||||
do {
|
do {
|
||||||
response = try await send(events: events, token: authorization.token)
|
response = try await send(
|
||||||
|
events: events,
|
||||||
|
installationID: installationID,
|
||||||
|
token: authorization.token
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
if Task.isCancelled {
|
||||||
|
await repository.releaseEvents(events, leaseID: leaseID)
|
||||||
|
return
|
||||||
|
}
|
||||||
await scheduleRetry(
|
await scheduleRetry(
|
||||||
events: events,
|
events: events,
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
@@ -172,15 +208,24 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
) else {
|
) else {
|
||||||
return
|
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(
|
await processResponse(
|
||||||
refreshed,
|
refreshed,
|
||||||
events: events,
|
events: events,
|
||||||
|
installationID: installationID,
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
ownerID: ownerID,
|
ownerID: ownerID,
|
||||||
authorization: &authorization
|
authorization: &authorization
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
if Task.isCancelled {
|
||||||
|
await repository.releaseEvents(events, leaseID: leaseID)
|
||||||
|
return
|
||||||
|
}
|
||||||
await scheduleRetry(
|
await scheduleRetry(
|
||||||
events: events,
|
events: events,
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
@@ -195,6 +240,7 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
await processResponse(
|
await processResponse(
|
||||||
response,
|
response,
|
||||||
events: events,
|
events: events,
|
||||||
|
installationID: installationID,
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
ownerID: ownerID,
|
ownerID: ownerID,
|
||||||
authorization: &authorization
|
authorization: &authorization
|
||||||
@@ -204,10 +250,15 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
private func processResponse(
|
private func processResponse(
|
||||||
_ response: AnalyticsHTTPResponse,
|
_ response: AnalyticsHTTPResponse,
|
||||||
events: [AnalyticsLeasedEvent],
|
events: [AnalyticsLeasedEvent],
|
||||||
|
installationID: UUID,
|
||||||
leaseID: String,
|
leaseID: String,
|
||||||
ownerID: String,
|
ownerID: String,
|
||||||
authorization: inout AuthorizationState
|
authorization: inout AuthorizationState
|
||||||
) async {
|
) async {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
await repository.releaseEvents(events, leaseID: leaseID)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch response.statusCode {
|
switch response.statusCode {
|
||||||
case 200:
|
case 200:
|
||||||
let decoded: AnalyticsUploadResponse
|
let decoded: AnalyticsUploadResponse
|
||||||
@@ -270,12 +321,14 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
let midpoint = events.count / 2
|
let midpoint = events.count / 2
|
||||||
await process(
|
await process(
|
||||||
events: Array(events[..<midpoint]),
|
events: Array(events[..<midpoint]),
|
||||||
|
installationID: installationID,
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
ownerID: ownerID,
|
ownerID: ownerID,
|
||||||
authorization: &authorization
|
authorization: &authorization
|
||||||
)
|
)
|
||||||
await process(
|
await process(
|
||||||
events: Array(events[midpoint...]),
|
events: Array(events[midpoint...]),
|
||||||
|
installationID: installationID,
|
||||||
leaseID: leaseID,
|
leaseID: leaseID,
|
||||||
ownerID: ownerID,
|
ownerID: ownerID,
|
||||||
authorization: &authorization
|
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(
|
private func send(
|
||||||
events: [AnalyticsLeasedEvent],
|
events: [AnalyticsLeasedEvent],
|
||||||
|
installationID: UUID,
|
||||||
token: String?
|
token: String?
|
||||||
) async throws -> AnalyticsHTTPResponse {
|
) async throws -> AnalyticsHTTPResponse {
|
||||||
var headers = [
|
var headers = [
|
||||||
@@ -355,7 +418,10 @@ public actor AnalyticsUploadCoordinator {
|
|||||||
AnalyticsHTTPRequest(
|
AnalyticsHTTPRequest(
|
||||||
url: configuration.endpoint,
|
url: configuration.endpoint,
|
||||||
headers: headers,
|
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
|
events.map(\.attemptCount).max() ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
|
private static func requestBody(
|
||||||
var body = Data(#"{"events":["#.utf8)
|
for events: [AnalyticsLeasedEvent],
|
||||||
|
installationID: UUID
|
||||||
|
) -> Data {
|
||||||
|
var body = Data(
|
||||||
|
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
|
||||||
|
.utf8
|
||||||
|
)
|
||||||
for index in events.indices {
|
for index in events.indices {
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
body.append(UInt8(ascii: ","))
|
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 store: any GatewayGrantCredentialStore
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let now: @Sendable () -> Date
|
private let now: @Sendable () -> Date
|
||||||
|
private let refreshPath: String
|
||||||
|
private let accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing
|
||||||
private var refreshTask: Task<ManagedGatewayGrantCredentials, Error>?
|
private var refreshTask: Task<ManagedGatewayGrantCredentials, Error>?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||||
store: any GatewayGrantCredentialStore = GatewayGrantKeychainStore(),
|
store: any GatewayGrantCredentialStore = GatewayGrantKeychainStore(),
|
||||||
session: URLSession = .shared,
|
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.baseURL = baseURL
|
||||||
self.store = store
|
self.store = store
|
||||||
self.session = session
|
self.session = session
|
||||||
|
self.refreshPath = refreshPath
|
||||||
self.now = now
|
self.now = now
|
||||||
|
self.accountAccessPolicy = accountAccessPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host-only integration point. The account access token authorizes grant
|
/// Host-only integration point. The account access token authorizes grant
|
||||||
@@ -73,6 +80,12 @@ public actor GatewayGrantCoordinator {
|
|||||||
for scope: ManagedGatewayCapability,
|
for scope: ManagedGatewayCapability,
|
||||||
forceRefresh: Bool = false
|
forceRefresh: Bool = false
|
||||||
) async throws -> String {
|
) 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 {
|
guard let credentials = try await store.load() else {
|
||||||
throw ManagedGatewayError.missingGrant
|
throw ManagedGatewayError.missingGrant
|
||||||
}
|
}
|
||||||
@@ -123,7 +136,7 @@ public actor GatewayGrantCoordinator {
|
|||||||
let refreshToken: String
|
let refreshToken: String
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: endpoint("v1/gateway/grants/refresh"))
|
var request = URLRequest(url: endpoint(refreshPath))
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.setValue(
|
request.setValue(
|
||||||
@@ -227,13 +240,44 @@ enum ManagedGatewayHTTP {
|
|||||||
let code = decoded?.code ?? HTTPURLResponse.localizedString(forStatusCode: status)
|
let code = decoded?.code ?? HTTPURLResponse.localizedString(forStatusCode: status)
|
||||||
let resolvedRequestId = decoded?.requestId ?? requestId
|
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() {
|
switch code.lowercased() {
|
||||||
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
|
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
|
||||||
return .insufficientCredits
|
return .insufficientCredits
|
||||||
|
case "oobe_feature_already_used":
|
||||||
|
return .oobeFeatureAlreadyUsed
|
||||||
case "unauthorized", "invalid_gateway_refresh", "gateway_grant_denied", "invalid_grant":
|
case "unauthorized", "invalid_gateway_refresh", "gateway_grant_denied", "invalid_grant":
|
||||||
return .invalidGrant
|
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:
|
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 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 {
|
public enum StoreError: Error, Equatable, Sendable {
|
||||||
case unexpectedStatus(OSStatus)
|
case unexpectedStatus(OSStatus)
|
||||||
case invalidStoredValue
|
case invalidStoredValue
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let service = "com.osgkeyboard.gateway-grant"
|
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? {
|
public func load() async throws -> ManagedGatewayGrantCredentials? {
|
||||||
var query = Self.baseQuery
|
var query = baseQuery
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
|
||||||
@@ -51,14 +58,14 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
|||||||
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
||||||
let data = try Self.encoder.encode(credentials)
|
let data = try Self.encoder.encode(credentials)
|
||||||
let updateStatus = SecItemUpdate(
|
let updateStatus = SecItemUpdate(
|
||||||
Self.baseQuery as CFDictionary,
|
baseQuery as CFDictionary,
|
||||||
[kSecValueData as String: data] as CFDictionary
|
[kSecValueData as String: data] as CFDictionary
|
||||||
)
|
)
|
||||||
switch updateStatus {
|
switch updateStatus {
|
||||||
case errSecSuccess:
|
case errSecSuccess:
|
||||||
return
|
return
|
||||||
case errSecItemNotFound:
|
case errSecItemNotFound:
|
||||||
var query = Self.baseQuery
|
var query = baseQuery
|
||||||
query[kSecValueData as String] = data
|
query[kSecValueData as String] = data
|
||||||
// The extension can refresh after reboot without making account
|
// The extension can refresh after reboot without making account
|
||||||
// credentials readable; this item contains only the limited grant.
|
// credentials readable; this item contains only the limited grant.
|
||||||
@@ -73,17 +80,17 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func delete() async throws {
|
public func delete() async throws {
|
||||||
let status = SecItemDelete(Self.baseQuery as CFDictionary)
|
let status = SecItemDelete(baseQuery as CFDictionary)
|
||||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
throw StoreError.unexpectedStatus(status)
|
throw StoreError.unexpectedStatus(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var baseQuery: [String: Any] {
|
private var baseQuery: [String: Any] {
|
||||||
var query: [String: Any] = [
|
var query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: Self.service,
|
||||||
kSecAttrAccount as String: account,
|
kSecAttrAccount as String: slot.rawValue,
|
||||||
kSecAttrSynchronizable as String: kCFBooleanFalse!
|
kSecAttrSynchronizable as String: kCFBooleanFalse!
|
||||||
]
|
]
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -104,3 +111,23 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
|||||||
return decoder
|
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 translation
|
||||||
case editLastInput = "edit_last_input"
|
case editLastInput = "edit_last_input"
|
||||||
case aiQuestion = "ai_question"
|
case aiQuestion = "ai_question"
|
||||||
|
case currentInformationQuestion = "current_information_question"
|
||||||
case clipboardTransform = "clipboard_transform"
|
case clipboardTransform = "clipboard_transform"
|
||||||
case customSkill = "custom_skill"
|
case customSkill = "custom_skill"
|
||||||
case agentPlanning = "agent_planning"
|
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
|
/// Optional server-audited purpose. A purpose may affect billing only when the
|
||||||
/// authenticated gateway independently verifies its eligibility.
|
/// authenticated gateway independently verifies its eligibility.
|
||||||
public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
|
public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
|
||||||
case oobe
|
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 struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
|
||||||
public static let maximumAccessLifetime: TimeInterval = 5 * 60
|
public static let maximumAccessLifetime: TimeInterval = 5 * 60
|
||||||
|
|
||||||
@@ -85,7 +111,13 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
|||||||
case scopeNotGranted(ManagedGatewayCapability)
|
case scopeNotGranted(ManagedGatewayCapability)
|
||||||
case invalidGrant
|
case invalidGrant
|
||||||
case insufficientCredits
|
case insufficientCredits
|
||||||
|
case oobeFeatureAlreadyUsed
|
||||||
case timeout
|
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?)
|
case server(code: String, status: Int, requestId: String?)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
@@ -102,8 +134,20 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
|||||||
return SharedL10n.string("managed.error.grantRejected")
|
return SharedL10n.string("managed.error.grantRejected")
|
||||||
case .insufficientCredits:
|
case .insufficientCredits:
|
||||||
return SharedL10n.string("managed.error.insufficientCredits")
|
return SharedL10n.string("managed.error.insufficientCredits")
|
||||||
|
case .oobeFeatureAlreadyUsed:
|
||||||
|
return SharedL10n.string("managed.error.oobeFeatureAlreadyUsed")
|
||||||
case .timeout:
|
case .timeout:
|
||||||
return SharedL10n.string("managed.error.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, _):
|
case .server(let code, let status, _):
|
||||||
return SharedL10n.format(
|
return SharedL10n.format(
|
||||||
"managed.error.server",
|
"managed.error.server",
|
||||||
@@ -115,15 +159,15 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ManagedGatewayGrantTokenResponse: Decodable {
|
public struct ManagedGatewayGrantTokenResponse: Decodable, Sendable {
|
||||||
let grantId: String
|
public let grantId: String
|
||||||
let scopes: Set<ManagedGatewayCapability>
|
public let scopes: Set<ManagedGatewayCapability>
|
||||||
let accessToken: String
|
public let accessToken: String
|
||||||
let accessExpiresAt: Date
|
public let accessExpiresAt: Date
|
||||||
let refreshToken: String
|
public let refreshToken: String
|
||||||
let refreshExpiresAt: Date
|
public let refreshExpiresAt: Date
|
||||||
|
|
||||||
func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
|
public func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
|
||||||
ManagedGatewayGrantCredentials(
|
ManagedGatewayGrantCredentials(
|
||||||
grantId: grantId,
|
grantId: grantId,
|
||||||
scopes: scopes,
|
scopes: scopes,
|
||||||
@@ -149,5 +193,7 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
|
|||||||
let temperature: Double
|
let temperature: Double
|
||||||
let stream: Bool
|
let stream: Bool
|
||||||
let taskKind: ManagedGatewayTaskKind
|
let taskKind: ManagedGatewayTaskKind
|
||||||
|
let requestSource: ManagedGatewayRequestSource?
|
||||||
let requestPurpose: ManagedGatewayRequestPurpose?
|
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 capability: Capability
|
||||||
public let taskKind: ManagedGatewayTaskKind
|
public let taskKind: ManagedGatewayTaskKind
|
||||||
|
public let requestSource: ManagedGatewayRequestSource?
|
||||||
public let requestPurpose: ManagedGatewayRequestPurpose?
|
public let requestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
public let oobeFeature: ManagedGatewayOOBEFeature?
|
||||||
public let requestTimeout: TimeInterval
|
public let requestTimeout: TimeInterval
|
||||||
|
|
||||||
private let baseURL: URL
|
private let baseURL: URL
|
||||||
private let grants: GatewayGrantCoordinator
|
private let grants: GatewayGrantCoordinator
|
||||||
|
private let oobeGrants: OOBEGatewayGrantCoordinator
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let requestId: @Sendable () -> String
|
private let requestId: @Sendable () -> String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
capability: Capability,
|
capability: Capability,
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
requestSource: ManagedGatewayRequestSource? = nil,
|
||||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
grants: GatewayGrantCoordinator,
|
grants: GatewayGrantCoordinator,
|
||||||
|
oobeGrants: OOBEGatewayGrantCoordinator? = nil,
|
||||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||||
session: URLSession = .shared,
|
session: URLSession = .shared,
|
||||||
requestTimeout: TimeInterval = 15,
|
requestTimeout: TimeInterval = 15,
|
||||||
@@ -70,8 +76,14 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
) {
|
) {
|
||||||
self.capability = capability
|
self.capability = capability
|
||||||
self.taskKind = taskKind ?? capability.defaultTaskKind
|
self.taskKind = taskKind ?? capability.defaultTaskKind
|
||||||
|
self.requestSource = requestSource
|
||||||
self.requestPurpose = requestPurpose
|
self.requestPurpose = requestPurpose
|
||||||
|
self.oobeFeature = oobeFeature
|
||||||
self.grants = grants
|
self.grants = grants
|
||||||
|
self.oobeGrants = oobeGrants ?? OOBEGatewayGrantCoordinator(
|
||||||
|
baseURL: baseURL,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.session = session
|
self.session = session
|
||||||
self.requestTimeout = requestTimeout
|
self.requestTimeout = requestTimeout
|
||||||
@@ -189,7 +201,7 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
do {
|
do {
|
||||||
return try await bufferedAttempt(attempt.forcingRefresh())
|
return try await bufferedAttempt(attempt.forcingRefresh())
|
||||||
} catch ManagedGatewayError.invalidGrant {
|
} catch ManagedGatewayError.invalidGrant {
|
||||||
try? await grants.clearGrant()
|
try? await clearSelectedGrant()
|
||||||
throw ManagedGatewayError.invalidGrant
|
throw ManagedGatewayError.invalidGrant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,10 +292,34 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = try await grants.accessToken(
|
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,
|
for: capability.grantScope,
|
||||||
forceRefresh: attempt.forceRefresh
|
forceRefresh: attempt.forceRefresh
|
||||||
)
|
)
|
||||||
|
}
|
||||||
let body = ManagedGatewayTextRequest(
|
let body = ManagedGatewayTextRequest(
|
||||||
input: trimmedInput,
|
input: trimmedInput,
|
||||||
context: boundedContext,
|
context: boundedContext,
|
||||||
@@ -291,7 +327,9 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
|
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
|
||||||
stream: stream,
|
stream: stream,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
requestPurpose: requestPurpose
|
requestSource: requestSource,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
|
oobeFeature: oobeFeature
|
||||||
)
|
)
|
||||||
|
|
||||||
var request = URLRequest(
|
var request = URLRequest(
|
||||||
@@ -307,6 +345,14 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearSelectedGrant() async throws {
|
||||||
|
if requestPurpose == .oobe {
|
||||||
|
try await oobeGrants.clearGrant()
|
||||||
|
} else {
|
||||||
|
try await grants.clearGrant()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func payload(
|
static func payload(
|
||||||
from messages: [LLMRequest.Message]
|
from messages: [LLMRequest.Message]
|
||||||
) -> (input: String, context: String?) {
|
) -> (input: String, context: String?) {
|
||||||
@@ -410,13 +456,11 @@ public struct ManagedLLMClient: LLMClient {
|
|||||||
object["message"] != nil || code.hasSuffix("_error") else {
|
object["message"] != nil || code.hasSuffix("_error") else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ["insufficient_credits", "insufficient_balance"].contains(code) {
|
return ManagedGatewayHTTP.error(
|
||||||
return .insufficientCredits
|
code: code,
|
||||||
}
|
status: 200,
|
||||||
if ["unauthorized", "gateway_grant_denied", "invalid_grant"].contains(code) {
|
requestId: object["requestId"] as? String ?? requestId
|
||||||
return .invalidGrant
|
)
|
||||||
}
|
|
||||||
return .server(code: code, status: 200, requestId: requestId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +468,7 @@ public enum ManagedGatewayLLMClientFactory {
|
|||||||
public static func polish(
|
public static func polish(
|
||||||
taskKind: ManagedGatewayTaskKind = .dictationPolish,
|
taskKind: ManagedGatewayTaskKind = .dictationPolish,
|
||||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
grants: GatewayGrantCoordinator,
|
grants: GatewayGrantCoordinator,
|
||||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||||
session: URLSession = .shared
|
session: URLSession = .shared
|
||||||
@@ -432,6 +477,7 @@ public enum ManagedGatewayLLMClientFactory {
|
|||||||
capability: .polish,
|
capability: .polish,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
requestPurpose: requestPurpose,
|
requestPurpose: requestPurpose,
|
||||||
|
oobeFeature: oobeFeature,
|
||||||
grants: grants,
|
grants: grants,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
session: session
|
session: session
|
||||||
@@ -440,6 +486,8 @@ public enum ManagedGatewayLLMClientFactory {
|
|||||||
|
|
||||||
public static func ai(
|
public static func ai(
|
||||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||||
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
grants: GatewayGrantCoordinator,
|
grants: GatewayGrantCoordinator,
|
||||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||||
session: URLSession = .shared
|
session: URLSession = .shared
|
||||||
@@ -447,6 +495,8 @@ public enum ManagedGatewayLLMClientFactory {
|
|||||||
ManagedLLMClient(
|
ManagedLLMClient(
|
||||||
capability: .assistant,
|
capability: .assistant,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
|
requestPurpose: requestPurpose,
|
||||||
|
oobeFeature: oobeFeature,
|
||||||
grants: grants,
|
grants: grants,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
session: session
|
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
|
// AIAgentSkillLayout.swift
|
||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
|
// Enabled clipboard skills (ordered) plus which export skills the user
|
||||||
// the user confirmed a companion Shortcut for. Missing storage hydrates
|
// confirmed a companion Shortcut for. Missing storage hydrates to every
|
||||||
// to the three default transform skills; an explicit empty list is kept
|
// built-in default skill; an explicit empty list is kept
|
||||||
// so turning every skill off is distinct from a fresh install.
|
// so turning every skill off is distinct from a fresh install.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||||
public static let maximumEnabled = 8
|
public static let defaultEnabledIDs = AIClipboardSkillCatalog.catalog
|
||||||
public static let defaultEnabledIDs = [
|
.filter(\.isDefault)
|
||||||
AIClipboardSkillCatalog.replyID,
|
.map(\.id)
|
||||||
AIClipboardSkillCatalog.summarizeID,
|
|
||||||
AIClipboardSkillCatalog.translateID
|
|
||||||
]
|
|
||||||
|
|
||||||
/// 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]
|
public var enabledIDs: [String]
|
||||||
/// Export skills whose companion Shortcut the user marked as added.
|
/// Export skills whose companion Shortcut the user marked as added.
|
||||||
public var confirmedShortcutIDs: [String]
|
public var confirmedShortcutIDs: [String]
|
||||||
@@ -31,10 +28,6 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
|||||||
self.confirmedShortcutIDs = confirmedShortcutIDs
|
self.confirmedShortcutIDs = confirmedShortcutIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isFull: Bool {
|
|
||||||
enabledIDs.count >= Self.maximumEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isEnabled(_ id: String) -> Bool {
|
public func isEnabled(_ id: String) -> Bool {
|
||||||
enabledIDs.contains(id)
|
enabledIDs.contains(id)
|
||||||
}
|
}
|
||||||
@@ -43,20 +36,20 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
|||||||
confirmedShortcutIDs.contains(id)
|
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(
|
public func sanitized(
|
||||||
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
|
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
|
||||||
) -> AIAgentSkillLayout {
|
) -> AIAgentSkillLayout {
|
||||||
let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
||||||
var seenEnabled = Set<String>()
|
var seenEnabled = Set<String>()
|
||||||
let enabled = enabledIDs.filter { id in
|
let enabled = enabledIDs.compactMap { id -> String? in
|
||||||
guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
|
let canonical = AIClipboardSkillCatalog.canonicalID(for: id)
|
||||||
if skill.requiresShortcut {
|
guard known[canonical] != nil, seenEnabled.insert(canonical).inserted else {
|
||||||
return confirmedShortcutIDs.contains(id)
|
return nil
|
||||||
}
|
}
|
||||||
return true
|
return canonical
|
||||||
}
|
}
|
||||||
.prefix(Self.maximumEnabled)
|
|
||||||
|
|
||||||
var seenConfirmed = Set<String>()
|
var seenConfirmed = Set<String>()
|
||||||
let confirmed = confirmedShortcutIDs.filter { id in
|
let confirmed = confirmedShortcutIDs.filter { id in
|
||||||
@@ -65,7 +58,7 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return AIAgentSkillLayout(
|
return AIAgentSkillLayout(
|
||||||
enabledIDs: Array(enabled),
|
enabledIDs: enabled,
|
||||||
confirmedShortcutIDs: confirmed
|
confirmedShortcutIDs: confirmed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -75,6 +68,5 @@ public enum AIAgentSkillEnableResult: Equatable, Sendable {
|
|||||||
case enabled
|
case enabled
|
||||||
case alreadyEnabled
|
case alreadyEnabled
|
||||||
case needsShortcut
|
case needsShortcut
|
||||||
case full
|
|
||||||
case unknown
|
case unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public var locale: String
|
public var locale: String
|
||||||
public var conditions: [String]
|
public var conditions: [String]
|
||||||
public var metadata: AIHintMetadata?
|
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(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -56,7 +59,8 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
|
|||||||
source: String = "local",
|
source: String = "local",
|
||||||
locale: String = "zh",
|
locale: String = "zh",
|
||||||
conditions: [String] = [],
|
conditions: [String] = [],
|
||||||
metadata: AIHintMetadata? = nil
|
metadata: AIHintMetadata? = nil,
|
||||||
|
taskKind: ManagedGatewayTaskKind = .aiQuestion
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.displayText = displayText
|
self.displayText = displayText
|
||||||
@@ -67,6 +71,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
|
|||||||
self.locale = locale
|
self.locale = locale
|
||||||
self.conditions = conditions
|
self.conditions = conditions
|
||||||
self.metadata = metadata
|
self.metadata = metadata
|
||||||
|
self.taskKind = taskKind
|
||||||
}
|
}
|
||||||
|
|
||||||
public var requiresClipboard30s: Bool {
|
public var requiresClipboard30s: Bool {
|
||||||
@@ -83,7 +88,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
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 {
|
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"
|
locale = try container.decodeIfPresent(String.self, forKey: .locale) ?? "zh"
|
||||||
conditions = try container.decodeIfPresent([String].self, forKey: .conditions) ?? []
|
conditions = try container.decodeIfPresent([String].self, forKey: .conditions) ?? []
|
||||||
metadata = try container.decodeIfPresent(AIHintMetadata.self, forKey: .metadata)
|
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),
|
if let display = try container.decodeIfPresent(String.self, forKey: .displayText),
|
||||||
!display.isEmpty {
|
!display.isEmpty {
|
||||||
displayText = display
|
displayText = display
|
||||||
@@ -115,6 +124,7 @@ public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
|
|||||||
try container.encode(locale, forKey: .locale)
|
try container.encode(locale, forKey: .locale)
|
||||||
try container.encode(conditions, forKey: .conditions)
|
try container.encode(conditions, forKey: .conditions)
|
||||||
try container.encodeIfPresent(metadata, forKey: .metadata)
|
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 enum AIHintFeedEndpoints {
|
||||||
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
|
public static let baseURL = URL(string: "https://account.osglab.com/v1/content/hints")!
|
||||||
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
|
public static let manifestURL = baseURL.appendingPathComponent("manifest")
|
||||||
/// Packs the app fetches and the keyboard can resolve.
|
/// Packs the app fetches and the keyboard can resolve.
|
||||||
public static let supportedLocales = ["zh", "en"]
|
public static let supportedLocales = ["zh", "en"]
|
||||||
|
|
||||||
public static func packURL(locale: String) -> URL {
|
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 readyPackPrefix = "hints.ready."
|
||||||
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
|
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
|
||||||
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
|
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 {
|
public static func readyPackKey(locale: String) -> String {
|
||||||
readyPackPrefix + locale
|
readyPackPrefix + locale
|
||||||
@@ -203,4 +216,8 @@ public enum AIHintAppGroupKeys {
|
|||||||
public static func lastSuccessKey(locale: String) -> String {
|
public static func lastSuccessKey(locale: String) -> String {
|
||||||
lastSuccessPrefix + locale
|
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"
|
public static let engineMode = "config.engineMode"
|
||||||
/// Credential ownership is independent from local/cloud ASR selection.
|
/// Credential ownership is independent from local/cloud ASR selection.
|
||||||
public static let credentialSource = "config.credentialSource"
|
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 hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||||
public static let onboardingPage = "config.onboardingPage"
|
public static let onboardingPage = "config.onboardingPage"
|
||||||
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
||||||
@@ -73,8 +77,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
||||||
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
|
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
|
||||||
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
|
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).
|
/// User-created clipboard skills (no cloud sync; App Group only).
|
||||||
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
|
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
|
// MARK: - Stored fields
|
||||||
@@ -195,13 +204,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
|
|
||||||
public func makeClient(
|
public func makeClient(
|
||||||
taskKind: ManagedGatewayTaskKind? = nil,
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
requestPurpose: ManagedGatewayRequestPurpose? = nil
|
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil
|
||||||
) -> LLMClient {
|
) -> LLMClient {
|
||||||
if credentialSource == .managed {
|
if credentialSource == .managed || requestPurpose == .oobe {
|
||||||
return ManagedLLMClient(
|
return ManagedLLMClient(
|
||||||
capability: .polish,
|
capability: .polish,
|
||||||
taskKind: taskKind,
|
taskKind: taskKind,
|
||||||
requestPurpose: requestPurpose,
|
requestPurpose: requestPurpose,
|
||||||
|
oobeFeature: oobeFeature,
|
||||||
grants: GatewayGrantCoordinator()
|
grants: GatewayGrantCoordinator()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
case submitAIQuestion
|
case submitAIQuestion
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire version that includes managed-gateway request purpose.
|
/// Wire version that includes host-side webpage summary extraction.
|
||||||
public static let currentProtocolVersion = 7
|
public static let currentProtocolVersion = 10
|
||||||
|
|
||||||
public let protocolVersion: Int
|
public let protocolVersion: Int
|
||||||
public let sessionId: UUID
|
public let sessionId: UUID
|
||||||
@@ -41,10 +41,16 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
public let aiConversationID: UUID?
|
public let aiConversationID: UUID?
|
||||||
/// Prefilled question used only by `.submitAIQuestion`.
|
/// Prefilled question used only by `.submitAIQuestion`.
|
||||||
public let aiQuestionText: String?
|
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.
|
/// Fine-grained managed-gateway intent for AI question submissions.
|
||||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
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.
|
/// Optional server-audited purpose for managed gateway billing policy.
|
||||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
|
||||||
|
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||||
public let aiThinkingEnabled: Bool?
|
public let aiThinkingEnabled: Bool?
|
||||||
/// Absolute wall-clock deadlines survive extension reconstruction.
|
/// Absolute wall-clock deadlines survive extension reconstruction.
|
||||||
@@ -66,8 +72,11 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
sourceHistoryEntryRevision: Int64? = nil,
|
sourceHistoryEntryRevision: Int64? = nil,
|
||||||
aiConversationID: UUID? = nil,
|
aiConversationID: UUID? = nil,
|
||||||
aiQuestionText: String? = nil,
|
aiQuestionText: String? = nil,
|
||||||
|
aiWebPageURL: URL? = nil,
|
||||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
managedRequestSource: ManagedGatewayRequestSource? = nil,
|
||||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
|
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
aiThinkingEnabled: Bool? = nil,
|
aiThinkingEnabled: Bool? = nil,
|
||||||
startDeadlineAt: TimeInterval? = nil,
|
startDeadlineAt: TimeInterval? = nil,
|
||||||
processingDeadlineAt: TimeInterval? = nil
|
processingDeadlineAt: TimeInterval? = nil
|
||||||
@@ -86,8 +95,11 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
|||||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||||
self.aiConversationID = aiConversationID
|
self.aiConversationID = aiConversationID
|
||||||
self.aiQuestionText = aiQuestionText
|
self.aiQuestionText = aiQuestionText
|
||||||
|
self.aiWebPageURL = aiWebPageURL
|
||||||
self.aiTaskKind = aiTaskKind
|
self.aiTaskKind = aiTaskKind
|
||||||
|
self.managedRequestSource = managedRequestSource
|
||||||
self.managedRequestPurpose = managedRequestPurpose
|
self.managedRequestPurpose = managedRequestPurpose
|
||||||
|
self.managedOOBEFeature = managedOOBEFeature
|
||||||
self.aiThinkingEnabled = aiThinkingEnabled
|
self.aiThinkingEnabled = aiThinkingEnabled
|
||||||
self.startDeadlineAt = startDeadlineAt
|
self.startDeadlineAt = startDeadlineAt
|
||||||
self.processingDeadlineAt = processingDeadlineAt
|
self.processingDeadlineAt = processingDeadlineAt
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
public let aiConversationID: UUID?
|
public let aiConversationID: UUID?
|
||||||
/// When set with `.aiQuestion`, host skips ASR and answers this text.
|
/// When set with `.aiQuestion`, host skips ASR and answers this text.
|
||||||
public let aiQuestionText: String?
|
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.
|
/// Fine-grained managed-gateway intent. Regular questions keep the default.
|
||||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
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.
|
/// Optional server-audited purpose for managed gateway billing policy.
|
||||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||||
|
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
|
||||||
|
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||||
public let aiThinkingEnabled: Bool?
|
public let aiThinkingEnabled: Bool?
|
||||||
|
|
||||||
@@ -29,8 +35,11 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
sourceHistoryEntryRevision: Int64? = nil,
|
sourceHistoryEntryRevision: Int64? = nil,
|
||||||
aiConversationID: UUID? = nil,
|
aiConversationID: UUID? = nil,
|
||||||
aiQuestionText: String? = nil,
|
aiQuestionText: String? = nil,
|
||||||
|
aiWebPageURL: URL? = nil,
|
||||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||||
|
managedRequestSource: ManagedGatewayRequestSource? = nil,
|
||||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||||
|
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
aiThinkingEnabled: Bool? = nil
|
aiThinkingEnabled: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -39,8 +48,11 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||||
self.aiConversationID = aiConversationID
|
self.aiConversationID = aiConversationID
|
||||||
self.aiQuestionText = aiQuestionText
|
self.aiQuestionText = aiQuestionText
|
||||||
|
self.aiWebPageURL = aiWebPageURL
|
||||||
self.aiTaskKind = aiTaskKind
|
self.aiTaskKind = aiTaskKind
|
||||||
|
self.managedRequestSource = managedRequestSource
|
||||||
self.managedRequestPurpose = managedRequestPurpose
|
self.managedRequestPurpose = managedRequestPurpose
|
||||||
|
self.managedOOBEFeature = managedOOBEFeature
|
||||||
self.aiThinkingEnabled = aiThinkingEnabled
|
self.aiThinkingEnabled = aiThinkingEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +74,20 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
|||||||
conversationID: UUID,
|
conversationID: UUID,
|
||||||
prefilledQuestion: String? = nil,
|
prefilledQuestion: String? = nil,
|
||||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||||
thinkingEnabled: Bool? = nil
|
requestSource: ManagedGatewayRequestSource? = nil,
|
||||||
|
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||||
|
thinkingEnabled: Bool? = nil,
|
||||||
|
webPageURL: URL? = nil
|
||||||
) -> FlowUtteranceRequest {
|
) -> FlowUtteranceRequest {
|
||||||
FlowUtteranceRequest(
|
FlowUtteranceRequest(
|
||||||
mode: .aiQuestion,
|
mode: .aiQuestion,
|
||||||
aiConversationID: conversationID,
|
aiConversationID: conversationID,
|
||||||
aiQuestionText: prefilledQuestion,
|
aiQuestionText: prefilledQuestion,
|
||||||
|
aiWebPageURL: webPageURL,
|
||||||
aiTaskKind: taskKind,
|
aiTaskKind: taskKind,
|
||||||
|
managedRequestSource: requestSource,
|
||||||
|
managedRequestPurpose: oobeFeature == nil ? nil : .oobe,
|
||||||
|
managedOOBEFeature: oobeFeature,
|
||||||
aiThinkingEnabled: thinkingEnabled
|
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
|
public let id: UUID
|
||||||
|
/// Final text delivered to the host app and shown in history.
|
||||||
public let text: String
|
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
|
public let createdAt: Date
|
||||||
/// Last content mutation. Legacy rows default to `createdAt`.
|
/// Last content mutation. Legacy rows default to `createdAt`.
|
||||||
public let modifiedAt: Date
|
public let modifiedAt: Date
|
||||||
@@ -26,6 +37,10 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
|||||||
public init(
|
public init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
text: String,
|
text: String,
|
||||||
|
prePolishText: String? = nil,
|
||||||
|
wasTranslation: Bool = false,
|
||||||
|
polishStyleID: String? = nil,
|
||||||
|
polishStylePromptFingerprint: String? = nil,
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
modifiedAt: Date? = nil,
|
modifiedAt: Date? = nil,
|
||||||
revision: Int64 = 0,
|
revision: Int64 = 0,
|
||||||
@@ -34,6 +49,14 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
|||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.text = text
|
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.createdAt = createdAt
|
||||||
self.modifiedAt = modifiedAt ?? createdAt
|
self.modifiedAt = modifiedAt ?? createdAt
|
||||||
self.revision = revision
|
self.revision = revision
|
||||||
@@ -45,6 +68,13 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
|||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try container.decode(UUID.self, forKey: .id)
|
id = try container.decode(UUID.self, forKey: .id)
|
||||||
text = try container.decode(String.self, forKey: .text)
|
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)
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
|
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
|
||||||
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
|
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
|
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
|
||||||
// propagate single-entry deletes and "clear all" across devices.
|
// propagate single-entry deletes and "clear all" across devices.
|
||||||
|
|
||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
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 kvsKey = "speechHistory.v2"
|
||||||
public static let legacyKVSKey = "speechHistory.v1"
|
public static let legacyKVSKey = "speechHistory.v1"
|
||||||
public static let maxEntries = 300
|
public static let maxEntries = 300
|
||||||
@@ -26,17 +27,24 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
|||||||
public var schemaVersion: Int
|
public var schemaVersion: Int
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var entries: [SpeechHistoryEntry]
|
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.
|
/// Entry IDs deleted on any device, with deletion timestamps.
|
||||||
public var deletedEntryIDs: [UUID: Date]
|
public var deletedEntryIDs: [UUID: Date]
|
||||||
/// Recent idempotency keys for keyboard-originated history mutations.
|
/// Recent idempotency keys for keyboard-originated history mutations.
|
||||||
public var appliedMutationIDs: [UUID]
|
public var appliedMutationIDs: [UUID]
|
||||||
/// When set, entries created at or before this instant are excluded.
|
/// When set, entries created at or before this instant are excluded.
|
||||||
public var clearedAt: Date?
|
public var clearedAt: Date?
|
||||||
|
public static let maxPolishStylePromptSnapshots = 32
|
||||||
|
public static let maxPolishStylePromptSnapshotCharacters = 96_000
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
schemaVersion: Int = Self.schemaVersion,
|
schemaVersion: Int = Self.schemaVersion,
|
||||||
updatedAt: Date = Date(),
|
updatedAt: Date = Date(),
|
||||||
entries: [SpeechHistoryEntry] = [],
|
entries: [SpeechHistoryEntry] = [],
|
||||||
|
polishStylePromptSnapshots: [String: String] = [:],
|
||||||
deletedEntryIDs: [UUID: Date] = [:],
|
deletedEntryIDs: [UUID: Date] = [:],
|
||||||
appliedMutationIDs: [UUID] = [],
|
appliedMutationIDs: [UUID] = [],
|
||||||
clearedAt: Date? = nil
|
clearedAt: Date? = nil
|
||||||
@@ -44,6 +52,7 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
|||||||
self.schemaVersion = schemaVersion
|
self.schemaVersion = schemaVersion
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
self.entries = entries
|
self.entries = entries
|
||||||
|
self.polishStylePromptSnapshots = polishStylePromptSnapshots
|
||||||
self.deletedEntryIDs = deletedEntryIDs
|
self.deletedEntryIDs = deletedEntryIDs
|
||||||
self.appliedMutationIDs = appliedMutationIDs
|
self.appliedMutationIDs = appliedMutationIDs
|
||||||
self.clearedAt = clearedAt
|
self.clearedAt = clearedAt
|
||||||
@@ -54,6 +63,10 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
|||||||
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||||
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
|
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) {
|
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
|
||||||
deletedEntryIDs = map
|
deletedEntryIDs = map
|
||||||
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
|
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
|
||||||
@@ -105,21 +118,31 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
|||||||
entries = Array(entries.prefix(maxEntries))
|
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),
|
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||||
entries: entries,
|
entries: entries,
|
||||||
|
polishStylePromptSnapshots: snapshots,
|
||||||
deletedEntryIDs: deletedIDs,
|
deletedEntryIDs: deletedIDs,
|
||||||
appliedMutationIDs: appliedMutationIDs,
|
appliedMutationIDs: appliedMutationIDs,
|
||||||
clearedAt: clearedAt
|
clearedAt: clearedAt
|
||||||
)
|
)
|
||||||
|
merged.prunePolishStylePromptSnapshots()
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trim to the newest `maxEntries` rows (call after local-only appends).
|
/// Trim to the newest `maxEntries` rows (call after local-only appends).
|
||||||
public mutating func trimEntries() {
|
public mutating func trimEntries() {
|
||||||
guard entries.count > Self.maxEntries else { return }
|
if entries.count > Self.maxEntries {
|
||||||
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
|
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
|
||||||
updatedAt = Date()
|
updatedAt = Date()
|
||||||
}
|
}
|
||||||
|
prunePolishStylePromptSnapshots()
|
||||||
|
}
|
||||||
|
|
||||||
public mutating func pruneTombstonesIfNeeded() {
|
public mutating func pruneTombstonesIfNeeded() {
|
||||||
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
|
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
|
||||||
@@ -162,19 +185,93 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
|||||||
_ candidate: SpeechHistoryEntry,
|
_ candidate: SpeechHistoryEntry,
|
||||||
over existing: SpeechHistoryEntry
|
over existing: SpeechHistoryEntry
|
||||||
) -> SpeechHistoryEntry {
|
) -> SpeechHistoryEntry {
|
||||||
|
let winner: SpeechHistoryEntry
|
||||||
|
let fallback: SpeechHistoryEntry
|
||||||
if candidate.revision != existing.revision {
|
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 preservingCorpusMetadata(of: winner, fallback: fallback)
|
||||||
return candidate.modifiedAt > existing.modifiedAt ? candidate : existing
|
|
||||||
}
|
}
|
||||||
return candidate.createdAt >= existing.createdAt ? candidate : existing
|
|
||||||
|
/// 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 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 {
|
extension SyncedSpeechHistory {
|
||||||
mutating func recordClearAll(at date: Date = Date()) {
|
mutating func recordClearAll(at date: Date = Date()) {
|
||||||
entries.removeAll()
|
entries.removeAll()
|
||||||
|
polishStylePromptSnapshots.removeAll()
|
||||||
clearedAt = date
|
clearedAt = date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,3 +114,76 @@ public enum TranslationLanguageCatalog {
|
|||||||
return all.first { $0.id == offLocaleId } ?? all[0]
|
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
Reference in New Issue
Block a user