Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -62,6 +62,9 @@ Signing.local.xcconfig
|
||||
# Custom LM compile output (Mac-only; device-side prepare uses .bin from bundle)
|
||||
OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
|
||||
|
||||
# Reproducible Create ML candidates (selected models live in Shared resources)
|
||||
ModelTraining/ClipboardSemantics/Candidates/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### Version format
|
||||
|
||||
The current source-of-truth version is **2.0.0 (build 86)**. Releases use stable SemVer:
|
||||
The current source-of-truth version is **2.0.1 (build 87)**. Releases use stable SemVer:
|
||||
|
||||
| Field | File | Rule |
|
||||
|-------|------|------|
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
+27
-5
@@ -8,19 +8,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Privacy-preserving product analytics**: add an optional first-party event queue with cross-process SQLite durability, idempotent retry, fixed privacy-safe dimensions, and a device-local opt-out that clears pending events. / **隐私友好的产品分析**:新增可选的第一方事件队列,通过跨进程 SQLite 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关。
|
||||
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
|
||||
- **Free OOBE polish**: the first-run lesson defaults to on-device transcription plus OSG managed polish, sends an authenticated OOBE purpose tag, and uses a server-enforced one-time complimentary request without reserving or settling credits. / **免费 OOBE 润色**:首次教学默认使用端侧转写与 OSG 托管润色,发送经过身份验证的 OOBE 用途标签,并由服务端执行一次性免费资格,不预扣也不结算积分。
|
||||
- **Local clipboard semantics**: add reproducible bilingual Create ML training, self-contained on-device classifiers for tasks, questions, invitations, complaints, and sentiment, plus deterministic language, date, address, phone, URL, person, and organization labels without uploading clipboard text. / **本地剪贴板语义**:新增可复现的中英语料与 Create ML 训练流程,以及任务、问句、邀约、投诉和情感的端侧自包含分类器,并结合确定性的语言、日期、地址、电话、URL、人名和组织名标签,全程不上传剪贴板原文。
|
||||
- **Contextual clipboard skills**: install the complete built-in skill catalog by default, add source-language, invitation, task, clarification, empathy, business, conclusion, and list actions, remove the eight-skill cap, and rank keyboard skills ephemerally from local clipboard labels while preserving the user's saved order. / **情境化剪贴板技能**:默认安装完整内置技能目录,新增原语言回复、邀约、任务、澄清、共情、商务、结论与清单操作,移除八个技能上限,并根据本地剪贴板标签临时智能排序,同时保留用户保存的长期顺序。
|
||||
- **Learned speaking styles**: unlock user-initiated style generation after 5,000 effective dictation characters, prioritize recurring native speech and explicit user edits, use historical polish prompts only to subtract AI-added style, and require review before saving. / **学习说话风格**:累积 5,000 个有效听写字符后,可优先根据反复出现的原生口述与用户明确编辑生成个人风格,历史润色 Prompt 仅用于排除 AI 附加风格,并在保存前强制检查。
|
||||
|
||||
### Changed
|
||||
- **AI Agent first run**: replace the technical six-step setup with a concise privacy, permission, and keyboard flow that presents OSGKeyboard as an AI Agent voice keyboard, teaches on the real keyboard, and verifies the first voice insertion before completion. / **AI Agent 首次体验**:以简洁的隐私、权限与键盘流程取代技术化的六步配置,突出 AI Agent 语音输入法定位,并在真实键盘中完成教学、验证首次语音上屏后再结束。
|
||||
- **Skill catalog layout**: replace the two-column skill cards with compact full-width list rows while preserving enable, detail, and long-press reorder interactions. / **技能目录布局**:将双列技能卡片改为紧凑的通栏列表,同时保留启用、详情与长按排序交互。
|
||||
|
||||
### 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 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。
|
||||
|
||||
## [2.0.1] - 2026-08-21
|
||||
|
||||
### Added
|
||||
- **Rime term suggestions**: move the Personal Dictionary card above History and surface up to five repeated local Rime terms for explicit one-tap confirmation without recording full keystrokes. / **Rime 词条建议**:将个性词库卡片移到历史记录上方,展示最多五个本机 Rime 高频新词,并由用户逐个确认加入,不记录完整击键内容。
|
||||
- **Private keyboard usage summaries**: count only manually committed Chinese, Latin, and other graphemes plus UTC-day input-session classes, then upload immutable App Group summaries without retaining typed text, host-app context, or per-key events. / **隐私键盘用量摘要**:仅统计手动上屏的中文、拉丁字母及其他字形簇与 UTC 每日输入会话分类,并通过 App Group 上传不可变摘要,不保留输入原文、宿主上下文或逐按键事件。
|
||||
- **Official Skill Catalog**: fetch validated transform skills anonymously from the OSG content service, cache the last-known-good snapshot in App Group storage, and update enabled keyboard skills without requiring an app release. / **官方技能目录**:从 OSG 内容服务匿名获取并严格校验转换类技能,将最后可用快照缓存到 App Group,并可在无需发布新版 App 的情况下更新已启用的键盘技能。
|
||||
- **Privacy-preserving product analytics**: add an optional first-party event queue with cross-process SQLite durability, idempotent retry, fixed privacy-safe dimensions, and a device-local opt-out that clears pending events. / **隐私友好的产品分析**:新增可选的第一方事件队列,通过跨进程 SQLite 持久化、幂等重试与固定隐私安全维度可靠投递,并提供清除待发送事件的本机退出开关。
|
||||
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
|
||||
- **Anonymous four-feature OOBE**: after App Attest verifies the installation, first-run users can try voice input, clipboard translation, smart reply, and hold-to-ask AI once each without an account, API key, or credit charge; an optional sign-in step then offers the existing 1,000-credit signup reward. / **匿名四功能 OOBE**:App Attest 验证安装后,首次用户无需账号、API Key 或积分即可各体验一次语音输入、剪贴板翻译、智能回复与长按询问 AI;随后可选择登录并领取现有的 1000 积分注册奖励。
|
||||
|
||||
### Changed
|
||||
- **Paired voice history corpus**: retain the corrected pre-polish ASR transcript beside the displayed final text, together with translation and active-style metadata, so future style learning can exclude incompatible samples without exposing raw text in History. / **成对语音历史语料**:在展示的最终文本旁保留纠错后的润色前 ASR 转写,并记录翻译与当时风格元数据,便于后续风格学习排除不兼容样本,同时不在历史界面显示原文。
|
||||
- **Anonymous hint feed**: move AI hint manifests and locale packs to the account content API with ETag revalidation while preserving per-locale last-known-good packs and local evergreen merging. / **匿名提示内容源**:将 AI 提示清单与语言包迁移到账号内容 API,并加入 ETag 重新验证,同时保留各语言最后可用包与本地常驻内容合并策略。
|
||||
- **Native Liquid Glass tabs**: replace the custom iPhone dock with the system TabView so tab selection uses the native iOS 26 Liquid Glass transition and preserves each tab's navigation context. / **原生液态玻璃标签栏**:以系统 TabView 取代 iPhone 自定义 Dock,让标签切换使用 iOS 26 原生液态玻璃过渡,并保留各标签页的导航上下文。
|
||||
- **AI Agent first run**: replace the technical six-step setup with a concise privacy, permission, and keyboard flow that teaches four real keyboard capabilities before the optional account offer and verifies every lesson result before completion. / **AI Agent 首次体验**:以简洁的隐私、权限与键盘流程取代技术化的六步配置,在可选账号邀请之前教学四项真实键盘能力,并逐项验证结果后再完成。
|
||||
- **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
|
||||
|
||||
### Fixed
|
||||
- **Suspension-safe analytics**: batch uploads in the foreground or a system-granted refresh task, close SQLite outside authorized execution windows, and keep keyboard extensions recording-only to prevent system termination from database locks. / **安全挂起埋点**:仅在前台或系统授权的刷新任务中批量上传,在授权执行窗口之外关闭 SQLite,同时让键盘扩展仅负责记录,避免数据库锁触发系统终止。
|
||||
- **Signed-out AI billing safety**: switch every AI path back to user-owned BYOK when the account signs out or expires, and reject cached managed grants unless the host has confirmed an authenticated session. / **未登录 AI 计费安全**:账号退出或过期时将所有 AI 路径切回用户自备 BYOK,并在主 App 未确认有效登录会话时拒绝使用缓存的托管 Grant。
|
||||
- **Suspension-safe analytics**: batch uploads in the foreground or a system-granted refresh task, close SQLite outside authorized execution windows, release cancelled leases immediately, and keep keyboard extensions recording-only to prevent system termination from database locks. / **安全挂起埋点**:仅在前台或系统授权的刷新任务中批量上传,在授权执行窗口之外关闭 SQLite,并立即释放已取消的租约,同时让键盘扩展仅负责记录,避免数据库锁触发系统终止。
|
||||
- **One-shot purchase confirmation**: show a successful credit purchase only for the current action, automatically dismiss it, and prevent recovered historical StoreKit transactions from restoring stale green confirmation text. / **单次购买确认**:积分购买成功提示只对应当前操作并自动消失,恢复 StoreKit 历史交易时不再重新显示过期的绿色确认文字。
|
||||
- **Permanent invitation links**: load the account-scoped server invitation profile after sign-in, cache it per account, and keep sharing the exact stable server URL across refreshes and offline failures. / **永久邀请链接**:登录后异步加载账号级服务端邀请资料,按账号缓存,并在刷新或离线失败时持续分享服务端返回的固定链接。
|
||||
- **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。
|
||||
- **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。
|
||||
- **Account purchase recovery**: keep App Store credit packs independent from account-snapshot failures, retry transient product loading, cancel stale account tasks safely, and return expired sessions to sign-in without requiring a manual sign-out. / **账号内购恢复**:积分包不再受账号快照失败影响,商品瞬时加载失败会自动重试,旧账号任务可安全取消,登录过期后会直接返回登录状态,无需手动退出。
|
||||
- **Managed AI task routing**: credit-backed requests now preserve dictation, translation, editing, question, clipboard, custom-skill, and agent intent so the gateway can disable costly reasoning for low-latency transforms. / **托管 AI 任务路由**:积分请求现在会保留听写、翻译、编辑、问答、剪贴板、自定义技能与 Agent 意图,让网关可为低延迟转换任务关闭高成本思考。
|
||||
|
||||
## [2.0.0] - 2026-08-19
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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 four independent intents (`task`, `question`,
|
||||
`invitation`, and `complaint`) plus three-way sentiment. Apple data detectors
|
||||
remain responsible for dates, addresses, phone numbers, and URLs; `NLTagger`
|
||||
provides best-effort person and organization names.
|
||||
|
||||
The corpus contains 6,334 Chinese and English records:
|
||||
|
||||
- 4,100 generated training records
|
||||
- 1,080 generated validation records
|
||||
- 1,080 template-held-out test records
|
||||
- 74 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 task, question, and invitation models passed the automatic-routing
|
||||
precision gates. The complaint model is packaged for further evaluation but its
|
||||
automatic-routing flag remains disabled because golden-set precision is 88.89%,
|
||||
below the 90% release gate. 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,40 @@
|
||||
{
|
||||
"containsUserClipboardData": false,
|
||||
"families": {
|
||||
"complaint_request": 546,
|
||||
"complaint_statement": 546,
|
||||
"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": 3167,
|
||||
"zh-Hans": 3167
|
||||
},
|
||||
"positiveIntentLabels": {
|
||||
"complaint": 1092,
|
||||
"invitation": 548,
|
||||
"question": 2182,
|
||||
"task": 1094
|
||||
},
|
||||
"seed": 20260821,
|
||||
"sentiments": {
|
||||
"negative": 1578,
|
||||
"neutral": 4210,
|
||||
"positive": 546
|
||||
},
|
||||
"splits": {
|
||||
"golden": 74,
|
||||
"test": 1080,
|
||||
"train": 4100,
|
||||
"validation": 1080
|
||||
},
|
||||
"total": 6334
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,20 +22,22 @@
|
||||
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
||||
<div id="top">
|
||||
<h1>OSGKeyboard Privacy Policy</h1>
|
||||
<p><strong>Last updated:</strong> August 20, 2026 · <em>v2.0</em></p>
|
||||
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, you may optionally sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains both paths.</p>
|
||||
<p><strong>Last updated:</strong> August 22, 2026 · <em>v2.0</em></p>
|
||||
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, first-run onboarding includes a limited anonymous AI trial, and you may later sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains these options.</p>
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. With a user-configured cloud engine, recordings are sent directly to that ASR provider. If you explicitly choose managed credits, recordings are sent through the OSGKeyboard managed gateway at <code>account.osglab.com</code> to the managed ASR provider for transcription. OSGKeyboard does not keep raw recordings after the request is processed.</li>
|
||||
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you use a configured LLM or managed credits. A small amount of text around the cursor may be included to continue naturally. Secure fields are never captured, and cursor context is not written to logs or voice history. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
|
||||
<li><strong>Speaking-style learning</strong> — only after you tap “Generate My Style,” eligible pairs of pre-polish and final text, together with the polish-style prompts that produced those examples, are sent to your configured AI service. Native speech and explicit user edits are used to infer recurring habits; historical prompts are supplied only to distinguish and remove AI-added style. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. The generated prompt is shown for review and is not saved or activated until you confirm it.</li>
|
||||
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the selected AI service to generate an answer. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. When the selected service supports server-side web search, it may retrieve public web results for time-sensitive answers.</li>
|
||||
<li><strong>Anonymous onboarding AI trial</strong> — before you sign in, onboarding can issue a short-lived, feature-limited credential after Apple App Attest verifies a genuine app installation. The four fixed lessons may send one voice transcript, an explicitly copied lesson sample for translation and reply, and one transcribed AI question through <code>account.osglab.com</code>. This flow does not create an account, process an Apple identity token, use credits, or upload raw voice audio. The copied lesson sample is temporary and does not enable or populate clipboard history. The service keeps only pseudonymous integrity, request-state, feature, and metering records needed to enforce the one-use limits and investigate abuse; it does not retain request or response bodies.</li>
|
||||
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included. Opening the update page requests public content from <code>download.osglab.com</code>. Those hosts may process ordinary connection metadata such as IP address and request time.</li>
|
||||
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. Reminders, Calendar, and Notes exports pass the generated result on-device to an Apple Shortcut you install. Navigation opens an address or route in Apple Maps, Amap, or Baidu Maps; the selected provider's policy applies.</li>
|
||||
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
||||
<li><strong>Optional OSGKeyboard account and purchases</strong> — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.</li>
|
||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
|
||||
<li><strong>Optional first-party product analytics</strong> — enabled by default and removable at any time in Settings. OSGKeyboard sends only fixed event names and fixed categories about app/keyboard sessions, AI feature outcomes and duration ranges, purchase-page interactions, and invitation actions, together with an app-scoped random installation identifier and safe app/OS version labels. It never includes keyboard input, audio, transcripts, cursor context, prompts, model output, contacts, file or device names, Apple identifiers, email, credentials, advertising identifiers, or free-form properties. Turning analytics off deletes queued events; turning it back on creates a new installation identifier.</li>
|
||||
<li><strong>Optional first-party product analytics</strong> — enabled by default and removable at any time in Settings. OSGKeyboard sends only fixed event names and fixed categories about app/keyboard sessions, AI feature outcomes and duration ranges, purchase-page interactions, and invitation actions, plus daily aggregate counts of manually committed Chinese, Latin-letter, and other graphemes and four aggregate input-session classes. These records include an app-scoped random installation identifier and safe app/OS version labels. They never include typed text, pinyin, candidates, individual key events or timing, audio, transcripts, cursor context, prompts, model output, host-app or field information, contacts, file or device names, Apple identifiers, email, credentials, advertising identifiers, or free-form properties. Turning analytics off deletes queued records; turning it back on creates a new installation identifier.</li>
|
||||
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this 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>macOS local speech models</strong> — when selected, Qwen3-ASR model files are downloaded from Hugging Face and remain in the Mac app's local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
||||
@@ -53,17 +55,17 @@
|
||||
<ul>
|
||||
<li><strong>Microphone</strong> — required for voice input and background voice sessions.</li>
|
||||
<li><strong>Speech recognition</strong> — required for on-device transcription.</li>
|
||||
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, communicate with the main app, 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>
|
||||
|
||||
<h2>Third parties</h2>
|
||||
<p>User-configured cloud ASR and LLM requests go directly to the provider you configure. Managed-credit requests go through <code>account.osglab.com</code> to the managed ASR or LLM provider. Apple processes Sign in with Apple and StoreKit purchases. AI skills may hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and updates use <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each service's privacy policy applies.</p>
|
||||
<p>User-configured cloud ASR and LLM requests go directly to the provider you configure. Anonymous onboarding and managed-credit requests go through <code>account.osglab.com</code> to the managed LLM provider. Apple processes App Attest, Sign in with Apple, and StoreKit purchases. AI skills may hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and updates use <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each service's privacy policy applies.</p>
|
||||
|
||||
<h2>Data retention</h2>
|
||||
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
|
||||
<p><strong>Voice history</strong> — successful transcripts may be saved in the main 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>Product analytics</strong> — queued events expire locally after 34 days and the service accepts events no older than 35 days. Account deletion and analytics opt-out delete the local queue. Analytics identifiers are not used for cross-app tracking or advertising.</p>
|
||||
<p><strong>Product analytics</strong> — queued events expire locally after 34 days. Daily keyboard-usage summaries are upload-eligible only for the previous 35 UTC days; older or server-rejected summaries are removed from the outbox and leave only an identifier-free local status diagnostic. Account deletion and analytics opt-out delete local analytics records. Analytics identifiers are not used for cross-app tracking or advertising.</p>
|
||||
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
|
||||
<p><strong>Optional account</strong> — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.</p>
|
||||
|
||||
@@ -73,20 +75,22 @@
|
||||
|
||||
<hr id="zh">
|
||||
<h1>OSGKeyboard 隐私政策</h1>
|
||||
<p><strong>更新日期:</strong>2026 年 8 月 20 日 · <em>v2.0</em></p>
|
||||
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 用户也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策同时说明这两条可选路径。</p>
|
||||
<p><strong>更新日期:</strong>2026 年 8 月 22 日 · <em>v2.0</em></p>
|
||||
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 首次引导还提供限次匿名 AI 体验,之后也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策一并说明这些可选路径。</p>
|
||||
|
||||
<h2>我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
|
||||
<li><strong>转写文字与光标上下文</strong> — 使用已配置的 LLM 或托管积分时,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</li>
|
||||
<li><strong>说话风格学习</strong> — 只有当你主动点击「生成我的风格」后,符合条件的润色前后文本对及产出这些示例时使用的润色风格 Prompt,才会发送给你配置的 AI 服务。原生口述与用户明确编辑用于归纳反复出现的习惯;历史 Prompt 只用于识别并排除 AI 附加风格。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。生成结果会先展示供你检查,确认保存前不会写入或启用。</li>
|
||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字或你点选的建议会发送到当前选择的 AI 服务。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。所选服务支持联网搜索时,可能检索公开网页结果回答时效性问题。</li>
|
||||
<li><strong>匿名首次体验</strong> — 登录前,开机引导可在 Apple App Attest 验证正版 App 后签发短时、限功能的匿名凭证。四项固定教学会经 <code>account.osglab.com</code> 分别发送一次语音转写文字、由你明确复制的教学示例(用于翻译与回复),以及一次语音转写后的 AI 问题;不会创建账号、处理 Apple 身份令牌、使用积分或上传原始语音。教学示例仅短时使用,不会开启或写入剪贴板历史。服务端只保留执行一次性限制和调查滥用所需的匿名完整性、请求状态、功能类型及计量记录,不保留请求或回答正文。</li>
|
||||
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。相关主机可能处理 IP 地址、请求时间等普通连接元数据。</li>
|
||||
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</li>
|
||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
||||
<li><strong>可选 OSGKeyboard 账号与购买</strong> — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
|
||||
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,并携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识。绝不包含键盘输入、音频、转录、光标上下文、Prompt、模型输出、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送事件;重新开启会生成新的安装标识。</li>
|
||||
<li><strong>可选第一方产品分析</strong> — 默认开启,可随时在设置中关闭。OSGKeyboard 只发送固定事件名和固定分类,用于了解 App/键盘会话、AI 功能结果与耗时区间、购买页交互及邀请操作,以及手动上屏的中文、拉丁字母、其他字形簇每日汇总数量和四类汇总输入会话。记录会携带 App 范围内随机生成的安装标识和安全的 App/系统版本标识;绝不包含输入原文、拼音、候选词、逐按键事件或时间、音频、转录、光标上下文、Prompt、模型输出、宿主 App 或输入框信息、联系人、文件名、设备名、Apple 标识、邮箱、凭证、广告标识符或自由形式属性。关闭后会删除待发送记录;重新开启会生成新的安装标识。</li>
|
||||
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
||||
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
|
||||
@@ -104,17 +108,17 @@
|
||||
<ul>
|
||||
<li><strong>麦克风</strong> — 语音输入与后台语音会话所需。</li>
|
||||
<li><strong>语音识别</strong> — 端侧转写所需。</li>
|
||||
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key、与主 App 通信,并仅在你开启剪贴板历史后读取剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
|
||||
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key、与主 App 通信,并仅在你开启剪贴板历史,或主动复制内置教学示例进行 AI 体验时读取对应剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
|
||||
</ul>
|
||||
|
||||
<h2>第三方</h2>
|
||||
<p>用户自备的云端 ASR 与 LLM 请求直接发送到你配置的服务商;托管积分请求经 <code>account.osglab.com</code> 发送到托管 ASR 或 LLM 服务商。Apple 处理 Apple 登录与 StoreKit 购买。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各服务的隐私政策适用。</p>
|
||||
<p>用户自备的云端 ASR 与 LLM 请求直接发送到你配置的服务商;匿名首次体验与托管积分请求经 <code>account.osglab.com</code> 发送到托管 LLM 服务商。Apple 处理 App Attest、Apple 登录与 StoreKit 购买。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各服务的隐私政策适用。</p>
|
||||
|
||||
<h2>数据保留</h2>
|
||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
||||
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
||||
<p><strong>语音历史与学习风格</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。每条记录可同时保留界面展示的最终文本,以及在可用时保留 AI 润色前、已经过确定性纠错的 ASR 文本与当次使用的润色风格 Prompt 去重快照,供用户日后主动生成个人风格;润色前文本不会在历史界面显示,记录中也不保存原始音频或光标上下文。清空全部历史也会删除保留的 Prompt 快照。生成的风格只有在你检查并保存后才会写入。开启 iCloud 设置同步后,符合条件的历史与自定义风格可能通过你的私人 iCloud 账户在多设备间同步。</p>
|
||||
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
||||
<p><strong>产品分析</strong> — 待发送事件会在本地满 34 天后过期,服务端只接受 35 天以内的事件。注销账号或关闭产品分析会删除本地队列。分析标识不会用于跨 App 跟踪或广告。</p>
|
||||
<p><strong>产品分析</strong> — 待发送事件会在本地满 34 天后过期。每日键盘用量摘要仅可上传最近 35 个已完成的 UTC 日期;更早或被服务端拒绝的摘要会从待发送队列移除,只留下不含标识符的本地状态诊断。注销账号或关闭产品分析会删除本地分析记录。分析标识不会用于跨 App 跟踪或广告。</p>
|
||||
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
||||
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ enum AIAgentShortcutRunner {
|
||||
@MainActor
|
||||
static func runPendingIfNeeded() {
|
||||
AIAgentShortcutRun.trace("host.runPending begin")
|
||||
guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
|
||||
let catalog = AppGroupStore().agentUserSkillCatalog
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID, userCatalog: catalog) else {
|
||||
let store = AppGroupStore()
|
||||
guard let payload = store.consumePendingShortcutRun() else { return }
|
||||
guard let skill = store.resolvedAgentSkillCatalog.first(where: { $0.id == payload.skillID }) else {
|
||||
AIAgentShortcutRun.trace(
|
||||
"host.runPending skip unknownSkill=\(payload.skillID)"
|
||||
)
|
||||
|
||||
@@ -8,42 +8,71 @@
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum AIHintRefreshOutcome: Equatable {
|
||||
case skippedFresh
|
||||
case completed(updatedLocales: [String])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum AIHintRefreshService {
|
||||
private static var inFlight: Task<Void, Never>?
|
||||
final class AIHintRefreshService {
|
||||
static let shared = AIHintRefreshService()
|
||||
|
||||
private let transport: any PublicContentHTTPTransport
|
||||
private let defaults: UserDefaults?
|
||||
private let baseURL: URL
|
||||
private var inFlight: Task<AIHintRefreshOutcome, Never>?
|
||||
|
||||
init(
|
||||
transport: any PublicContentHTTPTransport = URLSessionPublicContentHTTPTransport(),
|
||||
defaults: UserDefaults? = AppGroup.defaultsIfAvailable,
|
||||
baseURL: URL = AIHintFeedEndpoints.baseURL
|
||||
) {
|
||||
self.transport = transport
|
||||
self.defaults = defaults
|
||||
self.baseURL = baseURL
|
||||
}
|
||||
|
||||
static func refreshIfNeeded(reason: String) {
|
||||
guard AIHintStore.shouldRefresh() else {
|
||||
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
|
||||
return
|
||||
shared.scheduleRefreshIfNeeded(reason: reason)
|
||||
}
|
||||
|
||||
func scheduleRefreshIfNeeded(reason: String) {
|
||||
guard inFlight == nil else {
|
||||
OSGDiag.log("AIHintRefresh skip (inFlight) reason=\(reason)", category: "hints")
|
||||
return
|
||||
}
|
||||
inFlight = Task {
|
||||
defer { inFlight = nil }
|
||||
await runRefresh(reason: reason)
|
||||
return await refreshNowIfNeeded(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private static func runRefresh(reason: String) async {
|
||||
AIHintStore.markAttempt()
|
||||
func refreshNowIfNeeded(
|
||||
reason: String,
|
||||
now: Date = Date(),
|
||||
force: Bool = false
|
||||
) async -> AIHintRefreshOutcome {
|
||||
if !force, !AIHintStore.shouldRefresh(now: now, defaults: defaults) {
|
||||
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
|
||||
return .skippedFresh
|
||||
}
|
||||
|
||||
AIHintStore.markAttempt(defaults: defaults)
|
||||
OSGDiag.log("AIHintRefresh start reason=\(reason)", category: "hints")
|
||||
|
||||
// The manifest only supplies fallback dates, so a manifest failure must
|
||||
// not stop the packs, and one locale's failure must not stop the other.
|
||||
let manifest = try? await fetchManifest()
|
||||
let manifest = (try? await fetchManifest()) ?? AIHintStore.loadManifest(defaults: defaults)
|
||||
var updatedLocales: [String] = []
|
||||
for locale in AIHintFeedEndpoints.supportedLocales {
|
||||
if Task.isCancelled { return }
|
||||
guard AIHintStore.shouldRefresh(locale: locale) else { continue }
|
||||
if Task.isCancelled { break }
|
||||
if !force,
|
||||
!AIHintStore.shouldRefresh(locale: locale, now: now, defaults: defaults) {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
let pack = try await readyPack(locale: locale, manifest: manifest)
|
||||
AIHintStore.saveReadyPack(pack)
|
||||
OSGDiag.log(
|
||||
"AIHintRefresh wrote locale=\(locale) cards=\(pack.cards.count)",
|
||||
category: "hints"
|
||||
)
|
||||
try await refreshPack(locale: locale, manifest: manifest, now: now)
|
||||
updatedLocales.append(locale)
|
||||
} catch {
|
||||
// Strategy B: keep this locale's previous successful ready pack.
|
||||
OSGDiag.log(
|
||||
@@ -53,31 +82,67 @@ enum AIHintRefreshService {
|
||||
)
|
||||
}
|
||||
}
|
||||
return .completed(updatedLocales: updatedLocales)
|
||||
}
|
||||
|
||||
private static func readyPack(
|
||||
private func refreshPack(
|
||||
locale: String,
|
||||
manifest: AIHintManifest?
|
||||
) async throws -> AIHintPack {
|
||||
let remote = try await fetchPack(locale: locale)
|
||||
manifest: AIHintManifest?,
|
||||
now: Date
|
||||
) async throws {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(locale))
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let etag = AIHintStore.packETag(locale: locale, defaults: defaults) {
|
||||
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
||||
}
|
||||
let (data, response) = try await transport.data(for: request)
|
||||
|
||||
if response.statusCode == 304 {
|
||||
guard var existing = AIHintStore.loadReadyPack(locale: locale, defaults: defaults) else {
|
||||
throw PublicContentHTTPError.notModifiedWithoutCache
|
||||
}
|
||||
existing.refreshedAt = now
|
||||
AIHintStore.saveReadyPack(existing, defaults: defaults)
|
||||
if let etag = response.value(forHTTPHeaderField: "ETag") {
|
||||
AIHintStore.setPackETag(etag, locale: locale, defaults: defaults)
|
||||
}
|
||||
OSGDiag.log("AIHintRefresh kept locale=\(locale) (304)", category: "hints")
|
||||
return
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
|
||||
let remote = try JSONDecoder().decode(AIHintPack.self, from: data)
|
||||
let filtered = remote.cards.filter { card in
|
||||
let hay = card.displayText + card.prompt
|
||||
return !hay.contains("历史上的今天")
|
||||
&& !hay.localizedCaseInsensitiveContains("on this day")
|
||||
}
|
||||
// Prefer remote clipboard cards when present; always keep local evergreen.
|
||||
let merged = merge(remote: filtered, locale: locale)
|
||||
let merged = Self.merge(remote: filtered, locale: locale)
|
||||
let compressed = await AIHintKeywordCompressor().compress(
|
||||
cards: merged,
|
||||
locale: locale
|
||||
)
|
||||
return AIHintPack(
|
||||
let ready = AIHintPack(
|
||||
locale: locale,
|
||||
generatedAt: remote.generatedAt ?? manifest?.generatedAt,
|
||||
expiresAt: remote.expiresAt ?? manifest?.expiresAt,
|
||||
version: max(remote.version, 1),
|
||||
cards: compressed,
|
||||
refreshedAt: Date()
|
||||
refreshedAt: now
|
||||
)
|
||||
AIHintStore.saveReadyPack(ready, defaults: defaults)
|
||||
AIHintStore.setPackETag(
|
||||
response.value(forHTTPHeaderField: "ETag"),
|
||||
locale: locale,
|
||||
defaults: defaults
|
||||
)
|
||||
OSGDiag.log(
|
||||
"AIHintRefresh wrote locale=\(locale) cards=\(ready.cards.count)",
|
||||
category: "hints"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,25 +168,26 @@ enum AIHintRefreshService {
|
||||
return Array(byID.values).sorted { $0.priority > $1.priority }
|
||||
}
|
||||
|
||||
private static func fetchManifest() async throws -> AIHintManifest {
|
||||
let (data, response) = try await URLSession.shared.data(from: AIHintFeedEndpoints.manifestURL)
|
||||
try validateHTTP(response)
|
||||
return try JSONDecoder().decode(AIHintManifest.self, from: data)
|
||||
private func fetchManifest() async throws -> AIHintManifest? {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("manifest"))
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let etag = AIHintStore.manifestETag(defaults: defaults) {
|
||||
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
||||
}
|
||||
|
||||
private static func fetchPack(locale: String) async throws -> AIHintPack {
|
||||
let url = AIHintFeedEndpoints.packURL(locale: locale)
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
try validateHTTP(response)
|
||||
return try JSONDecoder().decode(AIHintPack.self, from: data)
|
||||
let (data, response) = try await transport.data(for: request)
|
||||
if response.statusCode == 304 {
|
||||
return AIHintStore.loadManifest(defaults: defaults)
|
||||
}
|
||||
|
||||
private static func validateHTTP(_ response: URLResponse) throws {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw URLError(.badServerResponse)
|
||||
guard response.statusCode == 200 else {
|
||||
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
let manifest = try JSONDecoder().decode(AIHintManifest.self, from: data)
|
||||
AIHintStore.saveManifest(
|
||||
manifest,
|
||||
etag: response.value(forHTTPHeaderField: "ETag"),
|
||||
defaults: defaults
|
||||
)
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,16 +48,33 @@ struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding {
|
||||
|
||||
func bearerToken() async throws -> String? {
|
||||
guard try await apiClient.currentSession() != nil else { return nil }
|
||||
do {
|
||||
return try await apiClient.accessTokenForAuthorizedRequest()
|
||||
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func refreshBearerToken(
|
||||
afterUnauthorizedAccessToken failedToken: String?
|
||||
) async throws -> String? {
|
||||
guard let failedToken, !failedToken.isEmpty else { return nil }
|
||||
do {
|
||||
return try await apiClient.refreshAccessToken(
|
||||
afterUnauthorizedAccessToken: failedToken
|
||||
)
|
||||
} catch let error as AccountAPIError where Self.isInvalidSession(error) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isInvalidSession(_ error: AccountAPIError) -> Bool {
|
||||
switch error {
|
||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +109,7 @@ final class AnalyticsHostService: ObservableObject {
|
||||
let client: any AnalyticsClient
|
||||
|
||||
private let runtime: AnalyticsRuntime
|
||||
private let keyboardUsageRuntime: KeyboardUsageRuntime
|
||||
private let uploadSignal: AnalyticsUploadSignal
|
||||
private let pathMonitor = NWPathMonitor()
|
||||
private let monitorQueue = DispatchQueue(
|
||||
@@ -123,7 +141,19 @@ final class AnalyticsHostService: ObservableObject {
|
||||
trigger: signal,
|
||||
logger: HostAnalyticsLogger()
|
||||
)
|
||||
let keyboardUsageRuntime = KeyboardUsageRuntime(
|
||||
environment: environment,
|
||||
analyticsRepository: runtime.repository,
|
||||
uploadConfiguration: KeyboardUsageUploadConfiguration(
|
||||
endpoint: Self.keyboardUsageEndpoint
|
||||
),
|
||||
network: network,
|
||||
bearerProvider: bearerProvider,
|
||||
trigger: signal,
|
||||
logger: HostAnalyticsLogger()
|
||||
)
|
||||
self.runtime = runtime
|
||||
self.keyboardUsageRuntime = keyboardUsageRuntime
|
||||
client = runtime.client
|
||||
|
||||
Task {
|
||||
@@ -132,6 +162,11 @@ final class AnalyticsHostService: ObservableObject {
|
||||
await runtime.uploadCoordinator.uploadAvailableEvents(
|
||||
maximumBatches: uploadPolicy.maximumBatches
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
await keyboardUsageRuntime.uploadCoordinator
|
||||
.uploadAvailableSummaries(
|
||||
maximumBatches: uploadPolicy.maximumBatches
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +230,9 @@ final class AnalyticsHostService: ObservableObject {
|
||||
await MainActor.run {
|
||||
self.isEnabled = current
|
||||
}
|
||||
if current {
|
||||
if !current {
|
||||
await keyboardUsageRuntime.repository.clearAll()
|
||||
} else {
|
||||
await uploadSignal.requestActivationUpload()
|
||||
}
|
||||
}
|
||||
@@ -259,12 +296,14 @@ final class AnalyticsHostService: ObservableObject {
|
||||
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
|
||||
)
|
||||
@@ -289,11 +328,13 @@ final class AnalyticsHostService: ObservableObject {
|
||||
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)
|
||||
}
|
||||
@@ -306,6 +347,7 @@ final class AnalyticsHostService: ObservableObject {
|
||||
|
||||
if hasPendingAccountDeletion {
|
||||
await runtime.handleAccountDeletion()
|
||||
await keyboardUsageRuntime.repository.clearAll()
|
||||
hasPendingAccountDeletion = false
|
||||
}
|
||||
|
||||
@@ -314,6 +356,9 @@ final class AnalyticsHostService: ObservableObject {
|
||||
stableIdentifier: accountID.uuidString
|
||||
)
|
||||
guard requestedDatabaseAccess == .foreground else { return }
|
||||
if case .switchedAccount = observation {
|
||||
await keyboardUsageRuntime.repository.clearAll()
|
||||
}
|
||||
if case .unavailable = observation {
|
||||
return
|
||||
}
|
||||
@@ -327,16 +372,24 @@ final class AnalyticsHostService: ObservableObject {
|
||||
guard !Task.isCancelled else { return false }
|
||||
|
||||
await runtime.repository.resumeDatabaseAccess()
|
||||
await keyboardUsageRuntime.repository.resumeDatabaseAccess()
|
||||
|
||||
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1)
|
||||
let success = !Task.isCancelled
|
||||
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
|
||||
@@ -360,6 +413,10 @@ final class AnalyticsHostService: ObservableObject {
|
||||
string: "https://account.osglab.com/v1/analytics/events"
|
||||
)!
|
||||
|
||||
private static let keyboardUsageEndpoint = URL(
|
||||
string: "https://account.osglab.com/v1/analytics/keyboard-usage"
|
||||
)!
|
||||
|
||||
private static var environment: AnalyticsEnvironment {
|
||||
let appVersion = Bundle.main.object(
|
||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
let question: String
|
||||
let conversationID: UUID?
|
||||
let taskKind: ManagedGatewayTaskKind
|
||||
let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
let oobeFeature: ManagedGatewayOOBEFeature?
|
||||
let thinkingEnabled: Bool
|
||||
}
|
||||
|
||||
@@ -162,6 +164,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var pendingSourceHistoryEntryRevision: Int64?
|
||||
private var pendingAIConversationID: UUID?
|
||||
private var pendingManagedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||
private var pendingManagedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||
private var pendingProcessingDeadlineAt: TimeInterval?
|
||||
private var pendingStopUtteranceId: UUID?
|
||||
private var currentCommandSeq: Int64 = 0
|
||||
@@ -174,7 +177,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
@Published private(set) var isUtteranceProcessing = false
|
||||
private var finalizeTask: Task<Void, Never>?
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var transcriptionAnalyticsOperation: (any AnalyticsAIOperation)?
|
||||
private let analyticsOperations = FlowAnalyticsOperationRegistry()
|
||||
private var utteranceSafetyTask: Task<Void, Never>?
|
||||
private var chunkedPipeline: ChunkedUtterancePipeline?
|
||||
private var currentPartial = ""
|
||||
@@ -442,6 +445,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
status: .aborted
|
||||
)
|
||||
}
|
||||
analyticsOperations.cancelAll()
|
||||
|
||||
wantsActiveSession = false
|
||||
pipRecoveryOperation &+= 1
|
||||
@@ -527,6 +531,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
status: .aborted
|
||||
)
|
||||
}
|
||||
analyticsOperations.cancelAll()
|
||||
|
||||
wantsActiveSession = false
|
||||
pipRecoveryOperation &+= 1
|
||||
@@ -1502,6 +1507,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
)
|
||||
currentUtteranceMode = command.resolvedUtteranceMode
|
||||
pendingManagedRequestPurpose = command.managedRequestPurpose
|
||||
pendingManagedOOBEFeature = command.managedOOBEFeature
|
||||
pendingAIConversationID = currentUtteranceMode == .aiQuestion
|
||||
? command.aiConversationID
|
||||
: nil
|
||||
@@ -1615,6 +1621,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
) else { return }
|
||||
currentUtteranceMode = .aiQuestion
|
||||
pendingManagedRequestPurpose = command.managedRequestPurpose
|
||||
pendingManagedOOBEFeature = command.managedOOBEFeature
|
||||
pendingAIConversationID = command.aiConversationID
|
||||
pendingEditSourceText = nil
|
||||
pendingSourceHistoryEntryID = nil
|
||||
@@ -1637,6 +1644,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
question: question,
|
||||
conversationID: conversationID,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: command.managedRequestPurpose,
|
||||
oobeFeature: command.managedOOBEFeature,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
),
|
||||
sessionId: sessionId,
|
||||
@@ -1689,10 +1698,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
// send an XML envelope that must never appear above the mic.
|
||||
let pipelineStore = AppGroupStore()
|
||||
do {
|
||||
let taskKind = if pipelineStore.credentialSource == .managed,
|
||||
request.requestPurpose == nil {
|
||||
ManagedGatewayQuestionRouter.taskKind(
|
||||
for: request.question,
|
||||
requestedTaskKind: request.taskKind
|
||||
)
|
||||
} else {
|
||||
request.taskKind
|
||||
}
|
||||
let service = try AIQuestionService.configured(
|
||||
store: pipelineStore,
|
||||
conversations: aiConversations,
|
||||
taskKind: request.taskKind,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: request.requestPurpose,
|
||||
oobeFeature: request.oobeFeature,
|
||||
thinkingEnabled: request.thinkingEnabled,
|
||||
analyticsClient: analyticsClient,
|
||||
analyticsFeature: Self.analyticsFeature(for: request.taskKind)
|
||||
@@ -1870,7 +1890,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
||||
status: FlowResult.Status = .error
|
||||
) {
|
||||
finishTranscriptionAnalytics(
|
||||
finishUtteranceAnalytics(
|
||||
utteranceID: currentUtteranceId,
|
||||
success: false,
|
||||
failureCategory: Self.transcriptionFailureCategory(
|
||||
kind: kind,
|
||||
@@ -2168,11 +2189,19 @@ final class FlowSessionManager: ObservableObject {
|
||||
)
|
||||
|
||||
let cloudASRForStreaming = useStreaming ? cloudService : nil
|
||||
transcriptionAnalyticsOperation?.cancel()
|
||||
transcriptionAnalyticsOperation = analyticsClient.startAIFeature(
|
||||
.transcription,
|
||||
executionMode: analyticsExecutionMode
|
||||
if let taskUtteranceId {
|
||||
// A healed delivery gate may allow a replacement utterance before
|
||||
// an old finalize task unwinds. Close any old value task first.
|
||||
analyticsOperations.cancelAll()
|
||||
analyticsOperations.start(
|
||||
utteranceID: taskUtteranceId,
|
||||
feature: FlowAnalyticsFeatureMapping.recordingFeature(
|
||||
for: currentUtteranceMode
|
||||
),
|
||||
executionMode: analyticsExecutionMode,
|
||||
client: analyticsClient
|
||||
)
|
||||
}
|
||||
|
||||
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
||||
let outcome: ChunkedUtterancePipelineOutcome
|
||||
@@ -2262,7 +2291,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
manager.failUtterance(message: message, kind: .asrFailed)
|
||||
}
|
||||
case .cancelled:
|
||||
break
|
||||
// A provider-side cancellation while the mic is still open
|
||||
// has no later successful finalize path. Close the
|
||||
// utterance now so its analytics STARTED cannot be orphaned.
|
||||
let hasRecoverableText = !manager.currentPartial
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !manager.bestPartialSnapshot
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
if manager.isUtteranceRecording, !hasRecoverableText {
|
||||
manager.failUtterance(
|
||||
message: AppL10n.string(
|
||||
"flow.error.recognitionInterrupted"
|
||||
),
|
||||
kind: .recognitionInterrupted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2393,6 +2436,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
status: .aborted
|
||||
)
|
||||
}
|
||||
analyticsOperations.cancel(utteranceID: utteranceId)
|
||||
isUtteranceRecording = false
|
||||
isUtteranceProcessing = false
|
||||
startingUtteranceId = nil
|
||||
@@ -2493,6 +2537,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
pendingSourceHistoryEntryRevision = nil
|
||||
pendingAIConversationID = nil
|
||||
pendingManagedRequestPurpose = nil
|
||||
pendingManagedOOBEFeature = nil
|
||||
pendingProcessingDeadlineAt = nil
|
||||
currentUtteranceMode = .dictation
|
||||
}
|
||||
@@ -2511,6 +2556,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
let sourceHistoryEntryRevision = pendingSourceHistoryEntryRevision
|
||||
let aiConversationID = pendingAIConversationID
|
||||
let managedRequestPurpose = pendingManagedRequestPurpose
|
||||
let managedOOBEFeature = pendingManagedOOBEFeature
|
||||
let processingDeadlineAt = pendingProcessingDeadlineAt
|
||||
let asrEngineMode = sessionASREngineMode ?? store.engineMode
|
||||
// ALWAYS clear the processing gate for this utterance. The previous
|
||||
@@ -2519,12 +2565,17 @@ final class FlowSessionManager: ObservableObject {
|
||||
// and then skip refreshHostReady — keyboard stayed white forever
|
||||
// while host logs still said "utterance finalized".
|
||||
defer {
|
||||
// Any path that returns without an explicit terminal event is a
|
||||
// cancellation from the user's perspective. The registry is keyed
|
||||
// by utterance ID, so this cannot finish a newer recording.
|
||||
analyticsOperations.cancel(utteranceID: finalizeUtteranceId)
|
||||
pendingFieldContext = nil
|
||||
pendingEditSourceText = nil
|
||||
pendingSourceHistoryEntryID = nil
|
||||
pendingSourceHistoryEntryRevision = nil
|
||||
pendingAIConversationID = nil
|
||||
pendingManagedRequestPurpose = nil
|
||||
pendingManagedOOBEFeature = nil
|
||||
pendingProcessingDeadlineAt = nil
|
||||
if currentUtteranceMode == utteranceMode {
|
||||
currentUtteranceMode = .dictation
|
||||
@@ -2641,7 +2692,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
)
|
||||
return
|
||||
}
|
||||
finishTranscriptionAnalytics(success: true)
|
||||
if utteranceMode != .aiQuestion {
|
||||
finishUtteranceAnalytics(
|
||||
utteranceID: finalizeUtteranceId,
|
||||
success: true
|
||||
)
|
||||
}
|
||||
|
||||
// Re-read App Group at finalize so dictionary and translation changes
|
||||
// from the keyboard extension are visible before local correction,
|
||||
@@ -2700,10 +2756,20 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
|
||||
do {
|
||||
let taskKind = if pipelineStore.credentialSource == .managed,
|
||||
managedRequestPurpose == nil {
|
||||
// Classify the post-ASR spoken text before clipboard
|
||||
// resolution can wrap it in an internal prompt envelope.
|
||||
ManagedGatewayQuestionRouter.taskKind(for: text)
|
||||
} else {
|
||||
ManagedGatewayTaskKind.aiQuestion
|
||||
}
|
||||
let service = try AIQuestionService.configured(
|
||||
store: pipelineStore,
|
||||
conversations: aiConversations,
|
||||
taskKind: .aiQuestion,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: managedRequestPurpose,
|
||||
oobeFeature: managedOOBEFeature,
|
||||
analyticsClient: analyticsClient,
|
||||
analyticsFeature: .aiAssistant
|
||||
)
|
||||
@@ -2715,7 +2781,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
let answer = try await service.answer(
|
||||
question: question,
|
||||
conversationID: aiConversationID,
|
||||
targetLocaleID: pipelineStore.translationTargetLocaleId
|
||||
targetLocaleID: pipelineStore.translationTargetLocaleId,
|
||||
analyticsOperation: analyticsOperations.operation(
|
||||
for: finalizeUtteranceId
|
||||
)
|
||||
) { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
self?.publishStreamingAIAnswerIfNeeded(
|
||||
@@ -2843,6 +2912,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
||||
taskKind: isEditLastInput ? .editLastInput : nil,
|
||||
requestPurpose: managedRequestPurpose,
|
||||
oobeFeature: managedOOBEFeature,
|
||||
context: isInstructionMode ? nil : polishContext,
|
||||
timeoutLimit: processingDeadlineAt.map {
|
||||
max(0.1, $0 - Date().timeIntervalSince1970)
|
||||
@@ -2872,6 +2942,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
? nil
|
||||
: SpeechHistoryStore.shared.recordUtterance(
|
||||
text: delivered,
|
||||
prePolishText: text,
|
||||
polishStyleID: outcome.polishStyleID
|
||||
?? pipelineStore.activePolishStyleId,
|
||||
polishStylePrompt: outcome.polishStylePrompt,
|
||||
engineMode: engineMode,
|
||||
duration: recordingDuration,
|
||||
wasTranslation: isInstructionMode
|
||||
@@ -2951,6 +3025,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
|
||||
let historyEntry = SpeechHistoryStore.shared.recordUtterance(
|
||||
text: delivered,
|
||||
prePolishText: text,
|
||||
polishStyleID: pipelineStore.activePolishStyleId,
|
||||
// The provider failed, so this fallback was not produced by
|
||||
// the style Prompt and must not become Prompt-learning evidence.
|
||||
polishStylePrompt: nil,
|
||||
engineMode: engineMode,
|
||||
duration: recordingDuration,
|
||||
wasTranslation: pipelineStore.isTranslationEffective
|
||||
@@ -3147,7 +3226,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
status: FlowResult.Status = .error,
|
||||
aiConversationID: UUID? = nil
|
||||
) {
|
||||
finishTranscriptionAnalytics(
|
||||
finishUtteranceAnalytics(
|
||||
utteranceID: utteranceId,
|
||||
success: false,
|
||||
failureCategory: Self.transcriptionFailureCategory(
|
||||
kind: kind,
|
||||
@@ -3181,14 +3261,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
private static func analyticsFeature(
|
||||
for taskKind: ManagedGatewayTaskKind
|
||||
) -> AnalyticsFeature {
|
||||
switch taskKind {
|
||||
case .aiQuestion:
|
||||
return .hotword
|
||||
case .clipboardTransform, .customSkill, .agentPlanning:
|
||||
return .agent
|
||||
case .dictationPolish, .translation, .editLastInput:
|
||||
return .other
|
||||
}
|
||||
FlowAnalyticsFeatureMapping.feature(for: taskKind)
|
||||
}
|
||||
|
||||
private var analyticsExecutionMode: AnalyticsExecutionMode {
|
||||
@@ -3198,16 +3271,18 @@ final class FlowSessionManager: ObservableObject {
|
||||
return store.credentialSource == .managed ? .managed : .byok
|
||||
}
|
||||
|
||||
private func finishTranscriptionAnalytics(
|
||||
private func finishUtteranceAnalytics(
|
||||
utteranceID: UUID?,
|
||||
success: Bool,
|
||||
failureCategory: AnalyticsFailureCategory = .unknown
|
||||
) {
|
||||
guard let operation = transcriptionAnalyticsOperation else { return }
|
||||
transcriptionAnalyticsOperation = nil
|
||||
if success {
|
||||
operation.succeed()
|
||||
analyticsOperations.succeed(utteranceID: utteranceID)
|
||||
} else {
|
||||
operation.fail(category: failureCategory)
|
||||
analyticsOperations.fail(
|
||||
utteranceID: utteranceID,
|
||||
category: failureCategory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3370,6 +3445,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
providerIdOverride: String?,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
context: PolishContext?,
|
||||
timeoutLimit: TimeInterval? = nil
|
||||
) async throws -> PolishingService.PolishOutcome {
|
||||
@@ -3383,6 +3459,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,137 @@
|
||||
// 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)
|
||||
return .updated(revision: received.revision)
|
||||
default:
|
||||
throw PublicContentHTTPError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// PublicContentHTTPTransport.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Anonymous public-content transport. It is intentionally independent from
|
||||
// account/auth clients and is never linked into keyboard-extension behavior.
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol PublicContentHTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
|
||||
final class URLSessionPublicContentHTTPTransport:
|
||||
PublicContentHTTPTransport,
|
||||
@unchecked Sendable {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw PublicContentHTTPError.invalidResponse
|
||||
}
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
enum PublicContentHTTPError: Error, Equatable {
|
||||
case invalidResponse
|
||||
case unexpectedStatus(Int)
|
||||
case notModifiedWithoutCache
|
||||
case staleRevision(current: Int64, received: Int64)
|
||||
}
|
||||
@@ -11,11 +11,21 @@ extension SpeechHistoryStore {
|
||||
@discardableResult
|
||||
func recordUtterance(
|
||||
text: String,
|
||||
prePolishText: String,
|
||||
polishStyleID: String,
|
||||
polishStylePrompt: String?,
|
||||
engineMode: String,
|
||||
duration: TimeInterval,
|
||||
wasTranslation: Bool
|
||||
) -> SpeechHistoryEntry? {
|
||||
let entry = append(text: text, engineMode: engineMode)
|
||||
let entry = append(
|
||||
text: text,
|
||||
prePolishText: prePolishText,
|
||||
wasTranslation: wasTranslation,
|
||||
polishStyleID: polishStyleID,
|
||||
polishStylePrompt: polishStylePrompt,
|
||||
engineMode: engineMode
|
||||
)
|
||||
UsageStatisticsStore.shared.recordUtterance(
|
||||
text: text,
|
||||
duration: duration,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// AIAgentSkillsView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
|
||||
// Catalog of AI Agent clipboard skills. Enabled cards appear on
|
||||
// the keyboard after a copy; long-press drag rearranges that row live,
|
||||
// like Home Screen icons. Export skills confirm a companion Shortcut
|
||||
// before they can occupy a slot. Users can add custom export skills.
|
||||
@@ -18,7 +18,6 @@ struct AIAgentSkillsView: View {
|
||||
|
||||
@State private var viewingSkill: AIClipboardSkill?
|
||||
@State private var editingDraft: SkillEditorDraft?
|
||||
@State private var showFullAlert = false
|
||||
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
@State private var pasteAccessNeedsRecovery = false
|
||||
@State private var showPasteNoTextAlert = false
|
||||
@@ -26,11 +25,6 @@ struct AIAgentSkillsView: View {
|
||||
/// True while a skill card is lifted; locks the page scroll like SpringBoard.
|
||||
@State private var isReordering = false
|
||||
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: Spacing.sm),
|
||||
GridItem(.flexible(), spacing: Spacing.sm)
|
||||
]
|
||||
|
||||
private var skillCardShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
}
|
||||
@@ -84,14 +78,6 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("skills.full.title", language: config.uiLanguage),
|
||||
isPresented: $showFullAlert
|
||||
) {
|
||||
Button("common.done") { showFullAlert = false }
|
||||
} message: {
|
||||
Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||
isPresented: $showPasteNoTextAlert
|
||||
@@ -102,10 +88,19 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
.onAppear {
|
||||
store.reload()
|
||||
refreshOfficialSkillCatalog(
|
||||
reason: "AIAgentSkillsView.onAppear",
|
||||
force: true
|
||||
)
|
||||
refreshPasteAccessState()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
store.reload()
|
||||
refreshOfficialSkillCatalog(
|
||||
reason: "AIAgentSkillsView.active",
|
||||
force: true
|
||||
)
|
||||
refreshPasteAccessState()
|
||||
pasteAccessNeedsRecovery = false
|
||||
}
|
||||
@@ -291,13 +286,24 @@ struct AIAgentSkillsView: View {
|
||||
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
}
|
||||
|
||||
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 enabledSection: some View {
|
||||
CardSection(
|
||||
title: AppL10n.format(
|
||||
"skills.enabled.section",
|
||||
language: config.uiLanguage,
|
||||
store.enabledSkills.count,
|
||||
AIAgentSkillLayout.maximumEnabled
|
||||
store.enabledSkills.count
|
||||
)
|
||||
) {
|
||||
if store.enabledSkills.isEmpty {
|
||||
@@ -318,17 +324,23 @@ struct AIAgentSkillsView: View {
|
||||
skillCardVisual(skill, isEnabled: true)
|
||||
}
|
||||
)
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.clipShape(skillCardShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var availableSection: some View {
|
||||
CardSection("skills.available.section") {
|
||||
LazyVGrid(columns: columns, spacing: Spacing.sm) {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(store.availableSkills) { skill in
|
||||
skillCardInteractive(skill, isEnabled: false)
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.clipShape(skillCardShape)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,44 +376,50 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
|
||||
private func skillCardVisual(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: skill.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(palette.accentMuted, in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(skillTitle(skill))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.padding(.trailing, 32)
|
||||
Text(skillDescription(skill))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
|
||||
CatalogCardChrome.editIcon(palette: palette)
|
||||
.padding(Spacing.sm)
|
||||
.allowsHitTesting(false)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if isEnabled {
|
||||
CatalogCardChrome.checkIcon(palette: palette)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(width: 18)
|
||||
}
|
||||
.background(
|
||||
isEnabled ? palette.accentMuted : palette.surface,
|
||||
in: skillCardShape
|
||||
)
|
||||
.overlay(skillCardShape.stroke(
|
||||
isEnabled ? palette.accent : palette.divider,
|
||||
lineWidth: isEnabled ? 1.5 : 0.5
|
||||
))
|
||||
.clipShape(skillCardShape)
|
||||
.contentShape(skillCardShape)
|
||||
.containerShape(skillCardShape)
|
||||
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.surface)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 38 + Spacing.md * 2)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func skillTitle(_ skill: AIClipboardSkill) -> String {
|
||||
@@ -413,25 +431,25 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
|
||||
private func skillDescription(_ skill: AIClipboardSkill) -> String {
|
||||
if skill.isUserCreated {
|
||||
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !summary.isEmpty { return summary }
|
||||
if skill.isUserCreated {
|
||||
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
|
||||
}
|
||||
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
|
||||
}
|
||||
|
||||
private func handleCardTap(_ skill: AIClipboardSkill) {
|
||||
if store.layout.isEnabled(skill.id) {
|
||||
store.disable(skill.id)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
@@ -445,8 +463,6 @@ struct AIAgentSkillsView: View {
|
||||
switch store.enable(skill.id) {
|
||||
case .enabled, .alreadyEnabled:
|
||||
viewingSkill = nil
|
||||
case .full:
|
||||
showFullAlert = true
|
||||
case .needsShortcut, .unknown:
|
||||
break
|
||||
}
|
||||
@@ -456,8 +472,6 @@ struct AIAgentSkillsView: View {
|
||||
switch store.confirmShortcutAndEnable(skill.id) {
|
||||
case .enabled, .alreadyEnabled:
|
||||
viewingSkill = nil
|
||||
case .full:
|
||||
showFullAlert = true
|
||||
case .needsShortcut, .unknown:
|
||||
break
|
||||
}
|
||||
@@ -491,8 +505,8 @@ 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.
|
||||
/// List-style reorder: long-press lifts the row, other rows slide into the
|
||||
/// gap as the finger crosses midpoints, and drop settles in place.
|
||||
private struct EnabledSkillsReorderGrid<Card: View>: View {
|
||||
let skills: [AIClipboardSkill]
|
||||
@Binding var isReordering: Bool
|
||||
@@ -509,13 +523,12 @@ private struct EnabledSkillsReorderGrid<Card: View>: View {
|
||||
|
||||
private static var spaceName: String { "enabledSkillsGrid" }
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: Spacing.sm),
|
||||
GridItem(.flexible(), spacing: Spacing.sm)
|
||||
GridItem(.flexible(), spacing: 0)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
LazyVGrid(columns: columns, spacing: Spacing.sm) {
|
||||
LazyVGrid(columns: columns, spacing: 0) {
|
||||
ForEach(skills) { skill in
|
||||
gridCell(skill)
|
||||
}
|
||||
@@ -775,9 +788,9 @@ private struct SkillDetailSheet: View {
|
||||
}
|
||||
|
||||
private var skillDescription: String {
|
||||
if skill.isUserCreated {
|
||||
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !summary.isEmpty { return summary }
|
||||
if skill.isUserCreated {
|
||||
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
|
||||
}
|
||||
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Interactive layout preview and What's New recording host for AI Agent
|
||||
// clipboard skills on the real unified `AIKeyboardView`. Launch with
|
||||
// `--ai-skills-demo`, optional `--skills-count=N` (1...8), and optional
|
||||
// `--ai-skills-demo`, optional `--skills-count=N`, and optional
|
||||
// `--whats-new-lang=zh|en`.
|
||||
|
||||
#if DEBUG
|
||||
@@ -61,7 +61,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
.foregroundStyle(.white)
|
||||
.frame(minWidth: 72)
|
||||
Button {
|
||||
count = min(8, count + 1)
|
||||
count = min(AIClipboardSkillCatalog.catalog.count, count + 1)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
@@ -69,7 +69,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
HStack(spacing: 8) {
|
||||
ForEach([3, 5, 8], id: \.self) { n in
|
||||
ForEach([3, 8, AIClipboardSkillCatalog.catalog.count], id: \.self) { n in
|
||||
Button("\(n)") {
|
||||
count = n
|
||||
}
|
||||
@@ -103,7 +103,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
}
|
||||
|
||||
private func showSkills(_ count: Int) {
|
||||
let clamped = min(max(count, 1), 8)
|
||||
let clamped = min(max(count, 1), AIClipboardSkillCatalog.catalog.count)
|
||||
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
|
||||
state.enabledClipboardSkillIDs = Array(
|
||||
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
|
||||
@@ -135,7 +135,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
let prefix = "--skills-count="
|
||||
if let arg = ProcessInfo.processInfo.arguments.first(where: { $0.hasPrefix(prefix) }),
|
||||
let value = Int(arg.dropFirst(prefix.count)),
|
||||
(1...8).contains(value) {
|
||||
(1...AIClipboardSkillCatalog.catalog.count).contains(value) {
|
||||
return value
|
||||
}
|
||||
return 5
|
||||
|
||||
@@ -19,6 +19,7 @@ struct AccountAppleAuthorizationButton: View {
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
let purpose: AccountAppleAuthorizationPurpose
|
||||
var onSignedIn: () -> Void = {}
|
||||
var onDeleted: () -> Void = {}
|
||||
|
||||
@State private var rawNonce: String?
|
||||
@@ -30,7 +31,8 @@ struct AccountAppleAuthorizationButton: View {
|
||||
onCompletion: completeAuthorization
|
||||
)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityLabel(
|
||||
purpose == .signIn
|
||||
@@ -85,6 +87,7 @@ struct AccountAppleAuthorizationButton: View {
|
||||
await coordinator.signIn(with: payload)
|
||||
if coordinator.isSignedIn {
|
||||
ProviderConfig.shared.reloadFromPersistedStorage()
|
||||
onSignedIn()
|
||||
}
|
||||
case .deleteAccount:
|
||||
await coordinator.deleteAccount(with: payload)
|
||||
|
||||
@@ -14,7 +14,11 @@ struct AccountCenterUITestHarness: View {
|
||||
@StateObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
init() {
|
||||
let service = AccountCenterUITestService()
|
||||
let service = AccountCenterUITestService(
|
||||
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
|
||||
"--account-snapshot-failure"
|
||||
)
|
||||
)
|
||||
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
||||
_coordinator = StateObject(
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
@@ -91,6 +95,11 @@ private actor AccountCenterUITestService:
|
||||
createdAtEpochSeconds: 1_700_000_000,
|
||||
displayName: "UI Test Account"
|
||||
)
|
||||
private let shouldFailSnapshot: Bool
|
||||
|
||||
init(shouldFailSnapshot: Bool = false) {
|
||||
self.shouldFailSnapshot = shouldFailSnapshot
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
account
|
||||
@@ -108,7 +117,10 @@ private actor AccountCenterUITestService:
|
||||
}
|
||||
|
||||
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||
AccountCenterSnapshot(
|
||||
if shouldFailSnapshot {
|
||||
throw AccountCenterUITestError.snapshotUnavailable
|
||||
}
|
||||
return AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
|
||||
referrals: []
|
||||
@@ -180,6 +192,10 @@ private actor AccountCenterUITestService:
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountCenterUITestError: Error {
|
||||
case snapshotUnavailable
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class AccountCenterUITestCreditStore: AccountCreditStore {
|
||||
private let accountID: UUID
|
||||
|
||||
@@ -32,8 +32,10 @@ struct AccountCenterView: View {
|
||||
coordinator.creditPurchases.reset()
|
||||
return
|
||||
}
|
||||
await coordinator.refreshAccountData()
|
||||
await coordinator.creditPurchases.prepare(accountID: accountID)
|
||||
async let accountRefresh: Void = coordinator.refreshAccountData()
|
||||
async let purchasePreparation: Void =
|
||||
coordinator.creditPurchases.prepare(accountID: accountID)
|
||||
_ = await (accountRefresh, purchasePreparation)
|
||||
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||
displayNameDraft = session.displayName ?? ""
|
||||
}
|
||||
@@ -148,7 +150,10 @@ struct AccountCenterView: View {
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
.refreshable {
|
||||
await coordinator.refreshAccountData(force: true)
|
||||
async let accountRefresh: Void = coordinator.refreshAccountData(force: true)
|
||||
async let catalogRefresh: Void =
|
||||
coordinator.creditPurchases.refreshCatalog(accountID: session.accountID)
|
||||
_ = await (accountRefresh, catalogRefresh)
|
||||
}
|
||||
.accessibilityIdentifier("account.center.signedIn")
|
||||
}
|
||||
@@ -171,6 +176,9 @@ struct AccountCenterView: View {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
case .loaded:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
CardSection("account.storekit.section") {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
AccountCreditPurchaseSection(
|
||||
@@ -184,7 +192,6 @@ struct AccountCenterView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func purchaseHistoryLink(accountID: UUID) -> some View {
|
||||
NavigationLink {
|
||||
@@ -522,7 +529,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
} else if manager.state == .loading {
|
||||
} else if manager.catalogPhase == .loading {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
@@ -535,6 +542,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
catalogStateMessage
|
||||
stateMessage
|
||||
}
|
||||
.onChange(of: manager.lastGrantedBalance) { previous, current in
|
||||
@@ -613,6 +621,37 @@ private struct AccountCreditPurchaseSection: View {
|
||||
return productID
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var catalogStateMessage: some View {
|
||||
switch manager.catalogPhase {
|
||||
case .idle:
|
||||
catalogRetryMessage(messageKey: "account.storekit.error.productUnavailable")
|
||||
case .loading, .loaded:
|
||||
if let messageKey = manager.catalogRefreshErrorKey {
|
||||
catalogRetryMessage(messageKey: messageKey)
|
||||
}
|
||||
case .failed(let messageKey):
|
||||
catalogRetryMessage(messageKey: messageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func catalogRetryMessage(messageKey: String) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(
|
||||
LocalizedStringKey(messageKey),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.danger)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button("account.retry") {
|
||||
Task { await manager.refreshCatalog(accountID: accountID) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var stateMessage: some View {
|
||||
Group {
|
||||
@@ -646,11 +685,6 @@ private struct AccountCreditPurchaseSection: View {
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.danger)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button("account.retry") {
|
||||
Task { await manager.prepare(accountID: accountID) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
|
||||
@@ -21,13 +21,19 @@ struct AccountCreditPurchaseOption: Identifiable, Equatable {
|
||||
final class AccountCreditPurchaseManager: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case purchasing(productID: String)
|
||||
case pending
|
||||
case succeeded(credits: Int64)
|
||||
case failed(messageKey: String)
|
||||
}
|
||||
|
||||
enum CatalogPhase: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case failed(messageKey: String)
|
||||
}
|
||||
|
||||
enum HistoryPhase: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
@@ -36,7 +42,10 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
}
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var catalogPhase: CatalogPhase = .idle
|
||||
@Published private(set) var options: [AccountCreditPurchaseOption] = []
|
||||
@Published private(set) var isRefreshingCatalog = false
|
||||
@Published private(set) var catalogRefreshErrorKey: String?
|
||||
@Published private(set) var lastGrantedBalance: Int64?
|
||||
@Published private(set) var historyPhase: HistoryPhase = .idle
|
||||
@Published private(set) var isLoadingMoreHistory = false
|
||||
@@ -45,80 +54,192 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
private let service: any AccountCenterServicing
|
||||
private let store: any AccountCreditStore
|
||||
private let analyticsClient: any AnalyticsClient
|
||||
private let successMessageDuration: Duration
|
||||
private var prepareTask: Task<Void, Never>?
|
||||
private var prepareRequestID: UUID?
|
||||
private var updatesTask: Task<Void, Never>?
|
||||
private var successDismissTask: Task<Void, Never>?
|
||||
private var activeAccountID: UUID?
|
||||
private var sessionID: UUID?
|
||||
private var pendingProductID: String?
|
||||
private var configuredProductIDs: Set<String> = []
|
||||
private var nextHistoryCursor: String?
|
||||
|
||||
init(
|
||||
service: any AccountCenterServicing,
|
||||
store: any AccountCreditStore = LiveAccountCreditStore(),
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||
successMessageDuration: Duration = .seconds(4)
|
||||
) {
|
||||
self.service = service
|
||||
self.store = store
|
||||
self.analyticsClient = analyticsClient
|
||||
self.successMessageDuration = successMessageDuration
|
||||
}
|
||||
|
||||
deinit {
|
||||
prepareTask?.cancel()
|
||||
updatesTask?.cancel()
|
||||
successDismissTask?.cancel()
|
||||
}
|
||||
|
||||
func prepare(accountID: UUID) async {
|
||||
guard activeAccountID != accountID || options.isEmpty else { return }
|
||||
/// Starts one non-blocking StoreKit preparation for a signed-in account.
|
||||
/// Repeated calls for the same account preserve the current session state.
|
||||
func startSession(accountID: UUID) {
|
||||
guard activeAccountID != accountID else { return }
|
||||
reset(for: accountID)
|
||||
state = .loading
|
||||
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
|
||||
beginPrepare(accountID: accountID)
|
||||
}
|
||||
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,
|
||||
credits: creditProduct.credits,
|
||||
displayPrice: product.displayPrice
|
||||
)
|
||||
)
|
||||
}
|
||||
guard !loadedOptions.isEmpty else {
|
||||
throw AccountCreditPurchaseError.productUnavailable
|
||||
}
|
||||
configuredProductIDs = Set(catalog.map(\.productID))
|
||||
options = loadedOptions.sorted { $0.credits < $1.credits }
|
||||
state = .idle
|
||||
listenForUpdates(accountID: accountID)
|
||||
_ = await reconcileUnfinished(accountID: accountID)
|
||||
catalogPhase = .loaded
|
||||
catalogRefreshErrorKey = nil
|
||||
listenForUpdates(accountID: accountID, sessionID: sessionID)
|
||||
_ = await reconcileUnfinished(accountID: accountID, sessionID: sessionID)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
state = .failed(messageKey: Self.messageKey(for: error))
|
||||
guard isCurrentSession(
|
||||
accountID: accountID,
|
||||
sessionID: sessionID,
|
||||
requestID: requestID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let messageKey = Self.messageKey(for: error)
|
||||
catalogRefreshErrorKey = messageKey
|
||||
catalogPhase = options.isEmpty
|
||||
? .failed(messageKey: messageKey)
|
||||
: .loaded
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func purchase(productID: String, accountID: UUID) async -> Bool {
|
||||
guard activeAccountID == accountID,
|
||||
let sessionID,
|
||||
options.contains(where: { $0.productID == productID }) else {
|
||||
state = .failed(messageKey: "account.storekit.error.productUnavailable")
|
||||
return false
|
||||
}
|
||||
state = .purchasing(productID: productID)
|
||||
pendingProductID = nil
|
||||
cancelSuccessDismissal()
|
||||
analyticsClient.recordPurchaseStarted()
|
||||
do {
|
||||
switch try await store.purchase(productID: productID, accountID: accountID) {
|
||||
case .success(let verification):
|
||||
try await process(verification, accountID: accountID)
|
||||
let result = try await process(
|
||||
verification,
|
||||
accountID: accountID,
|
||||
sessionID: sessionID
|
||||
)
|
||||
presentSuccess(credits: result.creditsGranted)
|
||||
return true
|
||||
case .pending:
|
||||
pendingProductID = productID
|
||||
state = .pending
|
||||
case .userCancelled:
|
||||
pendingProductID = nil
|
||||
state = .idle
|
||||
analyticsClient.recordPurchaseCancelled()
|
||||
@unknown default:
|
||||
pendingProductID = nil
|
||||
state = .failed(messageKey: "account.storekit.error.unknown")
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return false
|
||||
} catch {
|
||||
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
|
||||
return false
|
||||
}
|
||||
pendingProductID = nil
|
||||
state = .failed(messageKey: Self.messageKey(for: error))
|
||||
}
|
||||
return false
|
||||
@@ -131,6 +252,8 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
func clearTransientState() {
|
||||
switch state {
|
||||
case .succeeded, .failed, .pending:
|
||||
pendingProductID = nil
|
||||
cancelSuccessDismissal()
|
||||
state = .idle
|
||||
default:
|
||||
break
|
||||
@@ -139,6 +262,7 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
|
||||
func dismissSuccessMessage() {
|
||||
guard case .succeeded = state else { return }
|
||||
cancelSuccessDismissal()
|
||||
state = .idle
|
||||
}
|
||||
|
||||
@@ -199,10 +323,20 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
}
|
||||
|
||||
func reset() {
|
||||
prepareTask?.cancel()
|
||||
prepareTask = nil
|
||||
prepareRequestID = nil
|
||||
updatesTask?.cancel()
|
||||
updatesTask = nil
|
||||
successDismissTask?.cancel()
|
||||
successDismissTask = nil
|
||||
activeAccountID = nil
|
||||
sessionID = nil
|
||||
pendingProductID = nil
|
||||
catalogPhase = .idle
|
||||
options = []
|
||||
isRefreshingCatalog = false
|
||||
catalogRefreshErrorKey = nil
|
||||
configuredProductIDs = []
|
||||
lastGrantedBalance = nil
|
||||
historyPhase = .idle
|
||||
@@ -213,30 +347,64 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func reconcileUnfinished(accountID: UUID) async -> Bool {
|
||||
private func reconcileUnfinished(accountID: UUID, sessionID: UUID) async -> Bool {
|
||||
var reconciled = false
|
||||
for await verification in store.unfinishedTransactions() {
|
||||
guard !Task.isCancelled else { break }
|
||||
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
|
||||
break
|
||||
}
|
||||
do {
|
||||
try await process(verification, accountID: accountID)
|
||||
_ = try await process(
|
||||
verification,
|
||||
accountID: accountID,
|
||||
sessionID: sessionID
|
||||
)
|
||||
reconciled = true
|
||||
} catch is CancellationError {
|
||||
break
|
||||
} catch {
|
||||
state = .failed(messageKey: Self.messageKey(for: error))
|
||||
// Historical recovery is background maintenance; it must not
|
||||
// replace the current purchase action with stale UI feedback.
|
||||
continue
|
||||
}
|
||||
}
|
||||
return reconciled
|
||||
}
|
||||
|
||||
private func listenForUpdates(accountID: UUID) {
|
||||
private func listenForUpdates(accountID: UUID, sessionID: UUID) {
|
||||
updatesTask?.cancel()
|
||||
let updates = store.transactionUpdates()
|
||||
updatesTask = Task { [weak self] in
|
||||
for await verification in updates {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let self else { return }
|
||||
guard self.isCurrentSession(
|
||||
accountID: accountID,
|
||||
sessionID: sessionID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await self.process(verification, accountID: accountID)
|
||||
let result = try await self.process(
|
||||
verification,
|
||||
accountID: accountID,
|
||||
sessionID: sessionID
|
||||
)
|
||||
if self.pendingProductID == result.productID {
|
||||
self.pendingProductID = nil
|
||||
self.presentSuccess(credits: result.creditsGranted)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard self.isCurrentSession(
|
||||
accountID: accountID,
|
||||
sessionID: sessionID
|
||||
), self.pendingProductID != nil else {
|
||||
continue
|
||||
}
|
||||
self.pendingProductID = nil
|
||||
self.state = .failed(messageKey: Self.messageKey(for: error))
|
||||
}
|
||||
}
|
||||
@@ -245,9 +413,11 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
|
||||
private func process(
|
||||
_ verification: AccountStoreVerification,
|
||||
accountID: UUID
|
||||
) async throws {
|
||||
guard case .verified(let transaction) = verification,
|
||||
accountID: UUID,
|
||||
sessionID: UUID
|
||||
) async throws -> AccountCreditPurchase {
|
||||
guard isCurrentSession(accountID: accountID, sessionID: sessionID),
|
||||
case .verified(let transaction) = verification,
|
||||
transaction.appAccountToken == accountID,
|
||||
configuredProductIDs.contains(transaction.productID) else {
|
||||
throw AccountCreditPurchaseError.verificationFailed
|
||||
@@ -258,23 +428,119 @@ final class AccountCreditPurchaseManager: ObservableObject {
|
||||
throw AccountCreditPurchaseError.invalidServerResponse
|
||||
}
|
||||
await transaction.finish()
|
||||
guard isCurrentSession(accountID: accountID, sessionID: sessionID) else {
|
||||
throw CancellationError()
|
||||
}
|
||||
lastGrantedBalance = result.balanceAfter
|
||||
historyPhase = .idle
|
||||
nextHistoryCursor = nil
|
||||
state = .succeeded(credits: result.creditsGranted)
|
||||
return result
|
||||
}
|
||||
|
||||
private func reset(for accountID: UUID) {
|
||||
prepareTask?.cancel()
|
||||
prepareTask = nil
|
||||
prepareRequestID = nil
|
||||
updatesTask?.cancel()
|
||||
updatesTask = nil
|
||||
successDismissTask?.cancel()
|
||||
successDismissTask = nil
|
||||
activeAccountID = accountID
|
||||
sessionID = UUID()
|
||||
pendingProductID = nil
|
||||
catalogPhase = .idle
|
||||
options = []
|
||||
isRefreshingCatalog = false
|
||||
catalogRefreshErrorKey = nil
|
||||
configuredProductIDs = []
|
||||
lastGrantedBalance = nil
|
||||
historyPhase = .idle
|
||||
isLoadingMoreHistory = false
|
||||
historyLoadMoreErrorKey = nil
|
||||
nextHistoryCursor = nil
|
||||
state = .idle
|
||||
}
|
||||
|
||||
/// Success is acknowledgement of the current purchase action, not durable
|
||||
/// transaction history. Expire it even when a retained tab never disappears.
|
||||
private func presentSuccess(credits: Int64) {
|
||||
cancelSuccessDismissal()
|
||||
state = .succeeded(credits: credits)
|
||||
let expectedSessionID = sessionID
|
||||
let duration = successMessageDuration
|
||||
successDismissTask = Task { @MainActor [weak self] in
|
||||
do {
|
||||
try await Task.sleep(for: duration)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard let self,
|
||||
self.sessionID == expectedSessionID,
|
||||
self.state == .succeeded(credits: credits) else {
|
||||
return
|
||||
}
|
||||
self.state = .idle
|
||||
self.successDismissTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSuccessDismissal() {
|
||||
successDismissTask?.cancel()
|
||||
successDismissTask = nil
|
||||
}
|
||||
|
||||
private func finishPrepare(requestID: UUID) {
|
||||
guard prepareRequestID == requestID else { return }
|
||||
prepareTask = nil
|
||||
prepareRequestID = nil
|
||||
isRefreshingCatalog = false
|
||||
}
|
||||
|
||||
private func isCurrentSession(
|
||||
accountID: UUID,
|
||||
sessionID: UUID,
|
||||
requestID: UUID? = nil
|
||||
) -> Bool {
|
||||
guard activeAccountID == accountID, self.sessionID == sessionID else {
|
||||
return false
|
||||
}
|
||||
if let requestID {
|
||||
return prepareRequestID == requestID
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func loadProductsWithRetry(
|
||||
productIDs: [String]
|
||||
) async throws -> [AccountStoreProduct] {
|
||||
let expectedIDs = Set(productIDs)
|
||||
var lastLoaded: [AccountStoreProduct] = []
|
||||
var lastError: Error?
|
||||
let retryDelays: [Duration] = [.zero, .milliseconds(200), .milliseconds(600)]
|
||||
|
||||
for delay in retryDelays {
|
||||
if delay != .zero {
|
||||
try await Task.sleep(for: delay)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
let loaded = try await store.products(for: productIDs)
|
||||
lastLoaded = loaded
|
||||
if Set(loaded.map(\.id)).isSuperset(of: expectedIDs) {
|
||||
return loaded
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
if !lastLoaded.isEmpty {
|
||||
return lastLoaded
|
||||
}
|
||||
if let lastError {
|
||||
throw lastError
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func messageKey(for error: Error) -> String {
|
||||
|
||||
@@ -55,12 +55,25 @@ enum AccountStorePurchaseOutcome: Sendable {
|
||||
@MainActor
|
||||
protocol AccountCreditStore {
|
||||
func product(for productID: String) async throws -> AccountStoreProduct?
|
||||
func products(for productIDs: [String]) async throws -> [AccountStoreProduct]
|
||||
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome
|
||||
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification>
|
||||
func transactionUpdates() -> AsyncStream<AccountStoreVerification>
|
||||
func allTransactions() -> AsyncStream<AccountStoreVerification>
|
||||
}
|
||||
|
||||
extension AccountCreditStore {
|
||||
func products(for productIDs: [String]) async throws -> [AccountStoreProduct] {
|
||||
var loadedProducts: [AccountStoreProduct] = []
|
||||
for productID in productIDs {
|
||||
if let product = try await product(for: productID) {
|
||||
loadedProducts.append(product)
|
||||
}
|
||||
}
|
||||
return loadedProducts
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LiveAccountCreditStore: AccountCreditStore {
|
||||
private var productsByID: [String: Product] = [:]
|
||||
@@ -70,6 +83,20 @@ final class LiveAccountCreditStore: AccountCreditStore {
|
||||
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
|
||||
}
|
||||
|
||||
func products(for productIDs: [String]) async throws -> [AccountStoreProduct] {
|
||||
var seenIDs: Set<String> = []
|
||||
let orderedIDs = productIDs.filter { seenIDs.insert($0).inserted }
|
||||
let missingIDs = orderedIDs.filter { productsByID[$0] == nil }
|
||||
if !missingIDs.isEmpty {
|
||||
let loadedProducts = try await Product.products(for: missingIDs)
|
||||
loadedProducts.forEach { productsByID[$0.id] = $0 }
|
||||
}
|
||||
return orderedIDs.compactMap { productID in
|
||||
guard let product = productsByID[productID] else { return nil }
|
||||
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome {
|
||||
guard let product = try await loadProduct(for: productID) else {
|
||||
return .success(.unverified)
|
||||
@@ -102,10 +129,8 @@ final class LiveAccountCreditStore: AccountCreditStore {
|
||||
if let product = productsByID[productID] {
|
||||
return product
|
||||
}
|
||||
let loaded = try await Product.products(for: [productID])
|
||||
guard let product = loaded.first(where: { $0.id == productID }) else { return nil }
|
||||
productsByID[productID] = product
|
||||
return product
|
||||
_ = try await products(for: [productID])
|
||||
return productsByID[productID]
|
||||
}
|
||||
|
||||
private static func map(_ verification: VerificationResult<Transaction>) -> AccountStoreVerification {
|
||||
|
||||
@@ -268,6 +268,14 @@ protocol AccountSessionServicing: Sendable {
|
||||
func clearManagedGateway() async
|
||||
}
|
||||
|
||||
enum AccountSessionEvent: Sendable {
|
||||
case expired
|
||||
}
|
||||
|
||||
protocol AccountSessionEventSourcing: Sendable {
|
||||
func events() async -> AsyncStream<AccountSessionEvent>
|
||||
}
|
||||
|
||||
extension AccountSessionServicing {
|
||||
func prepareManagedGateway() async throws {}
|
||||
func clearManagedGateway() async {}
|
||||
@@ -321,15 +329,18 @@ protocol ReferralProfileServicing: Sendable {
|
||||
|
||||
struct AccountDependencies: Sendable {
|
||||
let sessionService: any AccountSessionServicing
|
||||
let sessionEventSource: any AccountSessionEventSourcing
|
||||
let centerService: any AccountCenterServicing
|
||||
let referralService: any ReferralProfileServicing
|
||||
|
||||
init(
|
||||
sessionService: any AccountSessionServicing,
|
||||
sessionEventSource: (any AccountSessionEventSourcing)? = nil,
|
||||
centerService: any AccountCenterServicing,
|
||||
referralService: (any ReferralProfileServicing)? = nil
|
||||
) {
|
||||
self.sessionService = sessionService
|
||||
self.sessionEventSource = sessionEventSource ?? UnavailableAccountSessionEventSource()
|
||||
self.centerService = centerService
|
||||
self.referralService = referralService ?? UnavailableReferralProfileService()
|
||||
}
|
||||
@@ -338,6 +349,7 @@ struct AccountDependencies: Sendable {
|
||||
let service = UnavailableAccountService()
|
||||
return AccountDependencies(
|
||||
sessionService: service,
|
||||
sessionEventSource: UnavailableAccountSessionEventSource(),
|
||||
centerService: service,
|
||||
referralService: UnavailableReferralProfileService()
|
||||
)
|
||||
@@ -396,6 +408,12 @@ private struct UnavailableReferralProfileService: ReferralProfileServicing {
|
||||
}
|
||||
}
|
||||
|
||||
private struct UnavailableAccountSessionEventSource: AccountSessionEventSourcing {
|
||||
func events() async -> AsyncStream<AccountSessionEvent> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
}
|
||||
|
||||
enum ReferralUniversalLink {
|
||||
static let host = "osglab.com"
|
||||
static let codeLength = 22
|
||||
|
||||
@@ -50,11 +50,14 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
private let pendingReferralStore: any PendingReferralCodeStoring
|
||||
private let analyticsClient: any AnalyticsClient
|
||||
private let onAccountAuthenticated: (UUID) async -> Void
|
||||
private let onAccountSignedOut: () async -> Void
|
||||
private let onAccountDeleted: () async -> Void
|
||||
private let accountRefreshInterval: TimeInterval
|
||||
private let now: () -> Date
|
||||
private var didAttemptRestore = false
|
||||
private var accountRefreshTask: Task<Void, Never>?
|
||||
private var accountRefreshRequestID: UUID?
|
||||
private var sessionEventsTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
dependencies: AccountDependencies,
|
||||
@@ -67,6 +70,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
now: @escaping () -> Date = Date.init,
|
||||
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
|
||||
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
|
||||
onAccountSignedOut: @escaping () async -> Void = {},
|
||||
onAccountDeleted: @escaping () async -> Void = {}
|
||||
) {
|
||||
sessionService = dependencies.sessionService
|
||||
@@ -83,10 +87,24 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
self.pendingReferralStore = pendingReferralStore
|
||||
self.analyticsClient = analyticsClient
|
||||
self.onAccountAuthenticated = onAccountAuthenticated
|
||||
self.onAccountSignedOut = onAccountSignedOut
|
||||
self.onAccountDeleted = onAccountDeleted
|
||||
self.accountRefreshInterval = accountRefreshInterval
|
||||
self.now = now
|
||||
pendingReferralCode = pendingReferralStore.code
|
||||
let sessionEventSource = dependencies.sessionEventSource
|
||||
sessionEventsTask = Task { @MainActor [weak self] in
|
||||
let events = await sessionEventSource.events()
|
||||
for await event in events {
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.handleSessionEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
accountRefreshTask?.cancel()
|
||||
sessionEventsTask?.cancel()
|
||||
}
|
||||
|
||||
var isSignedIn: Bool {
|
||||
@@ -109,15 +127,20 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
|
||||
do {
|
||||
guard let session = try await sessionService.restoreSession() else {
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
sessionPhase = .signedOut
|
||||
return
|
||||
}
|
||||
await onAccountAuthenticated(session.accountID)
|
||||
sessionPhase = .signedIn(session)
|
||||
creditPurchases.startSession(accountID: session.accountID)
|
||||
referralProfile.startSession(accountID: session.accountID)
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
} catch {
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
sessionPhase = .signedOut
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
@@ -161,6 +184,7 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
let session = try await sessionService.signIn(with: payload)
|
||||
await onAccountAuthenticated(session.accountID)
|
||||
sessionPhase = .signedIn(session)
|
||||
creditPurchases.startSession(accountID: session.accountID)
|
||||
referralProfile.startSession(accountID: session.accountID)
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
@@ -196,13 +220,18 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
snapshotPhase = .loading
|
||||
}
|
||||
|
||||
let requestID = UUID()
|
||||
accountRefreshRequestID = requestID
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.performAccountRefresh(for: accountID)
|
||||
await self.performAccountRefresh(
|
||||
for: accountID,
|
||||
requestID: requestID
|
||||
)
|
||||
}
|
||||
accountRefreshTask = task
|
||||
await task.value
|
||||
accountRefreshTask = nil
|
||||
finishAccountRefresh(requestID: requestID)
|
||||
}
|
||||
|
||||
func updateDisplayName(_ displayName: String) async -> Bool {
|
||||
@@ -263,7 +292,8 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
|
||||
do {
|
||||
try await sessionService.signOut()
|
||||
creditPurchases.reset()
|
||||
await onAccountSignedOut()
|
||||
creditPurchases.endSession()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession()
|
||||
sessionPhase = .signedOut
|
||||
@@ -286,7 +316,8 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
do {
|
||||
try await sessionService.deleteAccount(with: payload)
|
||||
await onAccountDeleted()
|
||||
creditPurchases.reset()
|
||||
await onAccountSignedOut()
|
||||
creditPurchases.endSession()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession(removeCache: true)
|
||||
sessionPhase = .signedOut
|
||||
@@ -309,12 +340,12 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
operationErrorKey = nil
|
||||
}
|
||||
|
||||
private func performAccountRefresh(for accountID: UUID) async {
|
||||
private func performAccountRefresh(for accountID: UUID, requestID: UUID) async {
|
||||
isRefreshingAccountData = true
|
||||
accountRefreshErrorKey = nil
|
||||
defer { isRefreshingAccountData = false }
|
||||
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
let cachedSnapshot: AccountCenterSnapshot?
|
||||
if case let .loaded(snapshot) = snapshotPhase {
|
||||
cachedSnapshot = snapshot
|
||||
@@ -324,15 +355,22 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
let snapshot = try await centerService.loadAccountCenter(
|
||||
cachedSnapshot: cachedSnapshot
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard self.accountID == accountID,
|
||||
accountRefreshRequestID == requestID,
|
||||
snapshot.account.accountID == accountID else {
|
||||
return
|
||||
}
|
||||
snapshotPhase = .loaded(snapshot)
|
||||
sessionPhase = .signedIn(snapshot.account)
|
||||
lastAccountRefreshAt = now()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard self.accountID == accountID else { return }
|
||||
guard self.accountID == accountID,
|
||||
accountRefreshRequestID == requestID else {
|
||||
return
|
||||
}
|
||||
let messageKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.load"
|
||||
@@ -348,11 +386,34 @@ final class AccountSessionCoordinator: ObservableObject {
|
||||
private func clearAccountRefreshState() {
|
||||
accountRefreshTask?.cancel()
|
||||
accountRefreshTask = nil
|
||||
accountRefreshRequestID = nil
|
||||
lastAccountRefreshAt = nil
|
||||
isRefreshingAccountData = false
|
||||
accountRefreshErrorKey = nil
|
||||
}
|
||||
|
||||
private func finishAccountRefresh(requestID: UUID) {
|
||||
guard accountRefreshRequestID == requestID else { return }
|
||||
accountRefreshTask = nil
|
||||
accountRefreshRequestID = nil
|
||||
isRefreshingAccountData = false
|
||||
}
|
||||
|
||||
private func handleSessionEvent(_ event: AccountSessionEvent) async {
|
||||
switch event {
|
||||
case .expired:
|
||||
guard isSignedIn else { return }
|
||||
await sessionService.clearManagedGateway()
|
||||
await onAccountSignedOut()
|
||||
creditPurchases.endSession()
|
||||
clearAccountRefreshState()
|
||||
referralProfile.endSession()
|
||||
sessionPhase = .signedOut
|
||||
snapshotPhase = .idle
|
||||
operationErrorKey = "account.error.sessionExpired"
|
||||
}
|
||||
}
|
||||
|
||||
private func redeemPendingReferralIfNeeded() async {
|
||||
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Adapts the host-private account protocol to the account-center UI. Account
|
||||
// sessions never enter App Group storage; only scope-limited gateway grants do.
|
||||
// session credentials never enter App Group storage; only a non-secret local
|
||||
// eligibility marker and scope-limited gateway grants are shared.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardHostSupport
|
||||
@@ -40,6 +41,7 @@ enum LiveAccountDependencyFactory {
|
||||
)
|
||||
return AccountDependencies(
|
||||
sessionService: service,
|
||||
sessionEventSource: service,
|
||||
centerService: service,
|
||||
referralService: LiveReferralProfileService(apiClient: apiClient)
|
||||
)
|
||||
@@ -162,7 +164,10 @@ actor LiveReferralProfileService: ReferralProfileServicing {
|
||||
}
|
||||
}
|
||||
|
||||
private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing {
|
||||
private actor LiveAccountService:
|
||||
AccountSessionServicing,
|
||||
AccountSessionEventSourcing,
|
||||
AccountCenterServicing {
|
||||
private let apiClient: AccountAPIClient
|
||||
private let accountCenterLoader: AccountCenterSnapshotLoader
|
||||
private let integrity: DeviceIntegrityCoordinator
|
||||
@@ -187,14 +192,37 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
func events() async -> AsyncStream<AccountSessionEvent> {
|
||||
let invalidations = await apiClient.sessionInvalidations()
|
||||
return AsyncStream { continuation in
|
||||
let task = Task {
|
||||
for await invalidation in invalidations {
|
||||
switch invalidation {
|
||||
case .expired:
|
||||
await self.invalidateManagedGatewaySession()
|
||||
continuation.yield(.expired)
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
guard let cachedSession = try await apiClient.currentSession() else { return nil }
|
||||
guard let cachedSession = try await apiClient.currentSession() else {
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
}
|
||||
let account: OSGAccount?
|
||||
do {
|
||||
account = try await apiClient.account()
|
||||
} catch let error as AccountAPIError {
|
||||
switch error {
|
||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||
await invalidateManagedGatewaySession()
|
||||
return nil
|
||||
default:
|
||||
account = nil
|
||||
@@ -203,6 +231,7 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
||||
account = nil
|
||||
}
|
||||
let session = try await apiClient.currentSession() ?? cachedSession
|
||||
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||
await synchronizeManagedGrant()
|
||||
return uiSession(
|
||||
session,
|
||||
@@ -253,7 +282,6 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
||||
)
|
||||
throw error
|
||||
}
|
||||
await synchronizeManagedGrant()
|
||||
let account: OSGAccount
|
||||
do {
|
||||
account = try await apiClient.account()
|
||||
@@ -264,6 +292,8 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
||||
)
|
||||
throw error
|
||||
}
|
||||
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||
await synchronizeManagedGrant()
|
||||
OSGDiag.log("signIn completed", category: "account")
|
||||
return uiSession(
|
||||
session,
|
||||
@@ -273,16 +303,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
configuration.setManagedGatewayAccountSessionAvailable(false)
|
||||
configuration.setCredentialSource(.byok)
|
||||
await revokeManagedGrantIfPossible()
|
||||
do {
|
||||
try await apiClient.logout()
|
||||
} catch {
|
||||
try? await grants.clearGrant()
|
||||
configuration.setCredentialSource(.byok)
|
||||
throw error
|
||||
}
|
||||
try? await grants.clearGrant()
|
||||
configuration.setCredentialSource(.byok)
|
||||
}
|
||||
|
||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||
@@ -293,13 +323,16 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
||||
)
|
||||
try? await grants.clearGrant()
|
||||
await integrity.clearLocalKeyState()
|
||||
configuration.setManagedGatewayAccountSessionAvailable(false)
|
||||
configuration.setCredentialSource(.byok)
|
||||
}
|
||||
|
||||
func prepareManagedGateway() async throws {
|
||||
guard try await apiClient.currentSession() != nil else {
|
||||
await invalidateManagedGatewaySession()
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
configuration.setManagedGatewayAccountSessionAvailable(true)
|
||||
try await ensureManagedGrant()
|
||||
}
|
||||
|
||||
@@ -462,6 +495,14 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin
|
||||
try? await grants.clearGrant()
|
||||
}
|
||||
|
||||
private func invalidateManagedGatewaySession() async {
|
||||
// Flip the local gate before any await so the extension immediately
|
||||
// loses access to cached account grants.
|
||||
configuration.setManagedGatewayAccountSessionAvailable(false)
|
||||
configuration.setCredentialSource(.byok)
|
||||
try? await grants.clearGrant()
|
||||
}
|
||||
|
||||
private func uiSession(
|
||||
_ session: OSGKeyboardHostSupport.AccountSession,
|
||||
createdAtEpochSeconds: Int64,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// AppTab.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Shared top-level destinations for the native iPhone TabView and iPad sidebar.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum AppTab: Int, CaseIterable {
|
||||
case keyboard
|
||||
case skills
|
||||
case styles
|
||||
case settings
|
||||
|
||||
var accessibilityKey: LocalizedStringKey {
|
||||
switch self {
|
||||
case .keyboard: return "tab.keyboard"
|
||||
case .skills: return "tab.skills"
|
||||
case .styles: return "tab.styles"
|
||||
case .settings: return "tab.settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// Native phone tab symbol: outline when idle, fill when selected.
|
||||
/// `wand.and.sparkles` has no `.fill` pair.
|
||||
func dockSystemImage(selected: Bool) -> String {
|
||||
switch self {
|
||||
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
|
||||
case .skills: return "wand.and.sparkles"
|
||||
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
|
||||
var sidebarTitle: LocalizedStringKey { accessibilityKey }
|
||||
|
||||
/// iPad sidebar. Home stays `house` in both states; other tabs follow
|
||||
/// the same outline / fill pairing as the phone tab bar.
|
||||
func sidebarSystemImage(selected: Bool) -> String {
|
||||
switch self {
|
||||
case .keyboard: return "house"
|
||||
case .skills: return "wand.and.sparkles"
|
||||
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// MinimalTabBar.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Bottom tab bar — icon + label for Home / Skills / Styles / Settings.
|
||||
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
|
||||
// fill inside that capsule (Photos-style), not a second glass layer.
|
||||
// History + dictionary live as Home cards (not dock tabs).
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum AppTab: Int, CaseIterable {
|
||||
case keyboard
|
||||
case skills
|
||||
case styles
|
||||
case settings
|
||||
|
||||
var accessibilityKey: LocalizedStringKey {
|
||||
switch self {
|
||||
case .keyboard: return "tab.keyboard"
|
||||
case .skills: return "tab.skills"
|
||||
case .styles: return "tab.styles"
|
||||
case .settings: return "tab.settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone dock: outline when idle, fill when selected (system tab-bar
|
||||
/// convention). `wand.and.sparkles` has no `.fill` pair.
|
||||
func dockSystemImage(selected: Bool) -> String {
|
||||
switch self {
|
||||
case .keyboard: return selected ? "keyboard.fill" : "keyboard"
|
||||
case .skills: return "wand.and.sparkles"
|
||||
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
|
||||
var sidebarTitle: LocalizedStringKey { accessibilityKey }
|
||||
|
||||
/// iPad sidebar. Home stays `house` in both states; other tabs follow
|
||||
/// the same outline / fill pairing as the phone dock.
|
||||
func sidebarSystemImage(selected: Bool) -> String {
|
||||
switch self {
|
||||
case .keyboard: return "house"
|
||||
case .skills: return "wand.and.sparkles"
|
||||
case .styles: return selected ? "dial.high.fill" : "dial.high"
|
||||
case .settings: return selected ? "gearshape.fill" : "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MinimalTabBar: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Namespace private var selectionNamespace
|
||||
@Binding var selection: AppTab
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(AppTab.allCases, id: \.rawValue) { tab in
|
||||
Button {
|
||||
withAnimation(Motion.soft) {
|
||||
selection = tab
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: tab.dockSystemImage(selected: selection == tab))
|
||||
.font(.system(size: TabBarDockMetrics.iconSize, weight: .regular))
|
||||
Text(tab.accessibilityKey)
|
||||
.font(TypeStyle.caption2)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.foregroundStyle(tabIconColor(for: tab))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: TabBarDockMetrics.itemHeight)
|
||||
.background {
|
||||
if selection == tab {
|
||||
// Stretch into the dock padding so top/bottom
|
||||
// leftover matches the side leftover (~5 pt).
|
||||
Capsule()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, TabBarDockMetrics.selectionInset)
|
||||
.padding(
|
||||
.vertical,
|
||||
TabBarDockMetrics.selectionInset
|
||||
- TabBarDockMetrics.dockInsetVertical
|
||||
)
|
||||
.matchedGeometryEffect(
|
||||
id: "main-tab-selection",
|
||||
in: selectionNamespace
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(tab.accessibilityKey)
|
||||
.accessibilityAddTraits(selection == tab ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
|
||||
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
|
||||
.glassEffect(.regular.interactive(), in: .capsule)
|
||||
.frame(maxWidth: 360)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.bottom, TabBarDockMetrics.bottomPadding)
|
||||
}
|
||||
|
||||
private func tabIconColor(for tab: AppTab) -> Color {
|
||||
if selection == tab { return palette.accent }
|
||||
// 未选中:浅色模式更深、深色模式更亮,提升 dock 可读性。
|
||||
return colorScheme == .dark
|
||||
? Color(white: 0.76)
|
||||
: palette.textSecondary
|
||||
}
|
||||
}
|
||||
@@ -1,91 +1,30 @@
|
||||
// TabBarVisibility.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Push 进 NavigationStack 子页时隐藏底部自定义 tab 栏(对齐系统 TabView 行为)。
|
||||
// MainTabView 读取 `TabBarHiddenPreferenceKey`;子页用 `hidesTabBarWhenPushed()` 声明。
|
||||
// Push 进 NavigationStack 子页时隐藏底部系统 tab 栏。
|
||||
// 子页用 `hidesTabBarWhenPushed()` 声明,根页面保持原生 TabView 导航可见。
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Preference
|
||||
|
||||
enum TabBarHiddenPreferenceKey: PreferenceKey {
|
||||
static let defaultValue = false
|
||||
|
||||
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
||||
value = value || nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
private enum TabBarVisibleEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue = true
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// `false` when the custom dock is hidden (detail push / sheet over tab content).
|
||||
var isTabBarVisible: Bool {
|
||||
get { self[TabBarVisibleEnvironmentKey.self] }
|
||||
set { self[TabBarVisibleEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
extension View {
|
||||
/// Marks this view as a pushed detail screen so `MainTabView` hides the dock.
|
||||
/// Marks this view as a pushed detail screen so the native tab bar hides.
|
||||
func hidesTabBarWhenPushed() -> some View {
|
||||
preference(key: TabBarHiddenPreferenceKey.self, value: true)
|
||||
toolbar(.hidden, for: .tabBar)
|
||||
}
|
||||
|
||||
/// Bottom inset for scroll *content* above the floating dock (ScrollView inner stacks).
|
||||
/// Adds ordinary footer breathing room; native TabView owns tab-bar clearance.
|
||||
func tabBarScrollBottomPadding() -> some View {
|
||||
modifier(TabBarScrollBottomPaddingModifier())
|
||||
padding(.bottom, Spacing.lg)
|
||||
}
|
||||
|
||||
/// Bottom scroll-content margin for `List` tab roots. Unlike padding on the list
|
||||
/// container, this extends the scrollable area so rows can scroll above the dock.
|
||||
/// Adds a scrollable footer margin to `List` content above the system tab bar.
|
||||
func tabBarListScrollBottomMargin() -> some View {
|
||||
modifier(TabBarListScrollBottomMarginModifier())
|
||||
}
|
||||
}
|
||||
|
||||
enum TabBarDockMetrics {
|
||||
static let itemHeight: CGFloat = 46
|
||||
static let iconSize: CGFloat = 22
|
||||
/// Horizontal glass pad is 0: the selected capsule's `selectionInset`
|
||||
/// is the only side gap. `Spacing.md` (16) plus that 5 pt was ~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(
|
||||
contentMargins(
|
||||
.bottom,
|
||||
isTabBarVisible ? TabBarDockMetrics.scrollClearance : Spacing.lg
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TabBarListScrollBottomMarginModifier: ViewModifier {
|
||||
@Environment(\.isTabBarVisible) private var isTabBarVisible
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.contentMargins(
|
||||
.bottom,
|
||||
isTabBarVisible ? TabBarDockMetrics.scrollClearance : Spacing.lg,
|
||||
Spacing.lg,
|
||||
for: .scrollContent
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,11 @@ struct HomeView: View {
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
@State private var path = NavigationPath()
|
||||
@State private var dictionaryPreviewEntries: [PersonalDictionary.Entry] = []
|
||||
@State private var dictionarySuggestions: [RimeFrequentTerm] = []
|
||||
@State private var pendingDictionarySuggestion: RimeFrequentTerm?
|
||||
|
||||
private let appGroupStore = AppGroupStore()
|
||||
private let rimeFrequentTermStore = RimeFrequentTermStore()
|
||||
|
||||
private var usesWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
@@ -189,23 +193,49 @@ struct HomeView: View {
|
||||
}
|
||||
.onAppear {
|
||||
refreshPermissionStatuses()
|
||||
refreshDictionaryPreview()
|
||||
refreshDictionarySuggestions()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
refreshPermissionStatuses()
|
||||
refreshDictionaryPreview()
|
||||
refreshDictionarySuggestions()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
refreshDictionaryPreview()
|
||||
refreshDictionarySuggestions()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
refreshDictionaryPreview()
|
||||
refreshDictionarySuggestions()
|
||||
}
|
||||
.onChange(of: path.count) { _, count in
|
||||
guard count == 0 else { return }
|
||||
refreshDictionaryPreview()
|
||||
refreshDictionarySuggestions()
|
||||
}
|
||||
.alert(
|
||||
pendingDictionarySuggestion.map {
|
||||
AppL10n.format("home.card.dictionary.confirm.title", $0.term)
|
||||
} ?? "",
|
||||
isPresented: Binding(
|
||||
get: { pendingDictionarySuggestion != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: pendingDictionarySuggestion
|
||||
) { suggestion in
|
||||
Button("home.card.dictionary.confirm.add") {
|
||||
addSuggestedTerm(suggestion)
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: { suggestion in
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"home.card.dictionary.confirm.message",
|
||||
suggestion.commitCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,8 +335,8 @@ struct HomeView: View {
|
||||
/// and the body grows with its rows.
|
||||
private var homeLibrarySection: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
historyCard
|
||||
dictionaryCard
|
||||
historyCard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,33 +374,84 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var dictionaryCard: some View {
|
||||
homeLibraryCard(
|
||||
titleKey: "settings.personalDictionary.title",
|
||||
systemImage: "character.book.closed",
|
||||
route: .dictionary
|
||||
) {
|
||||
if dictionaryPreviewEntries.isEmpty {
|
||||
libraryEmptyLine("home.card.dictionary.empty")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(dictionaryPreviewEntries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { libraryRowDivider }
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.term)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(dictionaryDetailLine(for: entry))
|
||||
.font(TypeStyle.caption2)
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Button {
|
||||
path.append(HomeRoute.dictionary)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("settings.personalDictionary.title")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(0.6)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("home.card.dictionary.smart.subtitle")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
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)
|
||||
.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)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
|
||||
@@ -424,37 +505,40 @@ struct HomeView: View {
|
||||
.padding(.vertical, Spacing.xs)
|
||||
}
|
||||
|
||||
/// Source · uses · aliases — same secondary line as the dictionary page rows.
|
||||
private func dictionaryDetailLine(for entry: PersonalDictionary.Entry) -> String {
|
||||
var parts = [SharedL10n.string(entry.source.labelKey, language: config.uiLanguage)]
|
||||
if entry.usageCount > 1 {
|
||||
let format = AppL10n.string(
|
||||
"settings.personalDictionary.usageCount",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
parts.append(String(format: format, entry.usageCount))
|
||||
}
|
||||
if !entry.aliases.isEmpty {
|
||||
parts.append(entry.aliases.joined(separator: " / "))
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
micStatus = AppPermissions.micStatus
|
||||
speechStatus = AppPermissions.speechStatus
|
||||
}
|
||||
|
||||
private func refreshDictionaryPreview() {
|
||||
dictionaryPreviewEntries = AppGroupStore().personalDictionary.entries
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.updatedAt != rhs.updatedAt {
|
||||
return lhs.updatedAt > rhs.updatedAt
|
||||
private func refreshDictionarySuggestions() {
|
||||
let dictionary = appGroupStore.personalDictionary
|
||||
let suggestions = rimeFrequentTermStore.suggestions(
|
||||
excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
|
||||
limit: Self.dictionarySuggestionLimit
|
||||
)
|
||||
dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount
|
||||
? suggestions
|
||||
: []
|
||||
}
|
||||
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() {
|
||||
@@ -672,6 +756,8 @@ struct HomeView: View {
|
||||
|
||||
/// Rows shown inside the history / dictionary preview cards.
|
||||
private static let libraryPreviewLimit = 3
|
||||
private static let minimumDictionarySuggestionCount = 3
|
||||
private static let dictionarySuggestionLimit = 5
|
||||
|
||||
private static let previewTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
@@ -9,6 +9,14 @@ import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum AnalyticsFirstOpenAttribution {
|
||||
static let ordinaryLaunch: AnalyticsAcquisitionChannel = .appStoreOrganic
|
||||
|
||||
static func trustedChannel(for url: URL) -> AnalyticsAcquisitionChannel? {
|
||||
ReferralUniversalLink.code(from: url) == nil ? nil : .referral
|
||||
}
|
||||
}
|
||||
|
||||
struct MainAppRoot: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -21,7 +29,8 @@ struct MainAppRoot: View {
|
||||
@StateObject private var accountSession: AccountSessionCoordinator
|
||||
@State private var clmWarmupTask: Task<Void, Never>?
|
||||
@State private var rimeStartupTask: Task<Void, Never>?
|
||||
@State private var firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel = .unknown
|
||||
@State private var firstOpenAcquisitionChannel =
|
||||
AnalyticsFirstOpenAttribution.ordinaryLaunch
|
||||
|
||||
init(accountDependencies: AccountDependencies? = nil) {
|
||||
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
|
||||
@@ -34,8 +43,18 @@ struct MainAppRoot: View {
|
||||
dependencies: resolvedDependencies,
|
||||
analyticsClient: analytics.client,
|
||||
onAccountAuthenticated: { accountID in
|
||||
AppGroupStore().setManagedGatewayAccountSessionAvailable(true)
|
||||
await analytics.observeAuthenticatedAccount(accountID)
|
||||
},
|
||||
onAccountSignedOut: {
|
||||
// Revoke the local choice before exposing signed-out UI.
|
||||
// BYOK then remains the only possible AI execution path.
|
||||
AppGroupStore().setManagedGatewayAccountSessionAvailable(false)
|
||||
let config = ProviderConfig.shared
|
||||
if config.credentialSource != .byok {
|
||||
config.credentialSource = .byok
|
||||
}
|
||||
},
|
||||
onAccountDeleted: {
|
||||
await analytics.handleAccountDeletion()
|
||||
}
|
||||
@@ -93,6 +112,9 @@ struct MainAppRoot: View {
|
||||
category: "flow"
|
||||
)
|
||||
AppCloudSync.shared.startObservingExternalChanges()
|
||||
if scenePhase == .active {
|
||||
refreshOfficialSkillCatalog(reason: "MainAppRoot.onAppear")
|
||||
}
|
||||
|
||||
// Heavy work (Flow / CLM / Rime) only after onboarding. Doing it
|
||||
// earlier jetsams the host (~150 MB+) and the keyboard dies with it.
|
||||
@@ -168,6 +190,7 @@ struct MainAppRoot: View {
|
||||
FlowSessionBridge.setHostHeavy(false)
|
||||
return
|
||||
}
|
||||
refreshOfficialSkillCatalog(reason: "scenePhase.active")
|
||||
if config.hasCompletedOnboarding {
|
||||
activateForegroundServices(reason: "scenePhase.active")
|
||||
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
|
||||
@@ -179,6 +202,17 @@ struct MainAppRoot: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshOfficialSkillCatalog(reason: String) {
|
||||
Task {
|
||||
let outcome = await OfficialSkillCatalogRefreshService.shared.refreshIfNeeded(
|
||||
reason: reason
|
||||
)
|
||||
if outcome.didUpdateCache {
|
||||
AIAgentSkillLayoutStore.shared.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts foreground-only services once per active transition. Rime yields
|
||||
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
|
||||
private func activateForegroundServices(reason: String) {
|
||||
@@ -275,7 +309,9 @@ struct MainAppRoot: View {
|
||||
|
||||
private func handleIncomingURL(_ url: URL) {
|
||||
if accountSession.handleIncomingURL(url) {
|
||||
firstOpenAcquisitionChannel = .referral
|
||||
firstOpenAcquisitionChannel =
|
||||
AnalyticsFirstOpenAttribution.trustedChannel(for: url)
|
||||
?? AnalyticsFirstOpenAttribution.ordinaryLaunch
|
||||
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -25,9 +25,8 @@ struct MainSplitView: View {
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.background(palette.background)
|
||||
// No floating dock in split mode — child scroll views should not
|
||||
// reserve bottom clearance for the phone tab bar.
|
||||
.environment(\.isTabBarVisible, false)
|
||||
// No floating tab bar in split mode; shared footer spacing remains
|
||||
// useful for the final rows in each scroll view.
|
||||
}
|
||||
|
||||
// MARK: - Sidebar
|
||||
|
||||
@@ -7,10 +7,8 @@ import SwiftUI
|
||||
struct MainTabView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
|
||||
@State private var tab: AppTab = .keyboard
|
||||
@State private var isTabBarHidden = false
|
||||
|
||||
private var usesSplitLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
@@ -37,29 +35,45 @@ struct MainTabView: View {
|
||||
// MARK: - Phone layout
|
||||
|
||||
private var phoneTabLayout: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
palette.background.ignoresSafeArea()
|
||||
|
||||
MainTabContent(tab: tab)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.environment(\.isTabBarVisible, !isTabBarHidden)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !isTabBarHidden {
|
||||
// Match floating dock + home-indicator clearance so
|
||||
// page footers / scroll ends sit above MinimalTabBar.
|
||||
Color.clear.frame(height: TabBarDockMetrics.scrollClearance)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
|
||||
withAnimation(Motion.quick) {
|
||||
isTabBarHidden = hidden
|
||||
}
|
||||
TabView(selection: $tab) {
|
||||
Tab(value: AppTab.keyboard) {
|
||||
HomeView()
|
||||
} label: {
|
||||
tabLabel(for: .keyboard)
|
||||
}
|
||||
|
||||
if !isTabBarHidden {
|
||||
MinimalTabBar(selection: $tab)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
Tab(value: AppTab.skills) {
|
||||
AIAgentSkillsView()
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import SwiftUI
|
||||
struct PolishStylesView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var history = SpeechHistoryStore.shared
|
||||
|
||||
@State private var catalog = AppGroupStore().polishStyleCatalog
|
||||
@State private var activeID = AppGroupStore().activePolishStyleId
|
||||
@@ -19,6 +20,7 @@ struct PolishStylesView: View {
|
||||
@State private var editingPack: PolishStylePack?
|
||||
@State private var viewingPack: PolishStylePack?
|
||||
@State private var errorMessage: String?
|
||||
@State private var isGeneratingLearnedStyle = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let columns = [
|
||||
@@ -30,6 +32,7 @@ struct PolishStylesView: View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: Spacing.xl) {
|
||||
styleLearningCard
|
||||
packGridSection(
|
||||
title: "polishStyles.builtin.section",
|
||||
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
|
||||
@@ -98,6 +101,103 @@ 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
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "waveform.badge.sparkles")
|
||||
.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)
|
||||
} else {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
Text(
|
||||
isGeneratingLearnedStyle
|
||||
? AppL10n.string("polishStyles.learn.generating")
|
||||
: AppL10n.string("polishStyles.learn.action")
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
.disabled(!corpus.isReady || isGeneratingLearnedStyle || reachedLimit)
|
||||
|
||||
Text(
|
||||
reachedLimit
|
||||
? AppL10n.string("polishStyles.learn.limit")
|
||||
: AppL10n.string("polishStyles.learn.privacy")
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func packGridSection(
|
||||
title: LocalizedStringKey,
|
||||
packs: [PolishStylePack]
|
||||
@@ -176,6 +276,26 @@ struct PolishStylesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func generateLearnedStyle(from corpus: PolishStyleLearningCorpus) {
|
||||
guard corpus.isReady, !isGeneratingLearnedStyle else { return }
|
||||
isGeneratingLearnedStyle = true
|
||||
Task {
|
||||
defer { isGeneratingLearnedStyle = false }
|
||||
do {
|
||||
let generated = try await PolishStyleLearningService(store: store)
|
||||
.generateStyle(
|
||||
from: corpus,
|
||||
outputLanguage: config.uiLanguage
|
||||
)
|
||||
// Always let the user inspect and edit the learned prompt before
|
||||
// it is saved, synced, or made active.
|
||||
editingPack = generated
|
||||
} catch {
|
||||
errorMessage = localizedLearningError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey {
|
||||
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
|
||||
switch pack.id {
|
||||
@@ -255,6 +375,21 @@ struct PolishStylesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedLearningError(_ error: Error) -> String {
|
||||
switch error as? PolishStyleLearningError {
|
||||
case .insufficientCorpus:
|
||||
return AppL10n.string("polishStyles.learn.error.insufficient")
|
||||
case .invalidResponse:
|
||||
return AppL10n.string("polishStyles.learn.error.invalidResponse")
|
||||
case .promptTooLong:
|
||||
return AppL10n.string("polishStyles.learn.error.promptTooLong")
|
||||
case .requestTooLarge:
|
||||
return AppL10n.string("polishStyles.learn.error.requestTooLarge")
|
||||
case nil:
|
||||
return AppL10n.string("polishStyles.learn.error.request")
|
||||
}
|
||||
}
|
||||
|
||||
private func localized(_ error: Error) -> String {
|
||||
switch error as? PolishStyleValidationError {
|
||||
case .emptyName: return AppL10n.string("polishStyles.error.emptyName")
|
||||
|
||||
@@ -46,47 +46,76 @@
|
||||
"onboarding.experience.privacy.cloud.body" = "Audio or text is sent only when you choose cloud recognition or an AI feature, and only to the configured service.";
|
||||
"onboarding.experience.privacy.anywhere.title" = "Your AI Agent, in any app";
|
||||
"onboarding.experience.privacy.anywhere.body" = "Dictate, translate, edit, and act from chat, email, notes, or code.";
|
||||
"onboarding.experience.permissions.title" = "Let OSGKeyboard hear you";
|
||||
"onboarding.experience.permissions.title" = "Let us hear you";
|
||||
"onboarding.experience.permissions.subtitle" = "Voice input needs microphone and speech recognition access. Two system prompts will appear next.";
|
||||
"onboarding.experience.permissions.systemHint" = "These permissions are used only when you start voice input.";
|
||||
"onboarding.experience.permissions.later" = "Set Up Later";
|
||||
"onboarding.experience.keyboard.title" = "Add OSGKeyboard";
|
||||
"onboarding.experience.keyboard.title" = "Add the keyboard";
|
||||
"onboarding.experience.keyboard.subtitle" = "Add the keyboard in iOS Settings and turn on Allow Full Access.";
|
||||
"onboarding.experience.keyboard.ready" = "OSGKeyboard and Full Access are ready";
|
||||
"onboarding.experience.keyboard.fullAccess" = "Full Access connects the keyboard to the host app microphone and optional AI services";
|
||||
"onboarding.experience.keyboard.openAgain" = "Open Settings Again";
|
||||
"onboarding.experience.keyboard.practiceAction" = "Try It Now";
|
||||
"onboarding.experience.keyboard.previewTitle" = "Keyboards";
|
||||
"onboarding.experience.practice.skip" = "Try Later";
|
||||
"onboarding.experience.practice.sample" = "“I’ll arrive tomorrow afternoon and message you when I’m there.”";
|
||||
"onboarding.experience.keyboardSwitch.title" = "Switch keyboards";
|
||||
"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.permissionsTitle" = "Voice access is still needed";
|
||||
"onboarding.experience.practice.permissionsTitle" = "Enable voice access";
|
||||
"onboarding.experience.practice.permissionsSubtitle" = "Go back to finish permission setup, then try your first voice input.";
|
||||
"onboarding.experience.practice.switchTitle" = "Switch to OSGKeyboard";
|
||||
"onboarding.experience.practice.switchTitle" = "Switch keyboards";
|
||||
"onboarding.experience.practice.switchSubtitle" = "Tap the field below, hold the globe key, then choose OSGKeyboard.";
|
||||
"onboarding.experience.practice.fullAccessTitle" = "One more setting";
|
||||
"onboarding.experience.practice.fullAccessTitle" = "Enable Full Access";
|
||||
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard is enabled. Turn on Allow Full Access to use voice input.";
|
||||
"onboarding.experience.practice.fullAccessAction" = "Turn On Full Access";
|
||||
"onboarding.experience.practice.signInTitle" = "Sign in for a free AI writing trial";
|
||||
"onboarding.experience.practice.signInSubtitle" = "The lesson uses OSG Credits by default. This OOBE request will not deduct any credits.";
|
||||
"onboarding.experience.practice.signInBody" = "Sign in with Apple to claim one free OOBE AI polish. Speech is still transcribed on your device.";
|
||||
"onboarding.experience.practice.cloudTitle" = "Enable the free OOBE experience";
|
||||
"onboarding.experience.practice.cloudSubtitle" = "Only this transcript is sent to OSG managed AI and explicitly tagged as OOBE.";
|
||||
"onboarding.experience.practice.cloudBody" = "The server verifies this lesson as OOBE, with no credit reservation or charge. OSG Credits becomes your default AI service afterward.";
|
||||
"onboarding.experience.practice.cloudAction" = "Agree & Try for Free";
|
||||
"onboarding.experience.practice.creditsTitle" = "Preparing OSG Credits";
|
||||
"onboarding.experience.practice.creditsSubtitle" = "This OOBE lesson is completely free.";
|
||||
"onboarding.experience.practice.preparingCredits" = "Preparing your free OOBE request…";
|
||||
"onboarding.experience.practice.creditsFailed" = "OSG Credits is temporarily unavailable. Try again—no credits were deducted.";
|
||||
"onboarding.experience.practice.speakTitle" = "Now, say something";
|
||||
"onboarding.experience.practice.speakSubtitle" = "Tap the microphone on the keyboard, speak, then tap it again. Your text will appear here.";
|
||||
"onboarding.experience.complete.verifiedTitle" = "That’s it.";
|
||||
"onboarding.experience.practice.cloudTitle" = "Start the trial";
|
||||
"onboarding.experience.practice.cloudSubtitle" = "All four lessons work without an account or credits.";
|
||||
"onboarding.experience.practice.cloudBody" = "Speech is transcribed on-device. Only the transcript, lesson sample, and question needed for these exercises are sent to OSG managed AI. The anonymous lesson credential expires when onboarding ends.";
|
||||
"onboarding.experience.practice.cloudAction" = "Agree & Start";
|
||||
"onboarding.experience.practice.creditsTitle" = "Getting ready";
|
||||
"onboarding.experience.practice.creditsSubtitle" = "Verifying the app and obtaining a short-lived, restricted lesson credential.";
|
||||
"onboarding.experience.practice.preparingCredits" = "Securely connecting to OSG AI…";
|
||||
"onboarding.experience.practice.creditsFailed" = "The anonymous trial is temporarily unavailable. Check your connection and try again. You will not be charged.";
|
||||
"onboarding.experience.practice.retry" = "Try Again";
|
||||
"onboarding.experience.practice.progress.voice" = "1 of 4 · Voice Input";
|
||||
"onboarding.experience.practice.progress.translate" = "2 of 4 · AI Translation";
|
||||
"onboarding.experience.practice.progress.reply" = "3 of 4 · AI Reply";
|
||||
"onboarding.experience.practice.progress.askAI" = "4 of 4 · Ask AI";
|
||||
"onboarding.experience.practice.speakTitle" = "Read this aloud";
|
||||
"onboarding.experience.practice.speakSubtitle" = "Tap the keyboard microphone, read the sentence below, then tap it again.";
|
||||
"onboarding.experience.practice.readAloud" = "Read aloud";
|
||||
"onboarding.experience.practice.voiceSample" = "Today is Thursday. Where are you planning to go for fun?";
|
||||
"onboarding.experience.practice.translateTitle" = "Try Translate";
|
||||
"onboarding.experience.practice.translateSubtitle" = "Copy the sample below, tap Translate in OSGKeyboard, then insert the result into the field.";
|
||||
"onboarding.experience.practice.replyTitle" = "Try Reply";
|
||||
"onboarding.experience.practice.replySubtitle" = "Use the same sample, tap Reply in the keyboard, then insert the generated response.";
|
||||
"onboarding.experience.practice.askAITitle" = "Ask AI";
|
||||
"onboarding.experience.practice.askAISubtitle" = "Hold the keyboard microphone, ask a question, then insert the answer when it is ready.";
|
||||
"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.subtitle" = "Switch to OSGKeyboard in any text field to speak, write, or hand off the next step to your AI Agent.";
|
||||
"onboarding.experience.complete.subtitle" = "Tap Start Using";
|
||||
"onboarding.experience.complete.dictate" = "Voice to Text";
|
||||
"onboarding.experience.complete.translate" = "Translate";
|
||||
"onboarding.experience.complete.agent" = "AI Agent";
|
||||
"onboarding.experience.complete.edit" = "Voice Edit";
|
||||
"onboarding.experience.complete.reply" = "Smart Reply";
|
||||
"onboarding.experience.complete.agent" = "Ask AI";
|
||||
"onboarding.experience.complete.action" = "Start Using";
|
||||
"settings.onboarding.replay" = "Restart permission setup";
|
||||
|
||||
@@ -485,6 +514,12 @@
|
||||
"home.preview.placeholder" = "Tap to type and test…";
|
||||
"home.card.history.empty" = "No voice transcripts yet";
|
||||
"home.card.dictionary.empty" = "No words yet";
|
||||
"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your Rime typing";
|
||||
"home.card.dictionary.smart.empty" = "Keep using the Chinese keyboard. Repeated new terms will appear here.";
|
||||
"home.card.dictionary.smart.accessibilityHint" = "Used %lld times. Double-tap to add it to your personal dictionary.";
|
||||
"home.card.dictionary.confirm.title" = "Add “%@” to your personal dictionary?";
|
||||
"home.card.dictionary.confirm.message" = "Rime has recorded this term %lld times. Once added, it will also improve speech recognition and AI polishing.";
|
||||
"home.card.dictionary.confirm.add" = "Add to dictionary";
|
||||
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.wide.mode.cloud" = "Cloud";
|
||||
"home.wide.mode.local" = "On-device";
|
||||
@@ -508,7 +543,7 @@
|
||||
|
||||
/* AI Agent skills */
|
||||
"skills.title" = "Skills";
|
||||
"skills.enabled.section" = "In use (%d/%d)";
|
||||
"skills.enabled.section" = "In use (%d)";
|
||||
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
||||
"skills.available.section" = "Available";
|
||||
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
||||
@@ -528,14 +563,32 @@
|
||||
"skills.action.turnOffHint" = "Removes it from the keyboard only.";
|
||||
"skills.action.addToKeyboard" = "Add to keyboard";
|
||||
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
||||
"skills.full.title" = "Skill slots full";
|
||||
"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
|
||||
"skills.reply.name" = "Reply";
|
||||
"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
|
||||
"skills.replyInSourceLanguage.name" = "Reply in source language";
|
||||
"skills.replyInSourceLanguage.description" = "Understand the copied text and reply naturally in the same language.";
|
||||
"skills.summarize.name" = "Summarize";
|
||||
"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
|
||||
"skills.extractConclusions.name" = "Extract conclusions";
|
||||
"skills.extractConclusions.description" = "Pull out decisions, conclusions, and next steps from long text.";
|
||||
"skills.translate.name" = "Translate";
|
||||
"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
|
||||
"skills.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 request";
|
||||
"skills.clarifyRequest.description" = "Ask only the key questions needed before replying or acting.";
|
||||
"skills.empathyReply.name" = "Empathetic reply";
|
||||
"skills.empathyReply.description" = "Acknowledge a complaint and offer a safe next step.";
|
||||
"skills.askForDetails.name" = "Ask for details";
|
||||
"skills.askForDetails.description" = "Request the information needed to diagnose or resolve a problem.";
|
||||
"skills.businessReply.name" = "Business reply";
|
||||
"skills.businessReply.description" = "Draft a professional reply with clear commitment boundaries.";
|
||||
"skills.organizeList.name" = "Organize list";
|
||||
"skills.organizeList.description" = "Clean up lists, agendas, and steps without adding new tasks.";
|
||||
"skills.extractTodos.name" = "Extract tasks";
|
||||
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
|
||||
"skills.extractEvents.name" = "Extract events";
|
||||
@@ -589,6 +642,20 @@
|
||||
"polishStyles.custom.section" = "My styles";
|
||||
"polishStyles.intro.title" = "Choose a writing personality";
|
||||
"polishStyles.intro.body" = "The selected style shapes every polished dictation. Your custom styles sync through iCloud when settings sync is enabled.";
|
||||
"polishStyles.learn.title" = "Learn and generate my speaking style";
|
||||
"polishStyles.learn.body" = "Learn your recurring speaking habits while using earlier polish prompts only to filter out AI-added style.";
|
||||
"polishStyles.learn.progress" = "%lld / %lld effective characters";
|
||||
"polishStyles.learn.remaining" = "%lld more needed";
|
||||
"polishStyles.learn.ready" = "Ready to generate";
|
||||
"polishStyles.learn.action" = "Generate My Style";
|
||||
"polishStyles.learn.generating" = "Learning…";
|
||||
"polishStyles.learn.privacy" = "Only after you tap Generate, eligible before-and-after text and prior style prompts are sent to your configured AI service. You can review the result 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.structured.description" = "Organize multiple points into clear paragraphs and lists.";
|
||||
"polishStyles.formal.description" = "Professional, restrained writing for email and work.";
|
||||
@@ -754,6 +821,7 @@
|
||||
"account.signIn.apple" = "Sign in with Apple";
|
||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||
"account.signIn.loading" = "Signing in…";
|
||||
"account.error.sessionExpired" = "Your session expired. Sign in again to continue.";
|
||||
"account.summary.section" = "Account";
|
||||
"account.summary.signedIn" = "Signed in";
|
||||
"account.summary.protected" = "Protected account";
|
||||
|
||||
@@ -46,47 +46,76 @@
|
||||
"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。";
|
||||
"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用";
|
||||
"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。";
|
||||
"onboarding.experience.permissions.title" = "让 OSGKeyboard 听见你";
|
||||
"onboarding.experience.permissions.title" = "让我们听见你";
|
||||
"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
|
||||
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
|
||||
"onboarding.experience.permissions.later" = "暂时跳过";
|
||||
"onboarding.experience.keyboard.title" = "添加 OSGKeyboard";
|
||||
"onboarding.experience.keyboard.title" = "添加键盘";
|
||||
"onboarding.experience.keyboard.subtitle" = "在 iOS 设置中添加键盘,并打开「允许完全访问」。";
|
||||
"onboarding.experience.keyboard.ready" = "OSGKeyboard 与完全访问均已就绪";
|
||||
"onboarding.experience.keyboard.fullAccess" = "完全访问用于连接主 App 的麦克风和可选 AI 服务";
|
||||
"onboarding.experience.keyboard.openAgain" = "再次打开设置";
|
||||
"onboarding.experience.keyboard.practiceAction" = "开始体验";
|
||||
"onboarding.experience.keyboard.previewTitle" = "键盘";
|
||||
"onboarding.experience.practice.skip" = "稍后体验";
|
||||
"onboarding.experience.practice.sample" = "“我明天下午到,到了以后给你消息。”";
|
||||
"onboarding.experience.keyboardSwitch.title" = "切换键盘";
|
||||
"onboarding.experience.keyboardSwitch.subtitle" = "激活键盘后,我们会自动验证是否已开启完整访问。";
|
||||
"onboarding.experience.keyboardSwitch.instruction" = "点击下方输入框,长按地球键,然后选择 OSGKeyboard。";
|
||||
"onboarding.experience.keyboardSwitch.placeholder" = "在这里切换并验证键盘";
|
||||
"onboarding.experience.keyboardSwitch.waiting" = "等待 OSGKeyboard 出现…";
|
||||
"onboarding.experience.keyboardSwitch.ready" = "OSGKeyboard 与完整访问均已验证";
|
||||
"onboarding.experience.keyboardSwitch.fullAccessMissing" = "已检测到 OSGKeyboard,但尚未检测到完整访问";
|
||||
"onboarding.experience.keyboardSwitch.timeout" = "尚未检测到 OSGKeyboard,请确认已完成切换";
|
||||
"onboarding.experience.keyboardSwitch.checkSettings" = "返回设置检查完整访问";
|
||||
"onboarding.experience.keyboardSwitch.waitingAction" = "等待验证";
|
||||
"onboarding.experience.practice.skip" = "暂时跳过体验";
|
||||
"onboarding.experience.practice.sample" = "我明天下午到,到了以后给你消息。";
|
||||
"onboarding.experience.practice.editorTitle" = "新消息";
|
||||
"onboarding.experience.practice.permissionsTitle" = "还需要语音权限";
|
||||
"onboarding.experience.practice.permissionsTitle" = "开启语音权限";
|
||||
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
|
||||
"onboarding.experience.practice.switchTitle" = "切换到 OSGKeyboard";
|
||||
"onboarding.experience.practice.switchTitle" = "切换键盘";
|
||||
"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。";
|
||||
"onboarding.experience.practice.fullAccessTitle" = "再完成一个设置";
|
||||
"onboarding.experience.practice.fullAccessTitle" = "开启完全访问";
|
||||
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard 已启用。打开「允许完全访问」后就能使用语音。";
|
||||
"onboarding.experience.practice.fullAccessAction" = "去设置打开完全访问";
|
||||
"onboarding.experience.practice.signInTitle" = "登录,免费体验 AI 成文";
|
||||
"onboarding.experience.practice.signInSubtitle" = "教学体验默认使用 OSG 积分服务,本次 OOBE 请求不会扣除任何积分。";
|
||||
"onboarding.experience.practice.signInBody" = "使用 Apple 登录以领取一次免费的 OOBE AI 润色。语音仍在设备端转写。";
|
||||
"onboarding.experience.practice.cloudTitle" = "启用免费 OOBE 体验";
|
||||
"onboarding.experience.practice.cloudSubtitle" = "仅将本次转写文字发送到 OSG 托管 AI,并明确标记为 OOBE。";
|
||||
"onboarding.experience.practice.cloudBody" = "本次教学请求由服务端验证为 OOBE,不预扣、不结算积分。完成后,OSG 积分将作为默认 AI 服务。";
|
||||
"onboarding.experience.practice.cloudAction" = "同意并免费体验";
|
||||
"onboarding.experience.practice.creditsTitle" = "正在准备 OSG 积分服务";
|
||||
"onboarding.experience.practice.creditsSubtitle" = "本次 OOBE 教学完全免费。";
|
||||
"onboarding.experience.practice.preparingCredits" = "正在准备免费 OOBE 请求…";
|
||||
"onboarding.experience.practice.creditsFailed" = "暂时无法连接 OSG 积分服务,请重试。不会扣除积分。";
|
||||
"onboarding.experience.practice.speakTitle" = "现在,说一句话";
|
||||
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,说完后再点一次。文字会直接出现在这里。";
|
||||
"onboarding.experience.complete.verifiedTitle" = "就是这样。";
|
||||
"onboarding.experience.practice.cloudTitle" = "开始体验";
|
||||
"onboarding.experience.practice.cloudSubtitle" = "四项教学无需账号,也不会消耗积分。";
|
||||
"onboarding.experience.practice.cloudBody" = "语音在设备端转写;只有本次教学所需的转写文字、示例文本和问题会发送到 OSG 托管 AI。匿名体验凭证将在教学结束后失效。";
|
||||
"onboarding.experience.practice.cloudAction" = "同意并开始体验";
|
||||
"onboarding.experience.practice.creditsTitle" = "准备体验";
|
||||
"onboarding.experience.practice.creditsSubtitle" = "正在验证 App 并获取短时、限权的教学凭证。";
|
||||
"onboarding.experience.practice.preparingCredits" = "正在安全连接 OSG AI…";
|
||||
"onboarding.experience.practice.creditsFailed" = "暂时无法准备匿名体验。请检查网络后重试,不会产生任何费用。";
|
||||
"onboarding.experience.practice.retry" = "重试";
|
||||
"onboarding.experience.practice.progress.voice" = "1 / 4 · 语音输入";
|
||||
"onboarding.experience.practice.progress.translate" = "2 / 4 · AI 翻译";
|
||||
"onboarding.experience.practice.progress.reply" = "3 / 4 · AI 回复";
|
||||
"onboarding.experience.practice.progress.askAI" = "4 / 4 · 询问 AI";
|
||||
"onboarding.experience.practice.speakTitle" = "跟着念一句";
|
||||
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,照着下方文字念,说完后再点一次。";
|
||||
"onboarding.experience.practice.readAloud" = "请照着念";
|
||||
"onboarding.experience.practice.voiceSample" = "今天是礼拜四,你打算去哪里玩?";
|
||||
"onboarding.experience.practice.translateTitle" = "试试翻译";
|
||||
"onboarding.experience.practice.translateSubtitle" = "先复制下面的示例,再在 OSGKeyboard 中点击「翻译」。确认结果后插入文本框。";
|
||||
"onboarding.experience.practice.replyTitle" = "试试回复";
|
||||
"onboarding.experience.practice.replySubtitle" = "使用同一段示例文字,点击键盘中的「回复」,然后插入生成的回复。";
|
||||
"onboarding.experience.practice.askAITitle" = "问问 AI";
|
||||
"onboarding.experience.practice.askAISubtitle" = "长按键盘麦克风,说出一个问题;松开后等待回答,并将结果插入文本框。";
|
||||
"onboarding.experience.practice.copyAction" = "复制示例文字";
|
||||
"onboarding.experience.practice.copied" = "已复制,请切换到 OSGKeyboard";
|
||||
"onboarding.experience.practice.waiting" = "等待你在键盘中完成这一步…";
|
||||
"onboarding.experience.login.title" = "登录领 1000 积分";
|
||||
"onboarding.experience.login.subtitle" = "四项体验已经完成。登录后可继续使用 OSG 托管 AI,无需填写 API Key。";
|
||||
"onboarding.experience.login.body" = "符合首次注册资格时,1000 积分会自动到账。设备完整性检查用于防止重复领取。";
|
||||
"onboarding.experience.login.skip" = "暂不登录";
|
||||
"onboarding.experience.login.signedIn" = "登录成功,积分已刷新";
|
||||
"onboarding.experience.login.balance %lld" = "当前余额:%lld 积分";
|
||||
"onboarding.experience.complete.verifiedTitle" = "已经准备好了";
|
||||
"onboarding.experience.complete.title" = "已经准备好了";
|
||||
"onboarding.experience.complete.subtitle" = "以后在任何输入框切换到 OSGKeyboard,开口成文,也可以继续交给 AI Agent。";
|
||||
"onboarding.experience.complete.subtitle" = "点击开始使用";
|
||||
"onboarding.experience.complete.dictate" = "语音成文";
|
||||
"onboarding.experience.complete.translate" = "翻译";
|
||||
"onboarding.experience.complete.agent" = "AI Agent";
|
||||
"onboarding.experience.complete.edit" = "语音编辑";
|
||||
"onboarding.experience.complete.reply" = "智能回复";
|
||||
"onboarding.experience.complete.agent" = "询问 AI";
|
||||
"onboarding.experience.complete.action" = "开始使用";
|
||||
"settings.onboarding.replay" = "重新开始权限引导";
|
||||
|
||||
@@ -484,6 +513,12 @@
|
||||
"home.preview.placeholder" = "点这里试试键盘";
|
||||
"home.card.history.empty" = "还没有语音识别记录";
|
||||
"home.card.dictionary.empty" = "还没有词条";
|
||||
"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词";
|
||||
"home.card.dictionary.smart.empty" = "继续使用中文键盘,重复输入的新词会出现在这里。";
|
||||
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
|
||||
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?";
|
||||
"home.card.dictionary.confirm.message" = "Rime 已记录这个词 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
|
||||
"home.card.dictionary.confirm.add" = "加入词库";
|
||||
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||
"home.wide.mode.cloud" = "云端";
|
||||
"home.wide.mode.local" = "本机";
|
||||
@@ -507,7 +542,7 @@
|
||||
|
||||
/* AI Agent 技能 */
|
||||
"skills.title" = "技能";
|
||||
"skills.enabled.section" = "使用中(%d/%d)";
|
||||
"skills.enabled.section" = "使用中(%d)";
|
||||
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
||||
"skills.available.section" = "可添加";
|
||||
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
||||
@@ -527,14 +562,32 @@
|
||||
"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
|
||||
"skills.action.addToKeyboard" = "添加到键盘";
|
||||
"skills.action.reinstallShortcut" = "重新安装捷径";
|
||||
"skills.full.title" = "技能已满";
|
||||
"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
|
||||
"skills.reply.name" = "回复";
|
||||
"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
|
||||
"skills.replyInSourceLanguage.name" = "用原语言回复";
|
||||
"skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。";
|
||||
"skills.summarize.name" = "总结";
|
||||
"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
|
||||
"skills.extractConclusions.name" = "提取结论";
|
||||
"skills.extractConclusions.description" = "从长文提取决定、结论和下一步。";
|
||||
"skills.translate.name" = "翻译";
|
||||
"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
|
||||
"skills.acceptInvitation.name" = "接受邀约";
|
||||
"skills.acceptInvitation.description" = "起草简洁的接受回复,并确认重要信息。";
|
||||
"skills.declineInvitation.name" = "委婉拒绝";
|
||||
"skills.declineInvitation.description" = "真诚婉拒邀约,不虚构理由。";
|
||||
"skills.acceptTask.name" = "确认任务";
|
||||
"skills.acceptTask.description" = "确认收到的事项和原文写明的截止时间。";
|
||||
"skills.clarifyRequest.name" = "澄清要求";
|
||||
"skills.clarifyRequest.description" = "只追问回答或执行前最关键的信息。";
|
||||
"skills.empathyReply.name" = "共情回复";
|
||||
"skills.empathyReply.description" = "理解投诉或负面反馈,并给出稳妥下一步。";
|
||||
"skills.askForDetails.name" = "询问细节";
|
||||
"skills.askForDetails.description" = "询问定位或解决问题所需的信息。";
|
||||
"skills.businessReply.name" = "商务回复";
|
||||
"skills.businessReply.description" = "起草专业清晰、承诺边界明确的回复。";
|
||||
"skills.organizeList.name" = "整理清单";
|
||||
"skills.organizeList.description" = "整理清单、议程和步骤,不新增任务。";
|
||||
"skills.extractTodos.name" = "提取待办";
|
||||
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
|
||||
"skills.extractEvents.name" = "提取日程";
|
||||
@@ -588,6 +641,20 @@
|
||||
"polishStyles.custom.section" = "我的风格";
|
||||
"polishStyles.intro.title" = "选择完整写作人格";
|
||||
"polishStyles.intro.body" = "选中的风格会影响每次听写润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
|
||||
"polishStyles.learn.title" = "学习并生成我的说话风格";
|
||||
"polishStyles.learn.body" = "学习你反复出现的说话习惯;历史润色 Prompt 只用于排除 AI 附加的风格。";
|
||||
"polishStyles.learn.progress" = "%lld / %lld 个有效字符";
|
||||
"polishStyles.learn.remaining" = "还需 %lld 个";
|
||||
"polishStyles.learn.ready" = "可以开始生成";
|
||||
"polishStyles.learn.action" = "生成我的风格";
|
||||
"polishStyles.learn.generating" = "正在学习…";
|
||||
"polishStyles.learn.privacy" = "只有点击生成后,符合条件的润色前后文本和历史风格 Prompt 才会发送给你配置的 AI 服务;保存前可以先检查和修改。";
|
||||
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。";
|
||||
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
|
||||
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
|
||||
"polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
|
||||
"polishStyles.learn.error.request" = "无法通过当前配置的 AI 服务生成风格,请检查网络和 AI 设置后重试。";
|
||||
"polishStyles.light.description" = "修正识别错误与标点,尽量少改原话。";
|
||||
"polishStyles.structured.description" = "将多个事项整理为清晰段落与列表。";
|
||||
"polishStyles.formal.description" = "适合邮件和工作的专业、克制表达。";
|
||||
@@ -753,6 +820,7 @@
|
||||
"account.signIn.apple" = "通过 Apple 登录";
|
||||
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||
"account.signIn.loading" = "正在登录…";
|
||||
"account.error.sessionExpired" = "登录状态已过期,请重新登录后继续。";
|
||||
"account.summary.section" = "账号";
|
||||
"account.summary.signedIn" = "已登录";
|
||||
"account.summary.protected" = "账号已安全保护";
|
||||
|
||||
@@ -65,6 +65,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
||||
/// One random ID per keyboard presentation. The repository splits this ID
|
||||
/// into independent UTC-day fragments when a presentation crosses midnight.
|
||||
private var keyboardUsageSessionID = UUID()
|
||||
|
||||
private var memoryTelemetryContext: String {
|
||||
let language = typingSessionStorage?.language.rawValue ?? "-"
|
||||
@@ -306,6 +309,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
keyboardUsageSessionID = UUID()
|
||||
AnalyticsExtensionService.shared.recordPresentation(
|
||||
hasFullAccess: hasFullAccess
|
||||
)
|
||||
@@ -329,7 +333,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state: state,
|
||||
host: WhatsNewDemoDriver.HostHooks(
|
||||
insertText: { [weak self] text in
|
||||
self?.insertTextIntoDocument(text)
|
||||
self?.insertTextIntoDocument(text, source: .debugDemo)
|
||||
},
|
||||
deleteBackward: { [weak self] in
|
||||
self?.deleteBackwardFromDocument()
|
||||
@@ -415,7 +419,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
editHintScheduler = EditHintScheduler(state: state)
|
||||
textInserter = KeyboardTextInserter(
|
||||
state: state,
|
||||
insertText: { [weak self] text in self?.insertTextIntoDocument(text) },
|
||||
insertText: { [weak self] text, source in
|
||||
self?.insertTextIntoDocument(text, source: source)
|
||||
},
|
||||
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
||||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||||
@@ -622,8 +628,12 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self?.configSync.persistTranslationTargetLocaleId(id)
|
||||
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||||
}
|
||||
state.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
|
||||
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
|
||||
state.insertNewline = { [weak self] in
|
||||
self?.insertTextIntoDocument("\n", source: .manualKeyboard)
|
||||
}
|
||||
state.insertSpace = { [weak self] in
|
||||
self?.insertTextIntoDocument(" ", source: .manualKeyboard)
|
||||
}
|
||||
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
||||
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
||||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
||||
@@ -771,9 +781,20 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
/// Keeps field-action UI current even when a host app does not immediately
|
||||
/// echo this keyboard's own document mutation through `textDidChange`.
|
||||
private func insertTextIntoDocument(_ text: String) {
|
||||
private func insertTextIntoDocument(
|
||||
_ text: String,
|
||||
source: KeyboardTextInsertionSource
|
||||
) {
|
||||
guard !text.isEmpty else { return }
|
||||
textDocumentProxy.insertText(text)
|
||||
if source.contributesToKeyboardUsage {
|
||||
let counts = KeyboardUsageCharacterClassifier.classify(text)
|
||||
AnalyticsExtensionService.shared.keyboardUsageRecorder
|
||||
.recordManualKeyboardCounts(
|
||||
counts,
|
||||
sessionID: keyboardUsageSessionID
|
||||
)
|
||||
}
|
||||
// A non-empty insertion makes content actions available immediately.
|
||||
// The next host callback remains the authoritative correction.
|
||||
refreshAssistantFieldAction(hasTextOverride: true)
|
||||
@@ -786,7 +807,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func performDocumentReturn() {
|
||||
textDocumentProxy.insertText("\n")
|
||||
insertTextIntoDocument("\n", source: .assistantAction)
|
||||
scheduleAssistantFieldActionRefresh()
|
||||
}
|
||||
|
||||
@@ -980,7 +1001,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state: state,
|
||||
typing: typingSession,
|
||||
onInsert: { [weak self] text in
|
||||
self?.insertTextIntoDocument(text)
|
||||
self?.insertTextIntoDocument(text, source: .manualKeyboard)
|
||||
},
|
||||
onDeleteBackward: { [weak self] in
|
||||
self?.deleteBackwardFromDocument()
|
||||
|
||||
@@ -18,6 +18,7 @@ final class AIKeyboardCoordinator {
|
||||
private var requestInsertionFingerprint: String?
|
||||
private var conversationInsertionFingerprint: String?
|
||||
private var hasConversationInsertionTarget = false
|
||||
private var requestOOBEFeature: ManagedGatewayOOBEFeature?
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
@@ -37,6 +38,7 @@ final class AIKeyboardCoordinator {
|
||||
endConversationIfNeeded()
|
||||
state.aiSession.enter()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
conversationInsertionFingerprint = nil
|
||||
hasConversationInsertionTarget = false
|
||||
}
|
||||
@@ -53,6 +55,7 @@ final class AIKeyboardCoordinator {
|
||||
endConversationIfNeeded()
|
||||
state.aiSession.leave()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
conversationInsertionFingerprint = nil
|
||||
hasConversationInsertionTarget = false
|
||||
}
|
||||
@@ -66,7 +69,12 @@ final class AIKeyboardCoordinator {
|
||||
case .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||
prepareConversationForRequest()
|
||||
guard let conversationID = state.aiSession.conversationID else { return }
|
||||
let disposition = flow.beginAIRecording(conversationID: conversationID)
|
||||
let oobeFeature = expectedOOBEFeature(.askAI)
|
||||
requestOOBEFeature = oobeFeature
|
||||
let disposition = flow.beginAIRecording(
|
||||
conversationID: conversationID,
|
||||
oobeFeature: oobeFeature
|
||||
)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||
}
|
||||
@@ -81,8 +89,23 @@ final class AIKeyboardCoordinator {
|
||||
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
guard !skill.requiresShortcut
|
||||
|| state.confirmedClipboardShortcutIDs.contains(skill.id) else {
|
||||
state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
|
||||
return
|
||||
}
|
||||
prepareConversationForRequest()
|
||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
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.kind == .export {
|
||||
state.pendingClipboardSkillID = skill.id
|
||||
state.pendingClipboardSkillSource = material
|
||||
@@ -113,6 +136,7 @@ final class AIKeyboardCoordinator {
|
||||
submitResolvedPrompt(
|
||||
resolution,
|
||||
taskKind: skill.managedGatewayTaskKind,
|
||||
oobeFeature: oobeFeature,
|
||||
thinkingEnabled: skill.thinkingEnabled
|
||||
)
|
||||
}
|
||||
@@ -120,12 +144,19 @@ final class AIKeyboardCoordinator {
|
||||
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
||||
func submitHintCard(_ card: AIHintCard) {
|
||||
guard canAcceptIdleSubmit else { return }
|
||||
// The OOBE ask-AI lesson must use the explicit hold-to-talk path. Idle
|
||||
// cards can otherwise pull unrelated clipboard material into a request.
|
||||
guard state.oobePracticeSession == nil else { return }
|
||||
prepareConversationForRequest()
|
||||
let resolution = AIHintPool.resolvePrompt(
|
||||
for: card,
|
||||
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
)
|
||||
submitResolvedPrompt(resolution, taskKind: .aiQuestion)
|
||||
submitResolvedPrompt(
|
||||
resolution,
|
||||
taskKind: .aiQuestion,
|
||||
oobeFeature: nil
|
||||
)
|
||||
}
|
||||
|
||||
private var canAcceptIdleSubmit: Bool {
|
||||
@@ -141,6 +172,7 @@ final class AIKeyboardCoordinator {
|
||||
private func submitResolvedPrompt(
|
||||
_ resolution: AIClipboardPrompt.Resolution,
|
||||
taskKind: ManagedGatewayTaskKind,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) {
|
||||
guard case .ready(let prompt) = resolution else {
|
||||
@@ -156,10 +188,12 @@ final class AIKeyboardCoordinator {
|
||||
clearPendingExportSkill()
|
||||
return
|
||||
}
|
||||
requestOOBEFeature = oobeFeature
|
||||
let disposition = flow.submitAIQuestion(
|
||||
text: prompt,
|
||||
conversationID: conversationID,
|
||||
taskKind: taskKind,
|
||||
oobeFeature: oobeFeature,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
@@ -172,6 +206,7 @@ final class AIKeyboardCoordinator {
|
||||
guard state.aiSession.isBusy else { return }
|
||||
clearPendingExportSkill()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
flow.cancelAIRecording()
|
||||
state.aiSession.cancelCurrentWork()
|
||||
}
|
||||
@@ -179,6 +214,7 @@ final class AIKeyboardCoordinator {
|
||||
func confirmPendingAnswer() {
|
||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||
guard insertAnswer(answer) else { return }
|
||||
markOOBECompletedAfterInsertion()
|
||||
state.aiSession.markAnswerInserted(
|
||||
offersSend: state.returnKeyRole.usesActionFill
|
||||
)
|
||||
@@ -190,6 +226,7 @@ final class AIKeyboardCoordinator {
|
||||
func discardPendingAnswer() {
|
||||
state.aiSession.discardReadyAnswer()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
}
|
||||
|
||||
func performCurrentFieldAction() {
|
||||
@@ -274,6 +311,7 @@ final class AIKeyboardCoordinator {
|
||||
// fallback when the field or caret changed during generation.
|
||||
return
|
||||
}
|
||||
markOOBECompletedAfterInsertion()
|
||||
state.aiSession.markAnswerInserted(
|
||||
offersSend: state.returnKeyRole.usesActionFill
|
||||
)
|
||||
@@ -284,6 +322,7 @@ final class AIKeyboardCoordinator {
|
||||
func fail(_ message: String, utteranceID: UUID?) {
|
||||
clearPendingExportSkill()
|
||||
requestInsertionFingerprint = nil
|
||||
requestOOBEFeature = nil
|
||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||
}
|
||||
|
||||
@@ -319,16 +358,51 @@ final class AIKeyboardCoordinator {
|
||||
requestInsertionFingerprint = currentFingerprint
|
||||
}
|
||||
|
||||
private func oobeFeature(for skill: AIClipboardSkill) -> ManagedGatewayOOBEFeature? {
|
||||
switch skill.id {
|
||||
case AIClipboardSkillCatalog.replyID:
|
||||
return expectedOOBEFeature(.clipboardReply)
|
||||
case AIClipboardSkillCatalog.translateID:
|
||||
return expectedOOBEFeature(.clipboardTranslate)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func expectedOOBEFeature(
|
||||
_ feature: ManagedGatewayOOBEFeature
|
||||
) -> ManagedGatewayOOBEFeature? {
|
||||
guard state.oobePracticeSession?.expectedFeature == feature else { return nil }
|
||||
return feature
|
||||
}
|
||||
|
||||
private func oobeMaterial(for feature: ManagedGatewayOOBEFeature?) -> String? {
|
||||
guard let feature,
|
||||
feature == .clipboardReply || feature == .clipboardTranslate,
|
||||
let session = state.oobePracticeSession else {
|
||||
return nil
|
||||
}
|
||||
return KeyboardSetupBridge.oobeClipboardMaterial(sessionID: session.sessionID)
|
||||
}
|
||||
|
||||
private func markOOBECompletedAfterInsertion() {
|
||||
guard let feature = requestOOBEFeature,
|
||||
let session = state.oobePracticeSession else {
|
||||
return
|
||||
}
|
||||
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
|
||||
sessionID: session.sessionID,
|
||||
feature: feature
|
||||
)
|
||||
}
|
||||
|
||||
private var isPendingExportSkill: Bool {
|
||||
guard let id = state.pendingClipboardSkillID else { return false }
|
||||
return resolvedSkill(id: id)?.kind == .export
|
||||
}
|
||||
|
||||
private func resolvedSkill(id: String) -> AIClipboardSkill? {
|
||||
AIClipboardSkillCatalog.skill(
|
||||
id: id,
|
||||
userCatalog: AppGroupStore().agentUserSkillCatalog
|
||||
)
|
||||
state.enabledClipboardSkills.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Parse an export skill. Empty → in-keyboard tip, stay in the host app.
|
||||
|
||||
@@ -11,6 +11,7 @@ final class AnalyticsExtensionService: Sendable {
|
||||
static let shared = AnalyticsExtensionService()
|
||||
|
||||
let client: any AnalyticsClient
|
||||
let keyboardUsageRecorder: any KeyboardUsageRecording
|
||||
|
||||
private init() {
|
||||
let environment = Self.environment
|
||||
@@ -18,7 +19,15 @@ final class AnalyticsExtensionService: Sendable {
|
||||
environment: environment,
|
||||
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint)
|
||||
)
|
||||
let keyboardUsageRuntime = KeyboardUsageRuntime(
|
||||
environment: environment,
|
||||
analyticsRepository: runtime.repository,
|
||||
uploadConfiguration: KeyboardUsageUploadConfiguration(
|
||||
endpoint: Self.keyboardUsageEndpoint
|
||||
)
|
||||
)
|
||||
client = runtime.client
|
||||
keyboardUsageRecorder = keyboardUsageRuntime.recorder
|
||||
}
|
||||
|
||||
func recordPresentation(hasFullAccess _: Bool) {
|
||||
@@ -32,6 +41,10 @@ final class AnalyticsExtensionService: Sendable {
|
||||
string: "https://account.osglab.com/v1/analytics/events"
|
||||
)!
|
||||
|
||||
private static let keyboardUsageEndpoint = URL(
|
||||
string: "https://account.osglab.com/v1/analytics/keyboard-usage"
|
||||
)!
|
||||
|
||||
private static var environment: AnalyticsEnvironment {
|
||||
let appVersion = Bundle.main.object(
|
||||
forInfoDictionaryKey: "CFBundleShortVersionString"
|
||||
|
||||
@@ -42,7 +42,7 @@ public struct AppGroupPersistor {
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
applySkillSnapshot(store: store, into: state)
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
|
||||
@@ -97,11 +97,30 @@ public struct AppGroupPersistor {
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||
applySkillSnapshot(store: store, into: state)
|
||||
state.keyboardHapticIntensity = store.keyboardHapticIntensity
|
||||
applyAPIKeyAvailability(store: store, into: state)
|
||||
}
|
||||
|
||||
/// Resolve once outside SwiftUI body evaluation. Assigning the complete
|
||||
/// value snapshot publishes remote copy changes even when slot IDs are stable.
|
||||
private func applySkillSnapshot(
|
||||
store: AppGroupStore,
|
||||
into state: KeyboardViewController.State
|
||||
) {
|
||||
let layout = store.agentSkillLayout
|
||||
let language = store.uiLanguage
|
||||
state.uiLanguage = language
|
||||
state.enabledClipboardSkillIDs = layout.enabledIDs
|
||||
state.confirmedClipboardShortcutIDs = layout.confirmedShortcutIDs
|
||||
state.enabledClipboardSkills = AIClipboardSkillCatalog.visible(
|
||||
enabledIDs: layout.enabledIDs,
|
||||
officialCatalog: store.officialSkillCatalog,
|
||||
userCatalog: store.agentUserSkillCatalog,
|
||||
uiLanguage: language
|
||||
)
|
||||
}
|
||||
|
||||
/// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when
|
||||
/// the polish key is missing — show a soft tip above the mic instead.
|
||||
private func applyAPIKeyAvailability(
|
||||
|
||||
@@ -34,6 +34,7 @@ final class ClipboardCaptureCoordinator {
|
||||
|
||||
private let state: KeyboardState
|
||||
private let history: ClipboardHistoryStore
|
||||
private let semanticRanking: ClipboardSemanticRankingStore
|
||||
private let pasteboard: ClipboardPasteboardProviding
|
||||
private var pollTimer: Timer?
|
||||
private var isSecureProvider: () -> Bool = { false }
|
||||
@@ -48,10 +49,12 @@ final class ClipboardCaptureCoordinator {
|
||||
init(
|
||||
state: KeyboardState,
|
||||
history: ClipboardHistoryStore = .shared,
|
||||
semanticRanking: ClipboardSemanticRankingStore = .shared,
|
||||
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
|
||||
) {
|
||||
self.state = state
|
||||
self.history = history
|
||||
self.semanticRanking = semanticRanking
|
||||
self.pasteboard = pasteboard
|
||||
}
|
||||
|
||||
@@ -79,6 +82,9 @@ final class ClipboardCaptureCoordinator {
|
||||
// A suggestion belongs to one keyboard presentation. Clear any
|
||||
// presentation state left behind by a reused extension controller.
|
||||
endCurrentSuggestion()
|
||||
if let newest = history.newestAIHintEligibleEntry() {
|
||||
semanticRanking.analyze(newest)
|
||||
}
|
||||
forcesNextSample = true
|
||||
// Delay the system pasteboard read until the first poll tick. A
|
||||
// Universal Clipboard fetch or paste alert during the appear sequence
|
||||
@@ -94,6 +100,7 @@ final class ClipboardCaptureCoordinator {
|
||||
stopPolling()
|
||||
// A1 policy: closing the keyboard ends this generation's suggestion.
|
||||
endCurrentSuggestion()
|
||||
semanticRanking.clear()
|
||||
}
|
||||
|
||||
func refreshFlagsFromStore() {
|
||||
@@ -102,6 +109,9 @@ final class ClipboardCaptureCoordinator {
|
||||
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
if !state.clipboardHistoryEnabled {
|
||||
semanticRanking.clear()
|
||||
}
|
||||
}
|
||||
|
||||
func secureEntryDidChange(isSecure: Bool) {
|
||||
@@ -117,6 +127,7 @@ final class ClipboardCaptureCoordinator {
|
||||
return
|
||||
}
|
||||
endCurrentSuggestion()
|
||||
semanticRanking.clear()
|
||||
state.clipboardOverlay = .none
|
||||
}
|
||||
|
||||
@@ -153,12 +164,16 @@ final class ClipboardCaptureCoordinator {
|
||||
|
||||
func clearHistory() {
|
||||
endCurrentSuggestion()
|
||||
semanticRanking.clear()
|
||||
history.clearAll()
|
||||
}
|
||||
|
||||
func deleteEntry(id: UUID) {
|
||||
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
|
||||
history.remove(id: id)
|
||||
if semanticRanking.snapshot?.entryID == id {
|
||||
semanticRanking.clear()
|
||||
}
|
||||
if deletedChangeCount == state.clipboardSuggestionChangeCount {
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
@@ -233,6 +248,7 @@ final class ClipboardCaptureCoordinator {
|
||||
|
||||
// Prefer hasStrings peek before reading body (reduces empty reads).
|
||||
guard pasteboard.hasStrings else {
|
||||
semanticRanking.clear()
|
||||
history.lastObservedChangeCount = changeCount
|
||||
return
|
||||
}
|
||||
@@ -243,7 +259,9 @@ final class ClipboardCaptureCoordinator {
|
||||
if isCurrentGeneration {
|
||||
return
|
||||
}
|
||||
semanticRanking.clear()
|
||||
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
|
||||
semanticRanking.analyze(entry)
|
||||
updateSuggestion(with: entry, changeCount: changeCount)
|
||||
} else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
@@ -306,6 +324,7 @@ final class ClipboardCaptureCoordinator {
|
||||
// including generations that contain no acceptable text.
|
||||
clearSuggestion()
|
||||
guard sample.hasStrings else {
|
||||
semanticRanking.clear()
|
||||
history.lastObservedChangeCount = changeCount
|
||||
return
|
||||
}
|
||||
@@ -313,7 +332,9 @@ final class ClipboardCaptureCoordinator {
|
||||
// republish content from an already observed generation.
|
||||
guard !isCurrentGeneration else { return }
|
||||
|
||||
semanticRanking.clear()
|
||||
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
|
||||
semanticRanking.analyze(entry)
|
||||
updateSuggestion(with: entry, changeCount: changeCount)
|
||||
} else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
|
||||
@@ -92,7 +92,21 @@ final class KeyboardConfigSync {
|
||||
// Keychain fallback: a reboot must not resurrect the mic gate when
|
||||
// App Group transiently reads empty.
|
||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
||||
state.isOnboardingPracticeActive = KeyboardSetupBridge.isOnboardingPracticeActive
|
||||
let practice = KeyboardSetupBridge.activeOOBEPracticeSession
|
||||
state.oobePracticeSession = practice
|
||||
state.isOnboardingPracticeActive = practice != nil
|
||||
|| KeyboardSetupBridge.isOnboardingPracticeActive
|
||||
switch practice?.expectedFeature {
|
||||
case .voiceInput, .askAI:
|
||||
state.surface = .voice
|
||||
case .clipboardTranslate, .clipboardReply:
|
||||
state.surface = .ai
|
||||
if !state.aiSession.isActive {
|
||||
state.aiSession.enter()
|
||||
}
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func persistLocale(_ id: String) {
|
||||
|
||||
@@ -677,15 +677,22 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
|
||||
func beginAIRecording(
|
||||
conversationID: UUID
|
||||
conversationID: UUID,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil
|
||||
) -> FlowUtteranceStartDisposition {
|
||||
startUtterance(.aiQuestion(conversationID: conversationID))
|
||||
startUtterance(
|
||||
.aiQuestion(
|
||||
conversationID: conversationID,
|
||||
oobeFeature: oobeFeature
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func submitAIQuestion(
|
||||
text: String,
|
||||
conversationID: UUID,
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) -> FlowUtteranceStartDisposition {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -695,6 +702,7 @@ final class KeyboardFlowCoordinator {
|
||||
conversationID: conversationID,
|
||||
prefilledQuestion: trimmed,
|
||||
taskKind: taskKind,
|
||||
oobeFeature: oobeFeature,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
)
|
||||
@@ -1181,8 +1189,14 @@ final class KeyboardFlowCoordinator {
|
||||
let mode: FlowUtteranceMode? = request.mode == .dictation
|
||||
? nil
|
||||
: request.mode
|
||||
let implicitOOBEFeature: ManagedGatewayOOBEFeature? =
|
||||
state.oobePracticeSession?.expectedFeature == .voiceInput
|
||||
&& request.mode == .dictation
|
||||
? .voiceInput
|
||||
: nil
|
||||
let managedOOBEFeature = request.managedOOBEFeature ?? implicitOOBEFeature
|
||||
let managedRequestPurpose = request.managedRequestPurpose
|
||||
?? (state.isOnboardingPracticeActive && request.mode == .dictation ? .oobe : nil)
|
||||
?? (managedOOBEFeature == nil ? nil : .oobe)
|
||||
let command = FlowCommand(
|
||||
sessionId: activeSessionId,
|
||||
utteranceId: currentUtteranceId,
|
||||
@@ -1201,6 +1215,7 @@ final class KeyboardFlowCoordinator {
|
||||
aiConversationID: request.aiConversationID,
|
||||
aiTaskKind: request.aiTaskKind,
|
||||
managedRequestPurpose: managedRequestPurpose,
|
||||
managedOOBEFeature: managedOOBEFeature,
|
||||
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
||||
processingDeadlineAt: action == .stopRecording && request.isEdit
|
||||
? Date().timeIntervalSince1970
|
||||
@@ -1909,6 +1924,8 @@ final class KeyboardFlowCoordinator {
|
||||
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
||||
aiQuestionText: text,
|
||||
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
|
||||
managedRequestPurpose: currentUtteranceRequest?.managedRequestPurpose,
|
||||
managedOOBEFeature: currentUtteranceRequest?.managedOOBEFeature,
|
||||
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
||||
startDeadlineAt: currentStartDeadlineAt
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ final class KeyboardTextInserter {
|
||||
private static let caretVerificationLimit = 80
|
||||
|
||||
private let state: KeyboardState
|
||||
private let insertText: (String) -> Void
|
||||
private let insertText: (String, KeyboardTextInsertionSource) -> Void
|
||||
private let deleteBackward: () -> Void
|
||||
private let contextBeforeInput: () -> String?
|
||||
private let fieldContextProvider: () -> FlowFieldContext?
|
||||
@@ -41,7 +41,7 @@ final class KeyboardTextInserter {
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
insertText: @escaping (String) -> Void,
|
||||
insertText: @escaping (String, KeyboardTextInsertionSource) -> Void,
|
||||
deleteBackward: @escaping () -> Void,
|
||||
contextBeforeInput: @escaping () -> String?,
|
||||
fieldContextProvider: @escaping () -> FlowFieldContext?,
|
||||
@@ -87,8 +87,16 @@ final class KeyboardTextInserter {
|
||||
insertion: trimmed
|
||||
)
|
||||
let inserted = separator + trimmed
|
||||
insertText(inserted)
|
||||
insertText(inserted, .voiceTranscription)
|
||||
KeyboardSetupBridge.markVoiceInsertion()
|
||||
if delivery.polishWarning == nil,
|
||||
let practice = state.oobePracticeSession,
|
||||
practice.expectedFeature == .voiceInput {
|
||||
_ = KeyboardSetupBridge.markOOBEPracticeCompleted(
|
||||
sessionID: practice.sessionID,
|
||||
feature: .voiceInput
|
||||
)
|
||||
}
|
||||
state.noteUserDidInputText()
|
||||
recordLastInsertion(
|
||||
inserted,
|
||||
@@ -120,7 +128,7 @@ final class KeyboardTextInserter {
|
||||
insertion: trimmed
|
||||
)
|
||||
let inserted = separator + trimmed
|
||||
insertText(inserted)
|
||||
insertText(inserted, .aiGenerated)
|
||||
state.noteUserDidInputText()
|
||||
|
||||
let mutation = HistoryMutation(
|
||||
@@ -152,7 +160,7 @@ final class KeyboardTextInserter {
|
||||
guard !text.isEmpty else { return }
|
||||
// Verbatim on purpose — paste must reproduce exactly what was copied,
|
||||
// unlike dictation which needs word-boundary hygiene.
|
||||
insertText(text)
|
||||
insertText(text, .pasteboard)
|
||||
recordUndoableInsertion(text)
|
||||
// The paste pushed any previous input away from the caret, so the
|
||||
// "editable last input" hint no longer applies.
|
||||
@@ -207,7 +215,7 @@ final class KeyboardTextInserter {
|
||||
state.redoAvailable = false
|
||||
return
|
||||
}
|
||||
insertText(text)
|
||||
insertText(text, .redo)
|
||||
recordUndoableInsertion(text)
|
||||
OSGLog.keyboardExt.info("redo length=\(text.count, privacy: .public)")
|
||||
}
|
||||
@@ -375,7 +383,7 @@ final class KeyboardTextInserter {
|
||||
let inserted = separator + result
|
||||
transaction.appliedInsertedText = inserted
|
||||
PendingTextEditTransactionStore.save(transaction)
|
||||
insertText(inserted)
|
||||
insertText(inserted, .editGenerated)
|
||||
let verificationSuffix = String(inserted.suffix(80))
|
||||
guard contextBeforeInput()?.hasSuffix(verificationSuffix) == true else {
|
||||
// Never blindly delete after a partial/opaque host insertion. The
|
||||
@@ -448,7 +456,7 @@ final class KeyboardTextInserter {
|
||||
|
||||
switch transaction.deliveryMode {
|
||||
case .replace:
|
||||
insertText(transaction.beforeText)
|
||||
insertText(transaction.beforeText, .editGenerated)
|
||||
let restore = HistoryMutation(
|
||||
action: transaction.historyMutation.action == .append ? .delete : .restore,
|
||||
entryID: transaction.historyMutation.entryID,
|
||||
|
||||
@@ -50,6 +50,7 @@ struct AIKeyboardView: View {
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
|
||||
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
|
||||
let onInsert: (String) -> Void
|
||||
|
||||
@AppStorage("keyboard.assistant.longPressCoachCount")
|
||||
@@ -108,10 +109,13 @@ struct AIKeyboardView: View {
|
||||
selectedSkillPage = 0
|
||||
resetCarousel()
|
||||
}
|
||||
.onChange(of: state.enabledClipboardSkillIDs) { _, _ in
|
||||
.onChange(of: state.enabledClipboardSkills) { _, _ in
|
||||
selectedSkillPage = 0
|
||||
resetCarousel()
|
||||
}
|
||||
.onChange(of: semanticRanking.snapshot) { _, _ in
|
||||
selectedSkillPage = 0
|
||||
}
|
||||
.onChange(of: state.skillTipText) { _, tip in
|
||||
guard let tip, !tip.isEmpty else { return }
|
||||
#if DEBUG
|
||||
@@ -292,8 +296,10 @@ struct AIKeyboardView: View {
|
||||
@ViewBuilder
|
||||
private var contextArea: some View {
|
||||
ZStack {
|
||||
if state.isOnboardingPracticeActive, activeStatus == nil {
|
||||
Text(ExtL10n.string("keyboard.onboarding.practice.mic"))
|
||||
if showsOOBEClipboardSkill, activeStatus == nil {
|
||||
clipboardSkillPager
|
||||
} else if let onboardingPracticeHint, activeStatus == nil {
|
||||
Text(onboardingPracticeHint)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
@@ -532,7 +538,7 @@ struct AIKeyboardView: View {
|
||||
if skill.id == AIClipboardSkillCatalog.translateID {
|
||||
return AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: state.translationTargetLocaleId,
|
||||
uiLanguage: AppGroupStore().uiLanguage
|
||||
uiLanguage: state.uiLanguage
|
||||
)
|
||||
}
|
||||
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
@@ -550,6 +556,9 @@ struct AIKeyboardView: View {
|
||||
return assistantIsResting
|
||||
}
|
||||
#endif
|
||||
if showsOOBEClipboardSkill {
|
||||
return assistantIsResting
|
||||
}
|
||||
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
|
||||
guard !visibleClipboardSkills.isEmpty,
|
||||
let newest = clipboardHistory.newestEntry,
|
||||
@@ -568,12 +577,54 @@ struct AIKeyboardView: View {
|
||||
return preview
|
||||
}
|
||||
#endif
|
||||
return AIClipboardSkillCatalog.visible(
|
||||
enabledIDs: state.enabledClipboardSkillIDs,
|
||||
userCatalog: AppGroupStore().agentUserSkillCatalog
|
||||
if let expectedSkillID = oobeExpectedSkillID {
|
||||
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
|
||||
}
|
||||
guard let newest = clipboardHistory.newestEntry,
|
||||
let snapshot = semanticRanking.snapshot,
|
||||
snapshot.entryID == newest.id else {
|
||||
return state.enabledClipboardSkills
|
||||
}
|
||||
return ClipboardSkillSemanticRanker.ranked(
|
||||
skills: state.enabledClipboardSkills,
|
||||
sourceText: newest.text,
|
||||
analysis: snapshot.analysis,
|
||||
uiLanguage: state.uiLanguage
|
||||
)
|
||||
}
|
||||
|
||||
private var showsOOBEClipboardSkill: Bool {
|
||||
oobeExpectedSkillID != nil
|
||||
}
|
||||
|
||||
private var oobeExpectedSkillID: String? {
|
||||
switch state.oobePracticeSession?.expectedFeature {
|
||||
case .clipboardTranslate:
|
||||
return AIClipboardSkillCatalog.translateID
|
||||
case .clipboardReply:
|
||||
return AIClipboardSkillCatalog.replyID
|
||||
case .voiceInput, .askAI, nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var onboardingPracticeHint: String? {
|
||||
switch state.oobePracticeSession?.expectedFeature {
|
||||
case .voiceInput:
|
||||
return ExtL10n.string("keyboard.onboarding.practice.mic")
|
||||
case .clipboardTranslate:
|
||||
return ExtL10n.string("keyboard.onboarding.practice.translate")
|
||||
case .clipboardReply:
|
||||
return ExtL10n.string("keyboard.onboarding.practice.reply")
|
||||
case .askAI:
|
||||
return ExtL10n.string("keyboard.onboarding.practice.askAI")
|
||||
case nil:
|
||||
return state.isOnboardingPracticeActive
|
||||
? ExtL10n.string("keyboard.onboarding.practice.mic")
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Primary actions
|
||||
|
||||
private var primaryActionRow: some View {
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"onboarding.api.localReady.title" = "Local ASR is ready";
|
||||
"onboarding.api.localReady.body" = "Recognition works on-device. Add an API key in Settings for AI polish.";
|
||||
"keyboard.onboarding.practice.mic" = "Tap the mic, then tap again when done";
|
||||
"keyboard.onboarding.practice.translate" = "Tap Translate, then insert the generated text";
|
||||
"keyboard.onboarding.practice.reply" = "Tap Reply and let AI draft a response";
|
||||
"keyboard.onboarding.practice.askAI" = "Hold the mic and ask your question";
|
||||
|
||||
/* Common navigation */
|
||||
"common.back" = "Back";
|
||||
@@ -317,8 +320,18 @@
|
||||
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
|
||||
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
|
||||
"keyboard.ai.skill.reply" = "Reply";
|
||||
"keyboard.ai.skill.replyInSourceLanguage" = "Same language";
|
||||
"keyboard.ai.skill.summarize" = "Summarize";
|
||||
"keyboard.ai.skill.extractConclusions" = "Conclusions";
|
||||
"keyboard.ai.skill.translate" = "Translate";
|
||||
"keyboard.ai.skill.acceptInvitation" = "Accept";
|
||||
"keyboard.ai.skill.declineInvitation" = "Decline";
|
||||
"keyboard.ai.skill.acceptTask" = "Acknowledge";
|
||||
"keyboard.ai.skill.clarifyRequest" = "Clarify";
|
||||
"keyboard.ai.skill.empathyReply" = "Empathize";
|
||||
"keyboard.ai.skill.askForDetails" = "Ask details";
|
||||
"keyboard.ai.skill.businessReply" = "Business";
|
||||
"keyboard.ai.skill.organizeList" = "Organize";
|
||||
"keyboard.ai.skill.extractTodos" = "Tasks";
|
||||
"keyboard.ai.skill.extractEvents" = "Events";
|
||||
"keyboard.ai.skill.saveToNotes" = "Notes";
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"onboarding.api.localReady.title" = "本地识别已就绪";
|
||||
"onboarding.api.localReady.body" = "识别在端侧完成。请在设置中填写 API Key 以开启 AI 润色。";
|
||||
"keyboard.onboarding.practice.mic" = "点按麦克风,说完后再点一次";
|
||||
"keyboard.onboarding.practice.translate" = "点击「翻译」,再插入生成的文字";
|
||||
"keyboard.onboarding.practice.reply" = "点击「回复」,让 AI 起草可发送的回复";
|
||||
"keyboard.onboarding.practice.askAI" = "长按麦克风,说出你想问的问题";
|
||||
|
||||
/* Common navigation */
|
||||
"common.back" = "返回";
|
||||
@@ -317,8 +320,18 @@
|
||||
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
|
||||
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
|
||||
"keyboard.ai.skill.reply" = "回复";
|
||||
"keyboard.ai.skill.replyInSourceLanguage" = "原语言回复";
|
||||
"keyboard.ai.skill.summarize" = "总结";
|
||||
"keyboard.ai.skill.extractConclusions" = "提取结论";
|
||||
"keyboard.ai.skill.translate" = "翻译";
|
||||
"keyboard.ai.skill.acceptInvitation" = "接受邀约";
|
||||
"keyboard.ai.skill.declineInvitation" = "委婉拒绝";
|
||||
"keyboard.ai.skill.acceptTask" = "确认任务";
|
||||
"keyboard.ai.skill.clarifyRequest" = "澄清要求";
|
||||
"keyboard.ai.skill.empathyReply" = "共情回复";
|
||||
"keyboard.ai.skill.askForDetails" = "询问细节";
|
||||
"keyboard.ai.skill.businessReply" = "商务回复";
|
||||
"keyboard.ai.skill.organizeList" = "整理清单";
|
||||
"keyboard.ai.skill.extractTodos" = "待办";
|
||||
"keyboard.ai.skill.extractEvents" = "日程";
|
||||
"keyboard.ai.skill.saveToNotes" = "备忘录";
|
||||
|
||||
@@ -89,8 +89,10 @@ final class AnalyticsExtensionPrivacyTests: XCTestCase {
|
||||
let root = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
||||
)
|
||||
XCTAssertEqual(Set(root.keys), ["installationId", "events"])
|
||||
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
|
||||
let event = try XCTUnwrap(eventObjects.first)
|
||||
XCTAssertFalse(event.keys.contains("installationId"))
|
||||
XCTAssertFalse(event.keys.contains("properties"))
|
||||
XCTAssertFalse(event.keys.contains("text"))
|
||||
XCTAssertFalse(event.keys.contains("prompt"))
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// *don't* attempt that here — the State class is what we care about
|
||||
// for correctness.
|
||||
|
||||
import Combine
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@@ -144,6 +145,50 @@ final class KeyboardStateTests: XCTestCase {
|
||||
XCTAssertEqual(state.clipboardOverlay, .none)
|
||||
}
|
||||
|
||||
func testResolvedSkillCopyPublishesWhenIDsStayStable() {
|
||||
let state = KeyboardState()
|
||||
state.enabledClipboardSkillIDs = ["official.rewrite"]
|
||||
let first = AIClipboardSkill(
|
||||
id: "official.rewrite",
|
||||
systemImage: "wand.and.stars",
|
||||
titleKey: "",
|
||||
cardTitleKey: "",
|
||||
descriptionKey: "",
|
||||
kind: .transform,
|
||||
isDefault: false,
|
||||
customName: "Rewrite",
|
||||
customSummary: "Summary",
|
||||
customPrompt: "First prompt"
|
||||
)
|
||||
let second = AIClipboardSkill(
|
||||
id: "official.rewrite",
|
||||
systemImage: "wand.and.stars",
|
||||
titleKey: "",
|
||||
cardTitleKey: "",
|
||||
descriptionKey: "",
|
||||
kind: .transform,
|
||||
isDefault: false,
|
||||
customName: "Rewrite updated",
|
||||
customSummary: "Summary",
|
||||
customPrompt: "Second prompt"
|
||||
)
|
||||
state.enabledClipboardSkills = [first]
|
||||
let published = expectation(description: "resolved skill snapshot published")
|
||||
var cancellable: AnyCancellable?
|
||||
cancellable = state.$enabledClipboardSkills
|
||||
.dropFirst()
|
||||
.sink { skills in
|
||||
XCTAssertEqual(skills.first?.customPrompt, "Second prompt")
|
||||
published.fulfill()
|
||||
}
|
||||
|
||||
state.enabledClipboardSkills = [second]
|
||||
|
||||
wait(for: [published], timeout: 1)
|
||||
XCTAssertEqual(state.enabledClipboardSkillIDs, ["official.rewrite"])
|
||||
withExtendedLifetime(cancellable) {}
|
||||
}
|
||||
|
||||
func testReturnKeyRoleActionFillAndTitles() {
|
||||
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.send.usesActionFill)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// KeyboardUsageTypingTests.swift
|
||||
// OSGKeyboardExtTests
|
||||
//
|
||||
// Chinese preedit remains in the IME; only committed candidate text is counted.
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class KeyboardUsageTypingTests: XCTestCase {
|
||||
func testPinyinCompositionDoesNotCountLatinBeforeHanCommit() {
|
||||
let engine = KeyboardUsageRimeStub()
|
||||
let typing = TypingSessionController(engine: { engine })
|
||||
|
||||
let first = typing.handleKey("n")
|
||||
let second = typing.handleKey("i")
|
||||
XCTAssertEqual(first, .none)
|
||||
XCTAssertEqual(second, .none)
|
||||
XCTAssertEqual(engine.composition.rawInput, "ni")
|
||||
|
||||
let committed = typing.handleSpace()
|
||||
XCTAssertEqual(committed, .insert("你好"))
|
||||
let counts = KeyboardUsageCharacterClassifier.classify(committed.text)
|
||||
XCTAssertEqual(counts.chinese, 2)
|
||||
XCTAssertEqual(counts.english, 0)
|
||||
XCTAssertEqual(counts.other, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class KeyboardUsageRimeStub: RimeEngineBridging {
|
||||
private(set) var composition: TypingComposition = .empty
|
||||
var isReady = true
|
||||
var schema: TypingInputSchema = .fullPinyin
|
||||
|
||||
func prepare() async throws {}
|
||||
|
||||
func teardown() {
|
||||
composition = .empty
|
||||
}
|
||||
|
||||
func setLanguage(_ language: TypingInputLanguage) {}
|
||||
|
||||
@discardableResult
|
||||
func setSchema(_ schema: TypingInputSchema) -> Bool {
|
||||
self.schema = schema
|
||||
return true
|
||||
}
|
||||
|
||||
func processCharacter(_ character: Character) -> String? {
|
||||
composition.rawInput.append(character)
|
||||
composition.preedit = composition.rawInput
|
||||
return nil
|
||||
}
|
||||
|
||||
func processBackspace() -> String? {
|
||||
if !composition.rawInput.isEmpty {
|
||||
composition.rawInput.removeLast()
|
||||
}
|
||||
composition.preedit = composition.rawInput
|
||||
return nil
|
||||
}
|
||||
|
||||
func processSpace() -> String? {
|
||||
composition = .empty
|
||||
return "你好"
|
||||
}
|
||||
|
||||
func processReturn() -> String? {
|
||||
processSpace()
|
||||
}
|
||||
|
||||
func selectCandidate(at index: Int) -> String {
|
||||
processSpace() ?? ""
|
||||
}
|
||||
|
||||
func flushPreedit() -> String {
|
||||
let raw = composition.rawInput
|
||||
composition = .empty
|
||||
return raw
|
||||
}
|
||||
|
||||
func clearComposition() {
|
||||
composition = .empty
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,16 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
XCTAssertEqual(cloudScopes, [.polish, .assistant, .agent, .asr])
|
||||
}
|
||||
|
||||
func testOOBEFeatureConflictIsNotMappedToNetworkFailure() {
|
||||
let error = ManagedGatewayHTTP.error(
|
||||
data: errorJSON("oobe_feature_already_used"),
|
||||
status: 409,
|
||||
requestId: "oobe-conflict"
|
||||
)
|
||||
|
||||
XCTAssertEqual(error, .oobeFeatureAlreadyUsed)
|
||||
}
|
||||
|
||||
func testCreateGrantSendsAccountTokenButStoresOnlyGrant() async throws {
|
||||
GatewayStub.shared.enqueue(
|
||||
201,
|
||||
@@ -128,6 +138,40 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
XCTAssertEqual(try jsonBody(request)["refreshToken"] as? String, oldRefresh)
|
||||
}
|
||||
|
||||
func testSignedOutPolicyRejectsCachedAccountGrant() async throws {
|
||||
let suiteName = "ManagedGatewayTests.signedOut.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let now = Date(timeIntervalSince1970: 3_000)
|
||||
let store = MemoryGrantStore(
|
||||
credentials(
|
||||
accessToken: "cached-account-access",
|
||||
accessExpiresAt: now.addingTimeInterval(240),
|
||||
refreshExpiresAt: now.addingTimeInterval(3_600),
|
||||
receivedAt: now
|
||||
)
|
||||
)
|
||||
let accountState = AppGroupStore(defaults: defaults)
|
||||
let coordinator = GatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
store: store,
|
||||
session: stubSession(),
|
||||
now: { now },
|
||||
accountAccessPolicy: AppGroupManagedGatewayAccountAccessPolicy(
|
||||
defaults: defaults
|
||||
)
|
||||
)
|
||||
|
||||
await XCTAssertThrowsManaged(.missingGrant) {
|
||||
_ = try await coordinator.accessToken(for: .polish)
|
||||
}
|
||||
XCTAssertTrue(GatewayStub.shared.requests().isEmpty)
|
||||
|
||||
accountState.setManagedGatewayAccountSessionAvailable(true)
|
||||
let token = try await coordinator.accessToken(for: .polish)
|
||||
XCTAssertEqual(token, "cached-account-access")
|
||||
}
|
||||
|
||||
func testManagedClientMapsAllCapabilitiesHeadersBodyAndRequestIds() async throws {
|
||||
let now = Date(timeIntervalSince1970: 3_000)
|
||||
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
||||
@@ -187,6 +231,7 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
|
||||
(.polish, .translation),
|
||||
(.polish, .editLastInput),
|
||||
(.assistant, .currentInformationQuestion),
|
||||
(.assistant, .clipboardTransform),
|
||||
(.assistant, .customSkill)
|
||||
]
|
||||
@@ -211,13 +256,39 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
|
||||
func testManagedClientSerializesOOBERequestPurpose() async throws {
|
||||
let now = Date(timeIntervalSince1970: 3_750)
|
||||
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
||||
let store = MemoryGrantStore(credentials(accessToken: "account-access", receivedAt: now))
|
||||
let oobeStore = MemoryGrantStore(
|
||||
credentials(
|
||||
accessToken: "oobe-access",
|
||||
receivedAt: now,
|
||||
scopes: [.polish, .assistant]
|
||||
)
|
||||
)
|
||||
let suite = "managed.oobe.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let practice = try XCTUnwrap(
|
||||
KeyboardSetupBridge.beginOOBEPracticeSession(
|
||||
expectedFeature: .voiceInput,
|
||||
defaults: defaults,
|
||||
now: now
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(practice.expectedFeature, .voiceInput)
|
||||
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
|
||||
let client = ManagedLLMClient(
|
||||
capability: .polish,
|
||||
taskKind: .dictationPolish,
|
||||
requestPurpose: .oobe,
|
||||
oobeFeature: .voiceInput,
|
||||
grants: makeCoordinator(store: store, now: { now }),
|
||||
oobeGrants: OOBEGatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
store: oobeStore,
|
||||
session: stubSession(),
|
||||
now: { now },
|
||||
practiceSession: { practice }
|
||||
),
|
||||
baseURL: baseURL,
|
||||
session: stubSession()
|
||||
)
|
||||
@@ -228,6 +299,107 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
let body = try jsonBody(request)
|
||||
XCTAssertEqual(body["taskKind"] as? String, "dictation_polish")
|
||||
XCTAssertEqual(body["requestPurpose"] as? String, "oobe")
|
||||
XCTAssertEqual(body["oobeFeature"] as? String, "voice_input")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer oobe-access")
|
||||
let accountCredentials = await store.value()
|
||||
XCTAssertEqual(accountCredentials?.accessToken, "account-access")
|
||||
}
|
||||
|
||||
func testOOBEGrantRejectsAgentOrASRScopes() async {
|
||||
let now = Date(timeIntervalSince1970: 3_775)
|
||||
let coordinator = OOBEGatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
store: MemoryGrantStore(),
|
||||
session: stubSession(),
|
||||
now: { now }
|
||||
)
|
||||
|
||||
await XCTAssertThrowsManaged(.invalidGrant) {
|
||||
try await coordinator.install(
|
||||
self.credentials(
|
||||
receivedAt: now,
|
||||
scopes: [.polish, .assistant, .agent]
|
||||
)
|
||||
)
|
||||
}
|
||||
await XCTAssertThrowsManaged(.invalidGrant) {
|
||||
try await coordinator.install(
|
||||
self.credentials(
|
||||
receivedAt: now,
|
||||
scopes: [.polish, .assistant, .asr]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testOOBERefreshUsesDedicatedEndpoint() async throws {
|
||||
let now = Date(timeIntervalSince1970: 3_790)
|
||||
let practice = OOBEPracticeSession(
|
||||
sessionID: UUID(),
|
||||
expectedFeature: .voiceInput,
|
||||
startedAt: now.addingTimeInterval(-10),
|
||||
expiresAt: now.addingTimeInterval(300)
|
||||
)
|
||||
let store = MemoryGrantStore(
|
||||
credentials(
|
||||
accessToken: "expired",
|
||||
accessExpiresAt: now.addingTimeInterval(-1),
|
||||
receivedAt: now.addingTimeInterval(-600),
|
||||
scopes: [.polish, .assistant]
|
||||
)
|
||||
)
|
||||
GatewayStub.shared.enqueue(
|
||||
200,
|
||||
grantJSON(
|
||||
access: "refreshed",
|
||||
refresh: "rotated",
|
||||
scopes: ["polish", "ai"]
|
||||
)
|
||||
)
|
||||
let coordinator = OOBEGatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
store: store,
|
||||
session: stubSession(),
|
||||
now: { now },
|
||||
practiceSession: { practice }
|
||||
)
|
||||
|
||||
let token = try await coordinator.accessToken(
|
||||
for: .polish,
|
||||
feature: .voiceInput
|
||||
)
|
||||
|
||||
XCTAssertEqual(token, "refreshed")
|
||||
XCTAssertEqual(
|
||||
GatewayStub.shared.requests().first?.url?.path,
|
||||
"/v1/oobe/grants/refresh"
|
||||
)
|
||||
}
|
||||
|
||||
func testOOBEClientRejectsMissingFeatureWithoutUsingAccountGrant() async {
|
||||
let now = Date(timeIntervalSince1970: 3_800)
|
||||
let store = MemoryGrantStore(credentials(accessToken: "account-access", receivedAt: now))
|
||||
let client = ManagedLLMClient(
|
||||
capability: .polish,
|
||||
requestPurpose: .oobe,
|
||||
grants: makeCoordinator(store: store, now: { now }),
|
||||
baseURL: baseURL,
|
||||
session: stubSession()
|
||||
)
|
||||
|
||||
await XCTAssertThrowsManaged(
|
||||
.server(code: "missing_oobe_feature", status: 400, requestId: "fixed")
|
||||
) {
|
||||
_ = try await ManagedLLMClient(
|
||||
capability: client.capability,
|
||||
requestPurpose: client.requestPurpose,
|
||||
grants: makeCoordinator(store: store, now: { now }),
|
||||
baseURL: self.baseURL,
|
||||
session: self.stubSession(),
|
||||
requestId: { "fixed" }
|
||||
).polish("input", systemPrompt: "context")
|
||||
}
|
||||
XCTAssertTrue(GatewayStub.shared.requests().isEmpty)
|
||||
}
|
||||
|
||||
func testManagedTaskKindWireValuesMatchServerContract() {
|
||||
@@ -238,6 +410,7 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
"translation",
|
||||
"edit_last_input",
|
||||
"ai_question",
|
||||
"current_information_question",
|
||||
"clipboard_transform",
|
||||
"custom_skill",
|
||||
"agent_planning"
|
||||
@@ -412,7 +585,13 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
store: MemoryGrantStore,
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
) -> GatewayGrantCoordinator {
|
||||
GatewayGrantCoordinator(baseURL: baseURL, store: store, session: stubSession(), now: now)
|
||||
GatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
store: store,
|
||||
session: stubSession(),
|
||||
now: now,
|
||||
accountAccessPolicy: UnrestrictedManagedGatewayAccountAccessPolicy()
|
||||
)
|
||||
}
|
||||
|
||||
private func stubSession() -> URLSession {
|
||||
@@ -426,11 +605,12 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
refreshToken: String = "refresh-token-value-12345678901234567890",
|
||||
accessExpiresAt: Date = Date(timeIntervalSince1970: 20_000),
|
||||
refreshExpiresAt: Date = Date(timeIntervalSince1970: 40_000),
|
||||
receivedAt: Date = Date(timeIntervalSince1970: 10_000)
|
||||
receivedAt: Date = Date(timeIntervalSince1970: 10_000),
|
||||
scopes: Set<ManagedGatewayCapability> = [.polish, .assistant, .agent]
|
||||
) -> ManagedGatewayGrantCredentials {
|
||||
ManagedGatewayGrantCredentials(
|
||||
grantId: "11111111-1111-1111-1111-111111111111",
|
||||
scopes: [.polish, .assistant, .agent],
|
||||
scopes: scopes,
|
||||
accessToken: accessToken,
|
||||
accessExpiresAt: accessExpiresAt,
|
||||
refreshToken: refreshToken,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// The single HTTP exit for account, auth, and integrity traffic.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
public protocol AccountHTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
@@ -36,6 +37,7 @@ public actor AccountAPIClient {
|
||||
case integrityChallenge
|
||||
case attest
|
||||
case assert
|
||||
case oobeGrant
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
@@ -66,12 +68,15 @@ public actor AccountAPIClient {
|
||||
return "/v1/integrity/attest"
|
||||
case .assert:
|
||||
return "/v1/integrity/assert"
|
||||
case .oobeGrant:
|
||||
return "/v1/oobe/grants"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RefreshOperation {
|
||||
let id: UUID
|
||||
let failedAccessToken: String
|
||||
let task: Task<AccountSession, Error>
|
||||
}
|
||||
|
||||
@@ -85,6 +90,9 @@ public actor AccountAPIClient {
|
||||
private var cachedSession: AccountSession?
|
||||
private var didLoadSession = false
|
||||
private var refreshOperation: RefreshOperation?
|
||||
private var invalidationContinuations: [
|
||||
UUID: AsyncStream<AccountSessionInvalidation>.Continuation
|
||||
] = [:]
|
||||
|
||||
public init(
|
||||
baseURL: URL = URL(string: "https://account.osglab.com")!,
|
||||
@@ -117,6 +125,18 @@ public actor AccountAPIClient {
|
||||
try await loadSessionIfNeeded()
|
||||
}
|
||||
|
||||
public func sessionInvalidations() -> AsyncStream<AccountSessionInvalidation> {
|
||||
let id = UUID()
|
||||
return AsyncStream { continuation in
|
||||
invalidationContinuations[id] = continuation
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
Task {
|
||||
await self?.removeInvalidationContinuation(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func accessTokenForAuthorizedRequest() async throws -> String {
|
||||
try await sessionForRequest().accessToken
|
||||
}
|
||||
@@ -184,7 +204,9 @@ public actor AccountAPIClient {
|
||||
)
|
||||
let retryResponse = try await send(retryRequest)
|
||||
if retryResponse.response.statusCode == 401 {
|
||||
try await clearSession()
|
||||
try await invalidateSession(
|
||||
ifAccessTokenMatches: replacement.accessToken
|
||||
)
|
||||
}
|
||||
return try validatedData(retryResponse)
|
||||
}
|
||||
@@ -273,6 +295,25 @@ public actor AccountAPIClient {
|
||||
return try decode(AppAssertionResponse.self, from: data).counter
|
||||
}
|
||||
|
||||
public func requestOOBEGrant(
|
||||
_ request: OOBEGrantRequest
|
||||
) async throws -> ManagedGatewayGrantCredentials {
|
||||
let data = try await perform(
|
||||
endpoint: .oobeGrant,
|
||||
body: try encode(request),
|
||||
requiresSession: false
|
||||
)
|
||||
do {
|
||||
let response = try Self.gatewayDecoder().decode(
|
||||
ManagedGatewayGrantTokenResponse.self,
|
||||
from: data
|
||||
)
|
||||
return response.credentials(receivedAt: now())
|
||||
} catch {
|
||||
throw AccountAPIError.decoding
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(
|
||||
endpoint: Endpoint,
|
||||
body: Data?,
|
||||
@@ -319,7 +360,9 @@ public actor AccountAPIClient {
|
||||
)
|
||||
let retryResponse = try await send(retryRequest)
|
||||
if retryResponse.response.statusCode == 401 {
|
||||
try await clearSession()
|
||||
try await invalidateSession(
|
||||
ifAccessTokenMatches: replacement.accessToken
|
||||
)
|
||||
}
|
||||
return try validatedData(retryResponse)
|
||||
}
|
||||
@@ -345,7 +388,9 @@ public actor AccountAPIClient {
|
||||
}
|
||||
let nowSeconds = Int64(now().timeIntervalSince1970)
|
||||
guard session.refreshTokenExpiresAtEpochSeconds > nowSeconds else {
|
||||
try await clearSession()
|
||||
try await invalidateSession(
|
||||
ifAccessTokenMatches: session.accessToken
|
||||
)
|
||||
throw AccountAPIError.unauthorized("The refresh token has expired.")
|
||||
}
|
||||
if session.accessTokenExpiresAtEpochSeconds <= nowSeconds + 30 {
|
||||
@@ -367,6 +412,7 @@ public actor AccountAPIClient {
|
||||
|
||||
let operation = RefreshOperation(
|
||||
id: UUID(),
|
||||
failedAccessToken: current.accessToken,
|
||||
task: Task {
|
||||
try await self.requestRefresh(using: current.refreshToken)
|
||||
}
|
||||
@@ -378,6 +424,18 @@ public actor AccountAPIClient {
|
||||
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
||||
do {
|
||||
let replacement = try await operation.task.value
|
||||
guard let current = try await loadSessionIfNeeded() else {
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
if current.accessToken != operation.failedAccessToken {
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
return current
|
||||
}
|
||||
try await replaceSession(with: replacement)
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
@@ -387,8 +445,14 @@ public actor AccountAPIClient {
|
||||
if refreshOperation?.id == operation.id {
|
||||
refreshOperation = nil
|
||||
}
|
||||
if let current = try? await loadSessionIfNeeded(),
|
||||
current.accessToken != operation.failedAccessToken {
|
||||
return current
|
||||
}
|
||||
if shouldClearSession(afterRefreshError: error) {
|
||||
try? await clearSession()
|
||||
try? await invalidateSession(
|
||||
ifAccessTokenMatches: operation.failedAccessToken
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -451,6 +515,30 @@ public actor AccountAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func invalidateSession(ifAccessTokenMatches expectedAccessToken: String) async throws {
|
||||
guard let current = try await loadSessionIfNeeded(),
|
||||
current.accessToken == expectedAccessToken else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await clearSession()
|
||||
} catch {
|
||||
publishSessionInvalidation()
|
||||
throw error
|
||||
}
|
||||
publishSessionInvalidation()
|
||||
}
|
||||
|
||||
private func publishSessionInvalidation() {
|
||||
invalidationContinuations.values.forEach {
|
||||
$0.yield(.expired)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeInvalidationContinuation(id: UUID) {
|
||||
invalidationContinuations[id] = nil
|
||||
}
|
||||
|
||||
private func makeRequest(
|
||||
endpoint: Endpoint,
|
||||
body: Data?,
|
||||
@@ -564,6 +652,29 @@ public actor AccountAPIClient {
|
||||
throw AccountAPIError.decoding
|
||||
}
|
||||
}
|
||||
|
||||
private static func gatewayDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let value = try container.decode(String.self)
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: value) {
|
||||
return date
|
||||
}
|
||||
let standard = ISO8601DateFormatter()
|
||||
standard.formatOptions = [.withInternetDateTime]
|
||||
guard let date = standard.date(from: value) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Invalid ISO-8601 date"
|
||||
)
|
||||
}
|
||||
return date
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
|
||||
@@ -166,6 +166,31 @@ public struct AppAttestAssertion: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anonymous OOBE grant request. It intentionally contains no account session,
|
||||
/// requested scopes, or mutable feature claim; the server fixes policy to
|
||||
/// `polish` + `ai` after validating App Attest.
|
||||
public struct OOBEGrantRequest: Codable, Equatable, Sendable {
|
||||
public let installationId: UUID
|
||||
public let keyId: String
|
||||
public let challengeId: UUID
|
||||
public let challenge: String
|
||||
public let assertion: String
|
||||
|
||||
public init(
|
||||
installationId: UUID,
|
||||
keyId: String,
|
||||
challengeId: UUID,
|
||||
challenge: String,
|
||||
assertion: String
|
||||
) {
|
||||
self.installationId = installationId
|
||||
self.keyId = keyId
|
||||
self.challengeId = challengeId
|
||||
self.challenge = challenge
|
||||
self.assertion = assertion
|
||||
}
|
||||
}
|
||||
|
||||
public enum AppAttestChallengePurpose: String, Codable, Sendable {
|
||||
case attestation
|
||||
case assertion
|
||||
@@ -205,6 +230,10 @@ public protocol AppAttestKeyStateStoring: Sendable {
|
||||
func clearAppAttestKeyState() async throws
|
||||
}
|
||||
|
||||
public protocol OOBEInstallationIDStoring: Sendable {
|
||||
func oobeInstallationID() async throws -> UUID
|
||||
}
|
||||
|
||||
public enum AccountAPIError: Error, Equatable, Sendable {
|
||||
case invalidRequest(String)
|
||||
case unauthorized(String)
|
||||
@@ -222,6 +251,10 @@ public enum AccountAPIError: Error, Equatable, Sendable {
|
||||
case integrityUnavailable
|
||||
}
|
||||
|
||||
public enum AccountSessionInvalidation: Sendable {
|
||||
case expired
|
||||
}
|
||||
|
||||
extension AccountAPIError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
|
||||
@@ -128,6 +128,35 @@ public enum AppAttestCanonicalPayload {
|
||||
return data
|
||||
}
|
||||
|
||||
/// Matches the server's anonymous OOBE grant assertion payload byte for
|
||||
/// byte. The final line feed is part of the signed UTF-8 value.
|
||||
public static func oobeGrant(
|
||||
challenge: String,
|
||||
installationID: UUID,
|
||||
keyID: String
|
||||
) throws -> Data {
|
||||
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
|
||||
throw AccountAPIError.invalidResponse
|
||||
}
|
||||
let canonicalChallenge = challengeBytes.base64URLEncodedString()
|
||||
let payload = """
|
||||
osg-app-attest-v1
|
||||
purpose=oobe-gateway-grant
|
||||
challenge=\(canonicalChallenge)
|
||||
key_id=\(keyID)
|
||||
installation_id=\(installationID.uuidString.lowercased())
|
||||
scopes=ai,polish
|
||||
features=ask_ai,clipboard_reply,clipboard_translate,voice_input
|
||||
grant_ttl_seconds=1800
|
||||
access_ttl_seconds=300
|
||||
|
||||
"""
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
throw AccountAPIError.invalidResponse
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func challengeHash(_ challenge: String) throws -> Data {
|
||||
guard let challengeBytes = Data(base64URLEncoded: challenge) else {
|
||||
throw AccountAPIError.invalidResponse
|
||||
@@ -187,6 +216,15 @@ public actor DeviceIntegrityCoordinator {
|
||||
try? await keyStateStore.clearAppAttestKeyState()
|
||||
}
|
||||
|
||||
public func makeOOBEGrantRequest(
|
||||
installationID: UUID
|
||||
) async throws -> OOBEGrantRequest {
|
||||
try await makeOOBEGrantRequest(
|
||||
installationID: installationID,
|
||||
allowsKeyRecovery: true
|
||||
)
|
||||
}
|
||||
|
||||
private func optionalDeviceCheckToken() async -> String? {
|
||||
try? await makeDeviceCheckToken()
|
||||
}
|
||||
@@ -250,6 +288,47 @@ public actor DeviceIntegrityCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
private func makeOOBEGrantRequest(
|
||||
installationID: UUID,
|
||||
allowsKeyRecovery: Bool
|
||||
) async throws -> OOBEGrantRequest {
|
||||
guard appAttest.isSupported else {
|
||||
throw AccountAPIError.integrityUnavailable
|
||||
}
|
||||
let keyID = try await registeredKeyId()
|
||||
let challenge = try await apiClient.issueAppAttestChallenge(
|
||||
purpose: .assertion,
|
||||
keyId: keyID
|
||||
)
|
||||
let payload = try AppAttestCanonicalPayload.oobeGrant(
|
||||
challenge: challenge.challenge,
|
||||
installationID: installationID,
|
||||
keyID: keyID
|
||||
)
|
||||
let assertion: Data
|
||||
do {
|
||||
assertion = try await appAttest.generateAssertion(
|
||||
keyID,
|
||||
clientDataHash: AppAttestCanonicalPayload.sha256(payload)
|
||||
)
|
||||
} catch where allowsKeyRecovery {
|
||||
try? await keyStateStore.clearAppAttestKeyState()
|
||||
return try await makeOOBEGrantRequest(
|
||||
installationID: installationID,
|
||||
allowsKeyRecovery: false
|
||||
)
|
||||
} catch {
|
||||
throw AccountAPIError.integrityUnavailable
|
||||
}
|
||||
return OOBEGrantRequest(
|
||||
installationId: installationID,
|
||||
keyId: keyID,
|
||||
challengeId: challenge.challengeId,
|
||||
challenge: challenge.challenge,
|
||||
assertion: assertion.base64EncodedString()
|
||||
)
|
||||
}
|
||||
|
||||
private func registeredKeyId(
|
||||
allowsKeyRecovery: Bool = true
|
||||
) async throws -> String {
|
||||
|
||||
@@ -38,10 +38,14 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateStoring {
|
||||
public actor HostPrivateAccountKeychain:
|
||||
AccountSessionVault,
|
||||
AppAttestKeyStateStoring,
|
||||
OOBEInstallationIDStoring {
|
||||
private enum Account {
|
||||
static let session = "account.session"
|
||||
static let appAttestKeyState = "integrity.app-attest-key-state"
|
||||
static let oobeInstallationID = "oobe.installation-id"
|
||||
}
|
||||
|
||||
let descriptor: HostPrivateAccountKeychainDescriptor
|
||||
@@ -78,6 +82,15 @@ public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateS
|
||||
try delete(account: Account.appAttestKeyState)
|
||||
}
|
||||
|
||||
public func oobeInstallationID() async throws -> UUID {
|
||||
if let existing = try read(UUID.self, account: Account.oobeInstallationID) {
|
||||
return existing
|
||||
}
|
||||
let created = UUID()
|
||||
try write(created, account: Account.oobeInstallationID)
|
||||
return created
|
||||
}
|
||||
|
||||
private func read<Value: Decodable>(_ type: Value.Type, account: String) throws -> Value? {
|
||||
var query = baseQuery(account: account)
|
||||
query[kSecReturnData as String] = true
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// OOBEGrantProvisioningCoordinator.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Provisions anonymous, App-Attest-bound OOBE credentials without creating or
|
||||
// mutating an AccountSession.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
public actor OOBEGrantProvisioningCoordinator {
|
||||
private let apiClient: AccountAPIClient
|
||||
private let integrity: DeviceIntegrityCoordinator
|
||||
private let installationIDs: any OOBEInstallationIDStoring
|
||||
private let grants: OOBEGatewayGrantCoordinator
|
||||
|
||||
public init(
|
||||
apiClient: AccountAPIClient,
|
||||
integrity: DeviceIntegrityCoordinator,
|
||||
installationIDs: any OOBEInstallationIDStoring,
|
||||
grants: OOBEGatewayGrantCoordinator = OOBEGatewayGrantCoordinator()
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.integrity = integrity
|
||||
self.installationIDs = installationIDs
|
||||
self.grants = grants
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func provision() async throws -> ManagedGatewayGrantCredentials {
|
||||
let installationID = try await installationIDs.oobeInstallationID()
|
||||
let request = try await integrity.makeOOBEGrantRequest(
|
||||
installationID: installationID
|
||||
)
|
||||
let credentials = try await apiClient.requestOOBEGrant(request)
|
||||
try await grants.install(credentials)
|
||||
return credentials
|
||||
}
|
||||
|
||||
public func clear() async {
|
||||
try? await grants.clearGrant()
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,9 @@ enum MacDictationPipeline {
|
||||
}
|
||||
return MacDictationResult(
|
||||
text: polished,
|
||||
prePolishText: postASR,
|
||||
polishStyleID: outcome.polishStyleID,
|
||||
polishStylePrompt: outcome.polishStylePrompt,
|
||||
polishWarning: outcome.qualityDegraded
|
||||
? MacL10n.string("flow.warning.polishDegradedQuality")
|
||||
: nil,
|
||||
@@ -276,6 +279,9 @@ enum MacDictationPipeline {
|
||||
)
|
||||
return MacDictationResult(
|
||||
text: delivery.text,
|
||||
prePolishText: postASR,
|
||||
polishStyleID: nil,
|
||||
polishStylePrompt: nil,
|
||||
polishWarning: delivery.polishWarning,
|
||||
chunkWarning: nil
|
||||
)
|
||||
|
||||
@@ -5,6 +5,11 @@ import Foundation
|
||||
|
||||
struct MacDictationResult: Sendable, Equatable {
|
||||
let text: String
|
||||
/// Corrected ASR transcript before LLM polishing; never shown in History.
|
||||
let prePolishText: String
|
||||
/// Exact style metadata returned by the successful polish request.
|
||||
let polishStyleID: String?
|
||||
let polishStylePrompt: String?
|
||||
/// Shown when DeepSeek / cloud polish failed but raw ASR was delivered.
|
||||
let polishWarning: String?
|
||||
/// Non-fatal per-chunk ASR issues from long utterance chunking.
|
||||
|
||||
@@ -423,7 +423,15 @@ final class MacDictationViewModel: ObservableObject {
|
||||
targetApplication: targetApplication
|
||||
)
|
||||
self.recordUsage(for: result.text)
|
||||
self.speechHistory.append(text: result.text)
|
||||
self.speechHistory.append(
|
||||
text: result.text,
|
||||
prePolishText: result.prePolishText,
|
||||
wasTranslation: store.isTranslationEffective,
|
||||
polishStyleID: result.polishStyleID
|
||||
?? store.activePolishStyleId,
|
||||
polishStylePrompt: result.polishStylePrompt,
|
||||
engineMode: store.engineMode
|
||||
)
|
||||
self.appendToOverview(result.text)
|
||||
self.statusMessage = self.statusAfterDelivery(
|
||||
pasted: pasted,
|
||||
|
||||
@@ -8,11 +8,13 @@ import SwiftUI
|
||||
|
||||
struct MacPolishStylesView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@ObservedObject private var history = SpeechHistoryStore.shared
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@State private var editingPack: PolishStylePack?
|
||||
@State private var viewingPack: PolishStylePack?
|
||||
@State private var errorMessage: String?
|
||||
@State private var isGeneratingLearnedStyle = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
private var store: AppGroupStore { AppGroupStore(defaults: viewModel.defaults) }
|
||||
@@ -59,6 +61,7 @@ struct MacPolishStylesView: View {
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
styleLearningCard
|
||||
styleSection(
|
||||
title: MacL10n.string("mac.styles.builtin", language: lang),
|
||||
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
|
||||
@@ -114,6 +117,103 @@ struct MacPolishStylesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var styleLearningCorpus: PolishStyleLearningCorpus {
|
||||
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
|
||||
}
|
||||
|
||||
private var styleLearningCard: some View {
|
||||
let corpus = styleLearningCorpus
|
||||
let required = PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "waveform.badge.sparkles")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(
|
||||
palette.accentMuted,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(MacL10n.string("mac.styles.learn.title", language: lang))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(MacL10n.string("mac.styles.learn.body", language: lang))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
ProgressView(
|
||||
value: Double(min(corpus.effectiveCharacterCount, required)),
|
||||
total: Double(required)
|
||||
)
|
||||
.tint(palette.accent)
|
||||
|
||||
HStack {
|
||||
Text(
|
||||
MacL10n.format(
|
||||
"mac.styles.learn.progress",
|
||||
language: lang,
|
||||
Int64(corpus.effectiveCharacterCount),
|
||||
Int64(required)
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer()
|
||||
Text(
|
||||
corpus.isReady
|
||||
? MacL10n.string("mac.styles.learn.ready", language: lang)
|
||||
: MacL10n.format(
|
||||
"mac.styles.learn.remaining",
|
||||
language: lang,
|
||||
Int64(corpus.remainingCharacterCount)
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(corpus.isReady ? palette.accent : palette.textTertiary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(
|
||||
MacL10n.string(
|
||||
reachedLimit
|
||||
? "mac.styles.learn.limit"
|
||||
: "mac.styles.learn.privacy",
|
||||
language: lang
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer()
|
||||
Button {
|
||||
generateLearnedStyle(from: corpus)
|
||||
} label: {
|
||||
if isGeneratingLearnedStyle {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(
|
||||
MacL10n.string(
|
||||
isGeneratingLearnedStyle
|
||||
? "mac.styles.learn.generating"
|
||||
: "mac.styles.learn.action",
|
||||
language: lang
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
.disabled(!corpus.isReady || isGeneratingLearnedStyle || reachedLimit)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func styleSection(title: String, packs: [PolishStylePack]) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(title)
|
||||
@@ -160,6 +260,38 @@ struct MacPolishStylesView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func generateLearnedStyle(from corpus: PolishStyleLearningCorpus) {
|
||||
guard corpus.isReady, !isGeneratingLearnedStyle else { return }
|
||||
isGeneratingLearnedStyle = true
|
||||
Task {
|
||||
defer { isGeneratingLearnedStyle = false }
|
||||
do {
|
||||
let generated = try await PolishStyleLearningService(store: store)
|
||||
.generateStyle(from: corpus, outputLanguage: lang)
|
||||
// Review is mandatory before the learned prompt enters sync or
|
||||
// becomes the active dictation personality.
|
||||
editingPack = generated
|
||||
} catch {
|
||||
errorMessage = localizedLearningError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedLearningError(_ error: Error) -> String {
|
||||
switch error as? PolishStyleLearningError {
|
||||
case .insufficientCorpus:
|
||||
return MacL10n.string("mac.styles.learn.error.insufficient", language: lang)
|
||||
case .invalidResponse:
|
||||
return MacL10n.string("mac.styles.learn.error.invalidResponse", language: lang)
|
||||
case .promptTooLong:
|
||||
return MacL10n.string("mac.styles.learn.error.promptTooLong", language: lang)
|
||||
case .requestTooLarge:
|
||||
return MacL10n.string("mac.styles.learn.error.requestTooLarge", language: lang)
|
||||
case nil:
|
||||
return MacL10n.string("mac.styles.learn.error.request", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
private func subtitle(for pack: PolishStylePack) -> String {
|
||||
if pack.kind == .user {
|
||||
return MacL10n.string("mac.styles.customDescription", language: lang)
|
||||
|
||||
@@ -16,12 +16,12 @@ public enum AppGroup {
|
||||
/// The suite alone can appear to open while the container is still `(null)`
|
||||
/// when provisioning is misconfigured — that case produces the
|
||||
/// `CFPrefsPlistSource … Container: (null)` console warning.
|
||||
public static let isAvailable: Bool = {
|
||||
public static var isAvailable: Bool {
|
||||
guard UserDefaults(suiteName: identifier) != nil else { return false }
|
||||
return FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: identifier
|
||||
) != nil
|
||||
}()
|
||||
}
|
||||
|
||||
/// On-disk container path (or `"nil"`) for diagnostics. When this reads
|
||||
/// `nil` in a process, that process cannot share App Group state — the
|
||||
|
||||
@@ -42,7 +42,8 @@ public protocol ConfigurationStore: Sendable {
|
||||
|
||||
func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind?,
|
||||
requestPurpose: ManagedGatewayRequestPurpose?
|
||||
requestPurpose: ManagedGatewayRequestPurpose?,
|
||||
oobeFeature: ManagedGatewayOOBEFeature?
|
||||
) -> LLMClient
|
||||
}
|
||||
|
||||
@@ -51,10 +52,21 @@ public extension ConfigurationStore {
|
||||
var credentialSource: CredentialSource { .byok }
|
||||
|
||||
func makeClient() -> LLMClient {
|
||||
makeClient(taskKind: nil, requestPurpose: nil)
|
||||
makeClient(taskKind: nil, requestPurpose: nil, oobeFeature: nil)
|
||||
}
|
||||
|
||||
func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
|
||||
makeClient(taskKind: taskKind, requestPurpose: nil)
|
||||
makeClient(taskKind: taskKind, requestPurpose: nil, oobeFeature: nil)
|
||||
}
|
||||
|
||||
func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind?,
|
||||
requestPurpose: ManagedGatewayRequestPurpose?
|
||||
) -> LLMClient {
|
||||
makeClient(
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,13 +126,15 @@ public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
|
||||
|
||||
public func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind?,
|
||||
requestPurpose: ManagedGatewayRequestPurpose?
|
||||
requestPurpose: ManagedGatewayRequestPurpose?,
|
||||
oobeFeature: ManagedGatewayOOBEFeature?
|
||||
) -> LLMClient {
|
||||
if credentialSource == .managed {
|
||||
if credentialSource == .managed || requestPurpose == .oobe {
|
||||
return ManagedLLMClient(
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
grants: GatewayGrantCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ public enum AnalyticsModelError: Error, Sendable {
|
||||
}
|
||||
|
||||
public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
public let installationId: UUID
|
||||
public let clientEventId: UUID
|
||||
public let eventType: AnalyticsEventType
|
||||
public let occurredAt: Date
|
||||
@@ -103,7 +102,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
public let durationBucket: AnalyticsDurationBucket?
|
||||
|
||||
public init(
|
||||
installationId: UUID,
|
||||
clientEventId: UUID,
|
||||
eventType: AnalyticsEventType,
|
||||
occurredAt: Date,
|
||||
@@ -121,7 +119,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
throw AnalyticsModelError.invalidVersion
|
||||
}
|
||||
|
||||
self.installationId = installationId
|
||||
self.clientEventId = clientEventId
|
||||
self.eventType = eventType
|
||||
self.occurredAt = occurredAt
|
||||
@@ -140,7 +137,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case installationId
|
||||
case clientEventId
|
||||
case eventType
|
||||
case occurredAt
|
||||
@@ -160,7 +156,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||
)
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let installationId = try container.decode(UUID.self, forKey: .installationId)
|
||||
let clientEventId = try container.decode(UUID.self, forKey: .clientEventId)
|
||||
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
|
||||
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
|
||||
@@ -172,7 +167,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
)
|
||||
}
|
||||
try self.init(
|
||||
installationId: installationId,
|
||||
clientEventId: clientEventId,
|
||||
eventType: eventType,
|
||||
occurredAt: occurredAt,
|
||||
@@ -204,7 +198,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
throw AnalyticsModelError.invalidDimensions(eventType)
|
||||
}
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(installationId, forKey: .installationId)
|
||||
try container.encode(clientEventId, forKey: .clientEventId)
|
||||
try container.encode(eventType, forKey: .eventType)
|
||||
try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt)
|
||||
@@ -237,13 +230,16 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
|
||||
public let installationId: UUID
|
||||
public let events: [AnalyticsEvent]
|
||||
|
||||
public init(events: [AnalyticsEvent]) {
|
||||
public init(installationId: UUID, events: [AnalyticsEvent]) {
|
||||
self.installationId = installationId
|
||||
self.events = events
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case installationId
|
||||
case events
|
||||
}
|
||||
|
||||
@@ -253,6 +249,7 @@ public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
|
||||
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||
)
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
installationId = try container.decode(UUID.self, forKey: .installationId)
|
||||
events = try container.decode([AnalyticsEvent].self, forKey: .events)
|
||||
}
|
||||
}
|
||||
@@ -335,6 +332,7 @@ private extension AnalyticsEventType {
|
||||
switch self {
|
||||
case .firstOpen, .inviteOpened:
|
||||
return [
|
||||
.none,
|
||||
DimensionSet(
|
||||
acquisitionChannel: true,
|
||||
feature: false,
|
||||
|
||||
@@ -72,6 +72,7 @@ struct AnalyticsLeasedEvent: Sendable {
|
||||
|
||||
struct AnalyticsLeasedBatch: Sendable {
|
||||
let leaseID: String
|
||||
let installationID: UUID
|
||||
let events: [AnalyticsLeasedEvent]
|
||||
let body: Data
|
||||
}
|
||||
@@ -141,7 +142,6 @@ public actor AnalyticsRepository {
|
||||
return
|
||||
}
|
||||
let event = try AnalyticsEvent(
|
||||
installationId: installationID,
|
||||
clientEventId: uuidGenerator.makeUUID(),
|
||||
eventType: .firstOpen,
|
||||
occurredAt: clock.now(),
|
||||
@@ -150,7 +150,11 @@ public actor AnalyticsRepository {
|
||||
osVersion: context.environment.osVersion,
|
||||
acquisitionChannel: firstOpenAcquisitionChannel
|
||||
)
|
||||
try insert(event, database: database)
|
||||
try insert(
|
||||
event,
|
||||
installationID: installationID,
|
||||
database: database
|
||||
)
|
||||
try setMetadata(
|
||||
"1",
|
||||
for: MetadataKey.firstOpenRecorded,
|
||||
@@ -172,6 +176,21 @@ public actor AnalyticsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the existing App Group installation UUID, creating it only while
|
||||
/// analytics is enabled. Aggregate keyboard usage reuses this identity.
|
||||
public func installationIdentifierIfEnabled() -> UUID? {
|
||||
guard let database = openDatabaseIfNeeded() else { return nil }
|
||||
do {
|
||||
return try database.immediateTransaction {
|
||||
_ = try ensureEnabled(database)
|
||||
guard try isEnabled(database) else { return nil }
|
||||
return try ensureInstallation(database)
|
||||
}
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only diagnostics for tests and support tooling. Event payload content
|
||||
/// is deliberately excluded from this public snapshot.
|
||||
public func debugSnapshot() -> AnalyticsRepositoryDebugSnapshot {
|
||||
@@ -355,7 +374,6 @@ public actor AnalyticsRepository {
|
||||
guard try isEnabled(database) else { return }
|
||||
let installationID = try ensureInstallation(database)
|
||||
let event = try AnalyticsEvent(
|
||||
installationId: installationID,
|
||||
clientEventId: uuidGenerator.makeUUID(),
|
||||
eventType: eventType,
|
||||
occurredAt: clock.now(),
|
||||
@@ -368,7 +386,11 @@ public actor AnalyticsRepository {
|
||||
failureCategory: dimensions.failureCategory,
|
||||
durationBucket: dimensions.durationBucket
|
||||
)
|
||||
try insert(event, database: database)
|
||||
try insert(
|
||||
event,
|
||||
installationID: installationID,
|
||||
database: database
|
||||
)
|
||||
try enforceStoragePolicy(database)
|
||||
}
|
||||
}
|
||||
@@ -402,7 +424,6 @@ public actor AnalyticsRepository {
|
||||
|
||||
let installationID = try ensureInstallation(database)
|
||||
let event = try AnalyticsEvent(
|
||||
installationId: installationID,
|
||||
clientEventId: uuidGenerator.makeUUID(),
|
||||
eventType: .sessionStarted,
|
||||
occurredAt: now,
|
||||
@@ -410,7 +431,11 @@ public actor AnalyticsRepository {
|
||||
appVersion: context.environment.appVersion,
|
||||
osVersion: context.environment.osVersion
|
||||
)
|
||||
try insert(event, database: database)
|
||||
try insert(
|
||||
event,
|
||||
installationID: installationID,
|
||||
database: database
|
||||
)
|
||||
try setMetadata(
|
||||
String(now.timeIntervalSince1970),
|
||||
for: key,
|
||||
@@ -441,19 +466,42 @@ public actor AnalyticsRepository {
|
||||
try releaseExpiredEventLeases(now: now, database: database)
|
||||
try deleteExpiredEvents(now: now, database: database)
|
||||
|
||||
guard let installationText = try database.query(
|
||||
"""
|
||||
SELECT installation_id
|
||||
FROM pending_events
|
||||
WHERE next_attempt_at <= ? AND lease_id IS NULL
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
bindings: [.double(now)]
|
||||
).first?.text(at: 0),
|
||||
let installationID = UUID(uuidString: installationText) else {
|
||||
try clearUploadLease(database)
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidates = try database.query(
|
||||
"""
|
||||
SELECT id, payload, attempt_count
|
||||
FROM pending_events
|
||||
WHERE next_attempt_at <= ? AND lease_id IS NULL
|
||||
WHERE installation_id = ?
|
||||
AND next_attempt_at <= ?
|
||||
AND lease_id IS NULL
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
bindings: [.double(now), .int64(Int64(upload.maximumBatchCount))]
|
||||
bindings: [
|
||||
.text(installationText),
|
||||
.double(now),
|
||||
.int64(Int64(upload.maximumBatchCount))
|
||||
]
|
||||
)
|
||||
|
||||
var selected: [AnalyticsLeasedEvent] = []
|
||||
var bodySize = Self.emptyRequestBody.count
|
||||
var bodySize = Self.emptyRequestBody(
|
||||
installationID: installationID
|
||||
).count
|
||||
for row in candidates {
|
||||
guard let rowID = row.int64(at: 0),
|
||||
let payload = row.data(at: 1),
|
||||
@@ -504,8 +552,12 @@ public actor AnalyticsRepository {
|
||||
}
|
||||
return AnalyticsLeasedBatch(
|
||||
leaseID: leaseID,
|
||||
installationID: installationID,
|
||||
events: selected,
|
||||
body: Self.requestBody(for: selected)
|
||||
body: Self.requestBody(
|
||||
for: selected,
|
||||
installationID: installationID
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
@@ -696,18 +748,20 @@ public actor AnalyticsRepository {
|
||||
|
||||
private func insert(
|
||||
_ event: AnalyticsEvent,
|
||||
installationID: UUID,
|
||||
database: SQLiteDatabase
|
||||
) throws {
|
||||
let payload = try AnalyticsCanonicalJSON.encode(event)
|
||||
try database.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO pending_events (
|
||||
client_event_id, event_type, occurred_at, surface, payload,
|
||||
payload_size, priority, lease_id, lease_expires_at,
|
||||
installation_id, client_event_id, event_type, occurred_at,
|
||||
surface, payload, payload_size, priority, lease_id, lease_expires_at,
|
||||
attempt_count, next_attempt_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
|
||||
""",
|
||||
bindings: [
|
||||
.text(installationID.uuidString.lowercased()),
|
||||
.text(event.clientEventId.uuidString.lowercased()),
|
||||
.text(event.eventType.rawValue),
|
||||
.double(event.occurredAt.timeIntervalSince1970),
|
||||
@@ -887,11 +941,12 @@ public actor AnalyticsRepository {
|
||||
try database.execute(
|
||||
"""
|
||||
INSERT INTO quarantined_events (
|
||||
client_event_id, event_type, occurred_at, surface, payload,
|
||||
payload_size, attempt_count, reason, quarantined_at
|
||||
installation_id, client_event_id, event_type, occurred_at,
|
||||
surface, payload, payload_size, attempt_count, reason,
|
||||
quarantined_at
|
||||
)
|
||||
SELECT client_event_id, event_type, occurred_at, surface, payload,
|
||||
payload_size, attempt_count, ?, ?
|
||||
SELECT installation_id, client_event_id, event_type, occurred_at,
|
||||
surface, payload, payload_size, attempt_count, ?, ?
|
||||
FROM pending_events
|
||||
WHERE id = ?\(leaseClause)
|
||||
""",
|
||||
@@ -907,10 +962,18 @@ public actor AnalyticsRepository {
|
||||
try enforceStoragePolicy(database)
|
||||
}
|
||||
|
||||
private static let emptyRequestBody = Data(#"{"events":[]}"#.utf8)
|
||||
private static func emptyRequestBody(installationID: UUID) -> Data {
|
||||
requestBody(for: [], installationID: installationID)
|
||||
}
|
||||
|
||||
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
|
||||
var body = Data(#"{"events":["#.utf8)
|
||||
private static func requestBody(
|
||||
for events: [AnalyticsLeasedEvent],
|
||||
installationID: UUID
|
||||
) -> Data {
|
||||
var body = Data(
|
||||
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
|
||||
.utf8
|
||||
)
|
||||
for index in events.indices {
|
||||
if index > 0 {
|
||||
body.append(UInt8(ascii: ","))
|
||||
@@ -967,7 +1030,7 @@ public actor AnalyticsRepository {
|
||||
|
||||
private func migrate(_ database: SQLiteDatabase) throws {
|
||||
let version = try database.scalarInt64("PRAGMA user_version") ?? 0
|
||||
guard version <= 1 else {
|
||||
guard version <= 2 else {
|
||||
throw SQLiteStoreError(
|
||||
code: SQLITE_MISMATCH,
|
||||
category: .schema,
|
||||
@@ -984,10 +1047,42 @@ public actor AnalyticsRepository {
|
||||
) WITHOUT ROWID
|
||||
"""
|
||||
)
|
||||
try createCurrentQueueSchema(database)
|
||||
try database.execute("PRAGMA user_version = 2")
|
||||
}
|
||||
} else if version == 1 {
|
||||
try database.immediateTransaction {
|
||||
// Another process may have completed the migration while this
|
||||
// connection waited for BEGIN IMMEDIATE.
|
||||
guard try database.scalarInt64("PRAGMA user_version") == 1 else {
|
||||
return
|
||||
}
|
||||
// v1 payloads contain installationId inside each event and are
|
||||
// incompatible with the server's strict v2 envelope. Drop them
|
||||
// without decoding so neither old content nor identifiers can
|
||||
// escape through logs or a partially migrated retry.
|
||||
try database.execute("DROP TABLE IF EXISTS pending_events")
|
||||
try database.execute("DROP TABLE IF EXISTS quarantined_events")
|
||||
try createCurrentQueueSchema(database)
|
||||
try database.execute(
|
||||
"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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pending_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
installation_id TEXT NOT NULL,
|
||||
client_event_id TEXT UNIQUE NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
occurred_at REAL NOT NULL,
|
||||
@@ -1006,7 +1101,9 @@ public actor AnalyticsRepository {
|
||||
try database.execute(
|
||||
"""
|
||||
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(
|
||||
@@ -1019,6 +1116,7 @@ public actor AnalyticsRepository {
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quarantined_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
installation_id TEXT NOT NULL,
|
||||
client_event_id TEXT UNIQUE NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
occurred_at REAL NOT NULL,
|
||||
@@ -1031,9 +1129,6 @@ public actor AnalyticsRepository {
|
||||
)
|
||||
"""
|
||||
)
|
||||
try database.execute("PRAGMA user_version = 1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFileProtection(to databaseURL: URL) {
|
||||
@@ -1161,7 +1256,7 @@ private struct SQLiteStoreError: Error, Sendable {
|
||||
let operation: String
|
||||
}
|
||||
|
||||
private enum SQLiteBinding {
|
||||
enum SQLiteBinding {
|
||||
case null
|
||||
case int64(Int64)
|
||||
case double(Double)
|
||||
@@ -1169,7 +1264,7 @@ private enum SQLiteBinding {
|
||||
case blob(Data)
|
||||
}
|
||||
|
||||
private struct SQLiteRow {
|
||||
struct SQLiteRow {
|
||||
private let values: [SQLiteValue]
|
||||
|
||||
init(statement: OpaquePointer) {
|
||||
@@ -1243,7 +1338,7 @@ private enum SQLiteValue {
|
||||
case blob(Data)
|
||||
}
|
||||
|
||||
private final class SQLiteDatabase {
|
||||
final class SQLiteDatabase {
|
||||
private static let transient = unsafeBitCast(
|
||||
-1,
|
||||
to: sqlite3_destructor_type.self
|
||||
|
||||
@@ -77,7 +77,7 @@ public actor AnalyticsUploadCoordinator {
|
||||
/// Performs a bounded drain. The default keeps extension execution time and
|
||||
/// memory predictable while allowing a host app to request more batches.
|
||||
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
|
||||
guard !uploadInProgress else { return }
|
||||
guard !uploadInProgress, !Task.isCancelled else { return }
|
||||
uploadInProgress = true
|
||||
defer { uploadInProgress = false }
|
||||
|
||||
@@ -95,18 +95,35 @@ public actor AnalyticsUploadCoordinator {
|
||||
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
|
||||
let batchLimit = max(1, maximumBatches)
|
||||
for _ in 0..<batchLimit {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let batch = await repository.leaseBatch(
|
||||
ownerID: ownerID,
|
||||
configuration: configuration
|
||||
) else {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
await releaseForCancellation(
|
||||
events: batch.events,
|
||||
leaseID: batch.leaseID,
|
||||
ownerID: ownerID
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var authorization = AuthorizationState()
|
||||
if let bearerProvider {
|
||||
do {
|
||||
authorization.token = try await bearerProvider.bearerToken()
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
await releaseForCancellation(
|
||||
events: batch.events,
|
||||
leaseID: batch.leaseID,
|
||||
ownerID: ownerID
|
||||
)
|
||||
return
|
||||
}
|
||||
await scheduleRetry(
|
||||
events: batch.events,
|
||||
leaseID: batch.leaseID,
|
||||
@@ -120,21 +137,28 @@ public actor AnalyticsUploadCoordinator {
|
||||
|
||||
await process(
|
||||
events: batch.events,
|
||||
installationID: batch.installationID,
|
||||
leaseID: batch.leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
}
|
||||
|
||||
private func process(
|
||||
events: [AnalyticsLeasedEvent],
|
||||
installationID: UUID,
|
||||
leaseID: String,
|
||||
ownerID: String,
|
||||
authorization: inout AuthorizationState
|
||||
) async {
|
||||
guard !events.isEmpty else { return }
|
||||
guard !Task.isCancelled else {
|
||||
await repository.releaseEvents(events, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
guard await renewLease(
|
||||
events: events,
|
||||
leaseID: leaseID,
|
||||
@@ -142,11 +166,23 @@ public actor AnalyticsUploadCoordinator {
|
||||
) else {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
await repository.releaseEvents(events, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
|
||||
let response: AnalyticsHTTPResponse
|
||||
do {
|
||||
response = try await send(events: events, token: authorization.token)
|
||||
response = try await send(
|
||||
events: events,
|
||||
installationID: installationID,
|
||||
token: authorization.token
|
||||
)
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
await repository.releaseEvents(events, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
await scheduleRetry(
|
||||
events: events,
|
||||
leaseID: leaseID,
|
||||
@@ -172,15 +208,24 @@ public actor AnalyticsUploadCoordinator {
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let refreshed = try await send(events: events, token: authorization.token)
|
||||
let refreshed = try await send(
|
||||
events: events,
|
||||
installationID: installationID,
|
||||
token: authorization.token
|
||||
)
|
||||
await processResponse(
|
||||
refreshed,
|
||||
events: events,
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
await repository.releaseEvents(events, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
await scheduleRetry(
|
||||
events: events,
|
||||
leaseID: leaseID,
|
||||
@@ -195,6 +240,7 @@ public actor AnalyticsUploadCoordinator {
|
||||
await processResponse(
|
||||
response,
|
||||
events: events,
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
@@ -204,10 +250,15 @@ public actor AnalyticsUploadCoordinator {
|
||||
private func processResponse(
|
||||
_ response: AnalyticsHTTPResponse,
|
||||
events: [AnalyticsLeasedEvent],
|
||||
installationID: UUID,
|
||||
leaseID: String,
|
||||
ownerID: String,
|
||||
authorization: inout AuthorizationState
|
||||
) async {
|
||||
guard !Task.isCancelled else {
|
||||
await repository.releaseEvents(events, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
switch response.statusCode {
|
||||
case 200:
|
||||
let decoded: AnalyticsUploadResponse
|
||||
@@ -270,12 +321,14 @@ public actor AnalyticsUploadCoordinator {
|
||||
let midpoint = events.count / 2
|
||||
await process(
|
||||
events: Array(events[..<midpoint]),
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
await process(
|
||||
events: Array(events[midpoint...]),
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
@@ -340,8 +393,18 @@ public actor AnalyticsUploadCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseForCancellation(
|
||||
events: [AnalyticsLeasedEvent],
|
||||
leaseID: String,
|
||||
ownerID: String
|
||||
) async {
|
||||
await repository.releaseEvents(events, leaseID: leaseID)
|
||||
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||
}
|
||||
|
||||
private func send(
|
||||
events: [AnalyticsLeasedEvent],
|
||||
installationID: UUID,
|
||||
token: String?
|
||||
) async throws -> AnalyticsHTTPResponse {
|
||||
var headers = [
|
||||
@@ -355,7 +418,10 @@ public actor AnalyticsUploadCoordinator {
|
||||
AnalyticsHTTPRequest(
|
||||
url: configuration.endpoint,
|
||||
headers: headers,
|
||||
body: Self.requestBody(for: events)
|
||||
body: Self.requestBody(
|
||||
for: events,
|
||||
installationID: installationID
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -445,8 +511,14 @@ public actor AnalyticsUploadCoordinator {
|
||||
events.map(\.attemptCount).max() ?? 0
|
||||
}
|
||||
|
||||
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
|
||||
var body = Data(#"{"events":["#.utf8)
|
||||
private static func requestBody(
|
||||
for events: [AnalyticsLeasedEvent],
|
||||
installationID: UUID
|
||||
) -> Data {
|
||||
var body = Data(
|
||||
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
|
||||
.utf8
|
||||
)
|
||||
for index in events.indices {
|
||||
if index > 0 {
|
||||
body.append(UInt8(ascii: ","))
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// KeyboardUsageClient.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Fire-and-forget numeric recording facade. Calls are serialized per process so
|
||||
// a UTC rollover cannot finalize a day ahead of an earlier queued insertion.
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol KeyboardUsageRecording: Sendable {
|
||||
func recordManualKeyboardCounts(
|
||||
_ counts: KeyboardUsageCharacterCounts,
|
||||
sessionID: UUID
|
||||
)
|
||||
}
|
||||
|
||||
public struct NoopKeyboardUsageRecorder: KeyboardUsageRecording {
|
||||
public init() {}
|
||||
|
||||
public func recordManualKeyboardCounts(
|
||||
_ counts: KeyboardUsageCharacterCounts,
|
||||
sessionID: UUID
|
||||
) {}
|
||||
}
|
||||
|
||||
public final class LiveKeyboardUsageRecorder:
|
||||
KeyboardUsageRecording,
|
||||
@unchecked Sendable {
|
||||
private let analyticsRepository: AnalyticsRepository
|
||||
private let repository: KeyboardUsageRepository
|
||||
private let environment: AnalyticsEnvironment
|
||||
private let clock: any AnalyticsWallClock
|
||||
private let trigger: any AnalyticsUploadTriggering
|
||||
private let lock = NSLock()
|
||||
private var tailTask: Task<Void, Never>?
|
||||
|
||||
public init(
|
||||
analyticsRepository: AnalyticsRepository,
|
||||
repository: KeyboardUsageRepository,
|
||||
environment: AnalyticsEnvironment,
|
||||
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
|
||||
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger()
|
||||
) {
|
||||
self.analyticsRepository = analyticsRepository
|
||||
self.repository = repository
|
||||
self.environment = environment
|
||||
self.clock = clock
|
||||
self.trigger = trigger
|
||||
}
|
||||
|
||||
public func recordManualKeyboardCounts(
|
||||
_ counts: KeyboardUsageCharacterCounts,
|
||||
sessionID: UUID
|
||||
) {
|
||||
guard !counts.isEmpty else { return }
|
||||
let occurredAt = clock.now()
|
||||
let analyticsRepository = analyticsRepository
|
||||
let repository = repository
|
||||
let environment = environment
|
||||
let trigger = trigger
|
||||
|
||||
lock.withLock {
|
||||
let previous = tailTask
|
||||
tailTask = Task {
|
||||
await previous?.value
|
||||
guard let installationID =
|
||||
await analyticsRepository.installationIdentifierIfEnabled() else {
|
||||
return
|
||||
}
|
||||
await repository.record(
|
||||
counts: counts,
|
||||
sessionID: sessionID,
|
||||
installationID: installationID,
|
||||
occurredAt: occurredAt,
|
||||
environment: environment
|
||||
)
|
||||
await trigger.requestUpload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPendingRecords() async {
|
||||
let task = lock.withLock { tailTask }
|
||||
await task?.value
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardUsageRuntime: Sendable {
|
||||
public let repository: KeyboardUsageRepository
|
||||
public let recorder: any KeyboardUsageRecording
|
||||
public let uploadCoordinator: KeyboardUsageUploadCoordinator
|
||||
|
||||
public init(
|
||||
environment: AnalyticsEnvironment,
|
||||
analyticsRepository: AnalyticsRepository,
|
||||
repositoryConfiguration: KeyboardUsageRepositoryConfiguration = .appGroupDefault(),
|
||||
uploadConfiguration: KeyboardUsageUploadConfiguration,
|
||||
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
|
||||
bearerProvider: (any AnalyticsBearerProviding)? = nil,
|
||||
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
|
||||
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
|
||||
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
|
||||
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
|
||||
logger: any AnalyticsLogging = NoopAnalyticsLogger()
|
||||
) {
|
||||
let repository = KeyboardUsageRepository(
|
||||
configuration: repositoryConfiguration,
|
||||
clock: wallClock,
|
||||
uuidGenerator: uuidGenerator
|
||||
)
|
||||
self.repository = repository
|
||||
recorder = LiveKeyboardUsageRecorder(
|
||||
analyticsRepository: analyticsRepository,
|
||||
repository: repository,
|
||||
environment: environment,
|
||||
clock: wallClock,
|
||||
trigger: trigger
|
||||
)
|
||||
uploadCoordinator = KeyboardUsageUploadCoordinator(
|
||||
repository: repository,
|
||||
configuration: uploadConfiguration,
|
||||
network: network,
|
||||
bearerProvider: bearerProvider,
|
||||
clock: wallClock,
|
||||
uuidGenerator: uuidGenerator,
|
||||
random: random,
|
||||
logger: logger
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// KeyboardUsageModels.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Fixed-schema keyboard usage summaries. Raw text never crosses this model
|
||||
// boundary: callers provide only grapheme counts and random session IDs.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum KeyboardTextInsertionSource: String, CaseIterable, Sendable {
|
||||
case manualKeyboard = "MANUAL_KEYBOARD"
|
||||
case voiceTranscription = "VOICE_TRANSCRIPTION"
|
||||
case aiGenerated = "AI_GENERATED"
|
||||
case pasteboard = "PASTEBOARD"
|
||||
case editGenerated = "EDIT_GENERATED"
|
||||
case redo = "REDO"
|
||||
case assistantAction = "ASSISTANT_ACTION"
|
||||
case debugDemo = "DEBUG_DEMO"
|
||||
|
||||
public var contributesToKeyboardUsage: Bool {
|
||||
self == .manualKeyboard
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardUsageCharacterCounts: Equatable, Sendable {
|
||||
public static let maximumPerCategory = 1_000_000
|
||||
|
||||
public let chinese: Int
|
||||
public let english: Int
|
||||
public let other: Int
|
||||
|
||||
public init(chinese: Int = 0, english: Int = 0, other: Int = 0) {
|
||||
self.chinese = Self.clamped(chinese)
|
||||
self.english = Self.clamped(english)
|
||||
self.other = Self.clamped(other)
|
||||
}
|
||||
|
||||
public var total: Int {
|
||||
chinese + english + other
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
total == 0
|
||||
}
|
||||
|
||||
private static func clamped(_ value: Int) -> Int {
|
||||
min(maximumPerCategory, max(0, value))
|
||||
}
|
||||
}
|
||||
|
||||
public enum KeyboardUsageCharacterClassifier {
|
||||
public static func classify(_ text: String) -> KeyboardUsageCharacterCounts {
|
||||
var chinese = 0
|
||||
var english = 0
|
||||
var other = 0
|
||||
|
||||
for character in text {
|
||||
switch classification(of: character) {
|
||||
case .chinese:
|
||||
chinese = saturatingIncrement(chinese)
|
||||
case .english:
|
||||
english = saturatingIncrement(english)
|
||||
case .other:
|
||||
other = saturatingIncrement(other)
|
||||
}
|
||||
}
|
||||
return KeyboardUsageCharacterCounts(
|
||||
chinese: chinese,
|
||||
english: english,
|
||||
other: other
|
||||
)
|
||||
}
|
||||
|
||||
private enum Classification {
|
||||
case chinese
|
||||
case english
|
||||
case other
|
||||
}
|
||||
|
||||
private static func classification(of character: Character) -> Classification {
|
||||
// ICU Script properties cover CJK extensions and future Unicode updates;
|
||||
// checking the whole grapheme keeps combining sequences at one count.
|
||||
if character.unicodeScalars.contains(where: {
|
||||
matchesScript($0, regularExpression: hanScriptExpression)
|
||||
}) {
|
||||
return .chinese
|
||||
}
|
||||
if character.unicodeScalars.contains(where: {
|
||||
isLetter($0) && matchesScript($0, regularExpression: latinScriptExpression)
|
||||
}) {
|
||||
return .english
|
||||
}
|
||||
return .other
|
||||
}
|
||||
|
||||
private static func matchesScript(
|
||||
_ scalar: Unicode.Scalar,
|
||||
regularExpression: NSRegularExpression
|
||||
) -> Bool {
|
||||
let text = String(scalar)
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
return regularExpression.firstMatch(
|
||||
in: text,
|
||||
options: [],
|
||||
range: range
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static func isLetter(_ scalar: Unicode.Scalar) -> Bool {
|
||||
switch scalar.properties.generalCategory {
|
||||
case .uppercaseLetter,
|
||||
.lowercaseLetter,
|
||||
.titlecaseLetter,
|
||||
.modifierLetter,
|
||||
.otherLetter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func saturatingIncrement(_ value: Int) -> Int {
|
||||
min(KeyboardUsageCharacterCounts.maximumPerCategory, value + 1)
|
||||
}
|
||||
|
||||
private static let hanScriptExpression = try! NSRegularExpression(
|
||||
pattern: #"^\p{sc=Han}$"#
|
||||
)
|
||||
private static let latinScriptExpression = try! NSRegularExpression(
|
||||
pattern: #"^\p{sc=Latin}$"#
|
||||
)
|
||||
}
|
||||
|
||||
public enum KeyboardUsageSessionClassification: Int, CaseIterable, Sendable {
|
||||
case chineseOnly = 0
|
||||
case englishOnly = 1
|
||||
case mixedLanguage = 2
|
||||
case otherOnly = 3
|
||||
|
||||
init(counts: KeyboardUsageCharacterCounts) {
|
||||
switch (counts.chinese > 0, counts.english > 0) {
|
||||
case (true, true):
|
||||
self = .mixedLanguage
|
||||
case (true, false):
|
||||
self = .chineseOnly
|
||||
case (false, true):
|
||||
self = .englishOnly
|
||||
case (false, false):
|
||||
self = .otherOnly
|
||||
}
|
||||
}
|
||||
|
||||
func merging(_ counts: KeyboardUsageCharacterCounts) -> Self {
|
||||
let hasChinese = self == .chineseOnly
|
||||
|| self == .mixedLanguage
|
||||
|| counts.chinese > 0
|
||||
let hasEnglish = self == .englishOnly
|
||||
|| self == .mixedLanguage
|
||||
|| counts.english > 0
|
||||
switch (hasChinese, hasEnglish) {
|
||||
case (true, true):
|
||||
return .mixedLanguage
|
||||
case (true, false):
|
||||
return .chineseOnly
|
||||
case (false, true):
|
||||
return .englishOnly
|
||||
case (false, false):
|
||||
return .otherOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum KeyboardUsageModelError: Error, Sendable {
|
||||
case invalidDate
|
||||
case invalidCount
|
||||
case invalidSessionPartition
|
||||
case invalidVersion
|
||||
case invalidResponseCounts
|
||||
case unknownField(String)
|
||||
}
|
||||
|
||||
public struct KeyboardUsageSummary: Codable, Equatable, Sendable {
|
||||
public let clientSummaryId: UUID
|
||||
public let summaryDate: String
|
||||
public let chineseCharacterCount: Int
|
||||
public let englishCharacterCount: Int
|
||||
public let otherCharacterCount: Int
|
||||
public let inputSessionCount: Int
|
||||
public let chineseOnlySessionCount: Int
|
||||
public let englishOnlySessionCount: Int
|
||||
public let mixedLanguageSessionCount: Int
|
||||
public let otherOnlySessionCount: Int
|
||||
public let appVersion: String
|
||||
public let osVersion: String
|
||||
|
||||
public init(
|
||||
clientSummaryId: UUID,
|
||||
summaryDate: String,
|
||||
chineseCharacterCount: Int,
|
||||
englishCharacterCount: Int,
|
||||
otherCharacterCount: Int,
|
||||
inputSessionCount: Int,
|
||||
chineseOnlySessionCount: Int,
|
||||
englishOnlySessionCount: Int,
|
||||
mixedLanguageSessionCount: Int,
|
||||
otherOnlySessionCount: Int,
|
||||
appVersion: String,
|
||||
osVersion: String
|
||||
) throws {
|
||||
let characterCounts = [
|
||||
chineseCharacterCount,
|
||||
englishCharacterCount,
|
||||
otherCharacterCount
|
||||
]
|
||||
let sessionCounts = [
|
||||
inputSessionCount,
|
||||
chineseOnlySessionCount,
|
||||
englishOnlySessionCount,
|
||||
mixedLanguageSessionCount,
|
||||
otherOnlySessionCount
|
||||
]
|
||||
guard KeyboardUsageUTCDate.date(from: summaryDate) != nil else {
|
||||
throw KeyboardUsageModelError.invalidDate
|
||||
}
|
||||
guard characterCounts.allSatisfy({
|
||||
(0...KeyboardUsageCharacterCounts.maximumPerCategory).contains($0)
|
||||
}),
|
||||
sessionCounts.allSatisfy({ (0...100_000).contains($0) }) else {
|
||||
throw KeyboardUsageModelError.invalidCount
|
||||
}
|
||||
guard chineseOnlySessionCount
|
||||
+ englishOnlySessionCount
|
||||
+ mixedLanguageSessionCount
|
||||
+ otherOnlySessionCount == inputSessionCount,
|
||||
characterCounts.reduce(0, +) >= inputSessionCount else {
|
||||
throw KeyboardUsageModelError.invalidSessionPartition
|
||||
}
|
||||
guard AnalyticsEnvironment.isSafeVersion(appVersion),
|
||||
AnalyticsEnvironment.isSafeVersion(osVersion) else {
|
||||
throw KeyboardUsageModelError.invalidVersion
|
||||
}
|
||||
|
||||
self.clientSummaryId = clientSummaryId
|
||||
self.summaryDate = summaryDate
|
||||
self.chineseCharacterCount = chineseCharacterCount
|
||||
self.englishCharacterCount = englishCharacterCount
|
||||
self.otherCharacterCount = otherCharacterCount
|
||||
self.inputSessionCount = inputSessionCount
|
||||
self.chineseOnlySessionCount = chineseOnlySessionCount
|
||||
self.englishOnlySessionCount = englishOnlySessionCount
|
||||
self.mixedLanguageSessionCount = mixedLanguageSessionCount
|
||||
self.otherOnlySessionCount = otherOnlySessionCount
|
||||
self.appVersion = appVersion
|
||||
self.osVersion = osVersion
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case clientSummaryId
|
||||
case summaryDate
|
||||
case chineseCharacterCount
|
||||
case englishCharacterCount
|
||||
case otherCharacterCount
|
||||
case inputSessionCount
|
||||
case chineseOnlySessionCount
|
||||
case englishOnlySessionCount
|
||||
case mixedLanguageSessionCount
|
||||
case otherOnlySessionCount
|
||||
case appVersion
|
||||
case osVersion
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
|
||||
in: decoder,
|
||||
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||
)
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
try self.init(
|
||||
clientSummaryId: container.decode(UUID.self, forKey: .clientSummaryId),
|
||||
summaryDate: container.decode(String.self, forKey: .summaryDate),
|
||||
chineseCharacterCount: container.decode(Int.self, forKey: .chineseCharacterCount),
|
||||
englishCharacterCount: container.decode(Int.self, forKey: .englishCharacterCount),
|
||||
otherCharacterCount: container.decode(Int.self, forKey: .otherCharacterCount),
|
||||
inputSessionCount: container.decode(Int.self, forKey: .inputSessionCount),
|
||||
chineseOnlySessionCount: container.decode(
|
||||
Int.self,
|
||||
forKey: .chineseOnlySessionCount
|
||||
),
|
||||
englishOnlySessionCount: container.decode(
|
||||
Int.self,
|
||||
forKey: .englishOnlySessionCount
|
||||
),
|
||||
mixedLanguageSessionCount: container.decode(
|
||||
Int.self,
|
||||
forKey: .mixedLanguageSessionCount
|
||||
),
|
||||
otherOnlySessionCount: container.decode(
|
||||
Int.self,
|
||||
forKey: .otherOnlySessionCount
|
||||
),
|
||||
appVersion: container.decode(String.self, forKey: .appVersion),
|
||||
osVersion: container.decode(String.self, forKey: .osVersion)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardUsageUploadRequest: Codable, Equatable, Sendable {
|
||||
public let installationId: UUID
|
||||
public let summaries: [KeyboardUsageSummary]
|
||||
|
||||
public init(installationId: UUID, summaries: [KeyboardUsageSummary]) {
|
||||
self.installationId = installationId
|
||||
self.summaries = summaries
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case installationId
|
||||
case summaries
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
|
||||
in: decoder,
|
||||
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||
)
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
installationId = try container.decode(UUID.self, forKey: .installationId)
|
||||
summaries = try container.decode([KeyboardUsageSummary].self, forKey: .summaries)
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardUsageUploadResponse: Codable, Equatable, Sendable {
|
||||
public let accepted: Int
|
||||
public let replayed: Int
|
||||
|
||||
public init(accepted: Int, replayed: Int) throws {
|
||||
guard accepted >= 0, replayed >= 0 else {
|
||||
throw KeyboardUsageModelError.invalidResponseCounts
|
||||
}
|
||||
self.accepted = accepted
|
||||
self.replayed = replayed
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case accepted
|
||||
case replayed
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
|
||||
in: decoder,
|
||||
allowed: Set(CodingKeys.allCases.map(\.rawValue))
|
||||
)
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
try self.init(
|
||||
accepted: container.decode(Int.self, forKey: .accepted),
|
||||
replayed: container.decode(Int.self, forKey: .replayed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum KeyboardUsageUTCDate {
|
||||
static func string(from date: Date) -> String {
|
||||
formatter.string(from: date)
|
||||
}
|
||||
|
||||
static func date(from value: String) -> Date? {
|
||||
guard value.utf8.count == 10 else { return nil }
|
||||
return formatter.date(from: value)
|
||||
}
|
||||
|
||||
static func oldestAcceptedDate(relativeTo today: String) -> String? {
|
||||
guard let todayDate = date(from: today),
|
||||
let oldest = calendar.date(byAdding: .day, value: -35, to: todayDate) else {
|
||||
return nil
|
||||
}
|
||||
return string(from: oldest)
|
||||
}
|
||||
|
||||
private static var formatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.isLenient = false
|
||||
return formatter
|
||||
}
|
||||
|
||||
private static var calendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.locale = Locale(identifier: "en_US_POSIX")
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
return calendar
|
||||
}
|
||||
}
|
||||
|
||||
private enum KeyboardUsageCodableAllowlist {
|
||||
static func rejectUnknownKeys(
|
||||
in decoder: Decoder,
|
||||
allowed: Set<String>
|
||||
) throws {
|
||||
let container = try decoder.container(keyedBy: KeyboardUsageAnyCodingKey.self)
|
||||
if let unknown = container.allKeys.first(where: {
|
||||
!allowed.contains($0.stringValue)
|
||||
}) {
|
||||
throw KeyboardUsageModelError.unknownField(unknown.stringValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeyboardUsageAnyCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
let intValue: Int?
|
||||
|
||||
init?(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
intValue = nil
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
stringValue = String(intValue)
|
||||
self.intValue = intValue
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
// KeyboardUsageUploadCoordinator.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Uploads immutable daily summaries with the existing analytics transport,
|
||||
// optional account bearer, bounded retry and cross-process leases.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct KeyboardUsageUploadConfiguration: Sendable {
|
||||
public let endpoint: URL
|
||||
public let maximumBatchCount: Int
|
||||
public let globalLeaseDuration: TimeInterval
|
||||
public let summaryLeaseDuration: TimeInterval
|
||||
public let maximumBackoff: TimeInterval
|
||||
|
||||
public init(
|
||||
endpoint: URL,
|
||||
maximumBatchCount: Int = 50,
|
||||
globalLeaseDuration: TimeInterval = 2 * 60,
|
||||
summaryLeaseDuration: TimeInterval = 5 * 60,
|
||||
maximumBackoff: TimeInterval = 6 * 60 * 60
|
||||
) {
|
||||
self.endpoint = endpoint
|
||||
self.maximumBatchCount = min(50, max(1, maximumBatchCount))
|
||||
self.globalLeaseDuration = max(60, globalLeaseDuration)
|
||||
self.summaryLeaseDuration = max(60, summaryLeaseDuration)
|
||||
self.maximumBackoff = min(6 * 60 * 60, max(60, maximumBackoff))
|
||||
}
|
||||
}
|
||||
|
||||
public actor KeyboardUsageUploadCoordinator {
|
||||
private struct AuthorizationState {
|
||||
var token: String?
|
||||
var didRefresh = false
|
||||
}
|
||||
|
||||
private let repository: KeyboardUsageRepository
|
||||
private let configuration: KeyboardUsageUploadConfiguration
|
||||
private let network: any AnalyticsNetworking
|
||||
private let bearerProvider: (any AnalyticsBearerProviding)?
|
||||
private let clock: any AnalyticsWallClock
|
||||
private let uuidGenerator: any AnalyticsUUIDGenerating
|
||||
private let random: any AnalyticsRandomGenerating
|
||||
private let logger: any AnalyticsLogging
|
||||
private var uploadInProgress = false
|
||||
|
||||
public init(
|
||||
repository: KeyboardUsageRepository,
|
||||
configuration: KeyboardUsageUploadConfiguration,
|
||||
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
|
||||
bearerProvider: (any AnalyticsBearerProviding)? = nil,
|
||||
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
|
||||
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
|
||||
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
|
||||
logger: any AnalyticsLogging = NoopAnalyticsLogger()
|
||||
) {
|
||||
self.repository = repository
|
||||
self.configuration = configuration
|
||||
self.network = network
|
||||
self.bearerProvider = bearerProvider
|
||||
self.clock = clock
|
||||
self.uuidGenerator = uuidGenerator
|
||||
self.random = random
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
public func uploadAvailableSummaries(maximumBatches: Int = 1) async {
|
||||
guard !uploadInProgress, !Task.isCancelled else { return }
|
||||
uploadInProgress = true
|
||||
defer { uploadInProgress = false }
|
||||
|
||||
guard configuration.endpoint.scheme?.lowercased() == "https" else {
|
||||
log(
|
||||
outcome: .skipped,
|
||||
count: 0,
|
||||
category: .client
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
|
||||
for _ in 0..<max(1, maximumBatches) {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let batch = await repository.leaseBatch(
|
||||
ownerID: ownerID,
|
||||
configuration: configuration
|
||||
) else {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
await releaseForCancellation(
|
||||
summaries: batch.summaries,
|
||||
leaseID: batch.leaseID,
|
||||
ownerID: ownerID
|
||||
)
|
||||
return
|
||||
}
|
||||
var authorization = AuthorizationState()
|
||||
if let bearerProvider {
|
||||
do {
|
||||
authorization.token = try await bearerProvider.bearerToken()
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
await releaseForCancellation(
|
||||
summaries: batch.summaries,
|
||||
leaseID: batch.leaseID,
|
||||
ownerID: ownerID
|
||||
)
|
||||
return
|
||||
}
|
||||
await scheduleRetry(
|
||||
batch.summaries,
|
||||
leaseID: batch.leaseID,
|
||||
response: nil,
|
||||
category: .authentication
|
||||
)
|
||||
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
await process(
|
||||
batch.summaries,
|
||||
installationID: batch.installationID,
|
||||
leaseID: batch.leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
}
|
||||
|
||||
private func process(
|
||||
_ summaries: [KeyboardUsageLeasedSummary],
|
||||
installationID: UUID,
|
||||
leaseID: String,
|
||||
ownerID: String,
|
||||
authorization: inout AuthorizationState
|
||||
) async {
|
||||
guard !summaries.isEmpty else { return }
|
||||
guard !Task.isCancelled else {
|
||||
await repository.release(summaries: summaries, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
guard await renewLease(
|
||||
summaries: summaries,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
await repository.release(summaries: summaries, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
|
||||
let response: AnalyticsHTTPResponse
|
||||
do {
|
||||
response = try await send(
|
||||
summaries: summaries,
|
||||
installationID: installationID,
|
||||
token: authorization.token
|
||||
)
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
await repository.release(summaries: summaries, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: nil,
|
||||
category: Self.networkCategory(for: error)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if response.statusCode == 401,
|
||||
!authorization.didRefresh,
|
||||
let bearerProvider {
|
||||
authorization.didRefresh = true
|
||||
let failedToken = authorization.token
|
||||
do {
|
||||
authorization.token = try await bearerProvider.refreshBearerToken(
|
||||
afterUnauthorizedAccessToken: failedToken
|
||||
)
|
||||
guard await renewLease(
|
||||
summaries: summaries,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let refreshed = try await send(
|
||||
summaries: summaries,
|
||||
installationID: installationID,
|
||||
token: authorization.token
|
||||
)
|
||||
await processResponse(
|
||||
refreshed,
|
||||
summaries: summaries,
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
await repository.release(summaries: summaries, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .authentication,
|
||||
minimumDelay: configuration.maximumBackoff
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await processResponse(
|
||||
response,
|
||||
summaries: summaries,
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
}
|
||||
|
||||
private func processResponse(
|
||||
_ response: AnalyticsHTTPResponse,
|
||||
summaries: [KeyboardUsageLeasedSummary],
|
||||
installationID: UUID,
|
||||
leaseID: String,
|
||||
ownerID: String,
|
||||
authorization: inout AuthorizationState
|
||||
) async {
|
||||
guard !Task.isCancelled else {
|
||||
await repository.release(summaries: summaries, leaseID: leaseID)
|
||||
return
|
||||
}
|
||||
switch response.statusCode {
|
||||
case 200:
|
||||
let decoded: KeyboardUsageUploadResponse
|
||||
do {
|
||||
decoded = try JSONDecoder().decode(
|
||||
KeyboardUsageUploadResponse.self,
|
||||
from: response.body
|
||||
)
|
||||
} catch {
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .decoding
|
||||
)
|
||||
return
|
||||
}
|
||||
guard decoded.accepted + decoded.replayed == summaries.count else {
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .countMismatch
|
||||
)
|
||||
return
|
||||
}
|
||||
let completed = await repository.complete(
|
||||
rowIDs: summaries.map(\.rowID),
|
||||
leaseID: leaseID
|
||||
)
|
||||
log(
|
||||
outcome: completed ? .uploaded : .retryScheduled,
|
||||
count: summaries.count,
|
||||
statusCode: response.statusCode,
|
||||
attempt: maximumAttempt(in: summaries),
|
||||
category: completed ? nil : .storage
|
||||
)
|
||||
|
||||
case 400, 409, 422:
|
||||
if summaries.count == 1 {
|
||||
await repository.quarantine(
|
||||
rowIDs: [summaries[0].rowID],
|
||||
leaseID: leaseID,
|
||||
statusCode: response.statusCode
|
||||
)
|
||||
log(
|
||||
outcome: .quarantined,
|
||||
count: 1,
|
||||
statusCode: response.statusCode,
|
||||
attempt: summaries[0].attemptCount,
|
||||
category: .client
|
||||
)
|
||||
return
|
||||
}
|
||||
let midpoint = summaries.count / 2
|
||||
await process(
|
||||
Array(summaries[..<midpoint]),
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
await process(
|
||||
Array(summaries[midpoint...]),
|
||||
installationID: installationID,
|
||||
leaseID: leaseID,
|
||||
ownerID: ownerID,
|
||||
authorization: &authorization
|
||||
)
|
||||
|
||||
case 401:
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .authentication,
|
||||
minimumDelay: configuration.maximumBackoff
|
||||
)
|
||||
|
||||
case 408:
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .timeout
|
||||
)
|
||||
|
||||
case 429:
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .rateLimited
|
||||
)
|
||||
|
||||
case 500...599:
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .server
|
||||
)
|
||||
|
||||
case 400...499:
|
||||
await repository.quarantine(
|
||||
rowIDs: summaries.map(\.rowID),
|
||||
leaseID: leaseID,
|
||||
statusCode: response.statusCode
|
||||
)
|
||||
log(
|
||||
outcome: .quarantined,
|
||||
count: summaries.count,
|
||||
statusCode: response.statusCode,
|
||||
attempt: maximumAttempt(in: summaries),
|
||||
category: .client
|
||||
)
|
||||
|
||||
default:
|
||||
await scheduleRetry(
|
||||
summaries,
|
||||
leaseID: leaseID,
|
||||
response: response,
|
||||
category: .server
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseForCancellation(
|
||||
summaries: [KeyboardUsageLeasedSummary],
|
||||
leaseID: String,
|
||||
ownerID: String
|
||||
) async {
|
||||
await repository.release(summaries: summaries, leaseID: leaseID)
|
||||
await repository.releaseGlobalLease(ownerID: ownerID)
|
||||
}
|
||||
|
||||
private func send(
|
||||
summaries: [KeyboardUsageLeasedSummary],
|
||||
installationID: UUID,
|
||||
token: String?
|
||||
) async throws -> AnalyticsHTTPResponse {
|
||||
var headers = [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
]
|
||||
if let token, !token.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
return try await network.send(
|
||||
AnalyticsHTTPRequest(
|
||||
url: configuration.endpoint,
|
||||
headers: headers,
|
||||
body: Self.requestBody(
|
||||
summaries: summaries,
|
||||
installationID: installationID
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func renewLease(
|
||||
summaries: [KeyboardUsageLeasedSummary],
|
||||
leaseID: String,
|
||||
ownerID: String
|
||||
) async -> Bool {
|
||||
let renewed = await repository.renewLease(
|
||||
ownerID: ownerID,
|
||||
leaseID: leaseID,
|
||||
configuration: configuration
|
||||
)
|
||||
guard renewed else {
|
||||
await repository.release(summaries: summaries, leaseID: leaseID)
|
||||
log(
|
||||
outcome: .skipped,
|
||||
count: summaries.count,
|
||||
attempt: maximumAttempt(in: summaries),
|
||||
category: .storage
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func scheduleRetry(
|
||||
_ summaries: [KeyboardUsageLeasedSummary],
|
||||
leaseID: String,
|
||||
response: AnalyticsHTTPResponse?,
|
||||
category: AnalyticsUploadErrorCategory,
|
||||
minimumDelay: TimeInterval = 0
|
||||
) async {
|
||||
let attempt = maximumAttempt(in: summaries) + 1
|
||||
let exponent = min(attempt - 1, 16)
|
||||
let ceiling = min(
|
||||
configuration.maximumBackoff,
|
||||
pow(2, Double(exponent))
|
||||
)
|
||||
let jitterMilliseconds = random.next(
|
||||
upperBound: UInt64(max(0, ceiling * 1_000))
|
||||
)
|
||||
let jitter = TimeInterval(jitterMilliseconds) / 1_000
|
||||
let retryAfter = response.flatMap(retryAfterDelay) ?? 0
|
||||
let delay = min(
|
||||
configuration.maximumBackoff,
|
||||
max(minimumDelay, retryAfter, jitter)
|
||||
)
|
||||
await repository.scheduleRetry(
|
||||
summaries: summaries,
|
||||
leaseID: leaseID,
|
||||
delay: delay
|
||||
)
|
||||
log(
|
||||
outcome: .retryScheduled,
|
||||
count: summaries.count,
|
||||
statusCode: response?.statusCode,
|
||||
attempt: attempt,
|
||||
category: category
|
||||
)
|
||||
}
|
||||
|
||||
private func retryAfterDelay(
|
||||
_ response: AnalyticsHTTPResponse
|
||||
) -> TimeInterval? {
|
||||
guard let value = response.header(named: "Retry-After")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return nil
|
||||
}
|
||||
if let seconds = TimeInterval(value) {
|
||||
return min(configuration.maximumBackoff, max(0, seconds))
|
||||
}
|
||||
guard let date = Self.httpDateFormatter.date(from: value) else {
|
||||
return nil
|
||||
}
|
||||
return min(
|
||||
configuration.maximumBackoff,
|
||||
max(0, date.timeIntervalSince(clock.now()))
|
||||
)
|
||||
}
|
||||
|
||||
private func maximumAttempt(
|
||||
in summaries: [KeyboardUsageLeasedSummary]
|
||||
) -> Int {
|
||||
summaries.map(\.attemptCount).max() ?? 0
|
||||
}
|
||||
|
||||
private func log(
|
||||
outcome: AnalyticsUploadLogEntry.Outcome,
|
||||
count: Int,
|
||||
statusCode: Int? = nil,
|
||||
attempt: Int = 0,
|
||||
category: AnalyticsUploadErrorCategory?
|
||||
) {
|
||||
logger.log(
|
||||
AnalyticsUploadLogEntry(
|
||||
outcome: outcome,
|
||||
eventCount: count,
|
||||
statusCode: statusCode,
|
||||
attempt: attempt,
|
||||
errorCategory: category
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func requestBody(
|
||||
summaries: [KeyboardUsageLeasedSummary],
|
||||
installationID: UUID
|
||||
) -> Data {
|
||||
var body = Data(
|
||||
#"{"installationId":"\#(installationID.uuidString.lowercased())","summaries":["#
|
||||
.utf8
|
||||
)
|
||||
for index in summaries.indices {
|
||||
if index > 0 {
|
||||
body.append(UInt8(ascii: ","))
|
||||
}
|
||||
body.append(summaries[index].payload)
|
||||
}
|
||||
body.append(Data("]}".utf8))
|
||||
return body
|
||||
}
|
||||
|
||||
private static func networkCategory(
|
||||
for error: Error
|
||||
) -> AnalyticsUploadErrorCategory {
|
||||
guard let urlError = error as? URLError else { return .network }
|
||||
switch urlError.code {
|
||||
case .timedOut:
|
||||
return .timeout
|
||||
case .userAuthenticationRequired,
|
||||
.userCancelledAuthentication:
|
||||
return .authentication
|
||||
default:
|
||||
return .network
|
||||
}
|
||||
}
|
||||
|
||||
private static var httpDateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,25 @@ public actor GatewayGrantCoordinator {
|
||||
private let store: any GatewayGrantCredentialStore
|
||||
private let session: URLSession
|
||||
private let now: @Sendable () -> Date
|
||||
private let refreshPath: String
|
||||
private let accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing
|
||||
private var refreshTask: Task<ManagedGatewayGrantCredentials, Error>?
|
||||
|
||||
public init(
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
store: any GatewayGrantCredentialStore = GatewayGrantKeychainStore(),
|
||||
session: URLSession = .shared,
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
refreshPath: String = "v1/gateway/grants/refresh",
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
accountAccessPolicy: any ManagedGatewayAccountAccessAuthorizing =
|
||||
AppGroupManagedGatewayAccountAccessPolicy()
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.store = store
|
||||
self.session = session
|
||||
self.refreshPath = refreshPath
|
||||
self.now = now
|
||||
self.accountAccessPolicy = accountAccessPolicy
|
||||
}
|
||||
|
||||
/// Host-only integration point. The account access token authorizes grant
|
||||
@@ -73,6 +80,12 @@ public actor GatewayGrantCoordinator {
|
||||
for scope: ManagedGatewayCapability,
|
||||
forceRefresh: Bool = false
|
||||
) async throws -> String {
|
||||
// A valid cached grant is not proof of a current account session.
|
||||
// Check this before loading or refreshing credentials so signed-out
|
||||
// callers cannot consume credits through stale Keychain state.
|
||||
guard accountAccessPolicy.allowsAccountManagedAccess() else {
|
||||
throw ManagedGatewayError.missingGrant
|
||||
}
|
||||
guard let credentials = try await store.load() else {
|
||||
throw ManagedGatewayError.missingGrant
|
||||
}
|
||||
@@ -123,7 +136,7 @@ public actor GatewayGrantCoordinator {
|
||||
let refreshToken: String
|
||||
}
|
||||
|
||||
var request = URLRequest(url: endpoint("v1/gateway/grants/refresh"))
|
||||
var request = URLRequest(url: endpoint(refreshPath))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(
|
||||
@@ -230,6 +243,8 @@ enum ManagedGatewayHTTP {
|
||||
switch code.lowercased() {
|
||||
case "insufficient_credits", "insufficient_balance", "credit_balance_insufficient":
|
||||
return .insufficientCredits
|
||||
case "oobe_feature_already_used":
|
||||
return .oobeFeatureAlreadyUsed
|
||||
case "unauthorized", "invalid_gateway_refresh", "gateway_grant_denied", "invalid_grant":
|
||||
return .invalidGrant
|
||||
default:
|
||||
|
||||
@@ -14,18 +14,25 @@ public protocol GatewayGrantCredentialStore: Sendable {
|
||||
}
|
||||
|
||||
public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked Sendable {
|
||||
public enum Slot: String, Sendable {
|
||||
case account = "scope-limited.active"
|
||||
case oobe = "scope-limited.oobe"
|
||||
}
|
||||
|
||||
public enum StoreError: Error, Equatable, Sendable {
|
||||
case unexpectedStatus(OSStatus)
|
||||
case invalidStoredValue
|
||||
}
|
||||
|
||||
private static let service = "com.osgkeyboard.gateway-grant"
|
||||
private static let account = "scope-limited.active"
|
||||
private let slot: Slot
|
||||
|
||||
public init() {}
|
||||
public init(slot: Slot = .account) {
|
||||
self.slot = slot
|
||||
}
|
||||
|
||||
public func load() async throws -> ManagedGatewayGrantCredentials? {
|
||||
var query = Self.baseQuery
|
||||
var query = baseQuery
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
@@ -51,14 +58,14 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
||||
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
||||
let data = try Self.encoder.encode(credentials)
|
||||
let updateStatus = SecItemUpdate(
|
||||
Self.baseQuery as CFDictionary,
|
||||
baseQuery as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary
|
||||
)
|
||||
switch updateStatus {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
var query = Self.baseQuery
|
||||
var query = baseQuery
|
||||
query[kSecValueData as String] = data
|
||||
// The extension can refresh after reboot without making account
|
||||
// credentials readable; this item contains only the limited grant.
|
||||
@@ -73,17 +80,17 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
||||
}
|
||||
|
||||
public func delete() async throws {
|
||||
let status = SecItemDelete(Self.baseQuery as CFDictionary)
|
||||
let status = SecItemDelete(baseQuery as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw StoreError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
private static var baseQuery: [String: Any] {
|
||||
private var baseQuery: [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrService as String: Self.service,
|
||||
kSecAttrAccount as String: slot.rawValue,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse!
|
||||
]
|
||||
#if os(macOS)
|
||||
@@ -104,3 +111,23 @@ public struct GatewayGrantKeychainStore: GatewayGrantCredentialStore, @unchecked
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
|
||||
/// Dedicated extension-readable slot for anonymous onboarding grants. It can
|
||||
/// never overwrite or load the signed-in account's normal managed grant.
|
||||
public struct OOBEGatewayGrantKeychainStore: GatewayGrantCredentialStore, Sendable {
|
||||
private let storage = GatewayGrantKeychainStore(slot: .oobe)
|
||||
|
||||
public init() {}
|
||||
|
||||
public func load() async throws -> ManagedGatewayGrantCredentials? {
|
||||
try await storage.load()
|
||||
}
|
||||
|
||||
public func save(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
||||
try await storage.save(credentials)
|
||||
}
|
||||
|
||||
public func delete() async throws {
|
||||
try await storage.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// ManagedGatewayAccountAccessPolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Device-local account-session gate for account-funded managed requests.
|
||||
// The marker is intentionally non-secret and is never part of iCloud settings.
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ManagedGatewayAccountAccessAuthorizing: Sendable {
|
||||
func allowsAccountManagedAccess() -> Bool
|
||||
}
|
||||
|
||||
/// Production policy shared by the host and keyboard extension. A cached grant
|
||||
/// alone is insufficient: the host must also have confirmed an account session.
|
||||
public struct AppGroupManagedGatewayAccountAccessPolicy:
|
||||
ManagedGatewayAccountAccessAuthorizing,
|
||||
@unchecked Sendable {
|
||||
private let defaults: UserDefaults?
|
||||
|
||||
public init(defaults: UserDefaults? = AppGroup.defaultsIfAvailable) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func allowsAccountManagedAccess() -> Bool {
|
||||
defaults?.bool(
|
||||
forKey: AppGroupConfiguration.Keys.managedGatewayAccountSessionAvailable
|
||||
) == true
|
||||
}
|
||||
}
|
||||
|
||||
/// Anonymous OOBE grants have their own bounded practice-session policy and do
|
||||
/// not represent account-funded access. Tests may also inject this explicitly.
|
||||
public struct UnrestrictedManagedGatewayAccountAccessPolicy:
|
||||
ManagedGatewayAccountAccessAuthorizing {
|
||||
public init() {}
|
||||
|
||||
public func allowsAccountManagedAccess() -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public enum ManagedGatewayTaskKind: String, Codable, CaseIterable, Sendable {
|
||||
case translation
|
||||
case editLastInput = "edit_last_input"
|
||||
case aiQuestion = "ai_question"
|
||||
case currentInformationQuestion = "current_information_question"
|
||||
case clipboardTransform = "clipboard_transform"
|
||||
case customSkill = "custom_skill"
|
||||
case agentPlanning = "agent_planning"
|
||||
@@ -30,6 +31,25 @@ public enum ManagedGatewayRequestPurpose: String, Codable, Sendable {
|
||||
case oobe
|
||||
}
|
||||
|
||||
/// Server-audited onboarding capability. This value is carried independently
|
||||
/// from `taskKind` so billing and abuse policy never infer OOBE eligibility
|
||||
/// from a generic clipboard or AI operation.
|
||||
public enum ManagedGatewayOOBEFeature: String, Codable, CaseIterable, Sendable {
|
||||
case voiceInput = "voice_input"
|
||||
case clipboardTranslate = "clipboard_translate"
|
||||
case clipboardReply = "clipboard_reply"
|
||||
case askAI = "ask_ai"
|
||||
|
||||
public var requiredCapability: ManagedGatewayCapability {
|
||||
switch self {
|
||||
case .voiceInput:
|
||||
return .polish
|
||||
case .clipboardTranslate, .clipboardReply, .askAI:
|
||||
return .assistant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ManagedGatewayGrantCredentials: Codable, Equatable, Sendable {
|
||||
public static let maximumAccessLifetime: TimeInterval = 5 * 60
|
||||
|
||||
@@ -85,6 +105,7 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
||||
case scopeNotGranted(ManagedGatewayCapability)
|
||||
case invalidGrant
|
||||
case insufficientCredits
|
||||
case oobeFeatureAlreadyUsed
|
||||
case timeout
|
||||
case server(code: String, status: Int, requestId: String?)
|
||||
|
||||
@@ -102,6 +123,8 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
||||
return SharedL10n.string("managed.error.grantRejected")
|
||||
case .insufficientCredits:
|
||||
return SharedL10n.string("managed.error.insufficientCredits")
|
||||
case .oobeFeatureAlreadyUsed:
|
||||
return SharedL10n.string("managed.error.oobeFeatureAlreadyUsed")
|
||||
case .timeout:
|
||||
return SharedL10n.string("managed.error.timeout")
|
||||
case .server(let code, let status, _):
|
||||
@@ -115,15 +138,15 @@ public enum ManagedGatewayError: Error, LocalizedError, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ManagedGatewayGrantTokenResponse: Decodable {
|
||||
let grantId: String
|
||||
let scopes: Set<ManagedGatewayCapability>
|
||||
let accessToken: String
|
||||
let accessExpiresAt: Date
|
||||
let refreshToken: String
|
||||
let refreshExpiresAt: Date
|
||||
public struct ManagedGatewayGrantTokenResponse: Decodable, Sendable {
|
||||
public let grantId: String
|
||||
public let scopes: Set<ManagedGatewayCapability>
|
||||
public let accessToken: String
|
||||
public let accessExpiresAt: Date
|
||||
public let refreshToken: String
|
||||
public let refreshExpiresAt: Date
|
||||
|
||||
func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
|
||||
public func credentials(receivedAt: Date) -> ManagedGatewayGrantCredentials {
|
||||
ManagedGatewayGrantCredentials(
|
||||
grantId: grantId,
|
||||
scopes: scopes,
|
||||
@@ -150,4 +173,5 @@ struct ManagedGatewayTextRequest: Encodable, Sendable {
|
||||
let stream: Bool
|
||||
let taskKind: ManagedGatewayTaskKind
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -51,10 +51,12 @@ public struct ManagedLLMClient: LLMClient {
|
||||
public let capability: Capability
|
||||
public let taskKind: ManagedGatewayTaskKind
|
||||
public let requestPurpose: ManagedGatewayRequestPurpose?
|
||||
public let oobeFeature: ManagedGatewayOOBEFeature?
|
||||
public let requestTimeout: TimeInterval
|
||||
|
||||
private let baseURL: URL
|
||||
private let grants: GatewayGrantCoordinator
|
||||
private let oobeGrants: OOBEGatewayGrantCoordinator
|
||||
private let session: URLSession
|
||||
private let requestId: @Sendable () -> String
|
||||
|
||||
@@ -62,7 +64,9 @@ public struct ManagedLLMClient: LLMClient {
|
||||
capability: Capability,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
oobeGrants: OOBEGatewayGrantCoordinator? = nil,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared,
|
||||
requestTimeout: TimeInterval = 15,
|
||||
@@ -71,7 +75,12 @@ public struct ManagedLLMClient: LLMClient {
|
||||
self.capability = capability
|
||||
self.taskKind = taskKind ?? capability.defaultTaskKind
|
||||
self.requestPurpose = requestPurpose
|
||||
self.oobeFeature = oobeFeature
|
||||
self.grants = grants
|
||||
self.oobeGrants = oobeGrants ?? OOBEGatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
session: session
|
||||
)
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.requestTimeout = requestTimeout
|
||||
@@ -189,7 +198,7 @@ public struct ManagedLLMClient: LLMClient {
|
||||
do {
|
||||
return try await bufferedAttempt(attempt.forcingRefresh())
|
||||
} catch ManagedGatewayError.invalidGrant {
|
||||
try? await grants.clearGrant()
|
||||
try? await clearSelectedGrant()
|
||||
throw ManagedGatewayError.invalidGrant
|
||||
}
|
||||
}
|
||||
@@ -280,10 +289,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,
|
||||
forceRefresh: attempt.forceRefresh
|
||||
)
|
||||
}
|
||||
let body = ManagedGatewayTextRequest(
|
||||
input: trimmedInput,
|
||||
context: boundedContext,
|
||||
@@ -291,7 +324,8 @@ public struct ManagedLLMClient: LLMClient {
|
||||
temperature: min(max(attempt.options.temperature ?? 0.2, 0), 1),
|
||||
stream: stream,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature
|
||||
)
|
||||
|
||||
var request = URLRequest(
|
||||
@@ -307,6 +341,14 @@ public struct ManagedLLMClient: LLMClient {
|
||||
return request
|
||||
}
|
||||
|
||||
private func clearSelectedGrant() async throws {
|
||||
if requestPurpose == .oobe {
|
||||
try await oobeGrants.clearGrant()
|
||||
} else {
|
||||
try await grants.clearGrant()
|
||||
}
|
||||
}
|
||||
|
||||
static func payload(
|
||||
from messages: [LLMRequest.Message]
|
||||
) -> (input: String, context: String?) {
|
||||
@@ -413,6 +455,9 @@ public struct ManagedLLMClient: LLMClient {
|
||||
if ["insufficient_credits", "insufficient_balance"].contains(code) {
|
||||
return .insufficientCredits
|
||||
}
|
||||
if code == "oobe_feature_already_used" {
|
||||
return .oobeFeatureAlreadyUsed
|
||||
}
|
||||
if ["unauthorized", "gateway_grant_denied", "invalid_grant"].contains(code) {
|
||||
return .invalidGrant
|
||||
}
|
||||
@@ -424,6 +469,7 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
public static func polish(
|
||||
taskKind: ManagedGatewayTaskKind = .dictationPolish,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared
|
||||
@@ -432,6 +478,7 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
grants: grants,
|
||||
baseURL: baseURL,
|
||||
session: session
|
||||
@@ -440,6 +487,8 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
|
||||
public static func ai(
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
grants: GatewayGrantCoordinator,
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
session: URLSession = .shared
|
||||
@@ -447,6 +496,8 @@ public enum ManagedGatewayLLMClientFactory {
|
||||
ManagedLLMClient(
|
||||
capability: .assistant,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
grants: grants,
|
||||
baseURL: baseURL,
|
||||
session: session
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// OOBEGatewayGrantCoordinator.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Routes anonymous onboarding requests through a credential slot that is
|
||||
// isolated from signed-in account grants.
|
||||
|
||||
import Foundation
|
||||
|
||||
public actor OOBEGatewayGrantCoordinator {
|
||||
public static let allowedScopes: Set<ManagedGatewayCapability> = [
|
||||
.polish,
|
||||
.assistant
|
||||
]
|
||||
|
||||
private let grants: GatewayGrantCoordinator
|
||||
private let store: any GatewayGrantCredentialStore
|
||||
private let now: @Sendable () -> Date
|
||||
private let practiceSession: @Sendable () -> OOBEPracticeSession?
|
||||
|
||||
public init(
|
||||
baseURL: URL = GatewayGrantCoordinator.defaultBaseURL,
|
||||
store: any GatewayGrantCredentialStore = OOBEGatewayGrantKeychainStore(),
|
||||
session: URLSession = .shared,
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
practiceSession: @escaping @Sendable () -> OOBEPracticeSession? = {
|
||||
KeyboardSetupBridge.activeOOBEPracticeSession
|
||||
}
|
||||
) {
|
||||
self.store = store
|
||||
self.now = now
|
||||
self.practiceSession = practiceSession
|
||||
self.grants = GatewayGrantCoordinator(
|
||||
baseURL: baseURL,
|
||||
store: store,
|
||||
session: session,
|
||||
refreshPath: "v1/oobe/grants/refresh",
|
||||
now: now,
|
||||
accountAccessPolicy: UnrestrictedManagedGatewayAccountAccessPolicy()
|
||||
)
|
||||
}
|
||||
|
||||
/// Host-only provisioning handoff after App Attest succeeds.
|
||||
public func install(_ credentials: ManagedGatewayGrantCredentials) async throws {
|
||||
guard credentials.scopes == Self.allowedScopes,
|
||||
credentials.hasUsableRefreshToken(at: now()),
|
||||
credentials.refreshExpiresAt
|
||||
<= credentials.receivedAt.addingTimeInterval(30 * 60 + 1) else {
|
||||
throw ManagedGatewayError.invalidGrant
|
||||
}
|
||||
try await store.save(credentials)
|
||||
}
|
||||
|
||||
public func accessToken(
|
||||
for capability: ManagedGatewayCapability,
|
||||
feature: ManagedGatewayOOBEFeature,
|
||||
forceRefresh: Bool = false
|
||||
) async throws -> String {
|
||||
guard Self.allowedScopes.contains(capability),
|
||||
feature.requiredCapability == capability else {
|
||||
throw ManagedGatewayError.scopeNotGranted(capability)
|
||||
}
|
||||
guard let practice = practiceSession(),
|
||||
practice.isActive(at: now()),
|
||||
practice.expectedFeature == feature else {
|
||||
try? await clearGrant()
|
||||
throw ManagedGatewayError.missingGrant
|
||||
}
|
||||
return try await grants.accessToken(
|
||||
for: capability,
|
||||
forceRefresh: forceRefresh
|
||||
)
|
||||
}
|
||||
|
||||
public func clearGrant() async throws {
|
||||
try await grants.clearGrant()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
// AIAgentSkillLayout.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
|
||||
// the user confirmed a companion Shortcut for. Missing storage hydrates
|
||||
// to the three default transform skills; an explicit empty list is kept
|
||||
// Enabled clipboard skills (ordered) plus which export skills the user
|
||||
// confirmed a companion Shortcut for. Missing storage hydrates to every
|
||||
// built-in default skill; an explicit empty list is kept
|
||||
// so turning every skill off is distinct from a fresh install.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
public static let maximumEnabled = 8
|
||||
public static let defaultEnabledIDs = [
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.translateID
|
||||
]
|
||||
public static let defaultEnabledIDs = AIClipboardSkillCatalog.catalog
|
||||
.filter(\.isDefault)
|
||||
.map(\.id)
|
||||
|
||||
/// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
|
||||
/// Keyboard chip order. Unknown and duplicate IDs are dropped on sanitize.
|
||||
public var enabledIDs: [String]
|
||||
/// Export skills whose companion Shortcut the user marked as added.
|
||||
public var confirmedShortcutIDs: [String]
|
||||
@@ -31,10 +28,6 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
self.confirmedShortcutIDs = confirmedShortcutIDs
|
||||
}
|
||||
|
||||
public var isFull: Bool {
|
||||
enabledIDs.count >= Self.maximumEnabled
|
||||
}
|
||||
|
||||
public func isEnabled(_ id: String) -> Bool {
|
||||
enabledIDs.contains(id)
|
||||
}
|
||||
@@ -43,20 +36,16 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
confirmedShortcutIDs.contains(id)
|
||||
}
|
||||
|
||||
/// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
|
||||
/// Drops unknown IDs and duplicates. Shortcut setup is tracked separately
|
||||
/// so bundled export skills can remain visible as installed defaults.
|
||||
public func sanitized(
|
||||
catalog: [AIClipboardSkill] = AIClipboardSkillCatalog.catalog
|
||||
) -> AIAgentSkillLayout {
|
||||
let known = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
|
||||
var seenEnabled = Set<String>()
|
||||
let enabled = enabledIDs.filter { id in
|
||||
guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
|
||||
if skill.requiresShortcut {
|
||||
return confirmedShortcutIDs.contains(id)
|
||||
known[id] != nil && seenEnabled.insert(id).inserted
|
||||
}
|
||||
return true
|
||||
}
|
||||
.prefix(Self.maximumEnabled)
|
||||
|
||||
var seenConfirmed = Set<String>()
|
||||
let confirmed = confirmedShortcutIDs.filter { id in
|
||||
@@ -65,7 +54,7 @@ public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
return AIAgentSkillLayout(
|
||||
enabledIDs: Array(enabled),
|
||||
enabledIDs: enabled,
|
||||
confirmedShortcutIDs: confirmed
|
||||
)
|
||||
}
|
||||
@@ -75,6 +64,5 @@ public enum AIAgentSkillEnableResult: Equatable, Sendable {
|
||||
case enabled
|
||||
case alreadyEnabled
|
||||
case needsShortcut
|
||||
case full
|
||||
case unknown
|
||||
}
|
||||
|
||||
@@ -167,13 +167,13 @@ public struct AIHintManifest: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public enum AIHintFeedEndpoints {
|
||||
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
|
||||
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
|
||||
public static let baseURL = URL(string: "https://account.osglab.com/v1/content/hints")!
|
||||
public static let manifestURL = baseURL.appendingPathComponent("manifest")
|
||||
/// Packs the app fetches and the keyboard can resolve.
|
||||
public static let supportedLocales = ["zh", "en"]
|
||||
|
||||
public static func packURL(locale: String) -> URL {
|
||||
baseURL.appendingPathComponent("hints-\(locale).json")
|
||||
baseURL.appendingPathComponent(locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,9 @@ public enum AIHintAppGroupKeys {
|
||||
public static let readyPackPrefix = "hints.ready."
|
||||
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
|
||||
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
|
||||
public static let manifest = "hints.meta.manifest.v1"
|
||||
public static let manifestETag = "hints.meta.manifestETag.v1"
|
||||
public static let packETagPrefix = "hints.meta.packETag."
|
||||
|
||||
public static func readyPackKey(locale: String) -> String {
|
||||
readyPackPrefix + locale
|
||||
@@ -203,4 +206,8 @@ public enum AIHintAppGroupKeys {
|
||||
public static func lastSuccessKey(locale: String) -> String {
|
||||
lastSuccessPrefix + locale
|
||||
}
|
||||
|
||||
public static func packETagKey(locale: String) -> String {
|
||||
packETagPrefix + locale
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let engineMode = "config.engineMode"
|
||||
/// Credential ownership is independent from local/cloud ASR selection.
|
||||
public static let credentialSource = "config.credentialSource"
|
||||
/// Device-local, non-secret gate for account-funded managed requests.
|
||||
/// This key is deliberately excluded from iCloud settings payloads.
|
||||
public static let managedGatewayAccountSessionAvailable =
|
||||
"account.managedGateway.sessionAvailable.v1"
|
||||
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||
public static let onboardingPage = "config.onboardingPage"
|
||||
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
||||
@@ -73,8 +77,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
|
||||
/// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
|
||||
public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
|
||||
/// One-shot: append semantic built-ins introduced with the unlimited layout.
|
||||
public static let agentSkillDefaultsMigrationVersion =
|
||||
"config.aiAgentSkills.defaultsMigrationVersion"
|
||||
/// User-created clipboard skills (no cloud sync; App Group only).
|
||||
public static let agentUserSkillCatalog = "config.aiAgentSkills.userCatalog.v1"
|
||||
/// Last-known-good official catalog plus revision, freshness, and ETag metadata.
|
||||
public static let officialSkillCatalog = "content.officialSkills.snapshot.v1"
|
||||
}
|
||||
|
||||
// MARK: - Stored fields
|
||||
@@ -195,13 +204,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
|
||||
public func makeClient(
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil
|
||||
requestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil
|
||||
) -> LLMClient {
|
||||
if credentialSource == .managed {
|
||||
if credentialSource == .managed || requestPurpose == .oobe {
|
||||
return ManagedLLMClient(
|
||||
capability: .polish,
|
||||
taskKind: taskKind,
|
||||
requestPurpose: requestPurpose,
|
||||
oobeFeature: oobeFeature,
|
||||
grants: GatewayGrantCoordinator()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
case submitAIQuestion
|
||||
}
|
||||
|
||||
/// Wire version that includes managed-gateway request purpose.
|
||||
public static let currentProtocolVersion = 7
|
||||
/// Wire version that includes a strongly typed OOBE feature.
|
||||
public static let currentProtocolVersion = 8
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
@@ -45,6 +45,8 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||
/// Optional server-audited purpose for managed gateway billing policy.
|
||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
|
||||
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||
public let aiThinkingEnabled: Bool?
|
||||
/// Absolute wall-clock deadlines survive extension reconstruction.
|
||||
@@ -68,6 +70,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
aiQuestionText: String? = nil,
|
||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
aiThinkingEnabled: Bool? = nil,
|
||||
startDeadlineAt: TimeInterval? = nil,
|
||||
processingDeadlineAt: TimeInterval? = nil
|
||||
@@ -88,6 +91,7 @@ public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
self.aiQuestionText = aiQuestionText
|
||||
self.aiTaskKind = aiTaskKind
|
||||
self.managedRequestPurpose = managedRequestPurpose
|
||||
self.managedOOBEFeature = managedOOBEFeature
|
||||
self.aiThinkingEnabled = aiThinkingEnabled
|
||||
self.startDeadlineAt = startDeadlineAt
|
||||
self.processingDeadlineAt = processingDeadlineAt
|
||||
|
||||
@@ -17,6 +17,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
public let aiTaskKind: ManagedGatewayTaskKind?
|
||||
/// Optional server-audited purpose for managed gateway billing policy.
|
||||
public let managedRequestPurpose: ManagedGatewayRequestPurpose?
|
||||
/// Required feature discriminator when `managedRequestPurpose == .oobe`.
|
||||
public let managedOOBEFeature: ManagedGatewayOOBEFeature?
|
||||
/// Clipboard-skill thinking override. Nil keeps AI-mode default (on).
|
||||
public let aiThinkingEnabled: Bool?
|
||||
|
||||
@@ -31,6 +33,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
aiQuestionText: String? = nil,
|
||||
aiTaskKind: ManagedGatewayTaskKind? = nil,
|
||||
managedRequestPurpose: ManagedGatewayRequestPurpose? = nil,
|
||||
managedOOBEFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
aiThinkingEnabled: Bool? = nil
|
||||
) {
|
||||
self.mode = mode
|
||||
@@ -41,6 +44,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
self.aiQuestionText = aiQuestionText
|
||||
self.aiTaskKind = aiTaskKind
|
||||
self.managedRequestPurpose = managedRequestPurpose
|
||||
self.managedOOBEFeature = managedOOBEFeature
|
||||
self.aiThinkingEnabled = aiThinkingEnabled
|
||||
}
|
||||
|
||||
@@ -62,6 +66,7 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
conversationID: UUID,
|
||||
prefilledQuestion: String? = nil,
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
oobeFeature: ManagedGatewayOOBEFeature? = nil,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) -> FlowUtteranceRequest {
|
||||
FlowUtteranceRequest(
|
||||
@@ -69,6 +74,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
aiConversationID: conversationID,
|
||||
aiQuestionText: prefilledQuestion,
|
||||
aiTaskKind: taskKind,
|
||||
managedRequestPurpose: oobeFeature == nil ? nil : .oobe,
|
||||
managedOOBEFeature: oobeFeature,
|
||||
aiThinkingEnabled: thinkingEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// OfficialSkillCatalog.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Validated, host-fetched official clipboard skills. The keyboard extension
|
||||
// only reads the last-known-good App Group snapshot and never performs network work.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct OfficialSkillLocalization: Codable, Equatable, Sendable {
|
||||
public let name: String
|
||||
public let summary: String
|
||||
public let prompt: String
|
||||
|
||||
public init(name: String, summary: String, prompt: String) {
|
||||
self.name = name
|
||||
self.summary = summary
|
||||
self.prompt = prompt
|
||||
}
|
||||
}
|
||||
|
||||
public struct OfficialSkillDefinition: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let systemImage: String
|
||||
public let sortOrder: Int
|
||||
public let kind: AIClipboardSkillKind
|
||||
public let thinkingEnabled: Bool
|
||||
public let localizations: [String: OfficialSkillLocalization]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
systemImage: String,
|
||||
sortOrder: Int,
|
||||
kind: AIClipboardSkillKind,
|
||||
thinkingEnabled: Bool,
|
||||
localizations: [String: OfficialSkillLocalization]
|
||||
) {
|
||||
self.id = id
|
||||
self.systemImage = systemImage
|
||||
self.sortOrder = sortOrder
|
||||
self.kind = kind
|
||||
self.thinkingEnabled = thinkingEnabled
|
||||
self.localizations = localizations
|
||||
}
|
||||
|
||||
public func localization(
|
||||
language: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> OfficialSkillLocalization {
|
||||
localization(locale: language.resolvedLanguageCode(preferredLanguages: preferredLanguages))
|
||||
}
|
||||
|
||||
public func localization(locale: String) -> OfficialSkillLocalization {
|
||||
let normalized = locale.lowercased()
|
||||
let key = normalized.hasPrefix("zh") ? "zh-Hans" : "en"
|
||||
// A validated definition always contains both keys. Keep an English
|
||||
// fallback so a corrupt in-memory fixture cannot crash the extension.
|
||||
return localizations[key] ?? localizations["en"] ?? OfficialSkillLocalization(
|
||||
name: id,
|
||||
summary: "",
|
||||
prompt: ""
|
||||
)
|
||||
}
|
||||
|
||||
public func asClipboardSkill(
|
||||
language: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> AIClipboardSkill {
|
||||
asClipboardSkill(
|
||||
localization: localization(
|
||||
language: language,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public func asClipboardSkill(locale: String) -> AIClipboardSkill {
|
||||
asClipboardSkill(localization: localization(locale: locale))
|
||||
}
|
||||
|
||||
private func asClipboardSkill(localization: OfficialSkillLocalization) -> AIClipboardSkill {
|
||||
AIClipboardSkill(
|
||||
id: id,
|
||||
systemImage: systemImage,
|
||||
titleKey: "",
|
||||
cardTitleKey: "",
|
||||
descriptionKey: "",
|
||||
kind: kind,
|
||||
isDefault: false,
|
||||
customName: localization.name,
|
||||
customSummary: localization.summary,
|
||||
customPrompt: localization.prompt,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum OfficialSkillCatalogValidationError: Error, Equatable, Sendable {
|
||||
case unsupportedSchemaVersion(Int)
|
||||
case invalidRevision
|
||||
case invalidGeneratedAt
|
||||
case tooManySkills(maximum: Int)
|
||||
case invalidID(String)
|
||||
case duplicateID(String)
|
||||
case conflictsWithBuiltInID(String)
|
||||
case invalidSystemImage(String)
|
||||
case invalidSortOrder(String)
|
||||
case unsupportedKind(String)
|
||||
case invalidLocalizations(String)
|
||||
case emptyName(String, locale: String)
|
||||
case emptySummary(String, locale: String)
|
||||
case emptyPrompt(String, locale: String)
|
||||
case promptTooLong(String, locale: String, maximum: Int)
|
||||
}
|
||||
|
||||
public struct OfficialSkillCatalog: Codable, Equatable, Sendable {
|
||||
public static let supportedSchemaVersion = 1
|
||||
public static let maximumSkillCount = 100
|
||||
public static let maximumIDCharacters = 100
|
||||
public static let maximumSystemImageCharacters = 100
|
||||
public static let maximumSortOrder = 100_000
|
||||
public static let maximumNameCharacters = 40
|
||||
public static let maximumSummaryCharacters = 200
|
||||
public static let maximumPromptCharacters = 6_000
|
||||
|
||||
public let schemaVersion: Int
|
||||
public let revision: Int64
|
||||
public let generatedAt: String?
|
||||
public let skills: [OfficialSkillDefinition]
|
||||
/// Host wall clock for cache freshness. Absent in the server response.
|
||||
public var refreshedAt: Date?
|
||||
/// Last response ETag. Absent in the server response.
|
||||
public var etag: String?
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = supportedSchemaVersion,
|
||||
revision: Int64,
|
||||
generatedAt: String? = nil,
|
||||
skills: [OfficialSkillDefinition],
|
||||
refreshedAt: Date? = nil,
|
||||
etag: String? = nil
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.revision = revision
|
||||
self.generatedAt = generatedAt
|
||||
self.skills = skills
|
||||
self.refreshedAt = refreshedAt
|
||||
self.etag = etag
|
||||
}
|
||||
|
||||
public static let empty = OfficialSkillCatalog(revision: 0, skills: [])
|
||||
|
||||
public func validated(
|
||||
builtInIDs: Set<String> = Set(AIClipboardSkillCatalog.catalog.map(\.id))
|
||||
) throws -> OfficialSkillCatalog {
|
||||
guard schemaVersion == Self.supportedSchemaVersion else {
|
||||
throw OfficialSkillCatalogValidationError.unsupportedSchemaVersion(schemaVersion)
|
||||
}
|
||||
guard revision >= 0 else {
|
||||
throw OfficialSkillCatalogValidationError.invalidRevision
|
||||
}
|
||||
if let generatedAt, Self.iso8601Date(from: generatedAt) == nil {
|
||||
throw OfficialSkillCatalogValidationError.invalidGeneratedAt
|
||||
}
|
||||
guard skills.count <= Self.maximumSkillCount else {
|
||||
throw OfficialSkillCatalogValidationError.tooManySkills(
|
||||
maximum: Self.maximumSkillCount
|
||||
)
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
for skill in skills {
|
||||
guard Self.isValidID(skill.id) else {
|
||||
throw OfficialSkillCatalogValidationError.invalidID(skill.id)
|
||||
}
|
||||
guard seen.insert(skill.id).inserted else {
|
||||
throw OfficialSkillCatalogValidationError.duplicateID(skill.id)
|
||||
}
|
||||
guard !builtInIDs.contains(skill.id) else {
|
||||
throw OfficialSkillCatalogValidationError.conflictsWithBuiltInID(skill.id)
|
||||
}
|
||||
let image = skill.systemImage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !image.isEmpty, image.count <= Self.maximumSystemImageCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.invalidSystemImage(skill.id)
|
||||
}
|
||||
guard (0...Self.maximumSortOrder).contains(skill.sortOrder) else {
|
||||
throw OfficialSkillCatalogValidationError.invalidSortOrder(skill.id)
|
||||
}
|
||||
guard skill.kind == .transform else {
|
||||
throw OfficialSkillCatalogValidationError.unsupportedKind(skill.id)
|
||||
}
|
||||
guard Set(skill.localizations.keys) == ["zh-Hans", "en"] else {
|
||||
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
|
||||
}
|
||||
for locale in ["zh-Hans", "en"] {
|
||||
guard let localization = skill.localizations[locale] else {
|
||||
throw OfficialSkillCatalogValidationError.invalidLocalizations(skill.id)
|
||||
}
|
||||
let name = localization.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let summary = localization.summary.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let prompt = localization.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, name.count <= Self.maximumNameCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.emptyName(skill.id, locale: locale)
|
||||
}
|
||||
guard !summary.isEmpty,
|
||||
summary.count <= Self.maximumSummaryCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.emptySummary(skill.id, locale: locale)
|
||||
}
|
||||
guard !prompt.isEmpty else {
|
||||
throw OfficialSkillCatalogValidationError.emptyPrompt(skill.id, locale: locale)
|
||||
}
|
||||
guard prompt.count <= Self.maximumPromptCharacters else {
|
||||
throw OfficialSkillCatalogValidationError.promptTooLong(
|
||||
skill.id,
|
||||
locale: locale,
|
||||
maximum: Self.maximumPromptCharacters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OfficialSkillCatalog(
|
||||
schemaVersion: schemaVersion,
|
||||
revision: revision,
|
||||
generatedAt: generatedAt,
|
||||
skills: skills.sorted {
|
||||
if $0.sortOrder == $1.sortOrder { return $0.id < $1.id }
|
||||
return $0.sortOrder < $1.sortOrder
|
||||
},
|
||||
refreshedAt: refreshedAt,
|
||||
etag: etag
|
||||
)
|
||||
}
|
||||
|
||||
public func resolvedSkills(
|
||||
language: AppUILanguage,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> [AIClipboardSkill] {
|
||||
skills.map {
|
||||
$0.asClipboardSkill(
|
||||
language: language,
|
||||
preferredLanguages: preferredLanguages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func resolvedSkills(locale: String) -> [AIClipboardSkill] {
|
||||
skills.map { $0.asClipboardSkill(locale: locale) }
|
||||
}
|
||||
|
||||
private static func isValidID(_ id: String) -> Bool {
|
||||
guard id.hasPrefix("official."), id.count <= Self.maximumIDCharacters else {
|
||||
return false
|
||||
}
|
||||
let suffix = id.dropFirst("official.".count)
|
||||
guard !suffix.isEmpty else { return false }
|
||||
return suffix.unicodeScalars.allSatisfy { scalar in
|
||||
let value = scalar.value
|
||||
return (97...122).contains(value)
|
||||
|| (48...57).contains(value)
|
||||
|| value == 46
|
||||
|| value == 45
|
||||
|| value == 95
|
||||
}
|
||||
}
|
||||
|
||||
private static func iso8601Date(from value: String) -> Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: value) { return date }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: value)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,18 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public let id: UUID
|
||||
/// Final text delivered to the host app and shown in history.
|
||||
public let text: String
|
||||
/// ASR transcript after deterministic correction but before LLM polishing.
|
||||
/// Stored for future speaking-style learning and intentionally not shown.
|
||||
public let prePolishText: String?
|
||||
/// Translation outputs must not be treated as same-language style examples.
|
||||
public let wasTranslation: Bool
|
||||
/// Style active when the final text was produced; nil for legacy or AI rows.
|
||||
public let polishStyleID: String?
|
||||
/// SHA-256 key into `SyncedSpeechHistory.polishStylePromptSnapshots`.
|
||||
/// Deduplication preserves the exact prompt without repeating it per row.
|
||||
public let polishStylePromptFingerprint: String?
|
||||
public let createdAt: Date
|
||||
/// Last content mutation. Legacy rows default to `createdAt`.
|
||||
public let modifiedAt: Date
|
||||
@@ -26,6 +37,10 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
text: String,
|
||||
prePolishText: String? = nil,
|
||||
wasTranslation: Bool = false,
|
||||
polishStyleID: String? = nil,
|
||||
polishStylePromptFingerprint: String? = nil,
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date? = nil,
|
||||
revision: Int64 = 0,
|
||||
@@ -34,6 +49,14 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
let trimmedPrePolishText = prePolishText?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.prePolishText = trimmedPrePolishText?.isEmpty == false
|
||||
? trimmedPrePolishText
|
||||
: nil
|
||||
self.wasTranslation = wasTranslation
|
||||
self.polishStyleID = polishStyleID
|
||||
self.polishStylePromptFingerprint = polishStylePromptFingerprint
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt ?? createdAt
|
||||
self.revision = revision
|
||||
@@ -45,6 +68,13 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
prePolishText = try container.decodeIfPresent(String.self, forKey: .prePolishText)
|
||||
wasTranslation = try container.decodeIfPresent(Bool.self, forKey: .wasTranslation) ?? false
|
||||
polishStyleID = try container.decodeIfPresent(String.self, forKey: .polishStyleID)
|
||||
polishStylePromptFingerprint = try container.decodeIfPresent(
|
||||
String.self,
|
||||
forKey: .polishStylePromptFingerprint
|
||||
)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
|
||||
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
|
||||
// propagate single-entry deletes and "clear all" across devices.
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 3
|
||||
public static let schemaVersion = 5
|
||||
public static let kvsKey = "speechHistory.v2"
|
||||
public static let legacyKVSKey = "speechHistory.v1"
|
||||
public static let maxEntries = 300
|
||||
@@ -26,17 +27,24 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public var schemaVersion: Int
|
||||
public var updatedAt: Date
|
||||
public var entries: [SpeechHistoryEntry]
|
||||
/// Deduplicated historical style prompts keyed by SHA-256. Keeping prompt
|
||||
/// snapshots outside rows avoids multiplying a 6,000-character prompt by
|
||||
/// every dictation while still preserving the prompt used at that moment.
|
||||
public var polishStylePromptSnapshots: [String: String]
|
||||
/// Entry IDs deleted on any device, with deletion timestamps.
|
||||
public var deletedEntryIDs: [UUID: Date]
|
||||
/// Recent idempotency keys for keyboard-originated history mutations.
|
||||
public var appliedMutationIDs: [UUID]
|
||||
/// When set, entries created at or before this instant are excluded.
|
||||
public var clearedAt: Date?
|
||||
public static let maxPolishStylePromptSnapshots = 32
|
||||
public static let maxPolishStylePromptSnapshotCharacters = 96_000
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = Self.schemaVersion,
|
||||
updatedAt: Date = Date(),
|
||||
entries: [SpeechHistoryEntry] = [],
|
||||
polishStylePromptSnapshots: [String: String] = [:],
|
||||
deletedEntryIDs: [UUID: Date] = [:],
|
||||
appliedMutationIDs: [UUID] = [],
|
||||
clearedAt: Date? = nil
|
||||
@@ -44,6 +52,7 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.updatedAt = updatedAt
|
||||
self.entries = entries
|
||||
self.polishStylePromptSnapshots = polishStylePromptSnapshots
|
||||
self.deletedEntryIDs = deletedEntryIDs
|
||||
self.appliedMutationIDs = appliedMutationIDs
|
||||
self.clearedAt = clearedAt
|
||||
@@ -54,6 +63,10 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
|
||||
polishStylePromptSnapshots = try container.decodeIfPresent(
|
||||
[String: String].self,
|
||||
forKey: .polishStylePromptSnapshots
|
||||
) ?? [:]
|
||||
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
|
||||
deletedEntryIDs = map
|
||||
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
|
||||
@@ -105,21 +118,31 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
entries = Array(entries.prefix(maxEntries))
|
||||
}
|
||||
|
||||
return SyncedSpeechHistory(
|
||||
var snapshots = local.polishStylePromptSnapshots
|
||||
for (fingerprint, prompt) in remote.polishStylePromptSnapshots {
|
||||
snapshots[fingerprint] = snapshots[fingerprint] ?? prompt
|
||||
}
|
||||
|
||||
var merged = SyncedSpeechHistory(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
entries: entries,
|
||||
polishStylePromptSnapshots: snapshots,
|
||||
deletedEntryIDs: deletedIDs,
|
||||
appliedMutationIDs: appliedMutationIDs,
|
||||
clearedAt: clearedAt
|
||||
)
|
||||
merged.prunePolishStylePromptSnapshots()
|
||||
return merged
|
||||
}
|
||||
|
||||
/// Trim to the newest `maxEntries` rows (call after local-only appends).
|
||||
public mutating func trimEntries() {
|
||||
guard entries.count > Self.maxEntries else { return }
|
||||
if entries.count > Self.maxEntries {
|
||||
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
|
||||
updatedAt = Date()
|
||||
}
|
||||
prunePolishStylePromptSnapshots()
|
||||
}
|
||||
|
||||
public mutating func pruneTombstonesIfNeeded() {
|
||||
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
|
||||
@@ -162,19 +185,93 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
_ candidate: SpeechHistoryEntry,
|
||||
over existing: SpeechHistoryEntry
|
||||
) -> SpeechHistoryEntry {
|
||||
let winner: SpeechHistoryEntry
|
||||
let fallback: SpeechHistoryEntry
|
||||
if candidate.revision != existing.revision {
|
||||
return candidate.revision > existing.revision ? candidate : existing
|
||||
(winner, fallback) = candidate.revision > existing.revision
|
||||
? (candidate, existing)
|
||||
: (existing, candidate)
|
||||
} else if candidate.modifiedAt != existing.modifiedAt {
|
||||
(winner, fallback) = candidate.modifiedAt > existing.modifiedAt
|
||||
? (candidate, existing)
|
||||
: (existing, candidate)
|
||||
} else {
|
||||
(winner, fallback) = candidate.createdAt >= existing.createdAt
|
||||
? (candidate, existing)
|
||||
: (existing, candidate)
|
||||
}
|
||||
if candidate.modifiedAt != existing.modifiedAt {
|
||||
return candidate.modifiedAt > existing.modifiedAt ? candidate : existing
|
||||
return preservingCorpusMetadata(of: winner, fallback: fallback)
|
||||
}
|
||||
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 {
|
||||
mutating func recordClearAll(at date: Date = Date()) {
|
||||
entries.removeAll()
|
||||
polishStylePromptSnapshots.removeAll()
|
||||
clearedAt = date
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"classifiers" : [
|
||||
{
|
||||
"acceptedForAutomaticRouting" : true,
|
||||
"algorithm" : "maxEnt",
|
||||
"confidenceThreshold" : 0.6,
|
||||
"id" : "task",
|
||||
"labels" : [
|
||||
"notTask",
|
||||
"task"
|
||||
],
|
||||
"modelFile" : "TaskIntentClassifier.mlmodel",
|
||||
"positiveLabel" : "task"
|
||||
},
|
||||
{
|
||||
"acceptedForAutomaticRouting" : true,
|
||||
"algorithm" : "maxEnt",
|
||||
"confidenceThreshold" : 0.62,
|
||||
"id" : "question",
|
||||
"labels" : [
|
||||
"notQuestion",
|
||||
"question"
|
||||
],
|
||||
"modelFile" : "QuestionIntentClassifier.mlmodel",
|
||||
"positiveLabel" : "question"
|
||||
},
|
||||
{
|
||||
"acceptedForAutomaticRouting" : true,
|
||||
"algorithm" : "maxEnt",
|
||||
"confidenceThreshold" : 0.89,
|
||||
"id" : "invitation",
|
||||
"labels" : [
|
||||
"notInvitation",
|
||||
"invitation"
|
||||
],
|
||||
"modelFile" : "InvitationIntentClassifier.mlmodel",
|
||||
"positiveLabel" : "invitation"
|
||||
},
|
||||
{
|
||||
"acceptedForAutomaticRouting" : false,
|
||||
"algorithm" : "maxEnt",
|
||||
"confidenceThreshold" : 0.6,
|
||||
"id" : "complaint",
|
||||
"labels" : [
|
||||
"notComplaint",
|
||||
"complaint"
|
||||
],
|
||||
"modelFile" : "ComplaintIntentClassifier.mlmodel",
|
||||
"positiveLabel" : "complaint"
|
||||
},
|
||||
{
|
||||
"acceptedForAutomaticRouting" : true,
|
||||
"algorithm" : "maxEnt",
|
||||
"id" : "sentiment",
|
||||
"labels" : [
|
||||
"negative",
|
||||
"neutral",
|
||||
"positive"
|
||||
],
|
||||
"modelFile" : "SentimentClassifier.mlmodel"
|
||||
}
|
||||
],
|
||||
"corpusRecordCount" : 6334,
|
||||
"generatedAt" : "2026-08-21T15:28:40Z",
|
||||
"schemaVersion" : 1
|
||||
}
|
||||
@@ -13,11 +13,14 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
|
||||
@Published public private(set) var layout: AIAgentSkillLayout
|
||||
@Published public private(set) var userCatalog: AIUserSkillCatalog
|
||||
@Published public private(set) var officialCatalog: OfficialSkillCatalog
|
||||
|
||||
private let persistLayout: (AIAgentSkillLayout) -> Void
|
||||
private let persistUserCatalog: (AIUserSkillCatalog) -> Void
|
||||
private let loadLayout: () -> AIAgentSkillLayout
|
||||
private let loadUserCatalog: () -> AIUserSkillCatalog
|
||||
private let loadOfficialCatalog: () -> OfficialSkillCatalog
|
||||
private let loadUILanguage: () -> AppUILanguage
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
if let defaults {
|
||||
@@ -25,29 +28,41 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
self.persistUserCatalog = { AppGroupStore(defaults: defaults).setAgentUserSkillCatalog($0) }
|
||||
self.loadLayout = { AppGroupStore(defaults: defaults).agentSkillLayout }
|
||||
self.persistLayout = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
|
||||
self.loadOfficialCatalog = { AppGroupStore(defaults: defaults).officialSkillCatalog }
|
||||
self.loadUILanguage = { AppGroupStore(defaults: defaults).uiLanguage }
|
||||
} else {
|
||||
self.loadUserCatalog = { AppGroupStore().agentUserSkillCatalog }
|
||||
self.persistUserCatalog = { AppGroupStore().setAgentUserSkillCatalog($0) }
|
||||
self.loadLayout = { AppGroupStore().agentSkillLayout }
|
||||
self.persistLayout = { AppGroupStore().setAgentSkillLayout($0) }
|
||||
self.loadOfficialCatalog = { AppGroupStore().officialSkillCatalog }
|
||||
self.loadUILanguage = { AppGroupStore().uiLanguage }
|
||||
}
|
||||
self.userCatalog = self.loadUserCatalog()
|
||||
self.officialCatalog = self.loadOfficialCatalog()
|
||||
self.layout = self.loadLayout()
|
||||
}
|
||||
|
||||
public func reload() {
|
||||
userCatalog = loadUserCatalog()
|
||||
officialCatalog = loadOfficialCatalog()
|
||||
layout = loadLayout()
|
||||
}
|
||||
|
||||
public var mergedCatalog: [AIClipboardSkill] {
|
||||
AIClipboardSkillCatalog.all(userCatalog: userCatalog)
|
||||
AIClipboardSkillCatalog.all(
|
||||
officialCatalog: officialCatalog,
|
||||
userCatalog: userCatalog,
|
||||
uiLanguage: loadUILanguage()
|
||||
)
|
||||
}
|
||||
|
||||
public var enabledSkills: [AIClipboardSkill] {
|
||||
AIClipboardSkillCatalog.visible(
|
||||
enabledIDs: layout.enabledIDs,
|
||||
userCatalog: userCatalog
|
||||
officialCatalog: officialCatalog,
|
||||
userCatalog: userCatalog,
|
||||
uiLanguage: loadUILanguage()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,14 +77,13 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
@discardableResult
|
||||
public func enable(_ id: String) -> AIAgentSkillEnableResult {
|
||||
let current = layout.sanitized(catalog: mergedCatalog)
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog) else {
|
||||
guard let skill = mergedCatalog.first(where: { $0.id == id }) else {
|
||||
return .unknown
|
||||
}
|
||||
if current.isEnabled(id) { return .alreadyEnabled }
|
||||
if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
|
||||
return .needsShortcut
|
||||
}
|
||||
if current.isFull { return .full }
|
||||
commitLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: current.enabledIDs + [id],
|
||||
@@ -94,7 +108,7 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
||||
/// Marks the companion Shortcut as added, then tries to occupy a slot.
|
||||
@discardableResult
|
||||
public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
|
||||
guard let skill = AIClipboardSkillCatalog.skill(id: id, userCatalog: userCatalog),
|
||||
guard let skill = mergedCatalog.first(where: { $0.id == id }),
|
||||
skill.requiresShortcut else {
|
||||
return .unknown
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AIClipboardSkillKind: String, Sendable {
|
||||
public enum AIClipboardSkillKind: String, Codable, Sendable {
|
||||
/// LLM output is reviewed and inserted into the current text field.
|
||||
case transform
|
||||
/// LLM output is parsed and sent to a companion Shortcut. Never inserted.
|
||||
@@ -36,13 +36,14 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
public let customName: String?
|
||||
public let customSummary: String?
|
||||
public let customPrompt: String?
|
||||
/// Built-in skills are always false. Custom skills default off.
|
||||
/// Built-in skills are always false. Official/user skills preserve their policy.
|
||||
public let thinkingEnabled: Bool
|
||||
|
||||
/// Reminders, Calendar, and Notes exports need a companion Shortcut.
|
||||
/// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut.
|
||||
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
|
||||
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||
public var isOfficial: Bool { id.hasPrefix("official.") }
|
||||
/// The server applies the final model policy; this only preserves whether
|
||||
/// the user invoked a built-in transform or a custom skill.
|
||||
public var managedGatewayTaskKind: ManagedGatewayTaskKind {
|
||||
@@ -78,14 +79,26 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
self.customName = customName
|
||||
self.customSummary = customSummary
|
||||
self.customPrompt = customPrompt
|
||||
self.thinkingEnabled = id.hasPrefix("user.") ? thinkingEnabled : false
|
||||
self.thinkingEnabled = (id.hasPrefix("user.") || id.hasPrefix("official."))
|
||||
? thinkingEnabled
|
||||
: false
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIClipboardSkillCatalog: Sendable {
|
||||
public static let replyID = "reply"
|
||||
public static let replyInSourceLanguageID = "replyInSourceLanguage"
|
||||
public static let summarizeID = "summarize"
|
||||
public static let extractConclusionsID = "extractConclusions"
|
||||
public static let translateID = "translate"
|
||||
public static let acceptInvitationID = "acceptInvitation"
|
||||
public static let declineInvitationID = "declineInvitation"
|
||||
public static let acceptTaskID = "acceptTask"
|
||||
public static let clarifyRequestID = "clarifyRequest"
|
||||
public static let empathyReplyID = "empathyReply"
|
||||
public static let askForDetailsID = "askForDetails"
|
||||
public static let businessReplyID = "businessReply"
|
||||
public static let organizeListID = "organizeList"
|
||||
public static let extractTodosID = "extractTodos"
|
||||
public static let extractTodosShortcutName = "OSGExtractTodos"
|
||||
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||
@@ -112,11 +125,11 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: summarizeID,
|
||||
systemImage: "doc.text.magnifyingglass",
|
||||
titleKey: "keyboard.ai.skill.summarize",
|
||||
cardTitleKey: "skills.summarize.name",
|
||||
descriptionKey: "skills.summarize.description",
|
||||
id: replyInSourceLanguageID,
|
||||
systemImage: "globe",
|
||||
titleKey: "keyboard.ai.skill.replyInSourceLanguage",
|
||||
cardTitleKey: "skills.replyInSourceLanguage.name",
|
||||
descriptionKey: "skills.replyInSourceLanguage.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
@@ -129,6 +142,96 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: summarizeID,
|
||||
systemImage: "doc.text.magnifyingglass",
|
||||
titleKey: "keyboard.ai.skill.summarize",
|
||||
cardTitleKey: "skills.summarize.name",
|
||||
descriptionKey: "skills.summarize.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: extractConclusionsID,
|
||||
systemImage: "text.badge.checkmark",
|
||||
titleKey: "keyboard.ai.skill.extractConclusions",
|
||||
cardTitleKey: "skills.extractConclusions.name",
|
||||
descriptionKey: "skills.extractConclusions.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: acceptInvitationID,
|
||||
systemImage: "checkmark.bubble.fill",
|
||||
titleKey: "keyboard.ai.skill.acceptInvitation",
|
||||
cardTitleKey: "skills.acceptInvitation.name",
|
||||
descriptionKey: "skills.acceptInvitation.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: declineInvitationID,
|
||||
systemImage: "hand.raised.fill",
|
||||
titleKey: "keyboard.ai.skill.declineInvitation",
|
||||
cardTitleKey: "skills.declineInvitation.name",
|
||||
descriptionKey: "skills.declineInvitation.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: acceptTaskID,
|
||||
systemImage: "checkmark.circle.fill",
|
||||
titleKey: "keyboard.ai.skill.acceptTask",
|
||||
cardTitleKey: "skills.acceptTask.name",
|
||||
descriptionKey: "skills.acceptTask.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: clarifyRequestID,
|
||||
systemImage: "questionmark.bubble.fill",
|
||||
titleKey: "keyboard.ai.skill.clarifyRequest",
|
||||
cardTitleKey: "skills.clarifyRequest.name",
|
||||
descriptionKey: "skills.clarifyRequest.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: empathyReplyID,
|
||||
systemImage: "heart.fill",
|
||||
titleKey: "keyboard.ai.skill.empathyReply",
|
||||
cardTitleKey: "skills.empathyReply.name",
|
||||
descriptionKey: "skills.empathyReply.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: askForDetailsID,
|
||||
systemImage: "ellipsis.bubble.fill",
|
||||
titleKey: "keyboard.ai.skill.askForDetails",
|
||||
cardTitleKey: "skills.askForDetails.name",
|
||||
descriptionKey: "skills.askForDetails.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: businessReplyID,
|
||||
systemImage: "briefcase.fill",
|
||||
titleKey: "keyboard.ai.skill.businessReply",
|
||||
cardTitleKey: "skills.businessReply.name",
|
||||
descriptionKey: "skills.businessReply.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: organizeListID,
|
||||
systemImage: "list.bullet.rectangle",
|
||||
titleKey: "keyboard.ai.skill.organizeList",
|
||||
cardTitleKey: "skills.organizeList.name",
|
||||
descriptionKey: "skills.organizeList.description",
|
||||
kind: .transform,
|
||||
isDefault: true
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: extractTodosID,
|
||||
systemImage: "checklist",
|
||||
@@ -136,7 +239,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
cardTitleKey: "skills.extractTodos.name",
|
||||
descriptionKey: "skills.extractTodos.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
isDefault: true,
|
||||
shortcutName: extractTodosShortcutName,
|
||||
shortcutResourceName: extractTodosResourceName
|
||||
),
|
||||
@@ -147,7 +250,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
cardTitleKey: "skills.extractEvents.name",
|
||||
descriptionKey: "skills.extractEvents.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
isDefault: true,
|
||||
shortcutName: extractEventsShortcutName,
|
||||
shortcutResourceName: extractEventsResourceName
|
||||
),
|
||||
@@ -158,7 +261,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
cardTitleKey: "skills.saveToNotes.name",
|
||||
descriptionKey: "skills.saveToNotes.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
isDefault: true,
|
||||
shortcutName: saveToNotesShortcutName,
|
||||
shortcutResourceName: saveToNotesResourceName
|
||||
),
|
||||
@@ -169,33 +272,68 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
cardTitleKey: "skills.navigate.name",
|
||||
descriptionKey: "skills.navigate.description",
|
||||
kind: .export,
|
||||
isDefault: false
|
||||
isDefault: true
|
||||
)
|
||||
]
|
||||
|
||||
/// Legacy alias: the three default transform skills used to be the whole list.
|
||||
public static let builtIn: [AIClipboardSkill] = catalog
|
||||
|
||||
public static func all(userCatalog: AIUserSkillCatalog = .empty) -> [AIClipboardSkill] {
|
||||
catalog + userCatalog.entries.map { $0.asClipboardSkill() }
|
||||
public static func all(
|
||||
officialCatalog: OfficialSkillCatalog = .empty,
|
||||
userCatalog: AIUserSkillCatalog = .empty,
|
||||
uiLanguage: AppUILanguage = .auto,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> [AIClipboardSkill] {
|
||||
var ids = Set(catalog.map(\.id))
|
||||
var merged = catalog
|
||||
for skill in officialCatalog.resolvedSkills(
|
||||
language: uiLanguage,
|
||||
preferredLanguages: preferredLanguages
|
||||
) where ids.insert(skill.id).inserted {
|
||||
merged.append(skill)
|
||||
}
|
||||
for skill in userCatalog.entries.map({ $0.asClipboardSkill() })
|
||||
where ids.insert(skill.id).inserted {
|
||||
merged.append(skill)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
public static func skill(
|
||||
id: String,
|
||||
userCatalog: AIUserSkillCatalog = .empty
|
||||
officialCatalog: OfficialSkillCatalog = .empty,
|
||||
userCatalog: AIUserSkillCatalog = .empty,
|
||||
uiLanguage: AppUILanguage = .auto,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> AIClipboardSkill? {
|
||||
catalog.first { $0.id == id } ?? userCatalog.skill(id: id)?.asClipboardSkill()
|
||||
all(
|
||||
officialCatalog: officialCatalog,
|
||||
userCatalog: userCatalog,
|
||||
uiLanguage: uiLanguage,
|
||||
preferredLanguages: preferredLanguages
|
||||
).first { $0.id == id }
|
||||
}
|
||||
|
||||
/// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
|
||||
/// An explicit empty array shows no chips (carousel fallback).
|
||||
public static func visible(
|
||||
enabledIDs: [String]? = nil,
|
||||
userCatalog: AIUserSkillCatalog = .empty
|
||||
officialCatalog: OfficialSkillCatalog = .empty,
|
||||
userCatalog: AIUserSkillCatalog = .empty,
|
||||
uiLanguage: AppUILanguage = .auto,
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> [AIClipboardSkill] {
|
||||
let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
|
||||
guard !ids.isEmpty else { return [] }
|
||||
let byID = Dictionary(uniqueKeysWithValues: all(userCatalog: userCatalog).map { ($0.id, $0) })
|
||||
let byID = Dictionary(
|
||||
uniqueKeysWithValues: all(
|
||||
officialCatalog: officialCatalog,
|
||||
userCatalog: userCatalog,
|
||||
uiLanguage: uiLanguage,
|
||||
preferredLanguages: preferredLanguages
|
||||
).map { ($0.id, $0) }
|
||||
)
|
||||
return ids.compactMap { byID[$0] }
|
||||
}
|
||||
|
||||
@@ -248,17 +386,57 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
switch skillID {
|
||||
case replyID:
|
||||
return zh
|
||||
? "请根据剪贴板内容起草一段礼貌、简洁的回复,语气自然,可直接发送。"
|
||||
: "Draft a concise, polite reply the user can send, based on the clipboard text."
|
||||
? "请根据剪贴板内容起草一段礼貌、简洁的回复,使用原文的主要语言,语气自然,可直接发送。"
|
||||
: "Draft a concise, polite reply in the clipboard text's primary language that the user can send."
|
||||
case replyInSourceLanguageID:
|
||||
return zh
|
||||
? "请理解剪贴板内容,并严格使用原文的主要语言起草自然、简洁、可直接发送的回复。不要翻译,不要解释。"
|
||||
: "Understand the clipboard text and draft a natural, concise, sendable reply strictly in its primary language. Do not translate or explain."
|
||||
case summarizeID:
|
||||
return zh
|
||||
? "请概括剪贴板内容的核心意思,保留关键事实与结论,不要改写成可发送的短消息。"
|
||||
: "Summarize the clipboard text: keep the key facts and conclusions; do not rewrite it as a sendable short message."
|
||||
case extractConclusionsID:
|
||||
return zh
|
||||
? "请只提取剪贴板内容中最重要的结论、决定和下一步。使用简短要点,不重复背景,不补充原文没有的信息。"
|
||||
: "Extract only the most important conclusions, decisions, and next steps from the clipboard. Use concise bullets; do not repeat background or add facts."
|
||||
case translateID:
|
||||
return translateInstruction(
|
||||
locale: locale,
|
||||
translationTargetLocaleId: translationTargetLocaleId
|
||||
)
|
||||
case acceptInvitationID:
|
||||
return zh
|
||||
? "请根据剪贴板中的邀约,起草一段自然、简洁的接受回复,复述必要的时间或地点以便确认。不要虚构用户没有表达的安排。"
|
||||
: "Draft a natural, concise acceptance of the invitation. Confirm any necessary time or place, without inventing the user's plans."
|
||||
case declineInvitationID:
|
||||
return zh
|
||||
? "请根据剪贴板中的邀约,起草一段礼貌、真诚的婉拒回复;表达感谢但不过度解释,也不要虚构理由。"
|
||||
: "Draft a polite, sincere decline to the invitation. Express appreciation without overexplaining or inventing a reason."
|
||||
case acceptTaskID:
|
||||
return zh
|
||||
? "请对剪贴板中的任务或行动请求起草确认回复,明确已理解的事项和截止时间;不要承诺原文未要求或用户无法确认的结果。"
|
||||
: "Draft an acknowledgement of the task or action request, confirming the understood deliverable and deadline. Do not invent commitments."
|
||||
case clarifyRequestID:
|
||||
return zh
|
||||
? "请找出剪贴板内容中阻碍执行或回答的关键信息缺口,并起草一段简洁回复,最多提出两个最必要的澄清问题。"
|
||||
: "Identify the key missing information needed to act or answer, then draft a concise reply with at most two essential clarifying questions."
|
||||
case empathyReplyID:
|
||||
return zh
|
||||
? "请针对剪贴板中的不满、投诉或负面反馈起草回复:先表达理解,再确认核心问题,最后给出稳妥的下一步;不要推诿或过度承诺。"
|
||||
: "Reply to the complaint or negative feedback with empathy, acknowledgement of the core issue, and a safe next step. Do not deflect or overpromise."
|
||||
case askForDetailsID:
|
||||
return zh
|
||||
? "请针对剪贴板描述的问题起草一段追问回复,只询问定位或处理问题所必需的细节,问题清晰且不重复。"
|
||||
: "Draft a follow-up that asks only for the details necessary to diagnose or resolve the issue. Keep questions clear and non-repetitive."
|
||||
case businessReplyID:
|
||||
return zh
|
||||
? "请根据剪贴板内容起草一段专业、克制、清晰的商务回复,保留人名、组织名、时间和承诺边界,可直接发送。"
|
||||
: "Draft a professional, measured, clear business reply. Preserve names, organizations, dates, and commitment boundaries; make it sendable."
|
||||
case organizeListID:
|
||||
return zh
|
||||
? "请把剪贴板中的清单、议程或步骤整理成结构清晰、顺序合理的列表。合并重复项,保留原意,不新增任务。"
|
||||
: "Organize the clipboard's list, agenda, or steps into a clear logical order. Merge duplicates, preserve meaning, and add no new tasks."
|
||||
case extractTodosID:
|
||||
return zh
|
||||
? """
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user